Skip to content

Commit 3841ff0

Browse files
committed
Bound leaf-progress backstop resets between operator messages
1 parent 8b0847b commit 3841ff0

4 files changed

Lines changed: 126 additions & 6 deletions

File tree

docs/ARCHITECTURE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ Round 5 fixes the reset condition's shape instead of patching another instance:
148148

149149
Because the operator explicitly wants long autonomous runs to keep going, reaching the backstop threshold (`TURNS_SINCE_USER_MESSAGE_BACKSTOP`, 100) does not pause on its own — it fires a one-shot nudge asking the model for a progress summary, the same ephemeral-turn rewrite mechanism as the check-in nudge. Only if that nudge goes unheeded — `turnsSinceUserMessage` advances a further full `TURNS_SINCE_USER_MESSAGE_BACKSTOP` turns with still no user message and no thrash detected — does the director hard-pause, with a distinct message ("Auto-paused: went N turns without a message from the operator, and a progress-summary nudge went unanswered for a further N turns...") tagged `toolOnlyPauseReason: "backstop"` to distinguish it from a thrash pause in logs and messages. A genuine cycle (thrash) still preempts this escalation at any point and pauses immediately, since that is a fast, unambiguous no-progress signal on its own.
150150

151-
**Fleet-heavy work does not falsely trip this (CL-5893).** A primary that is productively blocked on many concurrent/sequential `task` dispatches racks up `turnsSinceUserMessage` at one tool.done→infer cycle per leaf, with no operator message in between — a successful leaf completion (`tool.done` for a `task` call, not a tool error, and no salvage-classifiable envelope in the report body) re-arms the interval exactly like a fresh operator message would (resetting `turnsSinceUserMessage` and clearing any pending backstop nudge) without being treated as one, so a productive multi-dispatch streak never hard-pauses no matter how many parent turns elapse. A failed or salvaged leaf completion earns no such credit, so true no-progress tool-only churn still nudges then pauses as above.
151+
**Fleet-heavy work does not falsely trip this (CL-5893).** A primary that is productively blocked on many concurrent/sequential `task` dispatches racks up `turnsSinceUserMessage` at one tool.done→infer cycle per leaf, with no operator message in between — a successful leaf completion (`tool.done` for a `task` call, not a tool error, a string result body, and no salvage-classifiable envelope in the report) re-arms the interval exactly like a fresh operator message would (resetting `turnsSinceUserMessage` and clearing any pending backstop nudge) without being treated as one, so a productive multi-dispatch streak gets meaningfully more room before the backstop can fire. A failed or salvaged leaf completion earns no such credit, so true no-progress tool-only churn still nudges then pauses as above. This reset is bounded, not unlimited: it is capped at `MAX_LEAF_PROGRESS_BACKSTOP_RESETS` (5) leaf-credited resets between genuine operator messages, so an unbroken run of trivial always-succeeding leaf tasks still exhausts the cap and lets the ordinary nudge/pause escalation force an operator checkpoint.
152152

153153
#### Sub-agent stall management
154154

src/agent/director.test.ts

