From f052988980a0f1c0d5bd1a06aa06ea2a3f0552be Mon Sep 17 00:00:00 2001 From: homen Date: Wed, 14 Oct 2026 14:15:35 -0700 Subject: [PATCH] fix(agent)!: bound repair execution and cancellation --- .tours/01-primary-user-flow.tour | 4 +- .tours/02-agent-execution.tour | 14 +- .tours/03-debug-and-recovery.tour | 2 +- CHANGELOG/cli/repair-deadline.md | 37 +++++ dist/agentAdapters.d.ts | 6 +- dist/agentAdapters.js | 197 ++++++++++++++++++++--- dist/agentLoop.d.ts | 2 + dist/agentLoop.js | 10 +- dist/cli.js | 80 ++++++--- dist/repairDeadline.d.ts | 2 + dist/repairDeadline.js | 12 ++ docs/START_HERE.md | 61 ++++++- docs/codebase/ARCHITECTURE.md | 2 +- package-lock.json | 4 +- package.json | 2 +- src/agentAdapters.ts | 159 +++++++++++++++--- src/agentLoop.ts | 11 +- src/cli.ts | 80 +++++---- src/repairDeadline.ts | 8 + tests/agentAdapters.test.ts | 259 +++++++++++++++++++++++++++++- tests/codexLoop.test.ts | 131 ++++++++++++++- tests/walkthrough.test.ts | 24 +-- 22 files changed, 962 insertions(+), 145 deletions(-) create mode 100644 CHANGELOG/cli/repair-deadline.md create mode 100644 dist/repairDeadline.d.ts create mode 100644 dist/repairDeadline.js create mode 100644 src/repairDeadline.ts diff --git a/.tours/01-primary-user-flow.tour b/.tours/01-primary-user-flow.tour index 2c599f0..6b226dc 100644 --- a/.tours/01-primary-user-flow.tour +++ b/.tours/01-primary-user-flow.tour @@ -5,14 +5,14 @@ "steps": [ { "file": "src/cli.ts", - "line": 196, + "line": 197, "title": "Entry", "description": "Everything starts here. One switch on the first argument — no router, no plugin registry. `dist/cli.js` is the compiled form of this file and is what `package.json` → `bin.proofloop` points at.\n\nNote `root`: every command operates on a repo directory, defaulting to the current one, overridable with `--dir`.", "pattern": "^\\s*export function runCli\\(argv: string\\[\\]\\): number \\| Promise \\{\\s*$" }, { "file": "src/cli.ts", - "line": 221, + "line": 222, "title": "The command we are following", "description": "`proofloop gate` — the primary user action. `--check` means 'read the last verdict, run nothing', which is the mode the Stop hook uses.", "pattern": "^\\s*case \"gate\":\\s*$" diff --git a/.tours/02-agent-execution.tour b/.tours/02-agent-execution.tour index cec6d85..8bd3e7e 100644 --- a/.tours/02-agent-execution.tour +++ b/.tours/02-agent-execution.tour @@ -5,31 +5,31 @@ "steps": [ { "file": "src/cli.ts", - "line": 519, + "line": 520, "title": "Entry: proofloop codex-loop", - "description": "Parses `--agent`, `--dry-run`, `--max-attempts`, `--run-id` and hands off. Errors here return exit 2 rather than throwing — a supervisor that crashes is worse than one that reports.", + "description": "Parses --agent, --dry-run, --max-attempts, --timeout-ms and --run-id. Each repair has a validated deadline, default 30 minutes, followed by a fixed cleanup allowance. The CLI awaits the launcher and forwards interruptions during repair; library callers use AbortSignal. Synchronous gates remain outside this deadline. Invalid input returns exit 2.", "pattern": "^\\s*async function runCodexLoopCommand\\(options: Record, root: string\\): Promise \\{\\s*$" }, { "file": "src/agentLoop.ts", - "line": 43, + "line": 48, "title": "The loop", "description": "Run the gate initially and after every successfully completed repair, including the final allowed repair. Only launch another agent when the gate still fails and the positive-integer launch budget has room. `attempts` counts launched processes, so an initially passing project reports zero.\n\nEach observation leaves `gate-attempt-N.json` in `.proofloop/runs//`, up to budget + 1 snapshots. A zero-exit repair is not itself a passing gate.", "pattern": "^\\s*while \\(true\\) \\{\\s*$" }, { "file": "src/agentLoop.ts", - "line": 113, + "line": 120, "title": "Gate result → agent verdict", "description": "The translation layer. Failed check names become `failedGates`; if the gate produced no state at all, the verdict still carries a reason (`gate_unavailable`) rather than an empty list. The agent is never told 'something failed' with nothing to act on.", "pattern": "^\\s*function verdictFromGate\\(runId: string, state: GateState \\| undefined, exitCode: number\\): ProofloopVerdict \\{\\s*$" }, { "file": "src/agentLoop.ts", - "line": 97, + "line": 104, "title": "When the agent cannot be launched", - "description": "Dry run or no configured command: stop with zero launches. A launched process that exits nonzero counts once and also stops. Return the gate's failure code, with the prompt available for a human to act on. The launch budget does not add a wall-clock timeout to the existing adapter.", - "pattern": "^\\s*if \\(options\\.dryRun \\|\\| !runResult\\.launched \\|\\| runResult\\.status === \"failed\"\\) \\{\\s*$" + "description": "Dry run, missing command or pre-launch cancellation counts zero launches. A launched failure, timeout, cancellation, output/persistence failure or unverified cleanup counts once and stops: no subsequent repair or verifier. Arbitrary detached/reparented jobs are outside verified containment. Existing log/receipt history naming is unchanged.", + "pattern": "^\\s*if \\(options\\.dryRun \\|\\| !runResult\\.launched \\|\\| runResult\\.status === \"failed\" \\|\\| options\\.signal\\?\\.aborted\\) \\{\\s*$" }, { "file": "src/mcp.ts", diff --git a/.tours/03-debug-and-recovery.tour b/.tours/03-debug-and-recovery.tour index 76eb26f..350826f 100644 --- a/.tours/03-debug-and-recovery.tour +++ b/.tours/03-debug-and-recovery.tour @@ -5,7 +5,7 @@ "steps": [ { "file": "src/cli.ts", - "line": 1070, + "line": 1092, "title": "Entry: proofloop hooks install", "description": "Writes three scripts into `.proofloop/hooks/` and registers them in the assistant's own settings file. Idempotent: re-running reports `(already present)` rather than duplicating entries.", "pattern": "^\\s*function runHooksCommand\\(sub: string \\| undefined, options: Record, root: string\\): number \\{\\s*$" diff --git a/CHANGELOG/cli/repair-deadline.md b/CHANGELOG/cli/repair-deadline.md new file mode 100644 index 0000000..3148e18 --- /dev/null +++ b/CHANGELOG/cli/repair-deadline.md @@ -0,0 +1,37 @@ +# Repair deadline — 2026-10-14 + +A developer allowing one coding-agent repair still needs that repair to finish +or report failure. Previously the shell launch used `spawnSync` without a +timeout: a finite four-second local repair blocked a caller's 100 ms timer for +4,092 ms and delayed the repair receipt until it returned. Launch count alone +did not control elapsed time. + +The existing launcher is now asynchronous, with a validated per-repair deadline +(default 30 minutes), standard `AbortSignal`, bounded process cleanup and 1 MiB +per output stream. The loop, Promise facade and direct CLI relaunch await it. +Timeout/cancellation remains failure even if the process later exits zero; +unverified cleanup stops automation with an explicit diagnostic. Failed or +cancelled repairs do not trigger another repair or verifier. + +Version 0.4.0 records the intentional public return-type break: +`launchProofloopAgentAdapter(...)` now needs `await` and rejects malformed +deadline values. See START_HERE for awaited try/catch migration. No second +synchronous launcher, dependency upgrade or npm publication is included. + +The deadline excludes synchronous gates and is followed by a fixed cleanup +allowance (Windows up to 4 seconds, POSIX up to 2 seconds). Normal foreground +completion does not establish that arbitrary detached or reparented jobs ended. +Windows console interruption and the POSIX process cases need their own real +platform evidence; one platform does not certify another. The Windows Node +inherited-pipe fixture failed to establish its pipe-retention precondition, so +it is explicitly unproved there. Existing log/receipt overwrite behavior and +whole-loop budgets remain unchanged. + +Validation uses real, finite local processes, actual generated prompts, failed +and passing project gates, cancellation, output/persistence failures, a +three-job burst and twelve isolated sequential jobs. The original synchronous +scenario fails causally before the fix. Raw failures, including a loaded +Windows helper timeout, are retained privately; the helper allowance is bounded +and unverified cleanup never becomes permission to continue. No provider/model +call, visual change, live deployment or completed Linux proof is claimed by +local Windows results. Exact-head Linux Node20 CI remains a release gate. diff --git a/dist/agentAdapters.d.ts b/dist/agentAdapters.d.ts index 6dca274..c7de5b4 100644 --- a/dist/agentAdapters.d.ts +++ b/dist/agentAdapters.d.ts @@ -53,6 +53,8 @@ export type ProofloopAgentAdapter = { launch(promptPath: string, targetDir: string, options?: { command?: string; env?: NodeJS.ProcessEnv; + timeoutMs?: number; + signal?: AbortSignal; }): Promise; collectTrace(runDir: string): Promise; buildRepairPrompt(verdict: ProofloopVerdict, options?: { @@ -76,7 +78,9 @@ export declare function launchProofloopAgentAdapter(args: { targetDir?: string; command?: string; env?: NodeJS.ProcessEnv; -}): AgentRunResult; + timeoutMs?: number; + signal?: AbortSignal; +}): Promise; export declare function collectProofloopAgentTrace(args: { adapterId: ProofloopAgentAdapterId; runDir: string; diff --git a/dist/agentAdapters.js b/dist/agentAdapters.js index 90d349a..4f4a759 100644 --- a/dist/agentAdapters.js +++ b/dist/agentAdapters.js @@ -24,6 +24,7 @@ const node_child_process_1 = require("node:child_process"); const node_fs_1 = require("node:fs"); const node_path_1 = require("node:path"); const proofloopHooks_1 = require("./proofloopHooks"); +const repairDeadline_1 = require("./repairDeadline"); exports.PROOFLOOP_AGENT_ADAPTER_IDS = ["codex", "claude-code", "cursor", "windsurf", "devin", "generic-cli"]; function parseProofloopAgentAdapterId(value) { if (exports.PROOFLOOP_AGENT_ADAPTER_IDS.includes(value)) @@ -34,7 +35,7 @@ function getProofloopAgentAdapter(id) { return { id, installHooks: (targetDir, options) => setupProofloopAgentAdapter({ adapterId: id, root: targetDir, ...options }), - launch: (promptPath, targetDir, options) => Promise.resolve(launchProofloopAgentAdapter({ adapterId: id, promptPath, targetDir, ...options })), + launch: (promptPath, targetDir, options) => launchProofloopAgentAdapter({ adapterId: id, promptPath, targetDir, ...options }), collectTrace: (runDir) => Promise.resolve(collectProofloopAgentTrace({ adapterId: id, runDir })), buildRepairPrompt: (verdict, options) => Promise.resolve(buildAgentRepairPrompt({ adapterId: id, verdict, ...options })), }; @@ -73,9 +74,16 @@ async function setupProofloopAgentAdapter(args) { writeJson(agentSetupReceiptPath(root, args.adapterId), receipt); return receipt; } -function launchProofloopAgentAdapter(args) { +async function launchProofloopAgentAdapter(args) { + const timeoutMs = (0, repairDeadline_1.repairTimeoutMs)(args.timeoutMs); const targetDir = (0, node_path_1.resolve)(args.targetDir ?? process.cwd()); const env = args.env ?? process.env; + const failedBeforeLaunch = (message) => ({ + adapterId: args.adapterId, status: "failed", launched: false, + promptPath: rel(targetDir, args.promptPath), message, + }); + if (args.signal?.aborted) + return failedBeforeLaunch("Repair cancelled before launch."); const command = args.command ?? defaultLaunchCommand(args.adapterId, env); if (!command) { const status = args.adapterId === "generic-cli" ? "needs_command" : "needs_adapter"; @@ -87,36 +95,179 @@ function launchProofloopAgentAdapter(args) { message: `${args.adapterId} has no launch command configured.`, }; } - const prompt = (0, node_fs_1.readFileSync)(args.promptPath, "utf8"); - const result = (0, node_child_process_1.spawnSync)(command, { - cwd: targetDir, - shell: true, - input: prompt, - stdio: ["pipe", "pipe", "pipe"], - encoding: "utf8", - env: { - ...env, - PROOFLOOP_AGENT_ADAPTER: args.adapterId, - PROOFLOOP_REPAIR_PROMPT: args.promptPath, - }, - }); + let prompt; + try { + prompt = (0, node_fs_1.readFileSync)(args.promptPath, "utf8"); + } + catch (error) { + return failedBeforeLaunch(`Cannot read repair prompt: ${errorMessage(error)}`); + } + const result = await runRepairProcess(command, targetDir, prompt, { + ...env, PROOFLOOP_AGENT_ADAPTER: args.adapterId, PROOFLOOP_REPAIR_PROMPT: args.promptPath, + }, timeoutMs, args.signal); const stdoutPath = (0, node_path_1.join)((0, node_path_1.dirname)(args.promptPath), `${safeAgentId(args.adapterId)}-stdout.log`); const stderrPath = (0, node_path_1.join)((0, node_path_1.dirname)(args.promptPath), `${safeAgentId(args.adapterId)}-stderr.log`); - (0, node_fs_1.writeFileSync)(stdoutPath, result.stdout ?? "", "utf8"); - (0, node_fs_1.writeFileSync)(stderrPath, result.stderr ?? "", "utf8"); - const exitCode = result.status ?? 1; + let failure = result.failure; + const persisted = {}; + for (const [key, path, contents] of [["stdoutPath", stdoutPath, result.stdout], ["stderrPath", stderrPath, result.stderr]]) { + try { + (0, node_fs_1.writeFileSync)(path, contents); + persisted[key] = rel(targetDir, path); + } + catch (error) { + failure = `${failure ? `${failure}; ` : ""}Cannot persist ${key}: ${errorMessage(error)}`; + } + } + const exitCode = failure ? result.code || 1 : result.code ?? 1; return { adapterId: args.adapterId, - status: exitCode === 0 ? "launched" : "failed", - launched: true, + status: !failure && exitCode === 0 ? "launched" : "failed", + launched: result.launched, command, promptPath: rel(targetDir, args.promptPath), exitCode, - stdoutPath: rel(targetDir, stdoutPath), - stderrPath: rel(targetDir, stderrPath), - message: exitCode === 0 ? `${args.adapterId} completed; rerun the Proof Loop gate.` : `${args.adapterId} exited ${exitCode}.`, + ...persisted, + message: failure ?? (exitCode === 0 ? `${args.adapterId} completed; rerun the Proof Loop gate.` : `${args.adapterId} exited ${exitCode}.`), + }; +} +const REPAIR_OUTPUT_BYTES = 1024 * 1024; +const CLEANUP_BUDGET_MS = process.platform === "win32" ? 4000 : 2000; +function runRepairProcess(command, cwd, prompt, env, timeoutMs, signal) { + return new Promise((resolveResult) => { + let child; + try { + child = (0, node_child_process_1.spawn)(command, { cwd, env, shell: true, detached: process.platform !== "win32", windowsHide: true, stdio: ["pipe", "pipe", "pipe"] }); + } + catch (error) { + resolveResult({ launched: false, code: null, stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), failure: `Repair launch failed: ${errorMessage(error)}` }); + return; + } + let launched = false, closed = false, exited = false, settled = false; + let code = null, failure; + const output = { stdout: [], stderr: [] }; + const bytes = { stdout: 0, stderr: 0 }; + const finish = () => { + if (settled) + return; + settled = true; + clearTimeout(deadline); + signal?.removeEventListener("abort", abort); + child.stdin.destroy(); + child.stdout.destroy(); + child.stderr.destroy(); + child.unref(); + resolveResult({ launched, code, stdout: Buffer.concat(output.stdout), stderr: Buffer.concat(output.stderr), ...(failure ? { failure } : {}) }); + }; + const fail = (reason) => { + if (settled || failure) + return; + failure = reason; + clearTimeout(deadline); + if (!child.pid) { + finish(); + return; + } + // A root which already exited cannot identify a remaining Windows process tree safely. + void terminateOwnedRepair(child.pid, () => closed, exited).then(confirmed => { + if (!confirmed) + failure += "; cleanup-unverified: owned process tree or output closure could not be confirmed; stop automation."; + finish(); + }, error => { + failure += `; cleanup-unverified: ${errorMessage(error)}; stop automation.`; + finish(); + }); + }; + const abort = () => fail("Repair cancelled by caller."); + const deadline = setTimeout(() => fail(`Repair timed out after ${timeoutMs}ms.`), timeoutMs); + child.once("spawn", () => { launched = true; }); + child.once("error", error => fail(`Repair launch failed: ${errorMessage(error)}`)); + child.once("exit", status => { exited = true; code = status; }); + child.once("close", status => { closed = true; code = status; if (!failure) + finish(); }); + for (const stream of ["stdout", "stderr"]) { + child[stream].on("data", (chunk) => { + if (settled) + return; + const remaining = REPAIR_OUTPUT_BYTES - bytes[stream]; + if (remaining > 0) { + const captured = Buffer.from(chunk.subarray(0, remaining)); + output[stream].push(captured); + bytes[stream] += captured.length; + } + if (chunk.length > remaining) + fail(`Repair ${stream} exceeded ${REPAIR_OUTPUT_BYTES} bytes.`); + }); + child[stream].on("error", error => fail(`Repair ${stream} failed: ${errorMessage(error)}`)); + } + child.stdin.on("error", error => fail(`Repair stdin failed: ${errorMessage(error)}`)); + signal?.addEventListener("abort", abort, { once: true }); + if (signal?.aborted) + abort(); + else + child.stdin.end(prompt); + }); +} +async function terminateOwnedRepair(pid, closed, rootAlreadyExited) { + const until = Date.now() + CLEANUP_BUDGET_MS; + if (process.platform === "win32") { + if (rootAlreadyExited) + return false; + const killed = await new Promise(resolveKill => { + const killer = (0, node_child_process_1.spawn)((0, node_path_1.join)(process.env.SystemRoot ?? "C:\\Windows", "System32", "taskkill.exe"), ["/PID", String(pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" }); + let done = false; + const finish = (ok) => { if (!done) { + done = true; + clearTimeout(timer); + resolveKill(ok); + } }; + const timer = setTimeout(() => { + try { + killer.kill(); + } + catch { /* Helper termination is unverified too. */ } + killer.unref(); + finish(false); + }, 3000); + killer.once("error", () => finish(false)); + killer.once("close", status => finish(status === 0)); + }); + while (killed && !closed() && Date.now() < until) + await pause(25); + // A helper exit alone is insufficient: require the owned shell's drained close too. + return killed && closed(); + } + const groupExists = () => { + try { + process.kill(-pid, 0); + return true; + } + catch (error) { + return error.code !== "ESRCH"; + } + }; + const send = (signal) => { + try { + process.kill(-pid, signal); + } + catch (error) { + if (error.code !== "ESRCH") + return false; + } + return true; }; + if (!send("SIGTERM")) + return false; + const escalateAt = Date.now() + 250; + while (groupExists() && Date.now() < escalateAt) + await pause(25); + if (groupExists() && !send("SIGKILL")) + return false; + while ((!closed() || groupExists()) && Date.now() < until) + await pause(25); + return closed() && !groupExists(); } +function pause(ms) { return new Promise(resolvePause => setTimeout(resolvePause, ms)); } +function errorMessage(error) { return error instanceof Error ? error.message : String(error); } function collectProofloopAgentTrace(args) { const root = (0, node_path_1.resolve)(args.root ?? process.cwd()); const evidenceFiles = (0, node_fs_1.existsSync)(args.runDir) diff --git a/dist/agentLoop.d.ts b/dist/agentLoop.d.ts index 0b5c531..ad9b47e 100644 --- a/dist/agentLoop.d.ts +++ b/dist/agentLoop.d.ts @@ -11,6 +11,8 @@ export declare function runProofloopAgentLoop(options: { root?: string; agentId?: ProofloopAgentAdapterId; maxAttempts?: number; + timeoutMs?: number; + signal?: AbortSignal; dryRun?: boolean; command?: string; runId?: string; diff --git a/dist/agentLoop.js b/dist/agentLoop.js index d5efc18..d793e74 100644 --- a/dist/agentLoop.js +++ b/dist/agentLoop.js @@ -6,8 +6,10 @@ const node_path_1 = require("node:path"); const agentAdapters_1 = require("./agentAdapters"); const codexRelaunch_1 = require("./codexRelaunch"); const gate_1 = require("./gate"); +const repairDeadline_1 = require("./repairDeadline"); async function runProofloopAgentLoop(options) { const root = (0, node_path_1.resolve)(options.root ?? process.cwd()); + const timeoutMs = (0, repairDeadline_1.repairTimeoutMs)(options.timeoutMs); const agentId = options.agentId ?? "codex"; const maxAttempts = options.maxAttempts === undefined ? 1 : options.maxAttempts; if (!Number.isSafeInteger(maxAttempts) || maxAttempts <= 0) { @@ -15,6 +17,8 @@ async function runProofloopAgentLoop(options) { } const runId = options.runId ?? `gate-${compactTimestamp(new Date())}`; const runDir = (0, node_path_1.join)(root, ".proofloop", "runs", runId); + if (options.signal?.aborted) + return { runId, runDir, exitCode: 1, attempts: 0, passed: false }; (0, node_fs_1.mkdirSync)(runDir, { recursive: true }); let attempts = 0; while (true) { @@ -52,10 +56,12 @@ async function runProofloopAgentLoop(options) { promptPath: repairPromptPath, message: "dry run; agent was not launched", } - : (0, agentAdapters_1.launchProofloopAgentAdapter)({ + : await (0, agentAdapters_1.launchProofloopAgentAdapter)({ adapterId: agentId, promptPath: repairPromptPath, targetDir: root, + timeoutMs, + signal: options.signal, ...(options.command ? { command: options.command } : {}), }); (0, agentAdapters_1.writeAgentRepairAttemptReceipt)({ @@ -70,7 +76,7 @@ async function runProofloopAgentLoop(options) { }); if (runResult.launched) attempts++; - if (options.dryRun || !runResult.launched || runResult.status === "failed") { + if (options.dryRun || !runResult.launched || runResult.status === "failed" || options.signal?.aborted) { return { runId, exitCode: lastExit || 1, attempts, passed: false, runDir, repairPromptPath }; } } diff --git a/dist/cli.js b/dist/cli.js index 1906b53..5648d70 100644 --- a/dist/cli.js +++ b/dist/cli.js @@ -51,6 +51,7 @@ const maturity_1 = require("./maturity"); const productivity_1 = require("./productivity"); const agentAdapters_1 = require("./agentAdapters"); const agentLoop_1 = require("./agentLoop"); +const repairDeadline_1 = require("./repairDeadline"); const codexRelaunch_1 = require("./codexRelaunch"); const providerSetup_1 = require("./providerSetup"); const transferCheck_1 = require("./transferCheck"); @@ -134,7 +135,7 @@ function usage() { " maturity [--dense|--json|--write] [--target-level 5] judge agent-era codebase/app maturity and missing layers", " productivity [--write] [--baseline-source ] write wage-equivalent verified productivity receipts and charts", " agents list|setup [id|all] install/report agent adapters for Codex, Claude Code, and wrapper hosts", - " codex-loop [--dry-run] [--max-attempts 2] run gate, write repair prompt, optionally relaunch Codex", + " codex-loop [--dry-run] [--max-attempts 2] [--timeout-ms 1800000] gate and bounded repairs", " codex reprompt|relaunch [run-id] show or launch the latest Codex repair prompt", " providers setup [id|all] write provider setup receipts for live integration lanes", " mcp start the optional read-only MCP server", @@ -450,14 +451,17 @@ async function runAgentsCommand(sub, adapter, options, root) { async function runCodexLoopCommand(options, root) { try { const agentId = (0, agentAdapters_1.parseProofloopAgentAdapterId)(str(options.agent) ?? "codex"); - const result = await (0, agentLoop_1.runProofloopAgentLoop)({ + const timeoutMs = (0, repairDeadline_1.repairTimeoutMs)(options["timeout-ms"] === undefined ? undefined : Number(str(options["timeout-ms"]) ?? NaN)); + const result = await withRepairCancellation(signal => (0, agentLoop_1.runProofloopAgentLoop)({ root, agentId, dryRun: options["dry-run"] === true, + timeoutMs, + signal, ...(str(options.command) !== undefined ? { command: str(options.command) } : {}), ...(options["max-attempts"] !== undefined ? { maxAttempts: Number(str(options["max-attempts"]) ?? NaN) } : {}), ...(str(options["run-id"]) !== undefined ? { runId: str(options["run-id"]) } : {}), - }); + })); console.log(`proofloop codex-loop: ${result.passed ? "passed" : "needs-repair"} run=${result.runId} attempts=${result.attempts}`); console.log(` runDir=${result.runDir}`); if (result.repairPromptPath) @@ -469,34 +473,58 @@ async function runCodexLoopCommand(options, root) { return 2; } } -function runCodexCommand(sub, runId, options, root) { - if (sub !== "reprompt" && sub !== "relaunch") { - console.error("proofloop codex: expected `reprompt` or `relaunch`."); - return 2; +async function runCodexCommand(sub, runId, options, root) { + try { + const timeoutMs = (0, repairDeadline_1.repairTimeoutMs)(options["timeout-ms"] === undefined ? undefined : Number(str(options["timeout-ms"]) ?? NaN)); + if (sub !== "reprompt" && sub !== "relaunch") { + console.error("proofloop codex: expected `reprompt` or `relaunch`."); + return 2; + } + const runDir = runId ? (0, codexRelaunch_1.codexRunDir)(root, runId) : (0, codexRelaunch_1.latestProofloopRunDir)(root); + if (!runDir) { + console.error("proofloop codex: no .proofloop/runs/ directory found. Run `npx proofloop codex-loop --dry-run` first."); + return 2; + } + const promptPath = (0, node_path_1.join)(runDir, "codex-reprompt.md"); + const prompt = (0, codexRelaunch_1.readCodexReprompt)(promptPath); + if (!prompt) { + console.error(`proofloop codex: no reprompt found at ${promptPath}.`); + return 2; + } + if (sub === "reprompt") { + console.log(prompt); + return 0; + } + const result = await withRepairCancellation(signal => (0, agentAdapters_1.launchProofloopAgentAdapter)({ + adapterId: "codex", + promptPath, + targetDir: root, + timeoutMs, + signal, + ...(str(options.command) !== undefined ? { command: str(options.command) } : {}), + })); + console.log(JSON.stringify(result, null, 2)); + return result.launched && result.status !== "failed" ? 0 : 1; } - const runDir = runId ? (0, codexRelaunch_1.codexRunDir)(root, runId) : (0, codexRelaunch_1.latestProofloopRunDir)(root); - if (!runDir) { - console.error("proofloop codex: no .proofloop/runs/ directory found. Run `npx proofloop codex-loop --dry-run` first."); + catch (error) { + console.error(`proofloop codex: ${error instanceof Error ? error.message : String(error)}`); return 2; } - const promptPath = (0, node_path_1.join)(runDir, "codex-reprompt.md"); - const prompt = (0, codexRelaunch_1.readCodexReprompt)(promptPath); - if (!prompt) { - console.error(`proofloop codex: no reprompt found at ${promptPath}.`); - return 2; +} +/** Only the CLI owns global interruption handlers; library callers pass AbortSignal. */ +async function withRepairCancellation(run) { + const controller = new AbortController(); + const interrupt = () => controller.abort(); + const signals = ["SIGINT", "SIGTERM", "SIGHUP"]; + for (const signal of signals) + process.on(signal, interrupt); + try { + return await run(controller.signal); } - if (sub === "reprompt") { - console.log(prompt); - return 0; + finally { + for (const signal of signals) + process.off(signal, interrupt); } - const result = (0, agentAdapters_1.launchProofloopAgentAdapter)({ - adapterId: "codex", - promptPath, - targetDir: root, - ...(str(options.command) !== undefined ? { command: str(options.command) } : {}), - }); - console.log(JSON.stringify(result, null, 2)); - return result.launched && result.status !== "failed" ? 0 : 1; } async function runProvidersCommand(sub, provider, options, root) { if (sub !== "setup") { diff --git a/dist/repairDeadline.d.ts b/dist/repairDeadline.d.ts new file mode 100644 index 0000000..b3931f7 --- /dev/null +++ b/dist/repairDeadline.d.ts @@ -0,0 +1,2 @@ +/** Shared private policy for the launcher, loop and CLI; not a total-loop deadline. */ +export declare function repairTimeoutMs(value: number | undefined): number; diff --git a/dist/repairDeadline.js b/dist/repairDeadline.js new file mode 100644 index 0000000..a9b17ed --- /dev/null +++ b/dist/repairDeadline.js @@ -0,0 +1,12 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.repairTimeoutMs = repairTimeoutMs; +/** Shared private policy for the launcher, loop and CLI; not a total-loop deadline. */ +function repairTimeoutMs(value) { + if (value === undefined) + return 30 * 60 * 1000; + if (!Number.isInteger(value) || value < 1 || value > 2147483647) { + throw new Error("timeoutMs must be an integer from 1 to 2147483647"); + } + return value; +} diff --git a/docs/START_HERE.md b/docs/START_HERE.md index 25bb40e..e001598 100644 --- a/docs/START_HERE.md +++ b/docs/START_HERE.md @@ -58,7 +58,7 @@ next, so you can put this page down at any point and keep going in the editor. **File:** `src/cli.ts` **Symbol:** `runCli` -**Called by:** `dist/cli.js` when run as a program (`package.json` → `bin.proofloop`), guarded by `require.main === module` at `src/cli.ts:1158` so importing the module never runs it +**Called by:** `dist/cli.js` when run as a program (`package.json` → `bin.proofloop`), guarded by `require.main === module` at `src/cli.ts:1180` so importing the module never runs it **Calls next:** one of ~28 command handlers; for this walkthrough, `runGateCli` **Why this exists** @@ -83,7 +83,7 @@ export function runCli(argv: string[]): number | Promise { **Output** — a process exit code (`number`), or a `Promise` for the async commands. **Failure behavior** — an unknown command prints usage to stderr and returns 2. -An exception anywhere below is caught at `src/cli.ts:1161` and also becomes +An exception anywhere below is caught at `src/cli.ts:1183` and also becomes exit 2. **Next** — Step 2, `runGateCli`. @@ -165,7 +165,7 @@ gate against a broken config would quietly prove nothing. **File:** `src/agentLoop.ts` **Symbol:** `runProofloopAgentLoop` -**Called by:** `runCli` case `"codex-loop"` → `runCodexLoopCommand` (`src/cli.ts:519`) +**Called by:** `runCli` case `"codex-loop"` → `runCodexLoopCommand` (`src/cli.ts:520`) **Calls next:** `runGateCli` (Step 2), `buildAgentRepairPrompt`, `launchProofloopAgentAdapter` (`src/agentAdapters.ts`) **Why this exists** @@ -187,14 +187,14 @@ while (true) { const prompt = buildAgentRepairPrompt({ adapterId: agentId, verdict, ... }); writeFileSync(repairPromptPath, prompt, "utf8"); - const runResult = options.dryRun ? { ...notLaunched } : launchProofloopAgentAdapter({ ... }); + const runResult = options.dryRun ? { ...notLaunched } : await launchProofloopAgentAdapter({ ... }); if (runResult.launched) attempts++; if (!runResult.launched || runResult.status === "failed") return { ...failedResult }; } ``` **Input** — repo root, an agent id (`codex`, `claude-code`, …), `maxAttempts` -(a positive safe integer, default 1), and `dryRun`. Invalid supplied budgets +(a positive safe integer, default 1), `timeoutMs`, an optional `AbortSignal`, and `dryRun`. Invalid supplied budgets are rejected before any gate, agent launch or run artifact is created. **Output** — `{ runId, exitCode, attempts, passed, runDir }`, plus files under `.proofloop/runs//`. `attempts` counts launched repair processes, not @@ -206,9 +206,54 @@ prompt or launch follows budget exhaustion. the adapter has no command, the count stays zero. A launched process that exits nonzero counts once and stops the loop with the gate's failure code. The loop does not certify changes from that failed process. Repair launch count is -bounded; the existing launcher has no wall-clock timeout. Use distinct run IDs +bounded; each repair also has a deadline, default 30 minutes. `--timeout-ms` +on `codex-loop` and `codex relaunch` accepts an integer from 1 to 2147483647. +The deadline starts when that repair launches, not when the gate or whole loop +starts. Timeout, cancellation, output overflow or persistence failure stops +automation with a failed result; no later zero exit can turn it into a pass. +Captured stdout and stderr are each capped at 1 MiB. + +Cleanup has a fixed additional allowance: up to 4 seconds on Windows (a bounded +3-second PID-scoped `taskkill /T /F` helper plus output closure) and 2 seconds +on POSIX (TERM, then KILL after 250 ms, then group/output checks). A helper exit +or a missing root alone is not proof of cleanup. An unconfirmed outcome says +`cleanup-unverified` and stops. The supported boundary is the owned foreground +shell and ordinary descendants while that ownership remains observable. +Deliberate background/daemon jobs, exited/reparented Windows descendants, +remote jobs and OS permission failures are not guaranteed contained. Normal +foreground exit/output closure does not certify that arbitrary background +processes stopped. The Windows inherited-pipe fixture did not establish pipe +retention or descendant survival; that case requires separate platform proof. + +The CLI forwards interruption while awaiting a repair and removes its handlers +afterward. Library users pass `AbortSignal`; the library installs no global +signal handlers. Synchronous gates remain outside the repair deadline and can +delay cancellation delivery. This is not a hard total-loop deadline. Use distinct run IDs and isolated project roots for concurrent jobs. Adapter logs and the latest repair receipt still overwrite between attempts; gate snapshots are separate. + +**0.4.0 API migration** — `launchProofloopAgentAdapter` now returns +`Promise`; the settled result shape is unchanged. Replace +`const result = launchProofloopAgentAdapter(args)` with an awaited call: + +```ts +const cancellation = new AbortController(); +try { + const result = await launchProofloopAgentAdapter({ + ...args, timeoutMs: 60_000, signal: cancellation.signal, + }); + if (result.status === "failed") console.error(result.message); +} catch (error) { + // Invalid timeout values reject before launching or writing artifacts. + console.error(error); +} +``` + +The Promise adapter facade and `runProofloopAgentLoop` accept the same timeout +and signal. Pre-aborted work and unavailable commands count zero launches; +an actually launched timed-out repair counts once. No synchronous compatibility +launcher is retained. This is an intentional public API break, not an npm +publication or proof about unknown external consumers. **Next** — Step 5, the tools an agent is offered. ## Step 5 — Tools are registered for the agent to call @@ -241,7 +286,7 @@ them writes to the repository. That is the security boundary — an agent cannot use this server to change files. **Failure behavior** — an unknown method or malformed frame produces a JSON-RPC error object; the server keeps reading. `runCli` returns the sentinel -`MCP_SERVER_RUNNING` (`src/cli.ts:105`) so the process is *not* exited while the +`MCP_SERVER_RUNNING` (`src/cli.ts:106`) so the process is *not* exited while the server is serving. **Next** — Step 6, how tool calls are proven after the fact. @@ -355,7 +400,7 @@ recorded as a failure with `exitCode: null`. **File:** `src/proofloopHooks.ts` **Symbol:** `stopGateScript` (line 504) — a generator that writes the standalone hook `.proofloop/hooks/stop-gate.mjs`; installed by `installProofloopHooks` (line 172) -**Called by:** `runCli` case `"hooks"` → `runHooksCommand` (`src/cli.ts:1070`) +**Called by:** `runCli` case `"hooks"` → `runHooksCommand` (`src/cli.ts:1092`) **Calls next:** at runtime the generated script calls `proofloop gate --check` (Step 2, check mode) or reads the receipt directly **Why this exists** diff --git a/docs/codebase/ARCHITECTURE.md b/docs/codebase/ARCHITECTURE.md index a5b47f8..78fba59 100644 --- a/docs/codebase/ARCHITECTURE.md +++ b/docs/codebase/ARCHITECTURE.md @@ -124,5 +124,5 @@ dependency of the deployed functions. All three at once, which is unusual, so: the shape of `src/gate.ts`: exported pure functions, plus one `run*Cli` wrapper that returns an exit code and does all the printing. - **New agent host** — an entry in `PROOFLOOP_AGENT_ADAPTER_IDS` - (`src/agentAdapters.ts:17`) and its settings path. + (`src/agentAdapters.ts:18`) and its settings path. - **New MCP tool** — an entry in `TOOLS` (`src/mcp.ts:28`). Keep it read-only. diff --git a/package-lock.json b/package-lock.json index 7a63467..f6e6396 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "proofloop", - "version": "0.3.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "proofloop", - "version": "0.3.0", + "version": "0.4.0", "license": "MIT", "bin": { "proofloop": "dist/cli.js" diff --git a/package.json b/package.json index 6525921..784c3bf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "proofloop", - "version": "0.3.0", + "version": "0.4.0", "description": "NodeProof makes coding agents prove the app works. Portable proof-supervisor CLI: gate + hooks + tool-use contracts + kickoff prompt. Zero runtime dependencies.", "keywords": [ "proof-loop", diff --git a/src/agentAdapters.ts b/src/agentAdapters.ts index b59b60b..e054304 100644 --- a/src/agentAdapters.ts +++ b/src/agentAdapters.ts @@ -9,10 +9,11 @@ * `needs_command` rather than guessing when the agent is not installed -- * silently doing nothing would look identical to a passing run. */ -import { spawnSync } from "node:child_process"; +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join, relative, resolve } from "node:path"; import { installProofloopHooks, type ProofloopHookWorker } from "./proofloopHooks"; +import { repairTimeoutMs } from "./repairDeadline"; export const PROOFLOOP_AGENT_ADAPTER_IDS = ["codex", "claude-code", "cursor", "windsurf", "devin", "generic-cli"] as const; @@ -68,7 +69,7 @@ export type ProofloopVerdict = { export type ProofloopAgentAdapter = { id: ProofloopAgentAdapterId; installHooks(targetDir: string, options?: { local?: boolean; command?: string }): Promise; - launch(promptPath: string, targetDir: string, options?: { command?: string; env?: NodeJS.ProcessEnv }): Promise; + launch(promptPath: string, targetDir: string, options?: { command?: string; env?: NodeJS.ProcessEnv; timeoutMs?: number; signal?: AbortSignal }): Promise; collectTrace(runDir: string): Promise; buildRepairPrompt(verdict: ProofloopVerdict, options?: { repairPrompt?: string; attempt?: number; maxAttempts?: number }): Promise; }; @@ -82,7 +83,7 @@ export function getProofloopAgentAdapter(id: ProofloopAgentAdapterId): Proofloop return { id, installHooks: (targetDir, options) => setupProofloopAgentAdapter({ adapterId: id, root: targetDir, ...options }), - launch: (promptPath, targetDir, options) => Promise.resolve(launchProofloopAgentAdapter({ adapterId: id, promptPath, targetDir, ...options })), + launch: (promptPath, targetDir, options) => launchProofloopAgentAdapter({ adapterId: id, promptPath, targetDir, ...options }), collectTrace: (runDir) => Promise.resolve(collectProofloopAgentTrace({ adapterId: id, runDir })), buildRepairPrompt: (verdict, options) => Promise.resolve(buildAgentRepairPrompt({ adapterId: id, verdict, ...options })), }; @@ -130,15 +131,23 @@ export async function setupProofloopAgentAdapter(args: { return receipt; } -export function launchProofloopAgentAdapter(args: { +export async function launchProofloopAgentAdapter(args: { adapterId: ProofloopAgentAdapterId; promptPath: string; targetDir?: string; command?: string; env?: NodeJS.ProcessEnv; -}): AgentRunResult { + timeoutMs?: number; + signal?: AbortSignal; +}): Promise { + const timeoutMs = repairTimeoutMs(args.timeoutMs); const targetDir = resolve(args.targetDir ?? process.cwd()); const env = args.env ?? process.env; + const failedBeforeLaunch = (message: string): AgentRunResult => ({ + adapterId: args.adapterId, status: "failed", launched: false, + promptPath: rel(targetDir, args.promptPath), message, + }); + if (args.signal?.aborted) return failedBeforeLaunch("Repair cancelled before launch."); const command = args.command ?? defaultLaunchCommand(args.adapterId, env); if (!command) { const status = args.adapterId === "generic-cli" ? "needs_command" : "needs_adapter"; @@ -151,37 +160,135 @@ export function launchProofloopAgentAdapter(args: { }; } - const prompt = readFileSync(args.promptPath, "utf8"); - const result = spawnSync(command, { - cwd: targetDir, - shell: true, - input: prompt, - stdio: ["pipe", "pipe", "pipe"], - encoding: "utf8", - env: { - ...env, - PROOFLOOP_AGENT_ADAPTER: args.adapterId, - PROOFLOOP_REPAIR_PROMPT: args.promptPath, - }, - }); + let prompt: string; + try { prompt = readFileSync(args.promptPath, "utf8"); } + catch (error) { return failedBeforeLaunch(`Cannot read repair prompt: ${errorMessage(error)}`); } + const result = await runRepairProcess(command, targetDir, prompt, { + ...env, PROOFLOOP_AGENT_ADAPTER: args.adapterId, PROOFLOOP_REPAIR_PROMPT: args.promptPath, + }, timeoutMs, args.signal); const stdoutPath = join(dirname(args.promptPath), `${safeAgentId(args.adapterId)}-stdout.log`); const stderrPath = join(dirname(args.promptPath), `${safeAgentId(args.adapterId)}-stderr.log`); - writeFileSync(stdoutPath, result.stdout ?? "", "utf8"); - writeFileSync(stderrPath, result.stderr ?? "", "utf8"); - const exitCode = result.status ?? 1; + let failure = result.failure; + const persisted: { stdoutPath?: string; stderrPath?: string } = {}; + for (const [key, path, contents] of [["stdoutPath", stdoutPath, result.stdout], ["stderrPath", stderrPath, result.stderr]] as const) { + try { writeFileSync(path, contents); persisted[key] = rel(targetDir, path); } + catch (error) { failure = `${failure ? `${failure}; ` : ""}Cannot persist ${key}: ${errorMessage(error)}`; } + } + const exitCode = failure ? result.code || 1 : result.code ?? 1; return { adapterId: args.adapterId, - status: exitCode === 0 ? "launched" : "failed", - launched: true, + status: !failure && exitCode === 0 ? "launched" : "failed", + launched: result.launched, command, promptPath: rel(targetDir, args.promptPath), exitCode, - stdoutPath: rel(targetDir, stdoutPath), - stderrPath: rel(targetDir, stderrPath), - message: exitCode === 0 ? `${args.adapterId} completed; rerun the Proof Loop gate.` : `${args.adapterId} exited ${exitCode}.`, + ...persisted, + message: failure ?? (exitCode === 0 ? `${args.adapterId} completed; rerun the Proof Loop gate.` : `${args.adapterId} exited ${exitCode}.`), + }; +} + +const REPAIR_OUTPUT_BYTES = 1024 * 1024; +const CLEANUP_BUDGET_MS = process.platform === "win32" ? 4000 : 2000; + +type RepairProcessResult = { launched: boolean; code: number | null; stdout: Buffer; stderr: Buffer; failure?: string }; + +function runRepairProcess(command: string, cwd: string, prompt: string, env: NodeJS.ProcessEnv, timeoutMs: number, signal?: AbortSignal): Promise { + return new Promise((resolveResult) => { + let child: ChildProcessWithoutNullStreams; + try { + child = spawn(command, { cwd, env, shell: true, detached: process.platform !== "win32", windowsHide: true, stdio: ["pipe", "pipe", "pipe"] }); + } catch (error) { + resolveResult({ launched: false, code: null, stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), failure: `Repair launch failed: ${errorMessage(error)}` }); + return; + } + let launched = false, closed = false, exited = false, settled = false; + let code: number | null = null, failure: string | undefined; + const output = { stdout: [] as Buffer[], stderr: [] as Buffer[] }; + const bytes = { stdout: 0, stderr: 0 }; + const finish = () => { + if (settled) return; + settled = true; + clearTimeout(deadline); + signal?.removeEventListener("abort", abort); + child.stdin.destroy(); child.stdout.destroy(); child.stderr.destroy(); + child.unref(); + resolveResult({ launched, code, stdout: Buffer.concat(output.stdout), stderr: Buffer.concat(output.stderr), ...(failure ? { failure } : {}) }); + }; + const fail = (reason: string) => { + if (settled || failure) return; + failure = reason; + clearTimeout(deadline); + if (!child.pid) { finish(); return; } + // A root which already exited cannot identify a remaining Windows process tree safely. + void terminateOwnedRepair(child.pid, () => closed, exited).then(confirmed => { + if (!confirmed) failure += "; cleanup-unverified: owned process tree or output closure could not be confirmed; stop automation."; + finish(); + }, error => { + failure += `; cleanup-unverified: ${errorMessage(error)}; stop automation.`; + finish(); + }); + }; + const abort = () => fail("Repair cancelled by caller."); + const deadline = setTimeout(() => fail(`Repair timed out after ${timeoutMs}ms.`), timeoutMs); + child.once("spawn", () => { launched = true; }); + child.once("error", error => fail(`Repair launch failed: ${errorMessage(error)}`)); + child.once("exit", status => { exited = true; code = status; }); + child.once("close", status => { closed = true; code = status; if (!failure) finish(); }); + for (const stream of ["stdout", "stderr"] as const) { + child[stream].on("data", (chunk: Buffer) => { + if (settled) return; + const remaining = REPAIR_OUTPUT_BYTES - bytes[stream]; + if (remaining > 0) { const captured = Buffer.from(chunk.subarray(0, remaining)); output[stream].push(captured); bytes[stream] += captured.length; } + if (chunk.length > remaining) fail(`Repair ${stream} exceeded ${REPAIR_OUTPUT_BYTES} bytes.`); + }); + child[stream].on("error", error => fail(`Repair ${stream} failed: ${errorMessage(error)}`)); + } + child.stdin.on("error", error => fail(`Repair stdin failed: ${errorMessage(error)}`)); + signal?.addEventListener("abort", abort, { once: true }); + if (signal?.aborted) abort(); + else child.stdin.end(prompt); + }); +} + +async function terminateOwnedRepair(pid: number, closed: () => boolean, rootAlreadyExited: boolean): Promise { + const until = Date.now() + CLEANUP_BUDGET_MS; + if (process.platform === "win32") { + if (rootAlreadyExited) return false; + const killed = await new Promise(resolveKill => { + const killer = spawn(join(process.env.SystemRoot ?? "C:\\Windows", "System32", "taskkill.exe"), ["/PID", String(pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" }); + let done = false; + const finish = (ok: boolean) => { if (!done) { done = true; clearTimeout(timer); resolveKill(ok); } }; + const timer = setTimeout(() => { + try { killer.kill(); } catch { /* Helper termination is unverified too. */ } + killer.unref(); finish(false); + }, 3000); + killer.once("error", () => finish(false)); + killer.once("close", status => finish(status === 0)); + }); + while (killed && !closed() && Date.now() < until) await pause(25); + // A helper exit alone is insufficient: require the owned shell's drained close too. + return killed && closed(); + } + const groupExists = () => { + try { process.kill(-pid, 0); return true; } + catch (error) { return (error as NodeJS.ErrnoException).code !== "ESRCH"; } }; + const send = (signal: NodeJS.Signals) => { + try { process.kill(-pid, signal); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ESRCH") return false; } + return true; + }; + if (!send("SIGTERM")) return false; + const escalateAt = Date.now() + 250; + while (groupExists() && Date.now() < escalateAt) await pause(25); + if (groupExists() && !send("SIGKILL")) return false; + while ((!closed() || groupExists()) && Date.now() < until) await pause(25); + return closed() && !groupExists(); } +function pause(ms: number): Promise { return new Promise(resolvePause => setTimeout(resolvePause, ms)); } +function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } + export function collectProofloopAgentTrace(args: { adapterId: ProofloopAgentAdapterId; runDir: string; diff --git a/src/agentLoop.ts b/src/agentLoop.ts index 95816c6..3533230 100644 --- a/src/agentLoop.ts +++ b/src/agentLoop.ts @@ -9,6 +9,7 @@ import { } from "./agentAdapters"; import { writeCodexRelaunchPacket } from "./codexRelaunch"; import { gateStatePath, runGateCli, type GateState } from "./gate"; +import { repairTimeoutMs } from "./repairDeadline"; export type ProofloopAgentLoopResult = { runId: string; @@ -23,6 +24,8 @@ export async function runProofloopAgentLoop(options: { root?: string; agentId?: ProofloopAgentAdapterId; maxAttempts?: number; + timeoutMs?: number; + signal?: AbortSignal; dryRun?: boolean; command?: string; runId?: string; @@ -30,6 +33,7 @@ export async function runProofloopAgentLoop(options: { logError?: (line: string) => void; }): Promise { const root = resolve(options.root ?? process.cwd()); + const timeoutMs = repairTimeoutMs(options.timeoutMs); const agentId = options.agentId ?? "codex"; const maxAttempts = options.maxAttempts === undefined ? 1 : options.maxAttempts; if (!Number.isSafeInteger(maxAttempts) || maxAttempts <= 0) { @@ -37,6 +41,7 @@ export async function runProofloopAgentLoop(options: { } const runId = options.runId ?? `gate-${compactTimestamp(new Date())}`; const runDir = join(root, ".proofloop", "runs", runId); + if (options.signal?.aborted) return { runId, runDir, exitCode: 1, attempts: 0, passed: false }; mkdirSync(runDir, { recursive: true }); let attempts = 0; @@ -76,10 +81,12 @@ export async function runProofloopAgentLoop(options: { promptPath: repairPromptPath, message: "dry run; agent was not launched", } - : launchProofloopAgentAdapter({ + : await launchProofloopAgentAdapter({ adapterId: agentId, promptPath: repairPromptPath, targetDir: root, + timeoutMs, + signal: options.signal, ...(options.command ? { command: options.command } : {}), }); writeAgentRepairAttemptReceipt({ @@ -94,7 +101,7 @@ export async function runProofloopAgentLoop(options: { }); if (runResult.launched) attempts++; - if (options.dryRun || !runResult.launched || runResult.status === "failed") { + if (options.dryRun || !runResult.launched || runResult.status === "failed" || options.signal?.aborted) { return { runId, exitCode: lastExit || 1, attempts, passed: false, runDir, repairPromptPath }; } } diff --git a/src/cli.ts b/src/cli.ts index d96a02b..f21bdf5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -90,6 +90,7 @@ import { setupProofloopAgentAdapter, } from "./agentAdapters"; import { runProofloopAgentLoop } from "./agentLoop"; +import { repairTimeoutMs } from "./repairDeadline"; import { codexRunDir, latestProofloopRunDir, readCodexReprompt } from "./codexRelaunch"; import { PROOFLOOP_PROVIDER_IDS, @@ -179,7 +180,7 @@ function usage(): string { " maturity [--dense|--json|--write] [--target-level 5] judge agent-era codebase/app maturity and missing layers", " productivity [--write] [--baseline-source ] write wage-equivalent verified productivity receipts and charts", " agents list|setup [id|all] install/report agent adapters for Codex, Claude Code, and wrapper hosts", - " codex-loop [--dry-run] [--max-attempts 2] run gate, write repair prompt, optionally relaunch Codex", + " codex-loop [--dry-run] [--max-attempts 2] [--timeout-ms 1800000] gate and bounded repairs", " codex reprompt|relaunch [run-id] show or launch the latest Codex repair prompt", " providers setup [id|all] write provider setup receipts for live integration lanes", " mcp start the optional read-only MCP server", @@ -519,14 +520,17 @@ async function runAgentsCommand(sub: string | undefined, adapter: string | undef async function runCodexLoopCommand(options: Record, root: string): Promise { try { const agentId = parseProofloopAgentAdapterId(str(options.agent) ?? "codex"); - const result = await runProofloopAgentLoop({ + const timeoutMs = repairTimeoutMs(options["timeout-ms"] === undefined ? undefined : Number(str(options["timeout-ms"]) ?? NaN)); + const result = await withRepairCancellation(signal => runProofloopAgentLoop({ root, agentId, dryRun: options["dry-run"] === true, + timeoutMs, + signal, ...(str(options.command) !== undefined ? { command: str(options.command)! } : {}), ...(options["max-attempts"] !== undefined ? { maxAttempts: Number(str(options["max-attempts"]) ?? NaN) } : {}), ...(str(options["run-id"]) !== undefined ? { runId: str(options["run-id"])! } : {}), - }); + })); console.log(`proofloop codex-loop: ${result.passed ? "passed" : "needs-repair"} run=${result.runId} attempts=${result.attempts}`); console.log(` runDir=${result.runDir}`); if (result.repairPromptPath) console.log(` repairPrompt=${result.repairPromptPath}`); @@ -537,34 +541,52 @@ async function runCodexLoopCommand(options: Record, ro } } -function runCodexCommand(sub: string | undefined, runId: string | undefined, options: Record, root: string): number { - if (sub !== "reprompt" && sub !== "relaunch") { - console.error("proofloop codex: expected `reprompt` or `relaunch`."); - return 2; - } - const runDir = runId ? codexRunDir(root, runId) : latestProofloopRunDir(root); - if (!runDir) { - console.error("proofloop codex: no .proofloop/runs/ directory found. Run `npx proofloop codex-loop --dry-run` first."); - return 2; - } - const promptPath = join(runDir, "codex-reprompt.md"); - const prompt = readCodexReprompt(promptPath); - if (!prompt) { - console.error(`proofloop codex: no reprompt found at ${promptPath}.`); +async function runCodexCommand(sub: string | undefined, runId: string | undefined, options: Record, root: string): Promise { + try { + const timeoutMs = repairTimeoutMs(options["timeout-ms"] === undefined ? undefined : Number(str(options["timeout-ms"]) ?? NaN)); + if (sub !== "reprompt" && sub !== "relaunch") { + console.error("proofloop codex: expected `reprompt` or `relaunch`."); + return 2; + } + const runDir = runId ? codexRunDir(root, runId) : latestProofloopRunDir(root); + if (!runDir) { + console.error("proofloop codex: no .proofloop/runs/ directory found. Run `npx proofloop codex-loop --dry-run` first."); + return 2; + } + const promptPath = join(runDir, "codex-reprompt.md"); + const prompt = readCodexReprompt(promptPath); + if (!prompt) { + console.error(`proofloop codex: no reprompt found at ${promptPath}.`); + return 2; + } + if (sub === "reprompt") { + console.log(prompt); + return 0; + } + const result = await withRepairCancellation(signal => launchProofloopAgentAdapter({ + adapterId: "codex", + promptPath, + targetDir: root, + timeoutMs, + signal, + ...(str(options.command) !== undefined ? { command: str(options.command)! } : {}), + })); + console.log(JSON.stringify(result, null, 2)); + return result.launched && result.status !== "failed" ? 0 : 1; + } catch (error) { + console.error(`proofloop codex: ${error instanceof Error ? error.message : String(error)}`); return 2; } - if (sub === "reprompt") { - console.log(prompt); - return 0; - } - const result = launchProofloopAgentAdapter({ - adapterId: "codex", - promptPath, - targetDir: root, - ...(str(options.command) !== undefined ? { command: str(options.command)! } : {}), - }); - console.log(JSON.stringify(result, null, 2)); - return result.launched && result.status !== "failed" ? 0 : 1; +} + +/** Only the CLI owns global interruption handlers; library callers pass AbortSignal. */ +async function withRepairCancellation(run: (signal: AbortSignal) => Promise): Promise { + const controller = new AbortController(); + const interrupt = () => controller.abort(); + const signals: NodeJS.Signals[] = ["SIGINT", "SIGTERM", "SIGHUP"]; + for (const signal of signals) process.on(signal, interrupt); + try { return await run(controller.signal); } + finally { for (const signal of signals) process.off(signal, interrupt); } } async function runProvidersCommand(sub: string | undefined, provider: string | undefined, options: Record, root: string): Promise { diff --git a/src/repairDeadline.ts b/src/repairDeadline.ts new file mode 100644 index 0000000..4981360 --- /dev/null +++ b/src/repairDeadline.ts @@ -0,0 +1,8 @@ +/** Shared private policy for the launcher, loop and CLI; not a total-loop deadline. */ +export function repairTimeoutMs(value: number | undefined): number { + if (value === undefined) return 30 * 60 * 1000; + if (!Number.isInteger(value) || value < 1 || value > 2147483647) { + throw new Error("timeoutMs must be an integer from 1 to 2147483647"); + } + return value; +} diff --git a/tests/agentAdapters.test.ts b/tests/agentAdapters.test.ts index f73b12e..86667fc 100644 --- a/tests/agentAdapters.test.ts +++ b/tests/agentAdapters.test.ts @@ -1,9 +1,12 @@ -import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { getEventListeners } from "node:events"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { buildAgentRepairPrompt, + getProofloopAgentAdapter, + launchProofloopAgentAdapter, setupProofloopAgentAdapter, writeAgentRepairAttemptReceipt, type AgentRunResult, @@ -107,3 +110,257 @@ function tempRoot(): string { tempRoots.push(root); return root; } + +describe("a developer waiting for a repair deadline", () => { + it("lets the caller run while a real repair is active, then ends it before its own expiry", async () => { + const root = tempRoot(); + const promptPath = join(root, "prompt.md"); + writeFileSync(promptPath, "Repair the two local checks without weakening them."); + writeFileSync(join(root, "slow.cjs"), ` +const fs=require('node:fs'); +if(fs.readFileSync(0,'utf8')!==fs.readFileSync(process.env.PROOFLOOP_REPAIR_PROMPT,'utf8'))throw Error('prompt mismatch'); +fs.writeFileSync('ready','yes'); +const timer=setInterval(()=>fs.appendFileSync('beats','.'),100); +setTimeout(()=>{clearInterval(timer);fs.writeFileSync('expired','yes');process.exitCode=7;},4000); +`); + let timerRan = false; + const timer = setTimeout(() => { timerRan = true; }, 100); + const started = Date.now(); + const pending = getProofloopAgentAdapter("generic-cli").launch(promptPath, root, { + command: `"${process.execPath}" slow.cjs`, timeoutMs: 500, + }); + const returnedAfter = Date.now() - started; + const result = await pending; + clearTimeout(timer); + expect(returnedAfter).toBeLessThan(300); + expect(timerRan).toBe(true); + expect(result).toMatchObject({ launched: true, status: "failed" }); + expect(result.message).toContain("timed out"); + expect(existsSync(join(root, "ready"))).toBe(true); + expect(existsSync(join(root, "expired"))).toBe(false); + const beats = readFileSync(join(root, "beats"), "utf8"); + await new Promise(resolve => setTimeout(resolve, 300)); + expect(readFileSync(join(root, "beats"), "utf8")).toBe(beats); + expect(Date.now() - started).toBeLessThan(3500); + }, 10000); +}); + +describe("a developer's interrupted or degraded local repair", () => { + it("cancels an ordinary descendant tree without leaving its heartbeat active", async () => { + const root = agentFixture(` +const {spawn}=require('node:child_process'); +spawn(process.execPath,['-e',"const fs=require('node:fs');const t=setInterval(()=>fs.appendFileSync('descendant-beats','.'),50);setTimeout(()=>{clearInterval(t);fs.writeFileSync('descendant-expired','yes');},6000)"],{stdio:['ignore','inherit','inherit']}); +fs.writeFileSync('ready','yes');setTimeout(()=>{},6000); +`); + const controller = new AbortController(); + const pending = launch(root, { timeoutMs: 3000, signal: controller.signal }); + await until(() => existsSync(join(root, "descendant-beats"))); + controller.abort(); + const result = await pending; + expect(result).toMatchObject({ status: "failed", launched: true }); + expect(result.message).toContain("cancelled"); + expect(result.message).not.toContain("cleanup-unverified"); + const beats = readFileSync(join(root, "descendant-beats"), "utf8"); + await wait(300); + expect(readFileSync(join(root, "descendant-beats"), "utf8")).toBe(beats); + expect(existsSync(join(root, "descendant-expired"))).toBe(false); + expect(getEventListeners(controller.signal, "abort")).toHaveLength(0); + }, 10000); + + it("never turns cancellation into success when the repair was about to exit zero", async () => { + const root = agentFixture("fs.writeFileSync('ready','yes');setTimeout(()=>{},300);"); + const controller = new AbortController(); + const pending = launch(root, { signal: controller.signal, timeoutMs: 2000 }); + await until(() => existsSync(join(root, "ready"))); + controller.abort(); + expect(await pending).toMatchObject({ status: "failed", launched: true, exitCode: 1 }); + expect(getEventListeners(controller.signal, "abort")).toHaveLength(0); + }); + + it("rejects malformed deadlines and pre-abort before reading a prompt or launching", async () => { + const root = tempRoot(); + for (const timeoutMs of [0, -1, 1.5, NaN, Infinity, 2147483648]) { + await expect(launchProofloopAgentAdapter({ adapterId: "generic-cli", targetDir: root, promptPath: join(root, "missing"), timeoutMs, command: "must-not-run" })) + .rejects.toThrow("timeoutMs must be an integer"); + } + const controller = new AbortController(); controller.abort(); + const result = await launchProofloopAgentAdapter({ adapterId: "generic-cli", targetDir: root, promptPath: join(root, "missing"), signal: controller.signal, command: "must-not-run" }); + expect(result).toMatchObject({ launched: false, status: "failed" }); + expect(result.message).toContain("before launch"); + expect(existsSync(join(root, "generic-cli-stdout.log"))).toBe(false); + }); + + it("reports missing input and an OS launch failure without counting a launch", async () => { + const root = agentFixture("console.log('done');"); + const missing = await launchProofloopAgentAdapter({ adapterId: "generic-cli", targetDir: root, promptPath: join(root, "missing"), command: "unused" }); + expect(missing).toMatchObject({ launched: false, status: "failed" }); + expect(missing.message).toContain("Cannot read repair prompt"); + const result = await launchProofloopAgentAdapter({ adapterId: "generic-cli", targetDir: join(root, "missing-dir"), promptPath: join(root, "prompt.md"), command: `"${process.execPath}" repair.cjs`, timeoutMs: 500 }); + expect(result).toMatchObject({ launched: false, status: "failed" }); + expect(result.message).toContain("launch failed"); + }); + + it.each(["stdout", "stderr"] as const)("bounds %s when a local repair floods output", async stream => { + const root = agentFixture(`process.${stream}.write(Buffer.alloc(1048576+8192,120));setTimeout(()=>{},1000);`); + const result = await launch(root, { timeoutMs: 2000 }); + expect(result).toMatchObject({ status: "failed", launched: true }); + expect(result.message).toContain(`${stream} exceeded 1048576 bytes`); + expect(statSync(join(root, `generic-cli-${stream}.log`)).size).toBe(1048576); + }); + + it("does not claim success when a repair closes stdin before accepting its prompt", async () => { + const root = agentFixture("", false); + writeFileSync(join(root, "prompt.md"), "x".repeat(4 * 1024 * 1024)); + writeFileSync(join(root, "repair.cjs"), "require('node:fs').closeSync(0);setTimeout(()=>{},500);"); + const result = await launch(root, { timeoutMs: 1500 }); + expect(result.status).toBe("failed"); + expect(result.message).toContain("stdin failed"); + }); + + it("reports a log persistence failure despite a zero-exit repair", async () => { + const root = agentFixture("console.log('local repair completed');"); + mkdirSync(join(root, "generic-cli-stdout.log")); + const result = await launch(root, { timeoutMs: 2000 }); + expect(result).toMatchObject({ status: "failed", launched: true }); + expect(result.message).toContain("Cannot persist stdoutPath"); + expect(result.stdoutPath).toBeUndefined(); + expect(result.stderrPath).toBeDefined(); + }); + + it.skipIf(process.platform !== "win32")("stops honestly when the host cannot start its tree-termination helper", async () => { + const root = agentFixture("fs.writeFileSync('ready','yes');setTimeout(()=>{fs.writeFileSync('expired','yes');},800);"); + const env = { ...process.env }; + const systemRoot = process.env.SystemRoot; + try { + // Real failed helper spawn, with the repair itself using the original valid OS environment. + process.env.SystemRoot = join(root, "unavailable-host-tools"); + const result = await launchProofloopAgentAdapter({ adapterId: "generic-cli", targetDir: root, promptPath: join(root, "prompt.md"), command: `"${process.execPath}" repair.cjs`, timeoutMs: 200, env }); + expect(result).toMatchObject({ status: "failed", launched: true }); + expect(result.message).toContain("cleanup-unverified"); + expect(result.message).toContain("stop automation"); + } finally { + if (systemRoot === undefined) delete process.env.SystemRoot; else process.env.SystemRoot = systemRoot; + await wait(1100); // Only this intentionally uncontained finite fixture expires itself. + } + expect(existsSync(join(root, "expired"))).toBe(true); + }); + + // The real Windows Node fixture did not retain the pipe after root exit; it cannot certify this case. + it.skipIf(process.platform === "win32")("fails boundedly when the root exits but a descendant retains the output pipe", async () => { + const root = agentFixture(` +require('node:child_process').spawn(process.execPath,['descendant.cjs'],{stdio:['ignore','inherit','inherit']}); +const poll=setInterval(()=>{if(fs.existsSync('pipe-ready')){clearInterval(poll);process.exit(0);}},10); +setTimeout(()=>{clearInterval(poll);process.exit(8);},2200); +`); + writeFileSync(join(root, "descendant.cjs"), ` +const fs=require('node:fs'),parentPid=process.ppid; +fs.writeFileSync('pipe-ready',JSON.stringify({pid:process.pid,parentPid,expiresAt:Date.now()+6000})); +const beat=setInterval(()=>{ + fs.appendFileSync('pipe-beats','.'); + process.stdout.write('.'); + if(!fs.existsSync('root-exited')){ + try{process.kill(parentPid,0);}catch(error){ + if(error.code!=='ESRCH')throw error; + fs.writeFileSync('root-exited',JSON.stringify({parentPid,observedAt:Date.now()})); + console.log('inherited pipe after root exit'); + } + } +},25); +setTimeout(()=>{clearInterval(beat);fs.writeFileSync('pipe-expired','yes');},6000); +`); + const start = Date.now(); + let stoppedBeforeExpiry = false; + try { + const result = await launch(root, { timeoutMs: 350 }); + const ready = existsSync(join(root, "pipe-ready")) ? JSON.parse(readFileSync(join(root, "pipe-ready"), "utf8")) : null; + const rootExit = existsSync(join(root, "root-exited")) ? JSON.parse(readFileSync(join(root, "root-exited"), "utf8")) : null; + const beats = existsSync(join(root, "pipe-beats")) ? readFileSync(join(root, "pipe-beats"), "utf8") : ""; + await wait(300); + const after = existsSync(join(root, "pipe-beats")) ? readFileSync(join(root, "pipe-beats"), "utf8") : ""; + const expired = existsSync(join(root, "pipe-expired")); + console.log(JSON.stringify({ scenario: "POSIX inherited pipe", elapsedMs: Date.now() - start, ready, rootExit, heartbeatBefore: beats.length, heartbeatAfter: after.length, expired, result })); + expect(result.status).toBe("failed"); + expect(result.message).toContain("timed out"); + // Stopped activity is independent evidence; it does not override an unverified group result. + if (result.message.includes("cleanup-unverified")) expect(result.message).toContain("stop automation"); + expect(ready).not.toBeNull(); + expect(rootExit?.parentPid).toBe(ready.parentPid); + expect(rootExit.observedAt - start).toBeLessThan(350); + expect(readFileSync(join(root, "generic-cli-stdout.log"), "utf8")).toContain("inherited pipe after root exit"); + expect(beats.length).toBeGreaterThan(0); + expect(after).toBe(beats); + expect(expired).toBe(false); + expect(Date.now()).toBeLessThan(ready.expiresAt); + expect(Date.now() - start).toBeLessThan(3000); + stoppedBeforeExpiry = true; + } finally { + // On a failed assertion let this owned finite fixture expire before temp-root teardown. + if (!stoppedBeforeExpiry) await wait(Math.max(0, 8000 - (Date.now() - start))); + } + }, 12000); + + it.skipIf(process.platform === "win32")("escalates a POSIX repair that ignores TERM", async () => { + const root = agentFixture(` +let termAt; +process.on('SIGTERM',()=>{termAt=Date.now();fs.writeFileSync('term-received',String(termAt));}); +fs.writeFileSync('ready',JSON.stringify({pid:process.pid,expiresAt:Date.now()+6000})); +const beat=setInterval(()=>{ + fs.appendFileSync('beats','.'); + if(termAt!==undefined)fs.writeFileSync('active-after-term',String(Date.now())); +},25); +setTimeout(()=>{clearInterval(beat);fs.writeFileSync('expired','yes');},6000); +`); + const start = Date.now(); + let stoppedBeforeExpiry = false; + try { + const result = await launch(root, { timeoutMs: 500 }); + const ready = existsSync(join(root, "ready")) ? JSON.parse(readFileSync(join(root, "ready"), "utf8")) : null; + const termAt = existsSync(join(root, "term-received")) ? Number(readFileSync(join(root, "term-received"), "utf8")) : null; + const activeAfterTerm = existsSync(join(root, "active-after-term")) ? Number(readFileSync(join(root, "active-after-term"), "utf8")) : null; + const beats = existsSync(join(root, "beats")) ? readFileSync(join(root, "beats"), "utf8") : ""; + await wait(300); + const after = existsSync(join(root, "beats")) ? readFileSync(join(root, "beats"), "utf8") : ""; + const expired = existsSync(join(root, "expired")); + console.log(JSON.stringify({ scenario: "POSIX ignored TERM", elapsedMs: Date.now() - start, ready, termAt, activeAfterTerm, heartbeatBefore: beats.length, heartbeatAfter: after.length, expired, result })); + expect(result.status).toBe("failed"); + expect(result.message).toContain("timed out"); + if (result.message.includes("cleanup-unverified")) expect(result.message).toContain("stop automation"); + expect(ready).not.toBeNull(); + expect(termAt).not.toBeNull(); + expect(activeAfterTerm).toBeGreaterThan(termAt!); + expect(beats.length).toBeGreaterThan(0); + expect(after).toBe(beats); + expect(expired).toBe(false); + expect(Date.now()).toBeLessThan(ready.expiresAt); + expect(Date.now() - start).toBeLessThan(3000); + stoppedBeforeExpiry = true; + } finally { + if (!stoppedBeforeExpiry) await wait(Math.max(0, 8000 - (Date.now() - start))); + } + }, 12000); + + it("removes cancellation listeners after repeated normal completions", async () => { + const controller = new AbortController(); + for (let i = 0; i < 6; i++) { + const root = agentFixture("console.log('finished');"); + expect(await launch(root, { timeoutMs: 1000, signal: controller.signal })).toMatchObject({ status: "launched", exitCode: 0 }); + expect(getEventListeners(controller.signal, "abort")).toHaveLength(0); + } + }); +}); + +function agentFixture(source: string, readPrompt = true): string { + const root = tempRoot(); + writeFileSync(join(root, "prompt.md"), "Repair the local checks; do not weaken them."); + writeFileSync(join(root, "repair.cjs"), `const fs=require('node:fs');${readPrompt ? "if(fs.readFileSync(0,'utf8')!==fs.readFileSync(process.env.PROOFLOOP_REPAIR_PROMPT,'utf8'))throw Error('prompt mismatch');" : ""}\n${source}`); + return root; +} +function launch(root: string, options: { timeoutMs?: number; signal?: AbortSignal } = {}) { + return launchProofloopAgentAdapter({ adapterId: "generic-cli", targetDir: root, promptPath: join(root, "prompt.md"), command: `"${process.execPath}" repair.cjs`, ...options }); +} +function wait(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } +async function until(condition: () => boolean): Promise { + const end = Date.now() + 2000; + while (!condition() && Date.now() < end) await wait(20); + expect(condition()).toBe(true); +} diff --git a/tests/codexLoop.test.ts b/tests/codexLoop.test.ts index c85b305..67cc721 100644 --- a/tests/codexLoop.test.ts +++ b/tests/codexLoop.test.ts @@ -1,5 +1,5 @@ import { spawn } from "node:child_process"; -import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -245,3 +245,132 @@ function tempRoot(): string { function nodeCommand(source: string): string { return `${JSON.stringify(process.execPath)} -e ${JSON.stringify(source)}`; } + +describe("a developer's elapsed-time repair budget", () => { + it("writes a timeout receipt and stops before another repair or verification", async () => { + const root = deadlineProject(); + const result = await repair(root, { maxAttempts: 3, timeoutMs: 350 }); + expect(result).toMatchObject({ passed: false, exitCode: 1, attempts: 1 }); + expectGateHistory(result.runDir, ["failed"]); + const receipt = readJson(join(result.runDir, "generic-cli-repair-attempt.json")); + expect(receipt.runResult).toMatchObject({ status: "failed", launched: true }); + expect(receipt.runResult.message).toContain("timed out"); + expect(existsSync(join(root, "expired"))).toBe(false); + expect(projectState(root).repairs).toBe(1); + expect(existsSync(join(result.runDir, "generic-cli-repair-prompt-2.md"))).toBe(false); + }); + + it("rejects invalid API and CLI deadlines before gates or receipts", async () => { + const root = repairProject(); + for (const timeoutMs of [0, -1, 1.1, NaN, Infinity, 2147483648]) { + await expect(repair(root, { timeoutMs })).rejects.toThrow("timeoutMs must be an integer"); + } + for (const value of ["", "wat", "0", "-1", "1.1", "Infinity", "2147483648"]) { + expect(await runCli(["--dir", root, "codex-loop", `--timeout-ms=${value}`])).toBe(2); + expect(await runCli(["--dir", root, "codex", "relaunch", "not-created", `--timeout-ms=${value}`])).toBe(2); + } + expect(await runCli(["--dir", root, "codex-loop", "--timeout-ms"])).toBe(2); + expect(existsSync(join(root, "events.jsonl"))).toBe(false); + expect(existsSync(join(root, ".proofloop"))).toBe(false); + }); + + it("does no work for a pre-aborted project and stops after a cancelled active repair", async () => { + const root = deadlineProject(); + const pre = new AbortController(); pre.abort(); + expect(await repair(root, { signal: pre.signal })).toMatchObject({ attempts: 0, passed: false }); + expect(existsSync(join(root, ".proofloop"))).toBe(false); + const controller = new AbortController(); + const pending = repair(root, { signal: controller.signal, timeoutMs: 2000, maxAttempts: 3 }); + await waitForFile(join(root, "ready")); + controller.abort(); + const result = await pending; + expect(result).toMatchObject({ attempts: 1, passed: false }); + expectGateHistory(result.runDir, ["failed"]); + expect(readJson(join(result.runDir, "generic-cli-repair-attempt.json")).runResult.message).toContain("cancelled"); + }); + + it("awaits a bounded direct Codex relaunch supplied with an explicit local command", async () => { + const root = deadlineProject(); + const runDir = join(root, ".proofloop", "runs", "direct"); + mkdirSync(runDir, { recursive: true }); + writeFileSync(join(runDir, "codex-reprompt.md"), "Repair the local project; do not weaken its checks."); + const signals = ["SIGINT", "SIGTERM", "SIGHUP"] as const; + const before = signals.map(s => process.listenerCount(s)); + const started = Date.now(); + try { + expect(await runCli(["--dir", root, "codex", "relaunch", "direct", "--command", `"${process.execPath}" repair.cjs`, "--timeout-ms", "350"])).toBe(1); + expect(existsSync(join(runDir, "codex-stdout.log"))).toBe(true); + const beats = readFileSync(join(root, "beats"), "utf8"); + await new Promise(r => setTimeout(r, 300)); + expect(readFileSync(join(root, "beats"), "utf8")).toBe(beats); + expect(existsSync(join(root, "expired"))).toBe(false); + expect(signals.map(s => process.listenerCount(s))).toEqual(before); + } finally { + // On a failed cleanup assertion the finite fixture must expire before the existing temp-root teardown. + await new Promise(r => setTimeout(r, Math.max(0, 6500 - (Date.now() - started)))); + } + }, 10000); + + it("keeps a three-job CLI burst isolated across success, timeout and partial repair", async () => { + const roots = [repairProject(), deadlineProject(), repairProject({ partial: true })]; + const results = await Promise.all(roots.map((root, i) => runDeadlineCli(root, `deadline-burst-${i}`))); + expect(results.map(r => r.code)).toEqual([0, 1, 1]); + expect(results[0].output).toContain("attempts=1"); + expect(results[1].output).toContain("attempts=1"); + expect(results[2].output).toContain("attempts=2"); + expectGateHistory(join(roots[1], ".proofloop/runs/deadline-burst-1"), ["failed"]); + const beats = readFileSync(join(roots[1], "beats"), "utf8"); + await new Promise(r => setTimeout(r, 200)); + expect(readFileSync(join(roots[1], "beats"), "utf8")).toBe(beats); + }, 15000); + + it("finishes twelve successive CLI jobs with separate receipts and no continued timed-out heartbeat", async () => { + const snapshots: Array<{ file: string; text: string }> = []; + for (let i = 0; i < 12; i++) { + const slow = i % 2 === 1, root = slow ? deadlineProject() : repairProject(), id = `deadline-sustained-${i}`; + const result = await runDeadlineCli(root, id); + expect(result.code).toBe(slow ? 1 : 0); + expect(result.output).toContain("attempts=1"); + const dir = join(root, ".proofloop/runs", id); + expectGateHistory(dir, slow ? ["failed"] : ["failed", "passed"]); + const file = join(dir, "generic-cli-repair-attempt.json"); + snapshots.push({ file, text: readFileSync(file, "utf8") }); + if (slow) { + const beats = readFileSync(join(root, "beats"), "utf8"); + await new Promise(r => setTimeout(r, 150)); + expect(readFileSync(join(root, "beats"), "utf8")).toBe(beats); + expect(existsSync(join(root, "expired"))).toBe(false); + } + } + for (const s of snapshots) expect(readFileSync(s.file, "utf8")).toBe(s.text); + }, 45000); + + it.skipIf(process.platform === "win32")("forwards a real CLI interrupt to its active POSIX repair and writes the failure receipt", async () => { + const root = deadlineProject(), id = "interrupt"; + const child = spawn(process.execPath, [join(process.cwd(), "dist/cli.js"), "--dir", root, "codex-loop", "--agent", "generic-cli", "--command", `"${process.execPath}" repair.cjs`, "--run-id", id, "--timeout-ms", "3000"], { stdio: "ignore", timeout: 10000 }); + const closed = new Promise(resolve => child.once("close", code => resolve(code))); + await waitForFile(join(root, "ready")); child.kill("SIGINT"); + expect(await closed).toBe(1); + expect(readJson(join(root, ".proofloop/runs", id, "generic-cli-repair-attempt.json")).runResult.message).toContain("cancelled"); + expectGateHistory(join(root, ".proofloop/runs", id), ["failed"]); + }, 15000); +}); + +function deadlineProject(): string { + const root = repairProject(); + writeFileSync(join(root, "repair.cjs"), `const fs=require('node:fs');const prompt=fs.readFileSync(0,'utf8');if(prompt!==fs.readFileSync(process.env.PROOFLOOP_REPAIR_PROMPT,'utf8'))throw Error('prompt mismatch');const state=JSON.parse(fs.readFileSync('project.json'));state.repairs++;fs.writeFileSync('project.json',JSON.stringify(state));fs.writeFileSync('ready','yes');const t=setInterval(()=>fs.appendFileSync('beats','.'),50);setTimeout(()=>{clearInterval(t);fs.writeFileSync('expired','yes');process.exitCode=7;},6000);`); + return root; +} +async function waitForFile(file: string): Promise { + const end = Date.now() + 2500; + while (!existsSync(file) && Date.now() < end) await new Promise(r => setTimeout(r, 20)); + expect(existsSync(file)).toBe(true); +} +function runDeadlineCli(root: string, id: string): Promise<{ code: number | null; output: string }> { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [join(process.cwd(), "dist/cli.js"), "--dir", root, "codex-loop", "--agent", "generic-cli", "--command", `"${process.execPath}" repair.cjs`, "--run-id", id, "--max-attempts", "2", "--timeout-ms", "500"], { timeout: 12000, windowsHide: true }); + let output = ""; + child.stdout.on("data", b => { output += b; }); child.stderr.on("data", b => { output += b; }); + child.once("error", reject); child.once("close", code => resolve({ code, output })); + }); +} diff --git a/tests/walkthrough.test.ts b/tests/walkthrough.test.ts index c71434e..afc9556 100644 --- a/tests/walkthrough.test.ts +++ b/tests/walkthrough.test.ts @@ -35,13 +35,13 @@ function lines(relPath: string): string[] { /** [file, 1-indexed line, text that line must contain] */ const ANCHORS: Array<[string, number, string]> = [ - ["src/cli.ts", 105, "MCP_SERVER_RUNNING"], - ["src/cli.ts", 196, "export function runCli"], - ["src/cli.ts", 221, 'case "gate":'], - ["src/cli.ts", 519, "runCodexLoopCommand"], - ["src/cli.ts", 1070, "runHooksCommand"], - ["src/cli.ts", 1158, "require.main === module"], - ["src/cli.ts", 1161, "catch((error"], + ["src/cli.ts", 106, "MCP_SERVER_RUNNING"], + ["src/cli.ts", 197, "export function runCli"], + ["src/cli.ts", 222, 'case "gate":'], + ["src/cli.ts", 520, "runCodexLoopCommand"], + ["src/cli.ts", 1092, "runHooksCommand"], + ["src/cli.ts", 1180, "require.main === module"], + ["src/cli.ts", 1183, "catch((error"], ["src/gate.ts", 21, "GATE_STATE_RELATIVE_PATH"], ["src/gate.ts", 53, "export function statusToExit"], ["src/gate.ts", 73, "function writeGateState"], @@ -53,11 +53,11 @@ const ANCHORS: Array<[string, number, string]> = [ ["src/gate.ts", 191, "export function formatGateState"], ["src/config.ts", 50, "export function readConfig"], ["src/config.ts", 63, "export function normalizeConfig"], - ["src/agentAdapters.ts", 17, "PROOFLOOP_AGENT_ADAPTER_IDS"], - ["src/agentLoop.ts", 22, "export async function runProofloopAgentLoop"], - ["src/agentLoop.ts", 43, "while (true)"], - ["src/agentLoop.ts", 97, "options.dryRun"], - ["src/agentLoop.ts", 113, "function verdictFromGate"], + ["src/agentAdapters.ts", 18, "PROOFLOOP_AGENT_ADAPTER_IDS"], + ["src/agentLoop.ts", 23, "export async function runProofloopAgentLoop"], + ["src/agentLoop.ts", 48, "while (true)"], + ["src/agentLoop.ts", 104, "options.dryRun"], + ["src/agentLoop.ts", 120, "function verdictFromGate"], ["src/maturity.ts", 281, "hasE2eScript || hasFile(signals"], ["src/mcp.ts", 28, "const TOOLS: McpTool[]"], ["src/mcp.ts", 56, "export function startMcpServer"],