Skip to content

Commit 34c6dd0

Browse files
committed
feat(subagent): mark recoverable failures continuable in wait output
1 parent 7a19b56 commit 34c6dd0

4 files changed

Lines changed: 138 additions & 3 deletions

File tree

‎src/inference-error-message.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,31 @@ export function classifyInferenceErrorCategory(
6868
: error.category;
6969
}
7070

71+
/**
72+
* Whether a normalized provider-failure category is transient enough that a
73+
* parent may spawn one successor with the same brief (CL-8978). Allowlist:
74+
* retryable/timeout — including 429 overload, which normalizes to retryable.
75+
* Fatal categories win explicitly: credential, quota, and context-overflow
76+
* failures must never read as continuable.
77+
*/
78+
const FATAL_PROVIDER_FAILURE_CATEGORIES: ReadonlySet<string> = new Set([
79+
"credential_failure",
80+
"quota_exhausted",
81+
"context_overflow",
82+
]);
83+
84+
const RECOVERABLE_PROVIDER_FAILURE_CATEGORIES: ReadonlySet<string> = new Set([
85+
"retryable",
86+
"timeout",
87+
]);
88+
89+
export function isRecoverableProviderFailureCategory(
90+
category: string,
91+
): boolean {
92+
if (FATAL_PROVIDER_FAILURE_CATEGORIES.has(category)) return false;
93+
return RECOVERABLE_PROVIDER_FAILURE_CATEGORIES.has(category);
94+
}
95+
7196
function codexUsageLimitLine(error: InferenceErrorLike): string | undefined {
7297
// Match normalizeCodexUsageLimitError: never brand a known non-Codex source.
7398
if (

‎src/subagent/agent-fleet.ts‎

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,10 @@ import {
107107
} from "./authority.js";
108108

109109
import { formatSubAgentSpawnAuthFailureMessage } from "./inference-auth-failure.js";
110-
import { isResolvedProviderFailureError } from "../inference-error-message.js";
110+
import {
111+
isRecoverableProviderFailureCategory,
112+
isResolvedProviderFailureError,
113+
} from "../inference-error-message.js";
111114
import { errorMessage } from "../agent/error-message.js";
112115
import { isSubAgentCancelError } from "./dispose.js";
113116
import {
@@ -125,6 +128,8 @@ interface FleetRecord {
125128
error?: string;
126129
stopReason?: string;
127130
providerFailure?: true;
131+
/** CL-8978: transient provider failure — the parent may spawn one successor. */
132+
recoverableFailure?: true;
128133
/** Set once a wait_agents caller has been handed this result. */
129134
collected?: boolean;
130135
/** Set once a waiter or occupancy take handed report/error. */
@@ -158,6 +163,8 @@ interface FleetOverlay {
158163
tombstoned?: boolean;
159164
hint?: string;
160165
providerFailure?: true;
166+
/** CL-8978: transient provider failure — the parent may spawn one successor. */
167+
recoverableFailure?: true;
161168
}
162169

163170
const RECOVERY_HINT =
@@ -265,6 +272,17 @@ class FleetMailbox {
265272
existing.providerFailure = true;
266273
}
267274

275+
/**
276+
* CL-8978: stamp a transient (retryable/timeout/overload) provider failure
277+
* alongside sessions.fail. Survives session eviction like providerFailure —
278+
* snapshot projects it even once the payload is tombstoned.
279+
*/
280+
markRecoverable(id: string): void {
281+
const existing = this.records.get(id);
282+
if (existing === undefined) return;
283+
existing.recoverableFailure = true;
284+
}
285+
268286
markQueued(id: string): void {
269287
const existing = this.records.get(id);
270288
if (existing === undefined || existing.collected === true) return;
@@ -462,6 +480,9 @@ class FleetMailbox {
462480
: {}),
463481
...(stopReason !== undefined ? { stopReason } : {}),
464482
...(overlay.providerFailure === true ? { providerFailure: true } : {}),
483+
...(overlay.recoverableFailure === true
484+
? { recoverableFailure: true }
485+
: {}),
465486
...(ask !== undefined
466487
? { question: ask.question, questionId: ask.questionId }
467488
: {}),
@@ -600,7 +621,10 @@ export const waitAgentsToolDefinition: ToolDefinition = {
600621
`slot), "running", and "awaiting_director". interrupt_agent unblocks this wait immediately with ` +
601622
`status "interrupted" (a parent-initiated pause — resume_agent, do not spawn_agent a successor against the still-live worker). ` +
602623
`close_agent also unblocks with status "interrupted" but is permanent. Terminal JSON includes stop_reason when the session recorded one ` +
603-
`(interrupted, cancelled, incomplete-report, and similar). awaiting_director is not terminal: re-wait while still pending re-delivers the same question. ` +
624+
`(interrupted, cancelled, incomplete-report, and similar). A "failed" entry with "continuable": true is a recoverable transient ` +
625+
`provider failure (retryable/timeout/overload) — terminal, not a timeout and not a stall: do not re-wait it, and you may spawn at most ` +
626+
`one successor with the same brief. "failed" without the marker (auth, quota, context-overflow, or other errors) is not continuable — ` +
627+
`do not respawn it. awaiting_director is not terminal: re-wait while still pending re-delivers the same question. ` +
604628
`Answer with send_input (soft). Do not call this in a tight zero-progress loop: a timeout means the targets are still ` +
605629
`queued, running, or awaiting a director answer, not "try again right away" — do other work, reply to the operator, or change the brief. Calling again with the ` +
606630
`same targets is a real timed wait, not a spin, but wastes turns if nothing has changed. ` +
@@ -1530,6 +1554,17 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool {
15301554
if (isProviderFailure || providerFailureObserved) {
15311555
deps.fleetRecords.markProviderFailure(session.id);
15321556
}
1557+
// CL-8978: a classified transient failure stays wait-terminal
1558+
// failed, but carries a continuable marker so the parent can
1559+
// spawn one successor instead of stalling on the failure.
1560+
// Fatal categories (credential/quota/context-overflow) and
1561+
// unclassified throws never mark — no auto-retry is added here.
1562+
if (
1563+
isResolvedProviderFailureError(err) &&
1564+
isRecoverableProviderFailureCategory(err.category)
1565+
) {
1566+
deps.fleetRecords.markRecoverable(session.id);
1567+
}
15331568
deps.sessions.fail(session.id, failReason);
15341569
})
15351570
.finally(() => {

‎src/subagent/fleet-dry-drive.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ export interface FleetDryMailboxRecord {
3434
readonly description?: string;
3535
readonly hint?: string;
3636
readonly providerFailure?: true;
37+
/** CL-8978: transient provider failure — the parent may spawn one successor. */
38+
readonly recoverableFailure?: true;
3739
readonly stopReason?: string;
3840
}
3941

@@ -58,9 +60,25 @@ export interface CollectedWorkerReport {
5860
error?: string;
5961
hint?: string;
6062
provider_failure?: true;
63+
/**
64+
* CL-8978: failed entries from a transient provider failure carry this
65+
* marker plus single-successor guidance in continue_with. Capped affordance:
66+
* at most one respawn with the same brief, never a retry loop.
67+
*/
68+
continuable?: true;
69+
continue_with?: string;
6170
stop_reason?: string;
6271
}
6372

73+
/**
74+
* Single-successor guidance for a failed+continuable entry. The marker is
75+
* advisory only — no runtime auto-retry backs it.
76+
*/
77+
export const RECOVERABLE_FAILURE_CONTINUE_GUIDANCE =
78+
"This worker failed with a transient provider error (retryable/timeout/overload) " +
79+
"and is terminal — do not re-wait it. You may spawn at most one successor with " +
80+
"the same brief; do not retry in a loop.";
81+
6482
export function shouldDriveOpenTasks(input: {
6583
previousRunning?: number | undefined;
6684
running?: number | undefined;
@@ -169,6 +187,12 @@ export function projectMailboxRecord(
169187
...(error !== undefined ? { error } : {}),
170188
...(taken.hint !== undefined ? { hint: taken.hint } : {}),
171189
...(taken.providerFailure === true ? { provider_failure: true } : {}),
190+
...(taken.status === "failed" && taken.recoverableFailure === true
191+
? {
192+
continuable: true as const,
193+
continue_with: RECOVERABLE_FAILURE_CONTINUE_GUIDANCE,
194+
}
195+
: {}),
172196
...(taken.stopReason !== undefined
173197
? { stop_reason: taken.stopReason }
174198
: {}),

‎src/subagent/run-recoverable-failure.test.ts‎

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,11 @@ describe("CL-8978 recoverable subagent failure", () => {
161161
// The parent handle still works: spawn and wait a successor.
162162
const deps2 = makeDeps(async () => ({ report: "successor done" }));
163163
// Share the fleet so the successor is a true sibling lane.
164-
const spawn2 = createSpawnAgentTool({ ...deps2, sessions: deps.sessions, fleetRecords: deps.fleetRecords });
164+
const spawn2 = createSpawnAgentTool({
165+
...deps2,
166+
sessions: deps.sessions,
167+
fleetRecords: deps.fleetRecords,
168+
});
165169
const wait2 = createWaitAgentsTool({
166170
sessions: deps.sessions,
167171
fleetRecords: deps.fleetRecords,
@@ -255,6 +259,53 @@ describe("CL-8978 recoverable subagent failure", () => {
255259
expect(await drive()).toBe(true);
256260
expect(prompts).toHaveLength(2);
257261
expect(deps.fleetRecords.peek(id)?.collected).toBe(true);
262+
// The mailbox path carries the same continuable marker as wait_agents.
263+
expect(prompts[1]).toContain('"continuable":true');
264+
});
265+
266+
test("in-flight work stays live until settled: a timeout is liveness, not failure", async () => {
267+
let resolveRun!: (v: RunSubAgentResult) => void;
268+
const gate = new Promise<RunSubAgentResult>((res) => {
269+
resolveRun = res;
270+
});
271+
const deps = makeDeps(() => gate);
272+
const spawn = createSpawnAgentTool(deps);
273+
const wait = createWaitAgentsTool({
274+
sessions: deps.sessions,
275+
fleetRecords: deps.fleetRecords,
276+
});
277+
278+
const spawned = await callTool(spawn, {
279+
description: "slow job",
280+
prompt: "do it",
281+
intent: "explore",
282+
});
283+
const id = spawned.agent_id as string;
284+
285+
// Unsettled work (including an in-flight provider retry) projects live.
286+
const snap = deps.fleetRecords.peek(id);
287+
expect(snap?.status).toBe("running");
288+
expect(isLiveWaitStatus(snap?.status ?? "failed")).toBe(true);
289+
290+
const waited = await callTool(wait, {
291+
targets: [id],
292+
timeout_ms: 50,
293+
mode: "all",
294+
});
295+
expect(waited.timed_out).toBe(true);
296+
const results = waited.results as Record<string, unknown>[];
297+
expect(results[0]?.status).toBe("running");
298+
expect(results[0]?.continuable).toBeUndefined();
299+
300+
resolveRun({ report: "slow done" });
301+
const waited2 = await callTool(wait, {
302+
targets: [id],
303+
timeout_ms: 5000,
304+
mode: "all",
305+
});
306+
expect(waited2.timed_out).toBe(false);
307+
const results2 = waited2.results as Record<string, unknown>[];
308+
expect(results2[0]?.status).toBe("done");
258309
});
259310

260311
test("wait_agents documents the continuable failed marker", () => {

0 commit comments

Comments
 (0)