Skip to content

Commit bb60de7

Browse files
committed
Unblock fleet waits on interrupt and scope them to the caller
wait_agents with omitted targets used the shared session store, so a parent could block on siblings and leftover workers. interrupt_agent never wrote the wait mailbox, so a wait after interrupt hung until timeout and the parent retried forever. Wait now uses this install's fleetRecords, interrupt terminalizes that mailbox immediately, nested spawn_agent records parentSessionId, and wait supports mode=all plus parent-turn abort.
1 parent 6dfeae5 commit bb60de7

9 files changed

Lines changed: 392 additions & 73 deletions

File tree

docs/ARCHITECTURE.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -195,11 +195,11 @@ Invocation: workflows are **not** top-level slash commands. Recipe definitions l
195195

196196
Three distinct concepts (do not conflate them):
197197

198-
| Concept | What it is | Surface |
199-
| ------------- | -------------------------------------------------------- | ------------------------------------------------------------------- |
200-
| **Agent** | A runtime entity with its own loop, tools, and context | Primary session or a spawned child |
201-
| **Task** | A checklist item owned by _one_ agent via `manage_tasks` | Local work plan — not a spawn |
202-
| **Sub-agent** | A short-lived child agent for one self-contained job | Spawned with the **`task`** tool (wire name kept for compatibility) |
198+
| Concept | What it is | Surface |
199+
| ------------- | -------------------------------------------------------- | --------------------------------------------------------- |
200+
| **Agent** | A runtime entity with its own loop, tools, and context | Primary session or a spawned child |
201+
| **Task** | A checklist item owned by _one_ agent via `manage_tasks` | Local work plan — not a spawn |
202+
| **Sub-agent** | A short-lived child agent for one self-contained job | Spawned with **`spawn_agent`** (or deprecated **`task`**) |
203203

204204
The **`task`** tool **spawns a sub-agent** on a separate inference source (tier/profile resolved from settings). 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.
205205

@@ -219,9 +219,9 @@ Every director package carries a required `tier: SubagentTier` field (`src/agent
219219

220220
Enforcement is runtime code at the existing tool-mount point, not prompt wording — this is the fix for four prior mechanisms (`writePaths`, `report.requiredSections`, a `--config` comment, the thrash matcher) that were documented-as-enforced while enforcing nothing:
221221

222-
- **Mount-time gate — live today, and fails closed.** `task-tool.ts` resolves the caller's tier at dispatch time — a closed director's `DirectorPackage.tier` — and forwards it as `RunSubAgentParams.orchestratorTier`. `runSubAgent` (`src/subagent/run.ts`) then calls `assertTierMayMountFleetVerb(tier, toolName)` (`src/subagent/authority.ts`) before installing `task` / `search_agents`, treating a **missing** `orchestratorTier` as `"leaf"` — deny, not skip. This is the case that matters most: a project-local or plugin `AgentProfile` with `orchestrator: true` is outside the closed director set and is **not** trusted with fleet verbs just because `orchestrator: true` is set — there is no profile-level opt-in today, so the mount always throws `FleetAuthorityError` for a profile-sourced orchestrator (CL-6942/CL-6944 can add one when a real caller needs it). `FLEET_VERBS` in `authority.ts` also names the not-yet-implemented verbs (`spawn_agent`, `wait_agents`, `list_agents`, `send_input`, `interrupt_agent`, `close_agent`, `resume_agent`, `read_agent_trace`, `followup_task`) so their future mount sites inherit the same gate.
223-
- **Subtree authority — a seam, not yet wired.** `assertCanTargetAgent(actor, targetId, nodes)` (`src/subagent/authority.ts`) implements the "root owns its tree; a child manages only its own descendants" rule (Tier 1 may target anyone, Tier 2 may target only its own descendants over the same `{id, parentSessionId}` shape `SubAgentSessionStore` already tracks, Tier 3 always fails closed) — but **it has no production call site yet**. No verb today lets one live agent address another (`task` only spawns), so this rule is exercised only by `authority.test.ts` and is not enforced at runtime in this PR. It exists so CL-6942 (split spawn from wait) and CL-6944 (`send_input` steering) — the first verbs that make an agent addressable by another — can call it from day one instead of each inventing its own check. Treat it as unenforced until one of those wires a call site.
224-
- `task()` is unaffected and remains the only spawn verb until the new verbs land beside it (deprecated-not-deleted per the CL-6940 epic). Its argument schema and wire contract are unchanged; the tier check only gates which packages may have it mounted at all.
222+
- **Mount-time gate — live today, and fails closed.** `task-tool.ts` resolves the caller's tier at dispatch time — a closed director's `DirectorPackage.tier` — and forwards it as `RunSubAgentParams.orchestratorTier`. `runSubAgent` (`src/subagent/run.ts`) then calls `assertTierMayMountFleetVerb(tier, toolName)` (`src/subagent/authority.ts`) before installing fleet verbs, treating a **missing** `orchestratorTier` as `"leaf"` — deny, not skip. This is the case that matters most: a project-local or plugin `AgentProfile` with `orchestrator: true` is outside the closed director set and is **not** trusted with fleet verbs just because `orchestrator: true` is set — there is no profile-level opt-in today, so the mount always throws `FleetAuthorityError` for a profile-sourced orchestrator. `FLEET_VERBS` in `authority.ts` names the live verbs (`task`, `spawn_agent`, `wait_agents`, `interrupt_agent`, `close_agent`, `resume_agent`, `followup_task`, `read_agent_trace`, `search_agents`) plus reserved names (`list_agents`, `send_input`) so a later mount site inherits the same gate.
223+
- **Subtree authority — wired for addressing verbs.** `assertCanTargetAgent(actor, targetId, nodes)` implements the "root owns its tree; a child manages only its own descendants" rule over the `{id, parentSessionId}` shape `SubAgentSessionStore` already tracks. `read_agent_trace` is a production call site. `spawn_agent` records `parentSessionId` on nested workers so `close_agent`'s descendant walk can see them. `wait_agents` with omitted targets waits only on that caller's own `fleetRecords`, not every running session in the shared store. `interrupt_agent` terminalizes the wait mailbox immediately.
224+
- `task()` remains the deprecated fused spawn+wait fallback. `spawn_agent` + `wait_agents` is the supported parallel path. The tier check still gates which packages may mount any fleet verb.
225225

226226
#### Closed director fleet (`src/agent/directors/`)
227227

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,8 @@ describe("skywalkerPackage", () => {
102102
expect(p).toContain("wait_agents");
103103
expect(p).toContain("Idle-orchestrator");
104104
expect(p).toContain("deprecated fused spawn+wait");
105+
expect(p).toContain('mode="all"');
106+
expect(p).toContain("uncollected spawns");
105107
expect(p).not.toContain("Present the plan when the change is large or ambiguous");
106108
});
107109

src/agent/directors/skywalker/package.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ You do not do the specialists' jobs by default. For tiny bounded product edits,
1616
1717
Do not run long-blocking jobs on the parent (evals, full test suites, long installs, long-running implementation). Dispatch intern (mechanical shell), tester (suite / repro), or builder (substantial code). Path tools (write_file/edit_file/delete_file) are the DIY surface; shell file-writes stay denied.
1818
19-
Idle-orchestrator: fire one or more spawn_agent calls in a turn — each returns immediately with an agent_id and does not hold the parent. Then **reply to the operator** with who is running and what happens next before you block. Prefer ending that turn (or calling wait_agents with a short timeout_ms) so Enter can land; do not immediately fuse into a long wait_agents / task() right after spawn. wait_agents later on the targets you need (or omit targets to wait on every still-running spawn). task() still fuses spawn+wait and holds the parent until that one worker finishes. Enter mid-run delivers at the next parent tool.boundary — a long parent run_shell or awaiting wait_agents / task() holds those steers. A bare spawn_agent does not.
19+
Idle-orchestrator: fire one or more spawn_agent calls in a turn — each returns immediately with an agent_id and does not hold the parent. Then **reply to the operator** with who is running and what happens next before you block. Prefer ending that turn (or calling wait_agents with a short timeout_ms) so Enter can land; do not immediately fuse into a long wait_agents / task() right after spawn. wait_agents later on the targets you need (or omit targets to wait on this session's own uncollected spawns — never a sibling's). Use mode="all" when you need every target to finish; interrupt_agent unblocks wait_agents immediately. A timeout means still running — do not tight-loop wait_agents hoping for a different answer. task() still fuses spawn+wait and holds the parent until that one worker finishes. Enter mid-run delivers at the next parent tool.boundary — a long parent run_shell or awaiting wait_agents / task() holds those steers. A bare spawn_agent does not.
2020
2121
# Operator updates (mandatory while fleet is live)
2222

src/agent/tools.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -346,7 +346,7 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise<AgentT
346346
createWaitAgentsTool({ sessions: fleetSessions, fleetRecords }),
347347
createCloseAgentTool({ sessions: fleetSessions }),
348348
createResumeAgentTool({ sessions: fleetSessions }),
349-
createInterruptAgentTool({ sessions: fleetSessions }),
349+
createInterruptAgentTool({ sessions: fleetSessions, fleetRecords }),
350350
createFollowupTaskTool({ sessions: fleetSessions }),
351351
);
352352
}

src/subagent/agent-fleet.test.ts

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
MAX_FLEET_RECORDS,
88
type AgentFleetDeps,
99
} from "./agent-fleet.js";
10+
import { createInterruptAgentTool } from "./lifecycle-tools.js";
1011
import { createSubAgentSessionStore } from "./session-store.js";
1112
import { createPermissionGate } from "../permission/gate.js";
1213
import { forcedStopReport } from "./stop-policy.js";
@@ -374,3 +375,200 @@ describe("fleetRecords retention cap", () => {
374375
expect(results[0]!.hint).toContain("read_agent_trace");
375376
});
376377
});
378+
379+
describe("spawn_agent parentage", () => {
380+
test("records the caller session as parentSessionId", async () => {
381+
const gate = deferred<RunSubAgentResult>();
382+
const deps = makeDeps(async () => gate.promise);
383+
deps.parentSessionId = "parent-orch";
384+
const spawn = createSpawnAgentTool(deps);
385+
386+
const spawned = await callTool(spawn, {
387+
description: "child",
388+
prompt: "do it",
389+
intent: "explore",
390+
});
391+
const session = deps.sessions.get(spawned.agent_id as string);
392+
expect(session?.parentSessionId).toBe("parent-orch");
393+
394+
gate.resolve({ report: "done" });
395+
});
396+
});
397+
398+
describe("wait_agents caller scope", () => {
399+
test("omitted targets wait only on this fleet, not every running session in the shared store", async () => {
400+
const gate = deferred<RunSubAgentResult>();
401+
const deps = makeDeps(async () => gate.promise);
402+
const foreign = deps.sessions.start({
403+
id: "foreign-sibling",
404+
description: "someone else's worker",
405+
agentId: "explorer",
406+
brief: "b",
407+
});
408+
deps.sessions.markRunning(foreign.id);
409+
410+
const spawn = createSpawnAgentTool(deps);
411+
const wait = createWaitAgentsTool({
412+
sessions: deps.sessions,
413+
fleetRecords: deps.fleetRecords,
414+
});
415+
const spawned = await callTool(spawn, {
416+
description: "mine",
417+
prompt: "do it",
418+
intent: "explore",
419+
});
420+
421+
const waited = await callTool(wait, { timeout_ms: 50 });
422+
expect(waited.timed_out).toBe(true);
423+
const results = waited.results as { agent_id: string; status: string }[];
424+
expect(results.map((r) => r.agent_id)).toEqual([spawned.agent_id as string]);
425+
expect(results.every((r) => r.agent_id !== foreign.id)).toBe(true);
426+
427+
gate.resolve({ report: "done" });
428+
});
429+
430+
test("mode=all stays blocked until every target is terminal", async () => {
431+
const gates = [deferred<RunSubAgentResult>(), deferred<RunSubAgentResult>()];
432+
let callIndex = 0;
433+
const deps = makeDeps(async () => gates[callIndex++]!.promise);
434+
const spawn = createSpawnAgentTool(deps);
435+
const wait = createWaitAgentsTool({
436+
sessions: deps.sessions,
437+
fleetRecords: deps.fleetRecords,
438+
});
439+
440+
const first = await callTool(spawn, {
441+
description: "a",
442+
prompt: "do it",
443+
intent: "explore",
444+
});
445+
const second = await callTool(spawn, {
446+
description: "b",
447+
prompt: "do it",
448+
intent: "explore",
449+
});
450+
const ids = [first.agent_id as string, second.agent_id as string];
451+
452+
gates[0]!.resolve({ report: "a done" });
453+
const partial = await callTool(wait, { targets: ids, mode: "all", timeout_ms: 50 });
454+
expect(partial.timed_out).toBe(true);
455+
const partialResults = partial.results as { status: string }[];
456+
expect(partialResults.some((r) => r.status === "running")).toBe(true);
457+
458+
gates[1]!.resolve({ report: "b done" });
459+
const finished = await callTool(wait, { targets: ids, mode: "all", timeout_ms: 5000 });
460+
expect(finished.timed_out).toBe(false);
461+
const finishedResults = finished.results as { status: string }[];
462+
expect(finishedResults.every((r) => r.status === "done")).toBe(true);
463+
});
464+
465+
test("aborting the wait returns without cancelling workers", async () => {
466+
const gate = deferred<RunSubAgentResult>();
467+
const deps = makeDeps(async () => gate.promise);
468+
const spawn = createSpawnAgentTool(deps);
469+
const wait = createWaitAgentsTool({
470+
sessions: deps.sessions,
471+
fleetRecords: deps.fleetRecords,
472+
});
473+
const spawned = await callTool(spawn, {
474+
description: "slow",
475+
prompt: "do it",
476+
intent: "explore",
477+
});
478+
const id = spawned.agent_id as string;
479+
480+
if (wait.kind !== "full") throw new Error("expected full tool");
481+
const ac = new AbortController();
482+
const started = Date.now();
483+
const pending = wait.handler(
484+
{ id: "wait-1", name: "wait_agents", arguments: { targets: [id], timeout_ms: 5000 } },
485+
ac.signal,
486+
);
487+
ac.abort();
488+
const result = await pending;
489+
expect(Date.now() - started).toBeLessThan(500);
490+
const content =
491+
typeof result.content === "string" ? result.content : JSON.stringify(result.content);
492+
const parsed = JSON.parse(content) as {
493+
timed_out: boolean;
494+
results: { status: string }[];
495+
};
496+
expect(parsed.timed_out).toBe(true);
497+
expect(parsed.results[0]!.status).toBe("running");
498+
expect(deps.sessions.get(id)?.status).toBe("running");
499+
500+
gate.resolve({ report: "done" });
501+
});
502+
});
503+
504+
describe("interrupt_agent unblocks wait_agents", () => {
505+
test("interrupt marks the fleet record terminal so wait returns without the run settling", async () => {
506+
const gate = deferred<RunSubAgentResult>();
507+
const deps = makeDeps(async (params) => {
508+
params.onAgentReady?.({
509+
close: async () => {},
510+
interrupt: () => {},
511+
followup: async () => "",
512+
});
513+
return gate.promise;
514+
});
515+
const spawn = createSpawnAgentTool(deps);
516+
const wait = createWaitAgentsTool({
517+
sessions: deps.sessions,
518+
fleetRecords: deps.fleetRecords,
519+
});
520+
const interrupt = createInterruptAgentTool({
521+
sessions: deps.sessions,
522+
fleetRecords: deps.fleetRecords,
523+
});
524+
525+
const spawned = await callTool(spawn, {
526+
description: "looping",
527+
prompt: "do it",
528+
intent: "explore",
529+
});
530+
const id = spawned.agent_id as string;
531+
532+
const waiting = callTool(wait, { targets: [id], timeout_ms: 5000 });
533+
if (interrupt.kind !== "full") throw new Error("expected full tool");
534+
await interrupt.handler(
535+
{ id: "int-1", name: "interrupt_agent", arguments: { target: id } },
536+
new AbortController().signal,
537+
);
538+
539+
const waited = await waiting;
540+
expect(waited.timed_out).toBe(false);
541+
const results = waited.results as { agent_id: string; status: string }[];
542+
expect(results).toEqual([{ agent_id: id, status: "interrupted" }]);
543+
expect(deps.sessions.get(id)?.lifecycleStatus).toBe("interrupted");
544+
expect(deps.sessions.get(id)?.status).toBe("running");
545+
});
546+
547+
test("an interrupted run result terminalizes a still-running fleet record", async () => {
548+
const settle = deferred<RunSubAgentResult>();
549+
const deps = makeDeps(async () => settle.promise);
550+
const spawn = createSpawnAgentTool(deps);
551+
const wait = createWaitAgentsTool({
552+
sessions: deps.sessions,
553+
fleetRecords: deps.fleetRecords,
554+
});
555+
556+
const spawned = await callTool(spawn, {
557+
description: "looping",
558+
prompt: "do it",
559+
intent: "explore",
560+
});
561+
const id = spawned.agent_id as string;
562+
563+
settle.resolve({
564+
report: "## Summary\nStopped.\n## Findings\npartial\n## Blockers\ninterrupted\n## Paths\n",
565+
interrupted: true,
566+
});
567+
568+
const waited = await callTool(wait, { targets: [id], timeout_ms: 5000 });
569+
expect(waited.timed_out).toBe(false);
570+
const results = waited.results as { status: string; report?: string }[];
571+
expect(results[0]!.status).toBe("interrupted");
572+
expect(results[0]!.report).toContain("partial");
573+
});
574+
});

0 commit comments

Comments
 (0)