Skip to content

Commit 3f9cd9e

Browse files
committed
Queue excess spawn_agent runs instead of capping in a prompt
Workers report queued until a burst slot is free. Nested children bypass the window so a full fleet cannot deadlock wait_agents. Retryable 429s freeze new admits through the shared retry policy.
1 parent 8073cfa commit 3f9cd9e

26 files changed

Lines changed: 784 additions & 193 deletions

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,16 @@ matching `## [X.Y.Z]` section (plus install instructions). Do not maintain
1111
parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
1212
`## [Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD`, then run the release script.
1313

14+
## [Unreleased]
15+
16+
### Added
17+
18+
- Sub-agent admission queue: `spawn_agent` never refuses for worker count.
19+
Excess dispatches report `queued` until a burst slot is free. Nested children
20+
of an already-admitted parent bypass the cap. Capacity changes never cancel
21+
in-flight work. Short provider 429s freeze new admits via the shared retry
22+
remapper.
23+
1424
## [0.3.14] - 2026-09-03
1525

1626
### Changed

docs/ARCHITECTURE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ Three distinct concepts (do not conflate them):
207207
| **Task** | A checklist item owned by _one_ agent via `manage_tasks` | Local work plan — not a spawn |
208208
| **Sub-agent** | A short-lived child agent for one self-contained job | Spawned with **`spawn_agent`**, collected with **`wait_agents`** |
209209

