Skip to content

Commit 21011aa

Browse files
fix(tui): re-issue compact continuation after a superseded hop (#1102)
* fix(tui): land compact continue on the replacement agent * fix(subagent): wait instead of infer while ask_director is parked Empty stall pings during a parked ask are dropped, not deferred, so unpark cannot look like a stall-window continuation. Compact continues skipped during the park still flush after the ask releases.
1 parent b2dd4ff commit 21011aa

12 files changed

Lines changed: 689 additions & 38 deletions

‎docs/ARCHITECTURE.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ The ChatDirector counts consecutive assistant turns that contain tool calls and
161161

162162
#### Sub-agent stall management
163163

164-
`SubAgentDirector` tracks `lastActivityAt`, updated on every real `inference.done` and `tool.done`. Directors are pure `decide(event, ...)` functions with no timer of their own and the reactor has no proactive "idle" event, so a genuinely silent worker (e.g. parked on a long-running background command with nothing else to do) produces no event for the director to react to. `runSubAgent` (`src/subagent/index.ts`) arms an external interval, at `subAgentStallTimeoutMs`, that pings the same content-less continuation channel the compaction governor uses to re-enter an idle reactor (`requestContinuation`). The director only acts on a ping if the elapsed time since `lastActivityAt` has crossed the timeout. A ping can still be delivered while `execute_tools` is in flight; outstanding call ids from the last `inference.done` reset the silence clock and wait rather than recording `stall-nudge` with a huge `silenceMs`. The first stall past the timeout records `stallNudgeAt` and issues one continuation nudge (asking the worker to check on the background work or report status). Later empty pings inside `subAgentStallTimeoutMs` of that instant wait without stopping or treating the ping as activity; salvage fires only once a ping arrives after that grace with still no `tool.done` / turn-boundary reset. That grace is what keeps two queued interval ticks from salvaging hundreds of milliseconds after the nudge. Any real activity clears `stallNudgeAt`, so a worker that is genuinely working through a slow single turn is never penalized. After the worker has already replied with a terminal report (complete envelope or salvage), further empty continuations — idle-compact meter sync or stall pings — return `wait` instead of falling through to `DefaultDirector.infer`; only a non-empty parent message (`resume_agent` / `send_input`) re-opens the brief.
164+
`SubAgentDirector` tracks `lastActivityAt`, updated on every real `inference.done` and `tool.done`. Directors are pure `decide(event, ...)` functions with no timer of their own and the reactor has no proactive "idle" event, so a genuinely silent worker (e.g. parked on a long-running background command with nothing else to do) produces no event for the director to react to. `runSubAgent` (`src/subagent/index.ts`) arms an external interval, at `subAgentStallTimeoutMs`, that pings the same content-less continuation channel the compaction governor uses to re-enter an idle reactor (`requestContinuation`). The director only acts on a ping if the elapsed time since `lastActivityAt` has crossed the timeout. A ping can still be delivered while `execute_tools` is in flight; outstanding call ids from the last `inference.done` reset the silence clock and wait rather than recording `stall-nudge` with a huge `silenceMs`. The first stall past the timeout records `stallNudgeAt` and issues one continuation nudge (asking the worker to check on the background work or report status). Later empty pings inside `subAgentStallTimeoutMs` of that instant wait without stopping or treating the ping as activity; salvage fires only once a ping arrives after that grace with still no `tool.done` / turn-boundary reset. That grace is what keeps two queued interval ticks from salvaging hundreds of milliseconds after the nudge. Any real activity clears `stallNudgeAt`, so a worker that is genuinely working through a slow single turn is never penalized. After the worker has already replied with a terminal report (complete envelope or salvage), further empty continuations — idle-compact meter sync or stall pings — return `wait` instead of falling through to `DefaultDirector.infer`; only a non-empty parent message (`resume_agent` / `send_input`) re-opens the brief. A parked `ask_director` is the same class of wait: empty stall pings are dropped (not deferred) for the park duration, compact-continue hops skipped during the park flush after unpark, and `SubAgentDirector` returns `wait` on any empty continuation that still arrives while the ask is pending so a long park cannot start a billable infer.
165165

166166
**Intervention log**: every stop and nudge is appended as one JSONL record to `interventions.jsonl` in the firing worker's trace dir (`src/subagent/intervention-log.ts`), carrying the trigger's measured value beside the threshold it crossed, the provider/model/family it fired on, and the run state at that moment (turns used vs budget, tool calls, read/edit counts). A refused parent re-dispatch is recorded on the parent side, where no worker run exists to record it. The parent also appends one `outcome` record per completed dispatch — the salvage kind `classifyBriefSalvage` assigned, or a clean-complete marker, plus the dispatch count — so the log carries dispatch outcomes as well as interventions, and a stop record can later be read alongside what the dispatch it touched actually produced. Writes are fire-and-forget and swallow their own errors — a diagnostic must not be able to fail a run. `scripts/intervention-forensics.ts` aggregates these across local sessions: per-intervention counts by model family, the measured-value distribution against the threshold, two context columns (stops that fired on runs which had already edited files; stops that fired before half the turn budget was spent — neither is a measured false-positive rate, since either is equally consistent with a correct stop or a wrong one), and outcome counts by kind. This exists because every threshold in this tree was set by judgment and four of those judgments were later reverted — a threshold change is expected to cite this data (CL-6938).
167167

@@ -192,7 +192,7 @@ When a cycle's input tokens cross a threshold, the director compacts the inferen
192192
- **Idle (end-of-turn)** — An interactive turn can end with a reply and then sit idle with no tool batch to intercept; the governor requests a continuation at that pause and compacts when it arrives. An operator message that races the continuation still compacts first, then re-enters inference to answer it.
193193
- **Overflow recovery** — A `context_overflow` inference error would otherwise become a terminal error reply; the governor compacts and retries instead, bounded so a history the compactor cannot shrink does not loop forever. Overflow ignores hysteresis for the compact itself.
194194

195-
The compaction control flow is shaped by a reactor invariant: a `compact` action runs in its own cycle (it cannot be paired with `infer`), and **the reactor delivers no event after a compact cycle**. A director that simply emitted `compact` in place of the follow-up `infer` would leave the loop idle forever — the cause of an earlier stall. Instead the governor pairs `compact` with an emit action (`custom.compaction.continue`), and the host answers that emission by delivering a content-less inbound message (`buildCompactionContinuationMessage()`) through the serial operation queue, generation-guarded like every other deliver. That message adds no turn (`createInboundTurn` returns `null` for empty content) but re-enters the loop, where the director issues the follow-up `infer` against the freshly truncated history. Each emission is answered at most once (a replayed duplicate of an already-answered emission is ignored), and an unsolicited continuation with neither resume flag set answers `wait` rather than burning a billable inference. The legacy `requestContinuation` closure survives only on the sub-agent path, which delivers directly with no host emit hop; idle arming keeps the two channels exclusive (closure fires and the arming flag stays false, or no closure and the flag reports the arming) so a caller honoring both cannot double-deliver.
195+
The compaction control flow is shaped by a reactor invariant: a `compact` action runs in its own cycle (it cannot be paired with `infer`), and **the reactor delivers no event after a compact cycle**. A director that simply emitted `compact` in place of the follow-up `infer` would leave the loop idle forever — the cause of an earlier stall. Instead the governor pairs `compact` with an emit action (`custom.compaction.continue`), and the host answers that emission by delivering a content-less inbound message (`buildCompactionContinuationMessage()`) through the serial operation queue, generation-guarded like every other deliver. A hop superseded because interrupt already bumped generation and enqueued a rebuild is re-queued onto the replacement agent rather than consumed on the outgoing liveAgent. That message adds no turn (`createInboundTurn` returns `null` for empty content) but re-enters the loop, where the director issues the follow-up `infer` against the freshly truncated history. Each emission is answered at most once (a replayed duplicate of an already-answered emission is ignored), and an unsolicited continuation with neither resume flag set answers `wait` rather than burning a billable inference. The legacy `requestContinuation` closure survives only on the sub-agent path, which delivers directly with no host emit hop; idle arming keeps the two channels exclusive (closure fires and the arming flag stays false, or no closure and the flag reports the arming) so a caller honoring both cannot double-deliver.
196196

197197
Compaction replaces older turns with a structured, workflow-aware summary rather than a stats blob: sections for **What Happened / What We're Doing / Relevant Links / Action Items / Next Steps**, with the active workflow and step woven in so compacting mid-`/build` or mid-`/plan` preserves the contract. The summary is produced by a one-shot model call; on any failure it falls back to a deterministic summary so a compaction cycle never breaks the session.
198198

‎src/subagent/ask-director.test.ts‎

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@ import {
55
ASK_DIRECTOR_MAX_QUESTIONS,
66
commitAskDirector,
77
createAskDirectorState,
8+
createDeferredContinuation,
89
evaluateAskDirector,
910
handleAskDirector,
1011
releaseAskDirector,
1112
resetAskDirectorTurn,
12-
skipStallContinuationWhileAskPending,
1313
} from "./ask-director.js";
1414

1515
describe("evaluateAskDirector", () => {
@@ -207,18 +207,43 @@ describe("evaluateAskDirector", () => {
207207
describe("requestContinuation stall skip", () => {
208208
test("is a no-op while askDirectorState.pending", () => {
209209
const state = createAskDirectorState();
210+
const latch = createDeferredContinuation();
210211
let delivered = 0;
211-
skipStallContinuationWhileAskPending(state, () => {
212+
const deliver = () => {
212213
delivered += 1;
213-
});
214+
};
215+
latch.request(state, deliver);
214216
expect(delivered).toBe(1);
215217

216218
expect(evaluateAskDirector({ question: "which file?", state }).ok).toBe(
217219
true,
218220
);
219-
skipStallContinuationWhileAskPending(state, () => {
221+
latch.request(state, deliver);
222+
expect(delivered).toBe(1);
223+
});
224+
225+
test("ask-pending skip defers compact continuation until the ask releases", () => {
226+
const state = createAskDirectorState();
227+
const latch = createDeferredContinuation();
228+
let delivered = 0;
229+
const deliver = () => {
220230
delivered += 1;
221-
});
231+
};
232+
233+
expect(evaluateAskDirector({ question: "which file?", state }).ok).toBe(
234+
true,
235+
);
236+
latch.request(state, deliver);
237+
expect(delivered).toBe(0);
238+
239+
latch.flush(state, deliver);
240+
expect(delivered).toBe(0);
241+
242+
releaseAskDirector(state);
243+
latch.flush(state, deliver);
244+
expect(delivered).toBe(1);
245+
246+
latch.flush(state, deliver);
222247
expect(delivered).toBe(1);
223248
});
224249
});

‎src/subagent/ask-director.ts‎

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -89,15 +89,30 @@ export function resetAskDirectorTurn(state: AskDirectorState): void {
8989
}
9090

9191
/**
92-
* Waiting on the spawning director is work, not silence. A stall ping
93-
* would otherwise salvage a blocked ask_director as stalled.
92+
* Compact continuation shares the stall ping channel. Skipping while
93+
* ask_director is parked must not drop the only continue: remember the skip
94+
* and flush after the ask releases.
9495
*/
95-
export function skipStallContinuationWhileAskPending(
96-
state: AskDirectorState,
97-
deliver: () => void,
98-
): void {
99-
if (state.pending) return;
100-
deliver();
96+
export function createDeferredContinuation(): {
97+
request: (state: AskDirectorState, deliver: () => void) => void;
98+
flush: (state: AskDirectorState, deliver: () => void) => void;
99+
} {
100+
let deferred = false;
101+
return {
102+
request(state, deliver) {
103+
if (state.pending) {
104+
deferred = true;
105+
return;
106+
}
107+
deferred = false;
108+
deliver();
109+
},
110+
flush(state, deliver) {
111+
if (!deferred || state.pending) return;
112+
deferred = false;
113+
deliver();
114+
},
115+
};
101116
}
102117

103118
export async function handleAskDirector(args: {

‎src/subagent/nudge-director.test.ts‎

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1458,6 +1458,105 @@ describe("SubAgentDirector stall nudge grace", () => {
14581458
});
14591459
});
14601460

1461+
describe("SubAgentDirector ask_director park wait-guard", () => {
1462+
test("empty stall ping during a parked ask waits instead of inferring", async () => {
1463+
let now = 6_000_000;
1464+
let parked = true;
1465+
const director = new SubAgentDirector(
1466+
"system",
1467+
[],
1468+
undefined,
1469+
1_000,
1470+
() => now,
1471+
);
1472+
director.observeAskPending(() => parked);
1473+
const caps = capabilities();
1474+
1475+
await director.decide(inferenceDoneText("which file?"), state, caps);
1476+
now += 60_000;
1477+
const parkedPing = actions(
1478+
await director.decide(messageReceived(""), state, caps),
1479+
);
1480+
expect(parkedPing).toEqual([{ type: "wait" }]);
1481+
expect(parkedPing.some((action) => action.type === "infer")).toBe(false);
1482+
expect(parkedPing.some((action) => action.type === "checkpoint")).toBe(
1483+
false,
1484+
);
1485+
});
1486+
1487+
test("compact continue skipped during park still resumes infer after unpark", async () => {
1488+
let parked = false;
1489+
let continuations = 0;
1490+
const director = new SubAgentDirector(
1491+
"system",
1492+
[],
1493+
() => {
1494+
continuations++;
1495+
},
1496+
30,
1497+
frozenNow,
1498+
);
1499+
director.observeAskPending(() => parked);
1500+
const caps = capabilities();
1501+
1502+
const compact = actions(
1503+
await director.decide(overflowError(), state, caps),
1504+
);
1505+
expect(compact).toEqual([
1506+
{
1507+
type: "compact",
1508+
compactor: "pruning-compactor",
1509+
reason: "context-overflow",
1510+
},
1511+
]);
1512+
expect(continuations).toBe(1);
1513+
1514+
parked = true;
1515+
const duringPark = actions(
1516+
await director.decide(messageReceived(""), state, caps),
1517+
);
1518+
expect(duringPark).toEqual([{ type: "wait" }]);
1519+
expect(duringPark.some((action) => action.type === "infer")).toBe(false);
1520+
1521+
parked = false;
1522+
const afterUnpark = inferAction(
1523+
await director.decide(messageReceived(""), state, caps),
1524+
);
1525+
expect(afterUnpark.type).toBe("infer");
1526+
});
1527+
1528+
test("unparked silence past the stall window still stall-nudges", async () => {
1529+
let now = 7_000_000;
1530+
let parked = true;
1531+
const director = new SubAgentDirector(
1532+
"system",
1533+
[],
1534+
undefined,
1535+
1_000,
1536+
() => now,
1537+
);
1538+
director.observeAskPending(() => parked);
1539+
const caps = capabilities();
1540+
1541+
await director.decide(inferenceDoneText("which file?"), state, caps);
1542+
now += 60_000;
1543+
expect(
1544+
actions(await director.decide(messageReceived(""), state, caps)),
1545+
).toEqual([{ type: "wait" }]);
1546+
1547+
parked = false;
1548+
now += 1_000;
1549+
const afterUnpark = actions(
1550+
await director.decide(messageReceived(""), state, caps),
1551+
);
1552+
expect(afterUnpark).toContainEqual({
1553+
type: "checkpoint",
1554+
message: "subagent-stall-nudge",
1555+
});
1556+
expect(afterUnpark.some((action) => action.type === "infer")).toBe(true);
1557+
});
1558+
});
1559+
14611560
function stubAdmission(
14621561
notes: { provider: string; until: number }[],
14631562
): AdmissionQueue {

‎src/subagent/nudge-director.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,12 @@ export class SubAgentDirector extends DefaultDirector {
171171
// (resume_agent / send_input).
172172
private reportReplied = false;
173173

174+
// Live getter from run.ts over askDirectorState.pending. Empty stall pings
175+
// and compact-continue hops must wait while the ask is parked — same idea as
176+
// ChatDirector's unsolicited-empty wait. Optional: tests and non-leaf runs
177+
// leave it unset.
178+
private isAskPending: () => boolean = () => false;
179+
174180
// Stall management: a leaf that goes quiet (e.g. parked on a long-running
175181
// background command with nothing else to do) produces no inbound events
176182
// for the director to react to. The reactor has no proactive "idle" event
@@ -215,6 +221,11 @@ export class SubAgentDirector extends DefaultDirector {
215221
this.onForcedStop = callback;
216222
}
217223

224+
/** Live ask_director park flag so empty continuations wait instead of inferring. */
225+
observeAskPending(isPending: () => boolean): void {
226+
this.isAskPending = isPending;
227+
}
228+
218229
/** Run state every intervention record carries, for judging it afterwards. */
219230
private interventionState(): {
220231
turnsCompleted: number;
@@ -284,6 +295,15 @@ export class SubAgentDirector extends DefaultDirector {
284295
this.verbatimToolCallNudgeFired = false;
285296
}
286297

298+
// Parked ask_director is waiting on the parent, not silent. Wait before
299+
// compact resume / stall-nudge so a long park cannot burn a billable
300+
// infer, and so an outstanding compact continue is not consumed.
301+
if (isEmptyContinuation(event) && this.isAskPending()) {
302+
this.lastActivityAt = this.now();
303+
this.stallNudgeAt = undefined;
304+
return capabilities.wait();
305+
}
306+
287307
const afterCompact = this.compaction.resumeAfterCompact(event);
288308
if (afterCompact !== null) {
289309
// Compacted history is the live occupancy until the next provider-

0 commit comments

Comments
 (0)