Skip to content

Commit d847880

Browse files
committed
fix(tui): hang stalled ask-wake resurface on shouldAbortForStall
After the silent-turn abort, a second wake-only predicate would duplicate that bound. Un-dedupe armed wakes on interrupt instead.
1 parent d8b3c97 commit d847880

4 files changed

Lines changed: 46 additions & 52 deletions

File tree

‎src/subagent/fleet-report.ask-wake.test.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,4 +84,20 @@ describe("pendingAskWakeText", () => {
8484
expect(text).toContain("using target a1");
8585
expect(text.toLowerCase()).toContain("worker");
8686
});
87+
88+
test("a re-surface count restates that the earlier wake stalled", () => {
89+
const text = pendingAskWakeText(
90+
{
91+
sessionId: "a1",
92+
agentId: "builder",
93+
description: "Build the thing",
94+
question: "Which port?",
95+
questionId: "q1",
96+
},
97+
{ resurface: 1 },
98+
);
99+
expect(text).toContain("Re-surface 1");
100+
expect(text).toContain("stalled");
101+
expect(text).toContain("send_input");
102+
});
87103
});

‎src/tui/runner/wiring.stall-bound.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ import { cancelWorkersForStop, createFleetStallPollTick } from "./wiring.js";
1818

1919
// CL-8016: a silent primary turn (wake text sent, inference never starts)
2020
// must not freeze the message queue and parked worker questions forever.
21-
// The stall poll tick bounds that turn: past the stall threshold it aborts,
21+
// The stall poll tick bounds that turn via shouldAbortForStall (including
22+
// awaiting-first-token after #1095): past the stall threshold it aborts,
2223
// the queued operator message gets a fresh turn, and parked asks either
2324
// re-surface (escalated) or settle exactly once via the ask deadline.
2425

‎src/tui/runtime-bridge.ts‎

