Skip to content

Commit 8288e86

Browse files
committed
fix(tui): abort stall while a sibling collect is still in flight
A sibling tool.done clears currentToolName while shell_collect stays in activeToolCalls, so keying only the last name left that poll unbounded.
1 parent 47ac0c9 commit 8288e86

4 files changed

Lines changed: 57 additions & 5 deletions

File tree

‎docs/PRODUCT.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ The evidence is in how the product fails today: the personas already produce exc
4141
5. **Resume capability** — Runs persist to a git-backed store and resume from the last point after interruption.
4242
6. **Legible loop** — A live event log, working-tree diff panel, plan tracker, and real-time cost meter show what happened, when, and why.
4343
7. **Operator-in-the-loop** — The agent can call `ask_operator` to pause and ask a clarifying question; the operator answers from a modal (TUI). Headless `corbits exec` unmounts `ask_operator` when stdin/stdout are not TTYs. TTY exec still reads a single line from stdin.
44-
8. **Mid-run steering** — Two modes while the agent is running, keyed to **whose** idle. **Parent-idle** is when the primary Skywalker turn is not inside an in-flight parent tool; **session-idle** is parent-idle **and** no live fleet lanes. **Enter** soft-steers while the parent is busy — delivers at the next **parent** `tool.boundary` without stopping the current run; a long parent `run_shell` or an awaiting `wait_agents` is parent-busy, so Enter is a queued steer, not a new turn. An in-flight TUI-primary `wait_agents` yields as a timeout when that steer is queued so occupancy can deliver it. A queued steer delivers at the next parent `tool.boundary` so occupancy can pick it up. Idle-with-fleet is shipped: after a non-blocking `spawn_agent` dispatch the parent goes idle while workers keep running, mailbox mail arrives as inbound when a worker finishes or fails, and mid-hold Enter starts a new primary turn instead of queueing a steer. **Alt+Enter** queues a follow-up delivered only on session-idle (`run` goes idle; does not interrupt). Session-idle Alt+Enter is a no-op. **Ctrl+C** stops the run outright. The notice row shows distinct `steer N` / `follow-up M` badges, and held messages list in a pending column stacked on the prompt box — `↑`/`↓` select, `Enter` force-pushes one now, `Ctrl+X` drops it — instead of echoing labelled transcript rows; when steers are pending and a parent tool has been in flight a few seconds, the notice names that command. Shortcuts are listed in `/help` (`Enter` soft-steer · `Alt+Enter` follow-up · `Ctrl+C` stop).
44+
8. **Mid-run steering** — Two modes while the agent is running, keyed to **whose** idle. **Parent-idle** is when the primary Skywalker turn is not inside an in-flight parent tool; **session-idle** is parent-idle **and** no live fleet lanes. **Enter** soft-steers while the parent is busy — delivers at the next **parent** `tool.boundary` without stopping the current run; a long parent `run_shell` is parent-busy, so Enter is a queued steer, not a new turn. A queued steer delivers at the next parent `tool.boundary` so occupancy can pick it up. Idle-with-fleet is shipped: after a non-blocking `spawn_agent` dispatch the parent goes idle while workers keep running, mailbox mail arrives as inbound when a worker finishes or fails, and mid-hold Enter starts a new primary turn instead of queueing a steer. **Alt+Enter** queues a follow-up delivered only on session-idle (`run` goes idle; does not interrupt). Session-idle Alt+Enter is a no-op. **Ctrl+C** stops the run outright. The notice row shows distinct `steer N` / `follow-up M` badges, and held messages list in a pending column stacked on the prompt box — `↑`/`↓` select, `Enter` force-pushes one now, `Ctrl+X` drops it — instead of echoing labelled transcript rows; when steers are pending and a parent tool has been in flight a few seconds, the notice names that command. Shortcuts are listed in `/help` (`Enter` soft-steer · `Alt+Enter` follow-up · `Ctrl+C` stop).
4545
9. **Orchestrator-only (TUI + exec)** — The primary session is always the orchestrator: it can act directly and delegates via `spawn_agent` (then idle; mailbox mail inbound) / `search_agents`. Nested orchestrators collect through mailbox mail the same way. Long jobs belong on workers — a parent that runs them itself stays parent-busy and holds Enter steers. Single-agent session mode, the first-run mode picker, and Settings → Session are gone (CL-5814). Legacy `sessionMode` values on disk are ignored.
4646

4747
## User Experience

‎src/tui/runtime-bridge.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1463,6 +1463,7 @@ export function attachSessionBridge(
14631463
streamingType: bag.turn.streamingType,
14641464
currentToolName: bag.turn.currentToolName,
14651465
activeToolCalls: bag.turn.activeToolCalls,
1466+
callIdByName: bag.turn.callIdByName,
14661467
stallNoticeMs,
14671468
repeating: bag.turn.repeating,
14681469
});

