Skip to content

Commit f981c7c

Browse files
Give successful leaf task completions backstop progress credit (#503)
* Give successful leaf task completions backstop progress credit * Bound leaf-progress backstop resets between operator messages
1 parent 1768d95 commit f981c7c

4 files changed

Lines changed: 285 additions & 2 deletions

File tree

‎docs/ARCHITECTURE.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,8 @@ 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, 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.
152+
151153
#### Sub-agent stall management
152154

153155
`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 leaf (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 delivered while a tool call is still executing simply queues until that cycle finishes, so "no pending harness-tracked work" falls out of when the check can run at all rather than needing separate bookkeeping. The first stall past the timeout gets one continuation nudge (asking the leaf to check on the background work or report status); a second **consecutive** stall (no activity since that nudge) escalates to the existing salvage path, returning a `stalled` `forcedStopReport` with the same structured shape (summary/findings/blockers) as `no-progress` / `turn-budget` / `thrash` / `never-acted` / `never-edited`. Any real activity between pings resets the streak, so a leaf that is genuinely working through a slow single turn is never penalized.

‎src/agent/director.test.ts‎

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,36 @@ function toolDoneEvent(callId: string): ReactorInboundEvent {
8989
} as unknown as ReactorInboundEvent;
9090
}
9191

92+
// A parent turn dispatching a leaf `task` call — varied arguments per id so
93+
// the fingerprint changes turn to turn (mirrors toolOnlyTurn's shape, but
94+
// with the tool name pendingTaskCallIds actually tracks).
95+
function taskTurn(id: string): ReactorInboundEvent {
96+
return {
97+
type: "inference.done",
98+
turn: {
99+
role: "assistant",
100+
model: "test",
101+
timestamp: 0,
102+
content: [{ type: "tool_call", id, name: "task", arguments: { prompt: `do ${id}` } }],
103+
},
104+
usage: { input: 0, output: 0 },
105+
source: "test",
106+
} as unknown as ReactorInboundEvent;
107+
}
108+
109+
// A task tool.done result. Defaults to a plain successful completion (no
110+
// tool error, no salvage-classifiable envelope in the body) — CL-5893's
111+
// "successful leaf tool.done" progress signal.
112+
function taskDoneEvent(
113+
callId: string,
114+
options: { isError?: boolean; content?: string } = {},
115+
): ReactorInboundEvent {
116+
return {
117+
type: "tool.done",
118+
result: { callId, isError: options.isError ?? false, content: options.content ?? "ok" },
119+
} as unknown as ReactorInboundEvent;
120+
}
121+
92122
// A genuine operator submit — carries OPERATOR_ORIGINATED_FLAG, matching what
93123
// userInboundMessage() builds at the real TUI/exec prompt-submit sites.
94124
function messageReceived(content = "hello"): ReactorInboundEvent {
@@ -785,4 +815,194 @@ describe("ChatDirector tool-only loop protection", () => {
785815
);
786816
expect(later.some((a) => a.type === "infer")).toBe(true);
787817
});
818+
819+
// CL-5893: the primary is productively blocked on a long stream of task
820+
// dispatches — each successful leaf completion is progress the operator
821+
// will see, so it must re-arm the backstop interval regardless of how many
822+
// parent turns (tool.done -> infer cycles) that takes in total.
823+
describe("CL-5893: successful leaf task completions re-arm the backstop", () => {
824+
test("a back-to-back streak of successful task completions is bounded — the cap exhausts and the nudge/pause escalation eventually fires", async () => {
825+
const director = createChatDirector("system", [], {
826+
onTasksChange: () => {},
827+
provider: providerlessPolicy,
828+
});
829+
const capabilities = makeCapabilities();
830+
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++) {
841+
const id = `task-ok-${i}`;
842+
await director.decide(taskTurn(id), mockState, capabilities);
843+
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));
889+
if (
890+
result.some((a) => a.type === "reply" && a.content.includes("Auto-paused")) ||
891+
result.some((a) => a.type === "infer" && ephemeralText(a)?.includes("progress summary"))
892+
) {
893+
sawPauseOrNudge = true;
894+
}
895+
}
896+
expect(sawPauseOrNudge).toBe(false);
897+
});
898+
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+
928+
test("periodic successful task completions amid other tool-only turns keep resetting the backstop", async () => {
929+
const director = createChatDirector("system", [], {
930+
onTasksChange: () => {},
931+
provider: providerlessPolicy,
932+
});
933+
const capabilities = makeCapabilities();
934+
935+
let sawPauseOrNudge = false;
936+
for (let round = 0; round < 5; round++) {
937+
// 80 varied tool-only turns per round — below the 100 threshold on
938+
// their own, and would accumulate past it across rounds without a
939+
// reset.
940+
const actions = await runToolOnlyStreak(director, capabilities, 80);
941+
if (
942+
actions.some((a) => a.type === "reply" && a.content.includes("Auto-paused")) ||
943+
actions.some((a) => a.type === "infer" && ephemeralText(a)?.includes("progress summary"))
944+
) {
945+
sawPauseOrNudge = true;
946+
}
947+
// A successful task completion lands at the end of the round and
948+
// must reset the interval before the next round starts.
949+
const id = `task-round-${round}`;
950+
await director.decide(taskTurn(id), mockState, capabilities);
951+
await director.decide(taskDoneEvent(id), mockState, capabilities);
952+
}
953+
expect(sawPauseOrNudge).toBe(false);
954+
});
955+
956+
test("failed task completions get no progress credit — the backstop still nudges then pauses", async () => {
957+
const director = createChatDirector("system", [], {
958+
onTasksChange: () => {},
959+
provider: providerlessPolicy,
960+
});
961+
const capabilities = makeCapabilities();
962+
963+
let nudged = false;
964+
let paused = false;
965+
for (let i = 0; i < 200 && !paused; i++) {
966+
const id = `task-fail-${i}`;
967+
await director.decide(taskTurn(id), mockState, capabilities);
968+
const result = actionsArray(
969+
await director.decide(
970+
taskDoneEvent(id, { isError: true, content: "boom" }),
971+
mockState,
972+
capabilities,
973+
),
974+
);
975+
if (result.some((a) => a.type === "reply" && a.content.includes("Auto-paused"))) {
976+
paused = true;
977+
} else if (result.some((a) => a.type === "infer" && ephemeralText(a)?.includes("progress summary"))) {
978+
nudged = true;
979+
}
980+
}
981+
expect(nudged).toBe(true);
982+
expect(paused).toBe(true);
983+
});
984+
985+
test("a task completion without a tool error but carrying a salvage envelope is not counted as progress", async () => {
986+
const director = createChatDirector("system", [], {
987+
onTasksChange: () => {},
988+
provider: providerlessPolicy,
989+
});
990+
const capabilities = makeCapabilities();
991+
992+
let paused = false;
993+
for (let i = 0; i < 200 && !paused; i++) {
994+
const id = `task-salvage-${i}`;
995+
await director.decide(taskTurn(id), mockState, capabilities);
996+
const result = actionsArray(
997+
await director.decide(
998+
taskDoneEvent(id, { content: forcedStopReport("no-progress", "x") }),
999+
mockState,
1000+
capabilities,
1001+
),
1002+
);
1003+
if (result.some((a) => a.type === "reply" && a.content.includes("Auto-paused"))) paused = true;
1004+
}
1005+
expect(paused).toBe(true);
1006+
});
1007+
});
7881008
});

‎src/agent/director.ts‎

Lines changed: 50 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";
@@ -413,18 +414,32 @@ class ChatDirectorImpl extends DefaultDirector {
413414
// synthetic system sends (compaction continuations, retries, future
414415
// director continuations) fire that event too without being operator
415416
// input (round-5 fix; see message-provenance.ts for the flag's invariant).
417+
// CL-5893: also reset (without being treated as an operator message) by a
418+
// successful leaf task tool.done — see the pendingTaskCallIds handling
419+
// below — so a parent productively blocked on long-running task calls does
420+
// not hard-pause purely from turn volume; a true no-progress tool-only
421+
// loop with no successful completions is unaffected.
416422
// Increments on every turn boundary, tool-only or narrated alike.
417423
private turnsSinceUserMessage = 0;
418424
// Set to the turnsSinceUserMessage value at which the backstop nudge fired,
419425
// so the escalation check can require a full further backstop interval to
420426
// elapse (still with no user message and no period-detected thrash) before
421-
// hard-pausing. Reset to null only on an operator-originated message; it
427+
// hard-pausing. Reset to null on an operator-originated message or a
428+
// successful leaf task completion (CL-5893); it
422429
// is NOT reset when thrash detection or the escalation pause fires —
423430
// pausedForToolOnly and toolOnlyPauseReason are recomputed fresh every
424431
// turn instead, so a stale non-null value here is harmless once a pause
425432
// is in effect (the next operator message clears both together).
426433
private backstopNudgeFiredAtTurn: number | null = null;
427434
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;
428443
// Which mechanism triggered pausedForToolOnly — the period-detection fast
429444
// path (a recognized cycle) or the backstop escalation (nudge went
430445
// unheeded for a further full interval with no user message). Drives the
@@ -673,6 +688,7 @@ class ChatDirectorImpl extends DefaultDirector {
673688
this.turnsSinceUserMessage = 0;
674689
this.backstopNudgeFiredAtTurn = null;
675690
this.pendingBackstopNudge = false;
691+
this.leafProgressBackstopResets = 0;
676692
this.salvageNudgeFired = false;
677693
this.pendingSalvageNudge = null;
678694
this.pendingTaskCallIds.clear();
@@ -851,6 +867,39 @@ class ChatDirectorImpl extends DefaultDirector {
851867
this.salvageNudgeFired = true;
852868
this.pendingSalvageNudge = PRIMARY_SALVAGE_NUDGE;
853869
}
870+
// CL-5893: a parent productively blocked on long-running task calls
871+
// racks up turnsSinceUserMessage one tool.done->infer cycle at a time
872+
// per leaf, and could hard-pause on fleet-heavy work despite never
873+
// actually stalling. A successful leaf completion — no tool error, and
874+
// no salvage class at all (not even a soft one like turn-budget or
875+
// deadline) — is real progress the operator will see reflected in the
876+
// transcript, so it re-arms the backstop interval exactly like a fresh
877+
// operator message would, without being treated as one: it does not
878+
// touch toolOnlyStreak/toolFingerprintHistory (those track cycling,
879+
// which a completed task says nothing about) or salvageNudgeFired.
880+
// True no-progress (tool-only churn with no successful leaf completions)
881+
// still nudges then pauses exactly as before.
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+
) {
898+
this.turnsSinceUserMessage = 0;
899+
this.backstopNudgeFiredAtTurn = null;
900+
this.pendingBackstopNudge = false;
901+
this.leafProgressBackstopResets++;
902+
}
854903
}
855904

856905
if (event.type === "tool.done" && this.workflowCalls.has(event.result.callId)) {

0 commit comments

Comments
 (0)