Skip to content

Commit 08e485b

Browse files
committed
Give successful leaf task completions backstop progress credit
1 parent 35a8141 commit 08e485b

3 files changed

Lines changed: 164 additions & 1 deletion

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, 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.
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: 138 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,112 @@ 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 productive streak of successful task completions never hard-pauses, however many parent turns elapse", async () => {
825+
const director = createChatDirector("system", [], {
826+
onTasksChange: () => {},
827+
provider: providerlessPolicy,
828+
});
829+
const capabilities = makeCapabilities();
830+
831+
let sawPauseOrNudge = false;
832+
for (let i = 0; i < 300; i++) {
833+
const id = `task-ok-${i}`;
834+
await director.decide(taskTurn(id), mockState, capabilities);
835+
const result = actionsArray(await director.decide(taskDoneEvent(id), mockState, capabilities));
836+
if (
837+
result.some((a) => a.type === "reply" && a.content.includes("Auto-paused")) ||
838+
result.some((a) => a.type === "infer" && ephemeralText(a)?.includes("progress summary"))
839+
) {
840+
sawPauseOrNudge = true;
841+
}
842+
}
843+
expect(sawPauseOrNudge).toBe(false);
844+
});
845+
846+
test("periodic successful task completions amid other tool-only turns keep resetting the backstop", async () => {
847+
const director = createChatDirector("system", [], {
848+
onTasksChange: () => {},
849+
provider: providerlessPolicy,
850+
});
851+
const capabilities = makeCapabilities();
852+
853+
let sawPauseOrNudge = false;
854+
for (let round = 0; round < 5; round++) {
855+
// 80 varied tool-only turns per round — below the 100 threshold on
856+
// their own, and would accumulate past it across rounds without a
857+
// reset.
858+
const actions = await runToolOnlyStreak(director, capabilities, 80);
859+
if (
860+
actions.some((a) => a.type === "reply" && a.content.includes("Auto-paused")) ||
861+
actions.some((a) => a.type === "infer" && ephemeralText(a)?.includes("progress summary"))
862+
) {
863+
sawPauseOrNudge = true;
864+
}
865+
// A successful task completion lands at the end of the round and
866+
// must reset the interval before the next round starts.
867+
const id = `task-round-${round}`;
868+
await director.decide(taskTurn(id), mockState, capabilities);
869+
await director.decide(taskDoneEvent(id), mockState, capabilities);
870+
}
871+
expect(sawPauseOrNudge).toBe(false);
872+
});
873+
874+
test("failed task completions get no progress credit — the backstop still nudges then pauses", async () => {
875+
const director = createChatDirector("system", [], {
876+
onTasksChange: () => {},
877+
provider: providerlessPolicy,
878+
});
879+
const capabilities = makeCapabilities();
880+
881+
let nudged = false;
882+
let paused = false;
883+
for (let i = 0; i < 200 && !paused; i++) {
884+
const id = `task-fail-${i}`;
885+
await director.decide(taskTurn(id), mockState, capabilities);
886+
const result = actionsArray(
887+
await director.decide(
888+
taskDoneEvent(id, { isError: true, content: "boom" }),
889+
mockState,
890+
capabilities,
891+
),
892+
);
893+
if (result.some((a) => a.type === "reply" && a.content.includes("Auto-paused"))) {
894+
paused = true;
895+
} else if (result.some((a) => a.type === "infer" && ephemeralText(a)?.includes("progress summary"))) {
896+
nudged = true;
897+
}
898+
}
899+
expect(nudged).toBe(true);
900+
expect(paused).toBe(true);
901+
});
902+
903+
test("a task completion without a tool error but carrying a salvage envelope is not counted as progress", async () => {
904+
const director = createChatDirector("system", [], {
905+
onTasksChange: () => {},
906+
provider: providerlessPolicy,
907+
});
908+
const capabilities = makeCapabilities();
909+
910+
let paused = false;
911+
for (let i = 0; i < 200 && !paused; i++) {
912+
const id = `task-salvage-${i}`;
913+
await director.decide(taskTurn(id), mockState, capabilities);
914+
const result = actionsArray(
915+
await director.decide(
916+
taskDoneEvent(id, { content: forcedStopReport("no-progress", "x") }),
917+
mockState,
918+
capabilities,
919+
),
920+
);
921+
if (result.some((a) => a.type === "reply" && a.content.includes("Auto-paused"))) paused = true;
922+
}
923+
expect(paused).toBe(true);
924+
});
925+
});
788926
});

src/agent/director.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -413,12 +413,18 @@ class ChatDirectorImpl extends DefaultDirector {
413413
// synthetic system sends (compaction continuations, retries, future
414414
// director continuations) fire that event too without being operator
415415
// input (round-5 fix; see message-provenance.ts for the flag's invariant).
416+
// CL-5893: also reset (without being treated as an operator message) by a
417+
// successful leaf task tool.done — see the pendingTaskCallIds handling
418+
// below — so a parent productively blocked on long-running task calls does
419+
// not hard-pause purely from turn volume; a true no-progress tool-only
420+
// loop with no successful completions is unaffected.
416421
// Increments on every turn boundary, tool-only or narrated alike.
417422
private turnsSinceUserMessage = 0;
418423
// Set to the turnsSinceUserMessage value at which the backstop nudge fired,
419424
// so the escalation check can require a full further backstop interval to
420425
// 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
426+
// hard-pausing. Reset to null on an operator-originated message or a
427+
// successful leaf task completion (CL-5893); it
422428
// is NOT reset when thrash detection or the escalation pause fires —
423429
// pausedForToolOnly and toolOnlyPauseReason are recomputed fresh every
424430
// turn instead, so a stale non-null value here is harmless once a pause
@@ -851,6 +857,23 @@ class ChatDirectorImpl extends DefaultDirector {
851857
this.salvageNudgeFired = true;
852858
this.pendingSalvageNudge = PRIMARY_SALVAGE_NUDGE;
853859
}
860+
// CL-5893: a parent productively blocked on long-running task calls
861+
// racks up turnsSinceUserMessage one tool.done->infer cycle at a time
862+
// per leaf, and could hard-pause on fleet-heavy work despite never
863+
// actually stalling. A successful leaf completion — no tool error, and
864+
// no salvage class at all (not even a soft one like turn-budget or
865+
// deadline) — is real progress the operator will see reflected in the
866+
// transcript, so it re-arms the backstop interval exactly like a fresh
867+
// operator message would, without being treated as one: it does not
868+
// touch toolOnlyStreak/toolFingerprintHistory (those track cycling,
869+
// which a completed task says nothing about) or salvageNudgeFired.
870+
// True no-progress (tool-only churn with no successful leaf completions)
871+
// still nudges then pauses exactly as before.
872+
if (!event.result.isError && salvage === null) {
873+
this.turnsSinceUserMessage = 0;
874+
this.backstopNudgeFiredAtTurn = null;
875+
this.pendingBackstopNudge = false;
876+
}
854877
}
855878

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

0 commit comments

Comments
 (0)