‎src/tui/stall-watchdog.test.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ describe("shouldAbortForStall", () => {
2424
streamingType: "text" as const,
2525
currentToolName: null,
2626
activeToolCalls: [],
27+
callIdByName: {},
2728
};
2829

2930
test("aborts a mid-stream hang past the timeout", () => {
@@ -96,6 +97,7 @@ describe("shouldAbortForStall — awaiting-response with a null stream eventuall
9697
streamingType: null,
9798
currentToolName: null,
9899
activeToolCalls: [],
100+
callIdByName: {},
99101
};
100102

101103
test("aborts a run awaiting a response once the stall budget elapses", () => {
@@ -158,6 +160,7 @@ describe("shouldAbortForStall — execution-watchdog-exempt tools do not pin for
158160
streamingType: "tool" as const,
159161
currentToolName: "shell_collect",
160162
activeToolCalls: ["collect-1"],
163+
callIdByName: { shell_collect: "collect-1" },
161164
};
162165

163166
test("in-flight collect auto-aborts after the stall budget", () => {
@@ -173,9 +176,41 @@ describe("shouldAbortForStall — execution-watchdog-exempt tools do not pin for
173176
...collect,
174177
currentToolName: "wait_agents",
175178
activeToolCalls: ["wait-1"],
179+
callIdByName: { wait_agents: "wait-1" },
176180
}),
177181
).toBe(true);
178182
});
183+
184+
// tool.done of a sibling bash clears currentToolName and streamingType
185+
// while shell_collect is still in activeToolCalls. Keying only the last
186+
// name would leave that poll unbounded forever.
187+
test("sibling tool.done while collect is in-flight still aborts at the stall budget", () => {
188+
const afterSiblingDone = {
189+
...collect,
190+
currentToolName: null,
191+
streamingType: null,
192+
awaitingResponse: false,
193+
activeToolCalls: ["collect-1"],
194+
callIdByName: { shell_collect: "collect-1" },
195+
};
196+
expect(
197+
shouldAbortForStall({ ...afterSiblingDone, nowMs: STALL_TIMEOUT_MS - 1 }),
198+
).toBe(false);
199+
expect(shouldAbortForStall(afterSiblingDone)).toBe(true);
200+
});
201+
202+
test("a remaining ordinary tool after a sibling done is not a stall", () => {
203+
expect(
204+
shouldAbortForStall({
205+
...collect,
206+
currentToolName: null,
207+
streamingType: null,
208+
awaitingResponse: false,
209+
activeToolCalls: ["bash-1"],
210+
callIdByName: { bash: "bash-1" },
211+
}),
212+
).toBe(false);
213+
});
179214
});
180215

181216
describe("applyStallRecovery", () => {
@@ -219,6 +254,7 @@ describe("shouldNoticeStall", () => {
219254
currentToolName: null,
220255
repeating: false,
221256
activeToolCalls: [],
257+
callIdByName: {},
222258
};
223259

224260
test("a parallel fan-out with sibling tools still running does not notice", () => {
@@ -303,6 +339,7 @@ describe("the stall level the indicator reads", () => {
303339
streamingType: null,
304340
currentToolName: null,
305341
activeToolCalls: [],
342+
callIdByName: {},
306343
repeating: false,
307344
};
308345

‎src/tui/stall-watchdog.ts‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export interface ShouldAbortForStallArgs {
2424
readonly streamingType: "text" | "thinking" | "tool" | null;
2525
readonly currentToolName: string | null;
2626
readonly activeToolCalls: readonly string[];
27+
readonly callIdByName: Readonly<Record<string, string>>;
2728
}
2829

2930
/**
@@ -58,18 +59,31 @@ function silentPastThreshold(
5859

5960
/**
6061
* Polls the per-tool execution watchdog leaves unarmed. Without a stall
61-
* bound they hold `streamingType === "tool"` with no other wall-clock limit.
62+
* bound they hold the turn with no other wall-clock limit. A sibling
63+
* tool.done clears `currentToolName` / `streamingType` while the poll is
64+
* still in `activeToolCalls`, so the bound keys any in-flight stall-bounded
65+
* name, not only the last announced tool.
6266
*/
63-
function isStallBoundedInFlightTool(args: ShouldAbortForStallArgs): boolean {
64-
if (args.streamingType !== "tool") return false;
65-
const name = args.currentToolName;
67+
function isStallBoundedToolName(name: string | null | undefined): boolean {
6668
return (
6769
name === "shell_collect" ||
6870
name === "wait_agents" ||
6971
name === "ask_director"
7072
);
7173
}
7274

75+
function isStallBoundedInFlightTool(args: ShouldAbortForStallArgs): boolean {
76+
if (isStallBoundedToolName(args.currentToolName)) return true;
77+
for (const [name, id] of Object.entries(args.callIdByName)) {
78+
if (isStallBoundedToolName(name) && args.activeToolCalls.includes(id)) {
79+
return true;
80+
}
81+
}
82+
// Name-only announcements track the call under its name until a real id
83+
// arrives, so the placeholder itself is the stall-bounded name.
84+
return args.activeToolCalls.some(isStallBoundedToolName);
85+
}
86+
7387
// Pure decision helper: returns true when the run is genuinely stuck and should
7488
// be aborted. Extracted so the timeout logic is unit-testable without timers.
7589
//

0 commit comments

Comments
 (0)