Skip to content

Commit e2e3b0b

Browse files
committed
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 4c68a02 commit e2e3b0b

5 files changed

Lines changed: 427 additions & 6 deletions

File tree

‎docs/ARCHITECTURE.md‎

Lines changed: 1 addition & 1 deletion
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

‎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)