fix(agents): classify quiet Claude live exits

Fixes #98946
This commit is contained in:
Ayaan Zaidi
2026-07-02 00:10:44 -07:00
parent 5e4d8eda19
commit 11441aefbe
3 changed files with 123 additions and 30 deletions
+98
View File
@@ -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<RunExit>((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<RunExit>((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",
+2
View File
@@ -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":
+23 -30
View File
@@ -58,6 +58,7 @@ type ClaudeLiveTurn = {
timeoutTimer: NodeJS.Timeout | null;
activeToolTimer: NodeJS.Timeout | null;
activeTools: Map<string, ClaudeLiveActiveTool>;
observedStdout: boolean;
streamingParser: ReturnType<typeof createCliJsonlStreamingParser>;
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<void>;
cleanupPromise: Promise<void> | 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;