diff --git a/packages/gittensory-engine/src/miner/cli-subprocess-driver.ts b/packages/gittensory-engine/src/miner/cli-subprocess-driver.ts index 3b58edfa6c..65e5d35000 100644 --- a/packages/gittensory-engine/src/miner/cli-subprocess-driver.ts +++ b/packages/gittensory-engine/src/miner/cli-subprocess-driver.ts @@ -24,8 +24,20 @@ export type CliSubprocessSpawnFn = ( cwd: string; env: Record; 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"). */ @@ -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; /** Extra env overlaid on the allowlisted parent (e.g. an auth value the CLI reads). */ @@ -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, diff --git a/test/unit/cli-subprocess-driver.test.ts b/test/unit/cli-subprocess-driver.test.ts index 2ddebb1f54..55f28611dc 100644 --- a/test/unit/cli-subprocess-driver.test.ts +++ b/test/unit/cli-subprocess-driver.test.ts @@ -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"]); + }); + }); });