Skip to content

Commit ec1f51b

Browse files
committed
Surface child termination reasons to the parent report and TUI
Forced stops (repetition guard, stall abort, salvage caps, operator cancel) now attach a machine-readable reason to the child's terminal state. The report envelope gains a dedicated Stopped: line (e.g. 'Stopped: repetition — window "Groaning. " × 1363'), the session store records it as stopReason, and the fleet transcript row announces 'lane stopped — <reason>' instead of a bare done/cancelled.
1 parent bebe563 commit ec1f51b

11 files changed

Lines changed: 263 additions & 26 deletions

src/subagent/fleet-report.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,3 +145,37 @@ describe("fleetDigest", () => {
145145
expect(fleetDigest([], T0)).toBe("nothing running");
146146
});
147147
});
148+
149+
describe("forced-stop reasons", () => {
150+
test("a lane finished by a forced stop announces the reason, not a bare done", () => {
151+
const seeded = observeFleet(createFleetWatch(), [lane({ id: "api" }), lane({ id: "docs" })], T0)
152+
.watch;
153+
const { updates } = observeFleet(
154+
seeded,
155+
[
156+
lane({
157+
id: "api",
158+
status: "done",
159+
stopReason: 'repetition — window "Groaning. " × 1363',
160+
}),
161+
lane({ id: "docs" }),
162+
],
163+
T0 + 1000,
164+
);
165+
expect(updates).toEqual(['api stopped — repetition — window "Groaning. " × 1363']);
166+
});
167+
168+
test("a cancelled lane carries its recorded reason", () => {
169+
const seeded = observeFleet(createFleetWatch(), [lane({ id: "api" }), lane({ id: "docs" })], T0)
170+
.watch;
171+
const { updates } = observeFleet(
172+
seeded,
173+
[
174+
lane({ id: "api", status: "cancelled", stopReason: "cancelled — Session closed" }),
175+
lane({ id: "docs" }),
176+
],
177+
T0 + 1000,
178+
);
179+
expect(updates).toEqual(["api stopped — cancelled — Session closed"]);
180+
});
181+
});

src/subagent/fleet-report.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ export type FleetLane = {
2727
readonly currentToolStartedAt: number | null;
2828
readonly report?: string;
2929
readonly error?: string;
30+
/** Machine-readable forced-stop reason (see SubAgentSession.stopReason). */
31+
readonly stopReason?: string;
3032
};
3133

