Skip to content

Commit 3701024

Browse files
Unblock fleet waits on interrupt and scope them to the caller (#679)
* 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. * Require fleetRecords on interrupt and tighten fleet wait coverage * Clarify mode=all interrupt test comment timing * Unblock fleet waits when close_agent tears a worker down closeOne flips strip status to cancelled, which killed the soft-interrupt fallback that still required status === running. Terminalize fleetRecords on close (same owning layer as interrupt) and collect on the soft path so omitted re-waits do not re-deliver.
1 parent f96199f commit 3701024

11 files changed

Lines changed: 623 additions & 91 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` and `close_agent` terminalize 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: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -344,9 +344,9 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise<AgentT
344344
orchestratorTools.push(
345345
createSpawnAgentTool(fleetDeps),
346346
createWaitAgentsTool({ sessions: fleetSessions, fleetRecords }),
347-
createCloseAgentTool({ sessions: fleetSessions }),
347+
createCloseAgentTool({ sessions: fleetSessions, fleetRecords }),
348348
createResumeAgentTool({ sessions: fleetSessions }),
349-
createInterruptAgentTool({ sessions: fleetSessions }),
349+
createInterruptAgentTool({ sessions: fleetSessions, fleetRecords }),
350350
createFollowupTaskTool({ sessions: fleetSessions }),
351351
);
352352
}

0 commit comments

Comments
 (0)