Skip to content

Commit dfeac6d

Browse files
fix(tui): end the silent wake turn when the ask deadline settles it (#1106)
* fix(tui): end the silent wake turn when the ask deadline settles it * fix(tui): abort expired wake only when asks expired this tick send_input answering the last ask also empties pendingAskWake while the parent is still inferring. Abort only when expireStaleAsks expired this tick.
1 parent 8b08823 commit dfeac6d

4 files changed

Lines changed: 276 additions & 15 deletions

File tree

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

Lines changed: 150 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -137,9 +137,7 @@ describe("stall-bound primary turn (CL-8016)", () => {
137137
() => bridge.flushMailboxMail(),
138138
{
139139
abortStalledWakeTurn: () => bridge.abortStalledWakeTurn(),
140-
expireStaleAsks: () => {
141-
store.expireStaleAsks(ASK_DEADLINE_MS);
142-
},
140+
expireStaleAsks: () => store.expireStaleAsks(ASK_DEADLINE_MS),
143141
},
144142
);
145143
tick();
@@ -292,9 +290,7 @@ describe("stall-bound primary turn (CL-8016)", () => {
292290
() => bridge.flushMailboxMail(),
293291
{
294292
abortStalledWakeTurn: () => bridge.abortStalledWakeTurn(),
295-
expireStaleAsks: () => {
296-
store.expireStaleAsks(ASK_DEADLINE_MS);
297-
},
293+
expireStaleAsks: () => store.expireStaleAsks(ASK_DEADLINE_MS),
298294
},
299295
);
300296

@@ -330,6 +326,154 @@ describe("stall-bound primary turn (CL-8016)", () => {
330326
});
331327
});
332328

329+
test("late wake expiring at the deadline ends the silent turn without the stall bound (CL-8060)", async () => {
330+
await withTestRenderer(async (h) => {
331+
let nowMs = 5_000_000;
332+
const store = createSubAgentSessionStore({ now: () => nowMs });
333+
const worker = parkWorker(store, "late");
334+
const shell = createAppShell(h.renderer, {
335+
terminal: { columns: 80, rows: 24 },
336+
wireKeys: false,
337+
});
338+
const port = createRecordingPort();
339+
const bridge = attachSessionBridge(shell, port, {
340+
now: () => nowMs,
341+
stallTimeoutMs: STALL_TIMEOUT_MS,
342+
schedule: () => () => undefined,
343+
});
344+
try {
345+
const askedAt = nowMs;
346+
// The wake lands late: just inside the ask deadline, so the silent
347+
// turn is still inside its stall window when the deadline hits.
348+
nowMs = askedAt + ASK_DEADLINE_MS - 500;
349+
bridge.handle({
350+
type: "agent-ask",
351+
asks: pendingAskSnapshot(store.list(), (id) => {
352+
const ask = store.peekAsk(id);
353+
return ask === undefined ? undefined : ask;
354+
}),
355+
});
356+
expect(bridge.turn.isProcessing).toBe(true);
357+
expect(wakeDeliveries(port)).toHaveLength(1);
358+
359+
const reportFleet = (): void => {
360+
bridge.handle({
361+
type: "agent-ask",
362+
asks: pendingAskSnapshot(store.list(), (id) => {
363+
const ask = store.peekAsk(id);
364+
return ask === undefined ? undefined : ask;
365+
}),
366+
});
367+
};
368+
const tick = createFleetStallPollTick(
369+
reportFleet,
370+
() => bridge.flushMailboxMail(),
371+
{
372+
abortStalledWakeTurn: () => bridge.abortStalledWakeTurn(),
373+
abortExpiredWakeTurn: (expiredThisTick) =>
374+
bridge.abortExpiredWakeTurn(expiredThisTick),
375+
expireStaleAsks: () => store.expireStaleAsks(ASK_DEADLINE_MS),
376+
},
377+
);
378+
379+
// Past the ask deadline but still inside the wake turn's stall window:
380+
// the deadline settles the question and the silent turn must end idle
381+
// without waiting for the stall bound.
382+
nowMs = askedAt + ASK_DEADLINE_MS + 1;
383+
tick();
384+
385+
expect(worker.resolved).toHaveLength(0);
386+
expect(worker.rejected).toHaveLength(1);
387+
expect(String(worker.rejected[0])).toContain(worker.questionId);
388+
expect(bridge.turn.isProcessing).toBe(false);
389+
const paths = bridge.turnMarkers().map((marker) => marker.path);
390+
expect(paths).toContain("expire-abort");
391+
expect(paths.some((path) => path.startsWith("stall-abort"))).toBe(
392+
false,
393+
);
394+
// Nothing left to re-surface: the expired wake does not send again.
395+
expect(wakeDeliveries(port)).toHaveLength(1);
396+
expect(bridge.abortStalledWakeTurn()).toBe(false);
397+
expect(bridge.abortExpiredWakeTurn(true)).toBe(false);
398+
} finally {
399+
bridge.dispose();
400+
}
401+
});
402+
});
403+
404+
test("send_input resolving the last ask on a live wake turn does not expire-abort", async () => {
405+
await withTestRenderer(async (h) => {
406+
let nowMs = 6_000_000;
407+
const store = createSubAgentSessionStore({ now: () => nowMs });
408+
const worker = parkWorker(store, "live");
409+
const shell = createAppShell(h.renderer, {
410+
terminal: { columns: 80, rows: 24 },
411+
wireKeys: false,
412+
});
413+
const port = createRecordingPort();
414+
const bridge = attachSessionBridge(shell, port, {
415+
now: () => nowMs,
416+
stallTimeoutMs: STALL_TIMEOUT_MS,
417+
schedule: () => () => undefined,
418+
});
419+
try {
420+
bridge.handle({
421+
type: "agent-ask",
422+
asks: pendingAskSnapshot(store.list(), (id) => {
423+
const ask = store.peekAsk(id);
424+
return ask === undefined ? undefined : ask;
425+
}),
426+
});
427+
expect(bridge.turn.isProcessing).toBe(true);
428+
expect(wakeDeliveries(port)).toHaveLength(1);
429+
430+
// Parent is inferring the wake when send_input answers the last ask.
431+
bridge.handle({ type: "inference.start", data: {} });
432+
expect(store.sendInputOne(worker.sessionId, "the answer")).toEqual({
433+
ok: true,
434+
status: "running",
435+
});
436+
expect(worker.resolved).toEqual(["the answer"]);
437+
expect(store.hasPendingAsk(worker.sessionId)).toBe(false);
438+
439+
const reportFleet = (): void => {
440+
bridge.handle({
441+
type: "agent-ask",
442+
asks: pendingAskSnapshot(store.list(), (id) => {
443+
const ask = store.peekAsk(id);
444+
return ask === undefined ? undefined : ask;
445+
}),
446+
});
447+
};
448+
// Subscribe-time report empties pendingAskWake while inference is live.
449+
reportFleet();
450+
const tick = createFleetStallPollTick(
451+
reportFleet,
452+
() => bridge.flushMailboxMail(),
453+
{
454+
abortStalledWakeTurn: () => bridge.abortStalledWakeTurn(),
455+
abortExpiredWakeTurn: (expiredThisTick) =>
456+
bridge.abortExpiredWakeTurn(expiredThisTick),
457+
expireStaleAsks: () => store.expireStaleAsks(ASK_DEADLINE_MS),
458+
},
459+
);
460+
461+
tick();
462+
463+
expect(bridge.turn.isProcessing).toBe(true);
464+
const paths = bridge.turnMarkers().map((marker) => marker.path);
465+
expect(paths).toContain("infer-start");
466+
expect(paths).not.toContain("expire-abort");
467+
expect(paths.some((path) => path.startsWith("stall-abort"))).toBe(
468+
false,
469+
);
470+
expect(wakeDeliveries(port)).toHaveLength(1);
471+
} finally {
472+
bridge.dispose();
473+
}
474+
});
475+
});
476+
333477
test("stop clears the store and mailbox; late send_input names the teardown", async () => {
334478
await withTestRenderer(async (h) => {
335479
let nowMs = 3_000_000;

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

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,68 @@ describe("fleet stall poll tick (CL-7676)", () => {
2626
expect(order).toEqual(["report", "flush"]);
2727
});
2828

29+
test("expire-abort runs before stall-abort and only when expireStaleAsks expired this tick", () => {
30+
const order: string[] = [];
31+
const expired: { sessionId: string; questionId: string }[] = [];
32+
const tick = createFleetStallPollTick(
33+
() => {
34+
order.push("report");
35+
},
36+
() => {
37+
order.push("flush");
38+
},
39+
{
40+
expireStaleAsks: () => {
41+
order.push("expire");
42+
return expired;
43+
},
44+
abortExpiredWakeTurn: (expiredThisTick) => {
45+
order.push(`expire-abort:${expiredThisTick}`);
46+
return expiredThisTick;
47+
},
48+
abortStalledWakeTurn: () => {
49+
order.push("stall-abort");
50+
return false;
51+
},
52+
},
53+
);
54+
tick();
55+
expect(order).toEqual([
56+
"expire",
57+
"report",
58+
"expire-abort:false",
59+
"stall-abort",
60+
"flush",
61+
]);
62+
order.length = 0;
63+
expired.push({ sessionId: "s", questionId: "q" });
64+
tick();
65+
expect(order).toEqual([
66+
"expire",
67+
"report",
68+
"expire-abort:true",
69+
"stall-abort",
70+
"flush",
71+
]);
72+
});
73+
74+
test("expireStaleAsks boolean true still expire-aborts after report", () => {
75+
const calls: boolean[] = [];
76+
const tick = createFleetStallPollTick(
77+
() => undefined,
78+
() => undefined,
79+
{
80+
expireStaleAsks: () => true,
81+
abortExpiredWakeTurn: (expiredThisTick) => {
82+
calls.push(expiredThisTick);
83+
return expiredThisTick;
84+
},
85+
},
86+
);
87+
tick();
88+
expect(calls).toEqual([true]);
89+
});
90+
2991
test("missed edge (driver throws once) self-heals on the next poll without duplicates", async () => {
3092
await withTestRenderer(
3193
async (h) => {

‎src/tui/runner/wiring.ts‎

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -98,18 +98,32 @@ const tuiLogger = getLogger([LOG_NAMESPACE_ROOT, "tui"]);
9898
* The CL-8016 stall bound rides the same tick, deadline first: past-deadline
9999
* asks settle (so the snapshot the abort reconciles against is fresh), then a
100100
* still-silent wake turn aborts and hands over to mail or a re-surface.
101+
*
102+
* The CL-8060 deadline bound rides it too: when expireStaleAsks actually
103+
* expired asks this tick and the reconciling snapshot is empty, the armed
104+
* wake turn is owed to nobody, so it aborts even inside its stall window
105+
* (a late wake has not been silent long enough to trip the stall bound at
106+
* the deadline). send_input emptying pending is not an expire — the parent
107+
* may still be inferring. Expire-abort runs before the stall abort; at
108+
* most one fires per tick, and both share the occupancy-first handoff.
101109
*/
102110
export function createFleetStallPollTick(
103111
reportFleet: () => void,
104112
flushMailboxMail: () => void,
105113
options?: {
106114
abortStalledWakeTurn?: () => boolean;
107-
expireStaleAsks?: () => void;
115+
abortExpiredWakeTurn?: (expiredThisTick: boolean) => boolean;
116+
expireStaleAsks?: () =>
117+
| boolean
118+
| readonly { sessionId: string; questionId: string }[];
108119
},
109120
): () => void {
110121
return () => {
111-
options?.expireStaleAsks?.();
122+
const expired = options?.expireStaleAsks?.();
123+
const expiredThisTick =
124+
expired === true || (Array.isArray(expired) && expired.length > 0);
112125
reportFleet();
126+
options?.abortExpiredWakeTurn?.(expiredThisTick);
113127
options?.abortStalledWakeTurn?.();
114128
flushMailboxMail();
115129
};
@@ -395,9 +409,10 @@ export function wirePostStartup(
395409
// before the abort reconciles, so the abort never re-surfaces a
396410
// question the deadline already settled.
397411
abortStalledWakeTurn: () => sessionBridge.abortStalledWakeTurn(),
398-
expireStaleAsks: () => {
399-
services.subAgentSessions.expireStaleAsks(ASK_DEADLINE_MS);
400-
},
412+
abortExpiredWakeTurn: (expiredThisTick) =>
413+
sessionBridge.abortExpiredWakeTurn(expiredThisTick),
414+
expireStaleAsks: () =>
415+
services.subAgentSessions.expireStaleAsks(ASK_DEADLINE_MS),
401416
},
402417
);
403418
const fleetStallPoll = setInterval(fleetStallPollTick, FLEET_STALL_POLL_MS);

‎src/tui/runtime-bridge.ts‎

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,20 @@ export interface SessionBridge {
317317
* settles deadline-past asks first, and by the #1095 monitor tick.
318318
*/
319319
abortStalledWakeTurn: () => boolean;
320+
/**
321+
* Ask-deadline bound for a silent ask-wake primary turn (CL-8060). The poll
322+
* must have expired asks this tick (`expiredThisTick`); then, if a wake was
323+
* actually sent (`askWakeTurnArmed`) but the deadline already settled every
324+
* pending ask (so `pendingAskWake` is empty after the reconciling report),
325+
* the turn has nothing left to surface — interrupt it even when the stall
326+
* bound has not tripped (e.g. a late wake still inside its stall window at
327+
* the deadline). send_input emptying pending is not an expire: the parent
328+
* may still be inferring. Same occupancy-first handoff as the stall abort,
329+
* so mailbox mail still wins the next turn; with nothing pending there is
330+
* nothing to re-surface. Returns true when it aborted. Driven by the fleet
331+
* stall poll, after the deadline settle and its reconciling report.
332+
*/
333+
abortExpiredWakeTurn: (expiredThisTick: boolean) => boolean;
320334
/**
321335
* Phase-transition stamps (`TurnMarker`), newest last. Diagnostic-only:
322336
* exists so the stall-bound regression test can observe the abort path;
@@ -453,8 +467,8 @@ interface TurnThinking {
453467
const THINKING_FRAGMENT_SEPARATOR = "\n\n";
454468

455469
/**
456-
* A turn-phase transition stamp (CL-8016): `infer-start`, `first-token`,
457-
* `settle`, or `stall-abort:<layer>`. The stalled layer (inference stream vs
470+
* A turn-phase transition stamp (CL-8016, CL-8060): `infer-start`, `first-token`,
471+
* `settle`, `stall-abort:<layer>`, or `expire-abort`. The stalled layer (inference stream vs
458472
* turn-loop vs tool execution) cannot be read off the turn record after the
459473
* fact, so the abort stamps `turnStallLayer` at the moment it fires — that is
460474
* what names the hung layer instead of a post-mortem guess.
@@ -1385,8 +1399,10 @@ function applyInbound(
13851399
}
13861400
}
13871401
// A fresh snapshot with nothing pending means no wake is owed, but a
1388-
// still-armed silent turn must stay armed so the stall abort can interrupt
1389-
// it. Disarming here (expire / send_input) would leave isProcessing hung.
1402+
// still-armed silent turn must stay armed until the poll's expire-abort
1403+
// (CL-8060, only when expireStaleAsks expired this tick) or stall abort
1404+
// can interrupt it. Disarming here (expire / send_input) would leave
1405+
// isProcessing hung; send_input emptying pending must not expire-abort.
13901406
bag.flushPendingAskWake?.();
13911407
return;
13921408
}
@@ -1953,6 +1969,28 @@ export function attachSessionBridge(
19531969
return true;
19541970
};
19551971

1972+
/**
1973+
* Ask-deadline bound for a silent ask-wake primary turn (CL-8060). Only an
1974+
* expire this tick plus an armed (wake-sent, never settled) turn with
1975+
* nothing left pending can match: expireStaleAsks settled the questions
1976+
* and the reconciling `reportFleet` emptied `pendingAskWake`, so the wake
1977+
* turn is owed to nobody. send_input emptying pending is not an expire.
1978+
* Unlike the stall bound there is no silence clock — the questions expired,
1979+
* so the turn ends even inside its stall window. Shares
1980+
* `abortInFlightAndHandoff` with the stall abort, so occupancy (mailbox
1981+
* mail) still wins the next turn and the trailing wake flush restates
1982+
* nothing.
1983+
*/
1984+
const abortExpiredWakeTurn = (expiredThisTick: boolean): boolean => {
1985+
if (!expiredThisTick) return false;
1986+
if (bag.disposed || !bag.askWakeTurnArmed) return false;
1987+
if (!bag.turn.isProcessing) return false;
1988+
if (bag.pendingAskWake.size > 0) return false;
1989+
recordTurnMarker(bag, "expire-abort");
1990+
abortInFlightAndHandoff();
1991+
return true;
1992+
};
1993+
19561994
const doInterrupt = (): void => {
19571995
if (bag.disposed) return;
19581996
abortInFlightAndHandoff();
@@ -2187,6 +2225,8 @@ export function attachSessionBridge(
21872225
flushMailboxMail();
21882226
},
21892227
abortStalledWakeTurn: () => abortStalledWakeTurn(),
2228+
abortExpiredWakeTurn: (expiredThisTick) =>
2229+
abortExpiredWakeTurn(expiredThisTick),
21902230
turnMarkers: () => bag.turnMarkers,
21912231
dispose: () => {
21922232
flushOpenRow(shell, bag);

0 commit comments

Comments
 (0)