Skip to content

Commit d305255

Browse files
committed
Rename the wait overlay to FleetMailbox
Wait JSON is a projection of stored WorkerLifecycle plus a per-install mailbox. Drop leftover resolve/reject methods that no longer write a second terminal store. Keep the dependency field name fleetRecords so call-site churn stays a rename of the type and factory.
1 parent a051749 commit d305255

10 files changed

Lines changed: 61 additions & 85 deletions

docs/ARCHITECTURE.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ Every director package carries a required `tier: SubagentTier` field (`src/agent
219219
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:
220220

221221
- **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`, `list_agents`, `send_input`, `interrupt_agent`, `close_agent`, `resume_agent`, `read_agent_trace`, `search_agents`) so every mount site inherits the same gate. `list_agents` is the non-blocking mailbox-scoped list of this install's own `spawn_agent` workers (same scope as `wait_agents`); nested orchestrators may mount it. Fleet discovery (`search_agents`) remains Tier 1 only.
222-
- **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. Production call sites: `read_agent_trace`, `send_input`, `interrupt_agent`, `close_agent`, and `resume_agent`. Nested mounts pass `{actorId, tier, getNodes}` from `run.ts`; a missing `actorId` fails closed. Tier-1 primary omits authority and stays unrestricted. `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. `list_agents` reports that same mailbox without blocking. `interrupt_agent` / `send_input` with `interrupt:true` terminalize the wait mailbox immediately; the soft-interrupt wait path collects so a later followup cannot resurrect an already-observed interrupt. `close_agent` also terminalizes the wait mailbox before teardown.
222+
- **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. Production call sites: `read_agent_trace`, `send_input`, `interrupt_agent`, `close_agent`, and `resume_agent`. Nested mounts pass `{actorId, tier, getNodes}` from `run.ts`; a missing `actorId` fails closed. Tier-1 primary omits authority and stays unrestricted. `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 per-install wait mailbox over the shared session store, not every running session. Wait JSON is a projection of stored lifecycle plus mailbox membership/pin/collected/interrupt override — not a second terminal store. `list_agents` reports that same mailbox without blocking. `interrupt_agent` / `send_input` with `interrupt:true` terminalize the wait mailbox immediately; the soft-interrupt wait path collects so a later followup cannot resurrect an already-observed interrupt. `close_agent` also terminalizes the wait mailbox before teardown. Operator cancel (`cancel` / `cancelAll`) projects wait status `interrupted`.
223223
- `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.
224224

225225
#### Closed director fleet (`src/agent/directors/`)
@@ -288,6 +288,8 @@ Every shipped specialist is a **director package** — a prompt-first `DirectorP
288288

289289
**Session records** (`src/subagent/session-store.ts`): each spawn is retained as an inspectable child session (id, profile, description, brief, status, tool activity, transcript entries). Child events land only in this store — not in the parent chat transcript. Live progress still uses the light `onProgress` channel for the status bar. Completed sessions are capped (`maxCompleted`) so a long chat does not grow without bound.
290290

291+
**Wait mailbox** (`src/subagent/agent-fleet.ts` `FleetMailbox`): per-install overlay over that session store. Wait JSON is a projection of stored lifecycle plus mailbox membership, pin, collected, and optional interrupt override — not a second terminal store. Mailbox `register` pins an uncollected result (honored by prune); past `MAX_FLEET_RECORDS` the oldest never-collected pin is compacted to a tombstone. Operator cancel projects wait status `interrupted`.
292+
291293
**Observe (OpenTUI)**: `shell.ts:enterSubagentObserve` swaps the transcript for a child's stream (live while running, historical when done) without stealing the parent reactor; child events are mapped to stream rows by `src/tui/observe-map.ts`. Esc leaves observe and restores the parent transcript. Parent Esc/stop and `/clear` still call `cancelAll` so live children close (`agent.close`) instead of continuing after the parent stops. The host-injection point that resolves a live session (`onObserveRequest``observeSessionFromSubAgents`, `src/tui/runner-host.ts`, picking the newest running child else the most recent session of any status) is triggered by Alt+O (`shell.ts:observeActiveSubagent`) — the command palette action that used to call it is gone along with `src/tui/palette.ts` itself, but the chord replaces it rather than dropping the feature.
292294

