Skip to content

Commit 74104e4

Browse files
committed
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.
1 parent ea84995 commit 74104e4

4 files changed

Lines changed: 668 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,17 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
1515

1616
### Agent
1717

18+
- **`spawn_agent` / `wait_agents` split the fused spawn+wait out of `task()`.**
19+
`spawn_agent` starts a worker and returns immediately with `{ agent_id,
20+
status: "running" }` — it never awaits the worker's completion. `wait_agents`
21+
blocks until any of the given (or, if omitted, all currently running)
22+
agent ids reaches a terminal state, or `timeout_ms` elapses (default
23+
30s, clamped to a 300s max); a timeout is not an error and never touches
24+
the workers — they keep running and stay waitable. Lets an orchestrator
25+
fire several workers in one turn instead of serializing one `task()` call
26+
per worker. `task()` is unchanged and remains the single-call spawn+block
27+
primitive for the common one-worker case.
28+
1829
- **Fleet authority tiers are now runtime-enforced, not documented in a prompt.**
1930
Every director package carries a required `tier` (`orchestrator` /
2031
`nested-orchestrator` / `leaf`): skywalker gets full fleet control, greybeard

src/subagent/agent-fleet.test.ts

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import { describe, expect, test } from "bun:test";
2+
3+
import { createSpawnAgentTool, createWaitAgentsTool, type AgentFleetDeps } from "./agent-fleet.js";
4+
import { createSubAgentSessionStore } from "./session-store.js";
5+
import { createPermissionGate } from "../permission/gate.js";
6+
import type { RunSubAgentParams } from "./types.js";
7+
8+
const testPermissionGate = createPermissionGate({
9+
approvals: [],
10+
interactive: false,
11+
skipPermissions: true,
12+
});
13+
14+
const provider = {
15+
providerName: "test-provider",
16+
baseURL: "http://localhost",
17+
model: "test-model",
18+
};
19+
20+
function deferred<T>(): {
21+
promise: Promise<T>;
22+
resolve: (v: T) => void;
23+
reject: (e: unknown) => void;
24+
} {
25+
let resolve!: (v: T) => void;
26+
let reject!: (e: unknown) => void;
27+
const promise = new Promise<T>((res, rej) => {
28+
resolve = res;
29+
reject = rej;
30+
});
31+
return { promise, resolve, reject };
32+
}
33+
34+
function makeDeps(run: (params: RunSubAgentParams) => Promise<string>): AgentFleetDeps {
35+
return {
36+
permissionGate: testPermissionGate,
37+
cwd: "/tmp",
38+
getWorkdirBase: () => "/tmp/workdir",
39+
provider,
40+
run,
41+
sessions: createSubAgentSessionStore(),
42+
};
43+
}
44+
45+
async function callTool(
46+
tool: ReturnType<typeof createSpawnAgentTool> | ReturnType<typeof createWaitAgentsTool>,
47+
args: Record<string, unknown>,
48+
): Promise<Record<string, unknown>> {
49+
if (tool.kind !== "full") throw new Error(`expected full tool, got ${tool.kind}`);
50+
const result = await tool.handler(
51+
{ id: `call-${Math.random()}`, name: tool.definition.name, arguments: args },
52+
new AbortController().signal,
53+
);
54+
const content =
55+
typeof result.content === "string" ? result.content : JSON.stringify(result.content);
56+
return JSON.parse(content);
57+
}
58+
59+
describe("spawn_agent", () => {
60+
test("returns immediately with a running agent_id without waiting for the worker", async () => {
61+
const gate = deferred<string>();
62+
const deps = makeDeps(async () => gate.promise);
63+
const spawn = createSpawnAgentTool(deps);
64+
65+
const started = Date.now();
66+
const result = await callTool(spawn, {
67+
description: "job",
68+
prompt: "do it",
69+
intent: "explore",
70+
});
71+
const elapsed = Date.now() - started;
72+
73+
expect(result.status).toBe("running");
74+
expect(typeof result.agent_id).toBe("string");
75+
expect(elapsed).toBeLessThan(1000);
76+
77+
// Worker is still pending; store confirms it has not finished.
78+
expect(deps.sessions.get(result.agent_id as string)?.status).toBe("running");
79+
80+
gate.resolve("done");
81+
});
82+
});
83+
84+
describe("spawn_agent + wait_agents", () => {
85+
test("wait_agents on one target returns once it completes while siblings keep running", async () => {
86+
const gates = [deferred<string>(), deferred<string>(), deferred<string>()];
87+
let callIndex = 0;
88+
const deps = makeDeps(async () => {
89+
const i = callIndex++;
90+
return gates[i]!.promise;
91+
});
92+
const spawn = createSpawnAgentTool(deps);
93+
const wait = createWaitAgentsTool({ sessions: deps.sessions });
94+
95+
const spawned = await Promise.all(
96+
[0, 1, 2].map((i) =>
97+
callTool(spawn, { description: `job-${i}`, prompt: "do it", intent: "explore" }),
98+
),
99+
);
100+
const ids = spawned.map((s) => s.agent_id as string);
101+
102+
gates[0]!.resolve("first report");
103+
104+
const waited = await callTool(wait, { targets: [ids[0]], timeout_ms: 5000 });
105+
expect(waited.timed_out).toBe(false);
106+
const results = waited.results as { agent_id: string; status: string; report?: string }[];
107+
expect(results).toHaveLength(1);
108+
expect(results[0]!.status).toBe("done");
109+
expect(results[0]!.report).toBe("first report");
110+
111+
// The other two remain untouched and running.
112+
expect(deps.sessions.get(ids[1]!)?.status).toBe("running");
113+
expect(deps.sessions.get(ids[2]!)?.status).toBe("running");
114+
115+
gates[1]!.resolve("second");
116+
gates[2]!.resolve("third");
117+
});
118+
119+
test("wait_agents times out on a still-running agent without cancelling it, and can be called again", async () => {
120+
const gate = deferred<string>();
121+
const deps = makeDeps(async () => gate.promise);
122+
const spawn = createSpawnAgentTool(deps);
123+
const wait = createWaitAgentsTool({ sessions: deps.sessions });
124+
125+
const spawned = await callTool(spawn, {
126+
description: "slow job",
127+
prompt: "do it",
128+
intent: "explore",
129+
});
130+
const id = spawned.agent_id as string;
131+
132+
const first = await callTool(wait, { targets: [id], timeout_ms: 50 });
133+
expect(first.timed_out).toBe(true);
134+
const firstResults = first.results as { agent_id: string; status: string }[];
135+
expect(firstResults[0]!.status).toBe("running");
136+
137+
// Not cancelled, not failed — still running.
138+
expect(deps.sessions.get(id)?.status).toBe("running");
139+
140+
// A second wait still works cleanly (either another timeout, or completion).
141+
gate.resolve("finished");
142+
const second = await callTool(wait, { targets: [id], timeout_ms: 5000 });
143+
expect(second.timed_out).toBe(false);
144+
const secondResults = second.results as {
145+
agent_id: string;
146+
status: string;
147+
report?: string;
148+
}[];
149+
expect(secondResults[0]!.status).toBe("done");
150+
expect(secondResults[0]!.report).toBe("finished");
151+
});
152+
153+
test("wait_agents with no targets waits on all currently running spawned agents", async () => {
154+
const gates = [deferred<string>(), deferred<string>()];
155+
let callIndex = 0;
156+
const deps = makeDeps(async () => gates[callIndex++]!.promise);
157+
const spawn = createSpawnAgentTool(deps);
158+
const wait = createWaitAgentsTool({ sessions: deps.sessions });
159+
160+
await callTool(spawn, { description: "a", prompt: "do it", intent: "explore" });
161+
await callTool(spawn, { description: "b", prompt: "do it", intent: "explore" });
162+
163+
gates[0]!.resolve("a done");
164+
const result = await callTool(wait, { timeout_ms: 5000 });
165+
expect(result.timed_out).toBe(false);
166+
const results = result.results as { status: string }[];
167+
expect(results).toHaveLength(2);
168+
expect(results.some((r) => r.status === "done")).toBe(true);
169+
170+
gates[1]!.resolve("b done");
171+
});
172+
});

0 commit comments

Comments
 (0)