Lines changed: 85 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -821,18 +821,71 @@ describe("ChatDirector tool-only loop protection", () => {
821821
// will see, so it must re-arm the backstop interval regardless of how many
822822
// parent turns (tool.done -> infer cycles) that takes in total.
823823
describe("CL-5893: successful leaf task completions re-arm the backstop", () => {
824-
test("a productive streak of successful task completions never hard-pauses, however many parent turns elapse", async () => {
824+
test("a back-to-back streak of successful task completions is bounded — the cap exhausts and the nudge/pause escalation eventually fires", async () => {
825825
const director = createChatDirector("system", [], {
826826
onTasksChange: () => {},
827827
provider: providerlessPolicy,
828828
});
829829
const capabilities = makeCapabilities();
830830

831-
let sawPauseOrNudge = false;
832-
for (let i = 0; i < 300; i++) {
831+
// Every success here lands one turn after the last reset, so the
832+
// MAX_LEAF_PROGRESS_BACKSTOP_RESETS credits are consumed almost
833+
// immediately (the worst case for the bound — a genuinely spaced-out
834+
// fleet gets far more turns before exhausting the same cap). Once
835+
// exhausted, successes stop resetting the interval and the ordinary
836+
// nudge (at the 100-turn threshold) then pause (a further 100 turns
837+
// unheeded) fire on schedule.
838+
let nudgedAt: number | null = null;
839+
let pausedAt: number | null = null;
840+
for (let i = 0; i < 300 && pausedAt === null; i++) {
833841
const id = `task-ok-${i}`;
834842
await director.decide(taskTurn(id), mockState, capabilities);
835843
const result = actionsArray(await director.decide(taskDoneEvent(id), mockState, capabilities));
844+
if (result.some((a) => a.type === "reply" && a.content.includes("Auto-paused"))) {
845+
pausedAt = i;
846+
} else if (
847+
nudgedAt === null &&
848+
result.some((a) => a.type === "infer" && ephemeralText(a)?.includes("progress summary"))
849+
) {
850+
nudgedAt = i;
851+
}
852+
}
853+
854+
expect(nudgedAt).not.toBeNull();
855+
expect(pausedAt).not.toBeNull();
856+
// A runaway trivial-success loop still pauses — it just gets the cap's
857+
// worth of extra headroom first, well past the plain 100-turn
858+
// threshold, before the escalation is forced.
859+
expect(pausedAt as number).toBeGreaterThan(150);
860+
});
861+
862+
test("an operator message re-arms the full leaf-progress cap", async () => {
863+
const director = createChatDirector("system", [], {
864+
onTasksChange: () => {},
865+
provider: providerlessPolicy,
866+
});
867+
const capabilities = makeCapabilities();
868+
869+
// Exhaust the cap with MAX_LEAF_PROGRESS_BACKSTOP_RESETS (5) successes.
870+
for (let i = 0; i < 5; i++) {
871+
const id = `task-ok-a-${i}`;
872+
await director.decide(taskTurn(id), mockState, capabilities);
873+
await director.decide(taskDoneEvent(id), mockState, capabilities);
874+
}
875+
876+
await director.decide(messageReceived(), mockState, capabilities);
877+
878+
// If the cap were not re-armed by the operator message, all 100 of
879+
// these would get zero credit and turnsSinceUserMessage would climb
880+
// straight to the 100-turn nudge threshold by the last iteration. With
881+
// the cap re-armed, the first 5 are credited again (holding the
882+
// interval near zero) and the remaining 95 only climb to 95 — no
883+
// nudge or pause.
884+
let sawPauseOrNudge = false;
885+
for (let i = 0; i < 100; i++) {
886+
const id = `task-ok-b-${i}`;
887+
await director.decide(taskTurn(id), mockState, capabilities);
888+
const result = actionsArray(await director.decide(taskDoneEvent(id), mockState, capabilities));
836889
if (
837890
result.some((a) => a.type === "reply" && a.content.includes("Auto-paused")) ||
838891
result.some((a) => a.type === "infer" && ephemeralText(a)?.includes("progress summary"))
@@ -843,6 +896,35 @@ describe("ChatDirector tool-only loop protection", () => {
843896
expect(sawPauseOrNudge).toBe(false);
844897
});
845898

899+
test("non-string tool result content gets no backstop credit — the backstop still nudges then pauses", async () => {
900+
const director = createChatDirector("system", [], {
901+
onTasksChange: () => {},
902+
provider: providerlessPolicy,
903+
});
904+
const capabilities = makeCapabilities();
905+
906+
let nudged = false;
907+
let paused = false;
908+
for (let i = 0; i < 200 && !paused; i++) {
909+
const id = `task-nonstring-${i}`;
910+
await director.decide(taskTurn(id), mockState, capabilities);
911+
const result = actionsArray(
912+
await director.decide(
913+
{ type: "tool.done", result: { callId: id, isError: false, content: undefined } } as unknown as ReactorInboundEvent,
914+
mockState,
915+
capabilities,
916+
),
917+
);
918+
if (result.some((a) => a.type === "reply" && a.content.includes("Auto-paused"))) {
919+
paused = true;
920+
} else if (result.some((a) => a.type === "infer" && ephemeralText(a)?.includes("progress summary"))) {
921+
nudged = true;
922+
}
923+
}
924+
expect(nudged).toBe(true);
925+
expect(paused).toBe(true);
926+
});
927+
846928
test("periodic successful task completions amid other tool-only turns keep resetting the backstop", async () => {
847929
const director = createChatDirector("system", [], {
848930
onTasksChange: () => {},

src/agent/director.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
detectToolFingerprintThrash,
2828
detectTurnsSinceUserMessageBackstop,
2929
TURNS_SINCE_USER_MESSAGE_BACKSTOP,
30+
MAX_LEAF_PROGRESS_BACKSTOP_RESETS,
3031
TOOL_FINGERPRINT_HISTORY_CAP,
3132
type ToolFingerprintThrashCheck,
3233
} from "../subagent/stop-policy.js";
@@ -431,6 +432,14 @@ class ChatDirectorImpl extends DefaultDirector {
431432
// is in effect (the next operator message clears both together).
432433
private backstopNudgeFiredAtTurn: number | null = null;
433434
private pendingBackstopNudge = false;
435+
// CL-5893: how many times a successful leaf task completion has re-armed
436+
// the backstop since the last genuine operator message. Capped at
437+
// MAX_LEAF_PROGRESS_BACKSTOP_RESETS so an unbroken run of trivial
438+
// always-succeeding leaf tasks cannot reset the backstop forever — once
439+
// exhausted, leaf successes stop resetting the interval and the ordinary
440+
// nudge/pause escalation proceeds. Reset to 0 only alongside the other
441+
// operator-message resets below, never by the leaf-success path itself.
442+
private leafProgressBackstopResets = 0;
434443
// Which mechanism triggered pausedForToolOnly — the period-detection fast
435444
// path (a recognized cycle) or the backstop escalation (nudge went
436445
// unheeded for a further full interval with no user message). Drives the
@@ -679,6 +688,7 @@ class ChatDirectorImpl extends DefaultDirector {
679688
this.turnsSinceUserMessage = 0;
680689
this.backstopNudgeFiredAtTurn = null;
681690
this.pendingBackstopNudge = false;
691+
this.leafProgressBackstopResets = 0;
682692
this.salvageNudgeFired = false;
683693
this.pendingSalvageNudge = null;
684694
this.pendingTaskCallIds.clear();
@@ -869,10 +879,26 @@ class ChatDirectorImpl extends DefaultDirector {
869879
// which a completed task says nothing about) or salvageNudgeFired.
870880
// True no-progress (tool-only churn with no successful leaf completions)
871881
// still nudges then pauses exactly as before.
872-
if (!event.result.isError && salvage === null) {
882+
//
883+
// Bounded (round 2): this reset is capped at
884+
// MAX_LEAF_PROGRESS_BACKSTOP_RESETS per operator message so an
885+
// unbroken loop of trivial always-succeeding leaf tasks cannot reset
886+
// the backstop forever — once the cap is exhausted, leaf successes
887+
// stop resetting the interval and the nudge/pause escalation
888+
// eventually forces an operator checkpoint. Credit also requires the
889+
// tool result content to actually be a string: non-string content is
890+
// coerced to "" above only for salvage classification (an empty body
891+
// classifies as success), which must not also buy backstop credit.
892+
if (
893+
!event.result.isError &&
894+
salvage === null &&
895+
typeof event.result.content === "string" &&
896+
this.leafProgressBackstopResets < MAX_LEAF_PROGRESS_BACKSTOP_RESETS
897+
) {
873898
this.turnsSinceUserMessage = 0;
874899
this.backstopNudgeFiredAtTurn = null;
875900
this.pendingBackstopNudge = false;
901+
this.leafProgressBackstopResets++;
876902
}
877903
}
878904

src/subagent/stop-policy.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,9 @@ export function detectToolFingerprintThrash(
217217
// narration-sensitive counter. Model-emitted text does not reset this
218218
// counter; only a genuine user/operator message does (see director.ts). That
219219
// is deliberate: this answers "how long since the operator last saw a real
220-
// checkpoint," not "is the model narrating."
220+
// checkpoint," not "is the model narrating." (CL-5893: a successful leaf
221+
// task completion also resets it, bounded by MAX_LEAF_PROGRESS_BACKSTOP_RESETS
222+
// below — see director.ts.)
221223
//
222224
// Because narration no longer resets it, reaching this threshold does not
223225
// hard-pause on its own — it only fires a nudge asking for a progress
@@ -245,6 +247,16 @@ export function detectToolFingerprintThrash(
245247
// ever actually measured.
246248
export const TURNS_SINCE_USER_MESSAGE_BACKSTOP = 100;
247249

250+
// CL-5893: cap on how many times a successful leaf task completion may
251+
// re-arm the backstop interval before a genuine operator message is
252+
// required. Without a cap, a loop of trivial always-succeeding leaf tasks
253+
// would reset the backstop forever and never force an operator checkpoint.
254+
// At 5 resets (~500 turns of headroom before this bound, vs. the plain
255+
// 100-turn threshold) a runaway trivial-success loop still nudges then
256+
// pauses, while genuine fleet-heavy work gets meaningfully more room than
257+
// the unbounded reset before this cap existed.
258+
export const MAX_LEAF_PROGRESS_BACKSTOP_RESETS = 5;
259+
248260
/** True once turns-since-last-user-message reaches the backstop threshold. */
249261
export function detectTurnsSinceUserMessageBackstop(turnsSinceUserMessage: number): boolean {
250262
return turnsSinceUserMessage >= TURNS_SINCE_USER_MESSAGE_BACKSTOP;

0 commit comments

Comments
 (0)