Skip to content

Commit 1f8fa5a

Browse files
committed
docs(fleet): document behavior changes that landed undocumented since v0.3.37
1 parent ac8892b commit 1f8fa5a

3 files changed

Lines changed: 56 additions & 9 deletions

File tree

‎docs/ARCHITECTURE.md‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,12 @@ The **`spawn_agent`** tool starts a fleet agent on a separate inference source (
253253

254254
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. On a nested orchestrator the question arrives as mailbox mail with an `awaiting_director` status — that is not terminal. Once a parked ask is surfaced (TUI wake, nested mailbox mail, or a successful `list_agents`), further `list_agents` calls fail closed until **`send_input`** answers or the ask is dropped — `list_agents` is not a poll. The parent answers with **`send_input`**, then continues. Escalate to the human with **`ask_operator`** only when the parent cannot resolve it.
255255

256+
The ask is never prose-only: `ask_director` requires a typed **`escalation`** object (`src/subagent/escalation-policy.ts`, `ask-director.ts`) with `policyVersion` plus the required facts — `classification`, `blockedOutcome`, `unavailableDirectorPath`, `permittedAlternatives`, `minimumAddition`, and `declineConsequence` (each a non-empty string, byte-bounded). It may also carry `recommendation`, `safeDefault`, `requestedMechanism` versus `minimumAuthority`, and an optional `verification` detail (`verificationOutcome`, `rootCause`, `attemptedNarrowChecks`, `reducedConfidence`). The five `classification` values decide where the ask settles (`decideEscalation`): `routine` never parks — the worker continues with one of its displayed permitted alternatives and the evaluation is recorded for audit (`routineEvaluations`, newest-only cap); it requires at least one alternative with `comparableConfidence: true`. `director_resolvable` also continues internally when such a comparable alternative exists and parks otherwise. `operator_decision_required`, `outcome_blocked`, and `irreversible_or_sensitive` always park — the first and last require an explicit `minimumAuthority`, which must differ from `requestedMechanism` when both are present — and every parked classification requires at least one permitted alternative to show what was already considered.
257+
258+
The parent resolves a parked ask with **`send_input`**'s optional structured `resolution` (`questionId`, `kind`, `answer`; `parseEscalationResolution` validates it at the tool boundary). The four kinds are `director_answer` (normal answer), `declined`, `unavailable`, and `minimum_grant_available` (resume by text only — it grants nothing and leaves a denied-call envelope unconsumed). A resolution is bound to the pending `questionId` — a stale or mismatched id is refused so a late answer cannot settle a newer question — and is mutually exclusive with `interrupt`. The resolution's message is delivered to the worker together with the structured answer; neither can silently drop the other. A resolution never grants authority or retries a tool — a denied call still needs the envelope-owned covering-grant retry path described above.
259+
260+
Projections keep the policy facts visible: wait JSON and `list_agents` carry the pending ask's `question`, `questionId`, and `assessment`, and the session's `terminal_outcome` (`verification_blocked` when a `declined`/`unavailable` resolution lands on an ask that carried a `verification` detail, `declined` for a plain decline/unavailability, or `verified` when the worker reports a successful verification in place of a blocked one) is projected through wait JSON, `list_agents`, and mailbox mail (`src/subagent/agent-fleet.ts`, `fleet-report.ts`).
261+
256262
When the parent TUI is not blocked in an in-flight tool call, 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 delivery-queue's idle-send path without entering the user follow-up queue or composer `/feedback` capture.
257263

258264
When profiles exist (local `.agents/agents/` and/or enabled **`kind: "agent"`** plugins, including **data-only** markdown plugins with no `index.ts`), the chat model also receives **`search_agents`** — a lexical index over profile id, description, and role text so the model can discover ids before calling `spawn_agent(agent=...)`. Default results are id, description, and spawn metadata (orchestrator flag, source). Pass `include_body=true` to include each match's loaded system prompt / body (truncated) so the parent can inspect plugin or Claude marketplace agents without `read_file` on paths outside the session cwd (path-escape blocks those roots by design; writes remain blocked). `spawn_agent` and `search_agents` are core tools on the primary session.
@@ -273,7 +279,7 @@ Enforcement is runtime code at the existing tool-mount point, not prompt wording
273279

274280
- **Mount-time gate — live today, and fails closed.** `spawn_agent` 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 `spawn_agent` rejects a profile-sourced orchestrator before starting a session. `FLEET_VERBS` in `authority.ts` names the live verbs (`spawn_agent`, `wait_agents`, `list_agents`, `send_input`, `interrupt_agent`, `close_agent`, `resume_agent`, `read_agent_trace`, `search_agents`) so every mount site inherits the same gate. `list_agents` is the non-blocking mailbox-scoped list of this install's own `spawn_agent` workers (same scope as `wait_agents`); nested orchestrators may mount it. Fleet discovery (`search_agents`) remains Tier 1 only.
275281
- **Subtree authority — wired for addressing verbs.** `assertCanTargetAgent(actor, targetId, nodes)` implements the "root owns its tree; a child manages only its own descendants" rule over the `{id, parentSessionId}` shape `SubAgentSessionStore` already tracks. Production call sites: `read_agent_trace`, `wait_agents` explicit targets, `send_input`, `interrupt_agent`, `close_agent`, and `resume_agent`. Nested mounts pass `{actorId, tier, getNodes}` from `run.ts`; a missing `actorId` fails closed. Tier-1 primary omits authority and stays unrestricted. `spawn_agent` records `parentSessionId` on nested workers so `close_agent`'s descendant walk can see them. `wait_agents` with omitted targets waits only on that caller's per-install wait mailbox over the shared session store, not every running session. Wait JSON is a projection of stored lifecycle plus mailbox membership/pin/collected/interrupt override — not a second terminal store. `list_agents` reports that same mailbox without blocking. `interrupt_agent` stamps the session `interrupted` and, with `close_agent`, writes the mailbox interrupt overlay; `send_input` with `interrupt:true` does not. `send_input` with `interrupt:true` leaves wait live (`running`/`queued`) until the followup settles. `close_agent` terminalizes the wait mailbox before teardown. Operator cancel (`cancel` / `cancelAll`) projects wait status `interrupted`.
276-
- **Leaf `ask_director` (CL-6945).** Tier 3 leaves mount `ask_director` (not `ask_operator`). The worker evaluates caps, then awaits a session-store port; a missing port returns an error and does not suspend. `wait_agents` projects `awaiting_director` with `question` / `question_id` / `description` — this is wait JSON only, not a `WorkerLifecycle` state. Re-wait while still pending re-delivers the same question. Soft `send_input` answers the pending ask (it does not deliver a steer inbound). Interrupt / settle / close cancel the ask, including descendants. A worker blocked in `ask_director` is not stall-salvaged.
282+
- **Leaf `ask_director` (CL-6945).** Tier 3 leaves mount `ask_director` (not `ask_operator`). The worker evaluates caps, then awaits a session-store port; a missing port returns an error and does not suspend. `wait_agents` projects `awaiting_director` with `question` / `question_id` / `description` — this is wait JSON only, not a `WorkerLifecycle` state. Re-wait while still pending re-delivers the same question. Soft `send_input` answers the pending ask (it does not deliver a steer inbound). Interrupt / settle / close cancel the ask, including descendants. A worker blocked in `ask_director` is not stall-salvaged — stall pings are dropped for the park duration — but a park is not unbounded: parked asks expire after `ASK_DEADLINE_MS` (30 minutes) via `expireStaleAsks` (`src/subagent/session-store.ts`), which settles the ask with a timeout error naming question and session and expires any denied-call envelope the ask carried (lapsed grants fail closed rather than lingering for a later retry).
277283
- `spawn_agent` + `wait_agents` is the only spawn path. The tier check still gates which packages may mount any fleet verb.
278284

279285
#### Closed director fleet (`src/agent/directors/`)

‎docs/IMPLEMENTATION.md‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,33 @@ by a history rewrite (compaction) are removed from the tree on the next commit.
483483
per-commit git tree still grows one entry per spilled tool-output blob across the
484484
session; that tree re-write is inherent to git and left as residual cost.
485485

486+
The wrapper also owns the on-disk transcript through a claim marker so two
487+
writer processes cannot silently fork one history. The first appendable write
488+
creates `session-owner.json` with an exclusive-create (`wx`) write; success
489+
means the writer owns the existing log. A writer that finds a marker owned by
490+
another process either reads the log back (append, when the live turns are a
491+
prefix of what it intends to write) or repairs it in place (rewrite, when the
492+
incoming turns are a compaction of the live log), and otherwise diverts.
493+
Diversion abandons the shared base names and re-binds turns and prompts to
494+
unique versioned bases, `turns-<epoch>-<uuid>.jsonl` and the same shape for
495+
prompt (`versionedBaseName`), so the other owner's history is never touched
496+
and versioned segments cannot be mistaken for live numbered segments by a
497+
base reader. Epoch+uuid avoids numbered segments (`turns-0001.jsonl`) that
498+
base readers treat as live history.
499+
500+
A rewrite is also a repair of a shared log, so it is staged in memory until
501+
the next `commit()` lands it unchanged: the wrapper publishes the staged
502+
rewrite to the working-tree segments, stages the resulting paths, and only
503+
then commits; a failed commit restores the published generation and leaves
504+
the rewrite staged for a retry. What counts as a legitimate rewrite of the
505+
shared log is a pure predicate, so an unrelated content change cannot pose as
506+
compaction: `isCompactionOf` requires the incoming turns to strip compaction
507+
markers, carry at least one summary turn, and project their remaining tail
508+
from the live log — each projected turn keeps role and tool-call ids while
509+
`projectsFrom` allows only content-preserving projections of the shared log
510+
(excerpt shortening, thinking-only omission stubs, projected tool results, or
511+
a coalesced plain-text span). Anything else diverts.
512+
486513
### Crash Logging
487514

488515
`index.ts` installs `uncaughtException` and `unhandledRejection` handlers (and catches a rejected `main`). Each calls `writeCrashReport` (`crash/report.ts`) to write a best-effort report to `~/.corbits/projects/<project-key>/errors/<timestamp>.txt`, using the same `projectKeyFor`/`projectSessionsRoot` (`session/project-key.ts`) that keys that project's session directories, so a crash report lands next to the session's `run.json` and transcript rather than under a separately computed slug. The file records the failure kind, an ISO timestamp, the cwd, and the stack. `projectSessionsRoot` shells out to git with no timeout, so the handler never calls it directly — `primeCrashReporting` resolves and caches the directory once at startup (right after config load), and `writeCrashReport` only ever reads that cached value; if priming never ran or failed, it falls back to an `unresolved` bucket rather than touching git mid-crash. `writeCrashReport` swallows its own errors and returns `null` without logging; the handler in `index.ts` is what prints the one-line failure notice to stderr when that happens, then exits non-zero.

‎docs/TUI.md‎

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -317,13 +317,20 @@ REQUIRED or QUEUED INPUT strips are also visible, they stack between it and
317317
the prompt box, in that order:
318318

319319
```
320-
◆ WORKER WAITING · director reply needed (send_input) · builder (copy assets): Which destination path should I use? (+1 more)
320+
◆ WORKER WAITING · director reply needed (send_input) · builder (copy assets): Which destination path should I use?; classification: director_resolvable; outcome: the export name is ambiguous in the current tree; minimum: read docs/ARCHITECTURE.md and pick; consequence: the rename lands under the wrong contract; recommendation: keep the existing export name; target: wkr-9f22 (+1 more)
321321
◆ INPUT REQUIRED · operator answer needed · Which environment should I deploy to?
322322
◆ QUEUED INPUT · 1
323323
```
324324

325-
It names the worker, previews its question, and says the director owns the
326-
reply. It is display only: composer text still goes to the primary director,
325+
It names the worker, previews its assessed decision, and says the director
326+
owns the reply. When the ask carries an escalation assessment, the preview is
327+
the question joined with that decision summary (`workerWaitDecisionSummary`
328+
in `src/tui/worker-wait.ts`): the escalation `classification`, the blocked
329+
`outcome`, the `minimum` authority (or the minimum addition when no authority
330+
is required), the decline `consequence`, a `recommendation` or `safeDefault`
331+
line when the worker supplied one, and the `target` session — not the bare
332+
question; an ask without an assessment previews the bare question. It is
333+
display only: composer text still goes to the primary director,
327334
and only the director's `send_input` to the worker's session answers the
328335
worker. The strip reads the `agent-ask` pending snapshot and nothing else
329336
(`src/tui/worker-wait.ts`, painted by `paintChrome` in `shell/chrome.ts`). An
@@ -479,9 +486,7 @@ because an earlier version could abandon the awaited promise on Escape and
479486
leave the session parked with no recovery path short of killing the process;
480487
Escape must always settle the promise it is dismissing.
481488

482-
The permissions prompt's title line names `/yolo` as the way to skip further
483-
prompts, longest first as the terminal narrows: `Esc cancel · Enter choose ·
484-
/yolo skip prompts`, then `Esc · Enter · /yolo`, then `Esc · Enter`. Operator
489+
The permissions prompt's title line is a four-tier ladder of key hints, longest first as the terminal narrows: `Esc cancel · Enter choose · Ctrl+C stop · /yolo skip prompts`, then `Esc · Enter · Ctrl+C stop · /yolo`, then `Esc · Enter · Ctrl+C stop`, and finally the stop key alone — `Ctrl+C stop` — which is retained down to contentWidth 28 (interior 26). Below that even the last tier does not fit and the title falls back to ` Permissions` (`PERMISSIONS_HINTS` in `src/tui/overlay-view.ts`). Operator
485490
questions and the model/provider picker do not advertise `/yolo`.
486491

487492
Once a permission or operator prompt is answered — or cancelled, timed out,
@@ -501,8 +506,17 @@ subject in the action color — the only Breakthrough Orange on the card. The
501506
overlay host border and title use calm dim chrome (`UI.textDim`); consequence
502507
impact in the description zone paints `UI.warning` (sand), not orange. A
503508
blank row separates the subject from context. Choices are deliberately small:
504-
each one is a bare, single-line action name (`Reject`, `Accept once`, the
505-
scope's label) with no consequence text folded into the row. A scope's hint
509+
each one is a bare, single-line action name (`Reject`, `Reject and stop`,
510+
`Accept once`, the scope's label) with no consequence text folded into the row.
511+
Every permission overlay carries the standing `Reject and stop` row between
512+
`Reject` and `Accept once` (`permissionChoicesFromRequest` in
513+
`src/tui/gate-wire.ts`). Selecting it settles the open gate as a plain deny —
514+
no grant is minted, even when a persistable scope is offered — and then stops
515+
the turn like Ctrl+C (`stopDecisionGate` in `shell/overlay-host.ts`): the
516+
in-flight turn aborts and every queued permission gate is dropped, each
517+
resolving as a deny that also mints no grant. Plain `Reject` declines only the
518+
open gate and leaves the turn running; `Accept once` grants without
519+
persisting, and a scope row persists that grant. A scope's hint
506520
paints instead as a body message above the choice list
507521
(`permissionBodyFromRequest` in `src/tui/gate-wire.ts`), and the expand key,
508522
which binds only when the subject carries collapsed payloads, reveals the

0 commit comments

Comments
 (0)