210-
The **`spawn_agent`** tool starts a sub-agent on a separate inference source (tier/profile resolved from settings) and returns immediately with an `agent_id`; **`wait_agents`** collects reports later. The dispatch brief separates durable `context`, actionable `prompt`, and optional `goals` (checklist seeds for the _child's_ own `manage_tasks` list). The child returns a structured report (`Summary` / `Findings` / `Blockers` / `Paths`) plus a tools-used footer. Parent and child never share a `manage_tasks` list.
210+
The **`spawn_agent`** tool starts a sub-agent on a separate inference source (tier/profile resolved from settings) and returns immediately with an `agent_id`; **`wait_agents`** collects reports later. Declared fan-out is unlimited: excess dispatches enqueue rather than fail. `run()` is admitted by `src/subagent/admission.ts` (default burst window 8 concurrent worker runs). Nested children of an already-admitted parent bypass the cap so a nested orchestrator cannot deadlock while holding a slot on `wait_agents`. Queued workers report wait/list status `queued` (live, not failed). Lowering capacity never cancels in-flight work. Short provider 429s freeze new admits via the shared retry remapper in `createCorbitsRetryPolicy`; `quota_exhausted` does not freeze. `list_agents` remains mailbox-scoped. The dispatch brief separates durable `context`, actionable `prompt`, and optional `goals` (checklist seeds for the _child's_ own `manage_tasks` list). The child returns a structured report (`Summary` / `Findings` / `Blockers` / `Paths`) plus a tools-used footer. Parent and child never share a `manage_tasks` list.
211211

212212
When profiles exist (local `.agents/agents/` and/or enabled **`kind: "agent"`** plugins, including **data-only** markdown plugins with no `index.ts`), the chat model also receives **`search_agents`** — a lexical index over profile id, description, and role text so the model can discover ids before calling `spawn_agent(agent=...)`. Results include each match's full loaded system prompt / body so the parent can inspect plugin or Claude marketplace agents without `read_file` on paths outside the session cwd (path-escape blocks those roots by design; writes remain blocked). `spawn_agent` and `search_agents` are core tools on the primary session.
213213

docs/IMPLEMENTATION.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ src/
8989
subagent/
9090
index.ts Sub-agent run exports + SubAgentDirector
9191
agent-fleet.ts spawn_agent / wait_agents fleet dispatch and mailbox tools
92+
admission.ts FIFO admission queue in front of worker run()
9293
session-store.ts Retained child session transcripts for observe UI
9394
identity-context.ts ALS: worker description + cwd for gate attribution
9495
config/

src/agent/directors/skywalker/package.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,10 @@ describe("skywalkerPackage", () => {
9191
expect(p).toContain("0–1 worker");
9292
expect(p).toContain("named, non-overlapping lanes");
9393
expect(p).not.toContain("2–4 workers");
94+
expect(p).not.toContain("at most 4");
95+
expect(p).not.toContain("Prefer synthesizing early returns");
96+
expect(p).toContain("queues excess");
97+
expect(p).toContain("Do not invent a numeric cap");
9498
});
9599

96100
test("systemPrompt prefers spawn_agent then wait_agents (idle-orchestrator)", () => {

src/agent/directors/skywalker/package.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,10 @@ When the operator (or brief) gives an http(s) URL to read:
6565
6666
# Effort scaling (IMPLEMENTATION / ORCHESTRATION)
6767
68-
Scale fan-out to the ask — no numeric worker ceiling pretends to enforce itself:
68+
Scale fan-out to the ask — the runtime queues excess rather than refusing:
6969
- Simple (answer, one-path lookup, tiny fix): 0–1 worker, few tools; often answer without fleet
7070
- Tiny single-file / one-route asks: **DIY on the parent** with write_file/edit_file; skip spawn, skip explorer, skip critic. Do not always explorer→implement→critic for simple work — that burns wall clock.
71-
- Multi-lane work: spawn only named, non-overlapping lanes (distinct path/package/ownership). Width follows the ask and clear non-overlap — not a soft numeric ladder.
72-
Prefer synthesizing early returns over launching a second wave.
71+
- Multi-lane work: spawn only named, non-overlapping lanes (distinct path/package/ownership). Width follows independent lanes; the runtime queues excess rather than refusing. Do not invent a numeric cap.
7372
7473
# Anti-cascade (stall / dig / diagnose)
7574

src/agent/retry-policy.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,4 +241,42 @@ describe("createCorbitsRetryPolicy", () => {
241241
current = "openai";
242242
expect(await policy(bare429)).toEqual({ kind: "abort" });
243243
});
244+
245+
test("retryable 429 notes admission pressure; quota_exhausted does not", async () => {
246+
const notes: { provider: string; until: number }[] = [];
247+
const admission = {
248+
enqueue: () => "running" as const,
249+
release: () => {},
250+
setCapacity: () => {},
251+
notePressure: (provider: string, untilMs: number) => {
252+
notes.push({ provider, until: untilMs });
253+
},
254+
cancel: () => {},
255+
};
256+
const policy = createCorbitsRetryPolicy({ providerId: "xai/thegreataxios", admission });
257+
const before = Date.now();
258+
await policy({
259+
attempt: 1,
260+
elapsedMs: 0,
261+
error: {
262+
category: "retryable",
263+
message: "Too Many Requests",
264+
retryAfterMs: 2_000,
265+
},
266+
});
267+
expect(notes).toHaveLength(1);
268+
expect(notes[0]!.provider).toBe("xai/thegreataxios");
269+
expect(notes[0]!.until).toBeGreaterThanOrEqual(before + 2_000);
270+
notes.length = 0;
271+
await policy({
272+
attempt: 1,
273+
elapsedMs: 0,
274+
error: {
275+
category: "quota_exhausted",
276+
message: "monthly cap",
277+
retryAfterMs: 86_400_000,
278+
},
279+
});
280+
expect(notes).toHaveLength(0);
281+
});
244282
});

src/agent/retry-policy.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@ import {
44
normalizeInferenceErrorForRetry,
55
type InferenceErrorWithGoContext,
66
} from "../inference-gateway-error.js";
7+
import { getProcessAdmissionQueue, type AdmissionQueue } from "../subagent/admission.js";
78

89
// Providers that enforce long-window quotas (e.g. monthly limits) set
910
// Retry-After to days or weeks. The default policy trusts that value and
1011
// schedules the next attempt accordingly — silently blocking the session
1112
// for the full duration. Abort instead and surface the error immediately
1213
// so the user can switch providers or decide when to retry manually.
13-
const MAX_BLIND_WAIT_MS = 30_000;
14+
export const MAX_BLIND_WAIT_MS = 30_000;
15+
const DEFAULT_PRESSURE_PAUSE_MS = 1_000;
1416

1517
export interface CorbitsRetryPolicyOptions {
1618
/**
@@ -19,6 +21,8 @@ export interface CorbitsRetryPolicyOptions {
1921
* (e.g. `/model`); it is resolved on each retry decision.
2022
*/
2123
providerId?: string | (() => string | undefined);
24+
/** Process admission controller. Tests inject a stub; production omits. */
25+
admission?: AdmissionQueue;
2226
}
2327

2428
/**
@@ -29,6 +33,7 @@ export interface CorbitsRetryPolicyOptions {
2933
*/
3034
export function createCorbitsRetryPolicy(options?: CorbitsRetryPolicyOptions): RetryPolicy {
3135
const defaultPolicy = createDefaultRetryPolicy();
36+
const admission = options?.admission ?? getProcessAdmissionQueue();
3237
return (situation: RetrySituation): RetryDecision | Promise<RetryDecision> => {
3338
const raw = options?.providerId;
3439
const stampedProviderId = typeof raw === "function" ? raw() : raw;
@@ -38,6 +43,11 @@ export function createCorbitsRetryPolicy(options?: CorbitsRetryPolicyOptions): R
3843
? { ...incoming, providerId: stampedProviderId }
3944
: incoming;
4045
const error = normalizeInferenceErrorForRetry(withProvider);
46+
if (error.category === "retryable") {
47+
const pauseMs = Math.min(error.retryAfterMs ?? DEFAULT_PRESSURE_PAUSE_MS, MAX_BLIND_WAIT_MS);
48+
const provider = withProvider.providerId ?? stampedProviderId ?? "unknown";
49+
admission.notePressure(provider, Date.now() + pauseMs);
50+
}
4151
if (
4252
error.category === "quota_exhausted" &&
4353
error.retryAfterMs !== undefined &&

src/subagent/agent-fleet.test.ts

Lines changed: 212 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import {
1111
MAX_FLEET_RECORDS,
1212
type AgentFleetDeps,
1313
} from "./agent-fleet.js";
14+
import { createAdmissionQueue } from "./admission.js";
15+
import { isLiveWaitStatus } from "./lifecycle.js";
1416
import {
1517
createInterruptAgentTool,
1618
createCloseAgentTool,
@@ -66,6 +68,7 @@ function makeDeps(
6668
run,
6769
sessions,
6870
fleetRecords: createFleetMailbox(sessions),
71+
admission: createAdmissionQueue({ capacity: Number.POSITIVE_INFINITY }),
6972
...(opts.settings !== undefined ? { settings: opts.settings } : {}),
7073
...(opts.catalog !== undefined ? { catalog: opts.catalog } : {}),
7174
...(opts.profiles !== undefined ? { profiles: opts.profiles } : {}),
@@ -80,7 +83,7 @@ function waitUntilMailboxTerminal(
8083
return new Promise((resolve) => {
8184
const done = (): boolean => {
8285
const snap = mailbox.peek(id);
83-
return snap !== undefined && snap.status !== "running";
86+
return snap !== undefined && !isLiveWaitStatus(snap.status);
8487
};
8588
if (done()) {
8689
resolve();
@@ -1390,3 +1393,211 @@ describe("spawn_agent dispatch contracts", () => {
13901393
expect(captured[0]!.nestedDispatch).toBeUndefined();
13911394
});
13921395
});
1396+
1397+
describe("admission queue", () => {
1398+
test("20 concurrent spawns admit or queue without error", async () => {
1399+
const gate = deferred<RunSubAgentResult>();
1400+
let started = 0;
1401+
const deps = makeDeps(async () => {
1402+
started += 1;
1403+
return gate.promise;
1404+
});
1405+
deps.admission = createAdmissionQueue({ capacity: 2 });
1406+
const spawn = createSpawnAgentTool(deps);
1407+
const results: { agent_id: string; status: string }[] = [];
1408+
for (let i = 0; i < 20; i++) {
1409+
const result = await callTool(spawn, {
1410+
description: `job-${i}`,
1411+
prompt: "do it",
1412+
intent: "explore",
1413+
});
1414+
results.push({ agent_id: result.agent_id as string, status: result.status as string });
1415+
}
1416+
expect(results).toHaveLength(20);
1417+
expect(results.every((r) => typeof r.agent_id === "string" && r.agent_id.length > 0)).toBe(
1418+
true,
1419+
);
1420+
expect(results.filter((r) => r.status === "running")).toHaveLength(2);
1421+
expect(results.filter((r) => r.status === "queued")).toHaveLength(18);
1422+
await new Promise((resolve) => setTimeout(resolve, 20));
1423+
expect(started).toBe(2);
1424+
1425+
const list = createListAgentsTool({
1426+
sessions: deps.sessions,
1427+
fleetRecords: deps.fleetRecords,
1428+
});
1429+
if (list.kind !== "full") throw new Error("expected full tool");
1430+
const raw = await list.handler(
1431+
{ id: "list-q", name: "list_agents", arguments: {} },
1432+
new AbortController().signal,
1433+
);
1434+
const content = typeof raw.content === "string" ? raw.content : JSON.stringify(raw.content);
1435+
const parsed = JSON.parse(content) as { agents: { status: string }[] };
1436+
expect(parsed.agents.filter((a) => a.status === "queued")).toHaveLength(18);
1437+
expect(parsed.agents.filter((a) => a.status === "running")).toHaveLength(2);
1438+
1439+
const queuedId = results.find((r) => r.status === "queued")!.agent_id;
1440+
const wait = createWaitAgentsTool({
1441+
sessions: deps.sessions,
1442+
fleetRecords: deps.fleetRecords,
1443+
});
1444+
const waited = await callTool(wait, { targets: [queuedId], timeout_ms: 50 });
1445+
expect(waited.timed_out).toBe(true);
1446+
const waitResults = waited.results as { agent_id: string; status: string }[];
1447+
expect(waitResults).toEqual([{ agent_id: queuedId, status: "queued" }]);
1448+
1449+
gate.resolve({ report: "ok" });
1450+
await Promise.all(
1451+
results.map((r) => waitUntilMailboxTerminal(deps.fleetRecords, deps.sessions, r.agent_id)),
1452+
);
1453+
});
1454+
1455+
test("nested children bypass a full window", async () => {
1456+
let started = 0;
1457+
const gate = deferred<RunSubAgentResult>();
1458+
const deps = makeDeps(async () => {
1459+
started += 1;
1460+
return gate.promise;
1461+
});
1462+
deps.admission = createAdmissionQueue({ capacity: 0 });
1463+
deps.parentSessionId = "parent-1";
1464+
const spawn = createSpawnAgentTool(deps);
1465+
const result = await callTool(spawn, {
1466+
description: "nested",
1467+
prompt: "do it",
1468+
intent: "explore",
1469+
});
1470+
expect(result.status).toBe("running");
1471+
await new Promise((resolve) => setTimeout(resolve, 20));
1472+
expect(started).toBe(1);
1473+
gate.resolve({ report: "ok" });
1474+
await waitUntilMailboxTerminal(deps.fleetRecords, deps.sessions, result.agent_id as string);
1475+
});
1476+
1477+
test("close_agent of a queued spawn does not start the run", async () => {
1478+
const gate = deferred<RunSubAgentResult>();
1479+
let started = 0;
1480+
const deps = makeDeps(async () => {
1481+
started += 1;
1482+
return gate.promise;
1483+
});
1484+
deps.admission = createAdmissionQueue({ capacity: 1 });
1485+
const spawn = createSpawnAgentTool(deps);
1486+
const first = await callTool(spawn, {
1487+
description: "holder",
1488+
prompt: "hold",
1489+
intent: "explore",
1490+
});
1491+
const queued = await callTool(spawn, {
1492+
description: "queued",
1493+
prompt: "wait",
1494+
intent: "explore",
1495+
});
1496+
expect(first.status).toBe("running");
1497+
expect(queued.status).toBe("queued");
1498+
await new Promise((resolve) => setTimeout(resolve, 20));
1499+
expect(started).toBe(1);
1500+
1501+
const close = createCloseAgentTool({
1502+
sessions: deps.sessions,
1503+
fleetRecords: deps.fleetRecords,
1504+
});
1505+
if (close.kind !== "full") throw new Error("expected full tool");
1506+
await close.handler(
1507+
{ id: "close-q", name: "close_agent", arguments: { target: queued.agent_id } },
1508+
new AbortController().signal,
1509+
);
1510+
await new Promise((resolve) => setTimeout(resolve, 20));
1511+
expect(started).toBe(1);
1512+
expect(deps.sessions.get(queued.agent_id as string)?.lifecycleStatus).toBe("shutdown");
1513+
expect(deps.fleetRecords.peek(queued.agent_id as string)?.status).toBe("interrupted");
1514+
1515+
gate.resolve({ report: "ok" });
1516+
await waitUntilMailboxTerminal(deps.fleetRecords, deps.sessions, first.agent_id as string);
1517+
});
1518+
1519+
test("sessions.cancel of a queued spawn makes wait_agents report interrupted, not queued", async () => {
1520+
const gate = deferred<RunSubAgentResult>();
1521+
let started = 0;
1522+
const deps = makeDeps(async () => {
1523+
started += 1;
1524+
return gate.promise;
1525+
});
1526+
deps.admission = createAdmissionQueue({ capacity: 1 });
1527+
const spawn = createSpawnAgentTool(deps);
1528+
const wait = createWaitAgentsTool({
1529+
sessions: deps.sessions,
1530+
fleetRecords: deps.fleetRecords,
1531+
});
1532+
const first = await callTool(spawn, {
1533+
description: "holder",
1534+
prompt: "hold",
1535+
intent: "explore",
1536+
});
1537+
const queued = await callTool(spawn, {
1538+
description: "queued",
1539+
prompt: "wait",
1540+
intent: "explore",
1541+
});
1542+
expect(first.status).toBe("running");
1543+
expect(queued.status).toBe("queued");
1544+
await new Promise((resolve) => setTimeout(resolve, 20));
1545+
expect(started).toBe(1);
1546+
const queuedId = queued.agent_id as string;
1547+
1548+
const startedAt = Date.now();
1549+
expect(deps.sessions.cancel(queuedId)).toBe(true);
1550+
const waited = await callTool(wait, { targets: [queuedId], timeout_ms: 200 });
1551+
const elapsed = Date.now() - startedAt;
1552+
expect(elapsed).toBeLessThan(200);
1553+
expect(waited.timed_out).toBe(false);
1554+
const results = waited.results as { agent_id: string; status: string }[];
1555+
expect(results).toEqual([{ agent_id: queuedId, status: "interrupted" }]);
1556+
expect(started).toBe(1);
1557+
1558+
gate.resolve({ report: "ok" });
1559+
await waitUntilMailboxTerminal(deps.fleetRecords, deps.sessions, first.agent_id as string);
1560+
});
1561+
1562+
test("start() threads fleet admission onto RunSubAgentParams", async () => {
1563+
let captured: RunSubAgentParams | undefined;
1564+
const deps = makeDeps(async (params) => {
1565+
captured = params;
1566+
return { report: "ok" };
1567+
});
1568+
const spawn = createSpawnAgentTool(deps);
1569+
const result = await callTool(spawn, {
1570+
description: "job",
1571+
prompt: "do it",
1572+
intent: "explore",
1573+
});
1574+
await waitUntilMailboxTerminal(deps.fleetRecords, deps.sessions, result.agent_id as string);
1575+
expect(captured?.admission).toBe(deps.admission);
1576+
});
1577+
1578+
test("lowering capacity does not cancel in-flight work", async () => {
1579+
const gate = deferred<RunSubAgentResult>();
1580+
let started = 0;
1581+
const admission = createAdmissionQueue({ capacity: 2 });
1582+
const deps = makeDeps(async () => {
1583+
started += 1;
1584+
return gate.promise;
1585+
});
1586+
deps.admission = admission;
1587+
const spawn = createSpawnAgentTool(deps);
1588+
const a = await callTool(spawn, { description: "a", prompt: "do it", intent: "explore" });
1589+
const b = await callTool(spawn, { description: "b", prompt: "do it", intent: "explore" });
1590+
await new Promise((resolve) => setTimeout(resolve, 20));
1591+
expect(started).toBe(2);
1592+
admission.setCapacity(0);
1593+
await new Promise((resolve) => setTimeout(resolve, 20));
1594+
expect(started).toBe(2);
1595+
expect(deps.sessions.get(a.agent_id as string)?.status).toBe("running");
1596+
expect(deps.sessions.get(b.agent_id as string)?.status).toBe("running");
1597+
gate.resolve({ report: "ok" });
1598+
await Promise.all([
1599+
waitUntilMailboxTerminal(deps.fleetRecords, deps.sessions, a.agent_id as string),
1600+
waitUntilMailboxTerminal(deps.fleetRecords, deps.sessions, b.agent_id as string),
1601+
]);
1602+
});
1603+
});

0 commit comments

Comments
 (0)