From ede3f149f85a4f588421326d70c42e9cba9747ef Mon Sep 17 00:00:00 2001 From: Ben Wachman Date: Fri, 9 Oct 2026 23:57:05 -0400 Subject: [PATCH] docs(tui): state and pin the bounded ask_director stall policy --- src/tui/stall-watchdog.test.ts | 50 ++++++++++++++++++++++++++++++++-- src/tui/stall-watchdog.ts | 10 +++++-- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/src/tui/stall-watchdog.test.ts b/src/tui/stall-watchdog.test.ts index cbe08ac2b..c4542c261 100644 --- a/src/tui/stall-watchdog.test.ts +++ b/src/tui/stall-watchdog.test.ts @@ -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, diff --git a/src/tui/stall-watchdog.ts b/src/tui/stall-watchdog.ts index b11915d0c..f00a152fa 100644 --- a/src/tui/stall-watchdog.ts +++ b/src/tui/stall-watchdog.ts @@ -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 ( @@ -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)) {