Lines changed: 28 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,6 @@ import {
4949
repetitionRecoveryMessage,
5050
isStalledForDisplay,
5151
shouldAbortForStall,
52-
shouldAbortForStalledWakeTurn,
5352
stallLevel,
5453
STALL_NOTICE_MESSAGE,
5554
STALL_NOTICE_MS,
@@ -309,12 +308,11 @@ export interface SessionBridge {
309308
*/
310309
flushMailboxMail: () => void;
311310
/**
312-
* Stall bound for a silent ask-wake primary turn (CL-8016). Ends the turn
313-
* through the same path as an operator stop when it is still silent past
314-
* the stall threshold, then hands queued mail over and re-surfaces the
315-
* still-pending questions (escalated) so neither the queue nor the parked
316-
* asks freeze. Returns true when it aborted. Never touches tool-execution
317-
* or gated turns; those are someone else's turn shape. Driven by the fleet
311+
* Stall bound for a silent ask-wake primary turn (CL-8016). If a wake was
312+
* actually sent (`askWakeTurnArmed`) and `shouldAbortForStall` says the
313+
* turn is silent past the bound (including awaiting-first-token, per
314+
* #1095), un-dedupe the delivered wakes and interrupt so the questions
315+
* re-surface escalated. Returns true when it aborted. Driven by the fleet
318316
* stall poll, which settles deadline-past asks first.
319317
*/
320318
abortStalledWakeTurn: () => boolean;
@@ -1885,26 +1883,12 @@ export function attachSessionBridge(
18851883
bag.flushMailboxMail = flushMailboxMail;
18861884

18871885
/**
1888-
* Stall bound for a silent ask-wake primary turn (CL-8016). Only an armed
1889-
* (wake-sent, never settled) turn can match: the predicate bounds silence
1890-
* past the threshold while tool-execution and gated turns stay excluded.
1891-
* The layer is stamped before the interrupt so the marker names the hung
1892-
* layer instead of the post-abort idle. The turn ends through the same
1893-
* path as an operator stop — interrupt the record, hand the queued mail
1894-
* over, then let occupancy drive mail or re-surface the still-pending
1895-
* questions (escalated) — so neither the queue nor the parked asks freeze.
1886+
* A stalled armed wake never landed: drop matching deliveredAskWake entries
1887+
* and bump the resurface count so the next flush restates the questions
1888+
* (escalated) instead of skipping them as already delivered.
18961889
*/
1897-
const abortStalledWakeTurn = (): boolean => {
1898-
if (bag.disposed || !bag.askWakeTurnArmed) return false;
1899-
if (!shouldAbortForStalledWakeTurn(stallArgsFor(now()))) return false;
1900-
const layer = turnStallLayer(bag.turn) ?? "mid-stream";
1901-
// The aborted turn never landed its wakes: release the still-pending
1902-
// questions from delivery dedupe so the trailing flush restates them
1903-
// (escalated via the count), and bump the count so the restatement reads
1904-
// as proof the earlier turn stalled rather than as a duplicate. Settled
1905-
// or replaced questions are untouched — only a pending question whose
1906-
// delivered id matches can re-surface, so each abort yields at most one
1907-
// restatement per live question.
1890+
const unDedupeArmedAskWakes = (): void => {
1891+
if (!bag.askWakeTurnArmed) return;
19081892
for (const [sessionId, ask] of bag.pendingAskWake) {
19091893
if (bag.deliveredAskWake.get(sessionId) !== ask.questionId) continue;
19101894
bag.deliveredAskWake.delete(sessionId);
@@ -1913,6 +1897,21 @@ export function attachSessionBridge(
19131897
(bag.askWakeResurface.get(sessionId) ?? 0) + 1,
19141898
);
19151899
}
1900+
};
1901+
1902+
/**
1903+
* Stall bound for a silent ask-wake primary turn (CL-8016). Only an armed
1904+
* (wake-sent, never settled) turn can match. Silence uses `shouldAbortForStall`
1905+
* — the same #1095 bound that already covers awaiting-first-token — then
1906+
* un-dedupes and interrupts so parked questions re-surface. Mail first so
1907+
* occupancy can take the next turn; the trailing wake flush restates if
1908+
* mail did not start one.
1909+
*/
1910+
const abortStalledWakeTurn = (): boolean => {
1911+
if (bag.disposed || !bag.askWakeTurnArmed) return false;
1912+
if (!shouldAbortForStall(stallArgsFor(now()))) return false;
1913+
const layer = turnStallLayer(bag.turn) ?? "mid-stream";
1914+
unDedupeArmedAskWakes();
19161915
recordTurnMarker(bag, `stall-abort:${layer}`);
19171916
bag.askWakeTurnArmed = false;
19181917
bag.turn = turnStateOnInterrupt(bag.turn, now());
@@ -1943,8 +1942,9 @@ export function attachSessionBridge(
19431942
// turn the operator (or the watchdog) deliberately stopped.
19441943
recordLastSent(null);
19451944
bag.awaitingContinuationInference = false;
1946-
// An operator stop ends the turn the same way a settle does: no bound
1947-
// is owed anymore, whatever the turn was started for.
1945+
// Armed wake that dies here (operator stop or #1095 stall abort) never
1946+
// landed: un-dedupe so flushPendingAskWake restates the questions.
1947+
unDedupeArmedAskWakes();
19481948
bag.askWakeTurnArmed = false;
19491949
bag.turn = turnStateOnInterrupt(bag.turn, now());
19501950
paintPhase();

‎src/tui/stall-watchdog.ts‎

Lines changed: 0 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -96,29 +96,6 @@ export function shouldAbortForStall(args: ShouldAbortForStallArgs): boolean {
9696
return silentPastThreshold(args, args.stallTimeoutMs);
9797
}
9898

99-
/**
100-
* Whether a silent primary turn started for parked `ask_director` questions
101-
* (CL-8016) is past the bound and should be aborted so the queue and the
102-
* questions unfreeze. Unlike `shouldAbortForStall` this deliberately includes
103-
* the awaiting-first-token state: a wake turn is machine-generated text the
104-
* parent never typed, so no operator intent is discarded by bounding it —
105-
* the abort re-surfaces the still-pending questions (escalated) or lets the
106-
* ask deadline settle them. Tool-execution turns are still excluded: an
107-
* `ask_director` wake turn never starts tools, so tools out means this is not
108-
* the wake turn stalling. Blocked turns are excluded via `status`.
109-
*/
110-
export function shouldAbortForStalledWakeTurn(
111-
args: ShouldAbortForStallArgs,
112-
): boolean {
113-
if (args.status !== "running") return false;
114-
if (args.nowMs - args.lastActivityAt < args.stallTimeoutMs) return false;
115-
// Tool execution is someone else's turn shape, not a stalled wake turn.
116-
if (args.activeToolCalls.length > 0 || args.streamingType === "tool") {
117-
return false;
118-
}
119-
return args.isProcessing;
120-
}
121-
12299
export type ShouldNoticeStallArgs = ShouldAbortForStallArgs & {
123100
readonly stallNoticeMs: number;
124101
/** Whether the repetition guard currently sees a looping tail. */

0 commit comments

Comments
 (0)