3234
type LaneMark = {
@@ -147,14 +149,26 @@ export function observeFleet(
147149

148150
if (before.status !== lane.status) {
149151
if (lane.status === "done") {
150-
changes.push({ kind: "done", line: `${lane.description} done` });
152+
// A forced stop (repetition / stall / salvage caps) lands as "done"
153+
// with a stopReason — that is attention, not a success line.
154+
if (lane.stopReason !== undefined) {
155+
changes.push({
156+
kind: "failed",
157+
line: `${lane.description} stopped — ${clip(lane.stopReason, OUTCOME_CHARS)}`,
158+
});
159+
} else {
160+
changes.push({ kind: "done", line: `${lane.description} done` });
161+
}
151162
} else if (lane.status === "failed") {
152163
changes.push({
153164
kind: "failed",
154165
line: `${lane.description} failed — ${clip(firstLine(lane.error) || "no error reported", OUTCOME_CHARS)}`,
155166
});
156167
} else if (lane.status === "cancelled") {
157-
changes.push({ kind: "failed", line: `${lane.description} cancelled` });
168+
changes.push({
169+
kind: "failed",
170+
line: `${lane.description} stopped — ${clip(lane.stopReason ?? "cancelled", OUTCOME_CHARS)}`,
171+
});
158172
}
159173
continue;
160174
}

src/subagent/index.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import {
1919
formatSubAgentReport,
2020
nextToolCallStreak,
2121
parseSubAgentReport,
22+
repetitionStopDetail,
23+
stopReasonFromReport,
2224
appendDeadlineParentHint,
2325
appendNeverActedParentHint,
2426
appendSubAgentParentHints,
@@ -770,6 +772,45 @@ describe("sub-agent stop helpers", () => {
770772
);
771773
});
772774

775+
test("forcedStopReport carries a machine-readable Stopped line the parent sees verbatim", () => {
776+
const repetition = forcedStopReport(
777+
"repetition",
778+
'Looped window (repeated 1363x): Groaning. ',
779+
'window "Groaning. " × 1363',
780+
);
781+
expect(repetition.startsWith('Stopped: repetition — window "Groaning. " × 1363\n')).toBe(true);
782+
expect(parseSubAgentReport(repetition).stopped).toBe('repetition — window "Groaning. " × 1363');
783+
expect(stopReasonFromReport(repetition)).toBe('repetition — window "Groaning. " × 1363');
784+
// Survives runSubAgent's parse/format normalization round-trip.
785+
const roundTripped = formatSubAgentReport(parseSubAgentReport(repetition));
786+
expect(stopReasonFromReport(roundTripped)).toBe('repetition — window "Groaning. " × 1363');
787+
// Classifiers and hints still fire on the unchanged Summary text.
788+
expect(appendSubAgentParentHints(repetition)).toContain("degenerated into a loop");
789+
790+
const cancelled = forcedStopReport("cancelled", "partial", "Session closed");
791+
expect(stopReasonFromReport(cancelled)).toBe("cancelled — Session closed");
792+
// Without a detail the line is the bare reason token.
793+
expect(stopReasonFromReport(forcedStopReport("cancelled", "partial"))).toBe("cancelled");
794+
expect(stopReasonFromReport(forcedStopReport("turn-budget", "x", "30/30 turns"))).toBe(
795+
"turn-budget — 30/30 turns",
796+
);
797+
798+
// A nested forced-stop quoted in Findings must not leak its Stopped line
799+
// as the outer report's reason.
800+
const nested = forcedStopReport("never-acted", forcedStopReport("cancelled", "inner", "inner reason"));
801+
expect(stopReasonFromReport(nested)).toBe("never-acted");
802+
// A clean report has no Stopped line.
803+
expect(stopReasonFromReport("## Summary\nDone.\n\n## Findings\nx")).toBe(null);
804+
});
805+
806+
test("repetitionStopDetail formats the looped window snippet and repeat count", () => {
807+
expect(repetitionStopDetail({ window: "Groaning. ", repeats: 1363 })).toBe(
808+
'window "Groaning. " × 1363',
809+
);
810+
const long = repetitionStopDetail({ window: "x".repeat(500), repeats: 7 });
811+
expect(long).toBe(`window "${"x".repeat(80)}" × 7`);
812+
});
813+
773814
test("createSubAgentRunController aborts on an explicit deadline and reports deadlineHit", async () => {
774815
const ctl = createSubAgentRunController(undefined, 20);
775816
expect(ctl.signal.aborted).toBe(false);
@@ -1849,6 +1890,29 @@ describe("createTaskTool", () => {
18491890
expect(row?.status).toBe("cancelled");
18501891
});
18511892

1893+
test("pre-progress cancel surfaces the recorded cancel reason to the parent", async () => {
1894+
const sessions = createSubAgentSessionStore();
1895+
const tool = createTaskTool({
1896+
permissionGate: testPermissionGate,
1897+
cwd: "/repo",
1898+
getWorkdirBase: () => "/repo/.corbits",
1899+
provider,
1900+
sessions,
1901+
run: async () => {
1902+
const row = sessions.list().find((s) => s.description === "reasoned");
1903+
if (row !== undefined) sessions.cancel(row.id, "Session closed");
1904+
const err = new Error("aborted");
1905+
err.name = "AbortError";
1906+
throw err;
1907+
},
1908+
});
1909+
const out = await callTask(tool, { description: "reasoned", prompt: "x", intent: "explore" });
1910+
expect(out).toContain("Stopped: cancelled — Session closed");
1911+
const row = sessions.list().find((s) => s.description === "reasoned");
1912+
expect(row?.status).toBe("cancelled");
1913+
expect(row?.stopReason).toBe("cancelled — Session closed");
1914+
});
1915+
18521916
test("pre-progress AbortError still surfaces as bare cancel", async () => {
18531917
const tool = createTaskTool({
18541918
permissionGate: testPermissionGate,

src/subagent/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export {
3636
formatSubAgentReport,
3737
hasReportEnvelope,
3838
parseSubAgentReport,
39+
stopReasonFromReport,
3940
subAgentToolName,
4041
type DispatchBrief,
4142
type SubAgentReport,
@@ -115,6 +116,7 @@ export {
115116
buildSubAgentPrimarySource,
116117
coreSubAgentWebTools,
117118
createSubAgentRunController,
119+
repetitionStopDetail,
118120
runSubAgent,
119121
shouldRequireEvidence,
120122
type SubAgentRunController,

src/subagent/nudge-director.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -290,9 +290,15 @@ export class SubAgentDirector extends DefaultDirector {
290290
: stop === "no-ship"
291291
? "subagent-no-ship"
292292
: "subagent-turn-budget";
293+
const detail =
294+
stop === "no-progress"
295+
? `identical tool call × ${this.streak.consecutiveIdentical}`
296+
: stop === "turn-budget"
297+
? `${this.turnsCompleted}/${this.maxTurns} turns`
298+
: undefined;
293299
const terminal: ReactorAction[] = [
294300
capabilities.checkpoint(checkpoint),
295-
capabilities.reply(forcedStopReport(stop, lastText(content))),
301+
capabilities.reply(forcedStopReport(stop, lastText(content), detail)),
296302
];
297303
this.compaction.noteIdleTurn(event, terminal);
298304
const compacted = this.compaction.interceptActions(event, terminal, capabilities);
@@ -354,7 +360,13 @@ export class SubAgentDirector extends DefaultDirector {
354360
}
355361
const terminal: ReactorAction[] = [
356362
capabilities.checkpoint("subagent-stalled"),
357-
capabilities.reply(forcedStopReport("stalled", this.lastAssistantText)),
363+
capabilities.reply(
364+
forcedStopReport(
365+
"stalled",
366+
this.lastAssistantText,
367+
`no activity for ${Math.round(elapsed / 1000)}s after stall nudge`,
368+
),
369+
),
358370
];
359371
return terminal;
360372
}

src/subagent/report.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,13 +119,31 @@ export type SubAgentReport = {
119119
findings: string;
120120
blockers: string;
121121
paths: string;
122+
/**
123+
* Machine-readable termination reason for a forced stop (e.g.
124+
* `repetition — window "Groaning. " × 1363`). Rendered as a dedicated
125+
* `Stopped:` line above the envelope; absent on successful completes.
126+
*/
127+
stopped?: string;
122128
};
123129

130+
const STOPPED_LINE_RE = /^Stopped:\s*(.+)$/m;
131+
132+
/** Machine-readable stop reason from a report's `Stopped:` line, or null. */
133+
export function stopReasonFromReport(report: string): string | null {
134+
return parseSubAgentReport(report).stopped ?? null;
135+
}
136+
124137
export function parseSubAgentReport(reply: string): SubAgentReport {
125138
const text = reply.trim();
126139
const sections: Record<string, string> = {};
127140
const headingRe = /^##\s+(Summary|Findings|Blockers|Paths)\s*$/gim;
128141
const matches = [...text.matchAll(headingRe)];
142+
// Only the preamble (before the first heading) can carry the report's own
143+
// Stopped: line — a nested forced-stop report quoted under Findings must
144+
// not be read as this report's reason.
145+
const preamble = matches.length > 0 ? text.slice(0, matches[0]?.index ?? 0) : "";
146+
const stopped = STOPPED_LINE_RE.exec(preamble)?.[1]?.trim();
129147
if (matches.length === 0) {
130148
return {
131149
summary: text.length > 0 ? text : "Sub-agent finished without a textual result.",
@@ -146,11 +164,16 @@ export function parseSubAgentReport(reply: string): SubAgentReport {
146164
findings: sections.findings ?? "",
147165
blockers: sections.blockers ?? "",
148166
paths: sections.paths ?? "",
167+
...(stopped !== undefined && stopped.length > 0 ? { stopped } : {}),
149168
};
150169
}
151170

152171
export function formatSubAgentReport(report: SubAgentReport): string {
153-
const lines: string[] = ["## Summary", report.summary.length > 0 ? report.summary : "(no summary)"];
172+
const lines: string[] = [];
173+
if (report.stopped !== undefined && report.stopped.length > 0) {
174+
lines.push(`Stopped: ${report.stopped}`, "");
175+
}
176+
lines.push("## Summary", report.summary.length > 0 ? report.summary : "(no summary)");
154177
if (report.findings.length > 0) {
155178
lines.push("", "## Findings", report.findings);
156179
}

src/subagent/run.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,22 @@ export function createSubAgentRunController(
224224
};
225225
}
226226

227+
/** Stopped-line detail for a repetition abort: the looped window plus repeat count. */
228+
export function repetitionStopDetail(hit: RepetitionHit): string {
229+
return `window ${JSON.stringify(hit.window.slice(0, 80))} × ${hit.repeats}`;
230+
}
231+
232+
/** String form of an abort signal's reason (cancel detail), or undefined. */
233+
function abortReasonText(signal: AbortSignal): string | undefined {
234+
const reason: unknown = signal.reason;
235+
if (typeof reason === "string" && reason.length > 0) return reason;
236+
// A bare abort() carries a default AbortError — no operator-written cause.
237+
if (reason instanceof Error && reason.name !== "AbortError" && reason.message.length > 0) {
238+
return reason.message;
239+
}
240+
return undefined;
241+
}
242+
227243
/**
228244
* Arm requireEvidence only for CritiqueDirector. Greybeard is also
229245
* intent=review and may spawn-only then envelope; that is not a fake
@@ -708,7 +724,13 @@ export async function runSubAgent(params: RunSubAgentParams): Promise<string> {
708724
repetition.hit !== null
709725
? `Looped window (repeated ${repetition.hit.repeats}x): ${repetition.hit.window.slice(0, 300)}\n\n${tail}`
710726
: tail;
711-
return appendActivitySummary(forcedStopReport(reason, partial), toolNamesUsed);
727+
const detail =
728+
repetition.hit !== null
729+
? repetitionStopDetail(repetition.hit)
730+
: reason === "deadline" && resolvedDeadlineMs !== undefined
731+
? `${resolvedDeadlineMs}ms elapsed`
732+
: abortReasonText(runController.signal);
733+
return appendActivitySummary(forcedStopReport(reason, partial, detail), toolNamesUsed);
712734
}
713735
}
714736
throw err;

src/subagent/session-store.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,3 +261,35 @@ describe("parallel tool calls", () => {
261261
expect(store.get(session.id)?.currentToolStartedAt).toBeNull();
262262
});
263263
});
264+
265+
describe("terminal stop reasons", () => {
266+
test("complete() records the report's Stopped line as stopReason", () => {
267+
const store = createSubAgentSessionStore();
268+
const session = store.start({ description: "d", agentId: "a", brief: "b" });
269+
store.complete(
270+
session.id,
271+
'Stopped: repetition — window "Groaning. " × 1363\n\n## Summary\nStopped: degenerate repetition in streamed output (same window looping mid-turn).',
272+
);
273+
const stored = store.get(session.id);
274+
expect(stored?.status).toBe("done");
275+
expect(stored?.stopReason).toBe('repetition — window "Groaning. " × 1363');
276+
});
277+
278+
test("a clean complete has no stopReason", () => {
279+
const store = createSubAgentSessionStore();
280+
const session = store.start({ description: "d", agentId: "a", brief: "b" });
281+
store.complete(session.id, "## Summary\nDone.\n\n## Findings\nx");
282+
expect(store.get(session.id)?.stopReason).toBeUndefined();
283+
});
284+
285+
test("cancel() records the cancel reason as stopReason", () => {
286+
const store = createSubAgentSessionStore();
287+
const withReason = store.start({ description: "d", agentId: "a", brief: "b" });
288+
store.cancel(withReason.id, "Session closed");
289+
expect(store.get(withReason.id)?.stopReason).toBe("cancelled — Session closed");
290+
291+
const bare = store.start({ description: "d2", agentId: "a", brief: "b" });
292+
store.cancel(bare.id);
293+
expect(store.get(bare.id)?.stopReason).toBe("cancelled");
294+
});
295+
});

0 commit comments

Comments
 (0)