From 1f8fa5a5ca776f1f752396621b0700fa218b0c19 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 12 Oct 2026 22:49:38 -0700 Subject: [PATCH] docs(fleet): document behavior changes that landed undocumented since v0.3.37 --- docs/ARCHITECTURE.md | 8 +++++++- docs/IMPLEMENTATION.md | 27 +++++++++++++++++++++++++++ docs/TUI.md | 30 ++++++++++++++++++++++-------- 3 files changed, 56 insertions(+), 9 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d705a8b6c..51cc275ac 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -253,6 +253,12 @@ The **`spawn_agent`** tool starts a fleet agent on a separate inference source ( 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. +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. + +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. + +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`). + 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. 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 - **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. - **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`. -- **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. +- **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). - `spawn_agent` + `wait_agents` is the only spawn path. The tier check still gates which packages may mount any fleet verb. #### Closed director fleet (`src/agent/directors/`) diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 7a8cb49a9..2b1a8360b 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -483,6 +483,33 @@ by a history rewrite (compaction) are removed from the tree on the next commit. per-commit git tree still grows one entry per spilled tool-output blob across the session; that tree re-write is inherent to git and left as residual cost. +The wrapper also owns the on-disk transcript through a claim marker so two +writer processes cannot silently fork one history. The first appendable write +creates `session-owner.json` with an exclusive-create (`wx`) write; success +means the writer owns the existing log. A writer that finds a marker owned by +another process either reads the log back (append, when the live turns are a +prefix of what it intends to write) or repairs it in place (rewrite, when the +incoming turns are a compaction of the live log), and otherwise diverts. +Diversion abandons the shared base names and re-binds turns and prompts to +unique versioned bases, `turns--.jsonl` and the same shape for +prompt (`versionedBaseName`), so the other owner's history is never touched +and versioned segments cannot be mistaken for live numbered segments by a +base reader. Epoch+uuid avoids numbered segments (`turns-0001.jsonl`) that +base readers treat as live history. + +A rewrite is also a repair of a shared log, so it is staged in memory until +the next `commit()` lands it unchanged: the wrapper publishes the staged +rewrite to the working-tree segments, stages the resulting paths, and only +then commits; a failed commit restores the published generation and leaves +the rewrite staged for a retry. What counts as a legitimate rewrite of the +shared log is a pure predicate, so an unrelated content change cannot pose as +compaction: `isCompactionOf` requires the incoming turns to strip compaction +markers, carry at least one summary turn, and project their remaining tail +from the live log — each projected turn keeps role and tool-call ids while +`projectsFrom` allows only content-preserving projections of the shared log +(excerpt shortening, thinking-only omission stubs, projected tool results, or +a coalesced plain-text span). Anything else diverts. + ### Crash Logging `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//errors/.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. diff --git a/docs/TUI.md b/docs/TUI.md index 2073ac17a..2556d9c25 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -317,13 +317,20 @@ REQUIRED or QUEUED INPUT strips are also visible, they stack between it and the prompt box, in that order: ``` -◆ WORKER WAITING · director reply needed (send_input) · builder (copy assets): Which destination path should I use? (+1 more) +◆ 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) ◆ INPUT REQUIRED · operator answer needed · Which environment should I deploy to? ◆ QUEUED INPUT · 1 ``` -It names the worker, previews its question, and says the director owns the -reply. It is display only: composer text still goes to the primary director, +It names the worker, previews its assessed decision, and says the director +owns the reply. When the ask carries an escalation assessment, the preview is +the question joined with that decision summary (`workerWaitDecisionSummary` +in `src/tui/worker-wait.ts`): the escalation `classification`, the blocked +`outcome`, the `minimum` authority (or the minimum addition when no authority +is required), the decline `consequence`, a `recommendation` or `safeDefault` +line when the worker supplied one, and the `target` session — not the bare +question; an ask without an assessment previews the bare question. It is +display only: composer text still goes to the primary director, and only the director's `send_input` to the worker's session answers the worker. The strip reads the `agent-ask` pending snapshot and nothing else (`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 leave the session parked with no recovery path short of killing the process; Escape must always settle the promise it is dismissing. -The permissions prompt's title line names `/yolo` as the way to skip further -prompts, longest first as the terminal narrows: `Esc cancel · Enter choose · -/yolo skip prompts`, then `Esc · Enter · /yolo`, then `Esc · Enter`. Operator +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 questions and the model/provider picker do not advertise `/yolo`. 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 overlay host border and title use calm dim chrome (`UI.textDim`); consequence impact in the description zone paints `UI.warning` (sand), not orange. A blank row separates the subject from context. Choices are deliberately small: -each one is a bare, single-line action name (`Reject`, `Accept once`, the -scope's label) with no consequence text folded into the row. A scope's hint +each one is a bare, single-line action name (`Reject`, `Reject and stop`, +`Accept once`, the scope's label) with no consequence text folded into the row. +Every permission overlay carries the standing `Reject and stop` row between +`Reject` and `Accept once` (`permissionChoicesFromRequest` in +`src/tui/gate-wire.ts`). Selecting it settles the open gate as a plain deny — +no grant is minted, even when a persistable scope is offered — and then stops +the turn like Ctrl+C (`stopDecisionGate` in `shell/overlay-host.ts`): the +in-flight turn aborts and every queued permission gate is dropped, each +resolving as a deny that also mints no grant. Plain `Reject` declines only the +open gate and leaves the turn running; `Accept once` grants without +persisting, and a scope row persists that grant. A scope's hint paints instead as a body message above the choice list (`permissionBodyFromRequest` in `src/tui/gate-wire.ts`), and the expand key, which binds only when the subject carries collapsed payloads, reveals the