Files
hmbown--codewhale/docs/COMMAND_CONTROL_PLANE.md
Hmbown 1ebb684be6 docs: document the command control plane and the Fleet/Lane verbs
Add `docs/COMMAND_CONTROL_PLANE.md` describing the shared contract: stable
`<domain>.<verb>` ids, read-vs-write authority, persistence scope, exact
target selection, typed unavailability, and the bounded receipt. Update
`docs/FLEET.md` for the new verbs and the `/fleet status` vs `/fleet
workers` split, and record both entries in the changelog.

Refs #1888, #4022.
2026-07-27 03:13:33 -07:00

148 lines
7.0 KiB
Markdown

# Shared command / control-plane contract
Issues #1888 and #4022.
Codewhale exposes the same lifecycle operations on three surfaces: a slash
command typed into the composer, a bound hotbar slot, and a CLI entrypoint.
Before this contract those three could — and did — drift: `/fleet status`
showed the current session's sub-agents while `codewhale fleet status` read the
durable ledger, and the CLI's Lane verbs had no slash equivalent at all.
The contract is one typed descriptor table plus one executor per domain, in
[`crates/lane/src/control.rs`](../crates/lane/src/control.rs) and
[`crates/tui/src/fleet/control.rs`](../crates/tui/src/fleet/control.rs).
`codewhale-lane` is the lowest crate the thin CLI facade and the TUI both
already depend on, so there is exactly one place the contract can live without
forking.
## Vocabulary
Unchanged and load-bearing: **Fleet = who**, **Workflow = order**, **Lane = one
running Workflow**, **Runtime = where/how**. Auto-Review is a permission
posture, never a reviewer role. There is no "Operation" product noun; the
internal `ControlOperation` type names control-plane *verbs* and never appears
in user-facing copy.
## What a descriptor pins down
Every `(domain, verb)` pair has exactly one `OperationDescriptor`, keyed by a
stable id of the form `<domain>.<verb>`:
| Field | Meaning |
| --- | --- |
| `id` | `lane.status`, `fleet.interrupt`, … — the same string on every surface and in every receipt |
| `authority` | `read` or `write`. Not a permission posture: it says whether the verb observes durable state or mutates it |
| `persistence` | Which durable store the effect lands in (`lane_registry`, `fleet_ledger`) |
| `target` | What exact identity it acts on (`none`, `lane_run`, `fleet_worker`, `fleet_run`) |
| `retry` | `idempotent` or `unsafe` |
| `surfaces` | Which surfaces offer it |
| `backend` | `Implemented`, `NotImplemented { hint }`, or `SurfaceLimited { available_on, hint }` |
| `slash_command` / `cli_invocation` | The exact bindings; the hotbar action id is always `slash.<slash_command>` |
The verb table today:
| Verb | Lane | Fleet |
| --- | --- | --- |
| `list` | read, whole registry | read, whole ledger |
| `status` | read, one Lane | read, whole ledger |
| `interrupt` | write, one Lane (idempotent) | write, one worker (idempotent) |
| `restart` | **no backend** — a Lane is re-created, not restarted | CLI-only (drives the manager loop) |
| `resume` | **no backend** — a stopped Lane's Runtime session is gone | write, one run (idempotent) |
## No surface advertises what it cannot do
`OperationDescriptor::availability(surface, ctx)` returns either `Available` or
a typed `UnavailableReason` with a sanitized hint:
- `backend_not_implemented` — nobody has built it. Every surface refuses.
- `surface_not_supported` — the backend exists but not here. The hint names the
surface that works (`codewhale fleet restart <worker-id>`).
- `no_lane_registry` / `no_fleet_ledger` — the durable store does not exist yet.
Availability is probed **read-only**. `LaneRegistry::open_default` and
`FleetManager::open` both create their store as a side effect, so a status verb
probes `lane_registry_root()` / `fleet_ledger_path()` first. Otherwise "this
workspace has no Fleet ledger" silently becomes "here is an empty Fleet ledger
I just made".
## Exact run identity
`parse_target` is the single target parser for all three surfaces: exactly one
token, exact ids only (no prefix or fuzzy matching), ASCII alphanumerics plus
`-`, `_`, `.`, no path separators, and a hard reject when a targetless verb is
handed an argument.
A write may be **fenced** by appending `@<lifecycle-seq>`:
```
codewhale lane interrupt lane-a1b2c3d4@3
/lane interrupt lane-a1b2c3d4@3
```
If the durable record has moved past sequence 3, the verb is rejected with a
`conflict` failure and the observed sequence, instead of stopping whatever
happens to be there now.
## Receipts
Every invocation returns a `ControlReceipt` carrying the operation id, surface,
authority, persistence scope, availability, target, `LifecycleOutcome`
(`inspected`, `transitioned`, `no_change`, `rejected`, `failed`), the observed
lifecycle sequence, retryability, an optional bounded sanitized failure, and an
optional bounded run page. `ControlReceipt::render()` is the only renderer; the
CLI prints it and the slash command returns it as a message. `--json` on the
Lane verbs emits the same struct.
## Typed unknowns
Run DTOs never imply absence. `Known<T>` is either `Known(value)` or
`Unknown(reason)` where the reason is `not_recorded`, `not_applicable`, or
`redacted`, and renders as `<not_recorded>` rather than a blank or a plausible
default.
Concretely: the Fleet receipt's `FleetResolvedRoute` records the **effective**
reasoning tier only, so `requested_reasoning` is `not_recorded` — it is not
back-filled from the effective value, and `reasoning_downgraded()` returns
`None` rather than guessing. The Lane registry records no route or usage at
all, so those fields are uniformly `not_recorded`. Fleet runs are fenced per
task rather than per run, so a Fleet run's `lifecycle_seq` is
`not_applicable`.
## Bounds and redaction
- Run lists are pages: `DEFAULT_RUN_LIST_LIMIT` (50) with a hard
`MAX_RUN_LIST_LIMIT` (200) ceiling, and the page reports `total` and
`truncated` so a bound is never mistaken for an empty result.
- Status worker rows and inspection artifact rows cap at 24 with an explicit
omission notice.
- Receipt detail caps at `MAX_DETAIL_LINES` (40) lines of `MAX_DETAIL_LINE_CHARS`
(240) characters.
- Every operator-visible string passes through `sanitize_line`: `$HOME`-rooted
paths collapse to `~/…`, credential-shaped `key=value` pairs and known token
prefixes (`sk-`, `ghp_`, `xoxb-`, `Bearer`, …) become `[redacted]`.
## Model-visible tool surface
Unchanged. This work adds no tool, no tool parameter, and no prompt text; the
model-facing sub-agent surface is still `agent` only. No tool-schema regression
measurement is required.
## Tests
- `crates/lane/src/control.rs` — descriptor-table integrity, the five-verb
symmetry across both domains, authority/persistence/target agreement across
surfaces, availability rules, target parsing and lifecycle fencing, receipt
round-trips, bounding, and redaction; plus executor tests proving all three
surfaces get byte-identical results for the same durable Lane and that
interrupt is idempotent and fenced.
- `crates/tui/src/fleet/control.rs` — route/usage DTO projection with typed
unknowns, bounded pages and rows, absent-ledger reporting without creation,
CLI-only `fleet.restart`, and cross-surface identity of `fleet.status`.
- `crates/tui/src/commands/groups/core/lane.rs` and `…/fleet.rs` — slash verbs
map onto the shared operations, `/fleet status` reads the durable ledger
rather than session sub-agents, and bare dispatch (what the hotbar fires) is
read-only.
- `crates/cli/src/lib.rs` — the CLI exposes exactly the declared Lane verbs
under the same ids, and `lane stop` is a compatibility spelling of
`lane interrupt`.