diff --git a/src/agents/cli-runner.spawn.test.ts b/src/agents/cli-runner.spawn.test.ts index af29973630d6..d92720db70e5 100644 --- a/src/agents/cli-runner.spawn.test.ts +++ b/src/agents/cli-runner.spawn.test.ts @@ -3080,6 +3080,104 @@ ${JSON.stringify({ ); }); + it("marks quiet Claude live exit-zero turns as retryable empty responses", async () => { + let resolveExit: ((exit: RunExit) => void) | undefined; + const exited = new Promise((resolve) => { + resolveExit = resolve; + }); + const stdin = { + write: vi.fn((_dataValue: string, cb?: (err?: Error | null) => void) => { + cb?.(); + resolveExit?.({ + reason: "exit", + exitCode: 0, + exitSignal: null, + durationMs: 1, + stdout: "", + stderr: "", + timedOut: false, + noOutputTimedOut: false, + }); + }), + end: vi.fn(), + }; + supervisorSpawnMock.mockImplementationOnce(async () => ({ + runId: "live-empty-run", + pid: 2345, + startedAtMs: Date.now(), + stdin, + wait: vi.fn(() => exited), + cancel: vi.fn(), + })); + + await expectRejectsWithFields( + executePreparedCliRun( + buildPreparedCliRunContext({ + provider: "claude-cli", + model: "sonnet", + runId: "run-live-empty", + backend: { + liveSession: "claude-stdio", + }, + }), + ), + { + name: "FailoverError", + reason: "empty_response", + code: "cli_unknown_empty_failure", + }, + ); + }); + + it("preserves Claude live stderr classification on exit-zero failures", async () => { + let resolveExit: ((exit: RunExit) => void) | undefined; + const exited = new Promise((resolve) => { + resolveExit = resolve; + }); + const stdin = { + write: vi.fn((_dataValue: string, cb?: (err?: Error | null) => void) => { + cb?.(); + resolveExit?.({ + reason: "exit", + exitCode: 0, + exitSignal: null, + durationMs: 1, + stdout: "", + stderr: "Prompt is too long", + timedOut: false, + noOutputTimedOut: false, + }); + }), + end: vi.fn(), + }; + supervisorSpawnMock.mockImplementationOnce(async () => ({ + runId: "live-exit-zero-overflow-run", + pid: 2345, + startedAtMs: Date.now(), + stdin, + wait: vi.fn(() => exited), + cancel: vi.fn(), + })); + + await expectRejectsWithFields( + executePreparedCliRun( + buildPreparedCliRunContext({ + provider: "claude-cli", + model: "sonnet", + runId: "run-live-exit-zero-overflow", + backend: { + liveSession: "claude-stdio", + }, + }), + ), + { + name: "FailoverError", + reason: "context_overflow", + code: "cli_context_overflow", + }, + ); + }); + it("fails when Claude exits before a live turn starts", async () => { supervisorSpawnMock.mockImplementationOnce(async () => ({ runId: "live-run", diff --git a/src/agents/cli-runner.ts b/src/agents/cli-runner.ts index 5a26b0a111ac..fa9ea1d0d289 100644 --- a/src/agents/cli-runner.ts +++ b/src/agents/cli-runner.ts @@ -98,6 +98,8 @@ function shouldRetryFreshCliSessionAfterFailover(params: { return true; case "unknown": return params.error.code === "cli_unknown_empty_failure"; + case "empty_response": + return params.error.code === "cli_unknown_empty_failure"; case "timeout": return params.error.code === "cli_no_output_timeout"; case "context_overflow": diff --git a/src/agents/cli-runner/claude-live-session.ts b/src/agents/cli-runner/claude-live-session.ts index 39bb410066b5..dfb6759a2cf2 100644 --- a/src/agents/cli-runner/claude-live-session.ts +++ b/src/agents/cli-runner/claude-live-session.ts @@ -58,6 +58,7 @@ type ClaudeLiveTurn = { timeoutTimer: NodeJS.Timeout | null; activeToolTimer: NodeJS.Timeout | null; activeTools: Map; + observedStdout: boolean; streamingParser: ReturnType; execPermission: ClaudeLiveExecPermission; resolve: (output: CliOutput) => void; @@ -73,8 +74,6 @@ type ClaudeLiveSession = { stderr: string; stdoutBuffer: string; currentTurn: ClaudeLiveTurn | null; - drainTimer: NodeJS.Timeout | null; - drainingAbortedTurn: boolean; idleTimer: NodeJS.Timeout | null; cleanup: () => Promise; cleanupPromise: Promise | null; @@ -375,13 +374,6 @@ function clearTurnTimers(turn: ClaudeLiveTurn): void { } } -function clearDrainTimer(session: ClaudeLiveSession): void { - if (session.drainTimer) { - clearTimeout(session.drainTimer); - session.drainTimer = null; - } -} - function finishTurn(session: ClaudeLiveSession, output: CliOutput): void { const turn = session.currentTurn; if (!turn) { @@ -447,7 +439,6 @@ function closeLiveSession( clearTimeout(session.idleTimer); session.idleTimer = null; } - clearDrainTimer(session); if (liveSessions.get(session.key) === session) { liveSessions.delete(session.key); } @@ -854,20 +845,10 @@ function handleClaudeLiveLine(session: ClaudeLiveSession, line: string): void { return; } const parsed = parseClaudeLiveJsonLine(session, trimmed); - if (!parsed) { - return; + if (turn) { + turn.observedStdout = true; } - if (session.drainingAbortedTurn) { - if (parsed.type === "result") { - const turnToClear = session.currentTurn; - if (turnToClear) { - clearTurnTimers(turnToClear); - session.currentTurn = null; - } - session.drainingAbortedTurn = false; - clearDrainTimer(session); - scheduleIdleClose(session); - } + if (!parsed) { return; } if (!turn) { @@ -940,7 +921,6 @@ function handleClaudeExit(session: ClaudeLiveSession, exitCode: number | null): clearTimeout(session.idleTimer); session.idleTimer = null; } - clearDrainTimer(session); if (liveSessions.get(session.key) === session) { liveSessions.delete(session.key); } @@ -965,8 +945,22 @@ function handleClaudeExit(session: ClaudeLiveSession, exitCode: number | null): const fallbackMessage = exitCode === 0 ? "Claude CLI exited before completing the turn." : "Claude CLI failed."; const message = extractCliErrorMessage(stderr) ?? (stderr || fallbackMessage); - if (exitCode === 0) { - failTurn(session, new Error(message)); + if (exitCode === 0 && !stderr) { + const turn = session.currentTurn; + const retryCode = + turn && !turn.observedStdout && turn.rawLines.length === 0 + ? "cli_unknown_empty_failure" + : undefined; + failTurn( + session, + new FailoverError(message, { + reason: "empty_response", + provider: session.providerId, + model: session.modelId, + status: resolveFailoverStatus("empty_response"), + code: retryCode, + }), + ); return; } const reason = classifyFailoverReason(message, { provider: session.providerId }) ?? "unknown"; @@ -1076,8 +1070,6 @@ async function createClaudeLiveSession(params: { stderr: "", stdoutBuffer: "", currentTurn: null, - drainTimer: null, - drainingAbortedTurn: false, idleTimer: null, cleanup: async () => { await mcpCaptureAttempt.cleanup?.(); @@ -1129,6 +1121,7 @@ function createTurn(params: { timeoutTimer: null, activeToolTimer: null, activeTools: new Map(), + observedStdout: false, streamingParser: createCliJsonlStreamingParser({ backend: params.context.preparedBackend.backend, providerId: params.context.backendResolved.id, @@ -1167,7 +1160,7 @@ function createTurn(params: { function closeOldestIdleSession(): boolean { for (const session of liveSessions.values()) { - if (!session.currentTurn && !session.drainingAbortedTurn) { + if (!session.currentTurn) { closeLiveSession(session, "idle"); return true; } @@ -1312,7 +1305,7 @@ export async function runClaudeLiveSessionTurn(params: { await cleanup(); throw new Error("Claude CLI live session closed before handling the turn"); } - if (session.currentTurn || session.drainingAbortedTurn) { + if (session.currentTurn) { throw new Error("Claude CLI live session is already handling a turn"); } const liveSession = session;