Skip to content

Commit ea7e138

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 4a099b3 commit ea7e138

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
@@ -1014,6 +1014,88 @@ describe("SubAgentDirector post-complete terminalization (CL-7068)", () => {
10141014
});
10151015
});
10161016

1017+
describe("SubAgentDirector stall nudge grace", () => {
1018+
test("queued pings inside grace wait; stop only after grace with no activity", async () => {
1019+
let now = 1_000_000;
1020+
const director = new SubAgentDirector(
1021+
"system",
1022+
[],
1023+
undefined,
1024+
1_000,
1025+
() => now,
1026+
);
1027+
const caps = capabilities();
1028+
1029+
await director.decide(inferenceDoneText("working"), state, caps);
1030+
1031+
now += 1_000;
1032+
const first = actions(
1033+
await director.decide(messageReceived(""), state, caps),
1034+
);
1035+
expect(first).toContainEqual({
1036+
type: "checkpoint",
1037+
message: "subagent-stall-nudge",
1038+
});
1039+
1040+
now += 200;
1041+
const midGrace = actions(
1042+
await director.decide(messageReceived(""), state, caps),
1043+
);
1044+
expect(midGrace).toEqual([{ type: "wait" }]);
1045+
1046+
now += 200;
1047+
const stillGrace = actions(
1048+
await director.decide(messageReceived(""), state, caps),
1049+
);
1050+
expect(stillGrace).toEqual([{ type: "wait" }]);
1051+
1052+
now += 600;
1053+
const stopped = actions(
1054+
await director.decide(messageReceived(""), state, caps),
1055+
);
1056+
expect(stopped).toContainEqual({
1057+
type: "checkpoint",
1058+
message: "subagent-stalled",
1059+
});
1060+
expect(stopped.some((action) => action.type === "reply")).toBe(true);
1061+
});
1062+
1063+
test("tool.done during grace clears stallNudgeAt so a later silence nudges again", async () => {
1064+
let now = 2_000_000;
1065+
const director = new SubAgentDirector(
1066+
"system",
1067+
[],
1068+
undefined,
1069+
1_000,
1070+
() => now,
1071+
);
1072+
const caps = capabilities();
1073+
1074+
await director.decide(inferenceDone(["read-1"]), state, caps);
1075+
now += 1_000;
1076+
const first = actions(
1077+
await director.decide(messageReceived(""), state, caps),
1078+
);
1079+
expect(first).toContainEqual({
1080+
type: "checkpoint",
1081+
message: "subagent-stall-nudge",
1082+
});
1083+
1084+
now += 100;
1085+
await director.decide(toolDone("read-1"), state, caps);
1086+
1087+
now += 1_000;
1088+
const afterActivity = actions(
1089+
await director.decide(messageReceived(""), state, caps),
1090+
);
1091+
expect(afterActivity).toContainEqual({
1092+
type: "checkpoint",
1093+
message: "subagent-stall-nudge",
1094+
});
1095+
expect(afterActivity.some((action) => action.type === "reply")).toBe(false);
1096+
});
1097+
});
1098+
10171099
function stubAdmission(
10181100
notes: { provider: string; until: number }[],
10191101
): AdmissionQueue {

‎src/subagent/nudge-director.ts‎

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,11 @@ export class SubAgentDirector extends DefaultDirector {
176176
private readonly stallTimeoutMs: number | undefined;
177177
private readonly now: () => number;
178178
private lastActivityAt: number;
179-
private consecutiveStalls = 0;
179+
// Wall clock when the first stall nudge was issued. Later empty pings inside
180+
// stallTimeoutMs of this instant wait without stopping or restarting grace;
181+
// stop only after the grace elapses with no activity. Cleared on real
182+
// tool.done / turn-boundary activity.
183+
private stallNudgeAt: number | undefined;
180184
private lastAssistantText = "";
181185
// Every stop and nudge is recorded with its measured value beside its
182186
// threshold, so a later threshold change can cite data instead of judgment
@@ -303,7 +307,7 @@ export class SubAgentDirector extends DefaultDirector {
303307
if (onTurnBoundary(event)) {
304308
this.lastConsumedNudgeText = null;
305309
this.lastActivityAt = this.now();
306-
this.consecutiveStalls = 0;
310+
this.stallNudgeAt = undefined;
307311
this.compaction.noteInferenceDone(event, state.turns);
308312
this.turnsCompleted++;
309313
const content = event.turn.content as readonly {
@@ -410,7 +414,7 @@ export class SubAgentDirector extends DefaultDirector {
410414
}
411415
if (event.type === "tool.done") {
412416
this.lastActivityAt = this.now();
413-
this.consecutiveStalls = 0;
417+
this.stallNudgeAt = undefined;
414418
if (event.result.isError === true) {
415419
// Failed-tool recovery guidance. Arm once; coalesce consecutive failure
416420
// audits until applyPendingNudge flushes a single counted record.
@@ -438,12 +442,12 @@ export class SubAgentDirector extends DefaultDirector {
438442
* "no pending harness-tracked work" falls out of when this method can run
439443
* at all rather than needing separate bookkeeping.
440444
*
441-
* First stall past the timeout: one continuation nudge, asking the leaf to
442-
* report status or keep going. A second consecutive stall (no activity
443-
* since the nudge) escalates to the existing salvage path, same shape as
444-
* the turn-boundary checks above. Returns null when this event is not
445-
* a stall check the director should act on (let it fall through as an
446-
* ordinary continuation).
445+
* First silence past the timeout: one continuation nudge, and record
446+
* stallNudgeAt. Queued pings that arrive inside the stallTimeoutMs grace
447+
* after that nudge neither stop nor restart the grace (and do not count as
448+
* activity). Stop only when a ping arrives after the grace with still no
449+
* activity. Returns null when this event is not a stall check the director
450+
* should act on (let it fall through as an ordinary continuation).
447451
*/
448452
private checkStallPing(
449453
event: ReactorInboundEvent,
@@ -456,8 +460,8 @@ export class SubAgentDirector extends DefaultDirector {
456460
const elapsed = this.now() - this.lastActivityAt;
457461
if (elapsed < this.stallTimeoutMs) return null;
458462

459-
this.consecutiveStalls++;
460-
if (this.consecutiveStalls === 1) {
463+
if (this.stallNudgeAt === undefined) {
464+
this.stallNudgeAt = this.now();
461465
this.interventions({
462466
id: "stall-nudge",
463467
class: "nudge",
@@ -473,6 +477,14 @@ export class SubAgentDirector extends DefaultDirector {
473477
inferWithSubAgentNudge(capabilities, SUBAGENT_STALL_NUDGE),
474478
];
475479
}
480+
481+
const sinceNudge = this.now() - this.stallNudgeAt;
482+
if (sinceNudge < this.stallTimeoutMs) {
483+
// Still inside the post-nudge grace. Wait without faking activity or
484+
// restarting the grace clock.
485+
return [capabilities.wait()];
486+
}
487+
476488
this.interventions({
477489
id: "stalled",
478490
class: "stop",

0 commit comments

Comments
 (0)