Files
agent-workflow/src/workflow-executor.ts
T
liujing b5618e1b0c feat: request detailed reviewer guidance from cycle 3
When a run is about to start its 3rd cycle, the host review bundle asks
Codex to supply a step-by-step implementation plan via the new decision
field implementationGuidance, which is injected into the executor fix
prompt (with re-sent plan context) so the executor can converge faster.

- Add DETAILED_GUIDANCE_CYCLE_THRESHOLD and bundle signal in reviewWorkflow
- Add optional detailedGuidanceRequested/reviewerInstructions to the bundle
- Add optional implementationGuidance to HostDecisionV1 (validated, <=20000 chars)
- Inject guidance section and re-send plan/criteria on marked fix cycles
- Cover with integration, executor, and config validation tests
2026-07-21 18:01:43 +08:00

1214 lines
41 KiB
TypeScript

/**
* Executor adapters for Reasonix, Claude Code, and Agy.
*
* All adapters:
* - Spawn processes with argument arrays (no shell interpolation)
* - Include fixed safety constraints in every prompt (no commit/push/reset)
* - Return ExecutorResult with session handle for future resume
*/
import { randomUUID } from "node:crypto";
import { execFileSync, spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { spawnStreamingChild } from "./child-process.js";
import type {
AgySessionHandle,
ClaudeSessionHandle,
ExecutorAdapter,
ExecutorConfig,
ExecutorKind,
ExecutorResumeOpts,
ExecutorResult,
ExecutorSessionHandle,
ExecutorStartOpts,
ProgressSidecar,
ReasonixSessionHandle,
VcsKind,
} from "./workflow-types.js";
import {
nowIso,
writeProgressSidecar,
readProgressSidecar,
attemptLogPath,
} from "./workflow-state.js";
// ---------------------------------------------------------------------------
// Safety prompt footer (appended to every executor instruction)
// ---------------------------------------------------------------------------
function getSafetyConstraints(vcsKind?: VcsKind): string {
let vcsInspectCmd = "";
if (vcsKind === "svn") {
vcsInspectCmd = "Before exiting, inspect svn status and leave no artifacts outside the allowed scope.";
} else if (vcsKind === "none") {
vcsInspectCmd = "Before exiting, confirm that no temporary files or paths outside the allowed scope remain.";
} else {
vcsInspectCmd = "Before exiting, inspect git status --porcelain --untracked-files=all and leave no artifacts outside the allowed scope.";
}
return `
## CRITICAL CONSTRAINTS — DO NOT VIOLATE
- DO NOT run git commit, git push, or git reset under any circumstances.
- DO NOT run svn commit, svn switch, svn update, svn relocate, or svn revert under any circumstances.
- DO NOT modify .svn metadata directories or files.
- DO NOT rewrite git history or delete branches.
- DO NOT create or modify files outside the scope listed above.
- Exception: a Scope Remediation prompt may authorize removing or restoring only the exact out-of-scope paths it lists.
- Put all temporary files in an OS temporary directory outside the repository (for example, one created with mktemp).
- If you accidentally create a temporary repository file, remove only that artifact before exiting.
- SQLite cleanup must include the database and its -wal, -shm, and -journal sidecars.
- ${vcsInspectCmd}
- You may read, edit, and test code, but never commit or push changes.
- If you encounter an error you cannot resolve, stop and report it clearly.
`.trimStart();
}
// ---------------------------------------------------------------------------
// Progress sidecar helpers
// ---------------------------------------------------------------------------
function startProgressSidecar(
dir: string,
executor: "reasonix" | "claude" | "agy",
cycleIndex: number,
attemptIndex: number,
logFilePath: string | undefined,
intervalMs = 5000,
): NodeJS.Timeout {
const sidecar: ProgressSidecar = {
executor,
cycleIndex,
attemptIndex,
phase: "executing",
startedAt: nowIso(),
lastActivityAt: nowIso(),
logFilePath: logFilePath ? path.relative(dir, logFilePath) : undefined,
logBytes: 0,
activitySummary: "Starting...",
updatedAt: nowIso(),
};
writeProgressSidecar(dir, sidecar);
return setInterval(() => {
const current = readProgressSidecar(dir);
if (!current) return;
const now = nowIso();
current.updatedAt = now;
if (current.logFilePath) {
try {
current.logBytes = fs.statSync(path.join(dir, current.logFilePath)).size;
} catch {
// file may not exist yet
}
}
writeProgressSidecar(dir, current);
}, intervalMs);
}
function updateSidecar(dir: string, updates: Partial<ProgressSidecar>): void {
const current = readProgressSidecar(dir);
if (!current) return;
Object.assign(current, updates, { updatedAt: nowIso() });
writeProgressSidecar(dir, current);
}
function stopProgressSidecar(dir: string, timer: NodeJS.Timeout, failed?: boolean): void {
clearInterval(timer);
const current = readProgressSidecar(dir);
if (!current) return;
current.phase = failed ? "failed" : "completed";
const elapsed = Date.now() - new Date(current.startedAt).getTime();
current.executionDurationMs = elapsed;
current.updatedAt = nowIso();
if (current.logFilePath) {
try {
current.logBytes = fs.statSync(path.join(dir, current.logFilePath)).size;
} catch {
// ignore
}
}
writeProgressSidecar(dir, current);
}
// --------------------------------------------------------------------------
// Claude stream-json activity derivation
// ---------------------------------------------------------------------------
function deriveClaudeActivity(line: string): string | undefined {
if (!line.trimStart().startsWith("{")) return undefined;
try {
const parsed = JSON.parse(line) as Record<string, unknown>;
const type = String(parsed.type ?? "");
// Real Claude Code stream-json envelope:
// type=system, subtype=init, session_id
// type=assistant, message:{content:[{type:"tool_use",...}]}
// type=result, is_error?, result, session_id
if (type === "assistant") {
const message = parsed.message as Record<string, unknown> | undefined;
const content = message?.content as unknown[] | undefined;
if (!Array.isArray(content) || content.length === 0) return undefined;
for (const item of content) {
if (typeof item !== "object" || item === null) continue;
const block = item as Record<string, unknown>;
const blockType = String(block.type ?? "");
if (blockType === "thinking") continue; // DO NOT surface thinking
if (blockType === "tool_use") {
return deriveToolUse(block);
}
}
return undefined;
}
// Direct tool_use at top level (legacy)
if (type === "tool_use") {
return deriveToolUse(parsed);
}
// System init / result / error at top level
switch (type) {
case "system": {
if (parsed.subtype === "init") return "Creating session";
return undefined;
}
case "result": {
// is_error result → error
if (parsed.is_error === true) {
const err = String(parsed.result ?? "").slice(0, 80);
return err ? `Error: ${err}` : "Error occurred";
}
const result = String(parsed.result ?? "").slice(0, 60);
return result ? `Result: ${result}` : "Processing";
}
case "error": {
const err = String(parsed.error ?? "").slice(0, 80);
return err ? `Error: ${err}` : "Error occurred";
}
default: return undefined;
}
} catch {
return undefined;
}
}
/** Derive a concise activity string from a tool_use content block. */
function deriveToolUse(block: Record<string, unknown>): string | undefined {
const name = String(block.name ?? "");
const input = block.input as Record<string, unknown> | undefined;
const target = (input?.file_path ?? input?.path ?? input?.file ?? input?.filePath ?? "") as string;
const lowerName = name.toLowerCase();
switch (lowerName) {
case "read":
case "read_file": return target ? `Reading ${target}` : "Reading file";
case "write":
case "create": return target ? `Writing ${target}` : "Writing file";
case "edit":
case "edit_file":
case "str_replace_editor": return target ? `Editing ${target}` : "Editing file";
case "bash":
case "command": {
const cmd = String(input?.command ?? "").slice(0, 80);
return cmd ? `Running: ${cmd}` : "Running command";
}
case "glob": return "Searching files";
case "grep":
case "search": return "Searching code";
case "web_fetch":
case "web": return "Fetching URL";
case "subagent":
case "agent":
case "dispatch_agent": return "Running sub-agent";
default: return `${name}...`;
}
}
// ---------------------------------------------------------------------------
// Prompt builders
// ---------------------------------------------------------------------------
function buildStartPrompt(opts: ExecutorStartOpts): string {
const { plan } = opts;
const scopeList = plan.scope.map((s) => ` - ${s}`).join("\n");
const criteriaList = plan.acceptanceCriteria.map((c, i) => ` ${i + 1}. ${c}`).join("\n");
const cmdsText = plan.verificationCommands
.map((cmd) => ` ${cmd.join(" ")}`)
.join("\n");
return `# Implementation Task: ${plan.title}
## Plan
${plan.planMarkdown}
## Allowed Scope
Only modify files within these paths (relative to repository root):
${scopeList}
## Acceptance Criteria
${criteriaList}
## Verification Commands
After implementation, ensure these commands pass:
${cmdsText}
${getSafetyConstraints(opts.vcsKind)}`;
}
function buildFixPrompt(opts: ExecutorResumeOpts): string {
const { plan, acceptedFindings } = opts;
if (opts.purpose === "task_plan") {
const scopeList = plan.scope.map((s) => ` - ${s}`).join("\n");
const criteriaList = plan.acceptanceCriteria.map((c, i) => ` ${i + 1}. ${c}`).join("\n");
const cmdsText = plan.verificationCommands.map((cmd) => ` ${cmd.join(" ")}`).join("\n");
return `# Continued Task Plan: ${plan.title}
This is a new frozen plan for the same task. Use the repository's current state and your existing task context.
## Plan
${plan.planMarkdown}
## Allowed Scope
Only modify files within these paths (relative to repository root):
${scopeList}
## Acceptance Criteria
${criteriaList}
## Verification Commands
After implementation, ensure these commands pass:
${cmdsText}
${getSafetyConstraints(opts.vcsKind)}`;
}
if (acceptedFindings.startsWith("# Scope Remediation")) {
return `# Scope Fix Request: ${plan.title}
${acceptedFindings}
Do not modify any other out-of-scope path. If a listed path is not clearly an executor-created artifact, stop and report it instead of deleting it.
## Frozen Scope
The implementation itself must remain within:
${plan.scope.map((s) => ` - ${s}`).join("\n")}
## Verification
After scope cleanup, run:
${plan.verificationCommands.map((cmd) => ` ${cmd.join(" ")}`).join("\n")}
${getSafetyConstraints(opts.vcsKind)}`;
}
const hasDetailedGuidance = acceptedFindings.includes("## Detailed Implementation Guidance");
const planContext = hasDetailedGuidance
? `\n## Plan\n\n${plan.planMarkdown}\n\n## Acceptance Criteria\n\n${plan.acceptanceCriteria
.map((c, i) => ` ${i + 1}. ${c}`)
.join("\n")}\n`
: "";
return `# Fix Request: ${plan.title}
${planContext}
The code review found the following issues that must be fixed:
${acceptedFindings}
## Scope Reminder
Only modify files within:
${plan.scope.map((s) => ` - ${s}`).join("\n")}
## Verification
After applying fixes, run:
${plan.verificationCommands.map((cmd) => ` ${cmd.join(" ")}`).join("\n")}
${getSafetyConstraints(opts.vcsKind)}`;
}
// ---------------------------------------------------------------------------
// Child env (inherit + extra PATH entries for executors)
// ---------------------------------------------------------------------------
function executorEnv(): NodeJS.ProcessEnv {
const homeDir = process.env.HOME || os.homedir();
const extras = [
path.join(homeDir, ".local", "bin"),
path.join(homeDir, ".bun", "bin"),
path.join(homeDir, ".n", "bin"),
"/usr/local/bin",
"/usr/bin",
"/bin",
process.env.PATH || "",
];
return {
...process.env,
PATH: [...new Set(extras.join(":").split(":").filter(Boolean))].join(":"),
};
}
function resolveExecutable(config: ExecutorConfig, defaultBin: string): string {
return config.binary || defaultBin;
}
function deriveAttemptIndex(logFilePath: string | undefined): number {
if (!logFilePath) return 0;
const base = path.basename(logFilePath);
const match = base.match(/retry-(\d+)\.log$/);
return match ? parseInt(match[1], 10) : 0;
}
// ---------------------------------------------------------------------------
// Reasonix adapter
// ---------------------------------------------------------------------------
function readFirstLine(filePath: string): string | undefined {
const CHUNK_SIZE = 65536;
const fd = fs.openSync(filePath, "r");
let buffer = Buffer.alloc(CHUNK_SIZE);
let totalData = "";
let newlineIndex = -1;
let position = 0;
try {
while (true) {
const bytesRead = fs.readSync(fd, buffer, 0, CHUNK_SIZE, position);
if (bytesRead === 0) break;
totalData += buffer.toString("utf8", 0, bytesRead);
newlineIndex = totalData.indexOf("\n");
if (newlineIndex !== -1) {
return totalData.slice(0, newlineIndex);
}
position += bytesRead;
if (position > 1024 * 1024) break; // 1MB safety limit
}
return totalData;
} finally {
fs.closeSync(fd);
}
}
function getWorkspaceFromJsonl(jsonlPath: string): string | undefined {
try {
const firstLine = readFirstLine(jsonlPath);
if (!firstLine) return undefined;
const obj = JSON.parse(firstLine);
if (obj && obj.role === "system" && typeof obj.content === "string") {
let match = obj.content.match(/Current workspace:\s*"([^"\n\r]+)"/i);
if (!match) {
match = obj.content.match(/Current workspace:\s*([^"\n\r]+)/i);
}
if (match) {
return match[1].trim();
}
}
} catch {
// ignore
}
return undefined;
}
function reasonixProjectDir(projectsDir: string, repoRoot: string): string {
const projectKey = path.resolve(repoRoot).replaceAll(path.sep, "-");
return path.join(projectsDir, projectKey);
}
function isSessionInReasonixProject(jsonlPath: string, projectsDir: string, repoRoot: string): boolean {
const sessionsDir = path.join(reasonixProjectDir(projectsDir, repoRoot), "sessions");
const relative = path.relative(sessionsDir, jsonlPath);
return relative !== "" && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
}
export function validateReasonixSessionForRepository(sessionFilePath: string, repoRoot: string): string {
const projectsDir = path.join(process.env.HOME || os.homedir(), ".reasonix", "projects");
const resolved = path.resolve(sessionFilePath);
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile() || !resolved.endsWith(".jsonl")) {
throw new Error(`Reasonix session file not found: ${resolved}`);
}
if (!fs.existsSync(`${resolved}.meta`)) {
throw new Error(`Reasonix session metadata file not found: ${resolved}.meta`);
}
const workspaceMatches = getWorkspaceFromJsonl(resolved) === repoRoot;
if (!workspaceMatches && !isSessionInReasonixProject(resolved, projectsDir, repoRoot)) {
throw new Error(`Reasonix session does not belong to repository ${repoRoot}: ${resolved}`);
}
return resolved;
}
function listJsonlFiles(dir: string, repoRoot?: string): string[] {
if (!fs.existsSync(dir)) return [];
const result: string[] = [];
function recurse(current: string) {
if (!fs.existsSync(current)) return;
const entries = fs.readdirSync(current, { withFileTypes: true });
for (const entry of entries) {
const full = path.join(current, entry.name);
if (entry.isDirectory()) {
const lowerDir = entry.name.toLowerCase();
if (lowerDir !== "archive" && lowerDir !== "subagents") {
recurse(full);
}
} else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
const lowerName = entry.name.toLowerCase();
if (
!lowerName.includes("events") &&
!lowerName.includes("archive") &&
!lowerName.includes("conflict") &&
!lowerName.includes("subagents")
) {
// Check companion file name: <session>.jsonl.meta
const metaFile = full + ".meta";
if (fs.existsSync(metaFile)) {
if (repoRoot) {
const ws = getWorkspaceFromJsonl(full);
if (ws === repoRoot || isSessionInReasonixProject(full, dir, repoRoot)) {
result.push(full);
}
} else {
result.push(full);
}
}
}
}
}
}
recurse(dir);
return result;
}
function sessionContainsPlanTitle(jsonlPath: string, planTitle: string): boolean {
try {
const content = fs.readFileSync(jsonlPath, "utf8");
return content.includes(`# Implementation Task: ${planTitle}`);
} catch {
return false;
}
}
export function findReasonixSessionForWorkflow(
repoRoot: string,
planTitle: string,
startedAt?: string,
completedAt?: string,
): string | undefined {
const homeDir = process.env.HOME || os.homedir();
const projectsDir = path.join(homeDir, ".reasonix", "projects");
const startMs = startedAt ? Date.parse(startedAt) : Number.NEGATIVE_INFINITY;
const endMs = completedAt ? Date.parse(completedAt) : Number.POSITIVE_INFINITY;
const toleranceMs = 5000;
const candidates = listJsonlFiles(projectsDir, repoRoot).filter((file) => {
if (!sessionContainsPlanTitle(file, planTitle)) return false;
try {
const mtimeMs = fs.statSync(file).mtimeMs;
return mtimeMs >= startMs - toleranceMs && mtimeMs <= endMs + toleranceMs;
} catch {
return false;
}
});
candidates.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
return candidates[0];
}
function detectNewSessionFile(beforeSnapshot: Set<string>, searchDirs: string[], repoRoot: string): string | undefined {
const candidates: string[] = [];
for (const dir of searchDirs) {
for (const file of listJsonlFiles(dir, repoRoot)) {
if (!beforeSnapshot.has(file)) {
candidates.push(file);
}
}
}
if (candidates.length === 0) return undefined;
if (candidates.length === 1) return candidates[0];
candidates.sort((a, b) => {
try {
return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs;
} catch {
return 0;
}
});
return candidates[0];
}
class ReasonixAdapter implements ExecutorAdapter {
readonly kind: ExecutorKind = "reasonix";
async probe(config: ExecutorConfig = {}): Promise<boolean> {
try {
const res = spawnSync(resolveExecutable(config, "reasonix"), ["--version"], {
stdio: "ignore",
env: executorEnv(),
});
return res.status === 0 && !res.error;
} catch {
return false;
}
}
async start(opts: ExecutorStartOpts): Promise<ExecutorResult> {
const bin = resolveExecutable(opts.config, "reasonix");
const prompt = buildStartPrompt(opts);
const promptFile = path.join(opts.runDir, `cycle-${opts.cycleIndex}-prompt.txt`);
fs.writeFileSync(promptFile, prompt, "utf8");
// Snapshot JSONL files before run to detect the session file created by reasonix
const homeDir = process.env.HOME || os.homedir();
const projectsDir = path.join(homeDir, ".reasonix", "projects");
fs.mkdirSync(projectsDir, { recursive: true });
const snapshotDirs = [projectsDir];
const beforeSnapshot = new Set(listJsonlFiles(projectsDir, opts.repoRoot));
const args: string[] = [
"run",
"--dir", opts.repoRoot,
...(opts.config.model ? ["--model", opts.config.model] : []),
prompt,
];
const startMs = Date.now();
const logFile = opts.logFilePath;
const attemptIndex = deriveAttemptIndex(logFile);
const sidecarTimer = startProgressSidecar(opts.runDir, "reasonix", opts.cycleIndex, attemptIndex, logFile);
const child = await spawnStreamingChild(bin, args, {
cwd: opts.repoRoot,
env: executorEnv(),
signal: opts.signal,
processGroup: true,
logFilePath: logFile,
onProgress: ({ pid, bytesLogged }) => {
opts.onActivity?.();
updateSidecar(opts.runDir, { pid, logBytes: bytesLogged, lastActivityAt: nowIso() });
},
});
const reasonixFailed = child.status !== 0 || !!child.error;
stopProgressSidecar(opts.runDir, sidecarTimer, reasonixFailed);
const durationMs = Date.now() - startMs;
const newSession = detectNewSessionFile(beforeSnapshot, snapshotDirs, opts.repoRoot);
const handle: ReasonixSessionHandle | undefined = newSession
? { kind: "reasonix", sessionFilePath: newSession }
: undefined;
const report = child.stdout + (child.stderr ? `\n[stderr]\n${child.stderr}` : "");
const failed = reasonixFailed;
return {
exitCode: child.status,
report,
durationMs,
sessionHandle: handle,
failed,
...(child.error ? { failureReason: child.error.message } : {}),
};
}
async resume(opts: ExecutorResumeOpts): Promise<ExecutorResult> {
const handle = opts.handle as ReasonixSessionHandle;
const bin = resolveExecutable(opts.config, "reasonix");
const prompt = buildFixPrompt(opts);
const promptFile = path.join(opts.runDir, `cycle-${opts.cycleIndex}-prompt.txt`);
fs.writeFileSync(promptFile, prompt, "utf8");
const args: string[] = [
"run",
"--dir", opts.repoRoot,
"--resume", handle.sessionFilePath,
...(opts.config.model ? ["--model", opts.config.model] : []),
prompt,
];
const startMs = Date.now();
const logFile = opts.logFilePath;
const attemptIndex = deriveAttemptIndex(logFile);
const sidecarTimer = startProgressSidecar(opts.runDir, "reasonix", opts.cycleIndex, attemptIndex, logFile);
const child = await spawnStreamingChild(bin, args, {
cwd: opts.repoRoot,
env: executorEnv(),
signal: opts.signal,
processGroup: true,
logFilePath: logFile,
onProgress: ({ pid, bytesLogged }) => {
opts.onActivity?.();
updateSidecar(opts.runDir, { pid, logBytes: bytesLogged, lastActivityAt: nowIso() });
},
});
const reasonixFailed = child.status !== 0 || !!child.error;
stopProgressSidecar(opts.runDir, sidecarTimer, reasonixFailed);
const durationMs = Date.now() - startMs;
const report = child.stdout + (child.stderr ? `\n[stderr]\n${child.stderr}` : "");
return {
exitCode: child.status,
report,
durationMs,
sessionHandle: handle, // Reasonix handle stays the same file
failed: reasonixFailed,
...(child.error ? { failureReason: child.error.message } : {}),
};
}
async cancel(_handle: ExecutorSessionHandle): Promise<void> {
// Reasonix does not expose a cancel API in v1; no-op.
}
}
// ---------------------------------------------------------------------------
// Claude Code adapter
// ---------------------------------------------------------------------------
function extractClaudeSessionId(output: string): string | undefined {
// Claude Code outputs JSON with session_id field when using --output-format json
try {
for (const line of output.split("\n")) {
const trimmed = line.trim();
if (!trimmed.startsWith("{")) continue;
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
if (typeof parsed.session_id === "string") return parsed.session_id;
}
} catch {
// fall through
}
// Also try a simple regex
const m = output.match(/"session_id"\s*:\s*"([^"]+)"/);
return m?.[1];
}
export function validateClaudeSessionForWorkflow(
sessionId: string,
repoRoot: string,
planTitle: string,
): string {
const { sessionFile, content } = readClaudeSessionForRepository(sessionId, repoRoot);
if (!content.includes(planTitle)) {
throw new Error(`Claude session ${sessionId} does not contain frozen plan title: ${planTitle}`);
}
return sessionFile;
}
export function validateClaudeSessionForRepository(sessionId: string, repoRoot: string): string {
return readClaudeSessionForRepository(sessionId, repoRoot).sessionFile;
}
function readClaudeSessionForRepository(
sessionId: string,
repoRoot: string,
): { sessionFile: string; content: string } {
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(sessionId)) {
throw new Error(`Invalid Claude session ID: ${sessionId}`);
}
const projectsDir = path.join(process.env.HOME || os.homedir(), ".claude", "projects");
if (!fs.existsSync(projectsDir)) {
throw new Error(`Claude projects directory not found: ${projectsDir}`);
}
const candidates = fs.readdirSync(projectsDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => path.join(projectsDir, entry.name, `${sessionId}.jsonl`))
.filter((candidate) => fs.existsSync(candidate));
if (candidates.length !== 1) {
throw new Error(
candidates.length === 0
? `Claude session file not found for ID: ${sessionId}`
: `Multiple Claude session files found for ID: ${sessionId}`,
);
}
const sessionFile = candidates[0];
const fd = fs.openSync(sessionFile, "r");
const maxBytes = 4 * 1024 * 1024;
const buffer = Buffer.alloc(maxBytes);
let content: string;
try {
const bytesRead = fs.readSync(fd, buffer, 0, maxBytes, 0);
content = buffer.toString("utf8", 0, bytesRead);
} finally {
fs.closeSync(fd);
}
let repoMatches = false;
let sessionMatches = false;
for (const line of content.split("\n")) {
if (!line.trim()) continue;
try {
const entry = JSON.parse(line) as Record<string, unknown>;
if (entry.sessionId === sessionId) sessionMatches = true;
if (entry.cwd === repoRoot) repoMatches = true;
} catch {
// Ignore a trailing partial line at the read boundary.
}
}
if (!sessionMatches || !repoMatches) {
throw new Error(`Claude session ${sessionId} does not belong to repository ${repoRoot}`);
}
return { sessionFile, content };
}
function extractClaudeFailureReason(output: string): string | undefined {
for (const line of output.split("\n")) {
const trimmed = line.trim();
if (!trimmed.startsWith("{")) continue;
try {
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
if (Array.isArray(parsed.errors)) {
const errors = parsed.errors.filter((error): error is string => typeof error === "string" && error.trim().length > 0);
if (errors.length > 0) return errors.join("; ");
}
if (parsed.is_error === true && typeof parsed.result === "string" && parsed.result.trim()) {
return parsed.result.trim();
}
// Handle stream-json error format: {"type":"error","error":"Rate limit exceeded"}
if (parsed.type === "error" && typeof parsed.error === "string" && parsed.error.trim()) {
return parsed.error.trim();
}
} catch {
// Ignore non-JSON output lines.
}
}
return undefined;
}
class ClaudeAdapter implements ExecutorAdapter {
readonly kind: ExecutorKind = "claude";
async probe(config: ExecutorConfig = {}): Promise<boolean> {
try {
const res = spawnSync(resolveExecutable(config, "claude"), ["--version"], {
stdio: "ignore",
env: executorEnv(),
});
return res.status === 0 && !res.error;
} catch {
return false;
}
}
async start(opts: ExecutorStartOpts): Promise<ExecutorResult> {
const bin = resolveExecutable(opts.config, "claude");
const sessionId = randomUUID();
const prompt = buildStartPrompt(opts);
const args: string[] = [
"-p",
"--verbose",
"--safe-mode",
"--permission-mode", "bypassPermissions",
"--output-format", "stream-json",
"--session-id", sessionId,
...(opts.config.model ? ["--model", opts.config.model] : []),
prompt,
];
const startMs = Date.now();
const logFile = opts.logFilePath;
const attemptIndex = deriveAttemptIndex(logFile);
const sidecarTimer = startProgressSidecar(opts.runDir, "claude", opts.cycleIndex, attemptIndex, logFile);
let lineBuffer = "";
const child = await spawnStreamingChild(bin, args, {
cwd: opts.repoRoot,
env: executorEnv(),
signal: opts.signal,
processGroup: true,
logFilePath: logFile,
onProgress: ({ pid, bytesLogged }) => {
opts.onActivity?.();
updateSidecar(opts.runDir, { pid, logBytes: bytesLogged, lastActivityAt: nowIso() });
},
onStdoutData: (chunk: string) => {
lineBuffer += chunk;
while (true) {
const nl = lineBuffer.indexOf("\n");
if (nl < 0) break;
const line = lineBuffer.slice(0, nl);
lineBuffer = lineBuffer.slice(nl + 1);
const activity = deriveClaudeActivity(line);
if (activity) {
// Only surface tool actions in activitySummary, not result/error
const isToolActivity = !activity.startsWith("Result:") && !activity.startsWith("Error:");
if (isToolActivity) {
updateSidecar(opts.runDir, { activitySummary: activity, lastActivityAt: nowIso() });
} else {
updateSidecar(opts.runDir, { lastActivityAt: nowIso() });
}
}
}
},
});
const claudeFailed = child.status !== 0 || !!child.error;
stopProgressSidecar(opts.runDir, sidecarTimer, claudeFailed);
const durationMs = Date.now() - startMs;
const capturedId = extractClaudeSessionId(child.stdout) ?? sessionId;
const handle: ClaudeSessionHandle = { kind: "claude", sessionId: capturedId };
const report = child.stdout + (child.stderr ? `\n[stderr]\n${child.stderr}` : "");
const failureReason = child.error?.message
?? extractClaudeFailureReason(child.stdout)
?? extractClaudeFailureReason(child.stderr);
return {
exitCode: child.status,
report,
durationMs,
sessionHandle: handle,
failed: child.status !== 0 || !!child.error,
...(failureReason ? { failureReason } : {}),
};
}
async resume(opts: ExecutorResumeOpts): Promise<ExecutorResult> {
const handle = opts.handle as ClaudeSessionHandle;
const bin = resolveExecutable(opts.config, "claude");
const prompt = buildFixPrompt(opts);
const args: string[] = [
"-p",
"--verbose",
"--safe-mode",
"--permission-mode", "bypassPermissions",
"--output-format", "stream-json",
"--resume", handle.sessionId,
...(opts.config.model ? ["--model", opts.config.model] : []),
prompt,
];
const startMs = Date.now();
const logFile = opts.logFilePath;
const attemptIndex = deriveAttemptIndex(logFile);
const sidecarTimer = startProgressSidecar(opts.runDir, "claude", opts.cycleIndex, attemptIndex, logFile);
let lineBuffer = "";
const child = await spawnStreamingChild(bin, args, {
cwd: opts.repoRoot,
env: executorEnv(),
signal: opts.signal,
processGroup: true,
logFilePath: logFile,
onProgress: ({ pid, bytesLogged }) => {
opts.onActivity?.();
updateSidecar(opts.runDir, { pid, logBytes: bytesLogged, lastActivityAt: nowIso() });
},
onStdoutData: (chunk: string) => {
lineBuffer += chunk;
while (true) {
const nl = lineBuffer.indexOf("\n");
if (nl < 0) break;
const line = lineBuffer.slice(0, nl);
lineBuffer = lineBuffer.slice(nl + 1);
const activity = deriveClaudeActivity(line);
if (activity) {
// Only surface tool actions in activitySummary, not result/error
const isToolActivity = !activity.startsWith("Result:") && !activity.startsWith("Error:");
if (isToolActivity) {
updateSidecar(opts.runDir, { activitySummary: activity, lastActivityAt: nowIso() });
} else {
updateSidecar(opts.runDir, { lastActivityAt: nowIso() });
}
}
}
},
});
const claudeFailed = child.status !== 0 || !!child.error;
stopProgressSidecar(opts.runDir, sidecarTimer, claudeFailed);
const durationMs = Date.now() - startMs;
const report = child.stdout + (child.stderr ? `\n[stderr]\n${child.stderr}` : "");
const failureReason = child.error?.message
?? extractClaudeFailureReason(child.stdout)
?? extractClaudeFailureReason(child.stderr);
return {
exitCode: child.status,
report,
durationMs,
sessionHandle: handle,
failed: child.status !== 0 || !!child.error,
...(failureReason ? { failureReason } : {}),
};
}
async cancel(_handle: ExecutorSessionHandle): Promise<void> {
// Claude Code: no explicit cancel API in v1.
}
}
// ---------------------------------------------------------------------------
// Agy adapter
// ---------------------------------------------------------------------------
function extractAgyConversationId(output: string): string | undefined {
// Try to parse conversation_id from JSON output lines
for (const line of output.split("\n")) {
const trimmed = line.trim();
if (!trimmed.startsWith("{")) continue;
try {
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
if (typeof parsed.conversation_id === "string") return parsed.conversation_id;
if (typeof parsed.conversationId === "string") return parsed.conversationId;
} catch { /* skip */ }
}
return undefined;
}
function extractAgyConversationIdFromLog(logFile: string): string | undefined {
try {
const log = fs.readFileSync(logFile, "utf8");
const matches = [
...log.matchAll(/Print mode: conversation=([0-9a-f-]{36})\b/gi),
...log.matchAll(/Created conversation ([0-9a-f-]{36})\b/gi),
];
return matches.at(-1)?.[1];
} catch {
return undefined;
}
}
function agyConversationCacheFile(): string {
const homeDir = process.env.HOME || os.homedir();
return path.join(homeDir, ".gemini", "antigravity-cli", "cache", "last_conversations.json");
}
function getAgyWorkspaceConversationId(repoRoot: string): string | undefined {
try {
const parsed = JSON.parse(fs.readFileSync(agyConversationCacheFile(), "utf8")) as Record<string, unknown>;
const id = parsed[repoRoot];
return typeof id === "string" && /^[0-9a-f-]{36}$/i.test(id) ? id : undefined;
} catch {
return undefined;
}
}
function extractAgyFailureReason(stdout: string, stderr: string): string | undefined {
const output = `${stdout}\n${stderr}`.trim();
if (!output) return "Agy exited without producing output.";
const diagnostic = output
.split("\n")
.map((line) => line.trim())
.find((line) =>
/no output produced/i.test(line)
|| /headless mode cannot prompt/i.test(line)
|| /permission .*auto-denied/i.test(line)
|| /tool required .* permission/i.test(line));
return diagnostic;
}
function agyCommonArgs(config: ExecutorConfig, timeoutSeconds?: number): string[] {
return [
"--dangerously-skip-permissions",
"--mode", "accept-edits",
...(timeoutSeconds ? ["--print-timeout", "24h"] : []),
...(config.agent ? ["--agent", config.agent] : []),
...(config.model ? ["--model", config.model] : []),
];
}
class AgyAdapter implements ExecutorAdapter {
readonly kind: ExecutorKind = "agy";
async probe(config: ExecutorConfig = {}): Promise<boolean> {
try {
const res = spawnSync(resolveExecutable(config, "agy"), ["--version"], {
stdio: "ignore",
env: executorEnv(),
});
return res.status === 0 && !res.error;
} catch {
return false;
}
}
async start(opts: ExecutorStartOpts): Promise<ExecutorResult> {
const bin = resolveExecutable(opts.config, "agy");
const prompt = buildStartPrompt(opts);
const agyLogFile = path.join(opts.runDir, `cycle-${opts.cycleIndex}-agy.log`);
const previousWorkspaceConversationId = getAgyWorkspaceConversationId(opts.repoRoot);
const args: string[] = [
...agyCommonArgs(opts.config, opts.timeoutSeconds),
"--log-file", agyLogFile,
"--print", prompt,
];
const startMs = Date.now();
const logFile = opts.logFilePath;
const attemptIndex = deriveAttemptIndex(logFile);
const sidecarTimer = startProgressSidecar(opts.runDir, "agy", opts.cycleIndex, attemptIndex, logFile);
const child = await spawnStreamingChild(bin, args, {
cwd: opts.repoRoot,
env: executorEnv(),
signal: opts.signal,
processGroup: true,
logFilePath: logFile,
onProgress: ({ pid, bytesLogged }) => {
opts.onActivity?.();
updateSidecar(opts.runDir, { pid, logBytes: bytesLogged, lastActivityAt: nowIso() });
},
});
const agyFailed = child.status !== 0 || !!child.error || !!extractAgyFailureReason(child.stdout, child.stderr);
stopProgressSidecar(opts.runDir, sidecarTimer, agyFailed);
const durationMs = Date.now() - startMs;
const conversationId = extractAgyConversationId(child.stdout)
?? extractAgyConversationIdFromLog(agyLogFile)
?? (() => {
const currentId = getAgyWorkspaceConversationId(opts.repoRoot);
return currentId !== previousWorkspaceConversationId ? currentId : undefined;
})();
const handle: AgySessionHandle = {
kind: "agy",
...(conversationId ? { conversationId } : {}),
degraded: !conversationId,
};
const report = child.stdout + (child.stderr ? `\n[stderr]\n${child.stderr}` : "");
const failureReason = child.error?.message
?? extractAgyFailureReason(child.stdout, child.stderr);
return {
exitCode: child.status,
report,
durationMs,
sessionHandle: handle,
failed: child.status !== 0 || !!failureReason,
...(failureReason ? { failureReason } : {}),
};
}
async resume(opts: ExecutorResumeOpts): Promise<ExecutorResult> {
const handle = opts.handle as AgySessionHandle;
const bin = resolveExecutable(opts.config, "agy");
const prompt = buildFixPrompt(opts);
const agyLogFile = path.join(opts.runDir, `cycle-${opts.cycleIndex}-agy.log`);
const conversationId = handle.conversationId
?? getAgyWorkspaceConversationId(opts.repoRoot);
let args: string[];
if (conversationId) {
args = [
...agyCommonArgs(opts.config, opts.timeoutSeconds),
"--conversation", conversationId,
"--log-file", agyLogFile,
"--print", prompt,
];
} else {
// Degraded mode: use --continue (best effort, may pick wrong session in concurrent setups)
args = [
...agyCommonArgs(opts.config, opts.timeoutSeconds),
"--continue",
"--log-file", agyLogFile,
"--print", prompt,
];
}
const startMs = Date.now();
const logFile = opts.logFilePath;
const attemptIndex = deriveAttemptIndex(logFile);
const sidecarTimer = startProgressSidecar(opts.runDir, "agy", opts.cycleIndex, attemptIndex, logFile);
const child = await spawnStreamingChild(bin, args, {
cwd: opts.repoRoot,
env: executorEnv(),
signal: opts.signal,
processGroup: true,
logFilePath: logFile,
onProgress: ({ pid, bytesLogged }) => {
opts.onActivity?.();
updateSidecar(opts.runDir, { pid, logBytes: bytesLogged, lastActivityAt: nowIso() });
},
});
const agyFailed = child.status !== 0 || !!child.error || !!extractAgyFailureReason(child.stdout, child.stderr);
stopProgressSidecar(opts.runDir, sidecarTimer, agyFailed);
const durationMs = Date.now() - startMs;
const report = child.stdout + (child.stderr ? `\n[stderr]\n${child.stderr}` : "");
const capturedConversationId = conversationId
?? extractAgyConversationId(child.stdout)
?? extractAgyConversationIdFromLog(agyLogFile);
const updatedHandle: AgySessionHandle = {
kind: "agy",
...(capturedConversationId ? { conversationId: capturedConversationId } : {}),
degraded: !capturedConversationId,
};
const failureReason = child.error?.message
?? extractAgyFailureReason(child.stdout, child.stderr);
return {
exitCode: child.status,
report,
durationMs,
sessionHandle: updatedHandle,
failed: child.status !== 0 || !!failureReason,
...(failureReason ? { failureReason } : {}),
};
}
async cancel(_handle: ExecutorSessionHandle): Promise<void> {
// Agy: no explicit cancel API in v1.
}
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
export function createExecutorAdapter(kind: ExecutorKind): ExecutorAdapter {
switch (kind) {
case "reasonix":
return new ReasonixAdapter();
case "claude":
return new ClaudeAdapter();
case "agy":
return new AgyAdapter();
default: {
const _exhaustive: never = kind;
throw new Error(`Unknown executor kind: ${_exhaustive}`);
}
}
}
/** Check that the executor binary is available and throw a descriptive error if not. */
export async function probeExecutor(kind: ExecutorKind, config: ExecutorConfig = {}): Promise<void> {
const adapter = createExecutorAdapter(kind);
const available = await adapter.probe(config);
if (!available) {
const bin = config.binary || kind;
throw new Error(
`Executor '${kind}' is not available. ` +
`Make sure '${bin}' is installed and on PATH. ` +
`You can override the binary path with the 'binary' field in agent-workflow.json.`,
);
}
}
/**
* Derive a concise human-readable activity summary from a Claude Code stream-json line.
* Exported for testing. Returns undefined when the line does not describe observable activity.
*/
export function parseClaudeActivityLine(line: string): string | undefined {
return deriveClaudeActivity(line);
}