Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .tours/01-primary-user-flow.tour
Original file line number Diff line number Diff line change
Expand Up @@ -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<number> \\{\\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*$"
Expand Down
14 changes: 7 additions & 7 deletions .tours/02-agent-execution.tour
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string \\| boolean>, root: string\\): Promise<number> \\{\\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/<run-id>/`, 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",
Expand Down
2 changes: 1 addition & 1 deletion .tours/03-debug-and-recovery.tour
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string \\| boolean>, root: string\\): number \\{\\s*$"
Expand Down
37 changes: 37 additions & 0 deletions CHANGELOG/cli/repair-deadline.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 5 additions & 1 deletion dist/agentAdapters.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ export type ProofloopAgentAdapter = {
launch(promptPath: string, targetDir: string, options?: {
command?: string;
env?: NodeJS.ProcessEnv;
timeoutMs?: number;
signal?: AbortSignal;
}): Promise<AgentRunResult>;
collectTrace(runDir: string): Promise<AgentTrace>;
buildRepairPrompt(verdict: ProofloopVerdict, options?: {
Expand All @@ -76,7 +78,9 @@ export declare function launchProofloopAgentAdapter(args: {
targetDir?: string;
command?: string;
env?: NodeJS.ProcessEnv;
}): AgentRunResult;
timeoutMs?: number;
signal?: AbortSignal;
}): Promise<AgentRunResult>;
export declare function collectProofloopAgentTrace(args: {
adapterId: ProofloopAgentAdapterId;
runDir: string;
Expand Down
197 changes: 174 additions & 23 deletions dist/agentAdapters.js
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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 })),
};
Expand Down Expand Up @@ -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";
Expand All @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions dist/agentLoop.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading