/** * Workflow engine — implements the workflow CLI subcommands: * * start — validates plan, acquires lock, starts first execution cycle * review — generates a review bundle for the Codex host * retry-review — resumes a cleaned scope-gate review * decide — receives Codex HostDecisionV1, drives state transitions * extend — extends a budget_exhausted run with additional cycles * status — prints current run state * abort — terminates a run with a reason * * State machine: * executing → awaiting_review → awaiting_host → executing (next cycle) * ↘ awaiting_scope_resolution ↗ * Any active state → completed | needs_human | blocked | budget_exhausted | aborted * * Outputs AGENT_WORKFLOW_META_JSON: to stderr (or stdout when AGENT_WORKFLOW_META_STDOUT=1). */ import fs from "node:fs"; import path from "node:path"; import { execFileSync } from "node:child_process"; import { resolveWorkflowConfig, validateHostDecision, validateWorkflowPlan, WorkflowConfigError } from "./workflow-config.js"; import { checkConvergence, decisionSignatures } from "./workflow-convergence.js"; import type { FindingSignature } from "./workflow-convergence.js"; import { createExecutorAdapter, findReasonixSessionForWorkflow, probeExecutor, validateClaudeSessionForRepository, validateClaudeSessionForWorkflow, validateReasonixSessionForRepository, } from "./workflow-executor.js"; import { getOrCreateTaskBinding, readTaskBinding, saveTaskExecutorBinding, WorkflowTaskBindingError, } from "./workflow-task-binding.js"; import { WorkflowLockError, acquireLock, appendEvent, cycleDiffFile, cycleDecisionFile, cycleExecLogFile, cycleHostReviewFile, findRunDir, generateRunId, initWorkflowState, acquireLogsFollowLock, LogsFollowLockError, nowIso, readState, readProgressSidecar, releaseLogsFollowLock, releaseLock, runDir as makeRunDir, writeState, workflowsRoot, } from "./workflow-state.js"; import { createVcsProvider, detectVcs, extractBaselineHead, VcsError, } from "./vcs-provider.js"; import type { CycleRecord, ExecutorKind, ExecutorResult, ExecutorSessionHandle, HostDecisionV1, HostReviewBundleV1, ScopeViolation, VcsBaseline, VcsKind, WorkflowPlanV1, WorkflowState, WorkflowStatusOutput, WorkflowTerminalStatus, } from "./workflow-types.js"; // --------------------------------------------------------------------------- // Managed Abort Controllers for SIGTERM handling // --------------------------------------------------------------------------- const activeAbortControllers = new Set(); process.on("SIGTERM", () => { for (const ac of activeAbortControllers) { ac.abort(); } setTimeout(() => { process.exit(143); }, 500).unref(); }); export function createManagedController(timeoutSeconds?: number): { ac: AbortController; markActivity: () => void; cleanup: () => void; } { const ac = new AbortController(); activeAbortControllers.add(ac); let timer: NodeJS.Timeout | undefined; const armTimer = () => { if (!timeoutSeconds || ac.signal.aborted) return; if (timer) clearTimeout(timer); timer = setTimeout(() => { ac.abort(); }, timeoutSeconds * 1000); timer.unref(); }; armTimer(); return { ac, markActivity: armTimer, cleanup: () => { if (timer) clearTimeout(timer); activeAbortControllers.delete(ac); }, }; } function writeStateSafety(dir: string, state: WorkflowState): void { const diskState = readState(dir); if (diskState && ["aborted", "completed", "needs_human", "blocked", "budget_exhausted"].includes(diskState.status)) { throw new WorkflowEngineError(`Workflow was terminated externally (status: ${diskState.status}).`); } writeState(dir, state); } export function emitWorkflowMeta(meta: Record): void { const line = `AGENT_WORKFLOW_META_JSON: ${JSON.stringify(meta)}\n`; const dest = process.env.AGENT_WORKFLOW_META_STDOUT?.toLowerCase(); if (dest === "1" || dest === "true" || dest === "stdout") { process.stdout.write(line); } else { process.stderr.write(line); } } // --------------------------------------------------------------------------- // Helper: load state or fail // --------------------------------------------------------------------------- function requireState(runId: string, repoRoot?: string): { state: WorkflowState; dir: string } { const dir = findRunDir(runId, repoRoot); if (!dir) throw new WorkflowEngineError(`Run '${runId}' not found.`); const state = readState(dir); if (!state) throw new WorkflowEngineError(`State for run '${runId}' is corrupt or missing.`); return { state, dir }; } export class WorkflowEngineError extends Error { constructor( message: string, readonly exitCode: number = 4, ) { super(message); this.name = "WorkflowEngineError"; } } function makeScopeViolations(paths: string[]): ScopeViolation[] { return [...new Set(paths)].sort().map((filePath, index) => ({ id: `scope-${index + 1}`, path: filePath, kind: "out_of_scope", })); } function scopeViolationPaths(violations: ScopeViolation[] | undefined): string[] { return (violations ?? []).map((violation) => violation.path).sort(); } function sameScopeViolations(a: ScopeViolation[] | undefined, b: ScopeViolation[]): boolean { const left = scopeViolationPaths(a); const right = scopeViolationPaths(b); return left.length === right.length && left.every((value, index) => value === right[index]); } // --------------------------------------------------------------------------- // Terminal transition helper // --------------------------------------------------------------------------- function transitionToTerminal( state: WorkflowState, dir: string, terminal: WorkflowTerminalStatus, description: string, data?: Record, ): void { const diskState = readState(dir); if (diskState && ["aborted", "completed", "needs_human", "blocked", "budget_exhausted"].includes(diskState.status)) { return; } state.status = terminal; state.stopReason = terminal; state.stopDescription = description; state.enginePid = undefined; state.updatedAt = nowIso(); writeState(dir, state); appendEvent(dir, { kind: terminal === "aborted" ? "workflow_aborted" : "workflow_stopped", runId: state.runId, timestamp: state.updatedAt, data: { reason: terminal, description, ...data }, }); releaseLock(state.repoRoot, state.runId); } /** * Register an attempt log path in the cycle record BEFORE spawning the executor * so that a crash during execution still leaves a discoverable log file. */ function registerAttemptLog(dir: string, cycleRecord: CycleRecord, logFilePath: string): void { const priorLogs = cycleRecord.executorAttemptLogs ?? (cycleRecord.executorLogFile ? [cycleRecord.executorLogFile] : []); const relativeLog = path.relative(dir, logFilePath); cycleRecord.executorAttemptLogs = [...priorLogs, relativeLog]; cycleRecord.executorLogFile = relativeLog; } /** * Finalize an attempt after the executor finishes: set exit code and report slice. * The log path is already registered (by registerAttemptLog), so we never re-derive it. */ function persistExecutorAttempt( dir: string, cycleRecord: CycleRecord, execResult: ExecutorResult, logFilePath?: string, ): void { // If the log file was created and registered before spawning (even if empty), // keep it and don't overwrite. Empty logs are valid (e.g. immediate exit). if (logFilePath && fs.existsSync(logFilePath)) { const relativeLog = path.relative(dir, logFilePath); cycleRecord.executorLogFile = relativeLog; cycleRecord.executorExitCode = execResult.exitCode ?? undefined; cycleRecord.executorReport = execResult.report.slice(0, 2000); if (!cycleRecord.executorAttemptLogs?.includes(relativeLog)) { cycleRecord.executorAttemptLogs = [...(cycleRecord.executorAttemptLogs ?? []), relativeLog]; } return; } // Fallback: write the full report if logFilePath wasn't provided (synchronous adapter or legacy path) const attemptIndex = (cycleRecord.executorAttemptLogs?.length ?? 0); const execLog = attemptIndex === 0 ? cycleExecLogFile(dir, cycleRecord.cycleIndex) : path.join(dir, `cycle-${cycleRecord.cycleIndex}-exec-retry-${attemptIndex}.log`); fs.writeFileSync(execLog, execResult.report, "utf8"); const relativeLog = path.relative(dir, execLog); if (!cycleRecord.executorAttemptLogs?.includes(relativeLog)) { cycleRecord.executorAttemptLogs = [...(cycleRecord.executorAttemptLogs ?? []), relativeLog]; } cycleRecord.executorLogFile = relativeLog; cycleRecord.executorExitCode = execResult.exitCode ?? undefined; cycleRecord.executorReport = execResult.report.slice(0, 2000); } // --------------------------------------------------------------------------- // workflow start // --------------------------------------------------------------------------- export interface StartWorkflowOpts { planInput: string; // file path or "-" for stdin executor?: string; vcs?: string; maxCycles?: number; /** Start a fresh task binding for this Codex thread and repository. */ newTask?: boolean; /** Overrides CODEX_THREAD_ID for programmatic callers and tests. */ codexThreadId?: string; cwd?: string; } function validateTaskSessionHandle( executor: ExecutorKind, handle: ExecutorSessionHandle, repoRoot: string, ): void { if (handle.kind !== executor) { throw new WorkflowTaskBindingError(`Task binding contains a ${handle.kind} handle for executor ${executor}.`); } switch (handle.kind) { case "reasonix": validateReasonixSessionForRepository(handle.sessionFilePath, repoRoot); return; case "claude": validateClaudeSessionForRepository(handle.sessionId, repoRoot); return; case "agy": if (handle.degraded || !handle.conversationId || !/^[0-9a-f-]{36}$/i.test(handle.conversationId)) { throw new WorkflowTaskBindingError("Task binding contains an Agy session without a reliable conversation ID."); } } } function persistTaskSession(state: WorkflowState, handle: ExecutorSessionHandle): void { if (!state.taskId || !state.codexThreadId) return; try { saveTaskExecutorBinding({ taskId: state.taskId, codexThreadId: state.codexThreadId, repoRoot: state.repoRoot, executor: state.executor, handle, runId: state.runId, planTitle: state.plan.title, }); } catch (err) { process.stderr.write(`Warning: failed to update task executor binding: ${(err as Error).message}\n`); } } /** Resolve VCS selection from candidates and configs - exported for testing */ export async function resolveVcsSelection( cwd: string, explicitVcs?: VcsKind ): Promise<{ vcsKind: VcsKind; repoRoot: string; configSourceRoot?: string }> { // Discover candidates const { findVcsCandidates } = await import("./vcs-provider.js"); const candidates: { git?: string; svn?: string } = findVcsCandidates(cwd); // Load config from candidate roots plus cwd to find defaultVcs const configs = new Map(); const rootsToTry = new Set(); if (candidates.git) rootsToTry.add(candidates.git); if (candidates.svn) rootsToTry.add(candidates.svn); rootsToTry.add(cwd); // always try cwd as well for (const root of rootsToTry) { try { const config = resolveWorkflowConfig(root, {}); configs.set(root, { vcs: config.vcs, root }); } catch (err) { if (err instanceof WorkflowConfigError) { throw new WorkflowEngineError(`Invalid agent-workflow.json at ${root}: ${err.message}`); } const errCode = (err as any)?.code; if (errCode === "ENOENT") { configs.set(root, { root }); } else { throw err; } } } // If explicit VCS provided, use it directly if (explicitVcs) { const provider = createVcsProvider(explicitVcs); try { const repoRoot = provider.repoRoot(cwd); let configSourceRoot = repoRoot; if (explicitVcs === "none") { for (const root of rootsToTry) { if (fs.existsSync(path.join(root, "agent-workflow.json"))) { configSourceRoot = root; break; } } } return { vcsKind: explicitVcs, repoRoot, configSourceRoot }; } catch (err) { if (err instanceof VcsError) { throw new WorkflowEngineError(err.message); } throw new WorkflowEngineError((err as Error).message); } } // Extract project defaultVcs settings from configs const projectDefaults = Array.from(configs.values()) .map(c => c.vcs) .filter((v): v is VcsKind => v !== undefined); // Check for conflicting project defaults const uniqueDefaults = new Set(projectDefaults); if (uniqueDefaults.size > 1) { throw new WorkflowEngineError( `Conflicting VCS defaults in project configs: ${Array.from(uniqueDefaults).join(" vs ")}` ); } const projectDefault = projectDefaults[0]; if (projectDefault === "none") { let configSourceRoot = cwd; for (const c of configs.values()) { if (c.vcs === "none") { configSourceRoot = c.root; break; } } return { vcsKind: "none", repoRoot: cwd, configSourceRoot }; } if (candidates.git && candidates.svn) { // Ambiguous: need explicit selection if (projectDefault === "git") { return { vcsKind: "git", repoRoot: candidates.git, configSourceRoot: candidates.git }; } else if (projectDefault === "svn") { return { vcsKind: "svn", repoRoot: candidates.svn, configSourceRoot: candidates.svn }; } else { throw new WorkflowEngineError( "Ambiguous VCS: both Git and SVN detected. " + "Use --vcs git or --vcs svn to explicitly select, or set defaultVcs in agent-workflow.json." ); } } else if (candidates.git) { if (projectDefault === "svn") { throw new WorkflowEngineError(`Configured VCS is "svn" but no SVN working copy was detected.`); } return { vcsKind: "git", repoRoot: candidates.git, configSourceRoot: candidates.git }; } else if (candidates.svn) { if (projectDefault === "git") { throw new WorkflowEngineError(`Configured VCS is "git" but no Git repository was detected.`); } return { vcsKind: "svn", repoRoot: candidates.svn, configSourceRoot: candidates.svn }; } else { // Neither detected if (projectDefault === "git" || projectDefault === "svn") { throw new WorkflowEngineError( `Configured VCS is "${projectDefault}" but no such VCS repository was detected.` ); } return { vcsKind: "none", repoRoot: cwd, configSourceRoot: cwd }; } } export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: string }> { const cwd = opts.cwd ?? process.cwd(); // 1. Load and validate plan first let planRaw: string; if (opts.planInput === "-") { planRaw = fs.readFileSync(0, "utf8"); } else { if (!fs.existsSync(opts.planInput)) { throw new WorkflowEngineError(`Plan file not found: ${opts.planInput}`); } planRaw = fs.readFileSync(opts.planInput, "utf8"); } let plan: unknown; try { plan = JSON.parse(planRaw); } catch { throw new WorkflowEngineError("Plan is not valid JSON."); } validateWorkflowPlan(plan); const typedPlan = plan as WorkflowPlanV1; // 2. Resolve VCS selection using deterministic helper const { vcsKind, repoRoot, configSourceRoot } = await resolveVcsSelection(cwd, opts.vcs as VcsKind | undefined); // Reject AGENT_WORKFLOW_DIR/workflow state root that is equal to or nested under target repoRoot in none mode if (vcsKind === "none") { const absRepoRoot = path.resolve(repoRoot); const absStateRoot = path.resolve(workflowsRoot()); const relative = path.relative(absRepoRoot, absStateRoot); const isNestedOrEqual = !relative.startsWith("..") && !path.isAbsolute(relative); if (isNestedOrEqual) { throw new WorkflowEngineError( `Workflow state directory (${absStateRoot}) cannot be equal to or nested under the repository root (${absRepoRoot}) in none VCS mode. ` + `Please set the AGENT_WORKFLOW_DIR environment variable to an external location.`, 4 ); } } // 3. Resolve full config from configSourceRoot or final repoRoot const config = resolveWorkflowConfig(configSourceRoot ?? repoRoot, { ...(opts.executor ? { executor: opts.executor as import("./workflow-types.js").ExecutorKind } : {}), ...(opts.vcs ? { vcs: opts.vcs as VcsKind } : {}), ...(opts.maxCycles !== undefined ? { maxCycles: opts.maxCycles } : {}), }); const vcsProvider = createVcsProvider(vcsKind); const codexThreadId = (opts.codexThreadId ?? process.env.CODEX_THREAD_ID)?.trim(); if (opts.newTask && !codexThreadId) { throw new WorkflowEngineError("--new-task requires CODEX_THREAD_ID to identify the owning Codex session.", 4); } let existingTaskBinding; let taskSessionHandle: ExecutorSessionHandle | undefined; if (codexThreadId && !opts.newTask) { try { existingTaskBinding = readTaskBinding(repoRoot, codexThreadId); taskSessionHandle = existingTaskBinding?.executors[config.executor]?.handle; if (taskSessionHandle) validateTaskSessionHandle(config.executor, taskSessionHandle, repoRoot); } catch (err) { throw new WorkflowEngineError( `Cannot reuse the current task's ${config.executor} session: ${(err as Error).message} ` + "Start with --new-task only when this is intentionally a new task.", 4, ); } } // 4. Probe executor await probeExecutor(config.executor, config.executorConfig); // 5. Require clean working tree (skipped in none mode) if (vcsKind !== "none") { let isClean: boolean; try { isClean = vcsProvider.isClean(repoRoot); } catch (err) { if (err instanceof VcsError) { throw new WorkflowEngineError(err.message); } throw new WorkflowEngineError((err as Error).message); } if (!isClean) { throw new WorkflowEngineError( "Working tree has uncommitted changes. Commit or stash them before starting a workflow.", 4, ); } } // 6. Acquire lock (one active workflow per repo) const runId = generateRunId(); try { acquireLock(repoRoot, runId); } catch (err) { if (err instanceof WorkflowLockError) { throw new WorkflowEngineError(err.message, 4); } throw err; } const dir = makeRunDir(repoRoot, runId); fs.mkdirSync(dir, { recursive: true }); // 7. Capture baseline let vcsBaseline: VcsBaseline; try { vcsBaseline = vcsProvider.captureBaseline(repoRoot, dir); } catch (err) { try { releaseLock(repoRoot, runId); } catch {} if (err instanceof VcsError) { throw new WorkflowEngineError(err.message); } throw new WorkflowEngineError((err as Error).message); } const state = initWorkflowState({ runId, repoRoot, vcsBaseline, vcsKind, executor: config.executor, plan: typedPlan, maxCycles: config.maxCycles, timeoutSeconds: config.timeoutSeconds, }); state.executorConfig = config.executorConfig; state.timeoutSeconds = config.timeoutSeconds; state.enginePid = process.pid; state.status = "executing"; if (codexThreadId) { try { const binding = opts.newTask || !existingTaskBinding ? getOrCreateTaskBinding({ repoRoot, codexThreadId, planTitle: typedPlan.title, newTask: opts.newTask, }) : existingTaskBinding; state.taskId = binding.taskId; state.codexThreadId = codexThreadId; state.taskSessionReused = !!taskSessionHandle; if (taskSessionHandle) state.sessionHandle = taskSessionHandle; } catch (err) { releaseLock(repoRoot, runId); throw new WorkflowEngineError(`Cannot initialize Codex task binding: ${(err as Error).message}`, 4); } } writeState(dir, state); appendEvent(dir, { kind: "workflow_started", runId, timestamp: state.createdAt, data: { executor: config.executor, maxCycles: config.maxCycles, vcsKind, vcsBaseline, ...(state.taskId ? { taskId: state.taskId, taskSessionReused: state.taskSessionReused } : {}), }, }); process.stdout.write(`workflow started: ${runId}\n`); process.stdout.write(` vcs: ${vcsKind}\n`); process.stdout.write(` executor: ${config.executor}\n`); process.stdout.write(` max-cycles: ${config.maxCycles}\n`); if (state.taskId) { process.stdout.write(` task: ${state.taskId}${state.taskSessionReused ? " (session reused)" : ""}\n`); } if (vcsBaseline.kind === "git") { process.stdout.write(` baseline: ${vcsBaseline.head}\n`); } else if (vcsBaseline.kind === "svn") { process.stdout.write(` baseline: r${vcsBaseline.revision}\n`); } else if (vcsBaseline.kind === "none") { process.stdout.write(` baseline: snapshot\n`); } process.stdout.write(` run dir: ${dir}\n\n`); // 7. Start first execution cycle const cycleIndex = 1; const cycleRecord: CycleRecord = { cycleIndex, startedAt: nowIso() }; state.cycles.push(cycleRecord); state.currentCycle = cycleIndex; state.status = "executing"; state.updatedAt = nowIso(); writeState(dir, state); appendEvent(dir, { kind: "cycle_started", runId, timestamp: nowIso(), cycleIndex }); appendEvent(dir, { kind: "executor_started", runId, timestamp: nowIso(), cycleIndex, data: { executor: config.executor, sessionMode: taskSessionHandle ? "task_reuse" : "new" }, }); if (taskSessionHandle) { appendEvent(dir, { kind: "executor_session_reused", runId, timestamp: nowIso(), cycleIndex, data: { executor: config.executor, taskId: state.taskId }, }); } const adapter = createExecutorAdapter(config.executor); const { ac, markActivity, cleanup } = createManagedController(state.timeoutSeconds); // Create attempt log file BEFORE spawning the executor const execLogPath = path.join(dir, `cycle-${cycleIndex}-exec.log`); fs.writeFileSync(execLogPath, "", "utf8"); registerAttemptLog(dir, cycleRecord, execLogPath); writeState(dir, state); // persist so retry can discover the log let execResult; try { const commonOpts = { repoRoot, plan: typedPlan, runDir: dir, cycleIndex, logFilePath: execLogPath, config: config.executorConfig, timeoutSeconds: state.timeoutSeconds, signal: ac.signal, onActivity: markActivity, vcsKind, }; execResult = taskSessionHandle ? await adapter.resume({ ...commonOpts, handle: taskSessionHandle, acceptedFindings: "", purpose: "task_plan", }) : await adapter.start(commonOpts); } catch (err) { cleanup(); transitionToTerminal(state, dir, "blocked", `Executor failed to start: ${(err as Error).message}`); throw new WorkflowEngineError(`Executor error: ${(err as Error).message}`); } cleanup(); persistExecutorAttempt(dir, cycleRecord, execResult, execLogPath); if (execResult.sessionHandle) { state.sessionHandle = execResult.sessionHandle; persistTaskSession(state, execResult.sessionHandle); if (execResult.sessionHandle.kind === "agy") { state.agyDegraded = execResult.sessionHandle.degraded; } } appendEvent(dir, { kind: "executor_completed", runId, timestamp: nowIso(), cycleIndex, data: { exitCode: execResult.exitCode, durationMs: execResult.durationMs, failed: execResult.failed, ...(execResult.failureReason ? { failureReason: execResult.failureReason } : {}), }, }); if (execResult.failed) { transitionToTerminal(state, dir, "blocked", `Executor exited with failure: ${execResult.failureReason ?? "exit code " + String(execResult.exitCode)}`); throw new WorkflowEngineError("Executor failed."); } // Transition to awaiting_review state.status = "awaiting_review"; state.enginePid = undefined; state.updatedAt = nowIso(); writeStateSafety(dir, state); process.stdout.write(`cycle ${cycleIndex} execution completed\n`); process.stdout.write(`status: awaiting_review\n`); process.stdout.write(`next: agent-workflow review --run ${runId}\n`); emitWorkflowMeta({ runId, status: state.status, cycleIndex, executor: config.executor }); return { runId }; } // --------------------------------------------------------------------------- // workflow review // --------------------------------------------------------------------------- export interface ReviewWorkflowOpts { runId: string; cwd?: string; } export async function reviewWorkflow(opts: ReviewWorkflowOpts): Promise { const { state, dir } = requireState(opts.runId); if (state.status !== "awaiting_review") { throw new WorkflowEngineError( `Run '${opts.runId}' is in status '${state.status}', not awaiting_review.`, 2, ); } const cycleIndex = state.currentCycle; const cycleRecord = state.cycles.find((c) => c.cycleIndex === cycleIndex)!; state.enginePid = process.pid; writeStateSafety(dir, state); // Get VCS provider const vcsKind = state.vcsKind || "git"; const vcsProvider = createVcsProvider(vcsKind); const baseline = state.vcsBaseline || (state.baselineHead ? { kind: "git" as const, head: state.baselineHead } : undefined); if (!baseline) { throw new WorkflowEngineError("No baseline found in state (corrupt state file)."); } // 1. Generate diff from baseline let diff: string; try { diff = vcsProvider.diffFromBaseline(state.repoRoot, baseline, dir); } catch (err) { if (err instanceof VcsError) { transitionToTerminal(state, dir, "blocked", `Failed to generate diff: ${err.message}`); throw new WorkflowEngineError(err.message); } throw err; } const diffFile = cycleDiffFile(dir, cycleIndex); fs.writeFileSync(diffFile, diff, "utf8"); cycleRecord.diffFile = path.relative(dir, diffFile); // 2. Check for baseline drift try { vcsProvider.validateBaseline(state.repoRoot, baseline, dir); } catch (err) { if (err instanceof VcsError) { transitionToTerminal( state, dir, "blocked", `Baseline validation failed: ${err.message}`, ); throw new WorkflowEngineError(err.message); } throw err; } // 3. Check out-of-scope files let outOfScope: string[]; try { outOfScope = vcsProvider.checkFilesInScope(state.repoRoot, baseline, state.plan.scope, dir); } catch (err) { if (err instanceof VcsError) { transitionToTerminal(state, dir, "blocked", `Failed to check scope: ${err.message}`); throw new WorkflowEngineError(err.message); } throw err; } if (outOfScope.length > 0) { const scopeViolations = makeScopeViolations(outOfScope); const previousCycle = state.cycles.find((cycle) => cycle.cycleIndex === cycleIndex - 1); if (previousCycle && sameScopeViolations(previousCycle.scopeViolations, scopeViolations)) { transitionToTerminal( state, dir, "needs_human", `Scope remediation did not converge: ${outOfScope.sort().join(", ")}`, { cycleIndex, scopeViolations }, ); emitWorkflowMeta({ runId: state.runId, status: "needs_human", cycleIndex, scopeViolations }); throw new WorkflowEngineError("Scope remediation did not converge.", 3); } cycleRecord.scopeViolations = scopeViolations; state.status = "awaiting_scope_resolution"; state.enginePid = undefined; state.updatedAt = nowIso(); writeStateSafety(dir, state); appendEvent(dir, { kind: "scope_violation_detected", runId: state.runId, timestamp: state.updatedAt, cycleIndex, data: { scopeViolations }, }); process.stdout.write(`scope violations detected (cycle ${cycleIndex})\n`); for (const violation of scopeViolations) { process.stdout.write(` ${violation.id}: ${violation.path}\n`); } process.stdout.write(`next: agent-workflow decide --run ${opts.runId} --input \n`); emitWorkflowMeta({ runId: state.runId, status: state.status, cycleIndex, scopeViolations, }); return; } const generatedAt = nowIso(); appendEvent(dir, { kind: "review_started", runId: state.runId, timestamp: generatedAt, cycleIndex }); // 4. Persist all evidence needed by the Codex host. The CLI does not invoke // another reviewer: Codex owns review and final acceptance. const hostReviewFile = cycleHostReviewFile(dir, cycleIndex); const bundle: HostReviewBundleV1 = { version: "1", runId: state.runId, cycleIndex, repoRoot: state.repoRoot, ...(state.baselineHead ? { baselineHead: state.baselineHead } : {}), ...(state.vcsBaseline ? { vcsBaseline: state.vcsBaseline } : {}), ...(state.vcsKind ? { vcsKind: state.vcsKind } : {}), generatedAt, plan: state.plan, diffFile: diffFile, ...(cycleRecord.executorLogFile ? { executorLogFile: path.join(dir, cycleRecord.executorLogFile) } : {}), ...(cycleRecord.executorReport ? { executorReport: cycleRecord.executorReport } : {}), verificationCommands: state.plan.verificationCommands, }; fs.writeFileSync(hostReviewFile, JSON.stringify(bundle, null, 2), "utf8"); cycleRecord.hostReviewFile = path.relative(dir, hostReviewFile); cycleRecord.hostReviewReadyAt = generatedAt; appendEvent(dir, { kind: "review_completed", runId: state.runId, timestamp: generatedAt, cycleIndex, data: { hostReviewFile, diffFile }, }); state.status = "awaiting_host"; state.enginePid = undefined; state.updatedAt = generatedAt; writeStateSafety(dir, state); process.stdout.write(`Codex review bundle ready (cycle ${cycleIndex})\n`); process.stdout.write(` bundle: ${hostReviewFile}\n`); process.stdout.write(` diff: ${diffFile}\n`); process.stdout.write(`next: agent-workflow decide --run ${opts.runId} --input \n`); emitWorkflowMeta({ runId: state.runId, status: state.status, cycleIndex, hostReviewFile, diffFile, }); } // --------------------------------------------------------------------------- // workflow decide // --------------------------------------------------------------------------- export interface DecideWorkflowOpts { runId: string; decisionInput: string; // file path or "-" for stdin cwd?: string; } export async function decideWorkflow(opts: DecideWorkflowOpts): Promise { const { state, dir } = requireState(opts.runId); const resolvingScope = state.status === "awaiting_scope_resolution"; if (state.status !== "awaiting_host" && !resolvingScope) { throw new WorkflowEngineError( `Run '${opts.runId}' is in status '${state.status}', not awaiting_host or awaiting_scope_resolution.`, 2, ); } // Load decision let decisionRaw: string; if (opts.decisionInput === "-") { decisionRaw = fs.readFileSync(0, "utf8"); } else { if (!fs.existsSync(opts.decisionInput)) { throw new WorkflowEngineError(`Decision file not found: ${opts.decisionInput}`); } decisionRaw = fs.readFileSync(opts.decisionInput, "utf8"); } let decisionObj: unknown; try { decisionObj = JSON.parse(decisionRaw); } catch { throw new WorkflowEngineError("Decision is not valid JSON.", 2); } validateHostDecision(decisionObj); const decision = decisionObj as HostDecisionV1; const cycleIndex = state.currentCycle; const cycleRecord = state.cycles.find((c) => c.cycleIndex === cycleIndex)!; if (resolvingScope && decision.outcome === "accept") { throw new WorkflowEngineError("Cannot accept while scope violations remain. Use fix or needs_human.", 4); } if (resolvingScope && decision.outcome === "fix") { const expectedIds = new Set((cycleRecord.scopeViolations ?? []).map((violation) => violation.id)); const submittedIds = decision.findingDecisions.map((finding) => finding.findingId); const acceptedIds = new Set( decision.findingDecisions .filter((finding) => finding.disposition === "accept") .map((finding) => finding.findingId), ); const unknownIds = decision.findingDecisions .map((finding) => finding.findingId) .filter((id) => !expectedIds.has(id)); const missingIds = [...expectedIds].filter((id) => !acceptedIds.has(id)); const duplicateIds = submittedIds.filter((id, index) => submittedIds.indexOf(id) !== index); if (expectedIds.size === 0 || unknownIds.length > 0 || missingIds.length > 0 || duplicateIds.length > 0) { throw new WorkflowEngineError( `Scope fix must accept every current violation exactly once. Missing: ${missingIds.join(", ") || "none"}; unknown: ${unknownIds.join(", ") || "none"}; duplicate: ${[...new Set(duplicateIds)].join(", ") || "none"}.`, 4, ); } } if (!resolvingScope && decision.outcome === "fix") { const accepted = decision.findingDecisions.filter((finding) => finding.disposition === "accept"); if (accepted.length === 0) { throw new WorkflowEngineError("Fix decision must accept at least one Codex finding.", 4); } const missingSummary = accepted .filter((finding) => !finding.summary?.trim()) .map((finding) => finding.findingId); if (missingSummary.length > 0) { throw new WorkflowEngineError( `Accepted Codex findings require a non-empty summary: ${missingSummary.join(", ")}.`, 4, ); } } if (!resolvingScope && decision.outcome === "accept" && decision.findingDecisions.some((finding) => finding.disposition === "accept")) { throw new WorkflowEngineError("Accept decision cannot contain findings accepted for fixing.", 4); } // Save decision file const decFile = cycleDecisionFile(dir, cycleIndex); fs.writeFileSync(decFile, JSON.stringify(decision, null, 2), "utf8"); cycleRecord.decisionFile = path.relative(dir, decFile); cycleRecord.decisionOutcome = decision.outcome; cycleRecord.completedAt = nowIso(); appendEvent(dir, { kind: "decision_received", runId: state.runId, timestamp: nowIso(), cycleIndex, data: { outcome: decision.outcome, reason: decision.reason }, }); // Handle decision outcomes if (decision.outcome === "needs_human") { transitionToTerminal( state, dir, "needs_human", decision.reason ?? "Codex determined human intervention is required.", { cycleIndex }, ); process.stdout.write(`workflow stopped: needs_human\n`); process.stdout.write(`reason: ${decision.reason ?? "human intervention required"}\n`); emitWorkflowMeta({ runId: state.runId, status: "needs_human", cycleIndex }); process.exit(3); } if (decision.outcome === "accept") { // Validate accept conditions const acceptError = validateAcceptConditions(state, dir, cycleIndex, decision); if (acceptError) { throw new WorkflowEngineError(`Cannot accept: ${acceptError}`, 4); } transitionToTerminal(state, dir, "completed", "Codex accepted — all conditions met.", { cycleIndex }); process.stdout.write(`workflow completed: accepted\n`); emitWorkflowMeta({ runId: state.runId, status: "completed", cycleIndex }); process.exit(0); } // decision.outcome === "fix" — start next cycle const nextCycle = cycleIndex + 1; if (nextCycle > state.maxCycles) { transitionToTerminal( state, dir, "budget_exhausted", `Reached maximum cycles (${state.maxCycles}). Further execution not allowed.`, ); process.stdout.write(`workflow stopped: budget_exhausted (max cycles: ${state.maxCycles})\n`); emitWorkflowMeta({ runId: state.runId, status: "budget_exhausted", cycleIndex }); process.exit(1); } // Check convergence using finding signatures from all cycles with review data const convergenceError = resolvingScope ? undefined : checkWorkflowConvergence(state, dir); if (convergenceError) { transitionToTerminal(state, dir, "needs_human", convergenceError, { cycleIndex }); process.stdout.write(`workflow stopped: needs_human (non-convergence detected)\n`); process.stdout.write(`reason: ${convergenceError}\n`); emitWorkflowMeta({ runId: state.runId, status: "needs_human", cycleIndex }); process.exit(3); } // Build fix prompt from accepted findings const acceptedFindings = resolvingScope ? buildScopeResolutionText(cycleRecord.scopeViolations ?? []) : buildAcceptedFindingsText(decision, state, dir, cycleIndex); // Start next cycle const nextCycleRecord: CycleRecord = { cycleIndex: nextCycle, startedAt: nowIso() }; state.cycles.push(nextCycleRecord); state.currentCycle = nextCycle; state.status = "executing"; state.updatedAt = nowIso(); state.enginePid = process.pid; writeStateSafety(dir, state); appendEvent(dir, { kind: "cycle_started", runId: state.runId, timestamp: nowIso(), cycleIndex: nextCycle }); if (resolvingScope) { appendEvent(dir, { kind: "scope_resolution_started", runId: state.runId, timestamp: nowIso(), cycleIndex: nextCycle, data: { scopeViolations: cycleRecord.scopeViolations }, }); } await resumeExecutorCycle(state, dir, nextCycleRecord, acceptedFindings); } async function resumeExecutorCycle( state: WorkflowState, dir: string, cycleRecord: CycleRecord, acceptedFindings: string, ): Promise { const cycleIndex = cycleRecord.cycleIndex; if (!state.sessionHandle) { transitionToTerminal(state, dir, "blocked", "No session handle available for resume.", { cycleIndex }); throw new WorkflowEngineError("Cannot resume: no session handle. Workflow blocked."); } const adapter = createExecutorAdapter(state.executor); const { ac, markActivity, cleanup } = createManagedController(state.timeoutSeconds); // Determine attempt index and create log file before spawning const priorLogs = cycleRecord.executorAttemptLogs ?? (cycleRecord.executorLogFile ? [cycleRecord.executorLogFile] : []); const attemptIndex = priorLogs.length; const execLogPath = attemptIndex === 0 ? path.join(dir, `cycle-${cycleIndex}-exec.log`) : path.join(dir, `cycle-${cycleIndex}-exec-retry-${attemptIndex}.log`); fs.writeFileSync(execLogPath, "", "utf8"); registerAttemptLog(dir, cycleRecord, execLogPath); writeState(dir, state); // persist so retry can discover the log let execResult; try { execResult = await adapter.resume({ repoRoot: state.repoRoot, plan: state.plan, acceptedFindings, handle: state.sessionHandle, runDir: dir, cycleIndex, logFilePath: execLogPath, config: state.executorConfig ?? {}, timeoutSeconds: state.timeoutSeconds, signal: ac.signal, onActivity: markActivity, vcsKind: state.vcsKind || "git", }); } catch (err) { cleanup(); transitionToTerminal( state, dir, "blocked", `Executor resume failed: ${(err as Error).message}`, { cycleIndex }, ); throw new WorkflowEngineError(`Executor resume error: ${(err as Error).message}`); } cleanup(); persistExecutorAttempt(dir, cycleRecord, execResult, execLogPath); if (execResult.sessionHandle) { state.sessionHandle = execResult.sessionHandle; persistTaskSession(state, execResult.sessionHandle); if (execResult.sessionHandle.kind === "agy") { state.agyDegraded = execResult.sessionHandle.degraded; } } appendEvent(dir, { kind: "executor_completed", runId: state.runId, timestamp: nowIso(), cycleIndex, data: { exitCode: execResult.exitCode, durationMs: execResult.durationMs, failed: execResult.failed, ...(execResult.failureReason ? { failureReason: execResult.failureReason } : {}), }, }); if (execResult.failed) { transitionToTerminal( state, dir, "blocked", `Executor failed to resume: ${execResult.failureReason ?? "exit code " + String(execResult.exitCode)}`, { cycleIndex }, ); throw new WorkflowEngineError("Executor failed."); } state.status = "awaiting_review"; state.enginePid = undefined; state.updatedAt = nowIso(); writeStateSafety(dir, state); process.stdout.write(`cycle ${cycleIndex} execution completed\n`); process.stdout.write(`status: awaiting_review\n`); process.stdout.write(`next: agent-workflow review --run ${state.runId}\n`); emitWorkflowMeta({ runId: state.runId, status: state.status, cycleIndex }); } function validateAcceptConditions(state: WorkflowState, dir: string, cycleIndex: number, decision: HostDecisionV1): string | undefined { // 1. Codex must have received a persisted review bundle for this cycle. const cycle = state.cycles.find((c) => c.cycleIndex === cycleIndex); if (!cycle?.hostReviewFile) return "No Codex review bundle for this cycle."; if (!fs.existsSync(path.join(dir, cycle.hostReviewFile))) { return "Codex review bundle is missing from disk."; } // 2. Baseline must not have changed const vcsKind = state.vcsKind || "git"; const vcsProvider = createVcsProvider(vcsKind); const baseline = state.vcsBaseline || (state.baselineHead ? { kind: "git" as const, head: state.baselineHead } : undefined); if (!baseline) { return "No baseline found in state."; } try { vcsProvider.validateBaseline(state.repoRoot, baseline, dir); } catch (err) { if (err instanceof VcsError) { return err.message; } return `Baseline validation failed: ${(err as Error).message}`; } // 3. All files must be in scope let outOfScope: string[]; try { outOfScope = vcsProvider.checkFilesInScope(state.repoRoot, baseline, state.plan.scope, dir); } catch (err) { if (err instanceof VcsError) { return `Scope check failed: ${err.message}`; } return `Scope check failed: ${(err as Error).message}`; } if (outOfScope.length > 0) { return `Out-of-scope files: ${outOfScope.join(", ")}`; } // 4. Verification commands must have evidence and succeed if (state.plan.verificationCommands && state.plan.verificationCommands.length > 0) { if (!decision.verificationEvidence || decision.verificationEvidence.length === 0) { return "Accept decision requires verificationEvidence when plan specifies verificationCommands."; } for (const cmd of state.plan.verificationCommands) { if (!cmd || cmd.length === 0) continue; try { execFileSync(cmd[0], cmd.slice(1), { cwd: state.repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: (state.timeoutSeconds ?? 300) * 1000, }); } catch (err: any) { return `Verification command failed: ${cmd.join(" ")}\nError: ${err.message}`; } } } return undefined; } function checkWorkflowConvergence(state: WorkflowState, dir: string): string | undefined { const history: FindingSignature[][] = []; for (const cycle of state.cycles) { if (!cycle.decisionFile) continue; const decisionPath = path.join(dir, cycle.decisionFile); if (!fs.existsSync(decisionPath)) continue; try { const priorDecision = JSON.parse(fs.readFileSync(decisionPath, "utf8")) as HostDecisionV1; history.push(decisionSignatures(priorDecision.findingDecisions)); } catch { /* skip corrupt files */ } } const result = checkConvergence(history); if (!result.converging) { if (result.persistentFindings?.length) { return `Findings not converging: the same ${result.persistentFindings.length} issue(s) persist across consecutive cycles. Human review required.`; } if (result.oscillatingFindings?.length) { return `Findings oscillating: ${result.oscillatingFindings.length} issue(s) disappeared and reappeared. Human review required.`; } return "Findings not converging. Human review required."; } return undefined; } function buildAcceptedFindingsText( decision: HostDecisionV1, _state: WorkflowState, _dir: string, _cycleIndex: number, ): string { const accepted = decision.findingDecisions.filter((d) => d.disposition === "accept"); const lines = ["# Codex Review Findings", "", "Fix only the following issues accepted by the Codex host:"]; for (const d of accepted) { lines.push(`\n### Finding ${d.findingId}`); if (d.path) lines.push(`- Path: ${d.path}`); lines.push(`- Issue: ${d.summary}`); if (d.rationale) lines.push(`- Rationale: ${d.rationale}`); } return lines.join("\n"); } function buildScopeResolutionText(violations: ScopeViolation[]): string { const lines = [ "# Scope Remediation", "", "The frozen scope gate found these exact out-of-scope paths:", ]; for (const violation of violations) { lines.push(`- ${violation.id}: ${violation.path}`); } lines.push( "", "Inspect every listed path. Remove only temporary artifacts you created. If a path contains an intentional change, restore it to the baseline behavior without using git reset, git checkout, or git restore.", "Do not modify any other out-of-scope path and do not broaden the plan scope.", "Use an OS temporary directory for further investigation. For SQLite files, clean the database plus -wal, -shm, and -journal sidecars.", "Before finishing, run git status --porcelain --untracked-files=all and confirm no path outside the allowed scope remains.", ); return lines.join("\n"); } // --------------------------------------------------------------------------- // workflow retry-review // --------------------------------------------------------------------------- export interface RetryReviewWorkflowOpts { runId: string; } export function retryReviewWorkflow(opts: RetryReviewWorkflowOpts): void { const { state, dir } = requireState(opts.runId); if (state.status !== "awaiting_scope_resolution") { throw new WorkflowEngineError( `Run '${opts.runId}' cannot retry review from status '${state.status}'. Only scope-resolution runs are eligible.`, 4, ); } const vcsKind = state.vcsKind || "git"; const vcsProvider = createVcsProvider(vcsKind); const baseline = state.vcsBaseline || (state.baselineHead ? { kind: "git" as const, head: state.baselineHead } : undefined); if (!baseline) { throw new WorkflowEngineError("No baseline found in state."); } try { vcsProvider.validateBaseline(state.repoRoot, baseline, dir); } catch (err) { if (err instanceof VcsError) { throw new WorkflowEngineError(err.message, 4); } throw err; } let outOfScope: string[]; try { outOfScope = vcsProvider.checkFilesInScope(state.repoRoot, baseline, state.plan.scope, dir); } catch (err) { if (err instanceof VcsError) { throw new WorkflowEngineError(err.message, 4); } throw err; } if (outOfScope.length > 0) { throw new WorkflowEngineError( `Cannot retry review while out-of-scope files remain: ${outOfScope.sort().join(", ")}.`, 4, ); } const cycle = state.cycles.find((record) => record.cycleIndex === state.currentCycle); if (cycle) { delete cycle.scopeViolations; delete cycle.hostReviewFile; delete cycle.hostReviewReadyAt; } state.status = "awaiting_review"; state.enginePid = undefined; delete state.stopReason; delete state.stopDescription; state.updatedAt = nowIso(); writeState(dir, state); appendEvent(dir, { kind: "review_retried", runId: state.runId, timestamp: state.updatedAt, cycleIndex: state.currentCycle, data: { previousStatus: "awaiting_scope_resolution", }, }); process.stdout.write(`workflow review retry enabled: ${state.runId}\n`); process.stdout.write(`status: awaiting_review\n`); process.stdout.write(`next: agent-workflow review --run ${state.runId}\n`); emitWorkflowMeta({ runId: state.runId, status: state.status, cycleIndex: state.currentCycle }); } // --------------------------------------------------------------------------- // workflow retry-execute // --------------------------------------------------------------------------- function isProcessAlive(pid: number): boolean { try { process.kill(pid, 0); return true; } catch (err) { return (err as NodeJS.ErrnoException).code === "EPERM"; } } export interface RetryExecuteWorkflowOpts { runId: string; sessionId?: string; } export async function retryExecuteWorkflow(opts: RetryExecuteWorkflowOpts): Promise { const { state, dir } = requireState(opts.runId); if (opts.sessionId && state.executor !== "claude") { throw new WorkflowEngineError("--session can only be used with a Claude executor run.", 4); } const staleExecution = state.status === "executing" && state.enginePid !== undefined && !isProcessAlive(state.enginePid); const retryableFailure = staleExecution || (state.status === "blocked" && (state.stopDescription?.startsWith("Executor exited with failure:") === true || state.stopDescription?.startsWith("Executor failed to resume:") === true || state.stopDescription?.startsWith("Executor resume failed:") === true || state.stopDescription === "No session handle available for resume.")); if (!retryableFailure) { throw new WorkflowEngineError( `Run '${opts.runId}' cannot retry execution from status '${state.status}'. Only failed executor runs with a resumable session are eligible.`, 4, ); } const cycle = state.cycles.find((record) => record.cycleIndex === state.currentCycle); const previousCycle = state.cycles.find((record) => record.cycleIndex === state.currentCycle - 1); if (!cycle || cycle.diffFile || cycle.hostReviewFile || cycle.decisionFile || cycle.scopeViolations) { throw new WorkflowEngineError("Cannot retry execution: the failed cycle already contains review artifacts.", 4); } const recoveredReasonixSession = !state.sessionHandle && state.executor === "reasonix" ? findReasonixSessionForWorkflow( state.repoRoot, state.plan.title, previousCycle?.startedAt ?? cycle.startedAt, previousCycle?.completedAt, ) : undefined; if (!state.sessionHandle && !opts.sessionId && !recoveredReasonixSession) { throw new WorkflowEngineError("Cannot retry execution: no executor session handle is available.", 4); } const recoveringInitialCycle = state.currentCycle === 1; if (!recoveringInitialCycle && (!previousCycle?.decisionFile || previousCycle.decisionOutcome !== "fix")) { throw new WorkflowEngineError("Cannot retry execution: the preceding fix decision is missing.", 4); } const resolvingScope = !recoveringInitialCycle && (previousCycle?.scopeViolations?.length ?? 0) > 0; const vcsKind = state.vcsKind || "git"; const vcsProvider = createVcsProvider(vcsKind); const baseline = state.vcsBaseline || (state.baselineHead ? { kind: "git" as const, head: state.baselineHead } : undefined); if (!baseline) { throw new WorkflowEngineError("No baseline found in state.", 4); } try { vcsProvider.validateBaseline(state.repoRoot, baseline, dir); } catch (err) { if (err instanceof VcsError) { throw new WorkflowEngineError(err.message, 4); } throw err; } let outOfScope: string[]; try { outOfScope = vcsProvider.checkFilesInScope(state.repoRoot, baseline, state.plan.scope, dir); } catch (err) { if (err instanceof VcsError) { throw new WorkflowEngineError(err.message, 4); } throw err; } const approvedScopePaths = new Set((previousCycle?.scopeViolations ?? []).map((violation) => violation.path)); const unexpectedOutOfScope = resolvingScope ? outOfScope.filter((filePath) => !approvedScopePaths.has(filePath)) : outOfScope; if (unexpectedOutOfScope.length > 0) { throw new WorkflowEngineError( `Cannot retry execution while unexpected out-of-scope files remain: ${unexpectedOutOfScope.sort().join(", ")}.`, 4, ); } if (opts.sessionId) { try { validateClaudeSessionForWorkflow(opts.sessionId, state.repoRoot, state.plan.title); } catch (err) { throw new WorkflowEngineError(`Cannot rebind Claude session: ${(err as Error).message}`, 4); } } let acceptedFindings: string; if (recoveringInitialCycle) { acceptedFindings = "# Execution Recovery\n\nContinue the original implementation from the current working tree."; } else { const decisionPath = path.join(dir, previousCycle!.decisionFile!); let decision: HostDecisionV1; try { const raw = JSON.parse(fs.readFileSync(decisionPath, "utf8")) as unknown; validateHostDecision(raw); decision = raw; } catch (err) { throw new WorkflowEngineError(`Cannot retry execution: failed to read preceding decision: ${(err as Error).message}`, 4); } acceptedFindings = resolvingScope ? buildScopeResolutionText(previousCycle!.scopeViolations ?? []) : buildAcceptedFindingsText(decision, state, dir, previousCycle!.cycleIndex); } try { acquireLock(state.repoRoot, state.runId); } catch (err) { if (err instanceof WorkflowLockError) throw new WorkflowEngineError(err.message, 4); throw err; } const previousSessionId = state.sessionHandle?.kind === "claude" ? state.sessionHandle.sessionId : undefined; if (opts.sessionId) { state.sessionHandle = { kind: "claude", sessionId: opts.sessionId }; } else if (recoveredReasonixSession) { state.sessionHandle = { kind: "reasonix", sessionFilePath: recoveredReasonixSession }; } state.status = "executing"; delete state.stopReason; delete state.stopDescription; state.enginePid = process.pid; state.updatedAt = nowIso(); writeState(dir, state); if (opts.sessionId) { appendEvent(dir, { kind: "executor_session_rebound", runId: state.runId, timestamp: state.updatedAt, cycleIndex: state.currentCycle, data: { previousSessionId, sessionId: opts.sessionId }, }); } else if (recoveredReasonixSession) { appendEvent(dir, { kind: "executor_session_rebound", runId: state.runId, timestamp: state.updatedAt, cycleIndex: state.currentCycle, data: { sessionFilePath: recoveredReasonixSession, recovery: "reasonix_project_session" }, }); } // Determine recovery mode: stale_execution trumps initial_continuation let recoveryMode: string; if (staleExecution) { recoveryMode = "stale_execution"; } else if (recoveringInitialCycle) { recoveryMode = "initial_continuation"; } else { recoveryMode = "fix_resume"; } appendEvent(dir, { kind: "executor_retried", runId: state.runId, timestamp: state.updatedAt, cycleIndex: state.currentCycle, data: { executor: state.executor, recoveryMode, }, }); const sessionMode = opts.sessionId || recoveredReasonixSession ? "recovered" : "existing"; process.stdout.write(`retrying executor cycle ${state.currentCycle} with the ${sessionMode} ${state.executor} session\n`); await resumeExecutorCycle(state, dir, cycle, acceptedFindings); } // --------------------------------------------------------------------------- // workflow extend // --------------------------------------------------------------------------- export interface ExtendWorkflowOpts { runId: string; additionalCycles: number; } export async function extendWorkflow(opts: ExtendWorkflowOpts): Promise { // 1. Validate positive integer if (!Number.isInteger(opts.additionalCycles) || opts.additionalCycles < 1) { throw new WorkflowEngineError( `--additional-cycles must be a positive integer, got: ${opts.additionalCycles}`, 4, ); } const { state, dir } = requireState(opts.runId); // 2. Must be in budget_exhausted terminal state if (state.status !== "budget_exhausted") { throw new WorkflowEngineError( `Run '${opts.runId}' is in status '${state.status}', not budget_exhausted. ` + `Only budget_exhausted runs can be extended.`, 4, ); } // 3. Must have a valid session handle if (!state.sessionHandle) { throw new WorkflowEngineError( `Run '${opts.runId}' has no session handle. Cannot extend without a resumable session.`, 4, ); } // 4. The last persisted decision must be 'fix' const lastCycle = state.cycles[state.cycles.length - 1]; if (!lastCycle?.decisionFile || lastCycle.decisionOutcome !== "fix") { throw new WorkflowEngineError( `Run '${opts.runId}' cannot be extended: the last cycle decision must be 'fix'.`, 4, ); } // 5. Reacquire the repository lock BEFORE validation try { acquireLock(state.repoRoot, state.runId); } catch (err) { if (err instanceof WorkflowLockError) { throw new WorkflowEngineError(err.message, 4); } throw err; } // Helper to release lock and throw const failAndRelease = (message: string, exitCode = 4): never => { releaseLock(state.repoRoot, state.runId); throw new WorkflowEngineError(message, exitCode); }; // 6. Validate baseline and scope under the lock const vcsKind = state.vcsKind || "git"; const vcsProvider = createVcsProvider(vcsKind); const baseline = state.vcsBaseline || (state.baselineHead ? { kind: "git" as const, head: state.baselineHead } : undefined); if (!baseline) { failAndRelease("No baseline found in state."); return; // Never reached but helps TypeScript } try { vcsProvider.validateBaseline(state.repoRoot, baseline, dir); } catch (err) { const message = err instanceof VcsError ? err.message : (err as Error).message; failAndRelease(`Cannot extend: baseline validation failed: ${message}`); return; } let outOfScope: string[] = []; try { outOfScope = vcsProvider.checkFilesInScope(state.repoRoot, baseline, state.plan.scope, dir); } catch (err) { const message = err instanceof VcsError ? err.message : (err as Error).message; failAndRelease(`Cannot extend: scope check failed: ${message}`); return; } if (outOfScope.length > 0) { failAndRelease(`Cannot extend: out-of-scope files detected: ${outOfScope.join(", ")}`); return; } // 7. Check convergence using existing history const convergenceError = checkWorkflowConvergence(state, dir); if (convergenceError) { failAndRelease(`Cannot extend: ${convergenceError}`); return; } // 8. Load and validate the last decision BEFORE mutating state const decisionPath = path.join(dir, lastCycle.decisionFile!); let decision: HostDecisionV1; let acceptedFindings: string = ""; try { const raw = JSON.parse(fs.readFileSync(decisionPath, "utf8")) as unknown; validateHostDecision(raw); decision = raw; acceptedFindings = buildAcceptedFindingsText(decision, state, dir, lastCycle.cycleIndex); } catch (err) { failAndRelease(`Cannot extend: failed to read last decision: ${(err as Error).message}`); return; } // 9. Now atomically mutate state: budget, cycle, status, history const oldMaxCycles = state.maxCycles; const newMaxCycles = oldMaxCycles + opts.additionalCycles; const nextCycle = state.currentCycle + 1; const extendedAt = nowIso(); state.maxCycles = newMaxCycles; state.enginePid = process.pid; state.updatedAt = extendedAt; state.status = "executing"; delete state.stopReason; delete state.stopDescription; const nextCycleRecord: CycleRecord = { cycleIndex: nextCycle, startedAt: extendedAt }; state.cycles.push(nextCycleRecord); state.currentCycle = nextCycle; // Record extension history in state if (!state.extensionHistory) { state.extensionHistory = []; } state.extensionHistory.push({ extendedAt, oldMaxCycles, newMaxCycles, additionalCycles: opts.additionalCycles, resumedCycle: nextCycle, }); // 10. Persist state and events writeState(dir, state); appendEvent(dir, { kind: "budget_extended", runId: state.runId, timestamp: extendedAt, data: { additionalCycles: opts.additionalCycles, oldMaxCycles, newMaxCycles, nextCycle, }, }); appendEvent(dir, { kind: "cycle_started", runId: state.runId, timestamp: nowIso(), cycleIndex: nextCycle }); process.stdout.write(`workflow extended: ${state.runId}\n`); process.stdout.write(` additional cycles: ${opts.additionalCycles}\n`); process.stdout.write(` old max cycles: ${oldMaxCycles}\n`); process.stdout.write(` new max cycles: ${newMaxCycles}\n`); process.stdout.write(` resuming cycle: ${nextCycle}\n\n`); // 11. Resume the executor session immediately (lock released by resumeExecutorCycle on completion) try { await resumeExecutorCycle(state, dir, nextCycleRecord, acceptedFindings); } catch (err) { // resumeExecutorCycle already handles errors and releases lock throw err; } } // --------------------------------------------------------------------------- // workflow status // --------------------------------------------------------------------------- export interface StatusWorkflowOpts { runId: string; json?: boolean; } export function statusWorkflow(opts: StatusWorkflowOpts): void { const { state, dir } = requireState(opts.runId); const cycle = state.cycles.find((c) => c.cycleIndex === state.currentCycle); // Read live progress sidecar for real-time observability const sidecar = readProgressSidecar(dir); const liveLogFile = cycle?.executorLogFile ? path.join(dir, cycle.executorLogFile) : sidecar?.logFilePath ? path.join(dir, sidecar.logFilePath) : undefined; let executionElapsed: string | undefined; if (sidecar?.executionDurationMs !== undefined) { const ms = sidecar.executionDurationMs; const secs = Math.floor(ms / 1000); if (secs < 60) executionElapsed = `${secs}s`; else executionElapsed = `${Math.floor(secs / 60)}m ${secs % 60}s`; } else if (sidecar?.startedAt) { const ms = Date.now() - new Date(sidecar.startedAt).getTime(); const secs = Math.floor(ms / 1000); if (secs < 60) executionElapsed = `${secs}s`; else executionElapsed = `${Math.floor(secs / 60)}m ${secs % 60}s`; } const output: WorkflowStatusOutput = { runId: state.runId, status: state.status, executor: state.executor, currentCycle: state.currentCycle, maxCycles: state.maxCycles, remainingCycles: Math.max(0, state.maxCycles - state.currentCycle), sessionHandleKind: state.sessionHandle?.kind, taskId: state.taskId, taskSessionReused: state.taskSessionReused, degradedResume: state.agyDegraded, runDir: dir, diffFile: cycle?.diffFile ? path.join(dir, cycle.diffFile) : undefined, executorLogFile: cycle?.executorLogFile ? path.join(dir, cycle.executorLogFile) : undefined, hostReviewFile: cycle?.hostReviewFile ? path.join(dir, cycle.hostReviewFile) : undefined, scopeViolations: state.status === "awaiting_scope_resolution" ? cycle?.scopeViolations : undefined, stopReason: state.stopReason, stopDescription: state.stopDescription, ...(state.baselineHead ? { baselineHead: state.baselineHead } : {}), ...(state.vcsBaseline ? { vcsBaseline: state.vcsBaseline } : {}), ...(state.vcsKind ? { vcsKind: state.vcsKind } : {}), repoRoot: state.repoRoot, createdAt: state.createdAt, updatedAt: state.updatedAt, liveLogFile, executionElapsed, lastActivity: sidecar?.lastActivityAt, logBytes: sidecar?.logBytes, executorPid: sidecar?.pid, activitySummary: sidecar?.activitySummary, }; if (opts.json) { process.stdout.write(JSON.stringify(output, null, 2) + "\n"); return; } const lw = 20; const p = (label: string, value: string) => ` ${(label + ":").padEnd(lw)} ${value}`; const lines = [ "── agent-workflow ─────────────────────", p("Run ID", state.runId), p("Status", state.status.toUpperCase()), p("Executor", state.executor), p("Cycle", `${state.currentCycle} / ${state.maxCycles} (${output.remainingCycles} remaining)`), ]; if (state.sessionHandle) { const kind = state.sessionHandle.kind; const extra = kind === "agy" && state.agyDegraded ? " [degraded-continue]" : ""; lines.push(p("Session handle", kind + extra)); } if (state.taskId) { lines.push(p("Task", `${state.taskId}${state.taskSessionReused ? " [session reused]" : ""}`)); } // Live execution fields if (output.executorPid) lines.push(p("Executor PID", String(output.executorPid))); if (output.executionElapsed) lines.push(p("Elapsed", output.executionElapsed)); if (output.activitySummary) lines.push(p("Activity", output.activitySummary)); if (output.lastActivity) lines.push(p("Last activity", output.lastActivity)); if (output.logBytes !== undefined) lines.push(p("Log bytes", `${output.logBytes}`)); if (output.liveLogFile) lines.push(p("Live log", output.liveLogFile)); if (cycle?.hostReviewFile) lines.push(p("Host review", path.join(dir, cycle.hostReviewFile))); if (state.status === "awaiting_scope_resolution" && cycle?.scopeViolations?.length) { lines.push(p("Scope violations", String(cycle.scopeViolations.length))); for (const violation of cycle.scopeViolations) { lines.push(` ${violation.id}: ${violation.path}`); } } if (state.stopReason) lines.push(p("Stop reason", state.stopReason)); if (state.stopDescription) lines.push(p("Stop detail", state.stopDescription.slice(0, 80))); lines.push(p("Repo root", state.repoRoot)); const baselineHead = extractBaselineHead(state); if (baselineHead) { lines.push(p("Baseline HEAD", baselineHead.slice(0, 8))); } else if (state.vcsBaseline?.kind === "svn") { lines.push(p("Baseline SVN", `r${state.vcsBaseline.revision}`)); } if (state.vcsKind) { lines.push(p("VCS", state.vcsKind)); } lines.push(p("Created", state.createdAt)); lines.push(p("Updated", state.updatedAt)); lines.push("─".repeat(46)); process.stdout.write(lines.join("\n") + "\n"); emitWorkflowMeta(output as unknown as Record); } // --------------------------------------------------------------------------- // workflow abort // --------------------------------------------------------------------------- export interface AbortWorkflowOpts { runId: string; reason: string; } export function abortWorkflow(opts: AbortWorkflowOpts): void { const { state, dir } = requireState(opts.runId); if (["completed", "aborted", "needs_human", "blocked", "budget_exhausted"].includes(state.status)) { process.stdout.write(`Run '${opts.runId}' is already in terminal state: ${state.status}\n`); return; } const pid = state.enginePid; transitionToTerminal(state, dir, "aborted", opts.reason, { abortedAt: nowIso() }); if (pid) { try { process.kill(pid, "SIGTERM"); } catch { // Ignore if process is already dead } } process.stdout.write(`workflow aborted: ${opts.runId}\nreason: ${opts.reason}\n`); emitWorkflowMeta({ runId: opts.runId, status: "aborted", reason: opts.reason }); } // --------------------------------------------------------------------------- // workflow logs // --------------------------------------------------------------------------- export interface LogsWorkflowOpts { runId: string; follow: boolean; tail: number; repoRoot?: string; } export async function logsWorkflow(opts: LogsWorkflowOpts): Promise { const { state, dir } = requireState(opts.runId); // Resolve current log path: sidecar is freshest during execution const sidecar = readProgressSidecar(dir); let logPath: string | undefined; if (sidecar?.logFilePath && fs.existsSync(path.join(dir, sidecar.logFilePath))) { logPath = path.join(dir, sidecar.logFilePath); } else { const cycle = state.cycles.find((c) => c.cycleIndex === state.currentCycle); if (cycle?.executorLogFile) { logPath = path.join(dir, cycle.executorLogFile); } } // If no log file and state is executing, wait briefly for it to appear const isExecuting = state.status === "executing"; if (!logPath && isExecuting) { for (let i = 0; i < 30; i++) { await sleepMs(500); const sc = readProgressSidecar(dir); if (sc?.logFilePath && fs.existsSync(path.join(dir, sc.logFilePath))) { logPath = path.join(dir, sc.logFilePath); break; } const updatedState = readState(dir); if (updatedState) { const uc = updatedState.cycles.find((c) => c.cycleIndex === updatedState.currentCycle); if (uc?.executorLogFile) { logPath = path.join(dir, uc.executorLogFile); break; } } } } if (!logPath || !fs.existsSync(logPath)) { process.stdout.write(`No log file found for run '${opts.runId}'.\n`); return; } let followLockPath: string | undefined; if (opts.follow) { const codexThreadId = process.env.CODEX_THREAD_ID?.trim(); if (codexThreadId) { try { followLockPath = acquireLogsFollowLock(dir, codexThreadId); } catch (err) { if (err instanceof LogsFollowLockError) { throw new WorkflowEngineError(err.message, 4); } throw err; } } } try { // Tail the last N lines const lines = readTailLines(logPath, opts.tail); for (const line of lines) { process.stdout.write(line + "\n"); } if (!opts.follow) return; // Follow mode: watch for new data and retry rotations let currentPath = logPath; let lastByteOffset = fs.statSync(currentPath).size; // States that indicate execution has ceased (no more output expected) const terminalStates = new Set([ "completed", "needs_human", "blocked", "budget_exhausted", "aborted", "awaiting_review", "awaiting_host", "awaiting_scope_resolution", ]); while (true) { const curState = readState(dir); if (!curState) break; // Exit if the workflow has stopped executing if (terminalStates.has(curState.status)) { drainBytes(currentPath, lastByteOffset); return; } // Check for log rotation to a newer attempt const sc = readProgressSidecar(dir); let candidatePath: string | undefined; if (sc?.logFilePath && fs.existsSync(path.join(dir, sc.logFilePath))) { candidatePath = path.join(dir, sc.logFilePath); } else { const curCycle = curState.cycles.find((c) => c.cycleIndex === curState.currentCycle); if (curCycle?.executorLogFile) { candidatePath = path.join(dir, curCycle.executorLogFile); } } if (candidatePath && candidatePath !== currentPath) { drainBytes(currentPath, lastByteOffset); currentPath = candidatePath; lastByteOffset = fs.statSync(currentPath).size; process.stdout.write(`\n--- log rotated to ${path.basename(currentPath)} ---\n`); const headLines = readTailLines(currentPath, Math.min(opts.tail, 10)); for (const line of headLines) { process.stdout.write(line + "\n"); } continue; } // Drain new bytes since lastByteOffset using byte-accurate reading try { const stats = fs.statSync(currentPath); if (stats.size > lastByteOffset) { drainBytes(currentPath, lastByteOffset); lastByteOffset = stats.size; } } catch { // File may have been deleted or rotated } await sleepMs(500); } } finally { if (followLockPath) releaseLogsFollowLock(followLockPath); } } function sleepMs(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } function readTailLines(filePath: string, n: number): string[] { try { const content = fs.readFileSync(filePath, "utf8"); const lines = content.split("\n"); return lines.slice(Math.max(0, lines.length - n)); } catch { return []; } } /** * Drain all bytes starting at fromByteOffset using proper byte-level I/O. */ function drainBytes(filePath: string, fromByteOffset: number): void { try { const fd = fs.openSync(filePath, "r"); try { const stats = fs.fstatSync(fd); const remaining = stats.size - fromByteOffset; if (remaining > 0) { const buf = Buffer.alloc(remaining); fs.readSync(fd, buf, 0, remaining, fromByteOffset); process.stdout.write(buf.toString("utf8")); } } finally { fs.closeSync(fd); } } catch { // ignore } }