Skip to content

Commit 56c3a42

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 16a5570 commit 56c3a42

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
@@ -880,6 +880,88 @@ describe("SubAgentDirector post-complete terminalization (CL-7068)", () => {
880880
});
881881
});
882882

883+
describe("SubAgentDirector stall nudge grace", () => {
884+
test("queued pings inside grace wait; stop only after grace with no activity", async () => {
885+
let now = 1_000_000;
886+
const director = new SubAgentDirector(
887+
"system",
888+
[],
889+
undefined,
890+
1_000,
891+
() => now,
892+
);
893+
const caps = capabilities();
894+
895+
await director.decide(inferenceDoneText("working"), state, caps);
896+
897+
now += 1_000;
898+
const first = actions(
899+
await director.decide(messageReceived(""), state, caps),
900+
);
901+
expect(first).toContainEqual({
902+
type: "checkpoint",
903+
message: "subagent-stall-nudge",
904+
});
905+
906+
now += 200;
907+
const midGrace = actions(
908+
await director.decide(messageReceived(""), state, caps),
909+
);
910+
expect(midGrace).toEqual([{ type: "wait" }]);
911+
912+
now += 200;
913+
const stillGrace = actions(
914+
await director.decide(messageReceived(""), state, caps),
915+
);
916+
expect(stillGrace).toEqual([{ type: "wait" }]);
917+
918+
now += 600;
919+
const stopped = actions(
920+
await director.decide(messageReceived(""), state, caps),
921+
);
922+
expect(stopped).toContainEqual({
923+
type: "checkpoint",
924+
message: "subagent-stalled",
925+
});
926+
expect(stopped.some((action) => action.type === "reply")).toBe(true);
927+
});
928+
929+
test("tool.done during grace clears stallNudgeAt so a later silence nudges again", async () => {
930+
let now = 2_000_000;
931+
const director = new SubAgentDirector(
932+
"system",
933+
[],
934+
undefined,
935+
1_000,
936+
() => now,
937+
);
938+
const caps = capabilities();
939+
940+
await director.decide(inferenceDone(["read-1"]), state, caps);
941+
now += 1_000;
942+
const first = actions(
943+
await director.decide(messageReceived(""), state, caps),
944+
);
945+
expect(first).toContainEqual({
946+
type: "checkpoint",
947+
message: "subagent-stall-nudge",
948+
});
949+
950+
now += 100;
951+
await director.decide(toolDone("read-1"), state, caps);
952+
953+
now += 1_000;
954+
const afterActivity = actions(
955+
await director.decide(messageReceived(""), state, caps),
956+
);
957+
expect(afterActivity).toContainEqual({
958+
type: "checkpoint",
959+
message: "subagent-stall-nudge",
960+
});
961+
expect(afterActivity.some((action) => action.type === "reply")).toBe(false);
962+
});
963+
});
964+
883965
function stubAdmission(
884966
notes: { provider: string; until: number }[],
885967
): 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 {
@@ -401,7 +405,7 @@ export class SubAgentDirector extends DefaultDirector {
401405
}
402406
if (event.type === "tool.done") {
403407
this.lastActivityAt = this.now();
404-
this.consecutiveStalls = 0;
408+
this.stallNudgeAt = undefined;
405409
if (event.result.isError === true) {
406410
// Failed-tool recovery guidance. Arm once; coalesce consecutive failure
407411
// audits until applyPendingNudge flushes a single counted record.
@@ -429,12 +433,12 @@ export class SubAgentDirector extends DefaultDirector {
429433
* "no pending harness-tracked work" falls out of when this method can run
430434
* at all rather than needing separate bookkeeping.
431435
*
432-
* First stall past the timeout: one continuation nudge, asking the leaf to
433-
* report status or keep going. A second consecutive stall (no activity
434-
* since the nudge) escalates to the existing salvage path, same shape as
435-
* the turn-boundary checks above. Returns null when this event is not
436-
* a stall check the director should act on (let it fall through as an
437-
* ordinary continuation).
436+
* First silence past the timeout: one continuation nudge, and record
437+
* stallNudgeAt. Queued pings that arrive inside the stallTimeoutMs grace
438+
* after that nudge neither stop nor restart the grace (and do not count as
439+
* activity). Stop only when a ping arrives after the grace with still no
440+
* activity. Returns null when this event is not a stall check the director
441+
* should act on (let it fall through as an ordinary continuation).
438442
*/
439443
private checkStallPing(
440444
event: ReactorInboundEvent,
@@ -447,8 +451,8 @@ export class SubAgentDirector extends DefaultDirector {
447451
const elapsed = this.now() - this.lastActivityAt;
448452
if (elapsed < this.stallTimeoutMs) return null;
449453

450-
this.consecutiveStalls++;
451-
if (this.consecutiveStalls === 1) {
454+
if (this.stallNudgeAt === undefined) {
455+
this.stallNudgeAt = this.now();
452456
this.interventions({
453457
id: "stall-nudge",
454458
class: "nudge",
@@ -464,6 +468,14 @@ export class SubAgentDirector extends DefaultDirector {
464468
inferWithSubAgentNudge(capabilities, SUBAGENT_STALL_NUDGE),
465469
];
466470
}
471+
472+
const sinceNudge = this.now() - this.stallNudgeAt;
473+
if (sinceNudge < this.stallTimeoutMs) {
474+
// Still inside the post-nudge grace. Wait without faking activity or
475+
// restarting the grace clock.
476+
return [capabilities.wait()];
477+
}
478+
467479
this.interventions({
468480
id: "stalled",
469481
class: "stop",

0 commit comments

Comments
 (0)