293295
Data-only agent plugins (`src/plugins/data-only-agent.ts`) synthesize `agentPlugin.agents[]` from `agents/*.md` or flat `*.md` in the plugin directory, with optional co-located `skills/`. `loadPluginEntry` tries JS entrypoints first, then falls back to this layout (`/plugins` add-by-path supports filesystem completion via `listPathSuggestions`).

src/agent/tools.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ import {
4242
type SubAgentSessionStore,
4343
} from "../subagent/index.js";
4444
import {
45-
createFleetRecords,
45+
createFleetMailbox,
4646
createSpawnAgentTool,
4747
createWaitAgentsTool,
4848
createListAgentsTool,
@@ -329,7 +329,7 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise<AgentT
329329
const orchestratorTools: AgentTool[] = [];
330330
if (subAgentsEnabled && args.subAgent !== undefined) {
331331
const sa = args.subAgent;
332-
const fleetRecords = sa.sessions !== undefined ? createFleetRecords(sa.sessions) : undefined;
332+
const fleetRecords = sa.sessions !== undefined ? createFleetMailbox(sa.sessions) : undefined;
333333
orchestratorTools.push(
334334
createTaskTool({
335335
cwd,

src/subagent/agent-fleet.test.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, expect, test } from "bun:test";
22

33
import {
4-
createFleetRecords,
4+
createFleetMailbox,
55
createSpawnAgentTool,
66
createWaitAgentsTool,
77
createListAgentsTool,
@@ -58,7 +58,7 @@ function makeDeps(
5858
provider,
5959
run,
6060
sessions,
61-
fleetRecords: createFleetRecords(sessions),
61+
fleetRecords: createFleetMailbox(sessions),
6262
};
6363
}
6464

@@ -205,8 +205,8 @@ describe("spawn_agent + wait_agents", () => {
205205
test("reports survive well past the session store's display cap (20) until wait_agents collects them", async () => {
206206
// DEFAULT_MAX_COMPLETED on SubAgentSessionStore is 20 finished sessions;
207207
// spawn (and complete) enough workers to blow well past it before any of
208-
// them is collected, proving fleetRecords does not depend on the store's
209-
// cap.
208+
// them is collected, proving the wait mailbox pin keeps reports past the
209+
// store's display cap.
210210
//
211211
// CL-7007: this test previously asserted (as CL-7001's fix left it) that
212212
// the store itself had already evicted and released the earliest
@@ -215,8 +215,8 @@ describe("spawn_agent + wait_agents", () => {
215215
// ticket fixes (resume_agent failed with a bare
216216
// "not_found" past 20 spawned workers, blaming the caller for nothing).
217217
// Open retained sessions now have their own cap (`maxRetained`, default
218-
// 50), so 25 of them all stay resumable; fleetRecords/wait_agents is
219-
// still asserted below as the durable source of truth regardless.
218+
// 50), so 25 of them all stay resumable; mailbox pin + wait_agents is
219+
// still asserted below as the collect path regardless.
220220
const COUNT = 25;
221221
const deps = makeDeps(async () => ({ report: "irrelevant", agentRetained: true }));
222222
const spawn = createSpawnAgentTool(deps);
@@ -250,7 +250,7 @@ describe("spawn_agent + wait_agents", () => {
250250
});
251251

252252
// CL-6915: operator cancel aborts the child signal, but run() still returns a
253-
// salvage body (partial findings). Dropping that body left fleetRecords
253+
// salvage body (partial findings). Dropping that body left the wait mailbox
254254
// "running" forever so wait_agents never saw the salvage.
255255
test("cancelled spawn_agent still resolves wait_agents with salvage findings", async () => {
256256
const deps = makeDeps(async (params) => {
@@ -356,7 +356,7 @@ describe("spawn_agent same-cwd concurrency", () => {
356356
});
357357
});
358358

359-
describe("fleetRecords retention cap", () => {
359+
describe("wait mailbox session tombstone and pin", () => {
360360
test("many spawned-and-completed workers whose reports are never collected leave memory bounded", async () => {
361361
const COUNT = MAX_FLEET_RECORDS + 50;
362362
const deps = makeDeps(async () => ({ report: "x".repeat(1000) }));
@@ -838,7 +838,7 @@ describe("interrupt_agent unblocks wait_agents", () => {
838838

839839
test("soft-interrupt wait collects so a later followup cannot resurrect done", async () => {
840840
const sessions = createSubAgentSessionStore();
841-
const fleetRecords = createFleetRecords(sessions);
841+
const fleetRecords = createFleetMailbox(sessions);
842842
const worker = sessions.start({
843843
id: "soft-int",
844844
description: "looping",

src/subagent/agent-fleet.ts

Lines changed: 15 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,17 @@
1010
*
1111
* Running state is the session store's `WorkerLifecycle`. wait_agents blocks
1212
* on that store's `subscribe` raced against a timeout timer, never polling.
13-
* Wait JSON is a projection of stored lifecycle plus a per-install overlay
14-
* (`fleetRecords`): membership, pin, collected, and an optional wait-status
15-
* override. Spawn/resume settlement writes only the session store.
13+
* Wait JSON is a projection of stored lifecycle plus a per-install wait
14+
* mailbox (`FleetMailbox`): membership, pin, collected, and an optional
15+
* wait-status override. Spawn/resume settlement writes only the session store.
1616
*
1717
* The store's finished-session retention is a TUI display cap (`maxCompleted`,
1818
* default 20): `complete()`/`fail()` evict the oldest finished session —
1919
* report and all — once more than that many have finished. task() never hit
2020
* this because it awaits its own single result before the tool call returns;
2121
* here a caller can spawn far more workers than the cap in one turn and only
2222
* `wait_agents` them later, so an evicted report would otherwise vanish
23-
* silently. Overlay `register` pins the session (honored by pruneCompleted
23+
* silently. Mailbox `register` pins the session (honored by pruneCompleted
2424
* and pruneRetained) until collect unpins. Heavy payloads are still capped at
2525
* `MAX_FLEET_RECORDS`: past that, the oldest never-collected pin is compacted
2626
* to a tombstone (status only, plus a pointer at `read_agent_trace`).
@@ -114,31 +114,19 @@ const RECOVERY_HINT =
114114
export const MAX_FLEET_RECORDS = 200;
115115

116116
/**
117-
* Per-install wait overlay. Session lifecycle is the source of wait status
118-
* unless this overlay forces interrupted or has frozen a collected result.
119-
* See the module doc for pin/tombstone policy.
117+
* Per-install wait mailbox over the session store. Session lifecycle is the
118+
* source of wait status unless this overlay forces interrupted or has frozen
119+
* a collected result. See the module doc for pin/tombstone policy.
120120
*/
121-
class FleetRecords {
121+
class FleetMailbox {
122122
private readonly records = new Map<string, FleetOverlay>();
123-
private readonly listeners = new Set<() => void>();
124123
private readonly sessions: SubAgentSessionStore;
125124

126125
constructor(sessions: SubAgentSessionStore) {
127126
this.sessions = sessions;
128127
sessions?.subscribe(() => this.enforceCap());
129128
}
130129

131-
subscribe(listener: () => void): () => void {
132-
this.listeners.add(listener);
133-
return () => {
134-
this.listeners.delete(listener);
135-
};
136-
}
137-
138-
private notify(): void {
139-
for (const listener of this.listeners) listener();
140-
}
141-
142130
register(id: string): void {
143131
const existing = this.records.get(id);
144132
const alreadyPinned =
@@ -148,16 +136,6 @@ class FleetRecords {
148136
this.enforceCap();
149137
}
150138

151-
/**
152-
* Leftover dual-write. Settlement writes the session store; commit 3 deletes this.
153-
*/
154-
resolve(_id: string, _report: string): void {}
155-
156-
/**
157-
* Leftover dual-write. Settlement writes the session store; commit 3 deletes this.
158-
*/
159-
reject(_id: string, _error: string): void {}
160-
161139
/**
162140
* Overlay wait-status override so wait unblocks while the session may still
163141
* be running (send_input interrupt:true followup, close_agent teardown).
@@ -168,13 +146,11 @@ class FleetRecords {
168146
if (existing === undefined) return;
169147
if (existing.collected === true) {
170148
this.sessions?.wake();
171-
this.notify();
172149
return;
173150
}
174151
existing.forceInterrupted = true;
175152
this.sessions?.wake();
176153
this.enforceCap();
177-
this.notify();
178154
}
179155

180156
/**
@@ -188,7 +164,6 @@ class FleetRecords {
188164
if (existing.forceInterrupted !== true) return;
189165
delete existing.forceInterrupted;
190166
this.sessions?.wake();
191-
this.notify();
192167
}
193168

194169
ids(): string[] {
@@ -296,9 +271,9 @@ class FleetRecords {
296271
// One overlay per orchestrator install (shared by its spawn_agent and
297272
// wait_agents tool instances), not a module singleton — created in
298273
// createSpawnAgentTool and threaded to createWaitAgentsTool by the caller.
299-
export type FleetRecordsHandle = FleetRecords;
300-
export function createFleetRecords(sessions: SubAgentSessionStore): FleetRecordsHandle {
301-
return new FleetRecords(sessions);
274+
export type FleetMailboxHandle = FleetMailbox;
275+
export function createFleetMailbox(sessions: SubAgentSessionStore): FleetMailboxHandle {
276+
return new FleetMailbox(sessions);
302277
}
303278

304279
const SpawnAgentArgs = type({
@@ -403,7 +378,7 @@ export type AgentFleetDeps = SubAgentSandboxDeps & {
403378
provider: SubAgentProvider | (() => SubAgentProvider);
404379
run: (params: RunSubAgentParams) => Promise<RunSubAgentResult>;
405380
sessions: SubAgentSessionStore;
406-
fleetRecords: FleetRecordsHandle;
381+
fleetRecords: FleetMailboxHandle;
407382
/**
408383
* Session id of the caller that is mounting this spawn_agent. Nested
409384
* orchestrators pass their own worker id so close_agent can walk the tree.
@@ -833,10 +808,10 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool {
833808

834809
interface WaitAgentsDeps {
835810
sessions: SubAgentSessionStore;
836-
fleetRecords: FleetRecordsHandle;
811+
fleetRecords: FleetMailboxHandle;
837812
}
838813

839-
function isWaitTerminal(id: string, fleetRecords: FleetRecordsHandle): boolean {
814+
function isWaitTerminal(id: string, fleetRecords: FleetMailboxHandle): boolean {
840815
const record = fleetRecords.peek(id);
841816
return record !== undefined && record.status !== "running";
842817
}
@@ -850,7 +825,7 @@ function isWaitTerminal(id: string, fleetRecords: FleetRecordsHandle): boolean {
850825
*/
851826
async function waitForTerminal(
852827
sessions: SubAgentSessionStore,
853-
fleetRecords: FleetRecordsHandle,
828+
fleetRecords: FleetMailboxHandle,
854829
targets: readonly string[],
855830
timeoutMs: number,
856831
mode: "any" | "all",

0 commit comments

Comments
 (0)