Skip to content

Commit 7a19b56

Browse files
committed
test(subagent): prove recoverable failure yields continuable wait marker
1 parent e28dce8 commit 7a19b56

1 file changed

Lines changed: 263 additions & 0 deletions

File tree

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
import { describe, expect, test } from "bun:test";
2+
import {
3+
createFleetMailbox,
4+
createSpawnAgentTool,
5+
createWaitAgentsTool,
6+
waitAgentsToolDefinition,
7+
type AgentFleetDeps,
8+
} from "./agent-fleet.js";
9+
import { unlimitedAdmissionQueue } from "./admission.js";
10+
import { isLiveWaitStatus } from "./lifecycle.js";
11+
import {
12+
driveMailboxMail,
13+
occupancyShouldYieldWait,
14+
} from "./mailbox-mail-drive.js";
15+
import { createResolvedProviderFailureError } from "../inference-error-message.js";
16+
import { createPermissionGate } from "../permission/gate.js";
17+
import { createSubAgentSessionStore } from "./session-store.js";
18+
import type { RunSubAgentParams, RunSubAgentResult } from "./types.js";
19+
20+
const testPermissionGate = createPermissionGate({
21+
approvals: [],
22+
interactive: false,
23+
skipPermissions: true,
24+
reactorGated: false,
25+
});
26+
27+
const provider = {
28+
providerName: "test-provider",
29+
baseURL: "http://localhost",
30+
model: "test-model",
31+
};
32+
33+
function makeDeps(
34+
run: (params: RunSubAgentParams) => Promise<RunSubAgentResult>,
35+
): AgentFleetDeps {
36+
const sessions = createSubAgentSessionStore();
37+
return {
38+
permissionGate: testPermissionGate,
39+
cwd: "/tmp",
40+
getWorkdirBase: () => "/tmp/workdir",
41+
provider,
42+
run,
43+
sessions,
44+
fleetRecords: createFleetMailbox(sessions),
45+
admission: unlimitedAdmissionQueue(),
46+
};
47+
}
48+
49+
async function callToolRaw(
50+
tool:
51+
| ReturnType<typeof createSpawnAgentTool>
52+
| ReturnType<typeof createWaitAgentsTool>,
53+
args: Record<string, unknown>,
54+
): Promise<{ content: string; isError?: boolean }> {
55+
if (tool.kind !== "full")
56+
throw new Error(`expected full tool, got ${tool.kind}`);
57+
const result = await tool.handler(
58+
{
59+
id: `call-${Math.random()}`,
60+
name: tool.definition.name,
61+
arguments: args,
62+
},
63+
new AbortController().signal,
64+
);
65+
const content =
66+
typeof result.content === "string"
67+
? result.content
68+
: JSON.stringify(result.content);
69+
return {
70+
content,
71+
...(result.isError !== undefined ? { isError: result.isError } : {}),
72+
};
73+
}
74+
75+
async function callTool(
76+
tool:
77+
| ReturnType<typeof createSpawnAgentTool>
78+
| ReturnType<typeof createWaitAgentsTool>,
79+
args: Record<string, unknown>,
80+
): Promise<Record<string, unknown>> {
81+
const { content } = await callToolRaw(tool, args);
82+
return JSON.parse(content) as Record<string, unknown>;
83+
}
84+
85+
function retryableAfterToolsFailure(): Error {
86+
// runSubAgentInner throws without an outer retry once any tool already ran,
87+
// so a retryable provider fault after tool use lands in the agent-fleet
88+
// catch as a ResolvedProviderFailureError with category "retryable".
89+
return createResolvedProviderFailureError("test-provider", {
90+
category: "retryable",
91+
message: "upstream overloaded, retry later",
92+
statusCode: 429,
93+
});
94+
}
95+
96+
function waitUntilMailboxTerminal(
97+
mailbox: ReturnType<typeof createFleetMailbox>,
98+
sessions: ReturnType<typeof createSubAgentSessionStore>,
99+
id: string,
100+
): Promise<void> {
101+
return new Promise((resolve) => {
102+
const done = (): boolean => {
103+
const snap = mailbox.peek(id);
104+
return snap !== undefined && !isLiveWaitStatus(snap.status);
105+
};
106+
if (done()) {
107+
resolve();
108+
return;
109+
}
110+
const unsub = sessions.subscribe(() => {
111+
if (done()) {
112+
unsub();
113+
resolve();
114+
}
115+
});
116+
if (done()) {
117+
unsub();
118+
resolve();
119+
}
120+
});
121+
}
122+
123+
describe("CL-8978 recoverable subagent failure", () => {
124+
test("retryable-after-tools failure is wait-terminal failed with a continuable marker, and the parent can spawn/wait a successor", async () => {
125+
const deps = makeDeps(async () => {
126+
throw retryableAfterToolsFailure();
127+
});
128+
const spawn = createSpawnAgentTool(deps);
129+
const wait = createWaitAgentsTool({
130+
sessions: deps.sessions,
131+
fleetRecords: deps.fleetRecords,
132+
});
133+
134+
const spawned = await callTool(spawn, {
135+
description: "flaky job",
136+
prompt: "do it",
137+
intent: "explore",
138+
});
139+
const id = spawned.agent_id as string;
140+
expect(typeof id).toBe("string");
141+
142+
const waited = await callTool(wait, {
143+
targets: [id],
144+
timeout_ms: 5000,
145+
mode: "all",
146+
});
147+
expect(waited.timed_out).toBe(false);
148+
const results = waited.results as Record<string, unknown>[];
149+
expect(results).toHaveLength(1);
150+
expect(results[0]?.status).toBe("failed");
151+
expect(typeof results[0]?.error).toBe("string");
152+
// Machine-readable continuable marker plus single-successor guidance.
153+
expect(results[0]?.continuable).toBe(true);
154+
expect(typeof results[0]?.continue_with).toBe("string");
155+
156+
// The failure is terminal, never stuck running.
157+
const snap = deps.fleetRecords.peek(id);
158+
expect(snap?.status).toBe("failed");
159+
expect(isLiveWaitStatus(snap?.status ?? "running")).toBe(false);
160+
161+
// The parent handle still works: spawn and wait a successor.
162+
const deps2 = makeDeps(async () => ({ report: "successor done" }));
163+
// Share the fleet so the successor is a true sibling lane.
164+
const spawn2 = createSpawnAgentTool({ ...deps2, sessions: deps.sessions, fleetRecords: deps.fleetRecords });
165+
const wait2 = createWaitAgentsTool({
166+
sessions: deps.sessions,
167+
fleetRecords: deps.fleetRecords,
168+
});
169+
const spawned2 = await callTool(spawn2, {
170+
description: "successor job",
171+
prompt: "do it again",
172+
intent: "explore",
173+
});
174+
const id2 = spawned2.agent_id as string;
175+
expect(id2).not.toBe(id);
176+
const waited2 = await callTool(wait2, {
177+
targets: [id2],
178+
timeout_ms: 5000,
179+
mode: "all",
180+
});
181+
expect(waited2.timed_out).toBe(false);
182+
const results2 = waited2.results as Record<string, unknown>[];
183+
expect(results2[0]?.status).toBe("done");
184+
});
185+
186+
test("credential failure stays failed without a continuable marker", async () => {
187+
const deps = makeDeps(async () => {
188+
throw createResolvedProviderFailureError("test-provider", {
189+
category: "credential_failure",
190+
message: "Authentication failed",
191+
statusCode: 401,
192+
});
193+
});
194+
const spawn = createSpawnAgentTool(deps);
195+
const wait = createWaitAgentsTool({
196+
sessions: deps.sessions,
197+
fleetRecords: deps.fleetRecords,
198+
});
199+
200+
const spawned = await callTool(spawn, {
201+
description: "auth job",
202+
prompt: "do it",
203+
intent: "explore",
204+
});
205+
const waited = await callTool(wait, {
206+
targets: [spawned.agent_id as string],
207+
timeout_ms: 5000,
208+
mode: "all",
209+
});
210+
const results = waited.results as Record<string, unknown>[];
211+
expect(results[0]?.status).toBe("failed");
212+
expect(results[0]?.continuable).toBeUndefined();
213+
});
214+
215+
test("failed+recoverable lane is delivered as mailbox mail, and a failed send re-arms instead of dropping the terminal", async () => {
216+
const deps = makeDeps(async () => {
217+
throw retryableAfterToolsFailure();
218+
});
219+
const spawn = createSpawnAgentTool(deps);
220+
const spawned = await callTool(spawn, {
221+
description: "flaky job",
222+
prompt: "do it",
223+
intent: "explore",
224+
});
225+
const id = spawned.agent_id as string;
226+
await waitUntilMailboxTerminal(deps.fleetRecords, deps.sessions, id);
227+
228+
// Terminal failure is occupancy-yielding, so the parent is driven back
229+
// into a turn instead of sitting silent for the stall bound.
230+
expect(occupancyShouldYieldWait(deps.fleetRecords)).toBe(true);
231+
232+
const prompts: string[] = [];
233+
let sendShouldFail = true;
234+
const drive = (): boolean | Promise<boolean> =>
235+
driveMailboxMail({
236+
parentProcessing: false,
237+
mailbox: deps.fleetRecords,
238+
lanes: deps.sessions.list(),
239+
beginSystemContinuation: () => undefined,
240+
send: (prompt: string) => {
241+
prompts.push(prompt);
242+
if (sendShouldFail) throw new Error("send down");
243+
return { status: "accepted" };
244+
},
245+
});
246+
247+
// Occupancy send failure leaves the terminal uncollected ...
248+
expect(await drive()).toBe(false);
249+
expect(prompts).toHaveLength(1);
250+
expect(prompts[0]).toContain(id);
251+
expect(deps.fleetRecords.peek(id)?.collected).not.toBe(true);
252+
253+
// ... and the re-flush delivers it, starting the parent turn.
254+
sendShouldFail = false;
255+
expect(await drive()).toBe(true);
256+
expect(prompts).toHaveLength(2);
257+
expect(deps.fleetRecords.peek(id)?.collected).toBe(true);
258+
});
259+
260+
test("wait_agents documents the continuable failed marker", () => {
261+
expect(waitAgentsToolDefinition.description).toContain("continuable");
262+
});
263+
});

0 commit comments

Comments
 (0)