Skip to content

Commit 08a32d1

Browse files
CL-6942: split spawn_agent / wait_agents out of task() (#600)
* Split task() into non-blocking spawn_agent + wait_agents spawn_agent starts a worker and returns immediately with {agent_id, status}; wait_agents blocks on any of a target set (default: all live agents) reaching a terminal state, or a clamped timeout, without touching the workers on timeout. task() is unchanged. * Fix report eviction and add cwd write-lane refusal for spawn_agent fleetRecords is a never-capped map of terminal spawn_agent results, written before the session store's complete()/fail() so wait_agents never loses a report to the store's TUI-sized finished-session cap. spawn_agent also now refuses a second concurrent implement-intent spawn against the same cwd (no worktree isolation yet), releasing the lane once the running one finishes; explore/plan/review-intent spawns are unaffected and may still run concurrently. * Format agent-fleet.ts * Name spawned workers' trace dirs after their session id read_agent_trace's descendant-scoping check resolves a worker's parent chain from its trace directory name, so spawn_agent must pass the session-store id the same way task-tool.ts does.
1 parent 9fb5433 commit 08a32d1

4 files changed

Lines changed: 923 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,17 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
1818
- Tier 3 leaf workers can now report via `submit_result`, a typed channel alongside
1919
the markdown envelope that validates against a director-declared JSON Schema
2020
and returns a correction (capped at 3 rounds) on an invalid submission.
21+
- **`spawn_agent` / `wait_agents` split the fused spawn+wait out of `task()`.**
22+
`spawn_agent` starts a worker and returns immediately with `{ agent_id,
23+
status: "running" }` — it never awaits the worker's completion. `wait_agents`
24+
blocks until any of the given (or, if omitted, all currently running)
25+
agent ids reaches a terminal state, or `timeout_ms` elapses (default
26+
30s, clamped to a 300s max); a timeout is not an error and never touches
27+
the workers — they keep running and stay waitable. Lets an orchestrator
28+
fire several workers in one turn instead of serializing one `task()` call
29+
per worker. `task()` is unchanged and remains the single-call spawn+block
30+
primitive for the common one-worker case.
31+
2132
- **Fleet authority tiers are now runtime-enforced, not documented in a prompt.**
2233
Every director package carries a required `tier` (`orchestrator` /
2334
`nested-orchestrator` / `leaf`): skywalker gets full fleet control, greybeard

src/subagent/agent-fleet.test.ts

Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
import { describe, expect, test } from "bun:test";
2+
3+
import {
4+
createFleetRecords,
5+
createSpawnAgentTool,
6+
createWaitAgentsTool,
7+
type AgentFleetDeps,
8+
} from "./agent-fleet.js";
9+
import { createSubAgentSessionStore } from "./session-store.js";
10+
import { createPermissionGate } from "../permission/gate.js";
11+
import type { RunSubAgentParams } from "./types.js";
12+
13+
const testPermissionGate = createPermissionGate({
14+
approvals: [],
15+
interactive: false,
16+
skipPermissions: true,
17+
});
18+
19+
const provider = {
20+
providerName: "test-provider",
21+
baseURL: "http://localhost",
22+
model: "test-model",
23+
};
24+
25+
function deferred<T>(): {
26+
promise: Promise<T>;
27+
resolve: (v: T) => void;
28+
reject: (e: unknown) => void;
29+
} {
30+
let resolve!: (v: T) => void;
31+
let reject!: (e: unknown) => void;
32+
const promise = new Promise<T>((res, rej) => {
33+
resolve = res;
34+
reject = rej;
35+
});
36+
return { promise, resolve, reject };
37+
}
38+
39+
function makeDeps(
40+
run: (params: RunSubAgentParams) => Promise<string>,
41+
opts: { cwd?: string } = {},
42+
): AgentFleetDeps {
43+
return {
44+
permissionGate: testPermissionGate,
45+
cwd: opts.cwd ?? "/tmp",
46+
getWorkdirBase: () => "/tmp/workdir",
47+
provider,
48+
run,
49+
sessions: createSubAgentSessionStore(),
50+
fleetRecords: createFleetRecords(),
51+
};
52+
}
53+
54+
async function callToolRaw(
55+
tool: ReturnType<typeof createSpawnAgentTool> | ReturnType<typeof createWaitAgentsTool>,
56+
args: Record<string, unknown>,
57+
): Promise<{ content: string; isError?: boolean }> {
58+
if (tool.kind !== "full") throw new Error(`expected full tool, got ${tool.kind}`);
59+
const result = await tool.handler(
60+
{ id: `call-${Math.random()}`, name: tool.definition.name, arguments: args },
61+
new AbortController().signal,
62+
);
63+
const content =
64+
typeof result.content === "string" ? result.content : JSON.stringify(result.content);
65+
return { content, ...(result.isError !== undefined ? { isError: result.isError } : {}) };
66+
}
67+
68+
async function callTool(
69+
tool: ReturnType<typeof createSpawnAgentTool> | ReturnType<typeof createWaitAgentsTool>,
70+
args: Record<string, unknown>,
71+
): Promise<Record<string, unknown>> {
72+
const { content } = await callToolRaw(tool, args);
73+
return JSON.parse(content);
74+
}
75+
76+
describe("spawn_agent", () => {
77+
test("returns immediately with a running agent_id without waiting for the worker", async () => {
78+
const gate = deferred<string>();
79+
const deps = makeDeps(async () => gate.promise);
80+
const spawn = createSpawnAgentTool(deps);
81+
82+
const started = Date.now();
83+
const result = await callTool(spawn, {
84+
description: "job",
85+
prompt: "do it",
86+
intent: "explore",
87+
});
88+
const elapsed = Date.now() - started;
89+
90+
expect(result.status).toBe("running");
91+
expect(typeof result.agent_id).toBe("string");
92+
expect(elapsed).toBeLessThan(1000);
93+
94+
// Worker is still pending; store confirms it has not finished.
95+
expect(deps.sessions.get(result.agent_id as string)?.status).toBe("running");
96+
97+
gate.resolve("done");
98+
});
99+
});
100+
101+
describe("spawn_agent + wait_agents", () => {
102+
test("wait_agents on one target returns once it completes while siblings keep running", async () => {
103+
const gates = [deferred<string>(), deferred<string>(), deferred<string>()];
104+
let callIndex = 0;
105+
const deps = makeDeps(async () => {
106+
const i = callIndex++;
107+
return gates[i]!.promise;
108+
});
109+
const spawn = createSpawnAgentTool(deps);
110+
const wait = createWaitAgentsTool({ sessions: deps.sessions, fleetRecords: deps.fleetRecords });
111+
112+
const spawned = await Promise.all(
113+
[0, 1, 2].map((i) =>
114+
callTool(spawn, { description: `job-${i}`, prompt: "do it", intent: "explore" }),
115+
),
116+
);
117+
const ids = spawned.map((s) => s.agent_id as string);
118+
119+
gates[0]!.resolve("first report");
120+
121+
const waited = await callTool(wait, { targets: [ids[0]], timeout_ms: 5000 });
122+
expect(waited.timed_out).toBe(false);
123+
const results = waited.results as { agent_id: string; status: string; report?: string }[];
124+
expect(results).toHaveLength(1);
125+
expect(results[0]!.status).toBe("done");
126+
expect(results[0]!.report).toBe("first report");
127+
128+
// The other two remain untouched and running.
129+
expect(deps.sessions.get(ids[1]!)?.status).toBe("running");
130+
expect(deps.sessions.get(ids[2]!)?.status).toBe("running");
131+
132+
gates[1]!.resolve("second");
133+
gates[2]!.resolve("third");
134+
});
135+
136+
test("wait_agents times out on a still-running agent without cancelling it, and can be called again", async () => {
137+
const gate = deferred<string>();
138+
const deps = makeDeps(async () => gate.promise);
139+
const spawn = createSpawnAgentTool(deps);
140+
const wait = createWaitAgentsTool({ sessions: deps.sessions, fleetRecords: deps.fleetRecords });
141+
142+
const spawned = await callTool(spawn, {
143+
description: "slow job",
144+
prompt: "do it",
145+
intent: "explore",
146+
});
147+
const id = spawned.agent_id as string;
148+
149+
const first = await callTool(wait, { targets: [id], timeout_ms: 50 });
150+
expect(first.timed_out).toBe(true);
151+
const firstResults = first.results as { agent_id: string; status: string }[];
152+
expect(firstResults[0]!.status).toBe("running");
153+
154+
// Not cancelled, not failed — still running.
155+
expect(deps.sessions.get(id)?.status).toBe("running");
156+
157+
// A second wait still works cleanly (either another timeout, or completion).
158+
gate.resolve("finished");
159+
const second = await callTool(wait, { targets: [id], timeout_ms: 5000 });
160+
expect(second.timed_out).toBe(false);
161+
const secondResults = second.results as {
162+
agent_id: string;
163+
status: string;
164+
report?: string;
165+
}[];
166+
expect(secondResults[0]!.status).toBe("done");
167+
expect(secondResults[0]!.report).toBe("finished");
168+
});
169+
170+
test("wait_agents with no targets waits on all currently running spawned agents", async () => {
171+
const gates = [deferred<string>(), deferred<string>()];
172+
let callIndex = 0;
173+
const deps = makeDeps(async () => gates[callIndex++]!.promise);
174+
const spawn = createSpawnAgentTool(deps);
175+
const wait = createWaitAgentsTool({ sessions: deps.sessions, fleetRecords: deps.fleetRecords });
176+
177+
await callTool(spawn, { description: "a", prompt: "do it", intent: "explore" });
178+
await callTool(spawn, { description: "b", prompt: "do it", intent: "explore" });
179+
180+
gates[0]!.resolve("a done");
181+
const result = await callTool(wait, { timeout_ms: 5000 });
182+
expect(result.timed_out).toBe(false);
183+
const results = result.results as { status: string }[];
184+
expect(results).toHaveLength(2);
185+
expect(results.some((r) => r.status === "done")).toBe(true);
186+
187+
gates[1]!.resolve("b done");
188+
});
189+
190+
test("reports survive well past the session store's display cap (20) until wait_agents collects them", async () => {
191+
// DEFAULT_MAX_COMPLETED on SubAgentSessionStore is 20 finished sessions;
192+
// spawn (and complete) enough workers to blow well past it before any of
193+
// them is collected, proving fleetRecords — not the store — is what
194+
// wait_agents actually reads from.
195+
const COUNT = 25;
196+
const deps = makeDeps(async () => "irrelevant");
197+
const spawn = createSpawnAgentTool(deps);
198+
const wait = createWaitAgentsTool({ sessions: deps.sessions, fleetRecords: deps.fleetRecords });
199+
200+
const ids: string[] = [];
201+
for (let i = 0; i < COUNT; i++) {
202+
const spawned = await callTool(spawn, {
203+
description: `job-${i}`,
204+
prompt: `report-${i}`,
205+
intent: "explore",
206+
});
207+
ids.push(spawned.agent_id as string);
208+
}
209+
210+
// Let every spawn's run() resolve and complete() land before collecting.
211+
await new Promise((resolve) => setTimeout(resolve, 20));
212+
213+
// The store itself has already evicted all but the most recent 20.
214+
expect(deps.sessions.get(ids[0]!)).toBeUndefined();
215+
216+
// But every single one is still retrievable through wait_agents.
217+
const waited = await callTool(wait, { targets: ids, timeout_ms: 5000 });
218+
const results = waited.results as { agent_id: string; status: string; report?: string }[];
219+
expect(results).toHaveLength(COUNT);
220+
for (const result of results) {
221+
expect(result.status).toBe("done");
222+
expect(result.report).toBe("irrelevant");
223+
}
224+
});
225+
});
226+
227+
describe("spawn_agent write-lane isolation", () => {
228+
test("refuses a second concurrent implement-intent spawn against the same cwd", async () => {
229+
const gate = deferred<string>();
230+
const deps = makeDeps(async () => gate.promise, { cwd: "/repo" });
231+
const spawn = createSpawnAgentTool(deps);
232+
233+
const first = await callTool(spawn, {
234+
description: "build one",
235+
prompt: "implement thing one",
236+
intent: "implement",
237+
});
238+
expect(first.status).toBe("running");
239+
240+
const second = await callToolRaw(spawn, {
241+
description: "build two",
242+
prompt: "implement thing two",
243+
intent: "implement",
244+
});
245+
expect(second.isError).toBe(true);
246+
expect(second.content).toContain("Error:");
247+
expect(second.content).toContain(first.agent_id as string);
248+
249+
gate.resolve("done");
250+
});
251+
252+
test("does not refuse a second concurrent explore-intent spawn against the same cwd", async () => {
253+
const deps = makeDeps(async () => "explored", { cwd: "/repo" });
254+
const spawn = createSpawnAgentTool(deps);
255+
256+
const first = await callTool(spawn, {
257+
description: "explore one",
258+
prompt: "look around",
259+
intent: "explore",
260+
});
261+
const second = await callTool(spawn, {
262+
description: "explore two",
263+
prompt: "look around more",
264+
intent: "explore",
265+
});
266+
267+
expect(first.status).toBe("running");
268+
expect(second.status).toBe("running");
269+
});
270+
271+
test("releases the write lane once the implement worker finishes, allowing another", async () => {
272+
const deps = makeDeps(async () => "built", { cwd: "/repo" });
273+
const spawn = createSpawnAgentTool(deps);
274+
const wait = createWaitAgentsTool({ sessions: deps.sessions, fleetRecords: deps.fleetRecords });
275+
276+
const first = await callTool(spawn, {
277+
description: "build one",
278+
prompt: "implement thing one",
279+
intent: "implement",
280+
});
281+
await callTool(wait, { targets: [first.agent_id as string], timeout_ms: 5000 });
282+
283+
const second = await callTool(spawn, {
284+
description: "build two",
285+
prompt: "implement thing two",
286+
intent: "implement",
287+
});
288+
expect(second.status).toBe("running");
289+
});
290+
});

0 commit comments

Comments
 (0)