Skip to content

Commit 4af2ca0

Browse files
committed
fix(agents): extend live CLI no-output watchdog to the blocked-tool floor
1 parent bb1da73 commit 4af2ca0

2 files changed

Lines changed: 116 additions & 109 deletions

File tree

src/agents/cli-runner.spawn.test.ts

Lines changed: 84 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -1349,6 +1349,90 @@ describe("runCliAgent spawn path", () => {
13491349
}
13501350
});
13511351

1352+
it("extends the live no-output watchdog to the blocked-tool floor while a tool is outstanding", async () => {
1353+
const toolErrorEvents: Array<Record<string, unknown>> = [];
1354+
const stopDiagnostics = onTrustedToolExecutionEvent((event) => {
1355+
if (event.type === "tool.execution.error") {
1356+
toolErrorEvents.push(event as unknown as Record<string, unknown>);
1357+
}
1358+
});
1359+
let stdoutListener: ((chunk: string) => void) | undefined;
1360+
const cancel = vi.fn();
1361+
const stdin = {
1362+
write: vi.fn((_data: string, callback?: (error?: Error | null) => void) => {
1363+
stdoutListener?.(
1364+
[
1365+
JSON.stringify({ type: "system", subtype: "init", session_id: "live-quiet-tool" }),
1366+
JSON.stringify({
1367+
type: "assistant",
1368+
message: {
1369+
content: [{ type: "tool_use", id: "tool-quiet-1", name: "Bash", input: {} }],
1370+
},
1371+
}),
1372+
].join("\n") + "\n",
1373+
);
1374+
callback?.();
1375+
}),
1376+
end: vi.fn(),
1377+
};
1378+
supervisorSpawnMock.mockImplementationOnce(async (...args: unknown[]) => {
1379+
const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void };
1380+
stdoutListener = input.onStdout;
1381+
return {
1382+
runId: "live-quiet-tool-run",
1383+
pid: 2345,
1384+
startedAtMs: Date.now(),
1385+
stdin,
1386+
wait: vi.fn(() => new Promise(() => {})),
1387+
cancel,
1388+
};
1389+
});
1390+
1391+
const run = executePreparedCliRun(
1392+
buildPreparedCliRunContext({
1393+
provider: "claude-cli",
1394+
model: "sonnet",
1395+
runId: "run-live-quiet-tool",
1396+
backend: { liveSession: "claude-stdio" },
1397+
timeoutMs: 3_600_000,
1398+
}),
1399+
);
1400+
const rejection = expect(run).rejects.toThrow(/produced no output for 900s/);
1401+
await vi.waitFor(() => {
1402+
expect(stdin.write).toHaveBeenCalledOnce();
1403+
});
1404+
1405+
// Fake the clock only after the spawn path settled, then emit one more
1406+
// stdout line so the watchdog re-arms on the faked setTimeout/Date.
1407+
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] });
1408+
stdoutListener?.(
1409+
`${JSON.stringify({
1410+
type: "stream_event",
1411+
event: { type: "content_block_delta", delta: { type: "text_delta", text: "running" } },
1412+
})}\n`,
1413+
);
1414+
1415+
// Base watchdog (600s cap for a 1h budget) must not kill the quiet tool.
1416+
await vi.advanceTimersByTimeAsync(650_000);
1417+
expect(cancel).not.toHaveBeenCalled();
1418+
1419+
// The blocked-tool floor (15min of quiet) still terminates a wedged tool.
1420+
try {
1421+
await vi.advanceTimersByTimeAsync(300_000);
1422+
expect(cancel).toHaveBeenCalledWith("manual-cancel");
1423+
await rejection;
1424+
// Watchdog-killed turns must keep timeout provenance for active tools.
1425+
expect(toolErrorEvents).toContainEqual(
1426+
expect.objectContaining({
1427+
toolCallId: "tool-quiet-1",
1428+
terminalReason: "timed_out",
1429+
}),
1430+
);
1431+
} finally {
1432+
stopDiagnostics();
1433+
}
1434+
});
1435+
13521436
it("keeps non-capture live prepared backend cleanup with the whole-run owner", async () => {
13531437
let stdoutListener: ((chunk: string) => void) | undefined;
13541438
const stdin = {
@@ -2692,97 +2776,6 @@ ${JSON.stringify({
26922776
},
26932777
);
26942778

2695-
it("preserves no-output watchdog timeout provenance for active Claude live tools", async () => {
2696-
const diagnosticEvents: Array<Record<string, unknown>> = [];
2697-
const stopDiagnostics = onTrustedToolExecutionEvent((event) => {
2698-
if (event.type === "tool.execution.error") {
2699-
diagnosticEvents.push(event as unknown as Record<string, unknown>);
2700-
}
2701-
});
2702-
let stdoutListener: ((chunk: string) => void) | undefined;
2703-
const stdin = {
2704-
write: vi.fn((_data: string, cb?: (err?: Error | null) => void) => {
2705-
stdoutListener?.(
2706-
[
2707-
JSON.stringify({ type: "system", subtype: "init", session_id: "live-no-output" }),
2708-
JSON.stringify({
2709-
type: "assistant",
2710-
session_id: "live-no-output",
2711-
message: {
2712-
role: "assistant",
2713-
content: [
2714-
{
2715-
type: "tool_use",
2716-
id: "tool-live-no-output",
2717-
name: "Bash",
2718-
input: { command: "sleep 10" },
2719-
},
2720-
],
2721-
},
2722-
}),
2723-
].join("\n") + "\n",
2724-
);
2725-
cb?.();
2726-
}),
2727-
end: vi.fn(),
2728-
};
2729-
supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => {
2730-
const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void };
2731-
stdoutListener = input.onStdout;
2732-
return {
2733-
runId: "live-run-no-output",
2734-
pid: 3062,
2735-
startedAtMs: Date.now(),
2736-
stdin,
2737-
wait: vi.fn(() => new Promise(() => {})),
2738-
cancel: vi.fn(),
2739-
};
2740-
});
2741-
2742-
try {
2743-
const context = buildPreparedCliRunContext({
2744-
provider: "claude-cli",
2745-
model: "sonnet",
2746-
runId: "run-live-no-output",
2747-
sessionId: "session-live-no-output",
2748-
sessionKey: "agent:main:no-output",
2749-
backend: { liveSession: "claude-stdio" },
2750-
timeoutMs: 120_000,
2751-
});
2752-
const resultPromise = runClaudeLiveSessionTurn({
2753-
context,
2754-
args: context.preparedBackend.backend.args ?? [],
2755-
env: {},
2756-
prompt: "hello",
2757-
useResume: false,
2758-
noOutputTimeoutMs: 25,
2759-
getProcessSupervisor: () => ({
2760-
spawn: (params: Parameters<SupervisorSpawnFn>[0]) =>
2761-
supervisorSpawnMock(params) as ReturnType<SupervisorSpawnFn>,
2762-
cancel: vi.fn(),
2763-
cancelScope: vi.fn(),
2764-
getRecord: vi.fn(),
2765-
}),
2766-
onAssistantDelta: () => {},
2767-
cleanup: async () => {},
2768-
});
2769-
const runExpectation = expectRejectsWithFields(resultPromise, {
2770-
name: "FailoverError",
2771-
message: "CLI produced no output for 0s and was terminated.",
2772-
});
2773-
2774-
await runExpectation;
2775-
expect(diagnosticEvents).toContainEqual(
2776-
expect.objectContaining({
2777-
toolCallId: "tool-live-no-output",
2778-
terminalReason: "timed_out",
2779-
}),
2780-
);
2781-
} finally {
2782-
stopDiagnostics();
2783-
}
2784-
});
2785-
27862779
it("answers Claude live control_request can_use_tool with deny when exec policy is restrictive", async () => {
27872780
const diagnosticEvents: Array<Record<string, unknown>> = [];
27882781
const stopDiagnostics = onInternalDiagnosticEvent((event) => {

src/agents/cli-runner/claude-live-session.ts

Lines changed: 32 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
type ExecAsk,
2323
type ExecSecurity,
2424
} from "../../infra/exec-approvals.js";
25+
import { BLOCKED_TOOL_CALL_ABORT_FLOOR_MS } from "../../logging/diagnostic-run-activity.js";
2526
import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
2627
import {
2728
CLI_STREAM_JSON_DEFAULT_MAX_TURN_RAW_CHARS,
@@ -57,6 +58,8 @@ type ClaudeLiveTurn = {
5758
rawChars: number;
5859
sessionId?: string;
5960
noOutputTimer: NodeJS.Timeout | null;
61+
/** Last stdout/stderr time; null until the process emits anything this turn. */
62+
lastOutputAtMs: number | null;
6063
timeoutTimer: NodeJS.Timeout | null;
6164
activeTools: Map<string, ClaudeLiveActiveTool>;
6265
observedStdout: boolean;
@@ -684,24 +687,44 @@ function noteClaudeLiveProgress(
684687
emitClaudeLiveProgress(turn, "cli_live:stream_progress");
685688
}
686689

687-
function resetNoOutputTimer(session: ClaudeLiveSession): void {
688-
const turn = session.currentTurn;
689-
if (!turn) {
690-
return;
691-
}
690+
// The CLI emits a tool_use line, then nothing until the tool result, so a
691+
// quiet long-running tool is indistinguishable from a wedged process at the
692+
// stdout level. While observed tool calls are outstanding, extend the quiet
693+
// window to the blocked-tool floor instead of killing mid-tool.
694+
function armNoOutputTimer(session: ClaudeLiveSession, turn: ClaudeLiveTurn, delayMs: number): void {
692695
if (turn.noOutputTimer) {
693696
clearTimeout(turn.noOutputTimer);
694697
}
695698
turn.noOutputTimer = setTimeout(() => {
699+
const quietSinceMs = turn.lastOutputAtMs ?? turn.startedAtMs;
700+
if (turn.activeTools.size > 0) {
701+
const quietBudgetMs = Math.max(session.noOutputTimeoutMs, BLOCKED_TOOL_CALL_ABORT_FLOOR_MS);
702+
const remainingMs = quietSinceMs + quietBudgetMs - Date.now();
703+
if (remainingMs > 0) {
704+
armNoOutputTimer(session, turn, remainingMs);
705+
return;
706+
}
707+
}
696708
closeLiveSession(
697709
session,
698710
"abort",
699711
createTimeoutError(
700712
session,
701-
`CLI produced no output for ${Math.round(session.noOutputTimeoutMs / 1000)}s and was terminated.`,
713+
`CLI produced no output for ${Math.round((Date.now() - quietSinceMs) / 1000)}s and was terminated.`,
714+
// Retryable only when the process never produced any output this turn.
715+
turn.lastOutputAtMs === null ? "cli_no_output_timeout" : undefined,
702716
),
703717
);
704-
}, session.noOutputTimeoutMs);
718+
}, delayMs);
719+
}
720+
721+
function resetNoOutputTimer(session: ClaudeLiveSession): void {
722+
const turn = session.currentTurn;
723+
if (!turn) {
724+
return;
725+
}
726+
turn.lastOutputAtMs = Date.now();
727+
armNoOutputTimer(session, turn, session.noOutputTimeoutMs);
705728
}
706729

707730
function parseSessionId(parsed: Record<string, unknown>): string | undefined {
@@ -1136,6 +1159,7 @@ function createTurn(params: {
11361159
rawLines: [],
11371160
rawChars: 0,
11381161
noOutputTimer: null,
1162+
lastOutputAtMs: null,
11391163
timeoutTimer: null,
11401164
activeTools: new Map(),
11411165
observedStdout: false,
@@ -1161,17 +1185,7 @@ function createTurn(params: {
11611185
resolve: params.resolve,
11621186
reject: params.reject,
11631187
};
1164-
turn.noOutputTimer = setTimeout(() => {
1165-
closeLiveSession(
1166-
params.session,
1167-
"abort",
1168-
createTimeoutError(
1169-
params.session,
1170-
`CLI produced no output for ${Math.round(params.noOutputTimeoutMs / 1000)}s and was terminated.`,
1171-
"cli_no_output_timeout",
1172-
),
1173-
);
1174-
}, params.noOutputTimeoutMs);
1188+
armNoOutputTimer(params.session, turn, params.noOutputTimeoutMs);
11751189
turn.timeoutTimer = setTimeout(() => {
11761190
closeLiveSession(
11771191
params.session,

0 commit comments

Comments
 (0)