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
36 changes: 35 additions & 1 deletion packages/gittensory-engine/src/miner/cli-subprocess-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,20 @@ export type CliSubprocessSpawnFn = (
cwd: string;
env: Record<string, string | undefined>;
timeoutMs: number;
// Optional fast-fail deadline, mirrors src/selfhost/ai.ts's SpawnFn (#4994/#5053): a real spawn implementation
// starts this timer at process start and clears it the instant any stdout data arrives, so it only fires when
// the CLI has produced ZERO stdout by this deadline — a distinct, earlier signal than the full `timeoutMs`.
firstOutputTimeoutMs?: number;
},
) => Promise<{ stdout: string; code: number | null; stderr?: string; timedOut?: boolean }>;
) => Promise<{
stdout: string;
code: number | null;
stderr?: string;
timedOut?: boolean;
// Set alongside `timedOut` only when the kill was the first-output deadline, not the full `timeoutMs` — lets the
// driver report a distinct "stalled" error instead of conflating it with a genuine full timeout.
stalledNoOutput?: boolean;
}>;

export type CliSubprocessDriverOptions = {
/** The coding-agent CLI to spawn (e.g. "claude" or "codex"). */
Expand All @@ -34,6 +46,12 @@ export type CliSubprocessDriverOptions = {
spawn: CliSubprocessSpawnFn;
/** Per-run wall-clock budget handed to the spawn. Default: 120000ms. */
timeoutMs?: number;
/** Optional fast-fail deadline (#4994/#5053): killed early and reported distinctly ("stalled", not a generic
* timeout) if the subprocess produces zero stdout before this elapses. Mirrors src/selfhost/ai.ts's
* `firstOutputTimeoutMs`/`resolveClaudeFirstOutputTimeoutMs` pattern, built after a naive single-timeout design
* caused a real production outage against these same claude/codex binaries. Opt-in and backward compatible:
* omitting it leaves behavior exactly as it was before this option existed. */
firstOutputTimeoutMs?: number;
/** Parent env to allowlist from. Default: `{}` (a real caller passes `process.env`; the default stays pure). */
parentEnv?: Record<string, string | undefined>;
/** Extra env overlaid on the allowlisted parent (e.g. an auth value the CLI reads). */
Expand Down Expand Up @@ -98,9 +116,25 @@ export function createCliSubprocessCodingAgentDriver(options: CliSubprocessDrive
cwd: task.workingDirectory,
env,
timeoutMs,
...(options.firstOutputTimeoutMs !== undefined
? { firstOutputTimeoutMs: options.firstOutputTimeoutMs }
: {}),
});
const transcript = redactSecrets(spawned.stdout, knownSecrets).slice(0, MAX_TRANSCRIPT_CHARS);

if (spawned.timedOut && spawned.stalledNoOutput) {
// Fast-fail path (#4994/#5053): killed at firstOutputTimeoutMs, well before the full timeoutMs, because
// stdout produced no bytes at all. A distinct error (never reusing `${command}_timeout_...`) so this
// stall is separately countable in logs/Sentry from a genuine full timeout where the process was at
// least emitting output before it was killed.
return {
ok: false,
changedFiles: [],
summary: `${options.command} stalled with no stdout within ${options.firstOutputTimeoutMs}ms`,
transcript,
error: `${options.command}_stalled_no_output`,
};
}
if (spawned.timedOut) {
return {
ok: false,
Expand Down
55 changes: 55 additions & 0 deletions test/unit/cli-subprocess-driver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,4 +115,59 @@ describe("createCliSubprocessCodingAgentDriver (#4266)", () => {
expect(result.transcript).toBe("used [redacted] to auth");
expect(calls[0]?.args).toEqual(["run", "attempt-1"]);
});

describe("two-tier stalled-output fast-fail timeout (#4994/#5053)", () => {
it("reports a distinct stalled error when the subprocess produces zero stdout within firstOutputTimeoutMs", async () => {
const { spawn, calls } = fakeSpawn({ stdout: "", code: null, timedOut: true, stalledNoOutput: true });
const driver = createCliSubprocessCodingAgentDriver({
command: "claude",
spawn,
timeoutMs: 120_000,
firstOutputTimeoutMs: 5000,
});
const result = await driver.run(TASK);
expect(result.ok).toBe(false);
expect(result.error).toBe("claude_stalled_no_output");
expect(result.summary).toBe("claude stalled with no stdout within 5000ms");
expect(calls[0]?.opts.firstOutputTimeoutMs).toBe(5000);
});

it("is NOT killed early by the first-output timer when the subprocess produces output before firstOutputTimeoutMs (invariant: live output is never mistaken for a stall)", async () => {
const { spawn } = fakeSpawn({ stdout: "produced some output then finished", code: 0 });
const driver = createCliSubprocessCodingAgentDriver({ command: "claude", spawn, firstOutputTimeoutMs: 1000 });
const result = await driver.run(TASK);
expect(result.ok).toBe(true);
expect(result.summary).toBe("claude completed for attempt-1");
});

it("preserves the existing full-timeout behavior unchanged when output arrived but the process never exited (#4994/#5053 regression guard)", async () => {
// stalledNoOutput is absent -- stdout arrived, so only the full timeoutMs governs, same as before this
// feature existed.
const { spawn } = fakeSpawn({ stdout: "partial", code: null, timedOut: true });
const driver = createCliSubprocessCodingAgentDriver({
command: "codex",
spawn,
timeoutMs: 5000,
firstOutputTimeoutMs: 1000,
});
const result = await driver.run(TASK);
expect(result.ok).toBe(false);
expect(result.error).toBe("codex_timeout_5000ms");
expect(result.summary).toBe("codex timed out after 5000ms");
});

it("does not forward firstOutputTimeoutMs to spawn when omitted (opt-in, backward compatible)", async () => {
const { spawn, calls } = fakeSpawn({ stdout: "done", code: 0 });
const driver = createCliSubprocessCodingAgentDriver({ command: "claude", spawn });
await driver.run(TASK);
expect("firstOutputTimeoutMs" in (calls[0]?.opts ?? {})).toBe(false);
});

it("invariant: the driver's result never carries any field beyond the CodingAgentDriverResult contract (no attempt/governor state)", async () => {
const { spawn } = fakeSpawn({ stdout: "", code: null, timedOut: true, stalledNoOutput: true });
const driver = createCliSubprocessCodingAgentDriver({ command: "claude", spawn, firstOutputTimeoutMs: 500 });
const result = await driver.run(TASK);
expect(Object.keys(result).sort()).toEqual(["changedFiles", "error", "ok", "summary", "transcript"]);
});
});
});