diff --git a/CHANGELOG.md b/CHANGELOG.md index 146139d0e..1be3ab30d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,15 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename ## [Unreleased] +### Added + +- Occupancy delivers mailbox mail as system inbound when a worker finishes or + fails, including while siblings still run. Skywalker spawn-then-idle; do not + poll `wait_agents`. Nested orchestrators still collect with `wait_agents`. + TUI-primary `wait_agents` yields as a timeout (workers untouched) when a + queued Enter steer or uncollected mail/ask is ready. Already-collected waits + return status without a second report body. + ### Changed - `search_agents` default results are id, description, and spawn metadata. diff --git a/README.md b/README.md index 69bfc9f3d..05e6f8f86 100644 --- a/README.md +++ b/README.md @@ -152,8 +152,9 @@ Details live in `docs/PRODUCT.md` (safety model) and `docs/ARCHITECTURE.md` Corbits Code is a single-process CLI built on Interchange primitives. The primary session is always the **orchestrator** (Skywalker): it can act directly and -delegates substantial work through a closed director fleet via `spawn_agent`, -`wait_agents`, and `search_agents`. +delegates substantial work through a closed director fleet via `spawn_agent` +then idle (mailbox mail inbound), `search_agents`, and optional `wait_agents` +for nested orchestrators. ``` CLI (src/index.ts) @@ -162,7 +163,7 @@ CLI (src/index.ts) → create agent with ChatDirector, posix tools, permission gate → mount plugins, MCP, hooks, skills → primary orchestrator turn - ↳ spawn_agent / wait_agents → closed directors (builder, explorer, …) + ↳ spawn_agent then idle → mailbox mail inbound → closed directors (builder, explorer, …) → event stream → OpenTUI host (TUI) or stdout (exec) ``` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b532f898d..44ccdb55c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -112,12 +112,12 @@ In TUI chat mode there is no completion gate — the session stays open across t Two directors, selected by role: -- **ChatDirector** (interactive, `src/agent/director.ts`) — Extends `DefaultDirector` with task list tracking, workflow nudges, LSP auto-activation, and multi-turn chat semantics. It never terminates the session: operator declines are surfaced as replies and the reactor stays alive for the next message. Yielding while a live fleet is running is allowed (idle-with-fleet); the open-task nudge does not rewrite that wait/reply. When the fleet goes dry with tasks still todo/doing, the TUI runtime re-enters the parent with collected worker reports rather than settling idle. +- **ChatDirector** (interactive, `src/agent/director.ts`) — Extends `DefaultDirector` with task list tracking, workflow nudges, LSP auto-activation, and multi-turn chat semantics. It never terminates the session: operator declines are surfaced as replies and the reactor stays alive for the next message. Yielding while a live fleet is running is allowed (idle-with-fleet); the open-task nudge does not rewrite that wait/reply. When a worker finishes or fails while the parent is idle (including idle-with-fleet), occupancy delivers mailbox mail as system inbound so Skywalker starts a new turn without polling `wait_agents`. When the fleet goes dry with tasks still todo/doing, the TUI runtime re-enters the parent with collected worker reports rather than settling idle. Auto mode is toggled by CLI flags (`--auto` / `--no-auto`); there is currently no in-session key to toggle it (default on; constrained envelope — workspace writes and unconstrained shell auto-allow; installs, recursive rm, force/uncontained worktree changes, sensitive-path and opaque-wrapper shell still ask; contained non-force `git worktree add`/`remove`/`prune` and `list` auto-allow; shell file-mutation denied). It is not a separate edit/plan mode. - **SubAgentDirector** (delegated work, `src/subagent/index.ts`) — Drives a dispatched worker until a turn arrives with no tool calls, then replies with the final assistant text and ends the run. A tool-less turn **after tools** completes only with the four-heading envelope (Summary, Findings, Blockers, Paths); a missing envelope nudges once (**incomplete-report**) and a second tool-less turn still without the envelope salvages as **incomplete-report-stop**. Explore/read-only workers that used tools then replied with findings remain normal completes; `requireEvidence` (off by default, set per director) additionally requires at least one read before a tool-less spawn-only reply can complete. Reads done through `run_shell` count as evidence too — `src/subagent/shell-evidence.ts` classifies shell reads (`cat`, `grep`, `sed` without `-i`, …) over the same subject expansion the auto-shell policy uses — but there is no corresponding shell-write evidence or file-write requirement: a run that never touches a file still completes normally once it replies with the envelope. There is no turn budget. Operator/parent cancel after any progress returns a **cancelled** salvage report (partial findings + tool activity) instead of a bare cancel string; cancel before progress still surfaces as cancelled-by-operator. There is no repetition/no-progress/never-acted/never-edited hard stop and no fingerprint-based re-dispatch block — a genuinely stuck worker runs until it completes, stalls, hits an opt-in wall-clock deadline, or is cancelled. - `spawn_agent` starts each worker and records it in the caller's fleet mailbox; `wait_agents` collects terminal reports from that mailbox. Wait JSON includes `stop_reason` from the session when present so a salvage that is wait-`done` is not mistaken for a clean complete, and so parent-initiated interrupt (`interrupted`) is not mistaken for operator-cancel (`cancelled`). Deadline salvage prepends an advisory parent hint suggesting continuation plus a longer deadline if more wall-clock time is warranted. Failed and incomplete-report salvage tell the parent to diagnose from the report or error and MAY spawn one successor with a changed brief. A parent-initiated interrupt is a resumable pause: wait unblocks with `stop_reason: interrupted` (often while the session is still running and has no report); the parent should `resume_agent` or re-wait, and must not spawn a successor against a still-live worker. Successor only if that session is no longer resumable. Operator-cancelled salvage asks the parent to synthesize Findings and Paths and wait for the operator instead of auto-starting another specialist. Identical re-dispatch of the same brief stays refused at the prompt / spawn-handoff layer; there is no fingerprint-based re-dispatch hard-block. Deadline hints are advisory only — an identical re-dispatch is still admitted at runtime. Parent hints are prepended on salvage reports returned to the parent. The runtime does not auto-spawn successors. + `spawn_agent` starts each worker and records it in the caller's fleet mailbox. On the TUI primary, mailbox mail is the collect path: occupancy takes uncollected terminals and re-enters the parent as system inbound. Nested orchestrators still collect with `wait_agents`. TUI-primary `wait_agents` may yield as a timeout (workers untouched, no take) so occupancy can deliver mail or a queued Enter steer. Already-collected waits return status without a second report or error body. Wait JSON includes `stop_reason` from the session when present so a salvage that is wait-`done` is not mistaken for a clean complete, and so parent-initiated interrupt (`interrupted`) is not mistaken for operator-cancel (`cancelled`). Deadline salvage prepends an advisory parent hint suggesting continuation plus a longer deadline if more wall-clock time is warranted. Failed and incomplete-report salvage tell the parent to diagnose from the report or error and MAY spawn one successor with a changed brief. A parent-initiated interrupt is a resumable pause: wait unblocks with `stop_reason: interrupted` (often while the session is still running and has no report); the parent should `resume_agent` or re-wait, and must not spawn a successor against a still-live worker. Successor only if that session is no longer resumable. Operator-cancelled salvage asks the parent to synthesize Findings and Paths and wait for the operator instead of auto-starting another specialist. Identical re-dispatch of the same brief stays refused at the prompt / spawn-handoff layer; there is no fingerprint-based re-dispatch hard-block. Deadline hints are advisory only — an identical re-dispatch is still admitted at runtime. Parent hints are prepended on salvage reports returned to the parent. The runtime does not auto-spawn successors. #### Model-family policy (`src/agent/model-family-policy.ts`) @@ -204,15 +204,15 @@ Invocation: workflows are **not** top-level slash commands. Recipe definitions l Three distinct concepts (do not conflate them): -| Concept | What it is | Surface | -| --------------- | -------------------------------------------------------- | ---------------------------------------------------------------- | -| **Agent** | A runtime entity with its own loop, tools, and context | Primary session or a spawned child | -| **Task** | A checklist item owned by _one_ agent via `manage_tasks` | Local work plan — not a spawn | -| **Fleet agent** | A short-lived worker for one self-contained job | Spawned with **`spawn_agent`**, collected with **`wait_agents`** | +| Concept | What it is | Surface | +| --------------- | -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| **Agent** | A runtime entity with its own loop, tools, and context | Primary session or a spawned child | +| **Task** | A checklist item owned by _one_ agent via `manage_tasks` | Local work plan — not a spawn | +| **Fleet agent** | A short-lived worker for one self-contained job | Spawned with **`spawn_agent`**; primary mailbox mail arrives as inbound; nested orchestrators collect with **`wait_agents`** | -The **`spawn_agent`** tool starts a fleet 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 of 8 is race-avoidance so a 429 freeze can fire before a herd — not a declared-spawn cap). Occupancy is the whole first `run()`, including `wait_agents`. Nested children of an already-admitted parent bypass **capacity** so a nested orchestrator cannot deadlock while holding a slot; they still wait on a provider 429 pause. Drain is FIFO among currently admissible jobs (a paused provider is skipped, not head-of-line for every provider). Resume and followup inference re-enter the same queue. Queued workers report wait/list status `queued` (live, not failed). Lowering capacity never cancels in-flight work. Retryable 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). Implement/review dispatches (and their default directors) fail closed without non-empty `success_criteria`. The child returns a structured report (`Summary` / `Findings` / `Blockers` / `Paths`) plus a tools-used footer. Parent and child never share a `manage_tasks` list. +The **`spawn_agent`** tool starts a fleet agent on a separate inference source (tier/profile resolved from settings) and returns immediately with an `agent_id`. On the TUI primary, Skywalker idles after spawn and occupancy delivers mailbox mail as system inbound when a worker finishes or fails (including while siblings still run). Nested orchestrators still collect with **`wait_agents`**. Declared fan-out is unlimited: excess dispatches enqueue rather than fail. `run()` is admitted by `src/subagent/admission.ts` (default burst window of 8 is race-avoidance so a 429 freeze can fire before a herd — not a declared-spawn cap). Occupancy is the whole first `run()`, including `wait_agents`. Nested children of an already-admitted parent bypass **capacity** so a nested orchestrator cannot deadlock while holding a slot; they still wait on a provider 429 pause. Drain is FIFO among currently admissible jobs (a paused provider is skipped, not head-of-line for every provider). Resume and followup inference re-enter the same queue. Queued workers report wait/list status `queued` (live, not failed). Lowering capacity never cancels in-flight work. Retryable 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). Implement/review dispatches (and their default directors) fail closed without non-empty `success_criteria`. The child returns a structured report (`Summary` / `Findings` / `Blockers` / `Paths`) plus a tools-used footer. Parent and child never share a `manage_tasks` list. -Workers ask the spawning parent with **`ask_director`** (not the human). That parks a question while the worker stays `running`. **`wait_agents`** returns `awaiting_director` with a question payload — that is not terminal. The parent answers with **`send_input`**, then **`wait_agents`** again. Escalate to the human with **`ask_operator`** only when the parent cannot resolve it. +Workers ask the spawning parent with **`ask_director`** (not the human). That parks a question while the worker stays `running`. On the TUI primary that arrives as an idle-send wake (and yields an in-flight `wait_agents` as a timeout without the question payload). Nested **`wait_agents`** still returns `awaiting_director` with a question payload — that is not terminal. The parent answers with **`send_input`**, then continues. Escalate to the human with **`ask_operator`** only when the parent cannot resolve it. When the parent TUI is not blocked in `wait_agents`, the runner publishes an authoritative snapshot of currently pending top-level questions on each store notification, including empty snapshots before fleet-count updates. During synchronous session rotation, a runner-owned barrier suppresses both publications before delivery-generation invalidation, transcript clearing, and worker cancellation; successful reset reconciles a fresh snapshot before resuming asynchronous backend rebuild. The bridge drops resolved, cancelled, replaced, terminal, and removed asks and delivers each session/question identity once while pending. A coalesced wake starts only when the parent is not processing and every operator gate is closed, including parent-idle fleet holds where the shell stays busy. Worker gates do not manufacture parent processing. Replies use `send_input`'s `target` field with the worker session ID, never its shared catalog ID. Synthetic wakes use `SessionPort.deliver` through queued-delivery's idle-send path without entering the user follow-up queue or composer `/feedback` capture. diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index fecbe76e4..556ff49b8 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -86,6 +86,8 @@ src/ subagent/ index.ts Sub-agent run exports + SubAgentDirector agent-fleet.ts spawn_agent / wait_agents fleet dispatch and mailbox tools + mailbox-mail-drive.ts per-item mailbox mail occupancy (primary inbound) + fleet-dry-drive.ts fleet-0 + open-tasks occupancy continuation admission.ts Burst-window admission in front of worker run() session-store.ts Retained child session transcripts for observe UI identity-context.ts ALS: worker description + cwd for gate attribution @@ -194,7 +196,7 @@ Unmatched shell auto-allows, including contained non-force `git worktree add`/`r - **Alt+Enter** queues a follow-up (kind `"queue"`) delivered only on **session-idle** — parent-idle **and** no live fleet lanes (`run` goes idle). Session-idle Alt+Enter is a no-op. **Ctrl+C** stops the run. -Idle-with-fleet is shipped: after a non-blocking `spawn_agent` dispatch the parent turn can settle while workers keep running. The runner emits a `fleet` event carrying the live-lane count; the bridge holds the run busy on that count, so mid-hold Enter upgrades to a new primary turn (sent immediately) instead of queueing a steer, follow-ups keep waiting for true session-idle, and any steer left pending at the hold's engagement delivers immediately — the parent it was steering has already stopped. +Idle-with-fleet is shipped: after a non-blocking `spawn_agent` dispatch the parent turn can settle while workers keep running. The runner emits a `fleet` event carrying the live-lane count; the bridge holds the run busy on that count, so mid-hold Enter upgrades to a new primary turn (sent immediately) instead of queueing a steer, follow-ups keep waiting for true session-idle, and any steer left pending at the hold's engagement delivers immediately — the parent it was steering has already stopped. While the hold is up and the parent is not processing, occupancy flushes mailbox mail (`driveMailboxMail` + `buildMailboxMailMessage`) on store subscribe and idle-with-fleet settle — one child done while siblings run is enough. Skip that shot when a fleet-dry open-task continuation is latched. TUI-primary `wait_agents` gets `shouldYieldWait` (queued steer or uncollected mail/ask) and finishes as a timeout without taking workers. `src/tui/stream-event-map.ts` maps reactor events onto the bridge's inbound events, and `src/tui/turn-state.ts` tracks the turn's status. `src/tui/turns-to-blocks.ts` hydrates a resumed session's stored turns into the same content blocks. diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index d9121a1a7..1c67b3396 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -35,14 +35,14 @@ The evidence is in how the product fails today: the personas already produce exc ## Key Value Propositions 1. **Deterministic progress** — Every turn must produce a tool call. No idle thinking; the director aborts a stalled run rather than spinning. -2. **Task tracking** — The agent can maintain a `manage_tasks` checklist for multi-step work; non-interactive `submit_output` is blocked while checklist items remain open. (A "task" here is a work item, not a child agent — spawning uses the separate `spawn_agent` / `wait_agents` fleet-agent surface.) +2. **Task tracking** — The agent can maintain a `manage_tasks` checklist for multi-step work; non-interactive `submit_output` is blocked while checklist items remain open. (A "task" here is a work item, not a child agent — spawning uses the separate `spawn_agent` fleet-agent surface.) 3. **Stall detection** — The director detects idle cycles and intervenes. 4. **Safe by default** — Consequential actions (writes, edits, shell) pass a permission gate; secret files and catastrophic commands are denied outright, regardless of intent. 5. **Resume capability** — Runs persist to a git-backed store and resume from the last point after interruption. 6. **Legible loop** — A live event log, working-tree diff panel, plan tracker, and real-time cost meter show what happened, when, and why. 7. **Operator-in-the-loop** — The agent can call `ask_operator` to pause and ask a clarifying question; the operator answers from a modal (TUI) or via stdin when the product agent runs under `corbits exec`. -8. **Mid-run steering** — Two modes while the agent is running, keyed to **whose** idle. **Parent-idle** is when the primary Skywalker turn is not inside an in-flight parent tool; **session-idle** is parent-idle **and** no live fleet lanes. **Enter** soft-steers while the parent is busy — delivers at the next **parent** `tool.boundary` without stopping the current run; a long parent `run_shell` or an awaiting `wait_agents` is parent-busy, so Enter is a queued steer, not a new turn. Idle-with-fleet is shipped: after a non-blocking `spawn_agent` dispatch the parent goes idle while workers keep running, and mid-hold Enter starts a new primary turn instead of queueing a steer. **Alt+Enter** queues a follow-up delivered only on session-idle (`run` goes idle; does not interrupt). Session-idle Alt+Enter is a no-op. **Ctrl+C** stops the run outright. The notice row shows distinct `steer N` / `follow-up M` badges; when steers are pending and a parent tool has been in flight a few seconds, the notice names that command. Shortcuts are listed in `/help` (`Enter` soft-steer · `Alt+Enter` follow-up · `Ctrl+C` stop). -9. **Orchestrator-only (TUI + exec)** — The primary session is always the orchestrator: it can act directly and delegates via `spawn_agent` / `wait_agents` / `search_agents`. Long jobs belong on workers — a parent that runs them itself stays parent-busy and holds Enter steers. Single-agent session mode, the first-run mode picker, and Settings → Session are gone (CL-5814). Legacy `sessionMode` values on disk are ignored. +8. **Mid-run steering** — Two modes while the agent is running, keyed to **whose** idle. **Parent-idle** is when the primary Skywalker turn is not inside an in-flight parent tool; **session-idle** is parent-idle **and** no live fleet lanes. **Enter** soft-steers while the parent is busy — delivers at the next **parent** `tool.boundary` without stopping the current run; a long parent `run_shell` or an awaiting `wait_agents` is parent-busy, so Enter is a queued steer, not a new turn. An in-flight TUI-primary `wait_agents` yields as a timeout when that steer is queued so occupancy can deliver it. Idle-with-fleet is shipped: after a non-blocking `spawn_agent` dispatch the parent goes idle while workers keep running, mailbox mail arrives as inbound when a worker finishes or fails, and mid-hold Enter starts a new primary turn instead of queueing a steer. **Alt+Enter** queues a follow-up delivered only on session-idle (`run` goes idle; does not interrupt). Session-idle Alt+Enter is a no-op. **Ctrl+C** stops the run outright. The notice row shows distinct `steer N` / `follow-up M` badges; when steers are pending and a parent tool has been in flight a few seconds, the notice names that command. Shortcuts are listed in `/help` (`Enter` soft-steer · `Alt+Enter` follow-up · `Ctrl+C` stop). +9. **Orchestrator-only (TUI + exec)** — The primary session is always the orchestrator: it can act directly and delegates via `spawn_agent` (then idle; mailbox mail inbound) / `search_agents`. Nested orchestrators still collect with `wait_agents`. Long jobs belong on workers — a parent that runs them itself stays parent-busy and holds Enter steers. Single-agent session mode, the first-run mode picker, and Settings → Session are gone (CL-5814). Legacy `sessionMode` values on disk are ignored. ## User Experience @@ -168,7 +168,7 @@ Corbits Code fans work out to short-lived **fleet agents** — workers with thei - **Agents** are runtime entities (primary session or child). - **Tasks** are checklist items owned by one agent via `manage_tasks`. -- **Fleet agents** are spawned with `spawn_agent` / `wait_agents`. Workers ask the parent with `ask_director`. That parks a question while the worker stays `running`. `wait_agents` returns `awaiting_director` with a question payload — that is not terminal. The parent answers with `send_input` (`target` = the worker's session id), then `wait_agents` again. When the parent TUI is not blocked in `wait_agents`, a parked question arrives as a synthetic idle-send wake. Escalate to the human only with `ask_operator`. +- **Fleet agents** are spawned with `spawn_agent`. On the TUI primary, mailbox mail arrives as inbound when a worker finishes or fails — do not poll `wait_agents`. Nested orchestrators still collect with `wait_agents`. Workers ask the parent with `ask_director`. That parks a question while the worker stays `running`. Nested `wait_agents` returns `awaiting_director` with a question payload — that is not terminal. The parent answers with `send_input` (`target` = the worker's session id). When the parent TUI is not blocked in `wait_agents`, a parked question arrives as a synthetic idle-send wake. Escalate to the human only with `ask_operator`. Dispatch uses a structured brief (context / goal / optional goals seed) and returns a structured report. The TUI Agents strip and fleet board show who is running; live tool progress updates the status bar without dumping the child transcript into the parent chat. There is no turn budget. A tool-less final turn completes only with the four-heading report envelope; without it, one nudge is given and a second tool-less turn without the envelope salvages as `incomplete-report-stop`. A silent worker (no activity for `stallTimeoutMs`, opt-in) gets one continuation nudge, then salvages as `stalled` if a second consecutive check finds no activity. An opt-in `deadlineMs`, or an operator cancel, can also end a run early. Each of these returns a salvage report so a runaway or idle child cannot quietly burn a large token budget or look done after prose alone. diff --git a/docs/TUI.md b/docs/TUI.md index ebb1d602b..3c79d8e88 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -567,7 +567,9 @@ Two mid-run gestures, two delivery times (CL-6290): next **parent** `tool.boundary` (the parent tool finishing, not a child) via `Agent.deliver` into the live reactor, not a new `send`. A long parent `run_shell` or an awaiting `wait_agents` is parent-busy and holds - steers. The transcript row says `[will steer next]` while pending and + steers. An in-flight TUI-primary `wait_agents` yields as a timeout when a + steer is queued so occupancy can deliver it. The transcript row says + `[will steer next]` while pending and `[steering]` once delivered (`submitPrompt`, `drainSteersAtBoundary` in `runtime-bridge.ts`). - **Alt+Enter, mid-run** — follow-up: enqueues kind `"queue"` and delivers @@ -587,7 +589,9 @@ the parent turn settles while workers keep running; the runner emits `fleet` events carrying the live-lane count and the bridge holds the run busy on it. During the hold, Enter upgrades to a new primary turn sent immediately — there is no parent tool left to steer — while Alt+Enter follow-ups keep -waiting for true session-idle. A steer still pending when the hold engages +waiting for true session-idle. A child done or fail while siblings still run +flushes mailbox mail as system inbound (`flushMailboxMail`, skip when a +fleet-dry open-task shot is latched). A steer still pending when the hold engages sends at once (the parent it was steering has stopped), and the last lane terminalizing releases the hold, drains follow-ups, and returns the session to idle — unless todo/doing tasks remain, in which case a system diff --git a/src/agent/agent-search.ts b/src/agent/agent-search.ts index f24b79c84..19a149ed8 100644 --- a/src/agent/agent-search.ts +++ b/src/agent/agent-search.ts @@ -111,7 +111,7 @@ export function formatAgentSearchResults( "", ...entries.flatMap((entry, i) => (i === 0 ? [entry] : ["", entry])), "", - "Spawn with spawn_agent(description, prompt, agent=). For a team, call spawn_agent once per member (parallel in one turn when independent), then collect with wait_agents.", + "Spawn with spawn_agent(description, prompt, agent=). For a team, call spawn_agent once per member (parallel in one turn when independent), then reply and idle — mailbox mail arrives as inbound. Nested orchestrators still collect with wait_agents.", ].join("\n"), ); } diff --git a/src/agent/directors/greybeard/package.ts b/src/agent/directors/greybeard/package.ts index d7f81f164..15ef7b724 100644 --- a/src/agent/directors/greybeard/package.ts +++ b/src/agent/directors/greybeard/package.ts @@ -33,7 +33,7 @@ Judge the approach: 4. Rank risks for long-term maintainability and backward compatibility. 5. Report a clear verdict: hold / revise / block — with the why, not a checklist theater. -Spawn only when a concrete unknown blocks that judgment. Package spawn rules allow intern (mechanical shell), explorer (map/read), and critic (code evidence). When spawning critic, pass non-empty success_criteria (runtime fail-closes without it). intern and explorer remain optional. Prefer doing the review yourself with mounted read/search tools. Do not invent numeric spawn caps or act as a scheduler — width follows the unknown, not a soft ladder. +Spawn only when a concrete unknown blocks that judgment. Package spawn rules allow intern (mechanical shell), explorer (map/read), and critic (code evidence). When spawning critic, pass non-empty success_criteria (runtime fail-closes without it). intern and explorer remain optional. Prefer doing the review yourself with mounted read/search tools. Do not invent numeric spawn caps or act as a scheduler — width follows the unknown, not a soft ladder. Nested orchestrators collect with wait_agents — mailbox mail is the primary parent path. Blinders: do not call search_agents to discover the fleet (even when nested). You already know the limited spawn set; stay inside it. Do not spawn builder, counsel, skywalker, or other directors outside the allowlist. diff --git a/src/agent/directors/skywalker/package.test.ts b/src/agent/directors/skywalker/package.test.ts index e2128cdfe..4f80b5e01 100644 --- a/src/agent/directors/skywalker/package.test.ts +++ b/src/agent/directors/skywalker/package.test.ts @@ -117,18 +117,18 @@ describe("skywalkerPackage", () => { expect(p).toContain("Do not invent a numeric cap"); }); - test("systemPrompt prefers spawn_agent then wait_agents (idle-orchestrator)", () => { + test("systemPrompt prefers spawn_agent then idle (idle-orchestrator)", () => { const p = skywalkerPackage.systemPrompt; expect(p).toContain("spawn_agent"); expect(p).toContain("wait_agents"); expect(p).toContain("Idle-orchestrator"); expect(p).not.toContain("task()"); - expect(p).toContain('mode="all"'); - expect(p).toContain("uncollected spawns"); + expect(p).toContain("do not poll wait_agents"); + expect(p).toContain("mailbox mail arrives as inbound"); expect(p).toContain( "When the fleet goes dry the runtime re-enters with collected reports", ); - expect(p).toContain("do not tight-loop wait_agents"); + expect(p).not.toContain("do not tight-loop wait_agents"); expect(p).not.toContain( "Present the plan when the change is large or ambiguous", ); @@ -140,10 +140,10 @@ describe("skywalkerPackage", () => { expect(p).toContain("only surface that talks to the operator"); expect(p).toContain("frequent short status updates"); expect(p).toContain("reply to the operator"); - expect(p).toContain("before you block"); - expect(p).toContain("timeout_ms"); + expect(p).toContain("end the turn"); + expect(p).toContain("mailbox mail"); expect(p).toContain("answer them first"); - expect(p).toContain("Enter can land"); + expect(p).toContain("Enter mid-run"); }); test("systemPrompt anti-cascade keeps digs out of fleets", () => { @@ -229,8 +229,7 @@ describe("skywalkerPackage", () => { expect(p).toContain("send_input"); expect(p).toContain("awaiting_director"); expect(p).toContain("idle-send"); - expect(p).toContain("target = that worker's session id"); - expect(p).toContain("target = worker session id"); + expect(p).toMatch(/target = (that worker's |worker )session id/); expect(p).not.toMatch( /wait_agents returns status running plus a question/i, ); diff --git a/src/agent/directors/skywalker/package.ts b/src/agent/directors/skywalker/package.ts index 09c246d8a..3d06c85bf 100644 --- a/src/agent/directors/skywalker/package.ts +++ b/src/agent/directors/skywalker/package.ts @@ -6,27 +6,26 @@ import { SKYWALKER_TOOLS } from "../tool-sets.js"; const SKYWALKER_SYSTEM_PROMPT = `You are Skywalker — the primary orchestrator for Corbits Code. When asked your name, answer: Skywalker. -Agent id: skywalker (primary session; not a spawned worker). Prefer spawn_agent for specialists (parallel OK), then wait_agents for the reports you need next. +Agent id: skywalker (primary session; not a spawned worker). Prefer spawn_agent for specialists (parallel OK), then idle. Mailbox mail arrives as inbound when workers finish — do not poll wait_agents. PRIMARY INTENT: run the workflow. Classify every request. DIY tiny/single-file/one-route product edits. Delegate substantial work. Chain specialists into a sequence of actions. Track who is running. You are the only surface that talks to the operator — give frequent short status updates while work is in flight. Synthesize for the operator. Do not become the reviewer or explorer by default. -You do not do the specialists' jobs by default. For tiny bounded product edits, use write_file/edit_file/delete_file yourself. For substantial work you start specialists with spawn_agent, give the operator a short status, then wait_agents for reports and decide the next action. +You do not do the specialists' jobs by default. For tiny bounded product edits, use write_file/edit_file/delete_file yourself. For substantial work you start specialists with spawn_agent, give the operator a short status, then idle so mailbox mail can wake you. Do not poll wait_agents. # Parent tools 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. -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 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). list_agents shows that same fleet without blocking. 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. Enter mid-run delivers at the next parent tool.boundary — a long parent foreground run_shell or awaiting wait_agents holds those steers (start long commands with run_shell background:true instead, and collect later). A bare spawn_agent does not. When the fleet goes dry the runtime re-enters with collected reports. +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 **end the turn**. Workers keep running while you are idle; mailbox mail arrives as inbound when a worker finishes or fails — read it and decide the next action. Do not poll wait_agents. wait_agents is optional/deprecated on this primary parent (nested orchestrators such as greybeard still collect with it). list_agents shows the fleet without blocking. interrupt_agent unblocks an in-flight wait immediately. Enter mid-run delivers at the next parent tool.boundary — a long parent foreground run_shell or awaiting wait_agents holds those steers (start long commands with run_shell background:true instead). A bare spawn_agent does not. When the fleet goes dry the runtime re-enters with collected reports. # Operator updates (mandatory while fleet is live) -You are the chat surface. Workers cannot ask_operator; they ask_director. When wait_agents returns awaiting_director, answer with send_input using target = that worker's session id, then wait_agents again. When this session is not collecting, a parked question arrives as an idle-send wake — answer the same way (send_input target = worker session id). Escalate with ask_operator only when you cannot resolve it. While any specialist is running: -- After every spawn wave: short status (who, goal, what you are waiting on) before blocking. -- On meaningful progress or a finished report: short update — do not go silent for long waits. +You are the chat surface. Workers cannot ask_operator; they ask_director. A parked question arrives as an idle-send wake (list_agents shows awaiting_director) — answer with send_input using target = that worker's session id. Escalate with ask_operator only when you cannot resolve it. While any specialist is running: +- After every spawn wave: short status (who, goal, what you are waiting on) then end the turn. +- On mailbox mail or a finished report: short update — do not go silent. - When the operator messages mid-run: answer them first (COMMUNICATION). Do not make them wait on an in-flight wait_agents if you can end/timeout the wait and reply. - Keep updates short; no wall of task dumps. manage_tasks is the checklist; chat is the narrative. - Example chains: - tiny fix: DIY write_file/edit_file (do not spawn) - feature: explorer → plan → implement → critic @@ -54,7 +53,7 @@ Quick routing: - After every delegated builder landing → run a critic on the diff/criteria in a fresh context; when architecture is in play, add greybeard for architecture judgment success_criteria is required for implement/review and their default directors; recommended otherwise. Pass intent, do_not, report_focus, and agent when specialist. -Parallelize independent lanes with spawn_agent, then wait_agents. manage_tasks for your checklist. ask_operator when blocked or ambiguous — put long rationale in a normal transcript reply first, then call ask_operator with a short question and short option labels only. +Parallelize independent lanes with spawn_agent, then idle. manage_tasks for your checklist. ask_operator when blocked or ambiguous — put long rationale in a normal transcript reply first, then call ask_operator with a short question and short option labels only. # Fetch URLs (primary-mounted) @@ -113,7 +112,7 @@ Before responding, classify: Tiny / single-file / one-route / clear bounded edit: write_file/edit_file/delete_file on this session. Do not spawn. DIY edits: prefer deletion and reuse; clean only files you already touch; read first. -Substantial / multi-file / parallel lanes / long-running: spawn builder. Prefer spawn_agent so the parent stays free; wait_agents when you need the report. Keep long-blocking jobs off the parent so Enter can steer. Substantial builder work consumes a counsel / \`/plan\` plan (files, acceptance criteria, non-goals, risks, ordered steps). If that plan is missing, spawn counsel (or wait for \`/plan\`) before builder — put the plan in the builder brief. Builder blocks if the plan is still missing. Tiny parent-DIY edits stay plan-optional. \`/implement\` does not steal planning from \`/plan\`. +Substantial / multi-file / parallel lanes / long-running: spawn builder. Prefer spawn_agent so the parent stays free; mailbox mail arrives as inbound when the report is ready. Keep long-blocking jobs off the parent so Enter can steer. Substantial builder work consumes a counsel / \`/plan\` plan (files, acceptance criteria, non-goals, risks, ordered steps). If that plan is missing, spawn counsel (or wait for \`/plan\`) before builder — put the plan in the builder brief. Builder blocks if the plan is still missing. Tiny parent-DIY edits stay plan-optional. \`/implement\` does not steal planning from \`/plan\`. Docs/design (PRODUCT.md, ARCHITECTURE.md, docs/design/*, brand) still spawn shakespeare / bruckheimer / rand unless the ask is a one-line fix. @@ -125,7 +124,7 @@ Docs/design (PRODUCT.md, ARCHITECTURE.md, docs/design/*, brand) still spawn shak ## If ORCHESTRATION → coordinate -Track with manage_tasks. Parallelize independent lanes via spawn_agent + wait_agents. After each spawn wave, update the operator before blocking. Escalate blockers with ask_operator (chat rationale first, then short ask_operator). This is your core role. +Track with manage_tasks. Parallelize independent lanes via spawn_agent, then idle. After each spawn wave, update the operator and end the turn. Escalate blockers with ask_operator (chat rationale first, then short ask_operator). This is your core role. ## If COMMUNICATION → answer directly diff --git a/src/agent/prompts.ts b/src/agent/prompts.ts index d4af211cd..ab768c929 100644 --- a/src/agent/prompts.ts +++ b/src/agent/prompts.ts @@ -129,7 +129,7 @@ export function buildGuidelines( ...(subAgent ? [] : [ - "- Prefer spawn_agent(agent=…) / wait_agents for substantial product implementation, exploration, review, and docs — spawn remains default for substantial work, not a tool ban.", + "- Prefer spawn_agent(agent=…) then idle for substantial product implementation, exploration, review, and docs — mailbox mail arrives as inbound; do not poll wait_agents. Spawn remains default for substantial work, not a tool ban.", ]), "- read_file for file contents; grep or search_files to locate code; lsp for symbols, types, references, or call flow before opening large files.", subAgent @@ -174,10 +174,10 @@ export function buildGuidelines( : [ "", "Orchestration:", - "- Break multi-step or parallel work into focused worker dispatches with distinct lenses; prefer `spawn_agent` (fire several in one turn when jobs are independent), then reply with who is running and end the turn — workers keep running while you are idle, and `wait_agents` / `list_agents` on a later turn collect their reports without holding this conversation blocked.", + "- Break multi-step or parallel work into focused worker dispatches with distinct lenses; prefer `spawn_agent` (fire several in one turn when jobs are independent), then reply with who is running and end the turn — workers keep running while you are idle. Mailbox mail arrives as inbound when a worker finishes; read it and do not poll `wait_agents`. Nested orchestrators still collect with `wait_agents`. `list_agents` shows the fleet without blocking.", "- Pass the typed spawn contract: `intent`, `success_criteria` (done-when; required for implement/review and their default directors), `do_not` (scope fence), and `report_focus`. Free-form `prompt` without `success_criteria` fail-closes for implement/review and their default directors.", "- After workers return, classify fail / incomplete-report vs parent-initiated interrupt vs operator-cancel vs clean complete. Fail-path (`status: failed` or salvage `incomplete-report`): diagnose from the report or error and MAY spawn one successor with a changed brief. Parent-initiated interrupt (`interrupt_agent` / `send_input` with `interrupt:true` unblocks wait with `stop_reason: interrupted`): the worker is often still running and often has no report — `resume_agent` or re-wait; do not `spawn_agent` a successor against a still-live worker. Successor only if that session is no longer resumable. Operator-cancel (`stop_reason` cancelled): wait for the operator; do not auto-retry. Identical brief: refuse. Merge Summary/Findings into a coherent answer for the operator; do not paste raw fleet-agent dumps.", - "- Use manage_tasks for your own coordination checklist; spawning workers is `spawn_agent` / `wait_agents`, not manage_tasks.", + "- Use manage_tasks for your own coordination checklist; spawning workers is `spawn_agent`, not manage_tasks.", "- If context is compacted automatically, do not stop tasks early due to token fear; persist progress via manage_tasks and worker reports.", ]), ].join("\n"); @@ -243,7 +243,7 @@ const TOOL_SUMMARIES: Record = { spawn_agent: "start a worker agent and return immediately with agent_id; pass returned ids from search_agents as agent=...", wait_agents: - "wait for spawned workers by agent_id; returns awaiting_director when a worker asks, without collecting that session", + "optional/deprecated on the primary parent — mailbox mail arrives as inbound; nested orchestrators still wait for spawned workers by agent_id; returns awaiting_director when a worker asks, without collecting that session", search_agents: "find agent profiles by role or team before spawning with spawn_agent(agent=...); default results are id, description, and spawn metadata — pass include_body=true for the loaded system prompt / body", manage_tasks: diff --git a/src/agent/tools.ts b/src/agent/tools.ts index 73c67bd45..542ad9690 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -241,6 +241,12 @@ export interface AgentToolsetArgs { * Leaves keep apply_patch when their allowlist includes it. */ isCodex?: boolean; + /** + * TUI primary only. When true, wait_agents finishes as a timeout (workers + * untouched, no take) so occupancy can deliver mailbox mail or a queued + * operator steer. Nested mounts omit this. + */ + shouldYieldWait?: () => boolean; } // Per-server connection state surfaced to the TUI. @@ -521,7 +527,13 @@ export async function createAgentToolset( }; orchestratorTools.push( createSpawnAgentTool(fleetDeps), - createWaitAgentsTool({ sessions: fleetSessions, fleetRecords }), + createWaitAgentsTool({ + sessions: fleetSessions, + fleetRecords, + ...(args.shouldYieldWait !== undefined + ? { shouldYieldWait: args.shouldYieldWait } + : {}), + }), createListAgentsTool({ sessions: fleetSessions, fleetRecords }), createCloseAgentTool({ sessions: fleetSessions, fleetRecords }), createResumeAgentTool({ sessions: fleetSessions, fleetRecords }), diff --git a/src/session/runtime-assembly.test.ts b/src/session/runtime-assembly.test.ts index e53cf6294..47c319009 100644 --- a/src/session/runtime-assembly.test.ts +++ b/src/session/runtime-assembly.test.ts @@ -20,6 +20,7 @@ import type { GrantScope } from "../permission/types.js"; import { APPROVAL_PERSIST_FAILURE_NOTICE, buildCompactionContinuationMessage, + buildMailboxMailMessage, buildSubAgentProvider, createApprovalPersist, createLiveSubAgentSources, @@ -533,3 +534,16 @@ describe("buildCompactionContinuationMessage", () => { ); }); }); + +describe("buildMailboxMailMessage", () => { + test("builds a system inbound with mailbox mail content", () => { + const message = buildMailboxMailMessage("mailbox mail — reports"); + expect(message.content).toBe("mailbox mail — reports"); + expect(message.flags).toEqual([]); + expect(message.signatureStatus).toBe("missing"); + expect(message.ref).toEqual({ uid: 0, mailbox: "system" }); + expect(message.headers.from).toBe("user@local"); + expect(message.headers.to).toEqual(["agent@local"]); + expect(message.headers.messageId.startsWith("mailbox-mail-")).toBe(true); + }); +}); diff --git a/src/session/runtime-assembly.ts b/src/session/runtime-assembly.ts index 134f0f23a..8f7f0f3d3 100644 --- a/src/session/runtime-assembly.ts +++ b/src/session/runtime-assembly.ts @@ -440,6 +440,26 @@ export function buildFleetDryContinuationMessage(text: string): InboundMessage { }; } +/** + * System-originated inbound that re-enters the parent when mailbox mail + * (worker terminal or fail) is ready. Not operator input, so no + * OPERATOR_ORIGINATED_FLAG. + */ +export function buildMailboxMailMessage(text: string): InboundMessage { + return { + ref: { uid: 0, mailbox: "system" }, + headers: { + from: "user@local", + to: ["agent@local"], + date: new Date().toISOString(), + messageId: `mailbox-mail-${Date.now()}@local`, + }, + flags: [], + content: text, + signatureStatus: "missing", + }; +} + // Preview cap for a background shell's inline output; the full output stays in // the registry (shell_collect) and, when truncated, in the spill blob. const BACKGROUND_SHELL_PREVIEW_CHARS = 2_000; diff --git a/src/subagent/agent-fleet.test.ts b/src/subagent/agent-fleet.test.ts index 43a3c75e6..a5d96f0f0 100644 --- a/src/subagent/agent-fleet.test.ts +++ b/src/subagent/agent-fleet.test.ts @@ -3048,3 +3048,131 @@ describe("admission queue", () => { ]); }); }); + +describe("wait_agents occupancy yield (CL-7518)", () => { + test("shouldYieldWait finishes as timeout without taking or interrupting workers", async () => { + const gate = deferred(); + const deps = makeDeps(async () => gate.promise); + const spawn = createSpawnAgentTool(deps); + const wait = createWaitAgentsTool({ + sessions: deps.sessions, + fleetRecords: deps.fleetRecords, + shouldYieldWait: () => true, + }); + const spawned = await callTool(spawn, { + description: "live", + prompt: "do it", + intent: "explore", + }); + const id = spawned.agent_id as string; + const waited = await callTool(wait, { targets: [id], timeout_ms: 5_000 }); + expect(waited.timed_out).toBe(true); + const row = defined((waited.results as Record[])[0]); + expect(row.status).toBe("running"); + expect(row.report).toBeUndefined(); + expect(deps.sessions.get(id)?.status).toBe("running"); + expect(deps.fleetRecords.peek(id)?.collected).not.toBe(true); + gate.resolve({ report: "ok" }); + }); + + test("queued-steer wake yields an in-flight wait as timeout", async () => { + const gate = deferred(); + const deps = makeDeps(async () => gate.promise); + let yieldWait = false; + const spawn = createSpawnAgentTool(deps); + const wait = createWaitAgentsTool({ + sessions: deps.sessions, + fleetRecords: deps.fleetRecords, + shouldYieldWait: () => yieldWait, + }); + const spawned = await callTool(spawn, { + description: "live", + prompt: "do it", + intent: "explore", + }); + const id = spawned.agent_id as string; + const pending = callTool(wait, { targets: [id], timeout_ms: 5_000 }); + await new Promise((resolve) => setTimeout(resolve, 20)); + yieldWait = true; + deps.sessions.wake(); + const waited = await pending; + expect(waited.timed_out).toBe(true); + expect(deps.sessions.get(id)?.status).toBe("running"); + expect(deps.fleetRecords.peek(id)?.collected).not.toBe(true); + gate.resolve({ report: "ok" }); + }); + + test("already-collected wait has no second report or error copy", async () => { + const deps = makeDeps(async () => ({ report: "shipped" })); + const spawn = createSpawnAgentTool(deps); + const wait = createWaitAgentsTool({ + sessions: deps.sessions, + fleetRecords: deps.fleetRecords, + }); + const spawned = await callTool(spawn, { + description: "lane", + prompt: "do it", + intent: "explore", + }); + const id = spawned.agent_id as string; + await waitUntilMailboxTerminal(deps.fleetRecords, deps.sessions, id); + const first = await callTool(wait, { targets: [id], timeout_ms: 5_000 }); + expect(first.timed_out).toBe(false); + expect(defined((first.results as { report?: string }[])[0]).report).toBe( + "shipped", + ); + expect(deps.fleetRecords.peek(id)?.collected).toBe(true); + + const again = await callTool(wait, { targets: [id], timeout_ms: 5_000 }); + expect(again.timed_out).toBe(false); + const row = defined((again.results as Record[])[0]); + expect(row.status).toBe("done"); + expect(row.report).toBeUndefined(); + expect(row.error).toBeUndefined(); + expect(row.question).toBeUndefined(); + }); + + test("yield on awaiting_director omits the question payload", async () => { + const gate = deferred(); + const deps = makeDeps(async (params) => { + params.onAgentReady?.({ + close: async () => undefined, + interrupt: () => undefined, + followup: async () => "", + deliver: () => undefined, + }); + void params.askDirectorPort + ?.register({ + question: "which file should I edit?", + questionId: "ask-1", + }) + .then( + () => undefined, + () => undefined, + ); + return gate.promise; + }); + const spawn = createSpawnAgentTool(deps); + let id = ""; + const wait = createWaitAgentsTool({ + sessions: deps.sessions, + fleetRecords: deps.fleetRecords, + shouldYieldWait: () => + deps.fleetRecords.peek(id)?.status === "awaiting_director", + }); + const spawned = await callTool(spawn, { + description: "need a path", + prompt: "do it", + intent: "explore", + }); + id = spawned.agent_id as string; + const waited = await callTool(wait, { targets: [id], timeout_ms: 5_000 }); + expect(waited.timed_out).toBe(true); + const row = defined((waited.results as Record[])[0]); + expect(row.status).toBe("awaiting_director"); + expect(row.question).toBeUndefined(); + expect(row.question_id).toBeUndefined(); + expect(deps.fleetRecords.peek(id)?.collected).not.toBe(true); + gate.resolve({ report: "ok" }); + }); +}); diff --git a/src/subagent/agent-fleet.ts b/src/subagent/agent-fleet.ts index 39dab0f6b..936ecbe01 100644 --- a/src/subagent/agent-fleet.ts +++ b/src/subagent/agent-fleet.ts @@ -121,6 +121,8 @@ interface FleetRecord { providerFailure?: true; /** Set once a wait_agents caller has been handed this result. */ collected?: boolean; + /** Set once a waiter or occupancy take handed report/error. */ + bodyHanded?: boolean; /** Set once the payload has been compacted away to bound memory. */ tombstoned?: boolean; /** Present only on a tombstoned record — how to recover the detail. */ @@ -145,6 +147,8 @@ interface FleetOverlay { frozenStatus?: WaitJSONStatus; /** Last wait projection seen while the session still existed. */ lastWaitStatus?: WaitJSONStatus; + /** Report/error already copied into a wait or occupancy payload. */ + bodyHanded?: boolean; tombstoned?: boolean; hint?: string; providerFailure?: true; @@ -314,6 +318,9 @@ class FleetMailbox { if (!isLiveWaitStatus(snap.status) && overlay.collected !== true) { overlay.frozenStatus = snap.status; overlay.collected = true; + if (snap.report !== undefined || snap.error !== undefined) { + overlay.bodyHanded = true; + } if (overlay.pinHeld === true) { overlay.pinHeld = false; this.sessions?.unpin(id); @@ -403,6 +410,7 @@ class FleetMailbox { return { status, ...(overlay.collected === true ? { collected: true } : {}), + ...(overlay.bodyHanded === true ? { bodyHanded: true } : {}), ...(overlay.tombstoned === true ? { tombstoned: true } : {}), ...(overlay.hint !== undefined ? { hint: overlay.hint } : {}), ...(payload?.report !== undefined ? { report: payload.report } : {}), @@ -476,7 +484,7 @@ const SpawnAgentArgs = type({ export const spawnAgentToolDefinition: ToolDefinition = { name: SPAWN_AGENT_TOOL_NAME, description: - "Start a worker agent and return IMMEDIATELY with its agent_id — this never blocks on the worker's completion. Pass agent= a director/profile id returned by search_agents, or intent= (one of explore|implement|review|plan|general). The child starts blank. success_criteria is required for implement/review (and their default directors). Fire several spawn_agent calls in one turn to start workers in parallel, then use wait_agents to collect them. Excess fan-out is queued rather than refused.", + "Start a worker agent and return IMMEDIATELY with its agent_id — this never blocks on the worker's completion. Pass agent= a director/profile id returned by search_agents, or intent= (one of explore|implement|review|plan|general). The child starts blank. success_criteria is required for implement/review (and their default directors). Fire several spawn_agent calls in one turn to start workers in parallel, then reply and end the turn — workers keep running while you are idle. wait_agents is optional/deprecated on the primary parent (mailbox mail arrives as inbound). Nested orchestrators still collect with wait_agents. Excess fan-out is queued rather than refused.", inputSchema: { type: "object", properties: { @@ -538,6 +546,7 @@ export const MAX_WAIT_TIMEOUT_MS = 300_000; export const waitAgentsToolDefinition: ToolDefinition = { name: "wait_agents", description: + `Optional/deprecated on the primary parent: mailbox mail arrives as inbound when workers finish, so spawn then idle instead of polling. Nested orchestrators still collect with this tool. ` + `Block until the given agents reach a terminal state (done, failed, or interrupted), or a worker asks its director (awaiting_director), or timeout_ms elapses. ` + `Default mode is "any" (return when the first target finishes or asks). Pass mode="all" to wait until every target is ` + `terminal — except a pending ask_director unblocks immediately regardless of mode so the director can send_input. ` + @@ -1465,10 +1474,18 @@ interface WaitAgentsAuthority { getNodes: () => readonly FleetNode[]; } +type WaitFinishReason = "ready" | "timeout" | "yield"; + interface WaitAgentsDeps { sessions: SubAgentSessionStore; fleetRecords: FleetMailboxHandle; authority?: WaitAgentsAuthority; + /** + * TUI primary only. When true, finish the wait as a timeout (workers + * untouched, no take) so occupancy can deliver mailbox mail or a queued + * operator steer. Nested mounts omit this. + */ + shouldYieldWait?: () => boolean; } function isWaitTerminal(id: string, fleetRecords: FleetMailboxHandle): boolean { @@ -1478,10 +1495,11 @@ function isWaitTerminal(id: string, fleetRecords: FleetMailboxHandle): boolean { /** * Blocks until `mode` is satisfied for `targets`, or `timeoutMs` / abort - * elapses. Driven by the session store's mailbox (`subscribe`) raced against - * a timer and the parent tool signal; never polls. Timeout and abort have no - * side effects: workers keep running and remain waitable. Overlay writers - * wake this wait via `sessions.wake()`. + * elapses, or TUI-primary `shouldYieldWait` is true. Driven by the session + * store's mailbox (`subscribe`) raced against a timer and the parent tool + * signal; never polls. Timeout, abort, and yield have no side effects: + * workers keep running and remain waitable. Overlay writers wake this wait + * via `sessions.wake()`. */ async function waitForTerminal( sessions: SubAgentSessionStore, @@ -1490,7 +1508,8 @@ async function waitForTerminal( timeoutMs: number, mode: "any" | "all", signal?: AbortSignal, -): Promise { + shouldYieldWait?: () => boolean, +): Promise { const ready = (): boolean => { if ( targets.some( @@ -1502,27 +1521,31 @@ async function waitForTerminal( ? targets.every((id) => isWaitTerminal(id, fleetRecords)) : targets.some((id) => isWaitTerminal(id, fleetRecords)); }; - if (signal?.aborted) return true; - if (ready()) return false; + if (signal?.aborted) return "timeout"; + if (shouldYieldWait?.() === true) return "yield"; + if (ready()) return "ready"; - return await new Promise((resolve) => { + return await new Promise((resolve) => { let settled = false; - const finish = (timedOut: boolean): void => { + const finish = (reason: WaitFinishReason): void => { if (settled) return; settled = true; clearTimeout(timer); unsubscribeSessions(); signal?.removeEventListener("abort", onAbort); - resolve(timedOut); + resolve(reason); }; - const onAbort = (): void => finish(true); + const onAbort = (): void => finish("timeout"); const onChange = (): void => { - if (ready()) finish(false); + if (shouldYieldWait?.() === true) finish("yield"); + else if (ready()) finish("ready"); }; - const timer = setTimeout(() => finish(true), timeoutMs); + const timer = setTimeout(() => finish("timeout"), timeoutMs); const unsubscribeSessions = sessions.subscribe(onChange); signal?.addEventListener("abort", onAbort, { once: true }); - if (signal?.aborted) finish(true); + if (signal?.aborted) finish("timeout"); + else if (shouldYieldWait?.() === true) finish("yield"); + else if (ready()) finish("ready"); }); } @@ -1579,23 +1602,36 @@ export function createWaitAgentsTool(deps: WaitAgentsDeps): AgentTool { } } - const timedOut = await waitForTerminal( + const finishReason = await waitForTerminal( deps.sessions, deps.fleetRecords, targets, timeoutMs, mode, signal, + deps.shouldYieldWait, ); + const timedOut = finishReason !== "ready"; + const yielded = finishReason === "yield"; // Terminal overlay/session projections are marked collected once // delivered here; a running record is only peeked, so it stays waitable. + // A yield leaves reports for occupancy (mailbox mail / ask-wake). const results = targets.map((id) => { const record = deps.fleetRecords.peek(id); if (record === undefined) { return { agent_id: id, status: "unknown" as const }; } if (record.status === "awaiting_director") { + if (yielded) { + return { + agent_id: id, + status: record.status, + ...(record.description !== undefined + ? { description: record.description } + : {}), + }; + } return { agent_id: id, status: record.status, @@ -1613,6 +1649,16 @@ export function createWaitAgentsTool(deps: WaitAgentsDeps): AgentTool { if (isLiveWaitStatus(record.status)) { return { agent_id: id, status: record.status }; } + if (yielded || record.bodyHanded === true) { + return { + agent_id: id, + status: record.status, + ...(record.description !== undefined && + record.description.length > 0 + ? { description: record.description } + : {}), + }; + } const projected = takeAndProjectMailboxRecord(deps.fleetRecords, id); if (projected === undefined) { return { agent_id: id, status: "unknown" as const }; diff --git a/src/subagent/index.ts b/src/subagent/index.ts index 7f6d9110d..75ff5fbd4 100644 --- a/src/subagent/index.ts +++ b/src/subagent/index.ts @@ -27,6 +27,11 @@ export { type PendingAskWake, } from "./fleet-report.js"; export { driveOpenTasksAfterFleetDry } from "./fleet-dry-drive.js"; +export { + driveMailboxMail, + MAILBOX_MAIL_WAKE_PREFIX, + occupancyShouldYieldWait, +} from "./mailbox-mail-drive.js"; export { EMPTY_THRASH_STATE, nextThrashState, diff --git a/src/subagent/mailbox-mail-drive.test.ts b/src/subagent/mailbox-mail-drive.test.ts new file mode 100644 index 000000000..db49f99e0 --- /dev/null +++ b/src/subagent/mailbox-mail-drive.test.ts @@ -0,0 +1,259 @@ +import { describe, expect, test } from "bun:test"; +import { createFleetMailbox } from "./agent-fleet.js"; +import { + buildMailboxMailPrompt, + driveMailboxMail, + MAILBOX_MAIL_WAKE_PREFIX, + occupancyShouldYieldWait, +} from "./mailbox-mail-drive.js"; +import { createSubAgentSessionStore } from "./session-store.js"; +import type { + FleetDryMailbox, + FleetDryMailboxRecord, +} from "./fleet-dry-drive.js"; + +function mapMailbox( + records: Map, +): FleetDryMailbox { + return { + ids: () => [...records.keys()], + peek: (id) => records.get(id), + take: (id) => { + const existing = records.get(id); + if (existing === undefined) return undefined; + const taken = { ...existing, collected: true }; + records.set(id, taken); + return taken; + }, + }; +} + +describe("buildMailboxMailPrompt", () => { + test("prefixes collected JSON and tells the parent not to wait_agents", () => { + const prompt = buildMailboxMailPrompt([ + { + agent_id: "worker-1", + status: "done", + report: "shipped", + description: "lane", + }, + ]); + expect(prompt.startsWith(MAILBOX_MAIL_WAKE_PREFIX)).toBe(true); + expect(prompt).toContain("worker-1"); + expect(prompt).toContain("shipped"); + expect(prompt).toContain( + "already collected — do not call wait_agents for these agent_ids", + ); + }); +}); + +describe("driveMailboxMail", () => { + test("idle parent with one terminal drives even while siblings run", () => { + const records = new Map([ + ["done", { status: "done", report: "ok", description: "lane" }], + ["live", { status: "running" }], + ]); + const order: string[] = []; + const sent: string[] = []; + const driven = driveMailboxMail({ + parentProcessing: false, + mailbox: mapMailbox(records), + lanes: [], + beginSystemContinuation: (prompt) => { + order.push("begin"); + sent.push(prompt); + }, + send: (prompt) => { + order.push("send"); + sent.push(prompt); + }, + }); + expect(driven).toBe(true); + expect(order).toEqual(["begin", "send"]); + expect(sent[0]).toContain(MAILBOX_MAIL_WAKE_PREFIX); + expect(sent[0]).toContain("done"); + expect(sent[0]).not.toContain('"live"'); + expect(records.get("done")?.collected).toBe(true); + expect(records.get("live")?.collected).not.toBe(true); + }); + + test("fail path is the same terminal collect", () => { + const records = new Map([ + ["fail", { status: "failed", error: "boom" }], + ]); + const sent: string[] = []; + const driven = driveMailboxMail({ + parentProcessing: false, + mailbox: mapMailbox(records), + lanes: [], + beginSystemContinuation: () => undefined, + send: (prompt) => { + sent.push(prompt); + }, + }); + expect(driven).toBe(true); + expect(sent[0]).toContain("fail"); + expect(sent[0]).toContain("boom"); + expect(records.get("fail")?.collected).toBe(true); + }); + + test("parentProcessing or empty mailbox is a no-op", () => { + const records = new Map([ + ["done", { status: "done", report: "ok" }], + ]); + const noop = { + beginSystemContinuation: () => { + throw new Error("must not begin"); + }, + send: () => { + throw new Error("must not send"); + }, + }; + expect( + driveMailboxMail({ + parentProcessing: true, + mailbox: mapMailbox(records), + lanes: [], + ...noop, + }), + ).toBe(false); + expect( + driveMailboxMail({ + parentProcessing: false, + mailbox: mapMailbox(new Map()), + lanes: [], + ...noop, + }), + ).toBe(false); + expect(records.get("done")?.collected).not.toBe(true); + }); + + test("already-collected terminals are not driven again", () => { + const sessions = createSubAgentSessionStore(); + const mailbox = createFleetMailbox(sessions); + const session = sessions.start({ + id: "coll", + description: "already collected", + agentId: "builder", + brief: "brief", + }); + mailbox.register(session.id); + sessions.complete("coll", "already taken"); + mailbox.take("coll"); + expect( + driveMailboxMail({ + parentProcessing: false, + mailbox, + lanes: sessions.list(), + beginSystemContinuation: () => { + throw new Error("must not begin"); + }, + send: () => { + throw new Error("must not send"); + }, + }), + ).toBe(false); + }); + + test("send failure leaves reports waitable", () => { + const records = new Map([ + ["w1", { status: "done", report: "ok" }], + ]); + const driven = driveMailboxMail({ + parentProcessing: false, + mailbox: mapMailbox(records), + lanes: [], + beginSystemContinuation: () => undefined, + send: () => { + throw new Error("send failed"); + }, + }); + expect(driven).toBe(false); + expect(records.get("w1")?.collected).not.toBe(true); + }); + + test("async send false after begin leaves mailbox uncollected", async () => { + const records = new Map([ + ["w1", { status: "done", report: "ok" }], + ]); + const driven = driveMailboxMail({ + parentProcessing: false, + mailbox: mapMailbox(records), + lanes: [], + beginSystemContinuation: () => undefined, + send: () => Promise.resolve(false), + }); + expect(driven).toBe(true); + expect(records.get("w1")?.collected).not.toBe(true); + await Promise.resolve(); + expect(records.get("w1")?.collected).not.toBe(true); + }); + + test("async send success takes after the promise resolves", async () => { + const records = new Map([ + ["w1", { status: "done", report: "ok" }], + ]); + const driven = driveMailboxMail({ + parentProcessing: false, + mailbox: mapMailbox(records), + lanes: [], + beginSystemContinuation: () => undefined, + send: () => Promise.resolve(true), + }); + expect(driven).toBe(true); + expect(records.get("w1")?.collected).not.toBe(true); + await Promise.resolve(); + expect(records.get("w1")?.collected).toBe(true); + }); + + test("awaiting_director is not mailbox mail", () => { + const records = new Map([ + ["ask", { status: "awaiting_director" }], + ["live", { status: "running" }], + ]); + expect( + driveMailboxMail({ + parentProcessing: false, + mailbox: mapMailbox(records), + lanes: [], + beginSystemContinuation: () => { + throw new Error("must not begin"); + }, + send: () => { + throw new Error("must not send"); + }, + }), + ).toBe(false); + }); +}); + +describe("occupancyShouldYieldWait", () => { + test("yields on uncollected terminal, fail, or ask; not on live or collected", () => { + expect(occupancyShouldYieldWait(undefined)).toBe(false); + expect(occupancyShouldYieldWait(mapMailbox(new Map()))).toBe(false); + expect( + occupancyShouldYieldWait( + mapMailbox(new Map([["live", { status: "running" }]])), + ), + ).toBe(false); + expect( + occupancyShouldYieldWait( + mapMailbox(new Map([["done", { status: "done", report: "ok" }]])), + ), + ).toBe(true); + expect( + occupancyShouldYieldWait( + mapMailbox(new Map([["fail", { status: "failed", error: "boom" }]])), + ), + ).toBe(true); + expect( + occupancyShouldYieldWait( + mapMailbox(new Map([["ask", { status: "awaiting_director" }]])), + ), + ).toBe(true); + const collected = new Map([ + ["done", { status: "done", report: "ok", collected: true }], + ]); + expect(occupancyShouldYieldWait(mapMailbox(collected))).toBe(false); + }); +}); diff --git a/src/subagent/mailbox-mail-drive.ts b/src/subagent/mailbox-mail-drive.ts new file mode 100644 index 000000000..f9dbc29e7 --- /dev/null +++ b/src/subagent/mailbox-mail-drive.ts @@ -0,0 +1,87 @@ +/** + * Drive the parent back into a turn when uncollected mailbox terminals exist + * while Skywalker is idle. Pure: occupancy decides when to call; this module + * decides whether to drive and what to send. Sibling of fleet-dry-drive — + * per-item, not last-lane + open-tasks. + */ + +import { isLiveWaitStatus } from "./lifecycle.js"; +import { + collectUncollectedTerminals, + type CollectedWorkerReport, + type FleetDryLane, + type FleetDryMailbox, +} from "./fleet-dry-drive.js"; + +export const MAILBOX_MAIL_WAKE_PREFIX = "mailbox mail"; + +function isPromiseLike(value: unknown): value is Promise { + return typeof value === "object" && value !== null && "then" in value; +} + +export function occupancyShouldYieldWait( + mailbox: FleetDryMailbox | undefined, +): boolean { + if (mailbox === undefined) return false; + for (const id of mailbox.ids()) { + const record = mailbox.peek(id); + if (record === undefined) continue; + if (record.status === "awaiting_director") return true; + if (record.collected === true) continue; + if (!isLiveWaitStatus(record.status)) return true; + } + return false; +} + +export function buildMailboxMailPrompt( + reports: readonly CollectedWorkerReport[], +): string { + return [ + `${MAILBOX_MAIL_WAKE_PREFIX} — worker reports (already collected — do not call wait_agents for these agent_ids):`, + JSON.stringify(reports), + ].join("\n"); +} + +export function driveMailboxMail(args: { + parentProcessing: boolean; + mailbox: FleetDryMailbox | undefined; + lanes: readonly FleetDryLane[]; + beginSystemContinuation: (prompt: string) => void; + send: (prompt: string) => unknown; + onSendFailure?: () => void; +}): boolean { + if (args.parentProcessing) return false; + const reports = collectUncollectedTerminals(args.mailbox, args.lanes, false); + if (reports.length === 0) return false; + const prompt = buildMailboxMailPrompt(reports); + const takeReports = (): void => { + for (const report of reports) { + args.mailbox?.take(report.agent_id); + } + }; + const fail = (): boolean => { + args.onSendFailure?.(); + return false; + }; + try { + args.beginSystemContinuation(prompt); + const sent = args.send(prompt); + if (isPromiseLike(sent)) { + void sent.then( + (result) => { + if (result !== false) takeReports(); + else args.onSendFailure?.(); + }, + () => { + args.onSendFailure?.(); + }, + ); + return true; + } + if (sent === false) return fail(); + takeReports(); + } catch { + return fail(); + } + return true; +} diff --git a/src/tui/queued-delivery.test.ts b/src/tui/queued-delivery.test.ts index 4d271b0fc..34871ac0b 100644 --- a/src/tui/queued-delivery.test.ts +++ b/src/tui/queued-delivery.test.ts @@ -251,7 +251,7 @@ describe("createLeftoverSend", () => { expect(recorded).toEqual(["follow-up"]); }); - test("leftover send skips ingest for ask_director wake and still ingests operator prompts", async () => { + test("leftover send skips ingest for ask_director wake and mailbox mail", async () => { const sent: string[] = []; const ingested: string[] = []; const { enqueue, awaitTail } = createSessionOperationQueue(); @@ -271,11 +271,15 @@ describe("createLeftoverSend", () => { }); leftoverSend("ask_director wake — see @src/foo.ts"); + leftoverSend( + "mailbox mail — worker reports (already collected — do not call wait_agents for these agent_ids):\n[]", + ); leftoverSend("please read @src/foo.ts"); await awaitTail(); expect(ingested).toEqual(["please read @src/foo.ts"]); expect(sent).toEqual([ "ask_director wake — see @src/foo.ts", + "mailbox mail — worker reports (already collected — do not call wait_agents for these agent_ids):\n[]", "please read @src/foo.ts ingested", ]); }); diff --git a/src/tui/queued-delivery.ts b/src/tui/queued-delivery.ts index 31c169ced..fb7dccbed 100644 --- a/src/tui/queued-delivery.ts +++ b/src/tui/queued-delivery.ts @@ -11,6 +11,7 @@ import type { PendingImageAttachment } from "./image-attachments.js"; import type { ProductHostDeliver } from "./product-host.js"; import { ASK_DIRECTOR_WAKE_PREFIX } from "../subagent/fleet-report.js"; +import { MAILBOX_MAIL_WAKE_PREFIX } from "../subagent/mailbox-mail-drive.js"; export interface RouteQueuedDeliveryArgs { send: (text: string, attachments?: readonly PendingImageAttachment[]) => void; @@ -160,7 +161,8 @@ export function createLeftoverSend( ...args, hop: args.send, ingest: async (text, pending) => - text.startsWith(ASK_DIRECTOR_WAKE_PREFIX) + text.startsWith(ASK_DIRECTOR_WAKE_PREFIX) || + text.startsWith(MAILBOX_MAIL_WAKE_PREFIX) ? { text, attachments: pending } : args.ingest(text, pending), }); diff --git a/src/tui/runner/session.ts b/src/tui/runner/session.ts index 4570c00b5..8f8bb898c 100644 --- a/src/tui/runner/session.ts +++ b/src/tui/runner/session.ts @@ -25,6 +25,7 @@ import { getProcessAdmissionQueue } from "../../subagent/admission.js"; import { createSubAgentSessionStore, liveFleetCount, + occupancyShouldYieldWait, } from "../../subagent/index.js"; import { buildPluginDescriptor, @@ -302,12 +303,19 @@ export async function assembleTUISession( // construction-order cycle. const workflowHostHolder: { instance?: WorkflowHost } = {}; + const toolsetHolder: { + current?: Awaited>; + } = {}; const toolset = await createAgentToolset({ cwd: config.cwd, permissionGate, skillDirs, telemetry: liveTelemetry, isCodex: isCodexProviderName(config.providerName), + shouldYieldWait: () => { + if (state.hasQueuedSteer?.() === true) return true; + return occupancyShouldYieldWait(toolsetHolder.current?.fleetRecords); + }, ...(shellTimeout !== undefined ? { shellTimeout } : {}), ...(localSettingsForEnv?.env !== undefined ? { shellEnv: localSettingsForEnv.env } @@ -399,6 +407,7 @@ export async function assembleTUISession( profiles: () => liveAgentProfiles, }, }); + toolsetHolder.current = toolset; const { systemPrompt } = await loadSessionChatPrompt({ cwd: config.cwd, diff --git a/src/tui/runner/state.ts b/src/tui/runner/state.ts index 0b6bc81f0..c8ae26d23 100644 --- a/src/tui/runner/state.ts +++ b/src/tui/runner/state.ts @@ -290,6 +290,8 @@ export interface RunnerState { shutdownRuntime?: () => Promise; stopFleetReporting?: () => void; withFleetPublicationSuspended?: (reset: () => void) => void; + /** TUI primary: true when a queued Enter steer should yield in-flight wait_agents. */ + hasQueuedSteer?: () => boolean; } export function recordRunError(state: RunnerState, err: unknown): void { diff --git a/src/tui/runner/submit.ts b/src/tui/runner/submit.ts index c1ef24f20..8d4ee8564 100644 --- a/src/tui/runner/submit.ts +++ b/src/tui/runner/submit.ts @@ -41,6 +41,7 @@ import { tuiSendFailureMessage } from "./send-failure-message.js"; import type { ProviderFailureAttempt } from "../provider/failure-attempt.js"; import type { Agent } from "@intx/agent"; import { ASK_DIRECTOR_WAKE_PREFIX } from "../../subagent/fleet-report.js"; +import { MAILBOX_MAIL_WAKE_PREFIX } from "../../subagent/mailbox-mail-drive.js"; import { hostOf, runWhileAgentBusy, @@ -396,6 +397,7 @@ export function createDeliverRouting( recordSent: (text) => { if (text.trim().length === 0) return; if (text.startsWith(ASK_DIRECTOR_WAKE_PREFIX)) return; + if (text.startsWith(MAILBOX_MAIL_WAKE_PREFIX)) return; void appendSentMessage(state.config.cwd, state.sessionId, text).catch( (err: unknown) => { tuiLogger.debug("sent-message append failed: {error}", { diff --git a/src/tui/runner/wiring.ts b/src/tui/runner/wiring.ts index 5dbac175a..def5e8e03 100644 --- a/src/tui/runner/wiring.ts +++ b/src/tui/runner/wiring.ts @@ -22,6 +22,7 @@ import { setActiveDisposeHost } from "../../session/active-host.js"; import { createFleetWatch, driveOpenTasksAfterFleetDry, + driveMailboxMail, FLEET_REPORT_SETTLE_MS, FLEET_STALL_POLL_MS, liveFleetCount, @@ -62,7 +63,11 @@ import { type RunnerState, } from "./state.js"; import { LOG_NAMESPACE_ROOT } from "../../branding.js"; -import { buildFleetDryContinuationMessage } from "../../session/runtime-assembly.js"; +import { + buildFleetDryContinuationMessage, + buildMailboxMailMessage, +} from "../../session/runtime-assembly.js"; +import { steerCount } from "../session-queue.js"; const tuiLogger = getLogger([LOG_NAMESPACE_ROOT, "tui"]); @@ -210,8 +215,29 @@ export function wirePostStartup( }, }); }); + sessionBridge.setMailboxMailDriver(() => { + const send = state.sendWithAttemptIdentity; + if (send === undefined) return false; + return driveMailboxMail({ + parentProcessing: sessionBridge.turn.isProcessing, + mailbox: services.toolset.fleetRecords, + lanes: services.subAgentSessions.list(), + beginSystemContinuation: (prompt) => { + sessionBridge.beginSystemContinuation(prompt); + }, + send: (prompt) => send(buildMailboxMailMessage(prompt)), + onSendFailure: () => { + sessionBridge.abortSystemContinuation({ rearmDry: false }); + }, + }); + }); + sessionBridge.setWaitYieldWake(() => { + services.subAgentSessions.wake(); + }); + state.hasQueuedSteer = () => steerCount(hostOf(state).shell.session) > 0; const unsubscribeFleetReport = services.subAgentSessions.subscribe(() => { fleetWakePublisher.publish(); + sessionBridge.flushMailboxMail(); if (fleetSettle !== null) return; fleetSettle = setTimeout(() => { fleetSettle = null; @@ -226,6 +252,8 @@ export function wirePostStartup( if (fleetSettle !== null) clearTimeout(fleetSettle); unsubscribeFleetReport(); sessionBridge.setDryOpenTaskDriver(undefined); + sessionBridge.setMailboxMailDriver(undefined); + sessionBridge.setWaitYieldWake(undefined); }; // Registered slash-command names only — bare skill/agent words stay unstyled. diff --git a/src/tui/runtime-bridge.test.ts b/src/tui/runtime-bridge.test.ts index 897f1a55e..d63fdbccc 100644 --- a/src/tui/runtime-bridge.test.ts +++ b/src/tui/runtime-bridge.test.ts @@ -2118,6 +2118,274 @@ describe("fleet-dry open-task drive (CL-7540)", () => { }); }); +describe("mailbox mail occupancy (CL-7518)", () => { + function settleToollessTurn( + bridge: ReturnType, + ): void { + bridge.handle({ type: "inference.start", data: {} }); + bridge.handle({ type: "inference.done", data: {} }); + } + + test("idle-with-fleet child-done while siblings run flushes mailbox mail", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "idle", + }); + const port = createRecordingPort(); + const bridge = attachSessionBridge(shell, port); + try { + const prompt = + "mailbox mail — worker reports (already collected — do not call wait_agents for these agent_ids):\n[]"; + let terminals = 0; + let drives = 0; + bridge.setMailboxMailDriver(() => { + if (terminals === 0) return false; + drives += 1; + bridge.beginSystemContinuation(prompt); + return true; + }); + bridge.submit("dispatch workers", "immediate"); + bridge.handle({ type: "fleet", running: 2 }); + settleToollessTurn(bridge); + expect(drives).toBe(0); + terminals = 1; + bridge.handle({ type: "fleet", running: 1 }); + expect(drives).toBe(1); + expect(shell.session.run).toBe("busy"); + } finally { + bridge.dispose(); + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); + + test("fail wake is the same idle-with-fleet flush", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "busy", + }); + const port = createRecordingPort(); + const bridge = attachSessionBridge(shell, port); + try { + let drives = 0; + bridge.handle({ type: "fleet", running: 1 }); + settleToollessTurn(bridge); + bridge.setMailboxMailDriver(() => { + drives += 1; + return true; + }); + expect(drives).toBe(0); + bridge.flushMailboxMail(); + expect(drives).toBe(1); + } finally { + bridge.dispose(); + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); + + test("does not flush while the parent is processing", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "idle", + }); + const port = createRecordingPort(); + const bridge = attachSessionBridge(shell, port); + try { + let drives = 0; + bridge.setMailboxMailDriver(() => { + drives += 1; + return true; + }); + bridge.submit("dispatch workers", "immediate"); + bridge.handle({ type: "fleet", running: 1 }); + bridge.flushMailboxMail(); + expect(drives).toBe(0); + } finally { + bridge.dispose(); + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); + + test("skips mailbox mail when a fleet-dry open-task shot is latched", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "idle", + }); + const port = createRecordingPort(); + const bridge = attachSessionBridge(shell, port); + try { + let mail = 0; + let dry = 0; + let allowMail = false; + bridge.setMailboxMailDriver(() => { + if (!allowMail) return false; + mail += 1; + return true; + }); + bridge.setDryOpenTaskDriver(() => { + dry += 1; + bridge.beginSystemContinuation( + "The fleet has gone dry. Remaining open tasks:\n- t1: keep going (todo)\n", + ); + return true; + }); + bridge.submit("dispatch workers", "immediate"); + bridge.handle({ type: "fleet", running: 1 }); + settleToollessTurn(bridge); + allowMail = true; + bridge.handle({ type: "fleet", running: 0 }); + expect(dry).toBe(1); + expect(mail).toBe(0); + } finally { + bridge.dispose(); + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); + + test("no double-deliver: second flush is a no-op after the driver takes", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "busy", + }); + const port = createRecordingPort(); + const bridge = attachSessionBridge(shell, port); + try { + let remaining = 1; + let drives = 0; + bridge.handle({ type: "fleet", running: 1 }); + settleToollessTurn(bridge); + bridge.setMailboxMailDriver(() => { + if (remaining === 0) return false; + remaining = 0; + drives += 1; + return true; + }); + bridge.flushMailboxMail(); + bridge.flushMailboxMail(); + expect(drives).toBe(1); + } finally { + bridge.dispose(); + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); + + test("queued steer while wait is in-flight wakes occupancy yield", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "idle", + }); + const port = createRecordingPort(); + const bridge = attachSessionBridge(shell, port); + try { + let wakes = 0; + bridge.setWaitYieldWake(() => { + wakes += 1; + }); + bridge.submit("waiting on workers", "immediate"); + bridge.submit("steer now", "steer"); + expect(wakes).toBe(1); + } finally { + bridge.dispose(); + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); + + test("live fleet Enter still starts a new primary turn", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: true, + run: "busy", + }); + const port = createRecordingPort(); + const bridge = attachSessionBridge(shell, port); + try { + bridge.handle({ type: "fleet", running: 2 }); + settleToollessTurn(bridge); + port.clear(); + shell.prompt.value = "also update the docs"; + shell.prompt.submit(); + expect(port.calls.some((c) => c.op === "enqueue")).toBe(false); + expect(port.calls.some((c) => c.op === "sendImmediate")).toBe(true); + } finally { + bridge.dispose(); + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); + + test("mailbox mail send abort does not latch the fleet-dry skip", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "busy", + }); + const port = createRecordingPort(); + const bridge = attachSessionBridge(shell, port); + try { + let drives = 0; + bridge.handle({ type: "fleet", running: 1 }); + settleToollessTurn(bridge); + bridge.beginSystemContinuation( + "mailbox mail — worker reports (already collected — do not call wait_agents for these agent_ids):\n[]", + ); + bridge.abortSystemContinuation({ rearmDry: false }); + bridge.setMailboxMailDriver(() => { + drives += 1; + return true; + }); + bridge.flushMailboxMail(); + expect(drives).toBe(1); + } finally { + bridge.dispose(); + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); +}); + describe("syncAgentProgress", () => { function taskSession( over: Partial, diff --git a/src/tui/runtime-bridge.ts b/src/tui/runtime-bridge.ts index 00ac3067e..09ef042c3 100644 --- a/src/tui/runtime-bridge.ts +++ b/src/tui/runtime-bridge.ts @@ -252,17 +252,33 @@ export interface SessionBridge { beginSystemContinuation: (text: string) => void; /** * Occupancy send failed after beginSystemContinuation. Drop the occupancy - * echo so a later matching inbound is not swallowed, re-arm the dry-episode - * latch, drop the continuation hold, and idle so follow-ups can drain and a - * later settle can take another occupancy shot. + * echo so a later matching inbound is not swallowed, drop the continuation + * hold, and idle so follow-ups can drain. Pass rearmDry:false for mailbox + * mail so a later subscribe can retry; fleet-dry defaults to re-arming the + * latch for the next settle shot. */ - abortSystemContinuation: () => void; + abortSystemContinuation: (opts?: { rearmDry?: boolean }) => void; /** * Occupancy owner for dry+open continuation. Called once per dry episode * from settleRunToIdle when the fleet is dry. Return true if a continuation * was sent (run stays busy). */ setDryOpenTaskDriver: (driver: (() => boolean) | undefined) => void; + /** + * Occupancy owner for per-item mailbox mail. Called from idle-with-fleet + * settle (like flushPendingAskWake) and from the store-subscribe driver. + */ + setMailboxMailDriver: (driver: (() => boolean) | undefined) => void; + /** + * Wake in-flight wait_agents when the operator queues a steer. Timeout-shaped + * yield — workers are not interrupted. + */ + setWaitYieldWake: (wake: (() => void) | undefined) => void; + /** + * Occupancy flush for mailbox mail. No-op while the parent is processing. + * Skip when a fleet-dry open-task shot is about to run. + */ + flushMailboxMail: () => void; } const NOOP_PORT: SessionPort = { @@ -424,6 +440,11 @@ export interface BridgeBag { * settle path (`settleRunToIdle`) and `gateClosed` re-enter through it. */ flushPendingAskWake: (() => void) | null; + /** + * Occupancy flush for per-item mailbox mail. Set inside + * `attachSessionBridge` so settle and fleet events share one gate. + */ + flushMailboxMail: (() => void) | null; /** * One occupancy shot per dry episode. Reset when a live lane starts. Consumed * only when the driver actually sends a continuation — a no-op (no open @@ -439,6 +460,14 @@ export interface BridgeBag { awaitingContinuationInference: boolean; /** Occupancy driver: collect+send when settle takes a dry-episode shot. */ dryOpenTaskDriver: (() => boolean) | undefined; + /** + * Occupancy driver for per-item mailbox mail (worker terminal/fail) while + * the parent is idle, including idle-with-fleet. Not the fleet-0+open-tasks + * edge — that stays on dryOpenTaskDriver. + */ + mailboxMailDriver: (() => boolean) | undefined; + /** Wake in-flight wait_agents when a steer is queued (timeout-shaped yield). */ + waitYieldWake: (() => void) | undefined; /** Last prompt actually sent — replay source for the quota auto-retry. */ lastSentMessage: string; lastSentOrigin: "composer" | "internal" | null; @@ -1038,6 +1067,7 @@ function settleRunToIdle(shell: AppShell, bag: BridgeBag): void { // each one starts its own turn — while follow-ups keep waiting. drainSteersAtBoundary(shell, bag); bag.flushPendingAskWake?.(); + bag.flushMailboxMail?.(); return; } if (!bag.droveOpenTasksThisDry) { @@ -1059,6 +1089,7 @@ function settleRunToIdle(shell: AppShell, bag: BridgeBag): void { // Full drain: soft steers first, then follow-ups (drainOrder). drainAtBoundary(shell, bag); bag.flushPendingAskWake?.(); + bag.flushMailboxMail?.(); } function applyInbound( @@ -1083,6 +1114,8 @@ function applyInbound( } if (event.running === 0 && !bag.turn.isProcessing) { settleRunToIdle(shell, bag); + } else if (event.running > 0 && !bag.turn.isProcessing) { + bag.flushMailboxMail?.(); } paintChrome(shell); return; @@ -1185,9 +1218,12 @@ export function attachSessionBridge( pendingAskWake: new Map(), deliveredAskWake: new Map(), flushPendingAskWake: null, + flushMailboxMail: null, droveOpenTasksThisDry: false, awaitingContinuationInference: false, dryOpenTaskDriver: undefined, + mailboxMailDriver: undefined, + waitYieldWake: undefined, lastSentMessage: "", lastSentOrigin: null, quotaFired: false, @@ -1500,6 +1536,7 @@ export function attachSessionBridge( ? enqueueSteer(shell.session, t, undefined, attachments) : enqueue(shell.session, t, "queue", undefined, attachments); bag.port.enqueue(t, kind); + if (kind === "steer") bag.waitYieldWake?.(); // Show the message itself, not the internal transition ("queue +1 → // pending N") — the notice row already carries the depth once, in plain // language, so this row's job is making the pending item identifiable. @@ -1541,6 +1578,16 @@ export function attachSessionBridge( }; bag.flushPendingAskWake = flushPendingAskWake; + const flushMailboxMail = (): void => { + if (bag.disposed || bag.turn.isProcessing) return; + try { + bag.mailboxMailDriver?.(); + } catch { + // Occupancy miss is retryable on the next idle/subscribe edge. + } + }; + bag.flushMailboxMail = flushMailboxMail; + const doInterrupt = (): void => { if (bag.disposed) return; closeOpenRow(shell, bag); @@ -1564,6 +1611,7 @@ export function attachSessionBridge( bag.turn = turnStateOnInterrupt(bag.turn, now()); paintPhase(); flushPendingAskWake(); + flushMailboxMail(); }; const clearQueuedDelivery = (): void => { if (bag.disposed) return; @@ -1596,6 +1644,7 @@ export function attachSessionBridge( bag.turn = turnStateGateClosed(bag.turn, now()); paintPhase(); flushPendingAskWake(); + flushMailboxMail(); }; const tick = (): void => { @@ -1731,7 +1780,7 @@ export function attachSessionBridge( paintChrome(shell); paintPhase(); }, - abortSystemContinuation: () => { + abortSystemContinuation: (opts) => { if (bag.disposed) return; if (bag.awaitingContinuationInference) { const occupancy = bag.lastSentMessage; @@ -1746,7 +1795,9 @@ export function attachSessionBridge( } } bag.awaitingContinuationInference = false; - bag.droveOpenTasksThisDry = false; + if (opts?.rearmDry !== false) { + bag.droveOpenTasksThisDry = false; + } bag.lastSentMessage = ""; flushOpenRow(shell, bag); bag.turnThinking = null; @@ -1759,6 +1810,15 @@ export function attachSessionBridge( setDryOpenTaskDriver: (driver) => { bag.dryOpenTaskDriver = driver; }, + setMailboxMailDriver: (driver) => { + bag.mailboxMailDriver = driver; + }, + setWaitYieldWake: (wake) => { + bag.waitYieldWake = wake; + }, + flushMailboxMail: () => { + flushMailboxMail(); + }, dispose: () => { flushOpenRow(shell, bag); bag.disposed = true; @@ -1766,6 +1826,7 @@ export function attachSessionBridge( bag.pendingAskWake.clear(); bag.deliveredAskWake.clear(); bag.flushPendingAskWake = null; + bag.flushMailboxMail = null; applyCadence(null); clearShellBridgeHooks(shell); bridges.delete(shell);