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
50 changes: 48 additions & 2 deletions src/tui/stall-watchdog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,8 +178,54 @@ describe("shouldAbortForStall — execution-watchdog-exempt tools do not pin for
).toBe(true);
});

// A sibling tool.done clears the last announced name while the poll is in
// flight; keying only that name would leave it unbounded forever.
// ask_director is bounded, not exempt (CL-10206): the primary never mounts
// it, so the name is a guard, and a stray mount must notice, then abort,
// like wait_agents. Both shapes the turn state can hold are pinned: the
// announced call, and a leftover tracked only per-id after a sibling
// cleared currentToolName and the shared callIdByName slot.
test.each([
{
representation: "direct",
currentToolName: "ask_director",
activeToolCalls: ["ask-1"],
callIdByName: { ask_director: "ask-1" },
callNameById: { "ask-1": "ask_director" },
},
{
representation: "mapping-only",
currentToolName: null,
activeToolCalls: ["ask-1"],
callIdByName: {},
callNameById: { "ask-1": "ask_director" },
},
])(
"in-flight ask_director ($representation) is quiet, then notices, then aborts",
(ask) => {
const inFlightAsk = {
...collect,
...ask,
streamingType: "tool" as const,
stallNoticeMs: STALL_NOTICE_MS,
repeating: false,
};
const points = [
{ nowMs: STALL_NOTICE_MS - 1, level: "quiet" },
{ nowMs: STALL_NOTICE_MS, level: "notice" },
{ nowMs: STALL_TIMEOUT_MS - 1, level: "notice" },
{ nowMs: STALL_TIMEOUT_MS, level: "abort" },
] as const;

for (const { nowMs, level } of points) {
const args = { ...inFlightAsk, nowMs };
expect(stallLevel(args)).toBe(level);
expect(shouldAbortForStall(args)).toBe(level === "abort");
}
},
);

// A sibling tool.done (e.g. bash) clears currentToolName and streamingType
// while wait_agents is still in activeToolCalls. Keying only the last
// announced name would leave that poll unbounded forever.
test("sibling tool.done while collect is in-flight still aborts at the stall budget", () => {
const afterSiblingDone = {
...collect,
Expand Down
10 changes: 8 additions & 2 deletions src/tui/stall-watchdog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,13 @@ function silentPastThreshold(
// A fan-out sets `awaitingResponse` when any sibling finishes; outstanding
// calls mean the run is not silent regardless of that flag.
if (args.awaitingResponse && args.activeToolCalls.length === 0) return true;
// Watchdog-exempt polls (collect, wait_agents, ask_director) emit no parent
// events; with no other wall-clock bound, the stall budget is their backstop.
// Policy: an in-flight wait_agents or ask_director is stall-bounded, never
// exempt. The TUI primary mounts neither (wait_agents is mount-gated off and
// ask_director exists only in worker loops), so both names are defensive
// guards that keep a stray mount from pinning the clock. A worker's parked
// ask is bounded elsewhere: an unanswered ask expires after ASK_DEADLINE_MS
// via expireStaleAsks, and the director's silent ask-wake turn is aborted by
// this watchdog via abortStalledWakeTurn, which re-surfaces the question.
if (isStallBoundedInFlightTool(args) && args.isProcessing) return true;
// Mid-stream hang after the first token; tool runs have their own watchdog.
return (
Expand All @@ -62,6 +67,7 @@ function isStallBoundedToolName(name: string | null | undefined): boolean {
return isSameTool(name, "wait_agents") || isSameTool(name, "ask_director");
}

/** Why these names are bounded guards, not exemptions: see `silentPastThreshold`. */
function isStallBoundedInFlightTool(args: ShouldAbortForStallArgs): boolean {
if (isStallBoundedToolName(args.currentToolName)) return true;
for (const [name, id] of Object.entries(args.callIdByName)) {
Expand Down
Loading