Skip to content

Commit 308cab6

Browse files
committed
Give stall recovery a full timeout grace after the first nudge
Queued empty stall pings that drain right after the first nudge used to count as a second consecutive stall and stop the leaf immediately. Record stallNudgeAt on the nudge, wait through pings inside stallTimeoutMs without treating them as activity or restarting grace, and stop only once the grace elapses with no tool.done or turn reset.
1 parent ff61185 commit 308cab6

2 files changed

Lines changed: 105 additions & 11 deletions

File tree

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

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -975,6 +975,88 @@ describe("SubAgentDirector post-complete terminalization (CL-7068)", () => {
975975
});
976976
});
977977

978+
describe("SubAgentDirector stall nudge grace", () => {
979+
test("queued pings inside grace wait; stop only after grace with no activity", async () => {
980+
let now = 1_000_000;
981+
const director = new SubAgentDirector(
982+
"system",
983+
[],
984+
undefined,
985+
1_000,
986+
() => now,
987+
);
988+
const caps = capabilities();
989+
990+
await director.decide(inferenceDoneText("working"), state, caps);
991+
992+
now += 1_000;
993+
const first = actions(
994+
await director.decide(messageReceived(""), state, caps),
995+
);
996+
expect(first).toContainEqual({
997+
type: "checkpoint",
998+
message: "subagent-stall-nudge",
999+
});
1000+
1001+
now += 200;
1002+
const midGrace = actions(
1003+
await director.decide(messageReceived(""), state, caps),
1004+
);
1005+
expect(midGrace).toEqual([{ type: "wait" }]);
1006+
1007+
now += 200;
1008+
const stillGrace = actions(
1009+
await director.decide(messageReceived(""), state, caps),
1010+
);
1011+
expect(stillGrace).toEqual([{ type: "wait" }]);
1012+
1013+
now += 600;
1014+
const stopped = actions(
1015+
await director.decide(messageReceived(""), state, caps),
1016+
);
1017+
expect(stopped).toContainEqual({
1018+
type: "checkpoint",
1019+
message: "subagent-stalled",
1020+
});
1021+
expect(stopped.some((action) => action.type === "reply")).toBe(true);
1022+
});
1023+
1024+
test("tool.done during grace clears stallNudgeAt so a later silence nudges again", async () => {
1025+
let now = 2_000_000;
1026+
const director = new SubAgentDirector(
1027+
"system",
1028+
[],
1029+
undefined,
1030+
1_000,
1031+
() => now,
1032+
);
1033+
const caps = capabilities();
1034+
1035+
await director.decide(inferenceDone(["read-1"]), state, caps);
1036+
now += 1_000;
1037+
const first = actions(
1038+
await director.decide(messageReceived(""), state, caps),
1039+
);
1040+
expect(first).toContainEqual({
1041+
type: "checkpoint",
1042+
message: "subagent-stall-nudge",
1043+
});
1044+
1045+
now += 100;
1046+
await director.decide(toolDone("read-1"), state, caps);
1047+
1048+
now += 1_000;
1049+
const afterActivity = actions(
1050+
await director.decide(messageReceived(""), state, caps),
1051+
);
1052+
expect(afterActivity).toContainEqual({
1053+
type: "checkpoint",
1054+
message: "subagent-stall-nudge",
1055+
});
1056+
expect(afterActivity.some((action) => action.type === "reply")).toBe(false);
1057+
});
1058+
});
1059+
9781060
function stubAdmission(
9791061
notes: { provider: string; until: number }[],
9801062
): AdmissionQueue {

‎src/subagent/nudge-director.ts‎

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,11 @@ export class SubAgentDirector extends DefaultDirector {
172172
private readonly stallTimeoutMs: number | undefined;
173173
private readonly now: () => number;
174174
private lastActivityAt: number;
175-
private consecutiveStalls = 0;
175+
// Wall clock when the first stall nudge was issued. Later empty pings inside
176+
// stallTimeoutMs of this instant wait without stopping or restarting grace;
177+
// stop only after the grace elapses with no activity. Cleared on real
178+
// tool.done / turn-boundary activity.
179+
private stallNudgeAt: number | undefined;
176180
private lastAssistantText = "";
177181
// Every stop and nudge is recorded with its measured value beside its
178182
// threshold, so a later threshold change can cite data instead of judgment
@@ -299,7 +303,7 @@ export class SubAgentDirector extends DefaultDirector {
299303
if (onTurnBoundary(event)) {
300304
this.lastConsumedNudgeText = null;
301305
this.lastActivityAt = this.now();
302-
this.consecutiveStalls = 0;
306+
this.stallNudgeAt = undefined;
303307
this.compaction.noteInferenceDone(event, state.turns);
304308
this.turnsCompleted++;
305309
const content = event.turn.content as readonly {
@@ -406,7 +410,7 @@ export class SubAgentDirector extends DefaultDirector {
406410
}
407411
if (event.type === "tool.done") {
408412
this.lastActivityAt = this.now();
409-
this.consecutiveStalls = 0;
413+
this.stallNudgeAt = undefined;
410414
if (event.result.isError === true) {
411415
// Failed-tool recovery guidance. Arm once; coalesce consecutive failure
412416
// audits until applyPendingNudge flushes a single counted record.
@@ -434,12 +438,12 @@ export class SubAgentDirector extends DefaultDirector {
434438
* "no pending harness-tracked work" falls out of when this method can run
435439
* at all rather than needing separate bookkeeping.
436440
*
437-
* First stall past the timeout: one continuation nudge, asking the leaf to
438-
* report status or keep going. A second consecutive stall (no activity
439-
* since the nudge) escalates to the existing salvage path, same shape as
440-
* the turn-boundary checks above. Returns null when this event is not
441-
* a stall check the director should act on (let it fall through as an
442-
* ordinary continuation).
441+
* First silence past the timeout: one continuation nudge, and record
442+
* stallNudgeAt. Queued pings that arrive inside the stallTimeoutMs grace
443+
* after that nudge neither stop nor restart the grace (and do not count as
444+
* activity). Stop only when a ping arrives after the grace with still no
445+
* activity. Returns null when this event is not a stall check the director
446+
* should act on (let it fall through as an ordinary continuation).
443447
*/
444448
private checkStallPing(
445449
event: ReactorInboundEvent,
@@ -452,8 +456,8 @@ export class SubAgentDirector extends DefaultDirector {
452456
const elapsed = this.now() - this.lastActivityAt;
453457
if (elapsed < this.stallTimeoutMs) return null;
454458

455-
this.consecutiveStalls++;
456-
if (this.consecutiveStalls === 1) {
459+
if (this.stallNudgeAt === undefined) {
460+
this.stallNudgeAt = this.now();
457461
this.interventions({
458462
id: "stall-nudge",
459463
class: "nudge",
@@ -469,6 +473,14 @@ export class SubAgentDirector extends DefaultDirector {
469473
inferWithSubAgentNudge(capabilities, SUBAGENT_STALL_NUDGE),
470474
];
471475
}
476+
477+
const sinceNudge = this.now() - this.stallNudgeAt;
478+
if (sinceNudge < this.stallTimeoutMs) {
479+
// Still inside the post-nudge grace. Wait without faking activity or
480+
// restarting the grace clock.
481+
return [capabilities.wait()];
482+
}
483+
472484
this.interventions({
473485
id: "stalled",
474486
class: "stop",

0 commit comments

Comments
 (0)