1571 lines
54 KiB
TypeScript
1571 lines
54 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,
|
|
CodexSessionHandle,
|
|
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: ExecutorKind,
|
|
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.
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ---------------------------------------------------------------------------
|
|
// Codex adapter
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function extractCodexSessionId(output: string): string | undefined {
|
|
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>;
|
|
// Real Codex thread.started carries thread_id at event top level
|
|
if (parsed.type === "thread.started") {
|
|
if (typeof parsed.thread_id === "string") return parsed.thread_id;
|
|
const thread = parsed.thread as Record<string, unknown> | undefined;
|
|
if (thread && typeof thread.id === "string") return thread.id;
|
|
}
|
|
// Legacy fallback
|
|
if (typeof parsed.session_id === "string") return parsed.session_id;
|
|
if (parsed.type === "session_meta" && typeof parsed.id === "string") return parsed.id;
|
|
}
|
|
} catch {
|
|
// fall through
|
|
}
|
|
const m = output.match(/"session_id"\s*:\s*"([^"]+)"/);
|
|
return m?.[1];
|
|
}
|
|
|
|
export function validateCodexSessionForWorkflow(
|
|
sessionId: string,
|
|
repoRoot: string,
|
|
planTitle: string,
|
|
): string {
|
|
const { sessionFile, content } = readCodexSessionForRepository(sessionId, repoRoot);
|
|
if (!content.includes(planTitle)) {
|
|
throw new Error(`Codex session ${sessionId} does not contain frozen plan title: ${planTitle}`);
|
|
}
|
|
return sessionFile;
|
|
}
|
|
|
|
export function validateCodexSessionForRepository(sessionId: string, repoRoot: string): string {
|
|
return readCodexSessionForRepository(sessionId, repoRoot).sessionFile;
|
|
}
|
|
|
|
function readCodexSessionForRepository(
|
|
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 Codex session ID: ${sessionId}`);
|
|
}
|
|
|
|
const homeDir = process.env.HOME || os.homedir();
|
|
const searchDirs = [
|
|
path.join(homeDir, ".codex", "sessions"),
|
|
path.join(homeDir, ".codex", "archived_sessions"),
|
|
];
|
|
|
|
const candidates: string[] = [];
|
|
const findSessionFile = (dir: string) => {
|
|
if (!fs.existsSync(dir)) return;
|
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
if (entry.isDirectory()) {
|
|
findSessionFile(path.join(dir, entry.name));
|
|
} else if (entry.isFile()) {
|
|
// Real rollout filenames: rollout-<timestamp>-<uuid>.jsonl
|
|
const name = entry.name;
|
|
if (name.endsWith(".jsonl") && (name.includes(sessionId) || name === `${sessionId}.jsonl`)) {
|
|
candidates.push(path.join(dir, entry.name));
|
|
}
|
|
}
|
|
}
|
|
};
|
|
for (const searchDir of searchDirs) {
|
|
findSessionFile(searchDir);
|
|
}
|
|
|
|
if (candidates.length !== 1) {
|
|
throw new Error(
|
|
candidates.length === 0
|
|
? `Codex session file not found for ID: ${sessionId}`
|
|
: `Multiple Codex 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.type === "session_meta") {
|
|
const payload = entry.payload as Record<string, unknown> | undefined;
|
|
// Parse session ID from payload.id
|
|
const entryId = (payload?.id as string) ?? (entry.id as string) ?? (entry.session_id as string);
|
|
if (entryId === sessionId) sessionMatches = true;
|
|
// Parse working directory from payload.cwd
|
|
const cwd = (payload?.cwd as string) ?? (entry.cwd as string);
|
|
if (cwd && (path.resolve(cwd) === path.resolve(repoRoot) || path.normalize(cwd) === path.normalize(repoRoot))) {
|
|
repoMatches = true;
|
|
}
|
|
}
|
|
if (entry.session_id === sessionId) sessionMatches = true;
|
|
if (entry.cwd && path.resolve(entry.cwd as string) === path.resolve(repoRoot)) repoMatches = true;
|
|
} catch {
|
|
// Ignore a trailing partial line
|
|
}
|
|
}
|
|
|
|
if (!sessionMatches || !repoMatches) {
|
|
throw new Error(`Codex session ${sessionId} does not belong to repository ${repoRoot}`);
|
|
}
|
|
return { sessionFile, content };
|
|
}
|
|
|
|
function deriveCodexActivity(line: string): string | undefined {
|
|
const trimmed = line.trim();
|
|
if (!trimmed.startsWith("{")) return undefined;
|
|
try {
|
|
const ev = JSON.parse(trimmed) as Record<string, unknown>;
|
|
if (ev.type === "item.started" && typeof ev.item === "object" && ev.item) {
|
|
const item = ev.item as Record<string, unknown>;
|
|
// Real Codex item events use item.tool_name or item.name
|
|
if (typeof item.tool_name === "string") return `Tool: ${item.tool_name}`;
|
|
if (typeof item.name === "string") return `Tool: ${item.name}`;
|
|
if (typeof item.type === "string") return `Item: ${item.type}`;
|
|
}
|
|
if (ev.type === "item.completed") return "Item completed";
|
|
if (ev.type === "turn.completed") return "Turn completed";
|
|
if (ev.type === "thread.started") return "Thread started";
|
|
if (ev.type === "tool" && typeof ev.name === "string") return `Tool: ${ev.name}`;
|
|
if (ev.type === "message") return "Message...";
|
|
if (ev.type === "completion") return "Completion...";
|
|
if (ev.type === "initialization") return "Initializing...";
|
|
// Real Codex error may be in message or error field
|
|
if (ev.type === "error") {
|
|
const msg = typeof ev.message === "string" ? ev.message : typeof ev.error === "string" ? ev.error : undefined;
|
|
if (msg) return `Error: ${msg}`;
|
|
}
|
|
} catch {
|
|
// fall through
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function extractCodexFailureReason(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>;
|
|
// Real Codex error includes message field
|
|
if (parsed.type === "error") {
|
|
const msg = typeof parsed.message === "string" && parsed.message.trim() ? parsed.message :
|
|
typeof parsed.error === "string" && parsed.error.trim() ? parsed.error :
|
|
typeof parsed.message === "object" && parsed.message !== null && typeof (parsed.message as Record<string, unknown>).content === "string" ? (parsed.message as Record<string, unknown>).content as string :
|
|
typeof parsed.error === "object" && parsed.error !== null && typeof (parsed.error as Record<string, unknown>).content === "string" ? (parsed.error as Record<string, unknown>).content as string : undefined;
|
|
if (msg) return msg.trim();
|
|
}
|
|
} catch {
|
|
// skip
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function codexExecutorEnv(): NodeJS.ProcessEnv {
|
|
const env = executorEnv();
|
|
delete env.CODEX_THREAD_ID;
|
|
return env;
|
|
}
|
|
|
|
class CodexAdapter implements ExecutorAdapter {
|
|
readonly kind: ExecutorKind = "codex";
|
|
|
|
async probe(config: ExecutorConfig = {}): Promise<boolean> {
|
|
try {
|
|
const res = spawnSync(resolveExecutable(config, "codex"), ["--version"], {
|
|
stdio: "ignore",
|
|
env: codexExecutorEnv(),
|
|
});
|
|
return res.status === 0 && !res.error;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async start(opts: ExecutorStartOpts): Promise<ExecutorResult> {
|
|
const bin = resolveExecutable(opts.config, "codex");
|
|
const prompt = buildStartPrompt(opts);
|
|
|
|
// Codex CLI accepts prompt via stdin using '-' argument
|
|
const args: string[] = [
|
|
"exec",
|
|
"--json",
|
|
"--sandbox", "workspace-write",
|
|
"--cd", opts.repoRoot,
|
|
...(opts.config.model ? ["--model", opts.config.model] : []),
|
|
"-",
|
|
];
|
|
|
|
const startMs = Date.now();
|
|
const logFile = opts.logFilePath;
|
|
const attemptIndex = deriveAttemptIndex(logFile);
|
|
const sidecarTimer = startProgressSidecar(opts.runDir, "codex", opts.cycleIndex, attemptIndex, logFile);
|
|
|
|
let lineBuffer = "";
|
|
|
|
const child = await spawnStreamingChild(bin, args, {
|
|
cwd: opts.repoRoot,
|
|
env: codexExecutorEnv(),
|
|
input: prompt,
|
|
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 = deriveCodexActivity(line);
|
|
if (activity) {
|
|
const isToolActivity = !activity.startsWith("Error:");
|
|
if (isToolActivity) {
|
|
updateSidecar(opts.runDir, { activitySummary: activity, lastActivityAt: nowIso() });
|
|
} else {
|
|
updateSidecar(opts.runDir, { lastActivityAt: nowIso() });
|
|
}
|
|
}
|
|
}
|
|
},
|
|
});
|
|
|
|
let failed = child.status !== 0 || !!child.error;
|
|
const capturedId = extractCodexSessionId(child.stdout) ?? extractCodexSessionId(child.stderr);
|
|
let failureReason = child.error?.message
|
|
?? extractCodexFailureReason(child.stdout)
|
|
?? extractCodexFailureReason(child.stderr);
|
|
|
|
if (!failed && !capturedId) {
|
|
failed = true;
|
|
failureReason = "Codex execution completed successfully but yielded no resumable session ID.";
|
|
}
|
|
|
|
stopProgressSidecar(opts.runDir, sidecarTimer, failed);
|
|
const durationMs = Date.now() - startMs;
|
|
const handle: CodexSessionHandle | undefined = failed
|
|
? undefined
|
|
: { kind: "codex", sessionId: capturedId! };
|
|
const report = child.stdout + (child.stderr ? `\n[stderr]\n${child.stderr}` : "");
|
|
|
|
return {
|
|
exitCode: child.status,
|
|
report,
|
|
durationMs,
|
|
...(handle ? { sessionHandle: handle } : {}),
|
|
failed,
|
|
...(failureReason ? { failureReason } : {}),
|
|
};
|
|
}
|
|
|
|
async resume(opts: ExecutorResumeOpts): Promise<ExecutorResult> {
|
|
const handle = opts.handle as CodexSessionHandle;
|
|
const bin = resolveExecutable(opts.config, "codex");
|
|
const prompt = buildFixPrompt(opts);
|
|
|
|
// resume uses -c sandbox_mode= override (--sandbox is rejected by codex exec resume)
|
|
const args: string[] = [
|
|
"exec", "resume",
|
|
"-c", 'sandbox_mode="workspace-write"',
|
|
"--json",
|
|
...(opts.config.model ? ["--model", opts.config.model] : []),
|
|
handle.sessionId,
|
|
"-",
|
|
];
|
|
|
|
const startMs = Date.now();
|
|
const logFile = opts.logFilePath;
|
|
const attemptIndex = deriveAttemptIndex(logFile);
|
|
const sidecarTimer = startProgressSidecar(opts.runDir, "codex", opts.cycleIndex, attemptIndex, logFile);
|
|
|
|
let lineBuffer = "";
|
|
|
|
const child = await spawnStreamingChild(bin, args, {
|
|
cwd: opts.repoRoot,
|
|
env: codexExecutorEnv(),
|
|
input: prompt,
|
|
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 = deriveCodexActivity(line);
|
|
if (activity) {
|
|
const isToolActivity = !activity.startsWith("Error:");
|
|
if (isToolActivity) {
|
|
updateSidecar(opts.runDir, { activitySummary: activity, lastActivityAt: nowIso() });
|
|
} else {
|
|
updateSidecar(opts.runDir, { lastActivityAt: nowIso() });
|
|
}
|
|
}
|
|
}
|
|
},
|
|
});
|
|
|
|
const failed = child.status !== 0 || !!child.error;
|
|
stopProgressSidecar(opts.runDir, sidecarTimer, failed);
|
|
const durationMs = Date.now() - startMs;
|
|
|
|
const report = child.stdout + (child.stderr ? `\n[stderr]\n${child.stderr}` : "");
|
|
const failureReason = child.error?.message
|
|
?? extractCodexFailureReason(child.stdout)
|
|
?? extractCodexFailureReason(child.stderr);
|
|
return {
|
|
exitCode: child.status,
|
|
report,
|
|
durationMs,
|
|
sessionHandle: handle,
|
|
failed,
|
|
...(failureReason ? { failureReason } : {}),
|
|
};
|
|
}
|
|
|
|
async cancel(_handle: ExecutorSessionHandle): Promise<void> {
|
|
}
|
|
}
|
|
|
|
|
|
// Factory
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function createExecutorAdapter(kind: ExecutorKind): ExecutorAdapter {
|
|
switch (kind) {
|
|
case "reasonix":
|
|
return new ReasonixAdapter();
|
|
case "claude":
|
|
return new ClaudeAdapter();
|
|
case "agy":
|
|
return new AgyAdapter();
|
|
case "codex":
|
|
return new CodexAdapter();
|
|
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);
|
|
}
|