Release 0.4.6

Workflows + multi-host coordination + factory foundations.

Deterministic workflow engine: strict spec validation (unknown
keys reject, unreachable steps and unguarded cycles fail); loop-
guard max_hops enforced at projection; waiting-exit replay
absorption; per-instance keepalive watchdogs auto-armed in the
routing transaction; startup sweep re-arms keepalives and
surfaces stuck instances; rig workflow continue relabeled to its
read-only inspector semantics. Full-featured spec language: role,
target.rig, preferred_targets, next_hop.on, invariants,
loop_guards, exception_routing, closure, gates, skill_refs,
spawn_budget with strict-keyset validation and shipped-spec
compat pinned by fixture round-trip.

Workflow CLI: new verbs rig workflow run (instantiate + follow
live to terminal; exit 3 = workflow failed distinct from
transport 1/2), rig workflow watch (mid-flight attach; snapshot-
first; drops reconnect then announced poll fallback), rig
workflow status (needs-attention rollup; proven-empty on a clean
fleet), rig workflow route (frontier re-target in one
transaction; the step does NOT advance). trace/list/show human
output is now formatted; --json byte-identical to before. Out-of-
band terminal closure of a live workflow-frontier packet now
rejects with workflow_frontier_packet.

Workflow web UI: new workflow catalog, per-instance detail + trail,
in-band rollup, visual topology renderer, and step-trail timeline
pages; new hooks for workflow reads + SSE stream (unscoped by
rig); pure layout math extracted for unit tests. UI acceptance
carried by per-slice merge-time real screenshots + the two-host
integration proof; twin fixture backfill routed as a non-blocking
0.4.7 fast-follow.

Exception + human-gate model: rig workflow resume redrive from
failed step (rebound, fresh capability-aware re-resolution, one
fresh max_hops window); exceptions become durable attention items
in the same transaction as the failure; maturity dial
orchestrator-first vs human-only per class, per workflow, per
host with default orchestrator-first; the attention band is
workflow-aware; the happy-path guarantee unchanged. Events:
additive workflow.resumed.

Multi-host coordination happy path: hosts register + select via
new dashboard host-config + indicator surfaces with sticky rig
host select consumed by observe/interactive verbs; remote-host
workspace surfaces readable via --host on the workspace tabs;
cross-host queue routing via rig queue create / handoff /
handoff-and-complete --host and member@rig@<host> destination
form (explicit-only, at-least-once + idempotent, successor-first
close-second, closure_target audit metadata); cross-host direct
coordination verbs (rig send, rig capture, rig transcript, rig
broadcast) gain an http transport branch reaching remote daemon
routes CLI-direct with byte-verbatim ssh preserved; fleet-
attention altitude rollup on the attention band.

Factory foundations: workflow-to-rig binding layer (rig workflow
instantiate / run --rig; roles resolve to seats by capability;
resolve-once, record-in-the-packet; stable seat coordinate); the
single-rig recursive-self-improvement factory MVP shipped starter
with an inner plan-implement-check-review-release loop, dogfood
decoupled + running out-of-band against the shipped product,
publish held at a human sign-off gate; member-exists instantiate
advisory + cross-rig wrapping workflow travelling the cross-host
queue path.

New agent runtime adapter joins the shipped runtime dial as a
first-class option; terminal provider gets first-class treatment
across the terminal launcher, send/capture/broadcast, and the new
provider-agnostic skill; daemon read-path hardening for the
multi-host + workflow load with no functional behavior change.

Two new bundled skills ship canonical + plugin with byte-parity
guard: a rig terminal open/views/status model (AGPL clean-room
rail) and a provider-agnostic vs cmux-specific delta.

Migrations: additive only. Existing v0.4.4 databases upgrade by
running rig daemon start on the new daemon.

Note: v0.4.5 was skipped; there is no cut between v0.4.4 and
v0.4.6. A pre-release fix-batch was applied to the reviewed tree
(product-name neutralization, superlative-prose scrub, shipped-
doctrine touch-ups).

See CHANGELOG.md and docs/releases/v0.4.6.md.
This commit is contained in:
mvschwarz committed 2026-07-09 15:03:26 -07:00
1 parent ac75777a3c
commit 8250d702a2
303 files changed
+39868 -1098

No files matched your search

+136 -3
View File
@@ -8,6 +8,139 @@ deprecations, and behavioral changes. Breaking changes are called out explicitly
---
## [0.4.6] - 2026-07-09
**Status**: shipped; workflows + multi-host coordination + factory foundations theme. 0.4.5 was skipped (no cut).
### Summary For Installing Agents
- **Package version**: bumps from `0.4.4`. 0.4.5 was skipped.
- **Migrations**: additive only — `049_workflow_instance_version`, `050_workflow_spec_json`, `051_workflow_resume`, `052_workflow_instance_bound_rig`, `053_sessions_node_id_index`, `054_queue_transitions_archive`. Existing v0.4.4 databases upgrade by running `rig daemon start` on the new daemon.
- **Node engines**: unchanged.
- **New bundled skills**: `openrig-herdr` and `openrig-cmux` ship in canonical shared source + bundled openrig-core plugin, byte-parity guarded.
### Headline
**Workflows + multi-host coordination + factory foundations.** OpenRig gains a rock-solid deterministic workflow engine — spec language, CLI, web UI, exception + human-gate model — plus the self-driving factory starter that runs on top of it. Multi-host coordination lands the full happy path: hosts register + select, remote workspaces read, cross-host queue routing, cross-host direct coordination verbs (`rig send` / `rig capture` / `rig transcript` / `rig broadcast`), and a fleet-attention rollup. Terminal provider first-class treatment and daemon read-path hardening ride alongside; a new Pi agent runtime adapter joins Claude and Codex as a first-class runtime.
### The rock-solid deterministic workflow engine
- **Migrations**: additive only — 049 `workflow_instances.version` (optimistic concurrency, default 0), 050 `workflow_specs.spec_json` (full parsed spec at cache time; legacy rows self-heal on next read-through and degrade with a visible advisory until then).
- **Behavioral**: `loop_guards.max_hops` is now ENFORCED at projection (exceeding converts the handoff to an honest structured failure — instances that silently looped will now fail loud at the guard); workflow spec validation is STRICT (unknown keys reject at parse; unreachable steps and unguarded cycles fail validation — declare `loop_guards.max_hops` to sanction a loop); waiting-exit replays are absorbed (exact duplicates return the stored outcome with zero writes); `rig workflow continue` is relabeled to its real read-only inspector semantics (the wire was always read-only; the label lied).
- **New**: per-instance workflow-keepalive watchdog jobs auto-arm in the routing transaction and disarm at terminal (deadline-gated: quiet while healthy, stuck-steering nudge when a step is overdue — 4h threshold on the routine-tier SLA); a startup sweep re-arms keepalives, reissues nudges lost to the commit-then-crash window, and surfaces stuck instances.
- **Advisories (fail-open)**: declared-but-unenforced spec keys (`invariants.*` except `allowed_exits`, `closure.*`, `gates[]`, `skill_refs`, `next_hop.mode: prefer`, `spawn_budget`) warn `declared_not_enforced_v1` at validation; exit code unchanged.
### The full-featured workflow spec language
- **The workflow spec DSL** the engine parses ships full-featured: step definitions with `role`/`target.rig`/`preferred_targets`, `next_hop.on` branch semantics, `invariants.allowed_exits`, `loop_guards.max_hops`, `exception_routing`, `closure.*`, `gates[]`, `skill_refs`, `spawn_budget`. Strict-keyset validation at parse (unknown keys reject; unreachable steps + unguarded cycles fail); shipped-spec compat pinned by fixture round-trip. Composes with the workflow-to-rig binding + the exception model.
### The workflow CLI
- **Migrations**: none.
- **New verbs**: `rig workflow run <spec>` (instantiate + follow live to a terminal state; exit 0 = completed, exit 3 = workflow failed — distinct from transport 1/2, so `run && next-thing` is honest) · `rig workflow watch <instance>` (read-only mid-flight attach; snapshot-first so fast early steps still render exactly once; drops reconnect then degrade to an announced poll fallback) · `rig workflow status` (the needs-attention rollup: counts + one row per failed/stuck/waiting instance with combined reasons and the next action; proven-empty on a clean fleet) · `rig workflow route <instance> --to <session>` (re-target the current frontier step: honest handed_off_to closure + successor recreate + frontier rebind in one transaction; the step does NOT advance; the old owner's stale project is structurally rejected with `packet_not_on_frontier`).
- **Behavioral**: `trace`/`list`/`show` human output is now formatted (per-step tree, columns, status glyphs, ATTN markers) — `--json` payloads are byte-identical to before; named daemon rejections render as what/why/fix in human mode (`--json` keeps the raw body).
- **Guard**: out-of-band TERMINAL closure of a live workflow-frontier packet (raw `rig queue update`, Mission Control route/handoff) now rejects with `workflow_frontier_packet` (HTTP 400) naming the correct workflow verbs. Non-workflow queue items are unaffected. The predicate is injected at startup (the queue layer does not import the workflow domain).
- **Events**: `workflow.routing_table_changed` extended additively with `{instanceId, stepId, from, to}` on route emissions; existing `{rigName, cause}` consumers unaffected.
### The workflow web UI
- **New pages + components**: `WorkflowsPage` (workflow catalog + instantiate flow), `WorkflowInstancePage` (per-instance detail + trail), `WorkflowInstancesBand` (in-band rollup surface), `WorkflowTopologyGraph` (visual topology renderer), `InstanceTrailTimeline` (packet + step trail).
- **New hooks**: `useWorkflow`, `useWorkflowSse` (workflow SSE stream, unscoped by rig — never `?rigId=`); workflow events cross-rig aggregate.
- **Behavioral**: workflow layout math is extracted as pure exports (`buildLauncherViews` / `suggestLayout`) for unit tests; permutation-invariant edge-handle assignment (`computeStepDepths` / `assignEdgeHandles`); post-merge borderRadius design-compliance fix landed. Twin fixture backfill for these routes is routed as a non-blocking 0.4.7 hygiene fast-follow; UI acceptance is carried by per-cut real screenshots + the two-host integration proof.
### The exception + human-gate model
- **Migrations**: `051_workflow_resume` — two additive columns on `workflow_instances` (`resume_count`, `hops_baseline`, both NOT NULL DEFAULT 0; no backfill; behavior byte-identical until the first resume).
- **New verb**: `rig workflow resume <instance> [--decision <text>]` — redrive a FAILED instance from its failed step: back to active, rebound, fresh packet to the step's RE-RESOLVED owner (a replaced dead seat receives the redrive — the recorded stale destination is never copied); completed steps never re-run; one fresh `max_hops` window per resume (`hops_baseline`); the resolved exception occurrence closes; a repeat failure raises a NEW occurrence-distinct item.
- **Exceptions are durable attention items now**: an unmapped `failed` close creates the item IN THE SAME transaction as the failure (no item-less failed window); stuck/overdue instances get their item at sweep/keepalive detection, occurrence-deduped. Items carry plain-language summary, a trace evidence pointer, the resume affordance, and structured tags (`workflow:`/`instance:`/`step:`/`exception:`/`occurrence:`) for query-side joins.
- **The maturity dial**: exception routing is configurable per class / per workflow (`exception_routing:` in the spec — strict-keyset validated) and per host (`rig config set workflow.exception_routing orchestrator|human_only`). Default = ORCHESTRATOR-FIRST: the item routes to the workflow's declared `orchestrator_role` target while the human band shows an AWARENESS row (holder + age — visibility, not a to-do). `human_only` classes/workflows route `human@host` first and gate there. No resolvable target = `human@host`, never lost. Orchestrator-routed items carry an ordinary tier — they never leak into the human attention legs; the shipped attention predicate is unchanged.
- **The attention band is workflow-aware**: failed/stuck instances with no item render a backstop row naming the missing-item anomaly; a frontier referencing a closed packet renders an anomaly row (the detection twin of the CLI close-path guard); healthy fleets render zero workflow rows.
- **Happy-path guarantee unchanged**: a healthy run creates zero exception items of any routing and involves no orchestrator — pinned by test and proof.
- **Events**: additive `workflow.resumed` (`{instanceId, workflowName, stepId, resumedBy, decision, resumeCount}`).
### Add / select hosts — host registry + dashboard
- **Host registry verbs** (shipped at 0.4.4) gain the dashboard-side complement: `HostConfigCard` surfaces each registered host's declared transport, health, and identity; `HostIndicator` shows the current selection at the operator field of view. New hooks: `useHosts`, `useFleet` (registry read + fleet-wide rollup).
- **`rig host select` persists a sticky selection** consumed by the observe/interactive verbs (see cross-host coordination verbs, below); durable writes never follow the selection (deliberate asymmetry — see cross-host queue routing, below).
### View remote workspace
- **A remote host's workspace surfaces are readable from a local operator** — the workspace observability tabs shipped at 0.4.1 gain `--host <id>` scope; the registry + bearer path fans out per-host reads. Read-only for this pass; cross-host writes are the queue-routing and coordination-verb sections below.
### Cross-host queue routing
- **A queue item can now be sent — and a hot-potato handed off — to a destination on ANOTHER host.** `rig queue create/handoff/handoff-and-complete` gain `--host <id>` and the host-qualified destination form `member@rig@<host>` (both resolve to the same out-of-band `hostId` envelope; the session string stays `member@rig`). The local daemon forwards the write to the target host's daemon over the host registry + bearer (the shipped mission-control forward-then-strip WRITE, generalized); **the qitem lives in the target host's DB** and that host's own nudge wakes the destination agent on ITS tmux.
- **Explicit-only routing**: queue verbs never follow the persisted `rig host select` selection — a durable write does not silently re-home on a sticky selection (deliberate asymmetry with the observe/interactive verbs).
- **At-least-once + idempotent, never exactly-once**: the forwarding daemon mints the qitem id before the first forward; a cross-host handoff's successor id is derived deterministically (`qitem-xh-…`) from (source, destination, host) so retries absorb on the target's primary key. Cross-host handoffs create the successor FIRST and close the local source SECOND (never-drop); the source close records the opaque three-part `closure_target=member@rig@<host>` (audit metadata, never parsed) and the successor carries the continued `chain_of_record` (opaque lineage ids on the target). Re-drives absorb on a matching `closure_target`; a mismatch is a structured `cross_host_close_conflict` (409).
- **Failure honesty**: unknown / ssh-declared / unreachable / auth-failed hosts each surface a distinct structured `remote_queue_write_failed` error naming the host; nothing is written on either side. Transport is http-only (daemon→daemon); the `rig send --host` ssh shell-out is untouched.
- **No migrations. Local (no-host) queue behavior is byte-identical.** Claim/update/inbox stay local-by-principle; sender-side ops on a forwarded item are a named follow-up.
### Cross-host agent coordination
- **The direct coordination verbs now cross hosts — send, observe, coordinate JUST WORK.** `rig send` and `rig capture` gain an **http transport branch**: an http-registered host (the kind the shipped `rig host pair` front door creates) is reached CLI-DIRECT via the shipped `runRemoteHttpOp` against the remote daemon's EXISTING `/api/transport/send|capture` routes — **zero daemon-side changes**; the ssh path stays byte-verbatim for ssh-registered hosts (the host entry's declared transport dictates the path; never a fallback). `rig transcript` and `rig broadcast` gain their FIRST cross-host affordance the same way (`--host <id>`, http-only): transcript reads the remote daemon's tail/grep routes with origin output verbatim; broadcast posts to the remote daemon's own fan-out engine, printing its per-target results verbatim (a partial fan-out exits non-zero, exactly as local) under its own named 30s deadline.
- **The `agent@rig@host` target form is CLI-edge sugar, uniform on the session-target verbs** (send/capture/transcript): the suffix is host-qualified IFF it matches a REGISTERED host id, else the target passes through unchanged with a loud host hint on failure (adopted/raw names containing `@` keep working — deliberately different from the queue verbs' always-strip rule; both documented side-by-side in cli-reference). Precedence: explicit `--host` > target sugar > the persisted host selection; a `--host`-vs-sugar conflict is a structured error. Broadcast's positional is message text (never parsed as a target), so it takes `--host`/selection only. Every session string that reaches any daemon stays `member@rig`.
- **Failure honesty per branch:** the http branch names its own steps (unknown-host / permission-gate / remote-daemon-unreachable / remote-command-failed, with the remote route's own error text surfaced); the ssh branch keeps its shipped taxonomy. `send --verify` over http prints the REMOTE route's verdict verbatim — never a locally synthesized "Verified: yes". Named terminal-bearer posture: default/tailnet = pass-through; a remote enforcing a different terminal bearer surfaces as the structured permission-gate step (remedy documented; no new auth machinery).
- **No migrations. No daemon changes. Local (no-host, no-selection) behavior of all four verbs is byte-identical.** Durable cross-host coordination remains the queue; the coordination verbs add no queue surface.
### Fleet-attention altitude
- **A fleet-altitude attention surface** — the attention band gains a fleet-wide altitude rollup (`FleetBand` + `FleetPage`): aggregates attention counts across every registered host, per-host status, drill-down to the per-host attention list. Composes with the host registry + the remote-workspace read. New hooks: `useFleet`, `useReviewAgents`.
### The workflow-to-rig binding layer
- **Migrations**: `052_workflow_instance_bound_rig` — one additive nullable column on `workflow_instances` (`bound_rig` TEXT; NULL = unbound = byte-identical prior behavior; no backfill).
- **Point a workflow at a rig at INSTANTIATION**: `rig workflow instantiate|run … --rig <name>` overrides the spec's `target.rig` DEFAULT; the binding persists on the instance (`boundRig` in `--json`, rendered by `show`/`trace`). Unknown-rig validation splits by provenance: an explicit `--rig <unknown>` is a hard `bound_rig_unknown` naming the registered rigs; an unknown spec-default `target.rig` DEGRADES to unbound with a loud instantiate advisory (surfaced in `--json` `advisories` + on stderr) — shipped/example specs that carry a descriptive `target.rig` and route via `preferred_targets` (e.g. `conveyor`) instantiate byte-identically to prior behavior (zero-regression).
- **Roles resolve to SEATS by capability**: pod members may declare `role: <name>` (rig.yaml, `rig expand` fragments, `rig add` fragments — opt-in per seat; charset-validated; rejected on terminal members; round-trips through export). On a bound instance, a workflow role with **no `preferred_targets`** resolves at step-close to a live capable seat on that rig: running agents declaring the role, managed seats only (adopted seats excluded loudly with `adopted_seat_not_role_resolvable_v1`), harness-pin-aware runtime match, least pending backlog, deterministic coordinate tiebreak. Declared `preferred_targets` stay the explicit override tier, byte-identical and never liveness-filtered; every previously-shipped spec behaves identically.
- **Resolve-once, record-in-the-packet**: resolution happens once inside the close+create transaction and records as the packet destination; replays consume the record (zero inventory reads); `rig workflow resume` re-resolves by design and is now capability-aware. Roles bind to the stable seat coordinate `{pod}-{member}@{rig}` — an agent handover behind the seat never strands the workflow.
- **Honest failures**: no live capable seat = structured `next_owner_unresolved` with per-candidate disqualifiers + fix line; zero-declaring rigs get a named message; instantiate hard-fails only on STRUCTURAL zero-role coverage (`bound_rig_role_uncovered`) — a declared-but-not-yet-running seat is fine (factory rigs warm up). Never a spawn, never auto-`add_member`, never a dead-seat route.
- **Exception routing rides the binding**: the exception model's orchestrator-role position resolves capability-aware on the bound rig (never-lost human@host fallback unchanged).
- **Scale-out = add a member under a role** (`rig add` fragments carry `role`); auto-scale-out is explicitly NOT built.
### The self-driving factory starter (`factory-rsi`)
- **New shipped starter: `rig up factory-rsi`** — the single-rig recursive-self-improvement factory MVP. One rig, seven seats (`plan-planner`, `build-implementer`, `check-qa`, `review-reviewer`, `dogfood-tester`, `release-manager`, `orch-lead`), running the new `factory-rsi` workflow. A launch-tier product starter (a `product-team` sibling), workspace-agnostic — point `--cwd <repo>` at whatever the loop should improve.
- **New builtin workflow: `factory-rsi`** — the inner loop `plan → implement → qa_check → review → release`, with `qa_check`/`review` `failed` → `implement` (bounded remediation), engine-routed — never an orchestrator relay. Dogfood is **decoupled** from this gated loop: the dogfood seat runs out-of-band against the **shipped** product and feeds its findings into the next plan (the RSI edge, ungated — no loop-stop in the MVP; the continuous out-of-band runtime mechanism is refined in a later release). The remediation loops are sanctioned only by the enforceable `loop_guards.max_hops`; a trip is an exception routed orchestrator-first (`exception_routing`), and `rig workflow resume` grants one more bounded window.
- **Recorded-state cycles**: the next plan's input is the *recorded* dogfood findings (`evidence_ref` / the packet trail), never a seat's chat memory — the RSI feedback is durable recorded state.
- **Publish stays a human act**: the release leg is two steps — `release_prep` (the release-manager PREPARES notes/docs/PR and records the evidence; un-gated, runs first) hands off to `release_signoff`, which holds the ship decision at a human gate (`gate.target: human@kernel`). Prepared artifacts exist before sign-off; no seat pushes, tags, publishes, or upgrades a host.
- **Rides the merged engine, no new machinery**: runs on the workflow engine + spec language + exception model as shipped, with the v0 hardcode seam (`target.rig: factory-rsi` + `preferred_targets` pin each role 1:1 to a seat) — no binding-layer dependency, no engine change. Runtime config: seats inherit their runtime's default model (no per-seat pin); plan/build/release/orch run on claude-code, and qa/review/dogfood run on codex for cross-runtime diversity against the builder.
- **No migrations. No breaking changes.**
### The member-exists instantiate advisory + cross-rig wrapping workflow
- **Mis-routed workflow destinations are caught loudly at instantiate, never silently orphaned.** A declared `preferred_target` that names a rig registered on this daemon but a MEMBER that does not exist (a typo or a stale seat name) now surfaces **ONE loud, aggregated advisory** on the shipped instantiate `advisories` list — naming the destination, every declaring step/role pair, the consequence (the work will not be claimed; it will surface as a stuck exception), and the fix hint (`rig ps` / add the member). Rendered exactly where advisories already render: the route body + CLI stderr. **No new surface, no new flag** — the shipped `target.rig`-degrade list simply gains a second producer.
- **ADVISORY, never a deny**: instantiate always proceeds; the queue transport gate stays rig-exists-only (unchanged). Scope guards: human-seat refs are classified before parse and skipped; raw/adopted (non-canonical) destinations are skipped (legitimate — the inventory cannot vouch for them); an unregistered rig keeps its existing loud transport rejection (no double advisory). Existence is structural — any lifecycle state, any member kind (a declared-but-not-launched seat or an explicitly named terminal member is a legitimate destination; liveness stays a projection-time concern).
- **Cross-rig wrapping workflow**: workflows declared on one rig may `preferred_target` a seat on another registered rig; the routing packet travels via the cross-host queue routing path (host-qualified destination form) and lands in the target host's DB. Local-rig-only workflows are byte-identical.
- **No migrations. No CLI changes. No breaking changes.** Advisory-free specs instantiate byte-identically.
### Pi agent runtime adapter
- **New runtime adapter: Pi** — an agent runtime beyond Claude/Codex, joining the shipped runtime dial as a first-class option in rig specs (`runtime: pi`) and pod member declarations. The adapter honors the standard runtime contract (identity via `rig whoami`, startup guidance, hooks, transcript path). Pi runtime seats participate in all workflow / role / queue / send / capture surfaces exactly like Claude/Codex seats.
### Terminal provider first-class treatment
- **The terminal provider dial gets first-class treatment across the terminal-facing surfaces** — the terminal launcher (`TerminalLauncher`), send/capture/broadcast surfaces, and the new `openrig-cmux` skill's `--provider cmux` seam honor a declared terminal provider per member with best-effort fallback. Existing seats using cmux keep their behavior byte-identical; declared-provider seats now route deterministically.
### Daemon read-path hardening
- **The daemon's read-path is hardened for the multi-host + workflow load** — SSE stream backpressure guards, projection caches, and additive indices for read-side query performance. **No functional behavior change** — reads that returned correct results before return the same results now, faster.
- **Migrations**: `053_sessions_node_id_index` (additive index) + `054_queue_transitions_archive` (archive table for read-side query performance). Additive only.
### New bundled skills (canonical + plugin, byte-parity guard)
- **`openrig-herdr`** — the full rig terminal open/views/status model: verbs, view grammar (`rig | pod:<rig>/<pod> | mission:<id> | slice:<id> | saved`), honest-partial/degrade reading, read-only policy (rig/pod interactive, mission/slice read-only-by-construction, saved per-member), scroll/copy + never-retroactive-flip honesty, same-size-only duplicate limit, terminal-views.yaml schema. AGPL clean-room rail — no herdr source text vendored. Ships in `skills/_canonical/core/openrig-herdr/` + `packages/daemon/assets/plugins/openrig-core/skills/openrig-herdr/`.
- **`openrig-cmux`** — the provider-agnostic vs cmux-specific delta (`--provider cmux`) with shipped-integration open-or-focus rule, patterns only (no arm's-length constraint pointing at openrig-herdr for the shared model). Ships in `skills/_canonical/core/openrig-cmux/` + `packages/daemon/assets/plugins/openrig-core/skills/openrig-cmux/`.
### Known Follow-ons
- **Save-verb** — did not land in 0.4.6; rides 0.4.7.
- **Twin fixture backfill** for the new workflow + multi-host UI routes (WorkflowsPage, WorkflowInstancePage, FleetPage, FleetBand, TerminalLauncher, HostConfigCard, HostIndicator, etc.) — non-blocking 0.4.7 hygiene fast-follow; UI acceptance carried by per-cut real screenshots + the two-host integration proof.
- **Carry-forwards from 0.4.4**: `openrig-user` bundled plugin stale-copy sweep, `whoami --all-hosts` silent host filter, managed-stop SIGTERM-escalation brittleness (recurred at 0.4.4 cutover), post-cutover reconcile-settle-visibility signal on `/healthz`.
- **iOS Safari** (Living Notes composer verification) — carry-forward from 0.4.4.
- **Wider mission-template prose sweep** — post-cut sequencing.
- **Continuous out-of-band dogfood runtime** — the factory-rsi RSI edge ships ungated in the MVP; the continuous mechanism is refined in a later release.
---
## [0.4.4] - 2026-07-06
**Status**: shipped; multi-host + Living Notes theme.
@@ -36,7 +169,7 @@ deprecations, and behavioral changes. Breaking changes are called out explicitly
- **New verbs (capped at exactly three)**: `rig host add` (registry writes validated by the loader's own rules — no more hand-edited YAML for the standard path), `rig host list` (config pointers, never secret values), `rig host doctor <id>` (stepwise distinct errors: transport → remote rig binary → daemon health → identity) with `--posture product-factory-vps` — the ONE built-in security baseline, three-valued per item (UNKNOWN is never pass).
- **Transport posture DECIDED + documented** (no behavior change): ssh carries pane ops (`send`/`capture`), http-bearer carries daemon REST (`up`/`down`/`launch`), `ps`/`whoami` follow the host's declared transport, fan-out is http-only; NO cross-transport fallback; NO http parity for send/capture in 0.4.4. Per-command table in cli-reference §Cross-host execution.
- **Product-factory bootstrap**: `scripts/bootstrap-product-factory-vps.sh` + `docs/reference/product-factory-vps-runbook.md` (fresh Ubuntu VPS → factory-ready; the 2026-06-23 OVH smoke-test posture as encoded defaults; safe UI tunnel + restricted fail-closed reverse-path recipes).
- **Product-factory bootstrap**: `scripts/bootstrap-product-factory-vps.sh` + `docs/reference/product-factory-vps-runbook.md` (fresh Ubuntu VPS → factory-ready; smoke-tested VPS posture as encoded defaults; safe UI tunnel + restricted fail-closed reverse-path recipes).
### BREAKING: `rig ps` consolidated all-rigs default + explicit disclosure ladder (OPR.0.4.4.21)
@@ -49,7 +182,7 @@ deprecations, and behavioral changes. Breaking changes are called out explicitly
### Multi-host foundation (OPR.0.4.4.11 + 13 + 15 + 18)
- **Shareable whole-topology staged spin-up (S11)** — a rig topology can be brought up in stages across multiple hosts; the spec + the daemon coordinate to reach a green whole-fleet state without requiring single-host bring-up.
- **VPS product-factory multi-host hardening (S13)** — `rig host` verbs (above) + the documented transport posture harden the fresh-Ubuntu-VPS → factory-ready flow. Product-factory bootstrap script + runbook ship for the OVH-smoke-tested posture.
- **VPS product-factory multi-host hardening (S13)** — `rig host` verbs (above) + the documented transport posture harden the fresh-Ubuntu-VPS → factory-ready flow. Product-factory bootstrap script + runbook ship for the smoke-tested VPS posture.
- **Multi-host consolidated For-You feed (S15)** — the For-You feed aggregates activity across all registered hosts in the topology. Real-host feed-subscription capture is sequenced as a lifecycle-post-publish belt-and-suspenders proof.
- **`rig file` cross-host movement (S18)** — files move across registered hosts via the `rig file` surface. Real registered-host two-host round-trip proves the flow on top of the VM stand-ins already merged.
@@ -90,7 +223,7 @@ deprecations, and behavioral changes. Breaking changes are called out explicitly
### Headline
**Rigs actually survive.** Crash-restore ledger (FR-3 → FR-7) survived a real hard power-off (`Tart stop --timeout 0`) → reboot → `rig start`: both Claude and Codex seats restored to their **original** sessions with recalled pre-crash markers, zero fresh-prime. That's the load-bearing "rigs survive" claim, and it's proven at the operating-system level, not just in unit tests.
**Rigs actually survive.** Crash-restore ledger (FR-3 → FR-7) survived a real hard power-off → reboot → `rig start`: both Claude and Codex seats restored to their **original** sessions with recalled pre-crash markers, zero fresh-prime. That's the load-bearing "rigs survive" claim, and it's proven at the operating-system level, not just in unit tests.
### Survival Backbone
@@ -109,6 +109,78 @@ Tier→SLA mapping for `closure_required_at` also lives in
enforced at the daemon transaction boundary — the workflow runtime
*projects* on closure but does not gate it (see `workflow-runtime.md`).
## 3b. Cross-host queue routing (v0.4.6 — OPR.0.4.6.MH3)
The queue's two coordination WRITE verbs — **create + handoff(-and-complete)
only** — are host-aware: a write body may carry an out-of-band `hostId`
envelope (BR-1 — session strings stay `member@rig` everywhere; the 3-part
`agent@rig@host` form is CLI input sugar that never leaves the CLI edge).
The mechanism **generalizes the shipped mission-control forward-then-strip
WRITE template** (`routes/mission-control.ts` § remote action): one shared
route-layer helper (`forwardQueueWrite`, `routes/queue.ts`) resolves the
host registry daemon-side (bearers never reach the caller), rejects
ssh-declared hosts (`unsupported-transport` — the daemon→daemon path is
http-only, because it is what fires the remote nudge), strips `hostId`, and
forwards the WHOLE body via `remoteJsonRequest` under a named write-class
deadline (`QUEUE_FORWARD_TIMEOUT_MS`). The origin's response returns
verbatim; transport failures map to a structured host-named taxonomy
(`remote_queue_write_failed`: unknown-host / unsupported-transport /
unreachable / auth-failed / remote-error). No local row is ever written on
the cross-host path.
**The model: origin-owns-the-record, at-least-once + idempotent,
message-passing closure (never 2PC).**
- **Origin-owns-the-record.** The qitem lives in the TARGET host's DB; that
row is THE record. The target daemon's OWN `maybeNudge` fires on ITS local
tmux (the forwarded body includes the `nudge` flag) — the sending daemon
never reaches across a host boundary.
- **Idempotency (arch Q-a).** The forwarding daemon MINTS the `qitemId`
before the first forward, so every retry carries the same id; dedup rides
the existing `qitem_id TEXT PRIMARY KEY`. On PK conflict the origin
returns the stored row when identity fields match (idempotent absorb) and
a structured `qitem_id_reuse` error when they differ
(`QueueRepository.create()` catch-path + `isQitemPrimaryKeyConflict`).
- **Cross-host handoff choreography (arch Q-c).** The local atomic
close+create cannot span two DBs, so the route-layer choreography
(`crossHostHandoff`, `routes/queue.ts`) runs: successor-create on the
target host FIRST (via the one forward helper), local source-close SECOND
(`QueueRepository.closeCrossHostHandoffSource`) — never the reverse. A
crash between the two leaves a live duplicate that the idempotent re-drive
converges; the reverse order would leave a closed source pointing at a
successor that does not exist (a dropped potato — the one forbidden
outcome). The successor id is DERIVED, not minted:
`deriveCrossHostSuccessorId(source, destination, host)` →
`qitem-xh-<sha256[:16]>` — a pure stateless function, so a re-drive
re-derives the same id across daemon restarts and absorbs on the target
PK. *(Named residual, inherent to the at-least-once/no-2PC fence: a
re-drive naming a DIFFERENT destination derives a different id and cannot
absorb the earlier successor — that orphan stays visible via the chain +
provenance tags; the source-close conflict check surfaces the
disagreement.)*
- **Closure across the boundary.** The source closes with
`closure_reason=handed_off_to` and `closure_target=member@rig@<host>` —
the 3-part form is legal THERE because `closure_target` is OPAQUE
audit/display metadata, presence-checked and never parsed for routing
(arch R1; any PR parsing it as a session string is a spec violation).
The exemption spans exactly the `closure_target` column on `queue_items`
and its verbatim mirror on `queue_transitions` — nothing else. In
particular the transition log's free-text `transition_note` is a durable
carrier too: the minted cross-host close note names the 2-part
`toSession` only (rev1-r2 B1). `handed_off_to` and every other
session-string carrier stay 2-part.
Re-drive semantics: already-terminal + MATCHING `closure_target` = absorb;
MISMATCH = structured `cross_host_close_conflict` (409). The successor
carries `chain_of_record = [...source.chain, source.qitemId]` — A-side ids
are opaque lineage identifiers on B (arch R2b; they do not dereference in
B's DB) — plus provenance tags `cross-host` + `from-host:<self-declared
name>` (honest best-effort, not authenticated identity).
- **Boundary discipline.** Claim / update / inbox stay local-by-principle
(after a cross-host handoff the successor lives where its worker lives);
sender-side ops on a forwarded item are a named follow-up. The local
(no-host) paths are byte-identical to pre-MH-3 behavior; the hot-potato
validation contract (§3) is unweakened across the boundary.
## 4. Coordination events
> Drift-fix D8 / OPEN-4 (carried verbatim, slice-00): `architecture.md`
@@ -8,11 +8,13 @@ applies-when: |
Working on or consuming the composed slice/mission review surface — the
/api/review/* routes, the ComposedSliceReview contract, the on-disk SDLC
convention it projects, staged-approval locks, proof artifacts and the C1
header, the freeze export, or media serving for review evidence.
header, the freeze export, media serving for review evidence, or the
cross-host FLEET aggregate (/api/review/fleet, the fleet composer's
union/one-count seam, v0.4.6 MH-5).
siblings: [content-surfaces.md, workspace-primitive.md, mission-control.md]
prerequisite-reads: [workspace-primitive.md]
last-verified-against-source: bb5ad219
last-updated: 2026-07-06
last-verified-against-source: c8341f72
last-updated: 2026-07-08
---
# Living Notes review surface (v0.4.4)
@@ -150,7 +152,47 @@ altitudes is one item). `composeRigAgents` serves the standalone rig
altitude (roster ∪ recently-holding, park/health/settled from the queue
transitions log).
## 7. The on-disk convention it projects (SDLC control plane)
## 7. The FLEET altitude — the cross-host aggregate SIBLING (v0.4.6, OPR.0.4.6.MH5)
`GET /api/review/fleet` aggregates every registered host's composed ▲/● set
into one manage-by-exception glance. It is a **sibling aggregate endpoint**,
never a fourth `AgentsScope` value (arch Q2): the scope grammar stays
exactly `slice:* | mission:* | rig`, and the local composer stays a pure
function over local state. `domain/review/fleet-compose.ts` is the ONE
review-domain module allowed to import hosts transport/registry — a
boundary enforced mechanically by the `review-import-audit` static test
(the zero-I/O `fanout-contract` types module is the sole allowlisted
hosts import elsewhere).
**Fan-out-the-composed-set (arch Q1):** the ▲ kinds are time-derived on
their own host's clock, so the fleet root fans out each host's
ALREADY-COMPOSED rig root (`GET /api/review/rig` over `remoteJsonRequest`,
concurrent under the named read-class deadline; the local host joins
in-process via the same gatherer — zero self-transport) and only **unions +
host-dimensions + counts**. It never recomputes exception truth (the
composer is clock-free by test pin), so cross-host clock skew can never
distort a ▲. Per-host failures degrade to the closed `PerHostStatus`
honesty contract; a failed host's counts are ABSENT from its
`FleetHostRollup` — absent-not-zero, in the type.
**One-count across the fleet (arch Q4):** the dedup `Set` lives in the
fleet composer — the one place — keyed `hostId|identity` (rendered verbatim
on the expanded drawer); within-host multi-altitude visibility collapses to
one row with `seenFrom` provenance; an MH-3-forwarded qitem lives only in
its origin host's DB, so no double-count by construction.
**Composition pins the seam (arch Q3, the rule of thumb):** sidedness and
caller pick the TRANSPORT seam (MH-4's CLI-direct verbs); when COMPOSITION
exists — the fleet union/one-count is a correctness rule — the seam pins
daemon-side: ONE composer, one endpoint, every consumer (the `/fleet` route
page and the FLEET band today via the shared `useFleet` hook; the TUI/CLI
fleet consumers as named follow-ups) reads the same one. UI cadence is the
bounded named `FLEET_POLL_INTERVAL_MS` with the feed-cadence-class floor,
and the ambient band gates its FETCH on fleet existence (the FS-1 amplifier
discipline; enable-at-scale remains the FS-1 release-validation gate). Read
+ surface only: acting on a remote host's item rides MH-3/MH-4.
## 8. The on-disk convention it projects (SDLC control plane)
The surface projects the markdown-control-plane shapes operationalized by
OPR.0.4.4.23 — conventions SSOT `docs/reference/sdlc-conventions.md`:
@@ -106,6 +106,55 @@ present @HEAD.
Architecture Rule 18 (carried, `architecture.md` §7): tmux is transport, not
truth — `send/capture/broadcast` wrap tmux reliably with honest errors.
### 3b. Cross-host coordination verbs (v0.4.6 — OPR.0.4.6.MH4)
`rig send/capture/transcript/broadcast --host <id>` (and the `agent@rig@host`
target sugar on the session-target verbs) cross the host boundary with **zero
daemon-side changes** — the remote daemon's existing local routes do all the
work; the only net-new is the CLI transport branch.
- **The seam principle (arch-ruled): SIDEDNESS + CALLER pick the seam.**
ONE-SIDED remote ops from a BEARER-CAPABLE caller (the CLI resolves the
registry bearer locally) go **CLI-DIRECT** via the shipped
`runRemoteHttpOp` — one hop, no local-daemon involvement (the
`ps --all-hosts` precedent). TWO-SIDED ops (MH-3's queue handoff closes the
local source and creates the remote successor — the local daemon owns half
the transaction) or BEARER-INCAPABLE callers (the browser: MC-action,
MH-2's read-through) use the **daemon-side forward-then-strip**. MH-4's
four verbs are pure one-sided remote ops from the CLI.
- **Transport is dictated by the host entry (ssh XOR http), never a
per-call choice:** ssh hosts keep the shipped shell-out byte-verbatim for
send/capture; http hosts (the `pair` front door's kind) take the
CLI-direct branch to `POST /api/transport/send|capture|broadcast` /
`GET /api/transcripts/*` with the SAME bodies/paths the local CLI builds
(one route, two callers = wrap parity by construction). transcript and
broadcast are http-only (no ssh path exists for them); a wrong-transport
verb dies with the structured requirement error — never a fallback.
- **Terminal-bearer posture (named limitation, v0 — `/api/transport/*`
ONLY):** the remote's transport routes (send/capture/broadcast) gate on
ITS TERMINAL bearer class (`OPENRIG_TERMINAL_BEARER_TOKEN`, default null →
pass-through; the tailnet is the auth boundary by design), while the CLI
presents the REGISTRY bearer from `hosts.yaml`. A remote enforcing a
DIFFERENT terminal bearer surfaces as the structured `permission-gate`
step (never a hang, never silent). Remedy: set the remote terminal bearer
equal to the paired registry bearer, or rely on the tailnet boundary.
**The transcript read is DELIBERATELY outside this class (arch n2):**
`/api/transcripts/*` mounts UNGATED (`server.ts` — the shipped open-route
posture; daemon-local trust boundary with route-level credential
redaction as the protective primitive), so a wrong terminal bearer that
permission-gates `send --host` does NOT gate `transcript --host` — the
read keeps succeeding, and the proof matrix must not treat the auth-fail
class as uniform across the four verbs. A coherent transcript-read auth
policy across tail/grep/full is a named future slice per
`routes/transcripts.ts`'s own comment (orch approved-option-a).
Bearer-class unification is likewise a NAMED follow-up, not v0 (no
un-asked auth machinery).
- **BR-1 holds:** the 3-part form is CLI-edge sugar only (suffix must match
a REGISTERED host id, else passthrough + a loud host hint); every session
string that reaches any daemon stays `member@rig`; the host travels
out-of-band. Durable cross-host coordination stays MH-3's queue — MH-4
adds no queue surface.
## 4. Transcript flow
(`architecture.md` §6 "Transcript flow")
@@ -180,7 +229,12 @@ The intentional limits in this layer:
detail (whoami/adopt).
- `lifecycle-snapshot-restore.md` §3 — restore-side transcript boundary
markers.
- `coordination-primitive.md` §3b — MH-3's cross-host QUEUE routing (the
two-sided daemon-forward twin of §3b's one-sided CLI-direct verbs).
- `cli-reference.md` § Cross-host execution — the per-verb transport
capability table, parse rules, and http failure taxonomy (MH-3 + MH-4).
- Source roots: `packages/daemon/src/domain/{session-transport,
transcript-store,history-query,ask-service,chat-repository}.ts`,
`packages/daemon/src/routes/{transport,transcripts,ask,chat}.ts`,
`packages/cli/src/mcp-server.ts`.
`packages/cli/src/mcp-server.ts`,
`packages/cli/src/{remote-host-ops,cross-host-target,cross-host-executor}.ts` (v0.4.6 MH-4).
+330 -3
View File
@@ -10,8 +10,8 @@ applies-when: |
contract, or the watchdog policy set including workflow-keepalive.
siblings: [coordination-primitive.md, mission-control.md]
prerequisite-reads: [../README.md, coordination-primitive.md]
last-verified-against-source: 7eaf524c
last-updated: 2026-05-16
last-verified-against-source: slice/opr-0.4.6.wf2-spec-language tip (base d18907ed — WF-1 merged)
last-updated: 2026-07-06
---
# Workflow Runtime + Watchdog Policies (PL-004 Phase C/D)
@@ -140,6 +140,332 @@ Phase D extends `RigEvent` with the additive `workflow.*` events
(idempotent inspect). Surface enumerated `routes/workflow.ts:21–28`.
Cross-ref: the `rig workflow` CLI surface — see `../cli-reference.md`.
## 6. The WF-1 failure envelope (OPR.0.4.6.WF1)
Added on top of the kept Phase D core (nothing above was re-specced;
FR-1 regression tests pin it):
- **Step deadlines (FR-2, derived — never stored):**
`workflow-deadline.ts` classifies every active|waiting instance's
frontier packet by anchor — claimed w/ `closure_required_at` ·
claimed w/ NULL deadline (`claimed_at` + threshold; workflow packets
ship tier `mode2`, which has no SLA entry) · never-claimed
(`created_at` + threshold) · unclaimed-after-claim (`created_at`;
unclaim NULLs `claimed_at`). `WORKFLOW_STEP_STUCK_THRESHOLD_SECONDS`
(4h, = the routine-tier SLA) is THE single threshold home; WF-5
binds to it. Stuck self-clears on normal re-projection.
- **Keepalive auto-arm (FR-3):** instantiate + every handoff ensure
ONE per-instance `workflow-keepalive` watchdog job INSIDE the scribe
transaction; terminal exits disarm it. Auto-armed jobs carry
`context.deadline_gated: true` — quiet while healthy, and their
overdue send targets the stuck packet's owner with re-project
steering. Operator-registered jobs keep exact POC always-send parity.
- **Boot sweep (FR-4):** `workflow-boot-sweep.ts` at daemon startup —
re-arms missing keepalives, reissues LOST post-commit nudges
(pending frontier packet with `last_nudge_attempt` NULL = the
commit-then-crash window, detected from the nudge ledger), surfaces
stuck instances; one summary log line.
- **Real idempotency (FR-5):** waiting-replay ABSORPTION under the
full closure-intent identity (exit/packet/step/actor/resultNote/
effective-blocker/evidence deep-equal) — exact replay = zero writes;
any mismatch = a new decision via the normal path. Migration 049
adds `workflow_instances.version`: every guarded advance bumps it
`WHERE version = ?`; a stale writer gets structured
`instance_version_conflict` and its whole transaction rolls back.
- **max_hops enforced (FR-6):** compared at projection via
`exceedsMaxHops(hopCount, baseline, maxHops)` (v1 baseline = 0; the
baseline is WF-5's resume seam). Exceeding converts the handoff to
an honest structured failure (packet closed, instance failed, guard
evidence in trail + `workflow.failed`). Migration 050 adds
`workflow_specs.spec_json` — before it, `loop_guards`/`invariants`/
`closure`/`entry` were silently DROPPED at projection-time
rehydration (column-only rebuild); legacy rows self-heal on
readThrough and degrade VISIBLY (named once-per-spec advisory).
- **Validation (FR-7):** `parseWorkflowSpec` rejects unknown keys loud
at every level against EXPORTED closed keysets (WF-2 extends them);
the validator walks reachability/cycles over the projector's own
exported `resolveNextStep` — unreachable steps fail; a cycle without
`max_hops` fails naming the fix; with it, sanctioned.
- **`continue` honesty (FR-8):** relabeled to its real read-only
inspector semantics everywhere (CLI description/outcome, route
comment); `project` remains the sole advance write path.
- **The v2 dispositions (FR-9):** every declared-but-unenforced key —
`invariants.{continuation_required,preserve_lineage,closure_required}`,
`closure.*`, step `gates[]`, role `skill_refs`, `next_hop.mode:
prefer`, `loop_guards.spawn_budget` — produces the fail-open
`declared_not_enforced_v1` validator advisory (warning; never
blocks). `spawn_budget`'s advisory names its WF-2/WF-6
parallel-frontier acceptance pointer (arch ruling 2026-07-06).
`fallbackSynthesis` (instance column, never written) is dispositioned
in the `workflow-types.ts` JSDoc.
## 7. The WF-2 spec language (OPR.0.4.6.WF2)
WF-2 grows the language the ratified WF-1 engine speaks. ONE named
engine extension (branch execution); everything else is language +
compilation onto shipped seams.
**Conditional-on-outcome branching (FR-1).** A step may declare
`next_hop.on: {<exit>: <step-id>}` — branch keys are the recorded exit
enum ONLY (`handoff|waiting|done|failed`; closed set, enforced at
parse — `spec_branch_key_invalid`). A MAPPED exit routes to its target
INSIDE the same scribe transaction: next qitem created in-txn, instance
stays ACTIVE bound to the target, hop count + version guard bumped
identically to a linear advance, and the taken branch recorded
ADDITIVELY (`lastContinuationDecision.branchTaken` + the trail row's
`closure_evidence.branch_taken`) — never in `closure_reason` (closed
Phase-A enum). An UNMAPPED `failed`/`done` stays terminal exactly as
before; unmapped `waiting` stays a park. The `max_hops` guard fires on
ANY route (branch routes create the canonical remediation cycles);
cycle detection at validation runs over the structural ∪ branch edge
union and requires a declared `max_hops` to sanction any cycle.
Routing seam: `resolveNextStep(spec, step, recordedExit?)` — one
exported function, structural default when no exit supplied (the
validator's path).
**Per-step `harness:` pin (FR-2).** `claude-code | codex` (agent
harnesses only — `terminal` rejected at parse with a teaching error; Pi
joins in 0.4.7). Owner resolution picks the first `preferred_target`
whose node `runtime` column matches (`nodeRuntimeOf`: latest session →
node join); no match = structured `harness_pin_unsatisfied` naming the
pin + every candidate's runtime. Explicit owner overrides are
reconciled too — an override can never silently defeat a pin. Static
check at instantiate for every pinned step; re-checked at each route.
**Per-step `host:` pin (FR-3).** `local`/absent = full execution today.
A registry id validates against `~/.openrig/hosts.yaml` (daemon
read-only twin; unknown id = `host_not_registered` naming registered
ids) but a remote pin fails loud at INSTANTIATE with
`host_pin_remote_unsupported` naming the MH-3 boundary + workaround —
the queue is local-only until MH-3; no qitem is ever minted into a
queue that cannot route it, and there is no silent local fallback.
**Structured step-level `gate:` (FR-5 — the socket; WF-5 owns
semantics).** Singular per step, closed keyset `{target, summary,
evidence_ref}`. HUMAN target (the shipped human-seat predicate) →
compiles to a human-routed item (tier `human-gate` + summary +
evidence_ref — the shipped 0.4.4 write path), resolved by the shipped
`resolve` verb; HANDLER-ROLE target → an ordinary agent item to the
role's resolved seat. Routing INTO a gated step creates the gate item
as the frontier packet and parks the instance `waiting`; resolve/close
continues the flow from that step (the WF-1 unpark — no restart). A
gated ENTRY step parks from birth.
**Dispositions (FR-4) — the inert third state is dead.** The legacy
step `gates: [...]` string list is REMOVED at parse
(`spec_gates_removed`, what/why/fix naming the new `gate:` object);
`next_hop.mode: prefer` is REMOVED at parse
(`spec_prefer_mode_removed` — it never had distinct behavior).
`skill_refs` / `closure.*` / `invariants.{continuation_required,
preserve_lineage,closure_required}` keep their WF-1 FR-9 explicitly-v2
advisories; `spawn_budget` stays explicitly-v2 (WF-6/parallel-frontier
acceptance pointer). Every key is consumed, removed, or
machine-readably advisory — zero silently-inert keys.
**Versioning honesty (FR-6).** New strictness lands at
`parseWorkflowSpec` (the only seam that sees raw keys; the WF-1
exported closed keysets extended with `harness`/`host`/`gate` +
`next_hop.on`) and applies at validate/instantiate/re-parse. A LIVE
instance pinned to a pre-WF-2 spec version keeps executing un-failed
(project() has no validation gate, by design; stored `spec_json` blobs
missing the new optional fields read fine); the same spec FILE
re-validated fails under the new rules.
**Hand-authorability (FR-6).** Three shipped example shapes at
`packages/daemon/src/builtins/workflow-specs/`: `linear-build.yaml`
(zero WF-2 features — the zero-regression reference),
`gated-release.yaml` (human gate + harness pins),
`branched-remediation.yaml` (bounded failed-path remediation loop).
**The composed RSI example (OPR.0.4.6.FAC2).** `factory-rsi.yaml` is the
single-rig recursive-self-improvement factory MVP: it composes the branch +
gate + guard primitives into the inner loop — `plan → implement → qa_check →
review → release_prep` — where `qa_check`/`review` branch `failed` back to
`implement` (bounded remediation) and `release_prep` (the release-manager
prepares artifacts, un-gated) hands off to a human-gated `release_signoff`.
Dogfood is decoupled from this gated loop: the dogfood seat runs out-of-band
against the shipped product and feeds its findings into the next plan (the RSI
edge, ungated). The remediation loops are sanctioned only by the enforceable
`loop_guards.max_hops`; a trip is a WF-5 exception (orchestrator-first via
`exception_routing`). It targets the shipped `factory-rsi` launch starter
(`specs/rigs/launch/factory-rsi/`) whose seats pin 1:1 to its roles via
`preferred_targets` — the v0 hardcode seam, no binding layer.
## The CLI surface (OPR.0.4.6.WF3)
WF-3 made the CLI the primary human/agent driving surface. Render-side
by rule (BR-2): `run`/`watch` consume the shipped SSE endpoints
(snapshot-first-then-stream, priorQitemId dedup, reconnect → announced
poll fallback, outcome-as-exit-code: 0 completed / 3 failed);
`trace`/`list`/`show` human modes are formatted (argo-shape tree, ps-
mechanics columns, ATTN markers) while `--json` stays byte-stable;
`status` composes the needs-attention rollup CLI-SIDE from the
API-carried `instance.deadline` classification (one threshold home —
the CLI never recomputes a class). The two daemon additions:
- **`route`** (`POST /api/workflow/:id/route`, runtime `route()`):
close+recreate+rebind in ONE scribe transaction — honest
`handed_off_to` closure with provenance, successor recreate (same
step; `current_step_id` unchanged; no hop bump — route is not an
advance), frontier rebind under the version guard, keepalive
re-target in-txn. Advance-authority revocation is STRUCTURAL: the
old packet leaves the frontier in the transaction, so a zombie
owner's stale `project` hits the shipped `packet_not_on_frontier`
409.
- **The frontier close-path guard** (`workflow-frontier-guard.ts` →
INJECTED into `QueueRepository` at startup; the queue never imports
the workflow domain): terminal closure of a live frontier packet
from non-workflow verbs rejects `workflow_frontier_packet` with a
what/why/fix naming `rig workflow project` / `route`. Workflow
writers pass `viaWorkflowVerb`; non-workflow qitems see zero new
behavior.
## The exception + human-gate model (OPR.0.4.6.WF5)
The deterministic engine's exception layer: the happy path stays
orchestrator-free and PROVEN so; every exception becomes exactly one
durable attention item the moment it exists; the responder resolves it
and the flow resumes from where it stopped.
- **The taxonomy** (`workflow-exception.ts`): three closed classes as
pure predicates over recorded state — `unmapped_failed` (recorded
`status=failed`; a WF-2-mapped `failed` routes to remediation and is
NOT an exception), `stuck_overdue` (the WF-1 deadline evaluator's
verdict consumed verbatim — the single threshold home), and
`human_gate_trip` (WF-2 HUMAN gates; the compiled park IS the item).
Handler-role gate-trips are deterministic handoffs, not exceptions —
classes (a)/(b) backstop the handler's own step. The occurrence key
is the recorded packet id of the episode: re-detections dedupe,
resolve+resume closes, a fresh packet is a NEW occurrence.
- **The maturity dial** (`workflow-exception-router.ts` + the
`exception_routing` spec grammar + the `workflow.exception_routing`
settings key): target resolution = spec per-class → spec default →
host dynamic key → ORCHESTRATOR-FIRST (the declared orchestrator
role via the same `preferred_targets` pick step owners use) →
`human@host` never-lost fallback. THE TIER SPLIT: `human-gate` rides
ONLY human-routed positions — an orchestrator-routed item carries
the ordinary tier so the shipped attention union (which matches on
tier regardless of destination) never leaks it into NEEDS-YOU. The
shipped attention predicate is untouched.
- **Class (a) born-in-txn**: the projector's failed-terminal branch
creates the item INSIDE the failing transaction — no window where
the instance is failed and no item exists; a gate-rejected routed
destination re-creates on `human@host` (never lost, never fails the
close). **Class (b) at detection**: the boot sweep and the keepalive
evaluation call the injected ensurer (`workflow-exception-
escalation.ts`) — occurrence-deduped against OPEN items by tag
query; the crash-surviving sweep re-creates a missed item.
- **`resume`** (`POST /api/workflow/:id/resume`, runtime `resume()`,
`rig workflow resume`): redrive semantics in ONE scribe transaction —
failed→active REBOUND to the recorded failed step; the owner is
RE-RESOLVED through the projection resolver (never copied from the
stale destination — resume is the one sanctioned re-resolution
point); `--decision` lands durably in the redrive packet; the
occurrence's open items close with provenance; the trail is
preserved, never rewritten. THE LIVELOCK RAIL (migration 051):
`hops_baseline` re-anchors the max_hops guard at resume so each
redrive gets exactly one bounded window; `resume_count` is the
recorded redrive fact; re-exceeding raises an honest NEW occurrence.
- **The workflow-aware ▲ band** (`review/compose.ts
deriveWorkflowExceptions` + the gatherer source): the missing-item
backstop row, the stuck row with evaluator evidence, the
frontier-non-open ANOMALY row (detection behind WF-3 FR-6's
prevention), and THE AWARENESS ROW — an orchestrator-routed
exception renders holder + age in the human band (one identity, two
projections, count = 1); human-routed items render nothing there
(the ● item is the row). Recomposition clears on state-exit.
## The workflow ↔ rig binding layer (OPR.0.4.6.FAC1)
Run any workflow on any rig that can support it, without editing the
spec — the self-driving factory's binding substrate. Three seams:
- **A1 — bind at instantiation** (migration 052,
`workflow_instances.bound_rig`): the effective binding =
`--rig`/`targetRig` override `?? spec.target.rig ?? null`; the spec
field stays a DEFAULT (no routing path reads it — display only). The
rig NAME persists (the durable operator-space coordinate); name→id
re-resolves fresh at each resolution site — a vanished rig fails loud
(`bound_rig_not_found`), never silently. NULL = unbound =
byte-identical pre-FAC-1 behavior. **Unknown-rig validation SPLITS by
PROVENANCE** (arch ruling 2026-07-07, target-rig zero-regression):
an explicit operator `--rig`/`targetRig` is AUTHORITATIVE — unknown →
`bound_rig_unknown` HARD-FAIL before any mutation; a spec-default
`target.rig` is ADVISORY (authored under the pre-FAC-1 regime where the
field was ignored at runtime) — unknown → DEGRADE to unbound + a LOUD
`advisories` notice on the instantiate result (surfaced by route + CLI
stderr), never a hard-fail. This preserves AC-1 zero-regression for
shipped/example specs that declare a descriptive `target.rig` AND route
via `preferred_targets` (e.g. `conveyor`): they instantiate exactly as
pre-FAC-1. A spec that genuinely needs a bound rig still fails loudly,
per-step (entry at instantiate, later role-only steps at projection) —
the degrade is to honest per-step failure with a heads-up, never to
silence.
- **A2 — role→seat capability resolution**
(`workflow-role-resolver.ts` pure policy +
`workflow-role-context.ts` lazy sync snapshot): TIER 3 inside
`resolveDefaultOwner`, activating ONLY when a role declares zero
`preferred_targets` AND the instance is bound. Tier order is sacred:
explicit owner → gate compile → declared `preferred_targets`
(byte-identical, never inventory-filtered) → capability → loud null.
The CLOSED fact set: role (`nodes.role`, declared per pod member —
seat-side, opt-in) · nodeKind · lifecycleState (`running` only) ·
runtime (harness-pin aware) · sync `pendingWorkCount` (pending-only
backlog) · the derived canonical coordinate. Async
`attachAgentActivity`/tmux probes are structurally absent from the
transaction. Managed seats only: adopted seats are excluded LOUDLY
(`adopted_seat_not_role_resolvable_v1`). Selection = least backlog,
plain-codepoint coordinate tiebreak (`driver10@rig < driver2@rig`).
Resolution runs ONCE at step-close (after the frontier + absorption
guards — replays perform zero inventory reads) and records as the
packet destination; the WF-5 resume is the one re-resolution point,
now capability-aware. All SIX owner-resolution sites carry the
context: projector next-step, human-gate parked-packet owner,
handler-role gate destination (no-targets + bound → capability),
runtime entry (live + recorded), the eager instantiate loop
(STRUCTURAL zero-role-coverage check only — `bound_rig_role_uncovered`;
no live resolution of future steps; a warming rig instantiates), and
resume. The WF-5 exception dial's orchestrator-role position resolves
capability-aware on the bound rig at both homes (in-txn class-(a) +
detection-time class-(b)), non-throwing with the human@host
never-lost fallback. Failures are loud-with-candidates: structured
per-candidate disqualifiers + a named zero-candidate message; never a
spawn, never auto-`add_member`, never a dead-seat route. Additive
`owner_resolution` trail evidence records `{mode, role, boundRig?,
seat}` per routing decision.
- **A3 — roles bind to SEATS, never occupants**: the ONE string rule —
the derived canonical coordinate `{pod}-{member}@{rig}` is both the
tiebreak key and the recorded destination, so an agent handover
behind the seat never strands the workflow (raw occupant-era session
names are never recorded as role-resolution destinations).
## The member-exists instantiate advisory (OPR.0.4.6.FAC3 — engine bit)
The queue transport validates the RIG of a destination, never the member
(`topologyValidateRig` is rig-exists-only by design — hardening it would
gate every queue write and break legitimate non-managed destinations),
so a declared `preferred_target` naming a registered rig but a typo'd or
stale MEMBER would mint an orphan packet, visible only later as a WF-5
stuck exception. FAC-3 catches it at the earliest knowable moment: at
instantiate, for every step-referenced role (each step's `actor_role`
plus a handler gate's target role — the same reference set the
structural coverage check walks), each declared `preferred_target` that
parses CANONICAL and names a rig registered on this daemon is probed
for member existence (`rigMemberExists` beside `rigDeclaresRole` —
sync SQL, the derived canonical coordinate per the FAC-1 Q5 one-string
rule, existence at ANY lifecycle state and node kind; liveness stays
projection's business). An unknown member yields ONE aggregated
advisory per unique target — naming every declaring step/role pair, the
consequence (the work will not be claimed; it will surface as a stuck
exception), and the fix hint — pushed into the shipped
`InstantiateResult.advisories` list (the FAC-1 `target.rig`-degrade
surface: one list, now two producers; rendered by the route body + CLI
stderr, zero new surface). ADVISORY-NEVER-DENY: instantiate always
proceeds. Skips (in order): human-seat refs (classified BEFORE parse,
the queue-gate archetype), non-canonical raw/adopted destinations (the
inventory cannot vouch for them), unregistered rigs (the transport
already rejects those loudly at queue-write — no double advisory).
## See also
- `coordination-primitive.md` — PL-004 Phase A; the closure authority the
@@ -149,4 +475,5 @@ Cross-ref: the `rig workflow` CLI surface — see `../cli-reference.md`.
workflow-runtime,workflow-instance-store,workflow-spec-cache,
workflow-step-trail-log,workflow-validator}.ts`,
`packages/daemon/src/domain/policies/workflow-keepalive.ts`,
`packages/daemon/src/routes/workflow.ts`.
`packages/daemon/src/domain/{workflow-exception,workflow-exception-router,
workflow-exception-escalation}.ts`, `packages/daemon/src/routes/workflow.ts`.
+129 -14
View File
@@ -331,6 +331,7 @@ Current behavior notes:
- `local:` `agent_ref` values resolve relative to the rig spec directory, not the caller shell cwd.
- If you copy a built-in spec to a new directory, keep its `agents/` tree beside it or rewrite those refs to `path:/absolute/path`.
- Managed apps are first-class `up` targets. `rig up secrets-manager` launches the shipped Vault example from the library.
- **`rig up factory-rsi` (OPR.0.4.6.FAC2)** launches the single-rig recursive-self-improvement factory MVP starter — seven seats (`plan`/`build`/`check`/`review`/`dogfood`/`release`/`orch`) that run the `factory-rsi` builtin workflow (inner loop plan→build→check→review→release); the dogfood seat runs out-of-band against the shipped product and feeds its findings back into the next plan. Workspace-agnostic: `rig up factory-rsi --cwd <repo>` points the loop at the repo to improve.
- v0.3.2 paper-cut fix-round (slice-22): pre-launch failures now return structured HTTP 4xx (`cycle_error` / `preflight_failed` / `validation_failed` / `service_boot_failed`) instead of bare 500; failed boots no longer leave orphan rig records on disk.
- **v0.4.4 (OPR.0.4.4.11) — whole-topology sources**: a `.rigtopology` manifest (or a YAML file whose body declares the topology form) boots MULTIPLE rigs in one staged spin-up. v0 manifest entries are **spec paths only** (a closed-key manifest: `.rigbundle` and bare library-name entries are rejected at parse time with per-entry what/why/fix naming the v0 boundary). Per-entry `host: <id>` is the ONLY placement mechanism for topology entries — `rig up --host <id> <topology>` is REJECTED pre-dispatch (two placement mechanisms must not coexist). The launcher acquires per-rig launch locks route-side and reports a CLOSED per-entry aggregate `{ok | failed | skipped}` (skipped is explicit — a lock conflict or upstream failure never reads as silent success). Source: `packages/cli/src/commands/up.ts` (`.rigtopology` sniff + `--host` rejection), `packages/daemon/src/domain/topology/{topology-manifest,multi-rig-launcher,remote-up-leaf}.ts`, `packages/daemon/src/routes/up.ts`.
- v0.3.4: `rig up` is resume-original-by-default for existing rigs. Per-seat opt-in to deliberate fresh-prime is via `--fresh <seats...>`. The five-term restore status vocabulary surfaced per-node is `resumed` / `fresh-primed` / `awaiting-decision` / `attention_required` / `failed`. On TTY, `awaiting-decision` nodes trigger an interactive [y/N] ASK; in headless mode they are reported honestly with the exact `rig up --existing <rig> --fresh <logicalId>` follow-up command.
@@ -634,6 +635,7 @@ Arguments:
Notes:
- The `add_member` converge op verb: adds a member to an existing pod in a running rig from a YAML/JSON member-fragment file.
- Member fragment accepts both the bare form (top-level member fields) and the wrapper form (`{ member: {...}, edges?: [...] }`). A top-level `edges:` field in the bare form is lifted as pod-local edges and is NOT silently dropped.
- **OPR.0.4.6.FAC1**: the fragment accepts an optional `role: <name>` (charset `A-Za-z0-9_.-`; rejected on `runtime: terminal`). A role-declared seat becomes eligible for workflow role→seat capability resolution on this rig — scale-out = add a member under the role (this verb IS the growth path). Role is opt-in per seat: a role-less member stays reachable only via explicit `preferred_targets`; a PROVIDED role is validated, never silently dropped.
- A present-but-non-array `edges` field is rejected with an honest error (no silent drop).
- `--rig-root <path>` controls agent resolution.
- HTTP outcomes: `201` on success (with the new node + persisted edges + optional warnings); `409 member_conflict`; `400 validation_failed` / `preflight_failed`; `404 pod_not_found` (lists existing pods).
@@ -753,7 +755,7 @@ Notes:
### `rig transcript`
Usage: `rig transcript <session> [--tail <lines>] [--grep <pattern>] [--json]`
Usage: `rig transcript <session> [--tail <lines>] [--grep <pattern>] [--host <id>] [--json]`
Defaults:
- `--tail 50`
@@ -761,6 +763,12 @@ Defaults:
Notes:
- Reads transcript files, not pane scrollback.
- `--grep` treats the pattern as regex.
- **v0.4.6 (OPR.0.4.6.MH4)** — `--host <id>` / the `agent@rig@host` session form reads the
transcript from a remote host, CLI-direct against that daemon's shipped
`GET /api/transcripts/:session/tail|grep` routes (http-registered hosts only — an
ssh-declared host is a structured transport-requirement error; there is no ssh path for this
verb). Output shape is the origin's, verbatim, under the `[via host=…]` banner. Precedence:
explicit `--host` > target sugar > the persisted host selection. See "Cross-host execution".
### `rig send`
@@ -774,7 +782,7 @@ Notes:
- `--raw` sends exact text/keystrokes without the From/To messaging envelope; still guarded against interactive prompts.
- `--dangerously-interact` is the ONLY override of the prompt/permission guard — it deliberately drives an interactive prompt/permission block (e.g. selects an option). It implies `--raw`, requires `--reason <text>`, and is recorded in the audit log. Cannot be combined with `--wait-for-idle`.
- `--reason <text>` records why the prompt is being driven (required with `--dangerously-interact`).
- `--host <id>` routes the same command to a remote host declared in `~/.openrig/hosts.yaml` via single-hop ssh; see "Cross-host execution" below. SSH success is NOT verify success — the remote rig's `Verified: yes/no` is what counts and is surfaced verbatim.
- `--host <id>` sends on a remote host declared in `~/.openrig/hosts.yaml`; see "Cross-host execution" below. **v0.4.6 (OPR.0.4.6.MH4)** — the host entry's transport decides the path: ssh hosts keep the single-hop ssh shell-out byte-verbatim (SSH success is NOT verify success — the remote rig's `Verified: yes/no` is what counts and is surfaced verbatim); http hosts (e.g. pair-registered) go CLI-direct to the remote daemon's `POST /api/transport/send` with the same body a local send posts (wrap parity by construction) — `--verify` prints the REMOTE route's `verified`/`outcome` verbatim, never a locally synthesized verdict. The `agent@rig@host` target form is sugar for `--host` when the suffix is a REGISTERED host id (explicit `--host` > sugar > persisted selection; a conflict between `--host` and the sugar is a structured error).
### `rig capture`
@@ -787,7 +795,7 @@ Default:
- `--lines 20`
Notes:
- `--host <id>` routes the same command to a remote host declared in `~/.openrig/hosts.yaml` via single-hop ssh; see "Cross-host execution" below.
- `--host <id>` captures on a remote host declared in `~/.openrig/hosts.yaml`; see "Cross-host execution" below. **v0.4.6 (OPR.0.4.6.MH4)** — ssh hosts keep the shell-out verbatim; http hosts go CLI-direct to the remote daemon's `POST /api/transport/capture` with the local body shape (lines/rig/pod/session), rendering single/multi results exactly as a local capture under the `[via host=…]` banner. The `agent@rig@host` session form is sugar for `--host` when the suffix is a REGISTERED host id (`--rig`/`--pod` values are names, never sugar-parsed).
### Cross-host execution (`--host <id>`)
@@ -804,7 +812,7 @@ hosts:
transport: ssh
target: vm-claude-test.local
user: your-username # optional
notes: "Tart VM" # optional
notes: "test VM" # optional
- id: factory-http
transport: http
url: http://100.64.1.2:7433
@@ -832,20 +840,69 @@ actionable error per mode; JSON output preserves the `failedStep` enum):
partition is the intended posture, not an accident of history: **ssh carries interactive pane
ops, http-bearer carries daemon REST ops, `ps`/`whoami` follow the host's DECLARED transport.**
There is NO cross-transport fallback, and NO http parity for `send`/`capture` ships in 0.4.4
(parity would be new attack surface with no scope-locked need). Per-command:
(parity would be new attack surface with no scope-locked need). **v0.4.6 UPDATE (OPR.0.4.6.MH4,
pm-RULED IN as fulfilling-confirmed-intent):** `send`/`capture` gain the http branch — the
founder's `pair` front door registers HTTP hosts, and without the branch a pair-registered demo
host could not receive send/capture at all. The mechanism is CLI-DIRECT via the shipped
`runRemoteHttpOp` to the remote daemon's EXISTING transport routes (zero daemon-side changes;
the ssh path is kept byte-verbatim for ssh hosts — coverage, not a rewrite, and still no
cross-transport fallback: the host entry's declared transport dictates the path). `transcript`
and `broadcast` gain their first cross-host affordance the same way (http-only — there is no
ssh path for them). Per-command:
| Command | ssh transport | http transport | Fan-out (`--all-hosts`/`--hosts`) |
| --- | --- | --- | --- |
| `rig send` | ✓ (ssh-ONLY) | ✗ | ✗ |
| `rig capture` | ✓ (ssh-ONLY) | ✗ | ✗ |
| `rig send` | ✓ (shell-out, byte-verbatim) | ✓ (v0.4.6 MH-4 — CLI-direct `POST /api/transport/send`) | ✗ |
| `rig capture` | ✓ (shell-out, byte-verbatim) | ✓ (v0.4.6 MH-4 — CLI-direct `POST /api/transport/capture`) | ✗ |
| `rig transcript --host` (v0.4.6, OPR.0.4.6.MH4) | ✗ (structured transport error) | ✓ (CLI-direct `GET /api/transcripts/:session/tail\|grep`) | ✗ |
| `rig broadcast --host` (v0.4.6, OPR.0.4.6.MH4) | ✗ (structured transport error) | ✓ (CLI-direct `POST /api/transport/broadcast`; remote fan-out, per-target passthrough) | ✗ |
| `rig up` / `rig down` / `rig launch` | ✗ | ✓ (http-ONLY) | ✗ |
| `rig file copy` (v0.4.4) | ✓ (ssh-ONLY, rsync-over-ssh) | ✗ | ✗ |
| `rig ps --host` | ✓ (declared) | ✓ (declared) | http-only; non-http hosts appear as STRUCTURED `unsupported-transport` statuses in `hosts[]` |
| `rig whoami --host` | ✓ (declared) | ✓ (declared) | http-only; non-http hosts are currently SILENTLY FILTERED from the fan-out (a shipped gap, routed for 0.4.5 triage — differs from ps's structured status) |
| `rig host doctor` | ✓ | ✓ | n/a (single host) |
| `rig queue create/handoff/handoff-and-complete --host` (v0.4.6, OPR.0.4.6.MH3) | ✗ | ✓ (http-ONLY, daemon→daemon forward — an ssh-declared host is a structured `unsupported-transport` error) | ✗ |
Out of scope in 0.4.4: cross-transport fallback; http parity for `send`/`capture`; connection
pooling; multi-hop SSH; cross-host queue routing; cross-host seat handover.
Out of scope in 0.4.4: cross-transport fallback; http parity for `send`/`capture` *(shipped in
0.4.6 — OPR.0.4.6.MH4, pm-ruled fulfilling-confirmed-intent; see the v0.4.6 update above)*;
connection pooling; multi-hop SSH; cross-host queue routing *(shipped in 0.4.6 — OPR.0.4.6.MH3;
see `rig queue` § Cross-host queue routing)*; cross-host seat handover.
**The http branch's failure taxonomy (v0.4.6 — OPR.0.4.6.MH4).** The http branch names its OWN
steps (never the ssh enum, never a generic "failed"): registry-load-failed / unknown-host (the
same class across all four verbs) / `permission-gate` (bearer resolution failed locally, or the
remote returned 401/403 — including the terminal-bearer posture below) / `remote-daemon-unreachable`
(network/timeout) / `remote-command-failed` (remote 4xx/5xx, with the remote route's own error
text surfaced beside the step). **Terminal-bearer posture (named, v0 — applies to
`/api/transport/*` ONLY, i.e. send/capture/broadcast):** the remote's transport routes gate on
ITS terminal bearer class, while the CLI presents the host's REGISTRY bearer. Default (no
terminal bearer) + tailnet binds = pass-through by design (the mesh is the auth boundary); a
remote enforcing a DIFFERENT terminal bearer surfaces as the structured `permission-gate` step —
remedy: set the remote's terminal bearer equal to the paired registry bearer, or rely on the
tailnet boundary. **`rig transcript --host` is NOT in this class (arch n2):** the remote's
`/api/transcripts/*` routes are the shipped UNGATED transcript-read posture (open route,
daemon-local trust boundary, route-level credential redaction as the protective primitive) — a
wrong terminal bearer that permission-gates a cross-host send does NOT gate a cross-host
transcript read; transcript keeps succeeding. A coherent transcript-read auth policy across
tail/grep/full is a named future slice per the route's own comment
(`routes/transcripts.ts`, orch decision approved-option-a), out of scope here. No new auth
machinery ships with this slice.
**Destination parse rules — the two-family contract (OPR.0.4.6.MH3, arch-ruled).** The
`agent@rig@host` three-part form is INPUT SUGAR at the CLI edge, never grammar: session strings
stay `member@rig` everywhere (BR-1), and the host always travels out-of-band. TWO parse rules
ship, per verb family, BY DESIGN — one canonical rule would either break adopted targets or
degrade queue error honesty:
| Verb family | 3-part trailing segment | Why |
| --- | --- | --- |
| Queue coordination writes (`rig queue create/handoff/handoff-and-complete`) | **Always stripped** into the out-of-band `hostId` envelope (after the human-seat classifier) | Queue destinations are canonical-only by construction (the daemon's `validateRig` rejects any non-canonical parse), so the unconditional strip loses nothing — and a mistyped host dies loud as an unknown-HOST error instead of a misleading rig-shaped `unknown_destination_rig`. |
| Session-target interactive/observe verbs (`rig send/capture/transcript`) | **Stripped only if the segment matches a REGISTERED host id** | Interactive verbs legitimately target raw/adopted tmux session names that may contain `@`; strip-iff-registered preserves them, with the unregistered-suffix host hint keeping mistypes loud. |
| `rig broadcast` | **No sugar** — the positional is MESSAGE TEXT, never parsed as a target | Sugar-parsing a message body would corrupt text containing `@`; cross-host broadcast routes on `--host` or the persisted selection only (v0.4.6 — OPR.0.4.6.MH4). |
Both families converge on the same outcome: a mistyped host dies loud with the host named. The
`RESERVED_HOST_IDS` set (`kernel`, `host`, `local` — rejected at `rig host add`) guarantees no
registered host can ever shadow the human-seat `@kernel`/`@host` classification family.
### `rig file` (v0.4.4 — OPR.0.4.4.18)
@@ -895,10 +952,18 @@ the path for exotica, and the factory bootstrap ships as script + runbook at
### `rig broadcast`
Usage: `rig broadcast <text> [--rig <name>] [--pod <name>] [--force] [--json]`
Usage: `rig broadcast <text> [--rig <name>] [--pod <name>] [--force] [--host <id>] [--json]`
Notes:
- Without `--rig` or `--pod`, broadcasts across all running sessions in all rigs.
- **v0.4.6 (OPR.0.4.6.MH4)** — `--host <id>` broadcasts on a remote host, CLI-direct to that
daemon's shipped `POST /api/transport/broadcast` (http-registered hosts only; an ssh-declared
host is a structured transport-requirement error). The REMOTE daemon resolves `--rig`/`--pod`
on ITS topology; its per-target results print verbatim and a partial fan-out exits non-zero
exactly as a local one. The remote call carries its own named fan-out deadline
(`BROADCAST_REMOTE_TIMEOUT_MS`, 30s — a full per-target loop outlives the 5s read default).
The `<text>` positional is message text and is NEVER parsed as a target, so broadcast takes
`--host` or the persisted host selection — not the `agent@rig@host` sugar.
### `rig ask`
@@ -960,11 +1025,12 @@ Notes:
Usage: `rig queue <subcommand>` — L3 owned-work queue plus inbox/outbox.
Subcommands:
- `create --source <session> --destination <session> (--body <text> | --body-file <path>) [--mission <id>] [--slice <id>] [--priority <p>] [--tier <t>] [--tags <csv>] [--target-repo <name>] [--no-nudge] [--expires-at <iso>] [--id <qitemId>] [--json]` — v0.3.2 slice-21 FR-4 adds `--body-file <path>` (use `-` for stdin) which kills the backtick-shell-corruption class for multiline bodies, and first-class `--mission <id>` / `--slice <id>` flags that translate to `mission:<id>` / `slice:<id>` tags (compose with `--tags`). Exactly one of `--body` / `--body-file` must be provided.
- `create --source <session> --destination <session> (--body <text> | --body-file <path>) [--mission <id>] [--slice <id>] [--priority <p>] [--tier <t>] [--tags <csv>] [--target-repo <name>] [--host <id>] [--no-nudge] [--expires-at <iso>] [--id <qitemId>] [--json]` — v0.3.2 slice-21 FR-4 adds `--body-file <path>` (use `-` for stdin) which kills the backtick-shell-corruption class for multiline bodies, and first-class `--mission <id>` / `--slice <id>` flags that translate to `mission:<id>` / `slice:<id>` tags (compose with `--tags`). Exactly one of `--body` / `--body-file` must be provided. v0.4.6 (OPR.0.4.6.MH3) adds `--host <id>` / the `agent@rig@host` destination form — see § Cross-host queue routing below.
- `claim <qitemId> --destination <session> [--json]` — pending → in-progress; computes closure_required_at from tier
- `unclaim <qitemId> --destination <session> [--reason <text>] [--json]` — in-progress → pending
- `update <qitemId> --actor <session> --state <state> [--closure-reason <r>] [--closure-target <t>] [--note <text>] [--json]`
- `handoff <qitemId> --from <session> --to <session> [--body <text>] [--note <text>] [--priority <p>] [--tier <t>] [--tags <csv>] [--json]` — transactional close-as-handed-off + create-new
- `handoff <qitemId> --from <session> --to <session> [--body <text>] [--note <text>] [--priority <p>] [--tier <t>] [--tags <csv>] [--host <id>] [--json]` — transactional close-as-handed-off + create-new; v0.4.6 (OPR.0.4.6.MH3) adds `--host <id>` / the `agent@rig@host` `--to` form (§ Cross-host queue routing)
- `handoff-and-complete <qitemId> --from <session> --to <session> [--body <text>] [--note <text>] [--priority <p>] [--tier <t>] [--tags <csv>] [--host <id>] [--json]` — variant of `handoff` that closes the source as `done` (terminal) instead of `handed-off`; same atomic close+create, chain_of_record, default-nudge, and cross-host contracts
- `fallback <qitemId> --destination <session> [--reason <text>] [--json]` — reroute to fallback seat
- `show <qitemId> [--json]`
- `transitions <qitemId> [--json]` — append-only transition log
@@ -999,6 +1065,54 @@ Closure-reason semantics:
- `no-follow-on` — terminal completion, nothing else needed.
- `escalation` — kicked up to a higher tier (target = escalation target).
### Cross-host queue routing (v0.4.6 — OPR.0.4.6.MH3)
`rig queue create`, `handoff`, and `handoff-and-complete` can target a destination on ANOTHER
registered host: `--host <id>` or the host-qualified destination form `member@rig@<host>` (both
resolve to the same out-of-band `hostId` request envelope; naming both with DIFFERENT hosts is a
structured ambiguity error). The mechanism generalizes the shipped mission-control
forward-then-strip WRITE: the local daemon resolves the host registry server-side (bearers never
reach the caller), strips the host at the edge, and forwards the WHOLE body over HTTP to the
target daemon — see `docs/as-built/architecture/coordination-primitive.md` § Cross-host queue
routing for the full model. Load-bearing contract points:
- **Explicit-only (no selection follow).** Queue verbs route cross-host ONLY on `--host` / the
3-part form — they NEVER consult the persisted `rig host select` selection (a durable write and
a hot-potato close must not silently re-home on yesterday's sticky selection). This is a
deliberate asymmetry with the observe/interactive verbs, which do follow selection.
- **Origin-owns-the-record.** The qitem lives in the TARGET host's DB; that row is THE record.
No local ghost row is ever written, and the target daemon's OWN nudge fires on ITS local tmux
(the whole body — including the `nudge` flag — is forwarded).
- **At-least-once + idempotent, never exactly-once.** The forwarding daemon MINTS the qitem id
before the first forward (a retry carries the same id by construction); a cross-host handoff's
successor id is DERIVED deterministically from (source qitem, destination, host) — namespaced
`qitem-xh-…`, so a re-driven forward absorbs on the target's primary key. Retrying is safe;
a same-id create whose identity fields differ is a structured `qitem_id_reuse` error.
- **Never-drop ordering.** A cross-host handoff creates the successor on the target host FIRST
and closes the local source SECOND. A crash between the two leaves a live duplicate that the
idempotent re-drive converges — never a source closed toward a successor that doesn't exist.
Re-drives: an already-closed source with the MATCHING `closure_target` absorbs as success; a
MISMATCH is a structured `cross_host_close_conflict` (409) — surfaced, never overwritten.
*(Named residual, inherent to at-least-once/no-2PC: a re-drive naming a DIFFERENT destination
is a NEW handoff decision and can leave the earlier successor live on the target host — visible
via the chain + provenance tags, not a dedup bug.)*
- **Closure fields.** The cross-host source close records `closure_reason=handed_off_to` and
`closure_target=member@rig@<host>` — `closure_target` is OPAQUE audit/display metadata,
presence-checked and NEVER parsed for routing (any PR parsing it as a session string is a spec
violation). Session-string carriers (`destination_session`, `source_session`, `blocked_on`,
`handed_off_to`) stay 2-part `member@rig` (BR-1). The forwarded successor carries the continued
`chain_of_record`; those A-side ids are OPAQUE lineage identifiers on the target host (they do
not dereference in the target's DB). Provenance: the forwarded item is tagged `cross-host` +
`from-host:<sender's self-declared name>` (honest best-effort, not authenticated identity).
- **Failure honesty.** Unknown host / ssh-declared host / unreachable / auth-failed each surface
as a distinct structured `remote_queue_write_failed` error naming the host — nothing is written
on either side. Transport is http-ONLY (the daemon→daemon path is what fires the remote nudge;
the `rig send --host` ssh shell-out is a different mechanism, untouched).
- **Local zero-regression.** No `--host` (or `local`) = today's local path, byte-identical.
Claim / update / inbox verbs stay local-by-principle: after a cross-host handoff the successor
lives on the target host where its worker lives. Sender-side operations on an already-forwarded
item (cancel/update from the sending host) are a named follow-up, out of v1.
## Coordination Project / Classifier and View (PL-004 Phase B)
Two top-level commands extend the coordination layer with L2 (project / classifier) and L5 (views).
@@ -1059,8 +1173,9 @@ Phase D extends the policy enum with `workflow-keepalive` (the policy deferred f
### `rig workflow`
- `validate <specPath>` — validate a workflow spec file; returns structured ok/error report (role resolution, step uniqueness, allowed-exits consistency, optional seat liveness).
- `instantiate <specPath> --root-objective <text> --created-by <session>` — create a new instance + entry-step qitem in the same daemon transaction; `--entry-owner <session>` overrides the default entry owner.
- `validate <specPath>` — validate a workflow spec file; returns structured ok/error report (role resolution, step uniqueness, allowed-exits consistency, optional seat liveness). Spec-only: it takes no rig context (OPR.0.4.6.FAC1 arch ruling — rig-coverage checks happen at instantiate).
- `instantiate <specPath> --root-objective <text> --created-by <session>` — create a new instance + entry-step qitem in the same daemon transaction; `--entry-owner <session>` overrides the default entry owner. **OPR.0.4.6.FAC1**: `--rig <name>` binds the instance to a rig (overrides the spec's `target.rig` DEFAULT; persists as `boundRig` on the instance, rendered by `show`/`trace` and carried in `--json`). On a bound instance, a role with **no `preferred_targets`** resolves to a live capable SEAT on that rig by the pure capability policy (running agents declaring the role, managed seats only, required runtime, least pending backlog, deterministic coordinate tiebreak). Unknown rig = structured `bound_rig_unknown`; a bound-rig role no seat structurally declares = `bound_rig_role_uncovered` (existence at any lifecycle state satisfies it — liveness is checked when the step projects). No `--rig` and no spec default = unbound, byte-identical pre-FAC-1 behavior.
- `run <specPath> …` — accepts the same `--rig <name>` binding (run instantiates too).
- `project --instance <id> --current-packet <qitem-id> --exit <handoff|waiting|done|failed> --actor-session <session>` — close the current packet AND project the next-step packet IN THE SAME daemon transaction (transactional-scribe; lost handoffs impossible by design). `--result-note <text>`, `--blocked-on <ref>`, `--next-owner <session>` modify behavior.
- `list [--status <s>]` — list instances; optionally filter by status (`active`/`waiting`/`completed`/`failed`).
- `show <instanceId>` — show one instance.
+2 -2
View File
@@ -220,8 +220,8 @@ new daemon. Migrations are additive.
- **Multi-Host Dogfood (slice 02)** — the `--host` CLI flag was
shipped pre-0.4.0; this slice closes the cross-machine integration
story (Tart-VM dogfood, Hetzner VPS + 2nd-Mac as final external
QA). HTTP-primary transport; cross-host identity rides 0.4.1.
story (multi-machine dogfood + external QA). HTTP-primary
transport; cross-host identity rides 0.4.1.
- **Native Codex Session Identity (slice 11)** — landing the common
identity types both the daemon and CLI consume — native Codex
session-identity capture + the foundation for the slice-12
+1 -1
View File
@@ -6,7 +6,7 @@ OpenRig v0.4.3 makes rigs actually survive. Crash the daemon or the whole
machine — turn the power off, not a graceful stop — and on reboot the
seats come back to their **original** sessions, with their pre-crash
markers recalled, zero fresh-prime. The proof is a real hard power-off
capstone (`Tart stop --timeout 0` → reboot → `rig start` → both Claude
capstone (hard power-off → reboot → `rig start` → both Claude
and Codex seats resumed to their originating conversations). This is the
load-bearing "rigs that survive" claim.
+198
View File
@@ -0,0 +1,198 @@
# OpenRig v0.4.6
## Summary — Workflows + multi-host coordination + factory foundations
OpenRig v0.4.6 lands the deterministic workflow engine and the
multi-host coordination model that runs on top of it. Workflows are
now a first-class product surface: a rock-solid engine, a full-
featured spec language, a workflow CLI, a workflow web UI, and an
exception + human-gate maturity model that keeps a healthy run
zero-interruption. On top of that engine, the self-driving factory
starter ships as an MVP shipped starter (`rig up factory-rsi`) —
one rig, seven seats, a self-driving `plan → implement → qa_check
→ review → release` inner loop with dogfood decoupled and running
out-of-band against the shipped product, feeding recorded findings
into the next plan.
Multi-host coordination lands the full happy path. Hosts register
and select through dashboard-visible cards; a remote host's
workspace surfaces are readable from the local operator; queue
items and hot-potato handoffs cross hosts explicitly through the
registered transport (never a silent selection re-home); the direct
coordination verbs (`rig send`, `rig capture`, `rig transcript`,
`rig broadcast`) cross hosts CLI-direct via the shipped http
transport branch with byte-verbatim ssh behavior preserved; a
fleet-altitude attention band rolls up needs-attention across the
whole registered fleet.
Foundations round out the release: a new agent runtime adapter (Pi)
joining Claude/Codex as a first-class runtime, terminal provider
first-class treatment across the terminal-facing surfaces, and
daemon read-path hardening for the multi-host + workflow load with
no functional behavior change.
Two new bundled skills ship canonical + plugin with byte-parity
guard: `openrig-herdr` (rig terminal open/views/status model) and
`openrig-cmux` (provider-agnostic delta).
Migrations are additive only: `049_workflow_instance_version`,
`050_workflow_spec_json`, `051_workflow_resume`,
`052_workflow_instance_bound_rig`, `053_sessions_node_id_index`,
`054_queue_transitions_archive`. Existing v0.4.4 databases upgrade
by running `rig daemon start` on the new daemon. (v0.4.5 was
skipped; there is no cut between v0.4.4 and v0.4.6.)
## What Shipped
### Workflows
- **The rock-solid deterministic engine** — strict spec validation
(unknown keys reject; unreachable steps and unguarded cycles
fail); `loop_guards.max_hops` enforced at projection; waiting-
exit replay absorption; per-instance keepalive watchdogs auto-
armed in the routing transaction; startup sweep re-arms and
surfaces stuck instances; `rig workflow continue` relabeled to
its read-only inspector semantics.
- **The full-featured spec language** — steps with role /
target.rig / preferred_targets / next_hop.on / invariants /
loop_guards / exception_routing / closure / gates / skill_refs /
spawn_budget; strict-keyset validation; shipped-spec compat
pinned by fixture round-trip.
- **The workflow CLI** — new verbs `rig workflow run` (instantiate
+ follow to terminal; exit 3 = workflow-failed distinct from
transport 1/2), `rig workflow watch` (mid-flight attach),
`rig workflow status` (needs-attention rollup), `rig workflow
route` (frontier re-target in one transaction). Formatted
`trace` / `list` / `show` human output; `--json` byte-identical
to before. New guard rejects out-of-band terminal closure of a
live workflow-frontier packet.
- **The workflow web UI** — new
Workflows/Instance/InstancesBand/TopologyGraph/InstanceTrailTimeline
pages; SSE stream unscoped by rig; pure `buildLauncherViews` +
`suggestLayout` + edge-handle assignment exports. UI acceptance
is carried by per-cut real screenshots + the two-host
integration proof; twin fixture backfill is routed as a
non-blocking 0.4.7 hygiene fast-follow.
- **The exception + human-gate model** — `rig workflow resume`
verb (redrive a failed instance from its failed step, rebound,
fresh capability-aware re-resolution, one fresh `max_hops`
window); exceptions become durable attention items in the same
transaction as the failure; maturity dial `orchestrator-first |
human_only` per class / per workflow / per host; the attention
band is workflow-aware; the happy-path guarantee is unchanged.
### Multi-host coordination
- **Add / select hosts + dashboard** — `HostConfigCard` +
`HostIndicator` surfaces on the dashboard; `rig host select`
persists a sticky selection consumed by the observe/interactive
verbs; new `useHosts` / `useFleet` hooks.
- **View remote workspace** — workspace observability tabs gain
`--host <id>` scope; registry + bearer fans out per-host reads.
Read-only.
- **Cross-host queue routing** — `rig queue create / handoff /
handoff-and-complete` gain `--host <id>` + host-qualified
destination form `member@rig@<host>`. Explicit-only routing.
At-least-once + idempotent via deterministic successor id;
handoff creates successor FIRST + closes local SECOND; retries
absorb on primary key. Failure honesty per host outcome.
- **Cross-host agent coordination** — `rig send` + `rig capture`
gain an http transport branch reaching the remote daemon's
existing `/api/transport/send|capture` routes CLI-direct;
`rig transcript` + `rig broadcast` gain their first cross-host
affordance the same way. The `agent@rig@host` target form is
CLI-edge sugar for session-target verbs. Byte-verbatim ssh
behavior preserved for ssh-registered hosts; explicit `--host`
> target sugar > selection; `send --verify` over http prints
the REMOTE route's verdict verbatim.
- **Fleet-attention altitude** — fleet-wide attention rollup via
`FleetBand` + `FleetPage`; per-host status + drill-down.
### Factory
- **The workflow-to-rig binding layer** — bind a workflow to a rig
at instantiation via `--rig`; roles resolve to seats by
capability; capable-seat resolution honors managed-only,
harness-pin-aware runtime match, least pending backlog,
deterministic tiebreak; resolve-once, record-in-the-packet;
roles bind to the stable seat coordinate `{pod}-{member}@{rig}`
(agent handover behind the seat never strands the workflow).
- **The self-driving factory starter (`factory-rsi`)** — new
`rig up factory-rsi` starter; new `factory-rsi` builtin
workflow with a `plan → implement → qa_check → review →
release` inner loop sanctioned by `loop_guards.max_hops`;
dogfood decoupled + running out-of-band against the shipped
product with its findings feeding the next plan (the RSI edge,
ungated in the MVP — refined in a later release); publish stays
a human act via `release_signoff` gate.
- **The member-exists advisory + cross-rig wrapping** —
mis-routed workflow destinations caught loudly at instantiate;
cross-rig wrapping workflows travel via the cross-host queue
routing path.
### Runtime + terminal + read-path
- **Pi agent runtime adapter** — a new first-class runtime
joining Claude/Codex; participates in all workflow / role /
queue / send / capture surfaces exactly like the shipped
runtimes.
- **Terminal provider first-class treatment** — terminal provider
first-class treatment across the terminal launcher,
send/capture/broadcast, and the new `openrig-cmux` skill's
`--provider cmux` seam with best-effort fallback.
- **Daemon read-path hardening** — SSE stream backpressure
guards, projection caches, additive indices for read-side query
performance. No functional behavior change; the same reads
return the same results, faster.
### New bundled skills
- **`openrig-herdr`** — the full rig terminal open/views/status
model (verbs, view grammar, honest-partial reading, read-only
policy, terminal-views.yaml schema). AGPL clean-room rail — no
herdr source text vendored.
- **`openrig-cmux`** — the provider-agnostic vs cmux-specific
delta with shipped-integration open-or-focus rule.
Both ship canonical + bundled openrig-core plugin, byte-parity
guarded.
## Migrations
Additive only:
- `049_workflow_instance_version` — optimistic concurrency on
`workflow_instances`.
- `050_workflow_spec_json` — full parsed spec cached on
`workflow_specs`; legacy rows self-heal on next read-through.
- `051_workflow_resume` — `resume_count` + `hops_baseline` on
`workflow_instances`.
- `052_workflow_instance_bound_rig` — nullable `bound_rig` TEXT
on `workflow_instances`.
- `053_sessions_node_id_index` — additive index.
- `054_queue_transitions_archive` — archive table for read-side
query performance.
Existing v0.4.4 databases upgrade by running `rig daemon start` on
the new daemon.
## Known Follow-ons
- **Save-verb** did not land in 0.4.6; rides 0.4.7.
- **Twin fixture backfill** for the new workflow + multi-host UI
routes routed as a non-blocking 0.4.7 hygiene fast-follow; UI
acceptance for the cut carried by per-cut real screenshots + the
two-host integration proof.
- **Continuous out-of-band dogfood runtime** — the factory-rsi
RSI edge ships ungated in the MVP; the continuous mechanism is
refined in a later release.
- **Carry-forwards from 0.4.4**: `openrig-user` bundled plugin
stale-copy sweep, `whoami --all-hosts` silent host filter,
managed-stop SIGTERM-escalation brittleness, post-cutover
reconcile-settle-visibility signal on `/healthz`.
- **iOS Safari** (Living Notes composer verification) —
carry-forward from 0.4.4.
- **Wider mission-template prose sweep** — post-cut sequencing.
Full technical detail lives in
[CHANGELOG.md](../../CHANGELOG.md#046---2026-07-09).
+5 -5
View File
@@ -1,12 +1,12 @@
{
"name": "openrig",
"version": "0.4.4",
"version": "0.4.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openrig",
"version": "0.4.4",
"version": "0.4.6",
"license": "Apache-2.0",
"workspaces": [
"packages/daemon",
@@ -7449,7 +7449,7 @@
},
"packages/cli": {
"name": "@openrig/cli",
"version": "0.4.4",
"version": "0.4.6",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -7481,7 +7481,7 @@
},
"packages/daemon": {
"name": "@openrig/daemon",
"version": "0.4.4",
"version": "0.4.6",
"license": "Apache-2.0",
"dependencies": {
"@hono/node-server": "^1.13.0",
@@ -7506,7 +7506,7 @@
},
"packages/ui": {
"name": "@openrig/ui",
"version": "0.4.4",
"version": "0.4.6",
"license": "Apache-2.0",
"dependencies": {
"@fontsource-variable/jetbrains-mono": "^5.2.8",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "openrig",
"version": "0.4.4",
"version": "0.4.6",
"private": true,
"license": "Apache-2.0",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/cli",
"version": "0.4.4",
"version": "0.4.6",
"type": "module",
"description": "Local control plane for multi-agent coding topologies",
"keywords": [
+121 -18
View File
@@ -1,12 +1,29 @@
import { Command } from "commander";
import { resolveEffectiveHost } from "../host-selection.js";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
import { loadHostRegistry, resolveHost, hostDisplayTarget } from "../host-registry.js";
import { emitCrossHostError, emitRemoteHttpFailure } from "../cross-host-cli-helpers.js";
import { runRemoteHttpOp } from "../remote-host-ops.js";
export function broadcastCommand(depsOverride?: StatusDeps): Command {
/**
* OPR.0.4.6.MH4 C3 — the cross-host broadcast deadline, named at the call
* site (arch delivery-lane note (a)): the remote route responds only after
* the ORIGIN daemon's per-target fan-out loop completes, so a many-seat rig
* needs more than the 5s read-class default.
*/
const BROADCAST_REMOTE_TIMEOUT_MS = 30_000;
export interface BroadcastDeps extends StatusDeps {
/** Test seam: inject a registry loader so no real ~/.openrig is touched. */
hostRegistryLoader?: () => ReturnType<typeof loadHostRegistry>;
}
export function broadcastCommand(depsOverride?: BroadcastDeps): Command {
const cmd = new Command("broadcast").description("Send a message to multiple agent sessions");
const getDeps = (): StatusDeps => depsOverride ?? {
const getDeps = (): BroadcastDeps => depsOverride ?? {
lifecycleDeps: realDeps(),
clientFactory: (url: string) => new DaemonClient(url),
};
@@ -16,6 +33,7 @@ export function broadcastCommand(depsOverride?: StatusDeps): Command {
.option("--rig <name>", "Broadcast to all sessions in a rig")
.option("--pod <name>", "Broadcast to all sessions in a pod")
.option("--force", "Send even if targets appear mid-task")
.option("--host <id>", "Broadcast on a remote host declared in ~/.openrig/hosts.yaml (http hosts only — CLI-direct to the remote daemon's fan-out)")
.option("--json", "JSON output for agents")
.addHelpText("after", `
Examples:
@@ -23,11 +41,34 @@ Examples:
rig broadcast --pod dev "New task spec at docs/planning/next-task.md"
rig broadcast "System maintenance in 5 minutes."
rig broadcast --rig my-rig "message" --json
rig broadcast --host vps-b --rig remote-rig "coordinate the remote factory"
Without --rig or --pod, broadcasts to ALL running tmux sessions plus any attached external_cli nodes across all rigs.
Unsupported external_cli targets are returned as explicit per-target failures in the result.`)
.action(async (text: string, opts: { rig?: string; pod?: string; force?: boolean; json?: boolean }) => {
Unsupported external_cli targets are returned as explicit per-target failures in the result.
--host broadcasts on a remote host declared in ~/.openrig/hosts.yaml,
CLI-direct against that daemon's shipped broadcast fan-out (http-registered
hosts, e.g. pair-registered; an ssh-registered host fails with the structured
transport-requirement error). The REMOTE daemon resolves --rig/--pod on ITS
topology and its per-target results print verbatim — a partial fan-out exits
non-zero exactly as a local one does. The positional is message text (never
parsed as a target), so broadcast takes --host or the persisted host
selection, not the agent@rig@host sugar.`)
.action(async (text: string, opts: { rig?: string; pod?: string; force?: boolean; host?: string; json?: boolean }) => {
// OPR.0.4.6.MH4 C3 — explicit --host > persisted selection. Broadcast
// has NO session-target operand (the positional is message text, which
// must never be sugar-parsed), so the §4 sugar does not apply here.
opts.host = resolveEffectiveHost(opts.host);
const deps = getDeps();
// --- Cross-host path (CLI-direct POST to the remote daemon's shipped
// /api/transport/broadcast; its own fan-out engine resolves the
// TargetSpec on ITS topology; daemon untouched). ---
if (opts.host) {
await runCrossHostBroadcast(opts.host, text, opts, deps);
return;
}
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state !== "running" || status.healthy === false) {
@@ -57,21 +98,83 @@ Unsupported external_cli targets are returned as explicit per-target failures in
return;
}
const data = res.data as Record<string, unknown>;
const results = (data["results"] as Array<{ sessionName: string; ok: boolean; error?: string }>) ?? [];
for (const r of results) {
if (r.ok) {
console.log(`${r.sessionName}: sent`);
} else {
console.log(`${r.sessionName}: FAILED — ${r.error ?? "unknown error"}`);
}
}
console.log(`${data["sent"]}/${data["total"]} delivered`);
if ((data["failed"] as number) > 0 || results.some((r) => !r.ok)) {
process.exitCode = 1;
}
renderBroadcastResults(res.data as Record<string, unknown>);
});
return cmd;
}
/**
* OPR.0.4.6.MH4 C3 — cross-host broadcast over http, CLI-DIRECT to the
* remote daemon's shipped POST /api/transport/broadcast (zero daemon-side
* changes). The body is the LOCAL shape verbatim ({text, force, rig?,
* pod?}); the ORIGIN daemon's own fan-out engine resolves the TargetSpec on
* ITS topology and returns per-target results, printed VERBATIM — per-target
* honesty is passthrough, never summarized; a partial fan-out exits non-zero
* exactly as the local path does. Deadline: BROADCAST_REMOTE_TIMEOUT_MS
* (named — a full fan-out outlives the read-class default).
*/
async function runCrossHostBroadcast(
hostId: string,
text: string,
opts: { rig?: string; pod?: string; force?: boolean; json?: boolean },
deps: BroadcastDeps,
): Promise<void> {
const loader = deps.hostRegistryLoader ?? loadHostRegistry;
const registry = loader();
if (!registry.ok) {
emitCrossHostError(hostId, "registry-load-failed", registry.error, opts.json);
return;
}
const resolved = resolveHost(registry.registry, hostId);
if (!resolved.ok) {
emitCrossHostError(hostId, "unknown-host", resolved.error, opts.json);
return;
}
const host = resolved.host;
const body: Record<string, unknown> = { text, force: opts.force };
if (opts.rig) body.rig = opts.rig;
if (opts.pod) body.pod = opts.pod;
const result = await runRemoteHttpOp(hostId, "POST", "/api/transport/broadcast", body, deps, {
timeoutMs: BROADCAST_REMOTE_TIMEOUT_MS,
});
if (opts.json) {
console.log(JSON.stringify({
cross_host: { host: host.id, target: hostDisplayTarget(host), transport: "http" },
result,
}));
const results = ((result.data as Record<string, unknown> | undefined)?.["results"] as Array<{ ok: boolean }> | undefined) ?? [];
if (!result.ok || results.some((r) => !r.ok)) process.exitCode = 1;
return;
}
if (!result.ok) {
emitRemoteHttpFailure(host.id, hostDisplayTarget(host), result, false);
return;
}
console.log(`[via host=${host.id} (${hostDisplayTarget(host)})]`);
renderBroadcastResults((result.data ?? {}) as Record<string, unknown>);
}
/** One renderer, two callers — the remote route returns the same shape the
* local route does, so per-target honesty prints identically. A partial
* fan-out sets a non-zero exit either way. */
function renderBroadcastResults(data: Record<string, unknown>): void {
const results = (data["results"] as Array<{ sessionName: string; ok: boolean; error?: string }>) ?? [];
for (const r of results) {
if (r.ok) {
console.log(`${r.sessionName}: sent`);
} else {
console.log(`${r.sessionName}: FAILED — ${r.error ?? "unknown error"}`);
}
}
console.log(`${data["sent"]}/${data["total"]} delivered`);
if ((data["failed"] as number) > 0 || results.some((r) => !r.ok)) {
process.exitCode = 1;
}
}
+109 -8
View File
@@ -1,11 +1,14 @@
import { Command } from "commander";
import { resolveEffectiveHost } from "../host-selection.js";
import { DaemonClient, terminalAuthHeaders } from "../client.js";
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
import { loadHostRegistry, resolveHost, hostDisplayTarget } from "../host-registry.js";
import { loadHostRegistry, resolveHost, hostDisplayTarget, type HttpHostEntry } from "../host-registry.js";
import { runCrossHostCommand, type RunCrossHostCommandOpts } from "../cross-host-executor.js";
import { emitCrossHostError, emitCrossHostFailure } from "../cross-host-cli-helpers.js";
import { emitCrossHostError, emitCrossHostFailure, emitRemoteHttpFailure } from "../cross-host-cli-helpers.js";
import { resolveCrossHostTarget } from "../cross-host-target.js";
import { runRemoteHttpOp } from "../remote-host-ops.js";
export interface CaptureDeps extends StatusDeps {
hostRegistryLoader?: () => ReturnType<typeof loadHostRegistry>;
@@ -28,7 +31,7 @@ export function captureCommand(depsOverride?: CaptureDeps): Command {
.option("--rig <name>", "Capture all sessions in a rig")
.option("--pod <name>", "Capture all sessions in a pod")
.option("--lines <n>", "Number of lines to capture (default: 20)", "20")
.option("--host <id>", "Run on a remote host declared in ~/.openrig/hosts.yaml (CLI-side ssh shell-out)")
.option("--host <id>", "Capture on a remote host declared in ~/.openrig/hosts.yaml (ssh hosts shell out; http hosts go CLI-direct to the remote daemon)")
.option("--json", "JSON output for agents")
.addHelpText("after", `
Examples:
@@ -42,14 +45,40 @@ Examples:
Supported notes:
- Multi-target capture reports unsupported external_cli nodes as explicit per-target failures.
- For outbound-only external_cli nodes, use rig whoami/rig ps instead of rig capture.
- --host runs the same command on a remote host declared in ~/.openrig/hosts.yaml
via single-hop ssh. The remote rig is authoritative on what it can capture.`)
- --host captures on a remote host declared in ~/.openrig/hosts.yaml. The host
entry's transport decides the path: ssh hosts via single-hop ssh; http hosts
(e.g. pair-registered) CLI-direct to the remote daemon's capture route. The
remote is authoritative on what it can capture either way. A session of the
form agent@rig@host is sugar for --host when the suffix is a REGISTERED
host id (explicit --host > sugar > persisted selection).`)
.action(async (session: string | undefined, opts: { rig?: string; pod?: string; lines?: string; host?: string; json?: boolean }) => {
// OPR.0.4.6.MH1 FR-2: selected-host routing — explicit --host wins;
// else the persisted selection feeds the SHIPPED --host path; no
// selection = today exactly. OPR.0.4.6.MH4 §4: the raw flag is kept
// so the target sugar slots BETWEEN explicit and selection.
const explicitHost = opts.host;
opts.host = resolveEffectiveHost(opts.host);
const deps = getDeps();
// --- Cross-host short-circuit (CLI-side ssh shell-out; daemon untouched) ---
// OPR.0.4.6.MH4 §4 — `agent@rig@host` target sugar (session operand
// only; --rig/--pod values are names, never sugar-parsed). Suffix must
// match a REGISTERED host id, else passthrough + loud-failure hint.
let crossHostHint: string | undefined;
if (session !== undefined) {
const targetResolution = resolveCrossHostTarget(session, explicitHost, deps.hostRegistryLoader);
if (!targetResolution.ok) {
console.error(targetResolution.error);
process.exitCode = 1;
return;
}
session = targetResolution.target;
crossHostHint = targetResolution.hint;
opts.host = explicitHost ?? targetResolution.sugarHost ?? opts.host;
}
// --- Cross-host short-circuit (CLI-side; ssh shell-out or the MH-4 http branch; daemon untouched) ---
if (opts.host) {
await runCrossHostCapture(opts.host, session, opts, deps);
await runCrossHostCapture(opts.host, session, opts, deps, crossHostHint);
return;
}
@@ -80,6 +109,8 @@ Supported notes:
if (res.status >= 400) {
const error = (res.data as Record<string, unknown>)["error"] as string | undefined;
console.error(error ?? `Capture failed (HTTP ${res.status})`);
// MH-4 §4 loud-failure hint: 3-part-shaped target, unregistered suffix.
if (crossHostHint) console.error(`hint: ${crossHostHint}`);
process.exitCode = 1;
return;
}
@@ -113,6 +144,7 @@ async function runCrossHostCapture(
session: string | undefined,
opts: { rig?: string; pod?: string; lines?: string; json?: boolean },
deps: CaptureDeps,
hint?: string,
): Promise<void> {
const loader = deps.hostRegistryLoader ?? loadHostRegistry;
const runner = deps.crossHostRun ?? runCrossHostCommand;
@@ -124,11 +156,19 @@ async function runCrossHostCapture(
}
const resolved = resolveHost(registry.registry, hostId);
if (!resolved.ok) {
emitCrossHostError(hostId, "unknown-host", resolved.error, opts.json);
emitCrossHostError(hostId, "unknown-host", hint ? `${resolved.error} (${hint})` : resolved.error, opts.json);
return;
}
const host = resolved.host;
// OPR.0.4.6.MH4 — the http transport branch: CLI-direct POST to the
// remote daemon's shipped /api/transport/capture with the SAME body the
// local path posts. ssh hosts fall through to the shell-out verbatim.
if (host.transport === "http") {
await runHttpHostCapture(host, session, opts, deps, hint);
return;
}
// Reconstruct argv. Order: positional first, then flags.
const argv: string[] = ["rig", "capture"];
if (session) argv.push(session);
@@ -157,3 +197,64 @@ async function runCrossHostCapture(
emitCrossHostFailure(host.id, hostDisplayTarget(host), result, opts.json);
}
/**
* OPR.0.4.6.MH4 C1 — cross-host capture over http, CLI-DIRECT to the remote
* daemon's shipped POST /api/transport/capture (zero daemon-side changes).
* Body parity with the local path (lines/rig/pod/session); the remote's
* single/multi result renders exactly as a local capture does, under the
* `[via host=…]` banner. Read-class deadline (the client default).
*/
async function runHttpHostCapture(
host: HttpHostEntry,
session: string | undefined,
opts: { rig?: string; pod?: string; lines?: string; json?: boolean },
deps: CaptureDeps,
hint?: string,
): Promise<void> {
const lines = parseInt(opts.lines ?? "20", 10);
const body: Record<string, unknown> = { lines: isNaN(lines) ? 20 : lines };
if (opts.rig) body.rig = opts.rig;
if (opts.pod) body.pod = opts.pod;
if (session) body.session = session;
const result = await runRemoteHttpOp(host.id, "POST", "/api/transport/capture", body, deps, {});
if (opts.json) {
console.log(JSON.stringify({
cross_host: { host: host.id, target: hostDisplayTarget(host), transport: "http" },
result,
...(!result.ok && hint ? { hint } : {}),
}));
if (!result.ok) process.exitCode = 1;
return;
}
if (!result.ok) {
emitRemoteHttpFailure(host.id, hostDisplayTarget(host), result, false, hint);
return;
}
console.log(`[via host=${host.id} (${hostDisplayTarget(host)})]`);
const data = (result.data ?? {}) as Record<string, unknown>;
// Multi-target result — rendered exactly as the local path renders it.
const results = data["results"] as Array<{ sessionName: string; content?: string; ok: boolean; error?: string }> | undefined;
if (results) {
for (const r of results) {
console.log(`--- ${r.sessionName} ---`);
if (r.ok && r.content) {
console.log(r.content);
} else {
console.log(` (error: ${r.error ?? "no content"})`);
}
}
return;
}
// Single target result
const content = data["content"] as string | undefined;
if (content) {
console.log(content);
}
}
+4
View File
@@ -43,10 +43,14 @@ Keys:
agents.* advisor_session, operator_session
feed.subscriptions.* action_required, approvals, shipped, progress, audit_log
runtime.codex.* hooks_enabled
workflow.* exception_routing (orchestrator | human_only — the maturity-dial host default)
policies.claude_compaction.*
enabled, threshold_percent, compact_instruction,
message_inline, message_file_path
snapshots.periodic.* enabled, interval_seconds, retention_keep
retention.* enabled, transitions_days, watchdog_days,
watchdog_keep_per_job, batch_size
terminal.status_bar show the inner tmux status bar on launch (default off)
Precedence: CLI flag > environment variable > config file > default`)
.action((opts: { json?: boolean; withSource?: boolean }) => {
+5
View File
@@ -1,4 +1,5 @@
import { Command } from "commander";
import { resolveEffectiveHost } from "../host-selection.js";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl, type LifecycleDeps } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
@@ -107,6 +108,10 @@ export function downCommand(depsOverride?: StatusDeps): Command {
.option("--json", "JSON output for agents")
.option("--host <id>", "Run on a remote host declared in ~/.openrig/hosts.yaml")
.action(async (rigHandle: string, opts: { delete?: boolean; force?: boolean; snapshot?: boolean; json?: boolean; host?: string }) => {
// OPR.0.4.6.MH1 FR-2: selected-host routing — explicit --host wins;
// else the persisted selection feeds the SHIPPED --host path; no
// selection = today exactly.
opts.host = resolveEffectiveHost(opts.host);
const deps = getDepsF();
if (opts.host) {
+358 -15
View File
@@ -1,17 +1,27 @@
// OPR.0.4.4.13 — the host-registry verbs. CAPPED at exactly three
// (add / list / doctor — arch R13-2a): no edit/remove/tunnel/bootstrap
// verbs; hand-editing hosts.yaml remains the path for everything else,
// and the bootstrap ships as script + runbook.
// OPR.0.4.4.13 — the host-registry verbs (add / list / doctor — arch
// R13-2a: no edit/remove/tunnel/bootstrap verbs; hand-editing hosts.yaml
// remains the path for exotica; the bootstrap ships as script + runbook).
// OPR.0.4.6.MH1 FR-1 extends the family with `select` — the persisted
// host-selection pointer (kubectl current-context shape). The pointer
// lives in the config twins as `host.selected`; this verb is a THIN
// CLIENT of the daemon config write (one write path for CLI+UI
// convergence, arch ruling); reads resolve from the local ConfigStore
// (env > file > default — zero new daemon lookups on read paths).
//
// Secret hygiene (FR-1): every render carries bearer POINTERS (env var /
// file NAMES) only — no code path in this file resolves a bearer value
// for display.
import { Command } from "commander";
import { existsSync } from "node:fs";
import { existsSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { hostname as osHostname, userInfo } from "node:os";
import { connect } from "node:net";
import { addHostEntry, defaultHostRegistryPath, loadHostRegistry, hostDisplayTarget, resolveHost, resolveRemoteBearer, type HostEntry, type HttpHostEntry } from "../host-registry.js";
import { getOpenRigHome } from "../openrig-compat.js";
import { addHostEntry, defaultHostRegistryPath, loadHostRegistry, validateHostRegistry, hostDisplayTarget, resolveHost, resolveRemoteBearer, type HostEntry, type HttpHostEntry } from "../host-registry.js";
import { runCrossHostCommand, type CrossHostResult } from "../cross-host-executor.js";
import { DaemonClient } from "../client.js";
import { readOwnHostName, readSelectedHost } from "../host-selection.js";
// ---------------------------------------------------------------------------
// rig host doctor — stepwise, honest, three-valued (FR-1 + FR-2).
@@ -30,6 +40,10 @@ export interface CheckRow {
export interface DoctorDeps {
run: (host: HostEntry, argv: readonly string[]) => Promise<CrossHostResult>;
httpGet: (url: string, headers?: Record<string, string>) => Promise<{ status: number; body: string }>;
/** OPR.0.4.6.MH1 FR-6: bounded JSON POST for the pair handshake.
* Optional so existing DoctorDeps fixtures keep compiling; the pair
* verb falls back to plain fetch when absent. */
httpPost?: (url: string, body: unknown) => Promise<{ status: number; body: string }>;
/** TCP connect probe: "open" (connected) | "closed" (refused/filtered/timeout) | "unknown" (probe itself failed). */
tcpProbe: (target: string, port: number, timeoutMs: number) => Promise<"open" | "closed" | "unknown">;
}
@@ -58,6 +72,15 @@ function defaultDoctorDeps(): DoctorDeps {
const res = await fetch(url, { headers, signal: AbortSignal.timeout(10_000) });
return { status: res.status, body: await res.text() };
},
httpPost: async (url, body) => {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: AbortSignal.timeout(10_000),
});
return { status: res.status, body: await res.text() };
},
tcpProbe: (target, port, timeoutMs) =>
new Promise((resolve) => {
const sock = connect({ host: target, port, timeout: timeoutMs });
@@ -71,6 +94,35 @@ function defaultDoctorDeps(): DoctorDeps {
/** FR-1 doctor legs — each failing step maps to a DISTINCT actionable error:
* "SSH works but daemon is down" ≠ "daemon works but registry is wrong" ≠
* "remote rig binary missing/old". */
/** OPR.0.4.6.MH1 FR-3 — the coarse ls status word: a BOUNDED tcp dial
* against the host's transport endpoint (ssh: target:22; http: the
* URL's host:port). Coarse by design (the kubectl STATUS / docker
* context ls precedent): reachable | unreachable | unknown — never a
* hang (hard timeout), never a guess (dial failure = unreachable,
* probe error/unparseable endpoint = unknown). */
export async function probeHostStatus(
host: HostEntry,
tcpProbe: DoctorDeps["tcpProbe"],
timeoutMs = 1500,
): Promise<"reachable" | "unreachable" | "unknown"> {
try {
let target: string;
let port: number;
if (host.transport === "ssh") {
target = host.target;
port = 22;
} else {
const u = new URL(host.url);
target = u.hostname;
port = u.port ? Number(u.port) : u.protocol === "https:" ? 443 : 80;
}
const r = await tcpProbe(target, port, timeoutMs);
return r === "open" ? "reachable" : r === "closed" ? "unreachable" : "unknown";
} catch {
return "unknown";
}
}
export async function doctorLegs(host: HostEntry, deps: DoctorDeps): Promise<CheckRow[]> {
const rows: CheckRow[] = [];
@@ -393,17 +445,282 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
});
cmd
.command("list")
.description("List registered hosts (config pointers only — never secret values)")
.command("select")
.description("Select which host you are viewing/acting on (persisted; 'local' returns to this host)")
.argument("<id>", "A registered host id, or 'local'")
.option("--json", "JSON output")
.action((opts: { json?: boolean }) => {
.action(async (id: string, opts: { json?: boolean }) => {
// Validate BEFORE persisting: 'local' is always legal; anything
// else must be a registered host id.
if (id !== "local") {
const registryPath = defaultHostRegistryPath();
const loaded = existsSync(registryPath) ? loadHostRegistry(registryPath) : null;
if (!loaded || !loaded.ok) {
console.error(
loaded && !loaded.ok
? loaded.error
: `cannot select '${id}': no hosts registered (no registry at ${registryPath}). Add one first: rig host add --id <id> --transport <ssh|http> ...`,
);
process.exitCode = 1;
return;
}
const ids = loaded.registry.hosts.map((h) => h.id);
if (!ids.includes(id)) {
console.error(
`cannot select '${id}': not a registered host id. Registered: ${ids.length > 0 ? ids.join(", ") : "(none)"}. Add it first: rig host add --id ${id} --transport <ssh|http> ... (or select 'local').`,
);
process.exitCode = 1;
return;
}
}
// ONE write path: the daemon config write (the CLI is a thin
// client — arch FR-1 ruling). Daemon-down surfaces the existing
// structured connection error (trade named in the PRD).
const client = new DaemonClient();
let res: { status: number; data: { ok?: boolean; error?: string } };
try {
res = await client.post<{ ok?: boolean; error?: string }>(
`/api/config/${encodeURIComponent("host.selected")}`,
{ value: id },
);
} catch (err) {
// Daemon down: the existing structured connection error, named
// trade in the PRD (selection writes need the local daemon up).
console.error((err as Error).message);
process.exitCode = 1;
return;
}
if (res.status !== 200 || res.data?.error) {
console.error(res.data?.error ?? `daemon config write failed (HTTP ${res.status})`);
process.exitCode = 1;
return;
}
if (opts.json) {
console.log(JSON.stringify({ ok: true, selected: id }));
return;
}
console.log(id === "local" ? "Selected host: local (this host)" : `Selected host: ${id}`);
console.log(id === "local" ? "Commands run against this host." : `Flagless commands that support --host now run against '${id}'. Return with: rig host select local`);
});
cmd
.command("rename")
.description("Rename THIS host's display name (renders in dashboard, explorer, ls, whoami)")
.argument("<name>", 'The new display name, e.g. "Mac mini 2"')
.option("--json", "JSON output")
.action(async (name: string, opts: { json?: boolean }) => {
// OPR.0.4.6.MH1 FR-4 — one stored name (the settings twins, arch
// Ruling 1), one write path (the daemon config write, same as
// select). The name is free text; only emptiness is rejected.
const trimmed = name.trim();
if (!trimmed) {
console.error("host name must not be empty");
process.exitCode = 1;
return;
}
const client = new DaemonClient();
let res: { status: number; data: { ok?: boolean; error?: string } };
try {
res = await client.post<{ ok?: boolean; error?: string }>(
`/api/config/${encodeURIComponent("host.name")}`,
{ value: trimmed },
);
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
return;
}
if (res.status !== 200 || res.data?.error) {
console.error(res.data?.error ?? `daemon config write failed (HTTP ${res.status})`);
process.exitCode = 1;
return;
}
if (opts.json) {
console.log(JSON.stringify({ ok: true, name: trimmed }));
return;
}
console.log(`This host is now named: ${trimmed}`);
});
cmd
.command("pair")
.description("Pair with a remote host from one pasted address — one approval on the target, done (FR-6)")
.argument("<url>", "The target daemon's address (http[s]://host:port, or host:port)")
.option("--id <id>", "Registry id for the new host (default: derived from the hostname)")
.option("--timeout <seconds>", "How long to wait for the target-side approval", "600")
.option("--json", "JSON output")
.action(async (rawUrl: string, opts: { id?: string; timeout?: string; json?: boolean }) => {
// OPR.0.4.6.MH1 FR-6 — THE founder-simple add path: one pasted
// address, one approval ON the target, done. The flag-heavy
// `rig host add` stays as the unchanged advanced path. B1: the CLI
// keeps its direct-fs write (addHostEntry); the browser surface
// pairs through its local daemon — both converge on the ONE write
// contract.
let target: URL;
try {
target = new URL(/^https?:\/\//.test(rawUrl.trim()) ? rawUrl.trim() : `http://${rawUrl.trim()}`);
} catch {
console.error(`'${rawUrl}' is not a usable address. Paste the target daemon's URL, e.g. http://vps-a:7433`);
process.exitCode = 1;
return;
}
const targetBase = target.origin;
const deps = doctorDepsOverride ?? defaultDoctorDeps();
// B1 fixback (guard code-review 2026-07-07): PREFLIGHT before any
// network call or credential mutation. The candidate entry runs the
// SAME validation contract the add will use (duplicate ids, reserved
// ids, invalid existing registry all fail HERE — before the target
// is asked for approval), and a pre-existing token file is
// pre-existing CREDENTIAL STATE: rejected, never overwritten, and
// never deleted by this request's cleanup.
const id = (opts.id ?? "").trim() || target.hostname.toLowerCase().replace(/[^a-z0-9.-]/g, "-").replace(/\./g, "-").replace(/^-+|-+$/g, "") || "paired-host";
const secretsDir = join(getOpenRigHome(), "secrets");
const tokenPath = join(secretsDir, `host-${id}.token`);
{
const registryPath = defaultHostRegistryPath();
let existing: HostEntry[] = [];
if (existsSync(registryPath)) {
const loaded = loadHostRegistry(registryPath);
if (!loaded.ok) {
console.error(`cannot pair: ${loaded.error}`);
process.exitCode = 1;
return;
}
existing = loaded.registry.hosts;
}
const preflight = validateHostRegistry(
{ hosts: [...existing, { id, transport: "http", url: targetBase, bearer_file: tokenPath }] },
registryPath,
);
if (!preflight.ok) {
console.error(`cannot pair as '${id}': ${preflight.error}`);
process.exitCode = 1;
return;
}
if (existsSync(tokenPath)) {
console.error(`a credential file already exists at ${tokenPath} — pre-existing credential state is never overwritten. Pass --id <different-id>, or remove the file yourself if you know it is stale.`);
process.exitCode = 1;
return;
}
}
const httpPost = deps.httpPost ?? (async (url: string, payload: unknown) => {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(10_000),
});
return { status: res.status, body: await res.text() };
});
const parseJson = (s: string): Record<string, unknown> => {
try { return JSON.parse(s) as Record<string, unknown>; } catch { return {}; }
};
let issued: { pairId?: string; code?: string; approvalQitemId?: string; error?: string; message?: string };
try {
const res = await httpPost(`${targetBase}/api/hosts/pair-request`, {
requester: `${userInfo().username}@${osHostname()}`,
});
issued = parseJson(res.body) as typeof issued;
if (res.status !== 200 || !issued.pairId || !issued.code) {
console.error(issued.message ?? `pairing refused: target responded HTTP ${res.status}${issued.error ? ` (${issued.error})` : ""}`);
process.exitCode = 1;
return;
}
} catch (err) {
console.error(`could not reach ${targetBase}: ${(err as Error).message}`);
process.exitCode = 1;
return;
}
if (!opts.json) {
console.log(`Pairing code: ${issued.code}`);
console.log(`Waiting for approval on the target (its attention queue carries item ${issued.approvalQitemId ?? "?"}).`);
console.log(`On the target: rig queue update ${issued.approvalQitemId ?? "<qitem-id>"} --state done --closure-reason no-follow-on`);
}
const timeoutMs = Math.max(1, Number(opts.timeout ?? "600") || 600) * 1000;
const deadline = Date.now() + timeoutMs;
let outcome: { status?: string; token?: string } = {};
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 2000));
try {
const res = await deps.httpGet(`${targetBase}/api/hosts/pair-request/${issued.pairId}`);
outcome = parseJson(res.body) as typeof outcome;
} catch {
continue; // transient poll failure: keep waiting until the deadline
}
if (outcome.status && outcome.status !== "pending") break;
}
if (outcome.status !== "approved" || !outcome.token) {
const why = outcome.status === "denied" ? "the target DENIED the pairing"
: outcome.status === "expired" ? "the pairing EXPIRED on the target"
: "no approval arrived before the timeout";
console.error(`pairing failed: ${why}. Nothing was persisted (no registry entry, no token file).`);
process.exitCode = 1;
return;
}
// Approved: token → EXCLUSIVE-CREATE 0600 file (open flag "wx" —
// rev1-r2 B3: check-then-rename had a window where a concurrent
// same-id pair could clobber the winner's file and then delete it
// on its own add failure; "wx" is atomic at the filesystem, so
// creation SUCCESS is the proof of ownership the cleanup relies
// on), then the registry entry via the ONE shipped write path.
// addHostEntry re-validates authoritatively (the registry may have
// changed since preflight).
try {
mkdirSync(secretsDir, { recursive: true, mode: 0o700 });
writeFileSync(tokenPath, `${outcome.token}\n`, { mode: 0o600, flag: "wx" });
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "EEXIST") {
console.error(`a credential file appeared at ${tokenPath} during pairing — refusing to overwrite it. Nothing was persisted by this request.`);
} else {
console.error(`failed to store the pairing token: ${(err as Error).message}`);
}
process.exitCode = 1;
return;
}
const added = addHostEntry({
id,
transport: "http",
url: targetBase,
bearer_file: tokenPath,
notes: `paired ${new Date().toISOString().slice(0, 10)}`,
});
if (!added.ok) {
rmSync(tokenPath, { force: true });
console.error(`pairing approved but the registry write failed: ${added.error}. The token file (created by this request) was removed — nothing persisted.`);
process.exitCode = 1;
return;
}
if (opts.json) {
console.log(JSON.stringify({ ok: true, entry: added.entry }));
return;
}
console.log(`Paired. Host '${id}' registered (${targetBase}, bearer_file ${tokenPath}).`);
console.log(`Verify it: rig host doctor ${id}`);
});
cmd
.command("list")
// OPR.0.4.6.MH1 QA fixback: the PRD/proof contract spells the verb
// `rig host ls` (FR-3); `list` stays the canonical name, `ls` is the
// contract-required alias (same action, same --json).
.alias("ls")
.description("List registered hosts w/ status + selected marker (config pointers only — never secret values)")
.option("--json", "JSON output")
.action(async (opts: { json?: boolean }) => {
const registryPath = defaultHostRegistryPath();
if (!existsSync(registryPath)) {
if (opts.json) {
console.log(JSON.stringify([]));
return;
}
console.log(`No hosts registered in ${registryPath}. Add one: rig host add --id <id> --transport <ssh|http> ...`);
console.log(`No hosts registered in ${registryPath}. Pair one from a single address: rig host pair <url> (advanced/manual path: rig host add --id <id> --transport <ssh|http> ...)`);
return;
}
const loaded = loadHostRegistry(registryPath);
@@ -412,19 +729,45 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
process.exitCode = 1;
return;
}
// OPR.0.4.6.MH1 FR-1/FR-3: selected marker (local ConfigStore read —
// env > file > default; zero daemon dependency) + a bounded status
// probe per host (parallel; hard per-host timeout — never a hang).
const selected = readSelectedHost();
const probeDeps: DoctorDeps = doctorDepsOverride ?? defaultDoctorDeps();
const statuses = await Promise.all(
loaded.registry.hosts.map((h) => probeHostStatus(h, probeDeps.tcpProbe)),
);
if (opts.json) {
// Entries as validated: bearer fields are NAMES by construction.
console.log(JSON.stringify(loaded.registry.hosts));
// ADDITIVE ONLY (the shipped bare-array shape is a contract —
// pre-MH1 consumers keep working): each row gains `selected` +
// `status`; the scriptable selection pointer is the true row (or
// `rig config get host.selected`).
console.log(JSON.stringify(loaded.registry.hosts.map((h, i) => ({ ...h, selected: h.id === selected, status: statuses[i] }))));
return;
}
if (loaded.registry.hosts.length === 0) {
console.log(`No hosts registered in ${registryPath}. Add one: rig host add --id <id> --transport <ssh|http> ...`);
console.log(`No hosts registered in ${registryPath}. Pair one from a single address: rig host pair <url> (advanced/manual path: rig host add --id <id> --transport <ssh|http> ...)`);
return;
}
// OPR.0.4.6.MH1 FR-4: the own-host name renders here when RENAMED;
// unnamed (default "localhost") keeps today's output byte-identical
// (the FR-4 zero-regression AC). Scriptable read: rig config get
// host.name — the --json array stays registry rows only (shipped
// bare-array contract).
const ownName = readOwnHostName();
if (ownName !== "localhost") {
console.log(`This host: ${ownName}\n`);
}
const pad = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + "…" : s.padEnd(n));
console.log(`${pad("ID", 20)} ${pad("TRANSPORT", 10)} ${pad("TARGET", 36)} ${pad("AUTH", 24)} NOTES`);
for (const h of loaded.registry.hosts) {
console.log(`${pad(h.id, 20)} ${pad(h.transport, 10)} ${pad(hostDisplayTarget(h), 36)} ${pad(authPointer(h), 24)} ${h.notes ?? "—"}`);
console.log(`${pad("", 2)}${pad("ID", 20)} ${pad("TRANSPORT", 10)} ${pad("TARGET", 30)} ${pad("STATUS", 12)} ${pad("AUTH", 24)} NOTES`);
for (let i = 0; i < loaded.registry.hosts.length; i++) {
const h = loaded.registry.hosts[i]!;
const marker = h.id === selected ? "* " : " ";
console.log(`${marker}${pad(h.id, 20)} ${pad(h.transport, 10)} ${pad(hostDisplayTarget(h), 30)} ${pad(statuses[i]!, 12)} ${pad(authPointer(h), 24)} ${h.notes ?? "—"}`);
}
if (selected !== "local") {
console.log(`\nSelected host: ${selected} (return with: rig host select local)`);
}
});
+5
View File
@@ -1,4 +1,5 @@
import { Command } from "commander";
import { resolveEffectiveHost } from "../host-selection.js";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
@@ -50,6 +51,10 @@ export function launchCommand(depsOverride?: StatusDeps): Command {
.option("--json", "JSON output")
.option("--host <id>", "Run on a remote host declared in ~/.openrig/hosts.yaml")
.action(async (rigId: string, nodeRef: string | undefined, opts: { json?: boolean; holdReason?: string; seats?: string; host?: string }) => {
// OPR.0.4.6.MH1 FR-2: selected-host routing — explicit --host wins;
// else the persisted selection feeds the SHIPPED --host path; no
// selection = today exactly.
opts.host = resolveEffectiveHost(opts.host);
const deps = getDeps();
if (opts.host) {
+9 -3
View File
@@ -1,4 +1,6 @@
import { Command } from "commander";
import { resolveEffectiveHost } from "../host-selection.js";
import { sessionRigOf } from "../session-name.js";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl, type LifecycleDeps } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
@@ -98,9 +100,8 @@ interface NodeEntry {
// with realistic agent counts. `--full` opts out. JSON output remains
// unbounded by default for back-compat (Decision C hybrid).
function extractRigName(sessionName: string): string | undefined {
const atIdx = sessionName.lastIndexOf("@");
if (atIdx < 0 || atIdx === sessionName.length - 1) return undefined;
return sessionName.slice(atIdx + 1);
// OPR.0.4.6.MH1 FR-8: the shared parse contract (greedy first-@ rig).
return sessionRigOf(sessionName);
}
const HUMAN_RIG_BUDGET = 50;
@@ -757,6 +758,11 @@ Exit codes:
.option("--all-hosts", "Fan out to all registered HTTP hosts (observation-only)")
.option("--hosts <ids>", "Fan out to specific hosts (comma-separated)")
.action(async (opts: PsCliOptions) => {
// OPR.0.4.6.MH1 FR-2: selected-host routing — explicit --host wins;
// else the persisted selection feeds the SHIPPED --host path; no
// selection = today exactly. Fan-out
// flags are their OWN explicit scope — never mixed with selection.
if (!opts.allHosts && !opts.hosts) opts.host = resolveEffectiveHost(opts.host);
if (opts.verbose) opts.full = true;
if (opts.running) opts.active = true;
const isRemote = !!(opts.host || opts.allHosts || opts.hosts);
+112 -6
View File
@@ -3,6 +3,7 @@ import { Command } from "commander";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { readOpenRigEnv } from "../openrig-compat.js";
import { sessionRigOf, isHumanSeatSessionRef } from "../session-name.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
@@ -185,11 +186,84 @@ function resolveCurrentSession(explicit: string | undefined, optionName: string)
}
function extractRigName(sessionName: string): string | undefined {
const atIdx = sessionName.lastIndexOf("@");
if (atIdx < 0 || atIdx === sessionName.length - 1) return undefined;
return sessionName.slice(atIdx + 1);
// OPR.0.4.6.MH1 FR-8: the shared parse contract (greedy first-@ rig).
return sessionRigOf(sessionName);
}
/**
* OPR.0.4.6.MH3 D-3 (C3): resolve a queue DESTINATION operand + optional
* explicit `--host` into the out-of-band request envelope (BR-1 — the session
* string that leaves the CLI stays 2-part `member@rig`; the host rides
* `hostId`; the 3-part string NEVER leaves the CLI edge).
*
* The queue parse rule (arch-ruled — deliberately DIFFERENT from the
* interactive verbs' strip-iff-registered rule): queue destinations are
* CANONICAL-ONLY by construction (the daemon's validateRig rejects any
* non-canonical parse), so the strip is UNCONDITIONAL after the human-seat
* classifier — a mistyped host dies loud with the HOST named (unknown-host)
* instead of a misleading rig-shaped `unknown_destination_rig`:
*
* 1. human-seat classifier FIRST (the shipped archetype): a human-seat ref
* is never captured. `RESERVED_HOST_IDS` (kernel/host/local) guarantees
* no REGISTERED host can shadow the human-seat `@kernel`/`@host` family.
* 2. fewer than two `@` → plain 2-part session, pass through untouched.
* 3. two or more `@` → split on the LAST `@`; the trailing segment is the
* host qualifier, stripped into `hostId`; the remainder is the
* destination session.
*
* D-2 (explicit-only): queue verbs NEVER consult the persisted host
* selection — cross-host routing happens only via `--host <id>` or the
* host-qualified destination form. Naming BOTH with different hosts is a
* structured ambiguity error, never a silent precedence pick.
*/
export type QueueHostResolution =
| { ok: true; destination: string; hostId?: string }
| { ok: false; error: string; message: string };
export function resolveQueueHostDestination(
destination: string,
explicitHost?: string,
): QueueHostResolution {
if (isHumanSeatSessionRef(destination)) {
return { ok: true, destination, hostId: explicitHost };
}
const atCount = destination.split("@").length - 1;
if (atCount < 2) {
return { ok: true, destination, hostId: explicitHost };
}
const lastAt = destination.lastIndexOf("@");
const head = destination.slice(0, lastAt);
const tail = destination.slice(lastAt + 1);
if (!tail) {
return {
ok: false,
error: "invalid_host_qualified_destination",
message: `destination '${destination}' ends with an empty host segment — use member@rig@<host> (or drop the trailing '@')`,
};
}
if (explicitHost !== undefined && explicitHost !== tail) {
return {
ok: false,
error: "host_qualifier_conflict",
message: `--host ${explicitHost} conflicts with the host-qualified destination '${destination}' (host '${tail}') — name ONE host (drop the flag or the qualifier)`,
};
}
return { ok: true, destination: head, hostId: tail };
}
/** Emit a D-3 resolution error (local, pre-daemon) in the house 3-part style. */
function emitHostResolutionError(res: { error: string; message: string }, json: boolean): void {
if (json) {
console.log(JSON.stringify({ error: res.error, message: res.message }));
} else {
console.error(res.message);
}
process.exitCode = 1;
}
const QUEUE_HOST_OPTION_HELP =
"OPR.0.4.6.MH3: route this queue write to a REGISTERED remote host (see rig host ls). EXPLICIT-ONLY — queue verbs never follow the persisted 'rig host select' selection. Equivalent to the host-qualified destination form member@rig@<host>.";
export function queueCommand(depsOverride?: QueueDeps): Command {
const cmd = new Command("queue").description("Coordination L3 — owned-work queue + inbox/outbox");
const getDeps = (): QueueDeps => depsOverride ?? {
@@ -215,6 +289,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
.option("--target-repo <name>", "PL-007: typed repo scope (must match a repo in the source rig's RigSpec.workspace.repos[])")
.option("--summary <text>", "OPR.0.4.1.18: short human-readable 1-2 sentence summary of the work (feeds the Story node label; the agent-speak --body stays the source of truth). Warned-if-missing; pre-18 qitems exempt.")
.option("--evidence-ref <path>", "OPR.0.4.4.19 FR-5: pointer to the durable artifact a human judges (e.g. a PROOF.md path). Required by the daemon when the item is human-routed; optional otherwise.")
.option("--host <id>", QUEUE_HOST_OPTION_HELP)
.option("--no-nudge", "Suppress the default destination nudge (cold-queue)")
.option("--json", "JSON output for agents")
.action(async (opts: {
@@ -233,9 +308,18 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
targetRepo?: string;
summary?: string;
evidenceRef?: string;
host?: string;
nudge?: boolean;
json?: boolean;
}) => {
// OPR.0.4.6.MH3 D-3 (C3): resolve the host qualifier at the CLI edge —
// the 3-part form never leaves the CLI; the request carries the 2-part
// destination + the out-of-band hostId envelope (BR-1).
const hostResolved = resolveQueueHostDestination(opts.destination, opts.host);
if (!hostResolved.ok) {
emitHostResolutionError(hostResolved, opts.json ?? false);
return;
}
// OPR.0.3.2.21.FR-4(a) — resolve body BEFORE contacting the daemon
// so a missing/ambiguous body fails fast and locally.
let resolvedBody: string;
@@ -276,7 +360,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
const res = await client.post<Record<string, unknown>>("/api/queue/create", {
qitemId: opts.id,
sourceSession: opts.source,
destinationSession: opts.destination,
destinationSession: hostResolved.destination,
body: resolvedBody,
summary: opts.summary,
evidenceRef: opts.evidenceRef,
@@ -286,6 +370,9 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
expiresAt: opts.expiresAt,
targetRepo: opts.targetRepo,
nudge: opts.nudge,
// OPR.0.4.6.MH3 FR-1: the out-of-band host envelope (omitted for
// plain local writes — the local path stays byte-identical).
...(hostResolved.hostId !== undefined ? { hostId: hostResolved.hostId } : {}),
});
printResult(opts.json ?? false, res.data, res.status);
});
@@ -463,6 +550,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
.option("--target-repo <name>", "PL-007: typed repo scope for the new qitem")
.option("--summary <text>", "OPR.0.4.1.18: short human-readable 1-2 sentence summary for the new qitem (feeds the Story node; --body stays source of truth). Warned-if-missing.")
.option("--evidence-ref <path>", "OPR.0.4.4.19 FR-5: durable-artifact pointer for the new qitem. Required by the daemon when the new qitem is human-routed; optional otherwise.")
.option("--host <id>", QUEUE_HOST_OPTION_HELP)
.option("--no-nudge", "Suppress the default nudge to the new destination")
.option("--json", "JSON output for agents")
.action(async (qitemId: string, opts: {
@@ -477,11 +565,19 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
targetRepo?: string;
summary?: string;
evidenceRef?: string;
host?: string;
nudge?: boolean;
json?: boolean;
}) => {
const from = resolveCurrentSession(opts.from, "from");
if (!from) return;
// OPR.0.4.6.MH3 D-3 (C3): the host qualifier resolves at the CLI edge
// (applies to the DESTINATION --to only; --from stays as given).
const hostResolved = resolveQueueHostDestination(opts.to, opts.host);
if (!hostResolved.ok) {
emitHostResolutionError(hostResolved, opts.json ?? false);
return;
}
// OPR.0.4.1.18 (FR-7): warn-on-author — a handoff authors a NEW qitem, so
// it should carry its own summary. Warn to stderr; do not hard-break.
if (!opts.summary) {
@@ -502,7 +598,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
await withClient(deps, async (client) => {
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/handoff`, {
fromSession: from,
toSession: opts.to,
toSession: hostResolved.destination,
body: opts.body,
summary: opts.summary,
evidenceRef: opts.evidenceRef,
@@ -512,6 +608,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
tags,
targetRepo: opts.targetRepo,
nudge: opts.nudge,
...(hostResolved.hostId !== undefined ? { hostId: hostResolved.hostId } : {}),
});
printResult(opts.json ?? false, res.data, res.status);
});
@@ -533,6 +630,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
.option("--target-repo <name>", "PL-007: typed repo scope for the new qitem")
.option("--summary <text>", "OPR.0.4.1.18: short human-readable 1-2 sentence summary for the new qitem (feeds the Story node; --body stays source of truth). Warned-if-missing.")
.option("--evidence-ref <path>", "OPR.0.4.4.19 FR-5: durable-artifact pointer for the new qitem. Required by the daemon when the new qitem is human-routed; optional otherwise.")
.option("--host <id>", QUEUE_HOST_OPTION_HELP)
.option("--no-nudge", "Suppress the default nudge to the new destination")
.option("--json", "JSON output for agents")
.action(async (qitemId: string, opts: {
@@ -547,11 +645,18 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
targetRepo?: string;
summary?: string;
evidenceRef?: string;
host?: string;
nudge?: boolean;
json?: boolean;
}) => {
const from = resolveCurrentSession(opts.from, "from");
if (!from) return;
// OPR.0.4.6.MH3 D-3 (C3): same edge resolution as handoff.
const hostResolved = resolveQueueHostDestination(opts.to, opts.host);
if (!hostResolved.ok) {
emitHostResolutionError(hostResolved, opts.json ?? false);
return;
}
// OPR.0.4.1.18 (FR-7): warn-on-author — a handoff authors a NEW qitem, so
// it should carry its own summary. Warn to stderr; do not hard-break.
if (!opts.summary) {
@@ -572,7 +677,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
await withClient(deps, async (client) => {
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/handoff-and-complete`, {
fromSession: from,
toSession: opts.to,
toSession: hostResolved.destination,
body: opts.body,
summary: opts.summary,
evidenceRef: opts.evidenceRef,
@@ -582,6 +687,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
tags,
targetRepo: opts.targetRepo,
nudge: opts.nudge,
...(hostResolved.hostId !== undefined ? { hostId: hostResolved.hostId } : {}),
});
printResult(opts.json ?? false, res.data, res.status);
});
+13 -2
View File
@@ -16,6 +16,7 @@ import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl, type LifecycleDeps } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import { detectRuntime } from "../restore-packet/runtime-detect.js";
import { parseSessionName } from "../session-name.js";
import { parseCodexJsonl } from "../restore-packet/codex-jsonl-parser.js";
import { parseClaudeTranscript } from "../restore-packet/claude-transcript-parser.js";
import { writePacket, type WritePacketOptions } from "../restore-packet/packet-writer.js";
@@ -104,8 +105,18 @@ function deriveProvenance(
if (typeof opts.sourceRigOverride === "string" && opts.sourceRigOverride.length > 0) {
sourceRig = opts.sourceRigOverride;
} else {
const m = /^[^@]+@([^@]+)$/.exec(sourceSessionId);
const captured = m?.[1];
// OPR.0.4.6.MH1 FR-8 + rev1-r2 B2: the shared parse contract, with
// this site's OWN strictness on top. The contract's greedy rig is the
// QUEUE GATE's shape — where an in-band host fails the registry
// lookup loudly. THIS site persists provenance with no lookup, so a
// multi-@ rig would be silently recorded (BR-1 keeps host out of the
// session string; the honest-provenance policy forbids silent
// acceptance). Canonical member@rig with a PLAIN rig derives; anything
// else throws the explicit override error below.
const parsedSource = parseSessionName(sourceSessionId);
const captured = parsedSource.kind === "canonical" && !parsedSource.rig.includes("@")
? parsedSource.rig
: undefined;
if (!captured) {
throw new Error(
`could not derive source_rig from session id '${sourceSessionId}' (does not match <seat>@<rig> shape); supply --source-rig-override <rig> explicitly. Silent fallback to source_rig:"unknown" is not allowed (M2c-CLI R2 honest-provenance policy).`,
+128 -9
View File
@@ -1,11 +1,14 @@
import { Command } from "commander";
import { resolveEffectiveHost } from "../host-selection.js";
import { DaemonClient, terminalAuthHeaders } from "../client.js";
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
import { loadHostRegistry, resolveHost, hostDisplayTarget } from "../host-registry.js";
import { loadHostRegistry, resolveHost, hostDisplayTarget, type HttpHostEntry } from "../host-registry.js";
import { runCrossHostCommand, type RunCrossHostCommandOpts } from "../cross-host-executor.js";
import { emitCrossHostError, emitCrossHostFailure } from "../cross-host-cli-helpers.js";
import { emitCrossHostError, emitCrossHostFailure, emitRemoteHttpFailure } from "../cross-host-cli-helpers.js";
import { resolveCrossHostTarget } from "../cross-host-target.js";
import { runRemoteHttpOp } from "../remote-host-ops.js";
import { readOpenRigEnv } from "../openrig-compat.js";
const WAIT_FOR_IDLE_REQUEST_OVERHEAD_MS = 5_000;
@@ -88,7 +91,7 @@ export function sendCommand(depsOverride?: SendDeps): Command {
.option("--raw", "Send exact text/keystrokes without the From/To messaging envelope (still guarded against interactive prompts)")
.option("--dangerously-interact", "DANGEROUS: deliberately drive an interactive prompt/permission block (implies --raw; requires --reason). The ONLY override of the prompt/permission guard.")
.option("--reason <text>", "Why the prompt is being driven (required with --dangerously-interact; recorded in the audit log)")
.option("--host <id>", "Run on a remote host declared in ~/.openrig/hosts.yaml (CLI-side ssh shell-out)")
.option("--host <id>", "Send on a remote host declared in ~/.openrig/hosts.yaml (ssh hosts shell out; http hosts go CLI-direct to the remote daemon)")
.option("--json", "JSON output for agents")
.addHelpText("after", `
Examples:
@@ -101,6 +104,7 @@ Examples:
rig send dev-impl@my-rig "Stop and read the spec." --force
rig send dev-impl@my-rig "message" --json
rig send --host vm-claude-test dev-impl@my-rig "remote message" --verify
rig send dev-impl@my-rig@vps-b "host-qualified target sugar (suffix must be a registered host id)"
Targeting: a bare seat (single send), OR one of --to / --pod / --rig (fan-out).
Fan-out reports per-recipient results + an "N/M delivered" summary; one recipient's
@@ -124,10 +128,23 @@ without the From/To envelope (e.g. a slash command); it is still guarded. Use
option, approve a permission, send /compact to a blocked pane) — the only override
of the prompt guard; it implies --raw and is audit-logged.
--host runs the same command on a remote host declared in ~/.openrig/hosts.yaml
via single-hop ssh. SSH success is NOT verify success: the remote rig's
'Verified: yes/no' line is what counts and is surfaced verbatim.`)
--host sends on a remote host declared in ~/.openrig/hosts.yaml. The host
entry's transport decides the path: ssh hosts run the same command via
single-hop ssh (SSH success is NOT verify success: the remote rig's
'Verified: yes/no' line is what counts and is surfaced verbatim); http hosts
(e.g. pair-registered) go CLI-direct to the remote daemon's send route — the
result and verify verdict are the REMOTE's, verbatim. A target of the form
agent@rig@host is sugar for --host when the suffix is a REGISTERED host id
(explicit --host > target sugar > persisted selection; a conflict between
--host and the sugar is an error).`)
.action(async (session: string | undefined, text: string | undefined, opts: { to?: string[]; pod?: string; rig?: string; verify?: boolean; force?: boolean; waitForIdle?: string; raw?: boolean; dangerouslyInteract?: boolean; reason?: string; host?: string; json?: boolean }) => {
// OPR.0.4.6.MH1 FR-2: selected-host routing — explicit --host wins;
// else the persisted selection feeds the SHIPPED --host path; no
// selection = today exactly. OPR.0.4.6.MH4 §4: the raw flag is kept
// so the single-seat target sugar can slot BETWEEN explicit and
// selection (explicit > sugar > selection).
const explicitHost = opts.host;
opts.host = resolveEffectiveHost(opts.host);
const waitForIdleMs = parseWaitForIdleMs(opts.waitForIdle);
if (opts.force && waitForIdleMs !== undefined) {
console.error("--wait-for-idle cannot be combined with --force");
@@ -199,9 +216,24 @@ via single-hop ssh. SSH success is NOT verify success: the remote rig's
return;
}
// --- Cross-host short-circuit (CLI-side ssh shell-out; daemon untouched) ---
// OPR.0.4.6.MH4 §4 — the `agent@rig@host` target sugar (single-seat
// only; the fan-out positional is message text). Suffix must match a
// REGISTERED host id, else the target passes through unchanged and
// the hint rides any later failure. Precedence: explicit --host >
// sugar > persisted selection (already folded into opts.host above).
const targetResolution = resolveCrossHostTarget(session, explicitHost, deps.hostRegistryLoader);
if (!targetResolution.ok) {
console.error(targetResolution.error);
process.exitCode = 1;
return;
}
session = targetResolution.target;
const crossHostHint = targetResolution.hint;
opts.host = explicitHost ?? targetResolution.sugarHost ?? opts.host;
// --- Cross-host short-circuit (CLI-side; ssh shell-out or the MH-4 http branch; daemon untouched) ---
if (opts.host) {
await runCrossHostSend(opts.host, session, text, opts, deps);
await runCrossHostSend(opts.host, session, text, opts, deps, waitForIdleMs, crossHostHint);
return;
}
@@ -232,6 +264,9 @@ via single-hop ssh. SSH success is NOT verify success: the remote rig's
if (res.status >= 400) {
const error = res.data["error"] as string | undefined;
console.error(error ?? `Send failed (HTTP ${res.status})`);
// MH-4 §4 loud-failure hint: the target was 3-part-shaped but its
// suffix matched no registered host — name the near-miss.
if (crossHostHint) console.error(`hint: ${crossHostHint}`);
process.exitCode = res.status >= 500 ? 2 : 1;
return;
}
@@ -268,6 +303,8 @@ async function runCrossHostSend(
text: string,
opts: { verify?: boolean; force?: boolean; waitForIdle?: string; raw?: boolean; dangerouslyInteract?: boolean; reason?: string; json?: boolean },
deps: SendDeps,
waitForIdleMs?: number,
hint?: string,
): Promise<void> {
const loader = deps.hostRegistryLoader ?? loadHostRegistry;
const runner = deps.crossHostRun ?? runCrossHostCommand;
@@ -279,11 +316,21 @@ async function runCrossHostSend(
}
const resolved = resolveHost(registry.registry, hostId);
if (!resolved.ok) {
emitCrossHostError(hostId, "unknown-host", resolved.error, opts.json);
emitCrossHostError(hostId, "unknown-host", hint ? `${resolved.error} (${hint})` : resolved.error, opts.json);
return;
}
const host = resolved.host;
// OPR.0.4.6.MH4 — the http transport branch: an http-registered host (the
// founder's `pair` front door) takes the CLI-direct path to the remote
// daemon's shipped /api/transport/send. The ssh path below stays
// byte-verbatim for ssh hosts (transport is dictated by the host entry —
// ssh XOR http, never a fallback).
if (host.transport === "http") {
await runHttpHostSend(host, session, text, opts, deps, waitForIdleMs, hint);
return;
}
// Reconstruct argv for the remote `rig send` invocation. Order is positional
// first so the remote Commander parses it the same way local does.
const argv: string[] = ["rig", "send", session, text];
@@ -315,6 +362,78 @@ async function runCrossHostSend(
emitCrossHostFailure(host.id, hostDisplayTarget(host), result, opts.json);
}
/**
* OPR.0.4.6.MH4 C1 — cross-host send over http, CLI-DIRECT to the remote
* daemon's shipped POST /api/transport/send (zero daemon-side changes).
* Wrap parity BY CONSTRUCTION: the body is built exactly as the LOCAL path
* builds it (same wrapSendBody call, same fields), so the remote daemon
* receives what its own local CLI would post. `actorSession` is the local
* sender verbatim — honest provenance; unknown on the remote it degrades to
* the shipped non-blocking advisory, never a refusal. `--verify` prints the
* REMOTE route's verified/outcome verbatim (remote-authoritative, never
* locally synthesized). Deadline: the read-class client default, or
* waitForIdleMs + overhead when --wait-for-idle (the local path's math).
*
* Auth posture (named, v0): runRemoteHttpOp presents the REGISTRY bearer;
* /api/transport/* is gated by the remote's TERMINAL bearer class. Default
* (null) + tailnet binds = pass-through by design; a remote enforcing a
* DIFFERENT terminal bearer surfaces as the structured permission-gate step
* (never a hang, never silent). Remedy documented in cli-reference.md.
*/
async function runHttpHostSend(
host: HttpHostEntry,
session: string,
text: string,
opts: { verify?: boolean; force?: boolean; waitForIdle?: string; raw?: boolean; dangerouslyInteract?: boolean; reason?: string; json?: boolean },
deps: SendDeps,
waitForIdleMs?: number,
hint?: string,
): Promise<void> {
const senderSession = resolveSenderSession();
const raw = Boolean(opts.raw || opts.dangerouslyInteract);
const outboundText = raw ? text : wrapSendBody(senderSession, session, text);
const result = await runRemoteHttpOp(host.id, "POST", "/api/transport/send", {
session, text: outboundText, verify: opts.verify, force: opts.force, waitForIdleMs,
dangerouslyInteract: opts.dangerouslyInteract, reason: opts.reason, actorSession: senderSession ?? null,
}, deps, waitForIdleMs !== undefined ? { timeoutMs: waitForIdleMs + WAIT_FOR_IDLE_REQUEST_OVERHEAD_MS } : {});
if (opts.json) {
console.log(JSON.stringify({
cross_host: { host: host.id, target: hostDisplayTarget(host), transport: "http" },
result,
...(!result.ok && hint ? { hint } : {}),
}));
if (!result.ok) process.exitCode = 1;
return;
}
if (!result.ok) {
emitRemoteHttpFailure(host.id, hostDisplayTarget(host), result, false, hint);
return;
}
console.log(`[via host=${host.id} (${hostDisplayTarget(host)})]`);
const data = (result.data ?? {}) as Record<string, unknown>;
console.log(`Sent to ${session}`);
const advisory = data["warning"] as string | undefined;
if (advisory) {
console.log(`Advisory: ${advisory}`);
}
if (opts.verify) {
// The REMOTE route's verdict, verbatim — mirrors the local render so
// scripts grepping `Verified:` behave identically across hosts.
const verified = data["verified"] as boolean | undefined;
console.log(`Verified: ${verified ? "yes" : "no"}`);
const outcome = data["outcome"] as string | undefined;
if (outcome === "delivered") {
console.log("Delivery: delivered (message landed; render confirmed)");
} else if (outcome === "rendered-unconfirmed") {
console.log(`Delivery: rendered-unconfirmed (landed; pane re-render not confirmed - confirm with: rig capture ${session})`);
}
}
}
// OPR.0.4.3.30 — fan-out send (`--to` / `--pod` / `--rig`). Reuses the DAEMON's broadcast
// machinery (resolve → per-seat send loop → per-recipient results) via /api/transport/broadcast.
// The message is sent BARE; the daemon wraps each recipient in its OWN From/To envelope
+144
View File
@@ -0,0 +1,144 @@
// OPR.0.4.6.02 C3 — the `rig terminal` command family (the TUI-1 opaque verb +
// the operator's terminal-provider-ride entry). Three subcommands, all hitting
// the ONE canonical daemon composer (`/api/terminal/...`):
//
// rig terminal open <view> [--provider herdr|cmux] [--json]
// rig terminal views [--json]
// rig terminal status [--provider herdr|cmux] [--json]
//
// `<view>` resolves daemon-side: a rig name (per-rig derived) | `mission:<id>` |
// `slice:<id>` (derived) | a saved-view id. The result is the ONE shared
// `{ opened, absent, degraded }` partition, carried byte-identically here and
// in the route JSON (arch Q3).
//
// Exit semantics (PRD / arch Q3): a partial open WITH NAMES is a SUCCESS with
// disclosure → exit 0; a ZERO-pane open (nothing tiled: unknown view, provider
// down, every seat absent/degraded) → non-zero. `views`/`status` are always
// exit 0 unless the daemon is unreachable.
import { Command } from "commander";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface TerminalDeps extends StatusDeps {}
/** The one shared open-result shape (mirrors the daemon `OpenViewResult`). */
interface OpenViewResult {
provider: string;
ok: boolean;
opened: string[];
absent: { seat: string; host: string | null; reason: string }[];
degraded: { seat: string; host: string; reason: string }[];
pages: number;
error?: string;
code?: string;
}
async function withClient<T>(
deps: TerminalDeps,
fn: (client: DaemonClient) => Promise<T>,
): Promise<T | undefined> {
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state !== "running" || status.healthy === false) {
console.error("Daemon not running. Start it with: rig daemon start");
process.exitCode = 1;
return undefined;
}
const client = deps.clientFactory(getDaemonUrl(status));
return fn(client);
}
/** Print a plain daemon response (views/status). JSON = compact; human = pretty. */
function printResult(json: boolean, body: unknown, status: number): void {
console.log(json ? JSON.stringify(body) : JSON.stringify(body, null, 2));
if (status >= 400) process.exitCode = status >= 500 ? 2 : 1;
}
/** Human-format the honest-partial open result (opened/absent/degraded, each named). */
function humanOpen(r: OpenViewResult): string {
const lines: string[] = [];
const tiled = r.opened.length;
lines.push(
tiled > 0
? `Opened ${tiled} tile(s) in ${r.provider}${r.pages > 1 ? ` across ${r.pages} page(s)` : ""}.`
: `No tiles opened in ${r.provider}.`,
);
if (r.error) lines.push(` provider: ${r.error}${r.code ? ` (${r.code})` : ""}`);
for (const seat of r.opened) lines.push(` ● ${seat}`);
for (const a of r.absent) lines.push(` ○ ${a.seat} — absent: ${a.reason}`);
for (const d of r.degraded) lines.push(` ▲ ${d.seat} — skipped (${d.host}): ${d.reason}`);
return lines.join("\n");
}
/** Open exit rule: exit 0 iff at least one pane was tiled (partial-with-names is success). */
function printOpen(json: boolean, r: OpenViewResult, status: number): void {
if (json) {
console.log(JSON.stringify(r));
} else {
console.log(humanOpen(r));
}
// A 4xx/5xx (bad input / unknown view / service down) OR a zero-pane open is a failure.
if (status >= 400 || r.opened.length === 0) {
process.exitCode = status >= 500 ? 2 : 1;
}
}
export function terminalCommand(depsOverride?: TerminalDeps): Command {
const cmd = new Command("terminal").description(
"Open OpenRig views (agent terminals) as tiles in a terminal provider (herdr / cmux)",
);
const getDeps = (): TerminalDeps =>
depsOverride ?? {
lifecycleDeps: realDeps(),
clientFactory: (url: string) => new DaemonClient(url),
};
cmd
.command("open")
.argument("<view>", "a rig name, mission:<id>, slice:<id>, or a saved-view id")
.description("Open every live agent in the view as an interactive terminal tile")
.option("--provider <name>", "terminal provider: herdr (default) or cmux (best-effort)")
.option("--json", "JSON output for agents")
.action(async (view: string, opts: { provider?: string; json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const body = { view, ...(opts.provider ? { provider: opts.provider } : {}) };
const res = await client.post<OpenViewResult>("/api/terminal/open", body);
printOpen(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("views")
.description("List saved views + the rigs openable as derived views")
.option("--json", "JSON output for agents")
.action(async (opts: { json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.get<unknown>("/api/terminal/views");
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("status")
.description("Show terminal provider availability + liveness (doctor)")
.option("--provider <name>", "restrict to one provider (herdr / cmux)")
.option("--json", "JSON output for agents")
.action(async (opts: { provider?: string; json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const path = opts.provider
? `/api/terminal/status?provider=${encodeURIComponent(opts.provider)}`
: "/api/terminal/status";
const res = await client.get<unknown>(path);
printResult(opts.json ?? false, res.data, res.status);
});
});
return cmd;
}
+136 -37
View File
@@ -1,12 +1,22 @@
import { Command } from "commander";
import { resolveEffectiveHost } from "../host-selection.js";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
import { loadHostRegistry, resolveHost, hostDisplayTarget } from "../host-registry.js";
import { emitCrossHostError, emitRemoteHttpFailure } from "../cross-host-cli-helpers.js";
import { resolveCrossHostTarget } from "../cross-host-target.js";
import { runRemoteHttpOp } from "../remote-host-ops.js";
export function transcriptCommand(depsOverride?: StatusDeps): Command {
export interface TranscriptDeps extends StatusDeps {
/** Test seam: inject a registry loader so no real ~/.openrig is touched. */
hostRegistryLoader?: () => ReturnType<typeof loadHostRegistry>;
}
export function transcriptCommand(depsOverride?: TranscriptDeps): Command {
const cmd = new Command("transcript").description("Read agent transcript output");
const getDeps = (): StatusDeps => depsOverride ?? {
const getDeps = (): TranscriptDeps => depsOverride ?? {
lifecycleDeps: realDeps(),
clientFactory: (url: string) => new DaemonClient(url),
};
@@ -15,14 +25,56 @@ export function transcriptCommand(depsOverride?: StatusDeps): Command {
.argument("<session>", "Session name (e.g. dev-impl@my-rig)")
.option("--tail <lines>", "Show last N lines (default: 50)", "50")
.option("--grep <pattern>", "Search for lines matching pattern (regex)")
.option("--host <id>", "Read from a remote host declared in ~/.openrig/hosts.yaml (http hosts only — CLI-direct to the remote daemon's transcript routes)")
.option("--json", "JSON output for agents")
.addHelpText("after", `
Examples:
rig transcript dev-impl@my-rig --tail 100
rig transcript dev-impl@my-rig --grep "decision|architecture"
rig transcript dev-impl@my-rig --json`)
.action(async (session: string, opts: { tail?: string; grep?: string; json?: boolean }) => {
rig transcript dev-impl@my-rig --json
rig transcript --host vps-b dev-impl@my-rig --tail 100
rig transcript dev-impl@my-rig@vps-b --grep "handoff"
--host reads the transcript from a remote host declared in
~/.openrig/hosts.yaml, CLI-direct against that daemon's shipped transcript
routes (http-registered hosts, e.g. pair-registered; an ssh-registered host
fails with the structured transport-requirement error). Output shape is the
origin's, verbatim. A session of the form agent@rig@host is sugar for --host
when the suffix is a REGISTERED host id (explicit --host > sugar > persisted
selection).`)
.action(async (session: string, opts: { tail?: string; grep?: string; host?: string; json?: boolean }) => {
// OPR.0.4.6.MH4 C2 — cross-host observe: explicit --host > the
// `agent@rig@host` target sugar > the persisted host selection
// (resolveEffectiveHost). The local path below is byte-untouched.
const explicitHost = opts.host;
opts.host = resolveEffectiveHost(opts.host);
const deps = getDeps();
const targetResolution = resolveCrossHostTarget(session, explicitHost, deps.hostRegistryLoader);
if (!targetResolution.ok) {
console.error(targetResolution.error);
process.exitCode = 1;
return;
}
session = targetResolution.target;
const crossHostHint = targetResolution.hint;
opts.host = explicitHost ?? targetResolution.sugarHost ?? opts.host;
// --grep takes precedence over --tail when both given
const useGrep = !!opts.grep;
const tailLines = parseInt(opts.tail ?? "50", 10);
const apiPath = useGrep
? `/api/transcripts/${encodeURIComponent(session)}/grep?pattern=${encodeURIComponent(opts.grep!)}`
: `/api/transcripts/${encodeURIComponent(session)}/tail?lines=${isNaN(tailLines) ? 50 : tailLines}`;
// --- Cross-host path (CLI-direct GET to the remote daemon's shipped
// transcript routes — the SAME paths the local path builds, origin
// shape verbatim; read-class deadline; daemon untouched). ---
if (opts.host) {
await runCrossHostTranscript(opts.host, apiPath, useGrep, opts, deps, crossHostHint);
return;
}
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state !== "running" || status.healthy === false) {
@@ -33,20 +85,7 @@ Examples:
const client = deps.clientFactory(getDaemonUrl(status));
// --grep takes precedence over --tail when both given
const useGrep = !!opts.grep;
let res: { status: number; data: Record<string, unknown> };
if (useGrep) {
res = await client.get<Record<string, unknown>>(
`/api/transcripts/${encodeURIComponent(session)}/grep?pattern=${encodeURIComponent(opts.grep!)}`,
);
} else {
const lines = parseInt(opts.tail ?? "50", 10);
res = await client.get<Record<string, unknown>>(
`/api/transcripts/${encodeURIComponent(session)}/tail?lines=${isNaN(lines) ? 50 : lines}`,
);
}
const res = await client.get<Record<string, unknown>>(apiPath);
if (opts.json) {
console.log(JSON.stringify(res.data));
@@ -57,30 +96,90 @@ Examples:
if (res.status >= 400) {
const error = (res.data as Record<string, unknown>)["error"] as string | undefined;
console.error(error ?? `Transcript request failed (HTTP ${res.status})`);
// MH-4 §4 loud-failure hint: 3-part-shaped target, unregistered suffix.
if (crossHostHint) console.error(`hint: ${crossHostHint}`);
process.exitCode = 1;
return;
}
if (useGrep) {
const matches = (res.data as Record<string, unknown>)["matches"] as string[] | undefined;
if (matches && matches.length > 0) {
for (const line of matches) {
console.log(line);
}
} else {
console.log("No matches found.");
}
} else {
const content = (res.data as Record<string, unknown>)["content"] as string | undefined;
if (content) {
// Print each line via console.log for consistent capture in tests and terminal
const lines = content.split("\n");
for (const line of lines) {
if (line) console.log(line);
}
}
}
renderTranscript(res.data, useGrep);
});
return cmd;
}
/**
* OPR.0.4.6.MH4 C2 — cross-host transcript over http, CLI-DIRECT to the
* remote daemon's shipped GET /api/transcripts/:session/tail | /grep
* (zero daemon-side changes; the MH-2 read-through PATTERN, one hop from
* the bearer-capable CLI). An ssh-registered host surfaces runRemoteHttpOp's
* structured transport error — never a silent wrong-transport attempt.
*/
async function runCrossHostTranscript(
hostId: string,
apiPath: string,
useGrep: boolean,
opts: { json?: boolean },
deps: TranscriptDeps,
hint?: string,
): Promise<void> {
// Resolve the host in the caller (the send/capture pattern) so an unknown
// host surfaces as the SAME `unknown-host` step class across all four
// verbs, and the banner carries the display target uniformly.
const loader = deps.hostRegistryLoader ?? loadHostRegistry;
const registry = loader();
if (!registry.ok) {
emitCrossHostError(hostId, "registry-load-failed", registry.error, opts.json);
return;
}
const resolved = resolveHost(registry.registry, hostId);
if (!resolved.ok) {
emitCrossHostError(hostId, "unknown-host", hint ? `${resolved.error} (${hint})` : resolved.error, opts.json);
return;
}
const host = resolved.host;
const result = await runRemoteHttpOp(hostId, "GET", apiPath, undefined, deps, {});
if (opts.json) {
console.log(JSON.stringify({
cross_host: { host: host.id, target: hostDisplayTarget(host), transport: "http" },
result,
...(!result.ok && hint ? { hint } : {}),
}));
if (!result.ok) process.exitCode = 1;
return;
}
if (!result.ok) {
emitRemoteHttpFailure(host.id, hostDisplayTarget(host), result, false, hint);
return;
}
console.log(`[via host=${host.id} (${hostDisplayTarget(host)})]`);
renderTranscript((result.data ?? {}) as Record<string, unknown>, useGrep);
}
/** One renderer, two callers — the remote output shape is the origin's,
* so local and cross-host reads render identically. */
function renderTranscript(data: Record<string, unknown>, useGrep: boolean): void {
if (useGrep) {
const matches = data["matches"] as string[] | undefined;
if (matches && matches.length > 0) {
for (const line of matches) {
console.log(line);
}
} else {
console.log("No matches found.");
}
} else {
const content = data["content"] as string | undefined;
if (content) {
// Print each line via console.log for consistent capture in tests and terminal
const lines = content.split("\n");
for (const line of lines) {
if (line) console.log(line);
}
}
}
}
+8
View File
@@ -1,4 +1,5 @@
import nodePath from "node:path";
import { resolveEffectiveHost } from "../host-selection.js";
import { existsSync, readFileSync } from "node:fs";
import { parse as parseYamlDoc } from "yaml";
import { Command } from "commander";
@@ -83,6 +84,13 @@ Examples:
.option("--json", "JSON output for agents")
.option("--host <id>", "Run on a remote host declared in ~/.openrig/hosts.yaml")
.action(async (source: string, opts: { plan?: boolean; yes?: boolean; cwd?: string; target?: string; existing?: boolean; fresh?: string[]; json?: boolean; host?: string }) => {
// OPR.0.4.6.MH1 FR-2: selected-host routing — explicit --host wins;
// else the persisted selection feeds the SHIPPED --host path; no
// selection = today exactly. Topology
// sources are EXEMPT: per-entry host: is the only topology placement
// (shipped R11-2); implicit selection must not turn a flagless
// topology up into the rejected --host form.
if (!sourceLooksLikeTopology(source)) opts.host = resolveEffectiveHost(opts.host);
const deps = getDepsF();
if (opts.host) {
+11
View File
@@ -1,4 +1,5 @@
import { Command } from "commander";
import { readOwnHostName, resolveEffectiveHost } from "../host-selection.js";
import { execSync } from "node:child_process";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
@@ -215,6 +216,10 @@ complete payload (contextUsage, command examples, runtime token detail,
workspace block). The compact form omits the Context line; use 'rig context' or
'rig whoami --full' for usage.`)
.action(async (opts: WhoamiCliOptions) => {
// OPR.0.4.6.MH1 FR-2: selected-host routing — explicit --host wins;
// else the persisted selection feeds the SHIPPED --host path; no
// selection = today exactly.
opts.host = resolveEffectiveHost(opts.host);
const deps = getDeps();
const full = Boolean(opts.full || opts.verbose);
@@ -297,6 +302,12 @@ workspace block). The compact form omits the Context line; use 'rig context' or
// Human-readable output
const data = res.data as unknown as WhoamiResult;
const id = data.identity;
// OPR.0.4.6.MH1 FR-4: the own-host name renders here when RENAMED;
// unnamed (default "localhost") keeps today's output byte-identical.
const ownHostName = readOwnHostName();
if (ownHostName !== "localhost") {
console.log(`Host: ${ownHostName}`);
}
console.log(`Rig: ${id.rigName}`);
console.log(`Logical ID: ${id.logicalId}`);
console.log(`Pod: ${(id.podNamespace ?? id.podId) ?? "—"} / ${id.memberId}`);
@@ -0,0 +1,117 @@
/**
* OPR.0.4.6.WF3 FR-5 — what/why/fix rendering for the named daemon
* rejections (the house 3-part shape; clig.dev's rewrite-for-humans
* rule). HUMAN MODE ONLY: `--json` preserves the RAW daemon body
* byte-identically so scripts keep the stable error codes; exit codes
* are untouched (1=4xx / 2=5xx, plus FR-1's outcome codes).
*
* Unrecognized error bodies fall back to the raw JSON render — this
* module only rewrites what it genuinely understands.
*/
export interface ThreePartRejection {
fact: string;
consequence: string;
action: string;
}
interface RejectionBody {
error?: string;
message?: string;
instanceId?: string;
expectedVersion?: number;
actualVersion?: number;
allowedExits?: string[];
[key: string]: unknown;
}
/**
* Map a recognized daemon rejection to the 3-part shape, or null when
* the body is not a named rejection (caller falls back to raw JSON).
*/
export function describeDaemonRejection(body: unknown, instanceHint?: string): ThreePartRejection | null {
if (typeof body !== "object" || body === null) return null;
const b = body as RejectionBody;
if (typeof b.error !== "string") return null;
const instance = typeof b.instanceId === "string" ? b.instanceId : instanceHint;
const traceCmd = instance ? `rig workflow trace ${instance}` : "rig workflow trace <instance>";
const showCmd = instance ? `rig workflow show ${instance}` : "rig workflow show <instance>";
switch (b.error) {
case "packet_not_on_frontier":
return {
fact: `That packet is not on the instance frontier${b.message ? ` (${b.message})` : ""}.`,
consequence:
"The step it belonged to was already closed, replayed, or re-routed — the frontier has moved past it, so this close cannot apply.",
action: `See where the instance actually is: ${traceCmd}`,
};
case "instance_not_active": {
const state = typeof b.message === "string" && b.message.length > 0 ? b.message : "not active";
return {
fact: `The instance is ${state}.`,
consequence: "Only an active instance can advance; terminal and waiting states reject projection.",
// OPR.0.4.6.WF5 FR-4: the promised pointer upgrade — resume is
// real now. A FAILED instance redrives; other terminal states
// still inspect-first.
action: `If it is failed, redrive it: rig workflow resume <instanceId> --actor-session <you>. Otherwise inspect: ${showCmd}`,
};
}
case "instance_not_failed":
return {
fact: "The instance is not in the failed state.",
consequence: "resume re-drives FAILED instances only — an active instance needs no resume, and a waiting instance continues via its preserved frontier packet (project), not a redrive.",
action: `See the real state first: ${showCmd}`,
};
case "resume_step_unrecoverable":
return {
fact: "The instance carries no recorded failed step to rebind to.",
consequence: "This is a pre-R2 row without a durable step binding; a redrive has no anchor.",
action: "Instantiate a fresh run of the workflow (the trail of this instance remains for the record).",
};
case "resume_step_missing_from_spec":
return {
fact: "The failed step no longer exists in the cached workflow spec.",
consequence: "The spec drifted after this instance failed; rebinding would route a step the workflow no longer declares.",
action: "Restore the step in the spec (re-validate to refresh the cache) or instantiate a fresh run.",
};
case "instance_version_conflict":
return {
fact: `A concurrent writer advanced the instance first (expected version ${b.expectedVersion ?? "?"}, actual ${b.actualVersion ?? "?"}).`,
consequence: "This projection was rolled back whole; no partial state was written.",
action: `Re-read the current state (${traceCmd}), then retry against it.`,
};
case "exit_not_allowed": {
const allowed = Array.isArray(b.allowedExits) && b.allowedExits.length > 0 ? b.allowedExits.join(" | ") : null;
return {
fact: `That exit is not allowed for this step${b.message ? ` (${b.message})` : ""}.`,
consequence: "The step declares which exits it accepts; the daemon rejected the close before any state changed.",
action: allowed ? `Use one of: ${allowed}.` : `Check the step's allowed exits in the spec, then re-close: ${traceCmd}`,
};
}
case "no_next_step":
return {
fact: `The workflow has no next step for that exit${b.message ? ` (${b.message})` : ""}.`,
consequence: "The handoff had nowhere to route; the close was rejected whole.",
action: `Check the spec's routing for this step, or close terminally (--exit done | failed). Inspect: ${traceCmd}`,
};
case "next_owner_unresolved":
return {
fact: `The next step's owner could not be resolved${b.message ? ` (${b.message})` : ""}.`,
consequence: "A projected packet needs a destination seat; nothing was routed.",
action: "Pass --next-owner <session>, or fix the step's suggested_roles in the spec.",
};
case "instance_not_found":
return {
fact: `No such workflow instance${instance ? `: ${instance}` : ""}.`,
consequence: "Nothing was read or changed.",
action: "List instances: rig workflow list",
};
default:
return null;
}
}
/** Render the 3-part shape as stderr lines (the emit3PartError shape). */
export function formatThreePart(rej: ThreePartRejection): string[] {
return [`Error: ${rej.fact}`, rej.consequence, rej.action];
}
@@ -0,0 +1,325 @@
import type { DaemonClient } from "../client.js";
/**
* OPR.0.4.6.WF3 FR-1 — the shared follow engine behind `rig workflow
* run` and `rig workflow watch`: two verbs, ONE renderer (the argo
* shape: same render at attach and live).
*
* Transport: the SHIPPED workflow SSE endpoint (`GET
* /api/workflow/sse`) consumed with the in-repo chatroom reader
* pattern (fetch + event-stream line decode). Zero daemon changes
* (BR-2) — this file only reads.
*
* Attach race (arch R2, snapshot-first-then-stream): the stream is
* OPENED first, then a state snapshot (`/trace`) is rendered, then
* live events flow. Events that duplicate a trail row already in the
* snapshot are deduped by priorQitemId, so fast early steps that
* close before attach are still shown exactly once.
*
* Exit codes (arch n1 — outcome codes DISTINCT from error codes):
* 0 = workflow completed
* 3 = workflow FAILED (the outcome-as-exit-code kubectl default —
* a workflow that fails is a command that fails; never collides
* with the shipped 1=4xx / 2=5xx transport codes)
*/
export const EXIT_WORKFLOW_FAILED = 3;
/** Terminal statuses a follow run resolves on. */
const TERMINAL_STATUSES = new Set(["completed", "failed"]);
export interface FollowInstanceView {
instanceId: string;
workflowName?: string;
status: string;
currentStepId?: string | null;
currentFrontier?: string[];
}
export interface FollowTrailRow {
stepId: string;
stepRole?: string;
closedAt?: string;
closureReason: string;
actorSession: string;
nextQitemId?: string | null;
priorQitemId: string;
}
interface WorkflowEvent {
type: string;
instanceId?: string;
stepId?: string;
closureReason?: string;
actorSession?: string;
priorQitemId?: string;
nextQitemId?: string;
nextOwner?: string;
nextStepId?: string;
workflowName?: string;
reason?: string;
[key: string]: unknown;
}
export interface FollowIo {
/** stdout line sink (tests inject; default console.log). */
out: (line: string) => void;
/** stderr line sink for honest transport-state notices. */
err: (line: string) => void;
/** sleep injection so tests never wait wall-clock. */
sleep: (ms: number) => Promise<void>;
/** fetch injection for the SSE leg (tests stub the stream). */
fetchImpl: typeof fetch;
}
export function realFollowIo(): FollowIo {
return {
out: (line) => console.log(line),
err: (line) => process.stderr.write(`${line}\n`),
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
fetchImpl: fetch,
};
}
export interface FollowOptions {
json: boolean;
io?: FollowIo;
/** SSE reconnect attempts before degrading to the poll fallback. */
maxReconnects?: number;
/** Poll fallback interval (ms). */
pollIntervalMs?: number;
}
const STATUS_GLYPH: Record<string, string> = {
completed: "✔",
failed: "✖",
active: "●",
waiting: "◐",
};
function glyphFor(status: string): string {
return STATUS_GLYPH[status] ?? "●";
}
function renderTrailRow(row: FollowTrailRow): string {
const exitGlyph = row.closureReason === "failed" ? "✖" : "✔";
const next = row.nextQitemId ? ` → ${row.nextQitemId}` : "";
return ` ${exitGlyph} ${row.stepId} ${row.closureReason} by ${row.actorSession}${next}`;
}
function renderEvent(event: WorkflowEvent): string | null {
switch (event.type) {
case "workflow.instantiated":
return ` ● instance ${event.instanceId ?? ""} created${event.workflowName ? ` (${event.workflowName})` : ""}`;
case "workflow.step_closed":
return ` ${event.closureReason === "failed" ? "✖" : "✔"} ${event.stepId ?? "(step)"} ${event.closureReason ?? ""} by ${event.actorSession ?? "(unknown)"}`;
case "workflow.next_qitem_projected":
return ` → ${event.nextStepId ?? "(next)"} owner ${event.nextOwner ?? "(unresolved)"} packet ${event.nextQitemId ?? ""}`;
case "workflow.completed":
return ` ✔ workflow completed`;
case "workflow.failed":
return ` ✖ workflow FAILED: ${event.reason ?? "(no reason recorded)"}`;
default:
// routing_table_changed and future additive types render as a
// neutral one-liner rather than being silently dropped.
return ` · ${event.type}`;
}
}
/** Outcome → process exit code, per the FR-1 contract. */
export function outcomeExitCode(status: string): number {
if (status === "failed") return EXIT_WORKFLOW_FAILED;
return 0;
}
interface SnapshotResult {
instance: FollowInstanceView;
trail: FollowTrailRow[];
}
async function fetchSnapshot(
client: DaemonClient,
instanceId: string,
): Promise<{ ok: true; snapshot: SnapshotResult } | { ok: false; status: number; body: unknown }> {
const res = await client.get<{ instance?: FollowInstanceView; trail?: FollowTrailRow[] }>(
`/api/workflow/${encodeURIComponent(instanceId)}/trace`,
);
if (res.status >= 400 || !res.data?.instance) {
return { ok: false, status: res.status, body: res.data };
}
return { ok: true, snapshot: { instance: res.data.instance, trail: res.data.trail ?? [] } };
}
function renderSnapshot(snapshot: SnapshotResult, io: FollowIo, json: boolean): void {
if (json) {
io.out(JSON.stringify({ type: "snapshot", instance: snapshot.instance, trail: snapshot.trail }));
return;
}
const inst = snapshot.instance;
io.out(`${glyphFor(inst.status)} ${inst.instanceId}${inst.workflowName ? ` ${inst.workflowName}` : ""} status=${inst.status}`);
for (const row of snapshot.trail) io.out(renderTrailRow(row));
if (inst.currentStepId) {
io.out(` ▸ at step ${inst.currentStepId} frontier=[${(inst.currentFrontier ?? []).join(", ")}]`);
}
}
/**
* Follow one instance to a terminal state. Returns the exit code the
* caller should set (0 completed / 3 failed / 1 transport-4xx / 2
* transport-5xx). Never throws for stream drops — those degrade
* honestly (reconnect notice → poll-fallback notice), per the
* chatroom precedent: the render may fall back, it never freezes
* silently.
*/
export async function followInstance(
client: DaemonClient,
instanceId: string,
opts: FollowOptions,
): Promise<number> {
const io = opts.io ?? realFollowIo();
const maxReconnects = opts.maxReconnects ?? 3;
const pollIntervalMs = opts.pollIntervalMs ?? 3000;
// Walk-caught (VM iteration 2): without aborting the SSE connection
// on return, the open socket keeps the node event loop alive and the
// process hangs ~minutes after the terminal event before exiting —
// a kubectl-style verb must exit PROMPTLY on outcome. Every return
// path aborts via the finally below.
const aborter = new AbortController();
try {
// 1. Open the stream FIRST (arch R2) so no event can fall between
// snapshot and attach. Events are buffered by the reader until
// the snapshot has rendered.
const sseUrl = `${client.baseUrl}/api/workflow/sse`;
let streamRes: Response | null = null;
try {
streamRes = await io.fetchImpl(sseUrl, {
headers: { Accept: "text/event-stream" },
signal: aborter.signal,
});
if (!streamRes.ok || !streamRes.body) streamRes = null;
} catch {
streamRes = null;
}
// 2. Snapshot + render.
const snap = await fetchSnapshot(client, instanceId);
if (!snap.ok) {
io.out(JSON.stringify(snap.body ?? { error: "trace_failed" }, null, opts.json ? 0 : 2));
return snap.status >= 500 ? 2 : 1;
}
renderSnapshot(snap.snapshot, io, opts.json);
if (TERMINAL_STATUSES.has(snap.snapshot.instance.status)) {
return outcomeExitCode(snap.snapshot.instance.status);
}
// Dedup guard: trail rows already rendered by the snapshot must not
// re-render when the (already-open) stream replays their events.
const seenClosures = new Set(snap.snapshot.trail.map((row) => row.priorQitemId));
let reconnectsLeft = maxReconnects;
// 3. Stream loop with honest degradation.
while (true) {
if (streamRes?.body) {
const outcome = await consumeStream(streamRes.body, instanceId, seenClosures, io, opts.json);
if (outcome !== null) return outcome;
// Stream ended without a terminal event — the drop path.
streamRes = null;
}
if (reconnectsLeft > 0) {
reconnectsLeft -= 1;
io.err(`stream dropped — reconnecting (${maxReconnects - reconnectsLeft}/${maxReconnects})`);
try {
const retry = await io.fetchImpl(sseUrl, {
headers: { Accept: "text/event-stream" },
signal: aborter.signal,
});
if (retry.ok && retry.body) {
streamRes = retry;
continue;
}
} catch {
// fall through to next reconnect / poll fallback
}
continue;
}
// 4. Poll fallback — announced, never silent (the chatroom rule).
io.err(`stream unavailable — degrading to poll fallback every ${Math.round(pollIntervalMs / 1000)}s`);
while (true) {
await io.sleep(pollIntervalMs);
const poll = await fetchSnapshot(client, instanceId);
if (!poll.ok) {
io.out(JSON.stringify(poll.body ?? { error: "poll_failed" }, null, opts.json ? 0 : 2));
return poll.status >= 500 ? 2 : 1;
}
// Render only trail rows not yet seen, preserving exactly-once.
for (const row of poll.snapshot.trail) {
if (seenClosures.has(row.priorQitemId)) continue;
seenClosures.add(row.priorQitemId);
if (opts.json) io.out(JSON.stringify({ type: "trail", row }));
else io.out(renderTrailRow(row));
}
if (TERMINAL_STATUSES.has(poll.snapshot.instance.status)) {
if (opts.json) io.out(JSON.stringify({ type: "terminal", status: poll.snapshot.instance.status }));
else io.out(` ${glyphFor(poll.snapshot.instance.status)} workflow ${poll.snapshot.instance.status}`);
return outcomeExitCode(poll.snapshot.instance.status);
}
}
}
} finally {
// Kill the SSE socket so the process exits promptly on outcome
// (the walk-caught hang: an un-aborted keep-alive held the event
// loop open for minutes after workflow.completed).
aborter.abort();
}
}
/**
* Consume one SSE body until a terminal event for the instance (→
* exit code) or stream end (→ null: caller reconnects/degrades).
*/
async function consumeStream(
body: ReadableStream<Uint8Array>,
instanceId: string,
seenClosures: Set<string>,
io: FollowIo,
json: boolean,
): Promise<number | null> {
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) return null;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (!line.startsWith("data:")) continue;
let event: WorkflowEvent;
try {
event = JSON.parse(line.slice(5).trim()) as WorkflowEvent;
} catch {
continue; // malformed data line — skip (chatroom precedent)
}
if (event.instanceId !== instanceId) continue;
if (event.type === "workflow.step_closed" && typeof event.priorQitemId === "string") {
if (seenClosures.has(event.priorQitemId)) continue; // snapshot already showed it
seenClosures.add(event.priorQitemId);
}
if (json) {
io.out(JSON.stringify(event));
} else {
const rendered = renderEvent(event);
if (rendered !== null) io.out(rendered);
}
if (event.type === "workflow.completed") return 0;
if (event.type === "workflow.failed") return EXIT_WORKFLOW_FAILED;
}
}
} catch {
return null; // read error = drop; caller handles honestly
} finally {
reader.releaseLock();
}
}
@@ -0,0 +1,292 @@
/**
* OPR.0.4.6.WF3 FR-2 — the glanceable human renderers for
* trace/list/show. RENDER-SIDE ONLY (BR-2): these functions format
* daemon payloads the CLI already receives; `--json` paths never come
* through here and stay byte-identical to the shipped output.
*
* Shapes ported from in-repo precedent: the `rig ps` table mechanics
* (fitCell/truncate) and the argo-get per-step tree bar (STEP / ACTOR
* / EXIT / DURATION columns with status glyphs).
*
* Present-tolerant fields: WF-2's branch/gate additions
* (closureEvidence.branch_taken etc.) render when the payload carries
* them and leave no residue when absent — this module never requires
* them (WF-3 builds against the pre-WF-2 tip; the fields land with
* WF-2).
*/
export interface RenderInstance {
instanceId: string;
workflowName?: string;
workflowVersion?: string;
status: string;
createdBySession?: string;
createdAt?: string;
currentStepId?: string | null;
currentFrontier?: string[];
hopCount?: number;
/** OPR.0.4.6.FAC1: the instance's bound rig (API-carried; null/absent = unbound, renders nothing). */
boundRig?: string | null;
/**
* WF-1 FR-2's API-carried classification (present since the
* completion fixback at 384e60f1). RAIL 1 (arch ruling): the CLI
* CONSUMES this verbatim and never recomputes a threshold or class.
*/
deadline?: {
state: string;
evidence?: {
stepId?: string | null;
ownerSession?: string;
overdueBySeconds?: number;
ageSeconds?: number;
} | null;
};
/** Present on waiting instances that recorded their park decision. */
lastContinuationDecision?: { blockedOn?: string | null } | null;
}
export interface RenderTrailRow {
stepId: string;
stepRole?: string;
closedAt?: string;
closureReason: string;
closureEvidence?: Record<string, unknown> | null;
actorSession: string;
nextQitemId?: string | null;
priorQitemId?: string;
}
const STATUS_GLYPH: Record<string, string> = {
completed: "✔",
failed: "✖",
active: "●",
waiting: "◐",
};
export function statusGlyph(status: string): string {
return STATUS_GLYPH[status] ?? "●";
}
function truncate(s: string, max: number): string {
return s.length > max ? s.slice(0, max - 1) + "…" : s;
}
function fitCell(value: string, width: number): string {
return truncate(value, width).padEnd(width);
}
/** Compact human duration between two ISO timestamps ("3s", "4m", "2h", "5d"). */
export function humanDuration(fromIso: string | undefined, toIso: string | undefined): string {
if (!fromIso || !toIso) return "";
const ms = Date.parse(toIso) - Date.parse(fromIso);
if (!Number.isFinite(ms) || ms < 0) return "";
const s = Math.round(ms / 1000);
if (s < 60) return `${s}s`;
const m = Math.round(s / 60);
// Minutes render up to AND INCLUDING 120 so sub-/at-2h durations keep
// their precision ("90m"/"120m" beat a rounded-up "2h" for judging
// step latency). Guard prepass catch: `m < 120` excluded exactly 120.
if (m <= 120) return `${m}m`;
const h = Math.round(m / 60);
if (h < 48) return `${h}h`;
return `${Math.round(h / 24)}d`;
}
/** Instance age relative to a supplied "now" (injectable for tests). */
export function humanAge(createdAt: string | undefined, nowIso: string): string {
return humanDuration(createdAt, nowIso);
}
/** Compact render of a plain seconds quantity (same scale rules). */
export function humanSeconds(seconds: number): string {
return humanDuration("1970-01-01T00:00:00.000Z", new Date(seconds * 1000).toISOString());
}
/**
* The trace tree (mini-req 2's bar: where it is + how it got there,
* one screen, no JSON literacy required).
*/
export function renderTraceTree(
instance: RenderInstance,
trail: RenderTrailRow[],
nowIso: string = new Date().toISOString(),
): string[] {
const lines: string[] = [];
const name = instance.workflowName ? `${instance.workflowName}${instance.workflowVersion ? ` v${instance.workflowVersion}` : ""}` : "";
lines.push(`${statusGlyph(instance.status)} ${instance.instanceId} ${name} status=${instance.status}${instance.hopCount !== undefined ? ` hops=${instance.hopCount}` : ""}${instance.boundRig ? ` rig=${instance.boundRig}` : ""}`);
if (instance.createdAt) {
lines.push(` created ${instance.createdAt}${instance.createdBySession ? ` by ${instance.createdBySession}` : ""} age ${humanAge(instance.createdAt, nowIso)}`);
}
lines.push("");
lines.push(` ${fitCell("STEP", 22)}${fitCell("ACTOR", 28)}${fitCell("EXIT", 10)}DURATION`);
let prevClosed = instance.createdAt;
for (let i = 0; i < trail.length; i++) {
const row = trail[i];
if (!row) continue;
const glyph = row.closureReason === "failed" ? "✖" : "✔";
const bar = i === trail.length - 1 && !instance.currentStepId ? "└─" : "├─";
const duration = humanDuration(prevClosed, row.closedAt);
lines.push(` ${bar} ${glyph} ${fitCell(row.stepId, 20)}${fitCell(row.actorSession, 28)}${fitCell(row.closureReason, 10)}${duration}`);
const branchTaken = row.closureEvidence?.["branch_taken"];
if (typeof branchTaken === "string" && branchTaken.length > 0) {
lines.push(` │ ↳ branch: ${branchTaken}`);
}
prevClosed = row.closedAt ?? prevClosed;
}
if (instance.currentStepId) {
const owner = ""; // frontier owner is a queue-side fact; the frontier packet ids are what the payload carries
lines.push(` └─ ▸ ${fitCell(instance.currentStepId, 20)}${fitCell(owner || "(current)", 28)}${fitCell("open", 10)}frontier=[${(instance.currentFrontier ?? []).join(", ")}]`);
} else if (trail.length === 0) {
lines.push(` (no steps closed yet)`);
}
return lines;
}
/**
* OPR.0.4.6.WF3 FR-3 — the attention marker for a list row (the
* single marker home). Classes: failed · stuck (consumed VERBATIM
* from the API-carried deadline classification — rail 1: the CLI
* never recomputes a threshold or class) · waiting. A row shows its
* highest-priority class; the `status` verb shows ALL classes per
* instance (exactly-once, combined reasons).
*/
export function attentionMarker(instance: RenderInstance): string {
if (instance.status === "failed") return "▲ failed";
if (isStuck(instance)) return "▲ stuck";
if (instance.status === "waiting") return "▲ waiting";
return "";
}
/** Rail 1: stuck-ness is READ from the API field, never derived. */
function isStuck(instance: RenderInstance): boolean {
const state = instance.deadline?.state;
return typeof state === "string" && state.startsWith("overdue");
}
// ── FR-3 part B: the needs-attention rollup (`rig workflow status`) ──
export interface AttentionRow {
instanceId: string;
workflowName: string;
/** All classes for this instance — exactly-once row, combined. */
classes: string[];
reasons: string[];
/** The actionable affordance (what-to-do-next), per class priority. */
affordance: string;
}
export interface AttentionRollup {
counts: { total: number; active: number; waiting: number; completed: number; failed: number };
attention: AttentionRow[];
}
/**
* Compose the rollup from list rows. Pure arithmetic over
* pre-classified rows (the arch ruling's boundary: counting +
* grouping + rendering is render-side; the ONE correctness-rule
* composition — the threshold classification — arrives ALREADY DONE
* in instance.deadline). Per-instance dedup by instanceId (rail 2):
* an instance with multiple attention reasons renders ONCE with all
* of them.
*/
export function composeAttentionRollup(instances: RenderInstance[]): AttentionRollup {
const counts = { total: instances.length, active: 0, waiting: 0, completed: 0, failed: 0 };
const attention: AttentionRow[] = [];
for (const inst of instances) {
if (inst.status === "active") counts.active += 1;
else if (inst.status === "waiting") counts.waiting += 1;
else if (inst.status === "completed") counts.completed += 1;
else if (inst.status === "failed") counts.failed += 1;
const classes: string[] = [];
const reasons: string[] = [];
if (inst.status === "failed") {
classes.push("failed");
reasons.push("workflow failed");
}
if (isStuck(inst)) {
classes.push("stuck");
const ev = inst.deadline?.evidence;
reasons.push(
`${inst.deadline?.state}${ev?.stepId ? ` at step ${ev.stepId}` : ""}${ev?.ownerSession ? ` (owner ${ev.ownerSession})` : ""}${typeof ev?.overdueBySeconds === "number" ? `, overdue ${humanSeconds(ev.overdueBySeconds)}` : ""}`,
);
}
if (inst.status === "waiting") {
classes.push("waiting");
const blocker = inst.lastContinuationDecision?.blockedOn;
reasons.push(`waiting${blocker ? ` on ${blocker}` : " (blocker unrecorded)"}`);
}
if (classes.length === 0) continue;
// Affordance by highest-priority class. `route` ships in this same
// slice (commit 6) — the verbs land together at merge (BR-1 holds
// at the shipped boundary).
const affordance = classes.includes("failed")
? `inspect: rig workflow trace ${inst.instanceId}`
: classes.includes("stuck")
? `re-route: rig workflow route ${inst.instanceId} --to <seat>`
: `resolve the blocker, then the owner projects (rig workflow trace ${inst.instanceId})`;
attention.push({
instanceId: inst.instanceId,
workflowName: inst.workflowName ?? "",
classes,
reasons,
affordance,
});
}
return { counts, attention };
}
/** Human render of the rollup — proven-empty, never blank. */
export function renderStatus(rollup: AttentionRollup): string[] {
const c = rollup.counts;
const lines: string[] = [];
lines.push(
`${c.total} instance${c.total === 1 ? "" : "s"}: ${c.active} active · ${c.waiting} waiting · ${c.completed} completed · ${c.failed} failed`,
);
if (rollup.attention.length === 0) {
lines.push("No instances need attention (proven empty — every in-flight instance reads healthy).");
return lines;
}
lines.push("");
lines.push(`${fitCell("", 2)}${fitCell("INSTANCE", 30)}${fitCell("WORKFLOW", 20)}${fitCell("CLASS", 16)}REASON`);
for (const row of rollup.attention) {
lines.push(
`${fitCell("▲", 2)}${fitCell(row.instanceId, 30)}${fitCell(row.workflowName, 20)}${fitCell(row.classes.join("+"), 16)}${row.reasons.join("; ")}`,
);
lines.push(` ${fitCell("", 2)}└ ${row.affordance}`);
}
return lines;
}
/** The list table: INSTANCE · WORKFLOW · STATUS · STEP · AGE · ATTN. */
export function renderInstanceList(
instances: RenderInstance[],
nowIso: string = new Date().toISOString(),
): string[] {
if (instances.length === 0) return ["No workflow instances."];
const lines: string[] = [];
lines.push(`${fitCell("", 2)}${fitCell("INSTANCE", 30)}${fitCell("WORKFLOW", 22)}${fitCell("STATUS", 11)}${fitCell("STEP", 20)}${fitCell("AGE", 6)}ATTN`);
for (const inst of instances) {
lines.push(
`${fitCell(statusGlyph(inst.status), 2)}${fitCell(inst.instanceId, 30)}${fitCell(inst.workflowName ?? "", 22)}${fitCell(inst.status, 11)}${fitCell(inst.currentStepId ?? "-", 20)}${fitCell(humanAge(inst.createdAt, nowIso), 6)}${attentionMarker(inst)}`,
);
}
return lines;
}
/** The show summary, headed by the status line. */
export function renderInstanceShow(
instance: RenderInstance,
nowIso: string = new Date().toISOString(),
): string[] {
const lines: string[] = [];
lines.push(`${statusGlyph(instance.status)} ${instance.instanceId} status=${instance.status}`);
if (instance.workflowName) lines.push(` workflow: ${instance.workflowName}${instance.workflowVersion ? ` v${instance.workflowVersion}` : ""}`);
// OPR.0.4.6.FAC1: the bound rig renders when present (unbound rows unchanged).
if (instance.boundRig) lines.push(` rig: ${instance.boundRig}`);
if (instance.createdAt) lines.push(` created: ${instance.createdAt}${instance.createdBySession ? ` by ${instance.createdBySession}` : ""} (age ${humanAge(instance.createdAt, nowIso)})`);
if (instance.currentStepId) lines.push(` at step: ${instance.currentStepId} frontier=[${(instance.currentFrontier ?? []).join(", ")}]`);
if (instance.hopCount !== undefined) lines.push(` hops: ${instance.hopCount}`);
lines.push(` next: rig workflow trace ${instance.instanceId}`);
return lines;
}
+320 -19
View File
@@ -3,6 +3,15 @@ import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl, printDaemonNotRunning } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
import { followInstance } from "./workflow-follow.js";
import {
composeAttentionRollup,
renderInstanceList,
renderInstanceShow,
renderStatus,
renderTraceTree,
} from "./workflow-render.js";
import { describeDaemonRejection, formatThreePart } from "./workflow-errors.js";
/**
* `rig workflow` — daemon-native Workflow Runtime (PL-004 Phase D).
@@ -34,6 +43,17 @@ async function withClient<T>(
function printResult(json: boolean, body: unknown, status: number): void {
if (json) {
console.log(JSON.stringify(body));
} else if (status >= 400) {
// WF3 FR-5: named daemon rejections render the house what/why/fix
// 3-part in human mode; unrecognized bodies keep the raw-JSON
// fallback. --json (above) stays the RAW body byte-identically;
// exit codes below are unchanged.
const rejection = describeDaemonRejection(body);
if (rejection) {
for (const line of formatThreePart(rejection)) process.stderr.write(`${line}\n`);
} else {
console.log(JSON.stringify(body, null, 2));
}
} else {
console.log(JSON.stringify(body, null, 2));
}
@@ -84,6 +104,19 @@ function asString(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
// OPR.0.4.6.FAC1 (arch ruling 2026-07-07): instantiate-time advisories
// (currently the spec-default-target.rig degrade-to-unbound notice) MUST
// be LOUD. Always write to STDERR — even in --json mode the structured
// body already carries `advisories`, but an operator piping stdout to a
// consumer still needs to see the warning, so it lands on stderr in both
// modes. Non-fatal: never sets a non-zero exit code.
export function printWorkflowAdvisories(advisories: string[] | undefined): void {
if (!advisories || advisories.length === 0) return;
for (const line of advisories) {
process.stderr.write(`⚠ workflow advisory: ${line}\n`);
}
}
export function workflowCommand(depsOverride?: WorkflowDeps): Command {
const cmd = new Command("workflow").description(
"Daemon-native Workflow Runtime — declarative spec + transactional-scribe step projection (PL-004 Phase D)",
@@ -117,6 +150,7 @@ Examples:
.requiredOption("--root-objective <text>", "Root objective for the run")
.requiredOption("--created-by <session>", "Session creating the instance (canonical <member>@<rig>)")
.option("--entry-owner <session>", "Override default entry-step owner")
.option("--rig <name>", "Bind the instance to this rig (overrides the spec's target.rig default; roles resolve to seats on this rig)")
.option("--json", "JSON output for agents")
.addHelpText("after", `
Examples:
@@ -133,6 +167,7 @@ Examples:
rootObjective: string;
createdBy: string;
entryOwner?: string;
rig?: string;
json?: boolean;
}) => {
const deps = getDeps();
@@ -142,20 +177,25 @@ Examples:
entryStepId?: string;
entryOwnerSession?: string;
status?: string;
instance?: { boundRig?: string | null };
advisories?: string[];
}>("/api/workflow/instantiate", {
specPath,
rootObjective: opts.rootObjective,
createdBySession: opts.createdBy,
entryOwnerSession: opts.entryOwner,
targetRig: opts.rig,
});
printResult(opts.json ?? false, res.data, res.status);
printWorkflowAdvisories(res.data?.advisories);
const body = res.data ?? {};
const instanceId = asString(body.instanceId) ?? "(no instance id)";
const entryStepId = asString(body.entryStepId);
const owner = asString(body.entryOwnerSession) ?? opts.entryOwner ?? "(default from spec)";
const boundRig = asString(body.instance?.boundRig ?? undefined);
printOutcomeSummary(opts.json ?? false, res.status, {
what: `Instantiated workflow from ${specPath} (instance ${instanceId})`,
state: `${body.status ?? "active"}; entry packet ${entryStepId ?? "pending"} owned by ${owner}`,
state: `${body.status ?? "active"}${boundRig ? `; bound to rig ${boundRig}` : ""}; entry packet ${entryStepId ?? "pending"} owned by ${owner}`,
next: entryStepId
? `Inspect: rig workflow show ${instanceId} | Open packet: rig queue show ${entryStepId}`
: `Inspect: rig workflow show ${instanceId}`,
@@ -271,7 +311,14 @@ Examples:
const qs = params.toString();
await withClient(deps, async (client) => {
const res = await client.get<unknown>(`/api/workflow/list${qs ? `?${qs}` : ""}`);
printResult(opts.json ?? false, res.data, res.status);
// WF3 FR-2: human mode renders the table; --json byte-identical (BR-2).
if (opts.json || res.status >= 400) {
printResult(opts.json ?? false, res.data, res.status);
return;
}
const body = res.data as { instances?: unknown[] } | unknown[];
const rows = (Array.isArray(body) ? body : body?.instances ?? []) as Parameters<typeof renderInstanceList>[0];
for (const line of renderInstanceList(rows)) console.log(line);
});
});
@@ -334,7 +381,12 @@ Examples:
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.get<unknown>(`/api/workflow/${encodeURIComponent(instanceId)}`);
printResult(opts.json ?? false, res.data, res.status);
// WF3 FR-2: human summary headed by the status line; --json byte-identical (BR-2).
if (opts.json || res.status >= 400) {
printResult(opts.json ?? false, res.data, res.status);
return;
}
for (const line of renderInstanceShow(res.data as Parameters<typeof renderInstanceShow>[0])) console.log(line);
});
});
@@ -350,16 +402,39 @@ Examples:
.action(async (instanceId: string, opts: { json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.get<unknown>(`/api/workflow/${encodeURIComponent(instanceId)}/trace`);
printResult(opts.json ?? false, res.data, res.status);
const res = await client.get<{ instance?: unknown; trail?: unknown[] }>(
`/api/workflow/${encodeURIComponent(instanceId)}/trace`,
);
// WF3 FR-2: human mode renders the per-step tree (mini-req 2's
// one-screen bar); --json byte-identical (BR-2).
if (opts.json || res.status >= 400 || !res.data?.instance) {
printResult(opts.json ?? false, res.data, res.status);
return;
}
const instance = res.data.instance as Parameters<typeof renderTraceTree>[0];
const trail = (res.data.trail ?? []) as Parameters<typeof renderTraceTree>[1];
for (const line of renderTraceTree(instance, trail)) console.log(line);
});
});
// OPR.0.4.6.WF1 FR-8 (G6): `continue` RELABELED to its real
// inspector semantics. The wire has been a read-only frontier+trail
// inspector since Phase D v1, but the label said "Mechanically
// advance" and the summary printed "Advanced instance ..." — the
// label-vs-wire lie dies here. A true mechanical advance would mint
// a closure without the owner's truthful exit, violating
// owner-as-author + BR-2 (project is the sole advance write path) —
// arch-endorsed as architecture, not just wording.
cmd
.command("continue <instanceId>")
.description("Mechanically advance an instance after truthful closure (idempotent inspector in v1)")
.description("Inspect an instance's current frontier + step trail (read-only; advancing happens via 'rig workflow project')")
.option("--json", "JSON output for agents")
.addHelpText("after", `
Read-only: reports where the instance is and how it got there so the
frontier owner can continue truthfully. To actually advance, the packet
OWNER closes it via:
$ rig workflow project --instance <id> --current-packet <qitem> --exit <exit> --actor-session <you>
Examples:
$ rig workflow continue WF01ABC
$ rig workflow continue WF01ABC --json
@@ -368,26 +443,252 @@ Examples:
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<{
advanced?: boolean;
nextPacketId?: string;
nextOwnerSession?: string;
instanceStatus?: string;
instance?: { status?: string; currentFrontier?: string[]; currentStepId?: string | null };
trail?: unknown[];
}>(`/api/workflow/${encodeURIComponent(instanceId)}/continue`, {});
printResult(opts.json ?? false, res.data, res.status);
const body = res.data ?? {};
const advanced = body.advanced !== false;
const nextId = asString(body.nextPacketId);
const nextOwner = asString(body.nextOwnerSession) ?? "(unknown)";
const status = body.instanceStatus ?? "(unknown)";
const status = body.instance?.status ?? "(unknown)";
const frontier = body.instance?.currentFrontier ?? [];
const stepId = body.instance?.currentStepId ?? null;
const trailLen = body.trail?.length ?? 0;
printOutcomeSummary(opts.json ?? false, res.status, {
what: advanced
? `Advanced instance ${instanceId}${nextId ? ` to packet ${nextId}` : ""}`
: `No advance for instance ${instanceId} (already at frontier or terminal)`,
state: `instance status = ${status}${nextId ? `; next owner = ${nextOwner}` : ""}`,
next: nextId ? `Inspect: rig queue show ${nextId}` : `Inspect: rig workflow trace ${instanceId}`,
what: `Inspected instance ${instanceId} (read-only; no state changed)`,
state: `status = ${status}; step = ${stepId ?? "(terminal)"}; frontier = [${frontier.join(", ")}]; trail rows = ${trailLen}`,
next: frontier.length > 0
? `The frontier owner advances via: rig workflow project --instance ${instanceId} --current-packet ${frontier[0]} --exit <handoff|waiting|done|failed> --actor-session <owner>`
: `Terminal or empty frontier - see: rig workflow trace ${instanceId}`,
});
});
});
// OPR.0.4.6.WF3 FR-1 — the follow verbs. Two verbs, ONE renderer
// (workflow-follow.ts). BR-1 verb honesty: `run` instantiates AND
// follows; `watch` ONLY watches — neither advances a step (project
// remains the sole advance path). Outcome-as-exit-code by default
// (the kubectl choice): completed=0, workflow-failed=3 (distinct
// from the shipped 1=4xx / 2=5xx transport codes), so
// `rig workflow run … && next-thing` is honest in scripts.
cmd
.command("run <specPath>")
.description("Instantiate a workflow AND follow it live to a terminal state (exit 0 completed / 3 failed)")
.requiredOption("--root-objective <text>", "Root objective for the run")
.requiredOption("--created-by <session>", "Session creating the instance (canonical <member>@<rig>)")
.option("--entry-owner <session>", "Override default entry-step owner")
.option("--rig <name>", "Bind the instance to this rig (overrides the spec's target.rig default; roles resolve to seats on this rig)")
.option("--json", "Stream events as JSON lines for agents")
.addHelpText("after", `
Streams each step event as it happens; exits when the workflow reaches
a terminal state. Exit codes: 0 = completed, 3 = workflow failed,
1/2 = transport errors (4xx/5xx). If the event stream drops, the
command reconnects, then degrades to polling — announced, never a
silent freeze.
Examples:
$ rig workflow run workflows/conveyor.workflow.md \\
--root-objective "Ship it" --created-by orch-lead@my-rig
$ rig workflow run ./spec.yaml --root-objective x --created-by a@b --json
`)
.action(async (specPath: string, opts: {
rootObjective: string;
createdBy: string;
entryOwner?: string;
rig?: string;
json?: boolean;
}) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<{
instanceId?: string;
instance?: { instanceId?: string };
advisories?: string[];
}>("/api/workflow/instantiate", {
specPath,
rootObjective: opts.rootObjective,
createdBySession: opts.createdBy,
entryOwnerSession: opts.entryOwner,
targetRig: opts.rig,
});
printWorkflowAdvisories(res.data?.advisories);
// The daemon returns the nested InstantiateResult shape
// ({instance:{instanceId}}); tolerate a flattened {instanceId}
// too (walk-caught: reading only the flat field made `run`
// print-and-exit-0 without ever following — the same
// flattening confusion as the pre-existing instantiate
// summary polish note from WF-1 rev1-r2).
const instanceId =
asString(res.data?.instance?.instanceId) ?? asString(res.data?.instanceId);
if (res.status >= 400 || !instanceId) {
printResult(opts.json ?? false, res.data, res.status);
return;
}
if (!opts.json) console.log(`● instance ${instanceId} created — following`);
const code = await followInstance(client, instanceId, { json: opts.json ?? false });
if (code !== 0) process.exitCode = code;
});
});
cmd
.command("watch <instanceId>")
.description("Attach to an in-flight instance and follow it live (read-only; exit mirrors the outcome)")
.option("--json", "Stream events as JSON lines for agents")
.addHelpText("after", `
Read-only: renders the instance's current state (snapshot), then
streams live events until a terminal state. Attaching to an already
fast-moving instance is safe — steps that closed before attach render
from the snapshot exactly once. Exit codes: 0 = completed, 3 =
workflow failed, 1/2 = transport errors.
Examples:
$ rig workflow watch WF01ABC
$ rig workflow watch WF01ABC --json
`)
.action(async (instanceId: string, opts: { json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const code = await followInstance(client, instanceId, { json: opts.json ?? false });
if (code !== 0) process.exitCode = code;
});
});
// OPR.0.4.6.WF3 FR-4 — the ONE WF-3 mutation. BR-1: `route` routes;
// it NEVER advances a step (hop count untouched; project remains the
// sole advance path). Exception-lane by usage, not by gate.
cmd
.command("route <instanceId>")
.description("Re-route the current frontier step to a new owner (same step, honest handoff closure; never advances)")
.requiredOption("--to <session>", "New owner session (canonical <member>@<rig>)")
.requiredOption("--actor-session <session>", "Session performing the re-route (recorded as provenance)")
.option("--reason <text>", "Why the step is being re-routed (recorded in the audit trail)")
.option("--json", "JSON output for agents")
.addHelpText("after", `
Use when a step's owner is unresponsive (dead seat, compaction) and the
work should continue from the SAME step under a new owner. The old
owner's stale close attempts are structurally rejected afterwards
(packet_not_on_frontier).
Examples:
$ rig workflow route WF01ABC --to dev2-driver@my-rig \\
--actor-session orch-lead@my-rig --reason "owner seat dead"
`)
.action(async (instanceId: string, opts: {
to: string;
actorSession: string;
reason?: string;
json?: boolean;
}) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<{
stepId?: string | null;
closedPacketId?: string;
newPacketId?: string;
fromSession?: string;
toSession?: string;
instanceStatus?: string;
}>(`/api/workflow/${encodeURIComponent(instanceId)}/route`, {
toSession: opts.to,
actorSession: opts.actorSession,
reason: opts.reason,
});
printResult(opts.json ?? false, res.data, res.status);
const body = res.data ?? {};
printOutcomeSummary(opts.json ?? false, res.status, {
what: `Re-routed step ${body.stepId ?? "?"} from ${body.fromSession ?? "?"} to ${body.toSession ?? opts.to} (packet ${body.closedPacketId ?? "?"} → ${body.newPacketId ?? "?"})`,
state: `instance ${instanceId} = ${body.instanceStatus ?? "active"}; same step, new owner; no step advanced`,
next: `The new owner advances via: rig workflow project --instance ${instanceId} --current-packet ${body.newPacketId ?? "<packet>"} --exit <exit> --actor-session ${opts.to}`,
});
});
});
// OPR.0.4.6.WF5 FR-4 — resume: redrive a FAILED instance from the
// failed step (BR-1 verb honesty: resume re-drives, it never advances
// a step itself and never re-runs completed steps; waiting instances
// resume via the shipped project path, not this verb).
cmd
.command("resume <instanceId>")
.description("Redrive a FAILED instance from its failed step (completed steps never re-run; one fresh max_hops window)")
.requiredOption("--actor-session <session>", "Session performing the resume (recorded as provenance)")
.option("--decision <text>", "Durable instruction for the step owner (lands in the redrive packet)")
.option("--json", "JSON output for agents")
.addHelpText("after", `
Use after diagnosing an exception item: the instance returns to active,
REBOUND to the step that failed, with a fresh packet routed to that
step's re-resolved owner. The trail is preserved and extended; the
resolved exception occurrence closes; a NEW failure of the same step
raises a NEW occurrence honestly.
Examples:
$ rig workflow resume WF01ABC --actor-session orch-lead@my-rig \\
--decision "flaky fixture fixed in commit abc123 — retry"
`)
.action(async (instanceId: string, opts: {
actorSession: string;
decision?: string;
json?: boolean;
}) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<{
stepId?: string;
newPacketId?: string;
ownerSession?: string;
resumeCount?: number;
exceptionItemsClosed?: number;
}>(`/api/workflow/${encodeURIComponent(instanceId)}/resume`, {
actorSession: opts.actorSession,
decision: opts.decision,
});
printResult(opts.json ?? false, res.data, res.status);
const body = res.data ?? {};
printOutcomeSummary(opts.json ?? false, res.status, {
what: `Redrove instance ${instanceId} from step ${body.stepId ?? "?"} (redrive #${body.resumeCount ?? "?"}; packet ${body.newPacketId ?? "?"} → ${body.ownerSession ?? "?"}; ${body.exceptionItemsClosed ?? 0} exception item(s) resolved)`,
state: `instance ${instanceId} = active, rebound to ${body.stepId ?? "?"}; completed steps untouched; fresh max_hops window`,
next: `The owner advances via: rig workflow project --instance ${instanceId} --current-packet ${body.newPacketId ?? "<packet>"} --exit <exit> --actor-session ${body.ownerSession ?? "<owner>"}`,
});
});
});
// OPR.0.4.6.WF3 FR-3 part B — the needs-attention rollup. CLI-SIDE
// composition per the arch ruling (Rev-4 rails): consumes the
// API-carried instance.deadline classification + instance.status
// from the SHIPPED read surface; counting/grouping/rendering only —
// NO threshold or class is ever computed here, NO daemon route was
// added (the WF-4-era web UI adds a daemon rollup endpoint under its
// own authorization when it needs one; this verb is not the
// permanent home of that composition).
cmd
.command("status")
.description("Which instances need attention: counts + one row per failed/stuck/waiting instance with reason + next action (read-only)")
.option("--json", "Rollup as JSON for agents")
.addHelpText("after", `
Answers "what needs me" (list answers "what exists"). Every
attention-worthy instance appears exactly once with ALL its classes
(failed / stuck / waiting) and the actionable next step. A clean fleet
renders the proven-empty statement with counts — never a blank.
Examples:
$ rig workflow status
$ rig workflow status --json | jq '.attention[] | {instanceId, classes}'
`)
.action(async (opts: { json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.get<unknown>("/api/workflow/list");
if (res.status >= 400) {
printResult(opts.json ?? false, res.data, res.status);
return;
}
const body = res.data as { instances?: unknown[] } | unknown[];
const rows = (Array.isArray(body) ? body : body?.instances ?? []) as Parameters<typeof composeAttentionRollup>[0];
const rollup = composeAttentionRollup(rows);
if (opts.json) {
console.log(JSON.stringify(rollup));
return;
}
for (const line of renderStatus(rollup)) console.log(line);
});
});
return cmd;
}
+122
View File
@@ -14,6 +14,9 @@ import {
export interface RiggedConfig {
daemon: { port: number; host: string };
// OPR.0.4.6.MH1 FR-1 — the persisted host-selection pointer.
// OPR.0.4.6.MH1 FR-4 — the own-host display name (default "localhost").
host: { selected: string; name: string };
db: { path: string };
transcripts: {
enabled: boolean;
@@ -123,6 +126,22 @@ export interface RiggedConfig {
retentionKeep: number;
};
};
// OPR.0.4.6.02 S1 — inner-tmux status-bar default at session launch.
// Static boolean (default off), lockstep with the daemon settings-store
// twin. Consumed by the daemon NodeLauncher; the CLI carries it for the
// `rig config get/set terminal.status_bar` surface + twin parity.
terminal: {
statusBar: boolean;
};
// OPR.0.4.6.FS-1 W2 — queue-retention maintenance knobs (twin of the daemon
// settings-store): enabled + the four bounded numeric tunables.
retention: {
enabled: boolean;
transitionsDays: number;
watchdogDays: number;
watchdogKeepPerJob: number;
batchSize: number;
};
}
const DEFAULT_WORKSPACE_ROOT = getDefaultOpenRigPath("workspace");
@@ -144,6 +163,9 @@ const DEFAULT_CLAUDE_COMPACTION_EXTRA_INSTRUCTION_FILE_PATH = getDefaultOpenRigP
const DEFAULTS = {
daemon: { port: 7433, host: "127.0.0.1" },
// OPR.0.4.6.MH1 FR-1 — "local" ≡ no remote selection (LOCAL_HOST_ID).
// FR-4 — own-host display name; default "localhost" (PRD-named).
host: { selected: "local", name: "localhost" },
db: { path: getDefaultOpenRigPath("openrig.sqlite") },
transcripts: { enabled: true, path: getDefaultOpenRigPath("transcripts"), lines: 1000, pollIntervalSeconds: 2 },
workspace: {
@@ -217,11 +239,41 @@ const DEFAULTS = {
retentionKeep: 10,
},
},
// OPR.0.4.6.02 S1 — inner-tmux status bar OFF at launch by default.
terminal: {
statusBar: false,
},
// OPR.0.4.6.FS-1 W2 — retention defaults (twin of daemon getDefaultValue).
retention: {
enabled: true,
transitionsDays: 30,
watchdogDays: 14,
watchdogKeepPerJob: 50,
batchSize: 500,
},
} as const;
export const VALID_KEYS = [
"daemon.port",
"daemon.host",
// OPR.0.4.6.MH1 FR-1 — the persisted host-selection pointer (the
// kubectl current-context shape). ONE static key, lockstep with the
// daemon settings-store twin (parity test pins both). Registry-
// membership validation of the VALUE happens at the `rig host select`
// verb layer (the store stays value-agnostic); the daemon config
// write is the one write path — the CLI verb is a thin client.
// Default "local" (unset ≡ the local host — the FR-2 zero-regression
// posture by construction).
"host.selected",
// OPR.0.4.6.MH1 FR-4 — the own-host display name (arch Ruling 1: home =
// the settings twins, never hosts.yaml). One stored name, every surface
// reads it (dashboard/explorer/ls/whoami). Write path = the daemon
// config write via `rig host rename` (thin client, same as select).
"host.name",
// OPR.0.4.6.WF5 FR-2 — the host-level maturity-dial default (lockstep
// with the daemon settings twin). "orchestrator" | "human_only";
// unset ≡ orchestrator-first.
"workflow.exception_routing",
"db.path",
"transcripts.enabled",
"transcripts.path",
@@ -270,6 +322,17 @@ export const VALID_KEYS = [
"snapshots.periodic.enabled",
"snapshots.periodic.interval_seconds",
"snapshots.periodic.retention_keep",
// OPR.0.4.6.02 S1 — inner-tmux status-bar launch default. ONE static
// boolean (default off), lockstep with the daemon settings-store twin
// (the parity test pins both). Flip is future-launches-only (BR-1).
"terminal.status_bar",
// OPR.0.4.6.FS-1 W2 — queue-retention maintenance knobs; CLI-settable twin,
// lockstep with the daemon settings-store SETTINGS_VALID_KEYS.
"retention.enabled",
"retention.transitions_days",
"retention.watchdog_days",
"retention.watchdog_keep_per_job",
"retention.batch_size",
] as const;
export type ValidKey = typeof VALID_KEYS[number];
@@ -278,6 +341,11 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
// Only the original runtime keys keep RIGGED_* aliases for upgrade
// compatibility. New typed keys use OPENRIG_* only.
"daemon.port": { primary: "OPENRIG_PORT", legacy: "RIGGED_PORT" },
// OPR.0.4.6.MH1 FR-1/FR-4 — new keys, OPENRIG_* only (no RIGGED_* legacy).
"host.selected": { primary: "OPENRIG_HOST_SELECTED" },
"host.name": { primary: "OPENRIG_HOST_NAME" },
// OPR.0.4.6.WF5 FR-2 — new key, OPENRIG_* only.
"workflow.exception_routing": { primary: "OPENRIG_WORKFLOW_EXCEPTION_ROUTING" },
"daemon.host": { primary: "OPENRIG_HOST", legacy: "RIGGED_HOST" },
"db.path": { primary: "OPENRIG_DB", legacy: "RIGGED_DB" },
"transcripts.enabled": { primary: "OPENRIG_TRANSCRIPTS_ENABLED", legacy: "RIGGED_TRANSCRIPTS_ENABLED" },
@@ -323,6 +391,14 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
"snapshots.periodic.enabled": { primary: "OPENRIG_SNAPSHOTS_PERIODIC_ENABLED" },
"snapshots.periodic.interval_seconds": { primary: "OPENRIG_SNAPSHOTS_PERIODIC_INTERVAL_SECONDS" },
"snapshots.periodic.retention_keep": { primary: "OPENRIG_SNAPSHOTS_PERIODIC_RETENTION_KEEP" },
// OPR.0.4.6.02 S1 — net-new key; OPENRIG_* primary only (no RIGGED_* legacy).
"terminal.status_bar": { primary: "OPENRIG_TERMINAL_STATUS_BAR" },
// OPR.0.4.6.FS-1 W2 — retention knobs; net-new keys, OPENRIG_* primary only.
"retention.enabled": { primary: "OPENRIG_RETENTION_ENABLED" },
"retention.transitions_days": { primary: "OPENRIG_RETENTION_TRANSITIONS_DAYS" },
"retention.watchdog_days": { primary: "OPENRIG_RETENTION_WATCHDOG_DAYS" },
"retention.watchdog_keep_per_job": { primary: "OPENRIG_RETENTION_WATCHDOG_KEEP_PER_JOB" },
"retention.batch_size": { primary: "OPENRIG_RETENTION_BATCH_SIZE" },
};
// Maps dotted-string config keys to the camelCase RiggedConfig path.
@@ -331,6 +407,9 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
// to match TS conventions.
const KEY_TO_PATH: Record<ValidKey, string[]> = {
"daemon.port": ["daemon", "port"],
"host.selected": ["host", "selected"],
"host.name": ["host", "name"],
"workflow.exception_routing": ["workflow", "exceptionRouting"],
"daemon.host": ["daemon", "host"],
"db.path": ["db", "path"],
"transcripts.enabled": ["transcripts", "enabled"],
@@ -369,6 +448,12 @@ const KEY_TO_PATH: Record<ValidKey, string[]> = {
"snapshots.periodic.enabled": ["snapshots", "periodic", "enabled"],
"snapshots.periodic.interval_seconds": ["snapshots", "periodic", "intervalSeconds"],
"snapshots.periodic.retention_keep": ["snapshots", "periodic", "retentionKeep"],
"terminal.status_bar": ["terminal", "statusBar"],
"retention.enabled": ["retention", "enabled"],
"retention.transitions_days": ["retention", "transitionsDays"],
"retention.watchdog_days": ["retention", "watchdogDays"],
"retention.watchdog_keep_per_job": ["retention", "watchdogKeepPerJob"],
"retention.batch_size": ["retention", "batchSize"],
};
function isValidKey(key: string): key is ValidKey {
@@ -551,6 +636,29 @@ const KEY_CONSTRAINTS: Partial<Record<ValidKey, (raw: string, coerced: string |
);
}
},
// OPR.0.4.6.FS-1 W2 — retention numeric bounds. Lockstep with the daemon
// settings-store KEY_CONSTRAINTS twin (same messages/bounds). retention.enabled
// is a boolean (coerceValue enforces true/false) — no constraint entry.
"retention.transitions_days": (raw, coerced) => {
if (!/^-?\d+$/.test((raw ?? "").trim()) || typeof coerced !== "number" || !Number.isInteger(coerced) || coerced < 1) {
throw new Error(`Invalid value for retention.transitions_days: must be an integer >= 1, got "${raw}"`);
}
},
"retention.watchdog_days": (raw, coerced) => {
if (!/^-?\d+$/.test((raw ?? "").trim()) || typeof coerced !== "number" || !Number.isInteger(coerced) || coerced < 1) {
throw new Error(`Invalid value for retention.watchdog_days: must be an integer >= 1, got "${raw}"`);
}
},
"retention.watchdog_keep_per_job": (raw, coerced) => {
if (!/^-?\d+$/.test((raw ?? "").trim()) || typeof coerced !== "number" || !Number.isInteger(coerced) || coerced < 0) {
throw new Error(`Invalid value for retention.watchdog_keep_per_job: must be an integer >= 0, got "${raw}"`);
}
},
"retention.batch_size": (raw, coerced) => {
if (!/^-?\d+$/.test((raw ?? "").trim()) || typeof coerced !== "number" || !Number.isInteger(coerced) || coerced < 1) {
throw new Error(`Invalid value for retention.batch_size: must be an integer >= 1, got "${raw}"`);
}
},
};
function validateKeyConstraints(key: ValidKey, raw: string, coerced: string | number | boolean): void {
@@ -612,6 +720,10 @@ export class ConfigStore {
port: v("daemon.port") as number,
host: v("daemon.host") as string,
},
host: {
selected: v("host.selected") as string,
name: v("host.name") as string,
},
db: {
path: v("db.path") as string,
},
@@ -683,6 +795,16 @@ export class ConfigStore {
retentionKeep: v("snapshots.periodic.retention_keep") as number,
},
},
terminal: {
statusBar: v("terminal.status_bar") as boolean,
},
retention: {
enabled: v("retention.enabled") as boolean,
transitionsDays: v("retention.transitions_days") as number,
watchdogDays: v("retention.watchdog_days") as number,
watchdogKeepPerJob: v("retention.watchdog_keep_per_job") as number,
batchSize: v("retention.batch_size") as number,
},
};
}
@@ -6,6 +6,38 @@ import type { CrossHostResult } from "./cross-host-executor.js";
* `capture.ts` (and any future v1 cross-host command) format identically.
*/
/**
* OPR.0.4.6.MH4 — structured failure surface for the http transport branch.
* Each branch names its OWN step taxonomy: the http branch surfaces
* `runRemoteHttpOp`'s steps (registry/unknown-host/permission-gate/
* remote-daemon-unreachable/remote-command-failed) with the host named and
* the remote route's own error text as the detail — never a generic
* "failed", never a locally-invented message.
*/
export function emitRemoteHttpFailure(
hostId: string,
target: string,
result: { failedStep: string; error?: string; data?: unknown },
json?: boolean,
hint?: string,
): void {
const remoteError = (result.data as { error?: string } | undefined)?.error;
const detail = [result.error, remoteError].filter(Boolean).join(" — ");
if (json) {
console.log(JSON.stringify({
ok: false,
cross_host: { host: hostId, target, transport: "http" },
failedStep: result.failedStep,
error: detail,
...(hint ? { hint } : {}),
}));
} else {
console.error(`cross-host (host=${hostId}, ${target}): http ${result.failedStep}: ${detail}`);
if (hint) console.error(`hint: ${hint}`);
}
process.exitCode = 1;
}
export function emitCrossHostError(hostId: string, code: string, message: string, json?: boolean): void {
if (json) {
console.log(JSON.stringify({ ok: false, cross_host: { host: hostId }, failedStep: code, error: message }));
+94
View File
@@ -0,0 +1,94 @@
import { loadHostRegistry, resolveHost } from "./host-registry.js";
/**
* OPR.0.4.6.MH4 §4 — the host-qualified TARGET sugar + precedence contract,
* uniform across the cross-host coordination verbs (send / capture /
* transcript; broadcast has NO session-target operand — its positional is
* message text, which must never be sugar-parsed — so it takes `--host` +
* the persisted selection only).
*
* Sugar parse rule (CLI edge ONLY — BR-1: the daemon never sees a 3-part
* session string either way):
* - A target of the form `X@Y@Z` is host-qualified IFF `Z` matches a
* REGISTERED host id after registry load; then target=`X@Y`, host=`Z`.
* - If `Z` matches no registered host (or the registry cannot load), the
* string passes through UNCHANGED — it fails exactly as today
* (adopted/raw session names containing `@` keep working). The returned
* `hint` is appended to the eventual failure surface so a mistyped
* 3-part form always dies LOUD with the host named (never a silent
* fallthrough). This is deliberately DIFFERENT from MH-3's queue rule
* (canonical-only destinations always-strip post-classifier); the
* per-verb-class split is documented in cli-reference.md.
* - Reserved ids (kernel/host/local) can never be registered
* (RESERVED_HOST_IDS), so `@kernel`/`@host` human-seat forms can never
* be captured by the sugar.
*
* Precedence (the caller composes with the persisted selection):
* explicit `--host` > target sugar > persisted selection
* (`resolveEffectiveHost`). `--host X` + sugar `@Y` where X≠Y is a
* structured conflict — never a silent precedence pick. The same host
* named twice is fine.
*/
export interface CrossHostTargetResolution {
ok: true;
/** The target with any matched host qualifier stripped. */
target: string;
/** The sugar-derived host id (registered suffix), if any. */
sugarHost: string | undefined;
/**
* Loud-failure hint when the target was 3-part-SHAPED but the suffix
* matched no registered host. Callers append it to failure surfaces for
* this target; it never changes behavior on success paths.
*/
hint: string | undefined;
}
export interface CrossHostTargetConflict {
ok: false;
error: string;
}
export function resolveCrossHostTarget(
rawTarget: string,
explicitHost: string | undefined,
registryLoader?: () => ReturnType<typeof loadHostRegistry>,
): CrossHostTargetResolution | CrossHostTargetConflict {
const atCount = rawTarget.split("@").length - 1;
if (atCount < 2) {
return { ok: true, target: rawTarget, sugarHost: undefined, hint: undefined };
}
const lastAt = rawTarget.lastIndexOf("@");
const base = rawTarget.slice(0, lastAt);
const suffix = rawTarget.slice(lastAt + 1);
const unregisteredHint = suffix.length > 0
? `no registered host '${suffix}' — if '${suffix}' was meant as a host, check \`rig host ls\``
: undefined;
if (suffix.length === 0 || base.length === 0) {
return { ok: true, target: rawTarget, sugarHost: undefined, hint: unregisteredHint };
}
const loader = registryLoader ?? loadHostRegistry;
const registry = loader();
if (!registry.ok) {
// No registry = no registered suffix can match; pass through unchanged
// (the plain-target behavior must not gain a new failure mode).
return { ok: true, target: rawTarget, sugarHost: undefined, hint: unregisteredHint };
}
const resolved = resolveHost(registry.registry, suffix);
if (!resolved.ok) {
return { ok: true, target: rawTarget, sugarHost: undefined, hint: unregisteredHint };
}
if (explicitHost !== undefined && explicitHost !== suffix) {
return {
ok: false,
error: `ambiguous host: --host ${explicitHost} conflicts with the target's host qualifier @${suffix} — name one host`,
};
}
return { ok: true, target: base, sugarHost: suffix, hint: undefined };
}
+33
View File
@@ -77,6 +77,17 @@ export type HostResolution =
| { ok: true; host: HostEntry }
| { ok: false; error: string };
// OPR.0.4.6.MH1 FR-7 — reserved host ids (the cheap collision guard,
// arch rail 4): `kernel` and `host` are lexically claimed by the
// human-seat regex family (`@(kernel|host)$` — a host id reusing them
// would make host-qualified surfaces ambiguous with human-seat
// classification), and `local` is the shipped LOCAL_HOST_ID constant
// (a registered remote named "local" would shadow the local host in
// every selection/fan-out surface). Rejected at add/pair AND surfaced
// as a load-time finding on pre-existing files (fail loud, never
// silent). Mirrored verbatim in the daemon reader twin (parity test).
export const RESERVED_HOST_IDS = new Set(["kernel", "host", "local"]);
const KNOWN_TRANSPORTS = new Set(["ssh", "http"]);
export function defaultHostRegistryPath(): string {
@@ -144,6 +155,21 @@ export function validateHostRegistry(parsed: unknown, sourcePath: string): HostR
if (seenIds.has(id)) {
return { ok: false, error: `${prefix}.id: duplicate host id '${id}' (each host id must be unique within the registry)` };
}
if (RESERVED_HOST_IDS.has(id)) {
return {
ok: false,
error: `${prefix}.id: '${id}' is a reserved host id (reserved set: ${[...RESERVED_HOST_IDS].sort().join(", ")}). 'kernel' and 'host' collide with human-seat session classification (@kernel/@host), and 'local' is the local host itself — pick a different id.`,
};
}
// OPR.0.4.6.MH1 rev1-r2 B1 — host ids name FILES (the pair verb's
// bearer_file path embeds the id) and render in tables: path-bearing
// ids are rejected at the registry door, same home as reserved ids.
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(id)) {
return {
ok: false,
error: `${prefix}.id: '${id}' is not a valid host id — allowed: letters, digits, dot, underscore, dash (starting with a letter or digit). Host ids name credential files; path characters are not allowed.`,
};
}
seenIds.add(id);
const transport = entry["transport"];
if (typeof transport !== "string" || !KNOWN_TRANSPORTS.has(transport)) {
@@ -243,6 +269,13 @@ export type AddHostResult =
| { ok: true; path: string; entry: HostEntry }
| { ok: false; error: string };
/** OPR.0.4.6.MH1 (arch P3/P4): the CLI half of the ONE registry write
* contract — the daemon twin (packages/daemon/src/domain/hosts/
* hosts-registry-writer.ts) mirrors this verbatim, byte-parity-pinned by
* test. Concurrency ceiling (P4): atomic tmp+rename, whole-file
* LAST-WRITE-WINS on a concurrent add — one operator-scale registry
* file, NO locking machinery by design; a dropped concurrent entry
* re-converges by re-running the add. */
export function addHostEntry(rawEntry: Record<string, unknown>, path: string = defaultHostRegistryPath()): AddHostResult {
// Load what exists; a MISSING file is a valid starting point for `add`
// (the verb exists so operators never hand-create the YAML), but a present-
+54
View File
@@ -0,0 +1,54 @@
// OPR.0.4.6.MH1 FR-1/FR-2 — the persisted host-selection read + the
// selected-host routing shim.
//
// WRITE path: `rig host select` → daemon `POST /api/config/host.selected`
// (the one write path — the CLI verb is a thin client; arch FR-1 ruling).
// READ path (this module): the local ConfigStore resolution
// (env OPENRIG_HOST_SELECTED > ~/.openrig/config.json > default "local")
// — the daemon writes the SAME config.json the CLI reads, so reads cost
// zero daemon lookups (the FR-2 zero-regression posture: with no
// selection ever made, every command's behavior is byte-identical to
// pre-MH1 — "local" resolves to undefined and no new code path runs).
//
// Precedence at a command: explicit `--host` > selection context > local.
// Commands with their OWN remote semantics guard the shim themselves:
// ps suppresses it under `--all-hosts`/`--hosts` (fan-out is explicit
// scope), up suppresses it for topology sources (per-entry `host:` is
// the ONLY topology placement mechanism — shipped 0.4.4 rule).
import { ConfigStore } from "./config-store.js";
/** The persisted selection ("local" ≡ no remote selection). Never throws:
* a malformed config file falls back to "local" (read paths must not
* break). */
export function readSelectedHost(): string {
try {
const resolved = new ConfigStore().resolve() as unknown as { host?: { selected?: string } };
const v = resolved.host?.selected;
return typeof v === "string" && v.trim() !== "" ? v : "local";
} catch {
return "local";
}
}
/** FR-2: the effective host for a command that already supports `--host`.
* Explicit flag wins; else the persisted selection (when not "local");
* else undefined (= today's local path, untouched). */
export function resolveEffectiveHost(explicitHost: string | undefined): string | undefined {
if (explicitHost) return explicitHost;
const selected = readSelectedHost();
return selected === "local" ? undefined : selected;
}
/** OPR.0.4.6.MH1 FR-4 — the own-host display name (default "localhost";
* arch Ruling 1: home = the settings twins). Same read discipline as
* readSelectedHost: local ConfigStore, never throws. */
export function readOwnHostName(): string {
try {
const resolved = new ConfigStore().resolve() as unknown as { host?: { name?: string } };
const v = resolved.host?.name;
return typeof v === "string" && v.trim() !== "" ? v : "localhost";
} catch {
return "localhost";
}
}
+3
View File
@@ -34,6 +34,7 @@ import { streamCommand, type StreamDeps } from "./commands/stream.js";
import { queueCommand, type QueueDeps } from "./commands/queue.js";
import { projectCommand, type ProjectDeps } from "./commands/project.js";
import { viewCommand, type ViewDeps } from "./commands/view.js";
import { terminalCommand, type TerminalDeps } from "./commands/terminal.js";
import { watchdogCommand, type WatchdogDeps } from "./commands/watchdog.js";
import { workflowCommand, type WorkflowDeps } from "./commands/workflow.js";
import { startCommand, type StartDeps } from "./commands/start.js";
@@ -107,6 +108,7 @@ export interface ProgramDeps {
queueDeps?: QueueDeps;
projectDeps?: ProjectDeps;
viewDeps?: ViewDeps;
terminalDeps?: TerminalDeps;
watchdogDeps?: WatchdogDeps;
workflowDeps?: WorkflowDeps;
captureDeps?: StatusDeps;
@@ -182,6 +184,7 @@ export function createProgram(depsOverride?: ProgramDeps): Command {
program.addCommand(queueCommand(depsOverride?.queueDeps));
program.addCommand(projectCommand(depsOverride?.projectDeps));
program.addCommand(viewCommand(depsOverride?.viewDeps));
program.addCommand(terminalCommand(depsOverride?.terminalDeps));
program.addCommand(watchdogCommand(depsOverride?.watchdogDeps));
program.addCommand(workflowCommand(depsOverride?.workflowDeps));
program.addCommand(captureCommand(depsOverride?.captureDeps));
+10 -4
View File
@@ -20,7 +20,9 @@ export async function runRemoteHttpOp(
apiPath: string,
body: unknown | undefined,
deps: RemoteHostDeps,
opts: { json?: boolean },
// OPR.0.4.6.MH4 — optional per-call deadline (additive; absent = the
// DaemonClient default). Every remote call site names its own budget.
opts: { json?: boolean; timeoutMs?: number },
): Promise<RemoteOpResult> {
const loader = deps.hostRegistryLoader ?? loadHostRegistry;
const registry = loader();
@@ -45,15 +47,19 @@ export async function runRemoteHttpOp(
const client = deps.clientFactory(httpHost.url);
const headers = { Authorization: `Bearer ${bearerResult.token}` };
const requestOptions = opts.timeoutMs !== undefined ? { headers, timeoutMs: opts.timeoutMs } : { headers };
try {
const res = method === "POST"
? await client.post<unknown>(apiPath, body, { headers })
: await client.get<unknown>(apiPath, { headers });
? await client.post<unknown>(apiPath, body, requestOptions)
: await client.get<unknown>(apiPath, requestOptions);
const failedStep = classifyHttpFailedStep(res.status);
if (failedStep !== "none") {
return { ok: false, failedStep, error: `HTTP ${res.status}` };
// Carry the origin response body on failures too (additive): the
// remote route's own error text is the honest detail a caller
// surfaces beside the step class.
return { ok: false, failedStep, error: `HTTP ${res.status}`, data: res.data };
}
return { ok: true, failedStep: "none", data: res.data };
} catch (err) {
+70
View File
@@ -0,0 +1,70 @@
// ============================================================================
// OPR.0.4.6.MH1 FR-8 — THE session-name parse contract (parity-pinned).
//
// CANONICAL HOME: packages/daemon/src/domain/session-name.ts. The CLI and UI
// carry identical copies of this block (packages/cli/src/session-name.ts,
// packages/ui/src/lib/session-name.ts) because workspaces do not
// cross-import; the shared-vector parity test
// (packages/daemon/test/session-name-parity.test.ts) runs ONE vector set
// across all three copies — a divergence there means a copy drifted (the
// hosts-registry twin discipline; arch B2 ruling). Edit the daemon copy
// first, then mirror verbatim.
//
// SEMANTICS (pinned to the queue-destination gate, the archetype site):
// - member = everything before the FIRST "@" (non-empty); rig = everything
// after it (non-empty, MAY contain further "@"s). The greedy rig is
// load-bearing: "member@rig@x" parses as rig "rig@x", so the registry
// lookup misses and the queue gate rejects with the SAME
// unknown_destination_rig error as before this contract existed — host
// NEVER rides in-band in the session string (BR-1).
// - Human-seat refs (human@kernel / human-<x>@host) are CLASSIFIED BEFORE
// any parse wherever the distinction matters: call isHumanSeatSessionRef
// first, as the queue gate does.
// - Legacy flat-rig names (r{NN}-suffix) remain valid NON-canonical names;
// they carry no rig binding.
// - Exactly ONE structured malformed shape ("malformed_session_name") —
// no site invents its own.
// - parseSessionName does NOT trim and does NOT validate the character set
// (validateSessionName owns chars); callers that trimmed before this
// contract keep trimming — the migration is behavior-identical for every
// accepted string.
// ============================================================================
export type ParsedSessionName =
| { kind: "canonical"; member: string; rig: string }
| { kind: "legacy"; name: string }
| { kind: "malformed"; error: "malformed_session_name"; input: string };
const HUMAN_SEAT_SESSION_REF_PATTERN = /^human(?:-[A-Za-z0-9._-]+)?@(kernel|host)$/;
const LEGACY_FLAT_SESSION_PATTERN = /^r\d{2}-.+$/;
/** Arch tooth 1: human-seat classification runs BEFORE any parse at sites
* where the human/agent distinction matters (the queue-destination gate). */
export function isHumanSeatSessionRef(sessionRef: string): boolean {
return HUMAN_SEAT_SESSION_REF_PATTERN.test(sessionRef);
}
export function parseSessionName(raw: string): ParsedSessionName {
const at = raw.indexOf("@");
if (at > 0 && at < raw.length - 1) {
return { kind: "canonical", member: raw.slice(0, at), rig: raw.slice(at + 1) };
}
if (at === -1 && LEGACY_FLAT_SESSION_PATTERN.test(raw)) {
return { kind: "legacy", name: raw };
}
return { kind: "malformed", error: "malformed_session_name", input: raw };
}
/** Display: the member/local leg — text before the first "@", or the whole
* string when no "@" is present (the shipped split("@")[0] rendering,
* verbatim: "@rig" yields ""). */
export function sessionMemberLabel(session: string): string {
const at = session.indexOf("@");
return at === -1 ? session : session.slice(0, at);
}
/** Routing/grouping: the rig leg of a canonical name, else undefined. */
export function sessionRigOf(session: string): string | undefined {
const parsed = parseSessionName(session);
return parsed.kind === "canonical" ? parsed.rig : undefined;
}
@@ -81,7 +81,13 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
it("VALID_KEYS includes legacy, user-settings, ui.preview, recovery, agents, feed, and transcript-rotation keys", () => {
const expected = [
"daemon.port", "daemon.host", "db.path",
"daemon.port", "daemon.host",
// OPR.0.4.6.MH1 FR-1/FR-4 — host-selection pointer + own-host name.
"host.selected",
"host.name",
// OPR.0.4.6.WF5 FR-2 — the host-level maturity-dial default.
"workflow.exception_routing",
"db.path",
"transcripts.enabled", "transcripts.path",
// V1 pre-release CLI/daemon Item 1 — capture-pane rotation tunables.
"transcripts.lines", "transcripts.poll_interval_seconds",
@@ -117,6 +123,14 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
"snapshots.periodic.enabled",
"snapshots.periodic.interval_seconds",
"snapshots.periodic.retention_keep",
// OPR.0.4.6.02 S1 — inner-tmux status-bar launch default (static bool).
"terminal.status_bar",
// OPR.0.4.6.FS-1 W2 — queue-retention maintenance knobs.
"retention.enabled",
"retention.transitions_days",
"retention.watchdog_days",
"retention.watchdog_keep_per_job",
"retention.batch_size",
];
expect([...VALID_KEYS]).toEqual(expected);
});
+550
View File
@@ -0,0 +1,550 @@
// OPR.0.4.6.MH4 C4 — the http transport branch + §4 target-sugar/precedence
// matrix for the four cross-host coordination verbs. Everything runs through
// the shipped depsOverride injection (mock clientFactory + hostRegistryLoader
// + crossHostRun) — no daemon, no tmux, no network, no real ~/.openrig.
//
// The LOCAL zero-regression half lives in the existing suites (send.test.ts,
// capture.test.ts, transcript.test.ts, broadcast.test.ts run the local paths
// against a real local server) and in cross-host-commands.test.ts (the ssh
// argv byte-identity) — both untouched by this slice and both must stay
// green; this file covers the NET-NEW http branch, the sugar, and the
// precedence contract.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { sendCommand, type SendDeps } from "../src/commands/send.js";
import { captureCommand, type CaptureDeps } from "../src/commands/capture.js";
import { transcriptCommand, type TranscriptDeps } from "../src/commands/transcript.js";
import { broadcastCommand, type BroadcastDeps } from "../src/commands/broadcast.js";
import { resolveCrossHostTarget } from "../src/cross-host-target.js";
import type { CrossHostResult } from "../src/cross-host-executor.js";
import type { HostRegistryLoadResult } from "../src/host-registry.js";
interface CapturedOutput {
stdoutLines: string[];
stderrLines: string[];
}
let captured: CapturedOutput;
let originalLog: typeof console.log;
let originalError: typeof console.error;
beforeEach(() => {
captured = { stdoutLines: [], stderrLines: [] };
originalLog = console.log;
originalError = console.error;
console.log = (...args: unknown[]) => { captured.stdoutLines.push(args.map(String).join(" ")); };
console.error = (...args: unknown[]) => { captured.stderrLines.push(args.map(String).join(" ")); };
process.exitCode = undefined;
delete process.env.OPENRIG_HOST_SELECTED;
});
afterEach(() => {
console.log = originalLog;
console.error = originalError;
// Assertions read the LIVE process.exitCode inside test bodies (the run-1
// preflight lesson: a copy taken here is invisible to in-body assertions
// and made the exit-code checks vacuous). This hook only resets state.
process.exitCode = undefined;
delete process.env.OPENRIG_HOST_SELECTED;
});
// A registry with BOTH transports + a second http host for precedence cases.
const REGISTRY: HostRegistryLoadResult = {
ok: true,
registry: {
hosts: [
{ id: "vps-b", transport: "http", url: "http://vps-b:7433", bearer_env: "MH4_TEST_BEARER" },
{ id: "vps-c", transport: "http", url: "http://vps-c:7433", bearer_env: "MH4_TEST_BEARER" },
{ id: "vm-ssh", transport: "ssh", target: "vm-ssh.local" },
],
},
};
process.env["MH4_TEST_BEARER"] = "test-token";
interface RecordedCall {
url: string;
method: "GET" | "POST";
path: string;
body?: unknown;
options?: { timeoutMs?: number; headers?: Record<string, string> };
}
interface MockClientHarness {
calls: RecordedCall[];
clientFactory: (url: string) => never;
}
function mockClient(respond: (call: RecordedCall) => { status: number; data: unknown } | Error): MockClientHarness {
const calls: RecordedCall[] = [];
const clientFactory = (url: string) => ({
post: async (path: string, body?: unknown, options?: RecordedCall["options"]) => {
const call: RecordedCall = { url, method: "POST", path, body, options };
calls.push(call);
const res = respond(call);
if (res instanceof Error) throw res;
return res;
},
get: async (path: string, options?: RecordedCall["options"]) => {
const call: RecordedCall = { url, method: "GET", path, options };
calls.push(call);
const res = respond(call);
if (res instanceof Error) throw res;
return res;
},
}) as never;
return { calls, clientFactory };
}
const sshRunnerNeverCalled = vi.fn(async (): Promise<CrossHostResult> => {
throw new Error("ssh runner must not be invoked for an http host");
});
function httpDeps(harness: MockClientHarness, overrides: Partial<SendDeps & CaptureDeps & TranscriptDeps & BroadcastDeps> = {}): SendDeps & CaptureDeps & TranscriptDeps & BroadcastDeps {
return {
lifecycleDeps: {} as never,
clientFactory: harness.clientFactory,
hostRegistryLoader: () => REGISTRY,
crossHostRun: sshRunnerNeverCalled as never,
...overrides,
};
}
// ---------------------------------------------------------------------------
// §4 sugar/precedence — the pure helper contract
// ---------------------------------------------------------------------------
describe("resolveCrossHostTarget (§4 sugar + precedence)", () => {
const loader = () => REGISTRY;
it("plain 2-part target: untouched, no sugar, no hint, registry never loaded", () => {
const spy = vi.fn(loader);
const r = resolveCrossHostTarget("dev-impl@my-rig", undefined, spy);
expect(r).toEqual({ ok: true, target: "dev-impl@my-rig", sugarHost: undefined, hint: undefined });
expect(spy).not.toHaveBeenCalled();
});
it("3-part with REGISTERED suffix: host-qualified — target stripped, host extracted", () => {
const r = resolveCrossHostTarget("dev-impl@my-rig@vps-b", undefined, loader);
expect(r).toEqual({ ok: true, target: "dev-impl@my-rig", sugarHost: "vps-b", hint: undefined });
});
it("3-part with UNREGISTERED suffix: passes through UNCHANGED with the loud hint", () => {
const r = resolveCrossHostTarget("dev-impl@my-rig@nope", undefined, loader);
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.target).toBe("dev-impl@my-rig@nope");
expect(r.sugarHost).toBeUndefined();
expect(r.hint).toContain("no registered host 'nope'");
expect(r.hint).toContain("rig host ls");
}
});
it("registry load failure: passthrough + hint (a plain-target failure mode is never added)", () => {
const r = resolveCrossHostTarget("a@b@vps-b", undefined, () => ({ ok: false, error: "no file" }));
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.target).toBe("a@b@vps-b");
expect(r.sugarHost).toBeUndefined();
expect(r.hint).toContain("vps-b");
}
});
it("--host X + sugar @Y, X≠Y: structured conflict", () => {
const r = resolveCrossHostTarget("dev-impl@my-rig@vps-b", "vps-c", loader);
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error).toContain("ambiguous host");
expect(r.error).toContain("vps-c");
expect(r.error).toContain("@vps-b");
}
});
it("--host X + sugar @X (same host twice): fine", () => {
const r = resolveCrossHostTarget("dev-impl@my-rig@vps-b", "vps-b", loader);
expect(r).toEqual({ ok: true, target: "dev-impl@my-rig", sugarHost: "vps-b", hint: undefined });
});
it("adopted/raw 4-part-ish names keep working: only the LAST @ segment is a host candidate", () => {
const r = resolveCrossHostTarget("weird@name@extra@vps-b", undefined, loader);
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.target).toBe("weird@name@extra");
expect(r.sugarHost).toBe("vps-b");
}
});
});
// ---------------------------------------------------------------------------
// send — the http branch
// ---------------------------------------------------------------------------
describe("send --host (http branch)", () => {
it("http host: POSTs the LOCAL body shape (wrapped envelope) to /api/transport/send; ssh runner never invoked; BR-1: session stays 2-part", async () => {
const h = mockClient(() => ({ status: 200, data: { ok: true } }));
const cmd = sendCommand(httpDeps(h));
await cmd.parseAsync(["--host", "vps-b", "dev-impl@my-rig", "hello"], { from: "user" });
expect(sshRunnerNeverCalled).not.toHaveBeenCalled();
expect(h.calls.length).toBe(1);
const call = h.calls[0]!;
expect(call.url).toBe("http://vps-b:7433");
expect(call.method).toBe("POST");
expect(call.path).toBe("/api/transport/send");
const body = call.body as Record<string, unknown>;
expect(body.session).toBe("dev-impl@my-rig"); // BR-1: never member@rig@host
expect(String(body.text)).toContain("To: dev-impl@my-rig");
expect(String(body.text)).toContain("hello");
expect(call.options?.headers?.Authorization).toBe("Bearer test-token");
expect(captured.stdoutLines).toContain("[via host=vps-b (http://vps-b:7433)]");
expect(captured.stdoutLines).toContain("Sent to dev-impl@my-rig");
expect(process.exitCode).toBeUndefined();
});
it("--raw skips the envelope: exact text passthrough", async () => {
const h = mockClient(() => ({ status: 200, data: {} }));
const cmd = sendCommand(httpDeps(h));
await cmd.parseAsync(["--host", "vps-b", "dev-impl@my-rig", "/compact", "--raw"], { from: "user" });
const body = h.calls[0]!.body as Record<string, unknown>;
expect(body.text).toBe("/compact");
});
it("sugar target dev-impl@my-rig@vps-b routes http with the STRIPPED session", async () => {
const h = mockClient(() => ({ status: 200, data: {} }));
const cmd = sendCommand(httpDeps(h));
await cmd.parseAsync(["dev-impl@my-rig@vps-b", "hello"], { from: "user" });
expect(h.calls.length).toBe(1);
expect((h.calls[0]!.body as Record<string, unknown>).session).toBe("dev-impl@my-rig");
expect(h.calls[0]!.url).toBe("http://vps-b:7433");
});
it("--host conflict with a DIFFERENT sugar host: structured error, zero remote calls", async () => {
const h = mockClient(() => ({ status: 200, data: {} }));
const cmd = sendCommand(httpDeps(h));
await cmd.parseAsync(["--host", "vps-c", "dev-impl@my-rig@vps-b", "hello"], { from: "user" });
expect(h.calls.length).toBe(0);
expect(captured.stderrLines.join("\n")).toContain("ambiguous host");
expect(process.exitCode).toBe(1);
});
it("persisted selection drives the http branch when no --host and no sugar", async () => {
process.env.OPENRIG_HOST_SELECTED = "vps-b";
const h = mockClient(() => ({ status: 200, data: {} }));
const cmd = sendCommand(httpDeps(h));
await cmd.parseAsync(["dev-impl@my-rig", "hello"], { from: "user" });
expect(h.calls.length).toBe(1);
expect(h.calls[0]!.url).toBe("http://vps-b:7433");
});
it("explicit --host beats the persisted selection", async () => {
process.env.OPENRIG_HOST_SELECTED = "vps-b";
const h = mockClient(() => ({ status: 200, data: {} }));
const cmd = sendCommand(httpDeps(h));
await cmd.parseAsync(["--host", "vps-c", "dev-impl@my-rig", "hello"], { from: "user" });
expect(h.calls[0]!.url).toBe("http://vps-c:7433");
});
it("sugar beats the persisted selection", async () => {
process.env.OPENRIG_HOST_SELECTED = "vps-c";
const h = mockClient(() => ({ status: 200, data: {} }));
const cmd = sendCommand(httpDeps(h));
await cmd.parseAsync(["dev-impl@my-rig@vps-b", "hello"], { from: "user" });
expect(h.calls[0]!.url).toBe("http://vps-b:7433");
});
it("--verify prints the REMOTE verdict verbatim (remote-authoritative, never locally synthesized)", async () => {
const h = mockClient(() => ({ status: 200, data: { verified: true, outcome: "delivered" } }));
const cmd = sendCommand(httpDeps(h));
await cmd.parseAsync(["--host", "vps-b", "dev-impl@my-rig", "hello", "--verify"], { from: "user" });
expect(captured.stdoutLines).toContain("Verified: yes");
expect(captured.stdoutLines).toContain("Delivery: delivered (message landed; render confirmed)");
expect((h.calls[0]!.body as Record<string, unknown>).verify).toBe(true);
});
it("--verify with no remote verdict field prints Verified: no (no local invention)", async () => {
const h = mockClient(() => ({ status: 200, data: {} }));
const cmd = sendCommand(httpDeps(h));
await cmd.parseAsync(["--host", "vps-b", "dev-impl@my-rig", "hello", "--verify"], { from: "user" });
expect(captured.stdoutLines).toContain("Verified: no");
});
it("remote advisory (unknown actorSession) surfaces as the non-blocking Advisory line", async () => {
const h = mockClient(() => ({ status: 200, data: { warning: "actor session unknown on this host" } }));
const cmd = sendCommand(httpDeps(h));
await cmd.parseAsync(["--host", "vps-b", "dev-impl@my-rig", "hello"], { from: "user" });
expect(captured.stdoutLines.some((l) => l.startsWith("Advisory:"))).toBe(true);
expect(process.exitCode).toBeUndefined();
});
it("G8 auth seam: a 401 surfaces as the structured permission-gate step with the remote's own error text", async () => {
const h = mockClient(() => ({ status: 401, data: { error: "terminal bearer mismatch" } }));
const cmd = sendCommand(httpDeps(h));
await cmd.parseAsync(["--host", "vps-b", "dev-impl@my-rig", "hello"], { from: "user" });
const err = captured.stderrLines.join("\n");
expect(err).toContain("permission-gate");
expect(err).toContain("terminal bearer mismatch");
expect(err).toContain("host=vps-b");
expect(process.exitCode).toBe(1);
});
it("network failure surfaces as remote-daemon-unreachable, host named, never a hang", async () => {
const h = mockClient(() => new Error("connect ECONNREFUSED"));
const cmd = sendCommand(httpDeps(h));
await cmd.parseAsync(["--host", "vps-b", "dev-impl@my-rig", "hello"], { from: "user" });
const err = captured.stderrLines.join("\n");
expect(err).toContain("remote-daemon-unreachable");
expect(err).toContain("host=vps-b");
expect(process.exitCode).toBe(1);
});
it("--wait-for-idle sizes the http deadline: waitForIdleMs + overhead", async () => {
const h = mockClient(() => ({ status: 200, data: {} }));
const cmd = sendCommand(httpDeps(h));
await cmd.parseAsync(["--host", "vps-b", "dev-impl@my-rig", "hello", "--wait-for-idle", "30"], { from: "user" });
expect(h.calls[0]!.options?.timeoutMs).toBe(30_000 + 5_000);
expect((h.calls[0]!.body as Record<string, unknown>).waitForIdleMs).toBe(30_000);
});
it("fan-out×host guard kept verbatim: --host + --rig rejected before any call", async () => {
const h = mockClient(() => ({ status: 200, data: {} }));
const cmd = sendCommand(httpDeps(h));
await cmd.parseAsync(["--host", "vps-b", "--rig", "my-rig", "hello"], { from: "user" });
expect(h.calls.length).toBe(0);
expect(captured.stderrLines.join("\n")).toContain("single-seat sends only");
expect(process.exitCode).toBe(1);
});
it("unknown --host: structured unknown-host with the sugar hint appended when the target was 3-part-shaped", async () => {
const h = mockClient(() => ({ status: 200, data: {} }));
const cmd = sendCommand(httpDeps(h));
await cmd.parseAsync(["--host", "ghost", "dev-impl@my-rig@nope", "hello"], { from: "user" });
expect(h.calls.length).toBe(0);
const err = captured.stderrLines.join("\n");
expect(err).toContain("host=ghost");
expect(err).toContain("no registered host 'nope'");
expect(process.exitCode).toBe(1);
});
it("json envelope: cross_host {host, target, transport:http} + the raw result", async () => {
const h = mockClient(() => ({ status: 200, data: { verified: true } }));
const cmd = sendCommand(httpDeps(h));
await cmd.parseAsync(["--host", "vps-b", "dev-impl@my-rig", "hello", "--json"], { from: "user" });
const parsed = JSON.parse(captured.stdoutLines[0]!) as Record<string, any>;
expect(parsed.cross_host).toEqual({ host: "vps-b", target: "http://vps-b:7433", transport: "http" });
expect(parsed.result.ok).toBe(true);
expect(parsed.result.data.verified).toBe(true);
});
});
// ---------------------------------------------------------------------------
// capture — the http branch
// ---------------------------------------------------------------------------
describe("capture --host (http branch)", () => {
it("http host: POSTs the LOCAL body shape to /api/transport/capture and renders the single result under the banner", async () => {
const h = mockClient(() => ({ status: 200, data: { content: "pane content here" } }));
const cmd = captureCommand(httpDeps(h));
await cmd.parseAsync(["--host", "vps-b", "dev-impl@my-rig", "--lines", "50"], { from: "user" });
expect(sshRunnerNeverCalled).not.toHaveBeenCalled();
const call = h.calls[0]!;
expect(call.path).toBe("/api/transport/capture");
expect(call.body).toEqual({ lines: 50, session: "dev-impl@my-rig" });
expect(captured.stdoutLines).toContain("[via host=vps-b (http://vps-b:7433)]");
expect(captured.stdoutLines).toContain("pane content here");
});
it("multi-target (--rig/--pod) rides the http branch with per-target rendering", async () => {
const h = mockClient(() => ({
status: 200,
data: { results: [
{ sessionName: "a@r", ok: true, content: "aaa" },
{ sessionName: "b@r", ok: false, error: "no pane" },
] },
}));
const cmd = captureCommand(httpDeps(h));
await cmd.parseAsync(["--host", "vps-b", "--rig", "r"], { from: "user" });
expect((h.calls[0]!.body as Record<string, unknown>).rig).toBe("r");
expect(captured.stdoutLines).toContain("--- a@r ---");
expect(captured.stdoutLines).toContain("aaa");
expect(captured.stdoutLines.join("\n")).toContain("no pane");
});
it("sugar target routes http with the stripped session (BR-1)", async () => {
const h = mockClient(() => ({ status: 200, data: { content: "x" } }));
const cmd = captureCommand(httpDeps(h));
await cmd.parseAsync(["dev-impl@my-rig@vps-b"], { from: "user" });
expect((h.calls[0]!.body as Record<string, unknown>).session).toBe("dev-impl@my-rig");
});
it("ssh host still takes the ssh shell-out (branch selection)", async () => {
const sshCalls: { argv?: readonly string[] } = {};
const h = mockClient(() => ({ status: 200, data: {} }));
const cmd = captureCommand(httpDeps(h, {
crossHostRun: (async (_hh: never, argv: readonly string[]) => {
sshCalls.argv = argv;
return { ok: true, failedStep: "none", stdout: "remote pane\n", stderr: "", remoteExitCode: 0 } as CrossHostResult;
}) as never,
}));
await cmd.parseAsync(["--host", "vm-ssh", "dev-impl@my-rig"], { from: "user" });
expect(h.calls.length).toBe(0); // http client never touched for an ssh host
expect(sshCalls.argv).toEqual(["rig", "capture", "dev-impl@my-rig", "--lines", "20"]);
});
});
// ---------------------------------------------------------------------------
// transcript — net-new cross-host observe (http-only)
// ---------------------------------------------------------------------------
describe("transcript --host (net-new, CLI-direct GET)", () => {
it("tail: GETs the exact local route path against the remote and renders origin shape verbatim", async () => {
const h = mockClient(() => ({ status: 200, data: { content: "line1\nline2" } }));
const cmd = transcriptCommand(httpDeps(h));
await cmd.parseAsync(["--host", "vps-b", "dev-impl@my-rig", "--tail", "100"], { from: "user" });
const call = h.calls[0]!;
expect(call.method).toBe("GET");
expect(call.path).toBe("/api/transcripts/dev-impl%40my-rig/tail?lines=100");
expect(captured.stdoutLines).toContain("[via host=vps-b (http://vps-b:7433)]");
expect(captured.stdoutLines).toContain("line1");
expect(captured.stdoutLines).toContain("line2");
});
it("grep: GETs the grep route and renders matches", async () => {
const h = mockClient(() => ({ status: 200, data: { matches: ["hit one", "hit two"] } }));
const cmd = transcriptCommand(httpDeps(h));
await cmd.parseAsync(["dev-impl@my-rig@vps-b", "--grep", "hit"], { from: "user" });
expect(h.calls[0]!.path).toBe("/api/transcripts/dev-impl%40my-rig/grep?pattern=hit");
expect(captured.stdoutLines).toContain("hit one");
expect(captured.stdoutLines).toContain("hit two");
});
it("ssh host: the structured transport-requirement error (http-only verb, never silent)", async () => {
const h = mockClient(() => ({ status: 200, data: {} }));
const cmd = transcriptCommand(httpDeps(h));
await cmd.parseAsync(["--host", "vm-ssh", "dev-impl@my-rig"], { from: "user" });
expect(h.calls.length).toBe(0);
const err = captured.stderrLines.join("\n");
expect(err).toContain("SSH transport");
expect(err).toContain("host=vm-ssh");
expect(process.exitCode).toBe(1);
});
it("unknown host: the same unknown-host step class as the other verbs", async () => {
const h = mockClient(() => ({ status: 200, data: {} }));
const cmd = transcriptCommand(httpDeps(h));
await cmd.parseAsync(["--host", "ghost", "dev-impl@my-rig"], { from: "user" });
expect(h.calls.length).toBe(0);
expect(captured.stderrLines.join("\n")).toContain("host=ghost");
expect(process.exitCode).toBe(1);
});
it("selection-driven cross-host works for the net-new verb too", async () => {
process.env.OPENRIG_HOST_SELECTED = "vps-b";
const h = mockClient(() => ({ status: 200, data: { content: "x" } }));
const cmd = transcriptCommand(httpDeps(h));
await cmd.parseAsync(["dev-impl@my-rig", "--tail", "10"], { from: "user" });
expect(h.calls.length).toBe(1);
expect(h.calls[0]!.url).toBe("http://vps-b:7433");
});
it("arch n2: the transcript read is UNGATED — it SUCCEEDS under the exact wrong-terminal-bearer scenario that permission-gates send", async () => {
// ONE remote posture, two verbs: /api/transport/* gates on the remote's
// terminal bearer (401 when it differs); /api/transcripts/* is the
// shipped ungated read route and serves the same caller fine. The proof
// matrix must never treat the auth-fail class as uniform across verbs.
const wrongBearerRemote = (call: RecordedCall) =>
call.path.startsWith("/api/transport/")
? { status: 401, data: { error: "terminal bearer mismatch" } }
: { status: 200, data: { content: "remote transcript line" } };
// send: permission-gate.
const hSend = mockClient(wrongBearerRemote);
await sendCommand(httpDeps(hSend)).parseAsync(["--host", "vps-b", "dev-impl@my-rig", "hello"], { from: "user" });
expect(captured.stderrLines.join("\n")).toContain("permission-gate");
expect(process.exitCode).toBe(1);
// transcript against the SAME remote: succeeds, renders, exit clean —
// and never becomes permission-gate.
process.exitCode = undefined;
captured.stdoutLines = [];
captured.stderrLines = [];
const hTranscript = mockClient(wrongBearerRemote);
await transcriptCommand(httpDeps(hTranscript)).parseAsync(["--host", "vps-b", "dev-impl@my-rig", "--tail", "5"], { from: "user" });
expect(captured.stdoutLines).toContain("remote transcript line");
expect(captured.stderrLines.join("\n")).not.toContain("permission-gate");
expect(process.exitCode).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// broadcast — net-new cross-host coordinate (http-only; --host + selection, NO sugar)
// ---------------------------------------------------------------------------
describe("broadcast --host (net-new, CLI-direct POST)", () => {
it("http host: POSTs the LOCAL body verbatim; per-target results print verbatim; clean fan-out exits 0", async () => {
const h = mockClient(() => ({
status: 200,
data: { results: [
{ sessionName: "a@remote-rig", ok: true },
{ sessionName: "b@remote-rig", ok: true },
], sent: 2, total: 2, failed: 0 },
}));
const cmd = broadcastCommand(httpDeps(h));
await cmd.parseAsync(["--host", "vps-b", "--rig", "remote-rig", "coordinate"], { from: "user" });
const call = h.calls[0]!;
expect(call.path).toBe("/api/transport/broadcast");
expect(call.body).toEqual({ text: "coordinate", force: undefined, rig: "remote-rig" });
expect(captured.stdoutLines).toContain("[via host=vps-b (http://vps-b:7433)]");
expect(captured.stdoutLines).toContain("a@remote-rig: sent");
expect(captured.stdoutLines).toContain("2/2 delivered");
expect(process.exitCode).toBeUndefined();
});
it("partial fan-out: per-target honesty verbatim + NON-ZERO exit (never summarized clean)", async () => {
const h = mockClient(() => ({
status: 200,
data: { results: [
{ sessionName: "a@r", ok: true },
{ sessionName: "b@r", ok: false, error: "seat dead" },
], sent: 1, total: 2, failed: 1 },
}));
const cmd = broadcastCommand(httpDeps(h));
await cmd.parseAsync(["--host", "vps-b", "--rig", "r", "msg"], { from: "user" });
expect(captured.stdoutLines.join("\n")).toContain("b@r: FAILED — seat dead");
expect(captured.stdoutLines).toContain("1/2 delivered");
expect(process.exitCode).toBe(1);
});
it("names its OWN fan-out budget at the call site (not the read-class default)", async () => {
const h = mockClient(() => ({ status: 200, data: { results: [], sent: 0, total: 0, failed: 0 } }));
const cmd = broadcastCommand(httpDeps(h));
await cmd.parseAsync(["--host", "vps-b", "msg"], { from: "user" });
expect(h.calls[0]!.options?.timeoutMs).toBe(30_000);
});
it("the text positional is NEVER sugar-parsed: a message containing a@b@vps-b broadcasts locally intact when no host is set", async () => {
// No --host, no selection: the local path runs. lifecycleDeps is a stub,
// so the local path fails on daemon status — the assertion is that the
// REMOTE client was never called and the message was not reinterpreted.
const h = mockClient(() => ({ status: 200, data: {} }));
const cmd = broadcastCommand(httpDeps(h, { lifecycleDeps: { getStatus: async () => ({ state: "stopped" }) } as never }));
await cmd.parseAsync(["ping a@b@vps-b please"], { from: "user" }).catch(() => undefined);
expect(h.calls.length).toBe(0);
});
it("ssh host: structured transport-requirement error (http-only verb)", async () => {
const h = mockClient(() => ({ status: 200, data: {} }));
const cmd = broadcastCommand(httpDeps(h));
await cmd.parseAsync(["--host", "vm-ssh", "--rig", "r", "msg"], { from: "user" });
expect(h.calls.length).toBe(0);
expect(captured.stderrLines.join("\n")).toContain("SSH transport");
expect(process.exitCode).toBe(1);
});
it("selection-driven cross-host broadcast", async () => {
process.env.OPENRIG_HOST_SELECTED = "vps-b";
const h = mockClient(() => ({ status: 200, data: { results: [], sent: 0, total: 0, failed: 0 } }));
const cmd = broadcastCommand(httpDeps(h));
await cmd.parseAsync(["--rig", "r", "msg"], { from: "user" });
expect(h.calls.length).toBe(1);
expect(h.calls[0]!.url).toBe("http://vps-b:7433");
});
});
+245
View File
@@ -0,0 +1,245 @@
// OPR.0.4.6.MH1 FR-6 — rig host pair <url>: the founder-simple add path.
// One pasted address, one approval on the target, done — and the
// nothing-persists guarantee on every failure leg.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { Command } from "commander";
import { hostCommand, type DoctorDeps } from "../src/commands/host.js";
import { addHostEntry } from "../src/host-registry.js";
describe("rig host pair (OPR.0.4.6.MH1 FR-6)", () => {
let dir: string;
let savedHome: string | undefined;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), "hostpair-"));
savedHome = process.env.OPENRIG_HOME;
process.env.OPENRIG_HOME = dir;
});
afterEach(() => {
if (savedHome === undefined) delete process.env.OPENRIG_HOME;
else process.env.OPENRIG_HOME = savedHome;
fs.rmSync(dir, { recursive: true, force: true });
});
function capture(fn: () => Promise<void>): Promise<{ out: string[]; err: string[]; exitCode: number | undefined }> {
return new Promise(async (resolve) => {
const out: string[] = []; const err: string[] = [];
const ol = console.log; const oe = console.error; const oc = process.exitCode;
process.exitCode = undefined;
console.log = (...a: unknown[]) => out.push(a.join(" "));
console.error = (...a: unknown[]) => err.push(a.join(" "));
try { await fn(); } finally { console.log = ol; console.error = oe; }
const exitCode = process.exitCode as number | undefined;
process.exitCode = oc;
resolve({ out, err, exitCode });
});
}
function fakeDeps(opts: {
post?: { status: number; body: unknown };
poll: Array<{ status: number; body: unknown }>;
}): DoctorDeps & { postCalls: () => number } {
let polls = 0;
let posts = 0;
return {
run: async () => ({ ok: true, stdout: "", stderr: "", exitCode: 0 }) as never,
httpGet: async () => {
const r = opts.poll[Math.min(polls, opts.poll.length - 1)]!;
polls += 1;
return { status: r.status, body: JSON.stringify(r.body) };
},
httpPost: async () => {
posts += 1;
const r = opts.post ?? { status: 200, body: { pairId: "p-1", code: "424242", approvalQitemId: "qitem-1" } };
return { status: r.status, body: JSON.stringify(r.body) };
},
tcpProbe: async () => "open" as const,
postCalls: () => posts,
};
}
function run(deps: DoctorDeps, argv: string[]) {
const prog = new Command();
prog.exitOverride();
prog.addCommand(hostCommand(deps));
return prog.parseAsync(["node", "rig", "host", ...argv]);
}
it("approved walk: registry entry with bearer_file + a 0600 token file; code printed", async () => {
const deps = fakeDeps({ poll: [{ status: 200, body: { status: "approved", token: "issued-bearer" } }] });
const { out, exitCode } = await capture(() => run(deps, ["pair", "vps-a:7433", "--id", "vps-a", "--timeout", "10"]));
expect(exitCode).toBeUndefined();
expect(out.join("\n")).toContain("Pairing code: 424242");
expect(out.join("\n")).toContain("Paired. Host 'vps-a' registered");
const tokenPath = path.join(dir, "secrets", "host-vps-a.token");
expect(fs.readFileSync(tokenPath, "utf8")).toBe("issued-bearer\n");
expect(fs.statSync(tokenPath).mode & 0o777).toBe(0o600);
const yaml = fs.readFileSync(path.join(dir, "hosts.yaml"), "utf8");
expect(yaml).toContain("id: vps-a");
expect(yaml).toContain(`bearer_file: ${tokenPath}`);
expect(yaml).not.toContain("issued-bearer");
}, 15_000);
it("denied: loud error, exit 1, NOTHING persists", async () => {
const deps = fakeDeps({ poll: [{ status: 200, body: { status: "denied" } }] });
const { err, exitCode } = await capture(() => run(deps, ["pair", "http://vps-a:7433", "--timeout", "10"]));
expect(exitCode).toBe(1);
expect(err.join("\n")).toContain("DENIED");
expect(err.join("\n")).toContain("Nothing was persisted");
expect(fs.existsSync(path.join(dir, "hosts.yaml"))).toBe(false);
expect(fs.existsSync(path.join(dir, "secrets"))).toBe(false);
}, 15_000);
it("a tokenless target's structured refusal is surfaced verbatim", async () => {
const deps = fakeDeps({
post: { status: 409, body: { error: "pair_target_no_bearer", message: "this daemon runs without OPENRIG_AUTH_BEARER_TOKEN; pairing has no credential to issue." } },
poll: [],
});
const { err, exitCode } = await capture(() => run(deps, ["pair", "vps-a:7433"]));
expect(exitCode).toBe(1);
expect(err.join("\n")).toContain("OPENRIG_AUTH_BEARER_TOKEN");
expect(fs.existsSync(path.join(dir, "hosts.yaml"))).toBe(false);
});
it("registry-write failure AFTER approval removes the token file THIS request created (preflight/add race leg)", async () => {
// The preflight passes on an empty registry; a conflicting entry then
// lands DURING the approval wait (the TOCTOU window preflight cannot
// close — addHostEntry stays authoritative). The cleanup must remove
// only the token file this request created, and the racing entry's
// registry state must survive untouched.
const deps = fakeDeps({ poll: [{ status: 200, body: { status: "approved", token: "issued-bearer" } }] });
const origGet = deps.httpGet;
let raced = false;
deps.httpGet = async (url, headers) => {
if (!raced) {
raced = true;
expect(addHostEntry({ id: "vps-a", transport: "http", url: "http://racer:7433", bearer_env: "RACER_TOKEN" }).ok).toBe(true);
}
return origGet(url, headers);
};
const { err, exitCode } = await capture(() => run(deps, ["pair", "vps-a:7433", "--id", "vps-a", "--timeout", "10"]));
expect(exitCode).toBe(1);
expect(err.join("\n")).toContain("nothing persisted");
const yaml = fs.readFileSync(path.join(dir, "hosts.yaml"), "utf8");
expect(yaml).toContain("http://racer:7433");
expect(yaml).not.toContain("issued-bearer");
expect(fs.existsSync(path.join(dir, "secrets", "host-vps-a.token"))).toBe(false);
}, 15_000);
// B1 fixback (guard code-review 2026-07-07): failed pairs must never
// clobber/delete pre-existing credential or registry state, and must
// fail BEFORE the target is contacted.
it("B1: duplicate-id re-pair fails at PREFLIGHT — registry bytes, token contents and 0600 mode preserved; target NEVER contacted", async () => {
const secretsDir = path.join(dir, "secrets");
const tokenPath = path.join(secretsDir, "host-vps-a.token");
fs.mkdirSync(secretsDir, { recursive: true, mode: 0o700 });
fs.writeFileSync(tokenPath, "live-credential\n", { mode: 0o600 });
expect(addHostEntry({ id: "vps-a", transport: "http", url: "http://old-target:7433", bearer_file: tokenPath }).ok).toBe(true);
const yamlBefore = fs.readFileSync(path.join(dir, "hosts.yaml"), "utf8");
const deps = fakeDeps({ poll: [] });
const { err, exitCode } = await capture(() => run(deps, ["pair", "vps-a:7433", "--id", "vps-a"]));
expect(exitCode).toBe(1);
expect(err.join("\n")).toContain("duplicate host id");
expect(deps.postCalls()).toBe(0);
expect(fs.readFileSync(path.join(dir, "hosts.yaml"), "utf8")).toBe(yamlBefore);
expect(fs.readFileSync(tokenPath, "utf8")).toBe("live-credential\n");
expect(fs.statSync(tokenPath).mode & 0o777).toBe(0o600);
});
it("B1: a pre-existing token file at the derived path rejects the pair before target contact — file untouched", async () => {
const secretsDir = path.join(dir, "secrets");
const tokenPath = path.join(secretsDir, "host-vps-a.token");
fs.mkdirSync(secretsDir, { recursive: true, mode: 0o700 });
fs.writeFileSync(tokenPath, "stale-but-not-ours-to-delete\n", { mode: 0o600 });
const deps = fakeDeps({ poll: [] });
const { err, exitCode } = await capture(() => run(deps, ["pair", "vps-a:7433"]));
expect(exitCode).toBe(1);
expect(err.join("\n")).toContain("never overwritten");
expect(deps.postCalls()).toBe(0);
expect(fs.readFileSync(tokenPath, "utf8")).toBe("stale-but-not-ours-to-delete\n");
expect(fs.statSync(tokenPath).mode & 0o777).toBe(0o600);
expect(fs.existsSync(path.join(dir, "hosts.yaml"))).toBe(false);
});
it("empty-ls hint teaches pair FIRST (FR-6 front-door AC)", async () => {
const deps = fakeDeps({ poll: [] });
const { out } = await capture(() => run(deps, ["list"]));
const text = out.join("\n");
expect(text).toContain("rig host pair <url>");
expect(text.indexOf("rig host pair")).toBeLessThan(text.indexOf("rig host add"));
});
// rev1-r1 D: the client-side TIMEOUT walk — deny was walked end-to-end,
// timeout only server-side; this pins the client's nothing-persists leg.
it("R1-D: approval timeout on the client persists NOTHING (no registry entry, no token file)", async () => {
const deps = fakeDeps({ poll: [{ status: 200, body: { status: "pending", code: "424242" } }] });
const { err, exitCode } = await capture(() => run(deps, ["pair", "vps-a:7433", "--id", "vps-a", "--timeout", "1"]));
expect(exitCode).toBe(1);
expect(err.join("\n")).toContain("no approval arrived before the timeout");
expect(err.join("\n")).toContain("Nothing was persisted");
expect(fs.existsSync(path.join(dir, "hosts.yaml"))).toBe(false);
expect(fs.existsSync(path.join(dir, "secrets"))).toBe(false);
}, 15_000);
// rev1-r2 B1: path-bearing ids die at the registry-door preflight —
// they must never reach the token-path join, let alone the target.
it("B1 (rev1-r2): a path-bearing --id is rejected at preflight; target never contacted, nothing written", async () => {
const deps = fakeDeps({ poll: [] });
const { err, exitCode } = await capture(() => run(deps, ["pair", "vps-a:7433", "--id", "../escape"]));
expect(exitCode).toBe(1);
expect(err.join("\n")).toContain("not a valid host id");
expect(deps.postCalls()).toBe(0);
expect(fs.existsSync(path.join(dir, "secrets"))).toBe(false);
expect(fs.existsSync(path.join(dir, "hosts.yaml"))).toBe(false);
});
// rev1-r2 B3: the token write is exclusive-create ("wx") — a file that
// appears DURING the approval wait is never overwritten or deleted.
it("B3 (rev1-r2): a token file appearing during the approval wait is refused — contents preserved, nothing else persists", async () => {
const tokenPath = path.join(dir, "secrets", "host-vps-a.token");
const deps = fakeDeps({ poll: [{ status: 200, body: { status: "approved", token: "issued-bearer" } }] });
const origGet = deps.httpGet;
let planted = false;
deps.httpGet = async (url, headers) => {
if (!planted) {
planted = true;
fs.mkdirSync(path.dirname(tokenPath), { recursive: true, mode: 0o700 });
fs.writeFileSync(tokenPath, "winner-credential\n", { mode: 0o600 });
}
return origGet(url, headers);
};
const { err, exitCode } = await capture(() => run(deps, ["pair", "vps-a:7433", "--id", "vps-a", "--timeout", "10"]));
expect(exitCode).toBe(1);
expect(err.join("\n")).toContain("refusing to overwrite");
expect(fs.readFileSync(tokenPath, "utf8")).toBe("winner-credential\n");
expect(fs.existsSync(path.join(dir, "hosts.yaml"))).toBe(false);
}, 15_000);
// QA2 fixback: the PRD/proof contract spells the verb `rig host ls` —
// the alias must resolve to the SAME list behavior, --json included.
it("rig host ls is a working alias of list (PRD FR-3 contract spelling), --json preserved", async () => {
const deps = fakeDeps({ poll: [{ status: 200, body: { status: "approved", token: "issued-bearer" } }] });
await capture(() => run(deps, ["pair", "vps-a:7433", "--id", "vps-a", "--timeout", "10"]));
const human = await capture(() => run(fakeDeps({ poll: [] }), ["ls"]));
expect(human.exitCode).toBeUndefined();
expect(human.out.join("\n")).toContain("vps-a");
const json = await capture(() => run(fakeDeps({ poll: [] }), ["ls", "--json"]));
expect(json.exitCode).toBeUndefined();
const rows = JSON.parse(json.out.join("")) as Array<{ id: string; selected: boolean; status: string }>;
expect(rows).toHaveLength(1);
expect(rows[0]!.id).toBe("vps-a");
expect(rows[0]).toHaveProperty("selected");
expect(rows[0]).toHaveProperty("status");
}, 20_000);
});
+126
View File
@@ -0,0 +1,126 @@
// OPR.0.4.6.MH1 rev1-r1 B (fixback) — the `rig host select` COMMAND
// itself: registry validation, the one daemon write, the structured
// unknown-id error (persisting nothing), select-local, and the
// daemon-down surface. The read helpers are covered in
// host-selection.test.ts; these pin the verb's write+validation path
// (FR-1 ACs) as regressions.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import http from "node:http";
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { Command } from "commander";
import { hostCommand } from "../src/commands/host.js";
import { addHostEntry } from "../src/host-registry.js";
describe("rig host select (OPR.0.4.6.MH1 FR-1 command path)", () => {
let dir: string;
let savedEnv: Record<string, string | undefined>;
let server: http.Server;
let port: number;
let writes: Array<{ method: string; url: string; body: string }>;
beforeEach(async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), "hostselect-"));
savedEnv = {};
for (const k of ["OPENRIG_HOME", "RIGGED_HOME", "OPENRIG_URL", "RIGGED_URL", "OPENRIG_HOST_SELECTED"]) {
savedEnv[k] = process.env[k];
delete process.env[k];
}
process.env.OPENRIG_HOME = dir;
writes = [];
server = http.createServer((req, res) => {
let body = "";
req.on("data", (c) => { body += c; });
req.on("end", () => {
writes.push({ method: req.method ?? "", url: req.url ?? "", body });
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true }));
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
port = (server.address() as { port: number }).port;
process.env.OPENRIG_URL = `http://127.0.0.1:${port}`;
});
afterEach(() => {
server.close();
fs.rmSync(dir, { recursive: true, force: true });
for (const [k, v] of Object.entries(savedEnv)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
});
function capture(fn: () => Promise<void>): Promise<{ out: string[]; err: string[]; exitCode: number | undefined }> {
return new Promise(async (resolve) => {
const out: string[] = []; const err: string[] = [];
const ol = console.log; const oe = console.error; const oc = process.exitCode;
process.exitCode = undefined;
console.log = (...a: unknown[]) => out.push(a.join(" "));
console.error = (...a: unknown[]) => err.push(a.join(" "));
try { await fn(); } finally { console.log = ol; console.error = oe; }
const exitCode = process.exitCode as number | undefined;
process.exitCode = oc;
resolve({ out, err, exitCode });
});
}
function run(argv: string[]) {
const prog = new Command();
prog.exitOverride();
prog.addCommand(hostCommand());
return prog.parseAsync(["node", "rig", "host", ...argv]);
}
function seedRegistry() {
expect(addHostEntry({ id: "vps-a", transport: "http", url: "http://vps-a:7433", bearer_env: "VPS_A_TOKEN" }).ok).toBe(true);
}
it("selects a registered id: validates against the registry, then ONE daemon config write", async () => {
seedRegistry();
const { out, exitCode } = await capture(() => run(["select", "vps-a"]));
expect(exitCode).toBeUndefined();
expect(writes).toHaveLength(1);
expect(writes[0]!.method).toBe("POST");
expect(writes[0]!.url).toBe("/api/config/host.selected");
expect(JSON.parse(writes[0]!.body)).toEqual({ value: "vps-a" });
expect(out.join("\n")).toContain("Selected host: vps-a");
});
it("select <unknown> is a structured error naming the registered ids — and persists NOTHING", async () => {
seedRegistry();
const { err, exitCode } = await capture(() => run(["select", "nope"]));
expect(exitCode).toBe(1);
const errStr = err.join("\n");
expect(errStr).toContain("cannot select 'nope'");
expect(errStr).toContain("vps-a");
expect(writes).toHaveLength(0);
});
it("select with no registry at all fails loud and teaches the add path — no write", async () => {
const { err, exitCode } = await capture(() => run(["select", "vps-a"]));
expect(exitCode).toBe(1);
expect(err.join("\n")).toContain("no hosts registered");
expect(writes).toHaveLength(0);
});
it("select local is always legal (no registry needed) and writes 'local' through the same path", async () => {
const { out, exitCode } = await capture(() => run(["select", "local"]));
expect(exitCode).toBeUndefined();
expect(writes).toHaveLength(1);
expect(JSON.parse(writes[0]!.body)).toEqual({ value: "local" });
expect(out.join("\n")).toContain("local (this host)");
});
it("daemon down surfaces the connection error (the PRD-named trade), exit 1", async () => {
seedRegistry();
// Point at the fixture server's port AFTER closing it: guaranteed dead.
await new Promise<void>((resolve) => server.close(() => resolve()));
const { err, exitCode } = await capture(() => run(["select", "vps-a"]));
expect(exitCode).toBe(1);
expect(err.join("\n").length).toBeGreaterThan(0);
});
});
+88
View File
@@ -0,0 +1,88 @@
// OPR.0.4.6.MH1 FR-1/FR-2 — the persisted host selection: local read
// resolution, the routing shim's precedence (flag > selection > local),
// and the zero-regression negative (no selection ≡ pre-MH1 behavior by
// construction).
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { readOwnHostName, readSelectedHost, resolveEffectiveHost } from "../src/host-selection.js";
describe("host selection (OPR.0.4.6.MH1)", () => {
let tmp: string;
const savedEnv: Record<string, string | undefined> = {};
beforeEach(() => {
tmp = mkdtempSync(join(tmpdir(), "mh1-sel-"));
for (const k of ["OPENRIG_HOME", "RIGGED_HOME", "OPENRIG_HOST_SELECTED", "OPENRIG_HOST_NAME"]) {
savedEnv[k] = process.env[k];
delete process.env[k];
}
process.env["OPENRIG_HOME"] = tmp;
});
afterEach(() => {
rmSync(tmp, { recursive: true, force: true });
for (const [k, v] of Object.entries(savedEnv)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
});
it("defaults to 'local' with no config file (the zero-regression posture)", () => {
expect(readSelectedHost()).toBe("local");
// FR-2: no selection → undefined → every command's --host branch is
// byte-identically untaken.
expect(resolveEffectiveHost(undefined)).toBeUndefined();
});
it("reads the persisted selection from config.json (the daemon-written file)", () => {
writeFileSync(join(tmp, "config.json"), JSON.stringify({ host: { selected: "vps-a" } }));
expect(readSelectedHost()).toBe("vps-a");
expect(resolveEffectiveHost(undefined)).toBe("vps-a");
});
it("explicit --host wins over the persisted selection (precedence: flag > selection)", () => {
writeFileSync(join(tmp, "config.json"), JSON.stringify({ host: { selected: "vps-a" } }));
expect(resolveEffectiveHost("other-b")).toBe("other-b");
});
it("selection 'local' resolves to undefined (explicit return-to-local ≡ no selection)", () => {
writeFileSync(join(tmp, "config.json"), JSON.stringify({ host: { selected: "local" } }));
expect(resolveEffectiveHost(undefined)).toBeUndefined();
});
it("the env override (OPENRIG_HOST_SELECTED) wins over the file (store precedence env > file)", () => {
writeFileSync(join(tmp, "config.json"), JSON.stringify({ host: { selected: "vps-a" } }));
process.env["OPENRIG_HOST_SELECTED"] = "vps-env";
expect(readSelectedHost()).toBe("vps-env");
});
it("a malformed config file falls back to 'local' — read paths never break", () => {
writeFileSync(join(tmp, "config.json"), "{not json");
expect(readSelectedHost()).toBe("local");
expect(resolveEffectiveHost(undefined)).toBeUndefined();
});
// OPR.0.4.6.MH1 FR-4 — the own-host display name (same read discipline).
it("own-host name defaults to 'localhost' with no config (the FR-4 zero-regression posture)", () => {
expect(readOwnHostName()).toBe("localhost");
});
it("reads the persisted own-host name from config.json (the daemon-written file)", () => {
writeFileSync(join(tmp, "config.json"), JSON.stringify({ host: { name: "Mac mini 2" } }));
expect(readOwnHostName()).toBe("Mac mini 2");
});
it("the env override (OPENRIG_HOST_NAME) wins over the file for the own-host name", () => {
writeFileSync(join(tmp, "config.json"), JSON.stringify({ host: { name: "Mac mini 2" } }));
process.env["OPENRIG_HOST_NAME"] = "env-name";
expect(readOwnHostName()).toBe("env-name");
});
it("a malformed config file falls back to 'localhost' for the own-host name", () => {
writeFileSync(join(tmp, "config.json"), "{not json");
expect(readOwnHostName()).toBe("localhost");
});
});
@@ -0,0 +1,177 @@
// OPR.0.4.6.MH3 C3 — the CLI edge of cross-host queue routing (D-2/D-3).
// Pins:
// - resolveQueueHostDestination: human-seat classifier FIRST; <2 `@` =
// passthrough; >=2 `@` = split on the LAST `@`, trailing segment stripped
// into hostId (unconditional — queue destinations are canonical-only, so
// a typo dies loud as an unknown HOST, not a rig-shaped error);
// --host + a DIFFERENT qualifier = structured ambiguity error;
// empty trailing segment = structured error;
// - the 3-part string NEVER leaves the CLI edge: the request body carries
// the 2-part destination + the out-of-band hostId envelope (BR-1);
// - D-2 explicit-only: no persisted-selection lookup anywhere on this path
// (the resolver is pure; the commands add no selection fallback);
// - local invocations post byte-identical bodies (no hostId key at all).
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { QueueDeps } from "../src/commands/queue.js";
import { resolveQueueHostDestination } from "../src/commands/queue.js";
import { createProgram } from "../src/index.js";
vi.mock("../src/daemon-lifecycle.js", async () => {
const actual = await vi.importActual<Record<string, unknown>>("../src/daemon-lifecycle.js");
return {
...actual,
getDaemonStatus: vi.fn(async () => ({ state: "running", healthy: true, pid: 1234, port: 7433 })),
getDaemonUrl: vi.fn(() => "http://localhost:7433"),
};
});
function makeDeps(): { deps: QueueDeps; calls: Array<{ method: string; path: string; body?: unknown }> } {
const calls: Array<{ method: string; path: string; body?: unknown }> = [];
return {
calls,
deps: {
lifecycleDeps: {} as QueueDeps["lifecycleDeps"],
clientFactory: () => ({
get: vi.fn(async (path: string) => { calls.push({ method: "GET", path }); return { status: 200, data: {} }; }),
getText: vi.fn(async () => ({ status: 200, data: "" })),
post: vi.fn(async (path: string, body: unknown) => {
calls.push({ method: "POST", path, body });
return { status: 201, data: { qitemId: "qitem-test-1" } };
}),
delete: vi.fn(async () => ({ status: 204, data: null })),
postText: vi.fn(async () => ({ status: 200, data: "" })),
postExpectText: vi.fn(async () => ({ status: 200, data: "" })),
}) as unknown as ReturnType<QueueDeps["clientFactory"]>,
},
};
}
describe("resolveQueueHostDestination (D-3)", () => {
it("plain 2-part destination passes through untouched (with or without --host)", () => {
expect(resolveQueueHostDestination("dev@rig-b")).toEqual({ ok: true, destination: "dev@rig-b", hostId: undefined });
expect(resolveQueueHostDestination("dev@rig-b", "vps-b")).toEqual({ ok: true, destination: "dev@rig-b", hostId: "vps-b" });
});
it("3-part form splits on the LAST @ — trailing segment becomes hostId; the session stays 2-part", () => {
expect(resolveQueueHostDestination("dev@rig-b@vps-b")).toEqual({ ok: true, destination: "dev@rig-b", hostId: "vps-b" });
});
it("many-@ input strips exactly ONE trailing segment (canonical-only backstop rejects the rest daemon-side)", () => {
expect(resolveQueueHostDestination("a@b@c@d")).toEqual({ ok: true, destination: "a@b@c", hostId: "d" });
});
it("human-seat refs are classified FIRST and never captured", () => {
expect(resolveQueueHostDestination("human@kernel")).toEqual({ ok: true, destination: "human@kernel", hostId: undefined });
expect(resolveQueueHostDestination("human-ops.1@host", "vps-b")).toEqual({ ok: true, destination: "human-ops.1@host", hostId: "vps-b" });
});
it("reserved trailing segment 'local' resolves to the explicit local envelope (daemon treats as the local path)", () => {
expect(resolveQueueHostDestination("dev@rig-b@local")).toEqual({ ok: true, destination: "dev@rig-b", hostId: "local" });
});
it("--host + a DIFFERENT host qualifier = structured ambiguity error (never a silent precedence pick)", () => {
const res = resolveQueueHostDestination("dev@rig-b@vps-b", "vps-c");
expect(res.ok).toBe(false);
if (!res.ok) expect(res.error).toBe("host_qualifier_conflict");
});
it("--host + the MATCHING qualifier is fine (one host named twice)", () => {
expect(resolveQueueHostDestination("dev@rig-b@vps-b", "vps-b")).toEqual({ ok: true, destination: "dev@rig-b", hostId: "vps-b" });
});
it("empty trailing host segment = structured error", () => {
const res = resolveQueueHostDestination("dev@rig-b@");
expect(res.ok).toBe(false);
if (!res.ok) expect(res.error).toBe("invalid_host_qualified_destination");
});
});
describe("rig queue cross-host CLI wiring (C3)", () => {
beforeEach(() => {
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {});
vi.spyOn(process.stderr, "write").mockImplementation(() => true);
process.exitCode = undefined;
});
it("create --host <id>: body carries the 2-part destination + the hostId envelope", async () => {
const { deps, calls } = makeDeps();
const program = createProgram({ queueDeps: deps });
program.exitOverride();
await program.parseAsync([
"node", "rig", "queue", "create",
"--source", "orch@rig-a", "--destination", "dev@rig-b",
"--host", "vps-b", "--body", "do thing", "--json",
]);
const body = calls.find((c) => c.path === "/api/queue/create")!.body as Record<string, unknown>;
expect(body.destinationSession).toBe("dev@rig-b");
expect(body.hostId).toBe("vps-b");
});
it("create with the 3-part destination form: same envelope — the 3-part string never leaves the CLI", async () => {
const { deps, calls } = makeDeps();
const program = createProgram({ queueDeps: deps });
program.exitOverride();
await program.parseAsync([
"node", "rig", "queue", "create",
"--source", "orch@rig-a", "--destination", "dev@rig-b@vps-b",
"--body", "do thing", "--json",
]);
const body = calls.find((c) => c.path === "/api/queue/create")!.body as Record<string, unknown>;
expect(body.destinationSession).toBe("dev@rig-b");
expect(body.hostId).toBe("vps-b");
expect(JSON.stringify(body)).not.toContain("dev@rig-b@vps-b");
});
it("local create posts NO hostId key at all (byte-identical local body)", async () => {
const { deps, calls } = makeDeps();
const program = createProgram({ queueDeps: deps });
program.exitOverride();
await program.parseAsync([
"node", "rig", "queue", "create",
"--source", "orch@rig-a", "--destination", "dev@rig-a",
"--body", "do thing", "--json",
]);
const body = calls.find((c) => c.path === "/api/queue/create")!.body as Record<string, unknown>;
expect("hostId" in body).toBe(false);
});
it("handoff --to member@rig@host: 2-part toSession + hostId envelope", async () => {
const { deps, calls } = makeDeps();
const program = createProgram({ queueDeps: deps });
program.exitOverride();
await program.parseAsync([
"node", "rig", "queue", "handoff", "qitem-src-1",
"--from", "worker@rig-a", "--to", "dev@rig-b@vps-b", "--json",
]);
const body = calls.find((c) => c.path === "/api/queue/qitem-src-1/handoff")!.body as Record<string, unknown>;
expect(body.toSession).toBe("dev@rig-b");
expect(body.hostId).toBe("vps-b");
});
it("handoff-and-complete --host <id>: same envelope", async () => {
const { deps, calls } = makeDeps();
const program = createProgram({ queueDeps: deps });
program.exitOverride();
await program.parseAsync([
"node", "rig", "queue", "handoff-and-complete", "qitem-src-1",
"--from", "worker@rig-a", "--to", "dev@rig-b", "--host", "vps-b", "--json",
]);
const body = calls.find((c) => c.path === "/api/queue/qitem-src-1/handoff-and-complete")!.body as Record<string, unknown>;
expect(body.toSession).toBe("dev@rig-b");
expect(body.hostId).toBe("vps-b");
});
it("conflicting --host + qualifier: local structured error, NOTHING posted, exit 1", async () => {
const { deps, calls } = makeDeps();
const program = createProgram({ queueDeps: deps });
program.exitOverride();
await program.parseAsync([
"node", "rig", "queue", "handoff", "qitem-src-1",
"--from", "worker@rig-a", "--to", "dev@rig-b@vps-b", "--host", "vps-c", "--json",
]);
expect(calls.filter((c) => c.method === "POST")).toHaveLength(0);
expect(process.exitCode).toBe(1);
});
});
+48
View File
@@ -1322,6 +1322,54 @@ describe("M2c-CLI R2 --source-jsonl provenance from parsed session_meta", () =>
expect(fs.existsSync(path.join(target, "transcript.md"))).toBe(true);
});
// OPR.0.4.6.MH1 rev1-r2 B2: the parse contract's greedy rig is the
// queue gate's shape (where an unknown rig fails the lookup); this site
// persists provenance with NO lookup, so a multi-@ session id must be
// REJECTED with the explicit override guidance — never silently
// recorded as source_rig="rig@host" (BR-1: host stays out-of-band).
it("B2 (rev1-r2): a member@rig@host session id is rejected, never silently persisted as source_rig", async () => {
// The JSONL path reaches deriveProvenance without a daemon (the
// --source-session path fetches the transcript via the daemon first,
// which is not what B2 is about). A parsed session_meta id carrying
// an in-band host is exactly the silently-wrong-provenance risk.
const { createProgram } = await import("../src/index.js");
const fs = await import("node:fs");
const path = await import("node:path");
const src = path.join(tmpRoot, "b2-multi-at.jsonl");
fs.writeFileSync(src, [
JSON.stringify({ type: "session_meta", payload: { cwd: "/tmp", id: "member@rig@host" } }),
JSON.stringify({ type: "response_item", payload: { type: "message", role: "user", content: "hi" } }),
].join("\n"), "utf8");
const target = path.join(tmpRoot, "packet-b2");
const stderr: string[] = [];
const origConsoleError = console.error;
const origExitCode = process.exitCode;
console.error = (...args: unknown[]) => stderr.push(args.join(" "));
process.exitCode = undefined;
try {
const program = createProgram();
program.exitOverride();
await program.parseAsync([
"node", "rig", "restore-packet", "write",
"--source-jsonl", src,
"--target", target,
"--target-rig", "openrig-velocity-claude",
"--target-runtime", "codex",
"--current-work-summary", "B2 provenance strictness check.",
"--authority-boundaries", "B2 check only.",
]);
} finally {
console.error = origConsoleError;
}
expect(process.exitCode).not.toBe(0);
process.exitCode = origExitCode;
const errStr = stderr.join("\n");
expect(errStr).toMatch(/source-rig-override|could not derive source_rig/);
// Never silently persisted: no packet written, no rig@host value.
expect(fs.existsSync(target)).toBe(false);
});
it("--source-jsonl with NO session_meta and NO override flags fails with explicit guidance", async () => {
const { createProgram } = await import("../src/index.js");
const fs = await import("node:fs");
+136
View File
@@ -0,0 +1,136 @@
// OPR.0.4.6.02 C3 — the `rig terminal` CLI family. Pins arg parsing, the daemon
// path/body each subcommand hits, the --json shape, and the open exit
// semantics (partial-with-names = exit 0; zero-pane = non-zero).
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { TerminalDeps } from "../src/commands/terminal.js";
import { createProgram } from "../src/index.js";
vi.mock("../src/daemon-lifecycle.js", async () => {
const actual = await vi.importActual<Record<string, unknown>>("../src/daemon-lifecycle.js");
return {
...actual,
getDaemonStatus: vi.fn(async () => ({ state: "running", healthy: true, pid: 1234, port: 7433 })),
getDaemonUrl: vi.fn(() => "http://localhost:7433"),
};
});
interface StubResponse { status: number; data: unknown }
function makeDeps(opts?: { routes?: Record<string, StubResponse> }): {
deps: TerminalDeps;
calls: Array<{ method: string; path: string; body?: unknown }>;
} {
const calls: Array<{ method: string; path: string; body?: unknown }> = [];
const routes = opts?.routes ?? {};
return {
calls,
deps: {
lifecycleDeps: {} as TerminalDeps["lifecycleDeps"],
clientFactory: () => ({
get: vi.fn(async (path: string) => {
calls.push({ method: "GET", path });
return routes[`GET ${path}`] ?? { status: 200, data: {} };
}),
getText: vi.fn(async (path: string) => { calls.push({ method: "GET", path }); return { status: 200, data: "" }; }),
post: vi.fn(async (path: string, body: unknown) => {
calls.push({ method: "POST", path, body });
return routes[`POST ${path}`] ?? { status: 200, data: {} };
}),
delete: vi.fn(async (path: string) => { calls.push({ method: "DELETE", path }); return { status: 204, data: null }; }),
postText: vi.fn(async (path: string) => { calls.push({ method: "POST", path }); return { status: 200, data: "" }; }),
postExpectText: vi.fn(async (path: string) => { calls.push({ method: "POST", path }); return { status: 200, data: "" }; }),
}) as unknown as ReturnType<TerminalDeps["clientFactory"]>,
},
};
}
const opened = (seats: string[], extra: Record<string, unknown> = {}) => ({
provider: "herdr", ok: seats.length > 0, opened: seats, absent: [], degraded: [], pages: 1, ...extra,
});
describe("rig terminal CLI", () => {
let logs: string[];
beforeEach(() => {
logs = [];
vi.spyOn(console, "log").mockImplementation((...a) => logs.push(a.join(" ")));
vi.spyOn(console, "error").mockImplementation(() => {});
process.exitCode = undefined;
});
it("registers open/views/status subcommands", () => {
const { deps } = makeDeps();
const program = createProgram({ terminalDeps: deps });
const cmd = program.commands.find((c) => c.name() === "terminal");
expect(cmd).toBeDefined();
expect(cmd!.commands.map((c) => c.name()).sort()).toEqual(["open", "status", "views"]);
});
it("open POSTs /api/terminal/open with view + provider", async () => {
const { deps, calls } = makeDeps({
routes: { "POST /api/terminal/open": { status: 200, data: opened(["a-seat"]) } },
});
const program = createProgram({ terminalDeps: deps });
program.exitOverride();
await program.parseAsync(["node", "rig", "terminal", "open", "acme-build", "--provider", "cmux", "--json"]);
const call = calls.find((c) => c.path === "/api/terminal/open");
expect(call!.body).toEqual({ view: "acme-build", provider: "cmux" });
});
it("open with a partial (some opened, some named) is a SUCCESS — exit 0", async () => {
const { deps } = makeDeps({
routes: {
"POST /api/terminal/open": {
status: 200,
data: opened(["a-seat"], { absent: [{ seat: "b", host: null, reason: "not alive" }] }),
},
},
});
const program = createProgram({ terminalDeps: deps });
program.exitOverride();
await program.parseAsync(["node", "rig", "terminal", "open", "acme-build"]);
expect(process.exitCode).toBeUndefined(); // exit 0 (disclosure, not failure)
});
it("open with ZERO panes opened is a failure — non-zero exit", async () => {
const { deps } = makeDeps({
routes: { "POST /api/terminal/open": { status: 200, data: opened([], { ok: false, code: "herdr_unavailable", error: "no binary" }) } },
});
const program = createProgram({ terminalDeps: deps });
program.exitOverride();
await program.parseAsync(["node", "rig", "terminal", "open", "acme-build"]);
expect(process.exitCode).toBe(1);
});
it("open of an unknown view (404) exits non-zero and surfaces the reason", async () => {
const { deps } = makeDeps({
routes: { "POST /api/terminal/open": { status: 404, data: { provider: "herdr", ok: false, opened: [], absent: [], degraded: [], pages: 0, code: "view_not_found", error: "unknown view 'nope'" } } },
});
const program = createProgram({ terminalDeps: deps });
program.exitOverride();
await program.parseAsync(["node", "rig", "terminal", "open", "nope", "--json"]);
expect(process.exitCode).toBe(1);
expect(logs.join("\n")).toContain("view_not_found");
});
it("views GETs /api/terminal/views", async () => {
const { deps, calls } = makeDeps({
routes: { "GET /api/terminal/views": { status: 200, data: { saved: [], rigs: ["acme-build"] } } },
});
const program = createProgram({ terminalDeps: deps });
program.exitOverride();
await program.parseAsync(["node", "rig", "terminal", "views", "--json"]);
expect(calls.find((c) => c.path === "/api/terminal/views")).toBeDefined();
});
it("status GETs /api/terminal/status with the provider query", async () => {
const { deps, calls } = makeDeps({
routes: { "GET /api/terminal/status?provider=herdr": { status: 200, data: { providers: [] } } },
});
const program = createProgram({ terminalDeps: deps });
program.exitOverride();
await program.parseAsync(["node", "rig", "terminal", "status", "--provider", "herdr", "--json"]);
const call = calls.find((c) => c.method === "GET" && c.path.startsWith("/api/terminal/status"));
expect(call!.path).toContain("provider=herdr");
});
});
@@ -0,0 +1,198 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { WorkflowDeps } from "../src/commands/workflow.js";
import { createProgram } from "../src/index.js";
import { renderInstanceShow, renderTraceTree } from "../src/commands/workflow-render.js";
// OPR.0.4.6.FAC1 commit 2 — the CLI rig-binding surface (planner2
// §3.15): `--rig` lands on BOTH instantiate AND run (run instantiates
// too — missing it would silently narrow the founder surface), and the
// human renderers surface the bound rig while unbound rows stay
// byte-identical.
vi.mock("../src/daemon-lifecycle.js", async () => {
const actual = await vi.importActual<Record<string, unknown>>("../src/daemon-lifecycle.js");
return {
...actual,
getDaemonStatus: vi.fn(async () => ({
state: "running",
healthy: true,
pid: 1234,
port: 7433,
})),
getDaemonUrl: vi.fn(() => "http://localhost:7433"),
};
});
interface StubResponse {
status: number;
data: unknown;
}
function makeDeps(opts?: { routes?: Record<string, StubResponse> }): {
deps: WorkflowDeps;
calls: Array<{ method: string; path: string; body?: unknown }>;
} {
const calls: Array<{ method: string; path: string; body?: unknown }> = [];
const routes = opts?.routes ?? {};
return {
calls,
deps: {
lifecycleDeps: {} as WorkflowDeps["lifecycleDeps"],
clientFactory: () =>
({
get: vi.fn(async (path: string) => {
calls.push({ method: "GET", path });
return routes[`GET ${path}`] ?? { status: 200, data: {} };
}),
getText: vi.fn(async (path: string) => {
calls.push({ method: "GET", path });
return { status: 200, data: "" };
}),
post: vi.fn(async (path: string, body: unknown) => {
calls.push({ method: "POST", path, body });
return routes[`POST ${path}`] ?? { status: 200, data: {} };
}),
delete: vi.fn(async (path: string) => {
calls.push({ method: "DELETE", path });
return { status: 204, data: null };
}),
postText: vi.fn(async (path: string) => {
calls.push({ method: "POST", path });
return { status: 200, data: "" };
}),
postExpectText: vi.fn(async (path: string) => {
calls.push({ method: "POST", path });
return { status: 200, data: "" };
}),
}) as unknown as ReturnType<WorkflowDeps["clientFactory"]>,
},
};
}
describe("FAC-1 C2: --rig on both instantiate verbs + bound-rig rendering", () => {
beforeEach(() => {
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {});
process.exitCode = undefined;
});
it("instantiate --rig posts targetRig", async () => {
const { deps, calls } = makeDeps({
routes: { "POST /api/workflow/instantiate": { status: 201, data: { instance: {}, entryQitemId: "q-1" } } },
});
const program = createProgram({ workflowDeps: deps });
program.exitOverride();
await program.parseAsync([
"node", "rig", "workflow", "instantiate", "/abs/spec.yaml",
"--root-objective", "obj",
"--created-by", "ops@rig",
"--rig", "factory-b",
"--json",
]);
const call = calls.find((c) => c.path === "/api/workflow/instantiate");
expect((call!.body as Record<string, string>).targetRig).toBe("factory-b");
});
it("instantiate WITHOUT --rig omits targetRig (unbound default preserved)", async () => {
const { deps, calls } = makeDeps({
routes: { "POST /api/workflow/instantiate": { status: 201, data: { instance: {}, entryQitemId: "q-1" } } },
});
const program = createProgram({ workflowDeps: deps });
program.exitOverride();
await program.parseAsync([
"node", "rig", "workflow", "instantiate", "/abs/spec.yaml",
"--root-objective", "obj",
"--created-by", "ops@rig",
"--json",
]);
const call = calls.find((c) => c.path === "/api/workflow/instantiate");
expect((call!.body as Record<string, unknown>).targetRig).toBeUndefined();
});
// OPR.0.4.6.FAC1 arch ruling 2026-07-07 (loudness = guard invariant #1):
// when the daemon degrades a bad spec-default to unbound, it returns
// `advisories`; the CLI MUST surface them to stderr even in --json mode
// (a consumer piping stdout still sees the warning). Never silent.
it("instantiate surfaces daemon advisories to STDERR even in --json mode", async () => {
const { deps } = makeDeps({
routes: {
"POST /api/workflow/instantiate": {
status: 201,
data: {
instance: { boundRig: null },
entryQitemId: "q-1",
advisories: [
`workflow spec default target.rig "vanished-rig" is not a registered rig on this daemon — instantiating UNBOUND.`,
],
},
},
},
});
const stderrSpy = vi.spyOn(process.stderr, "write").mockReturnValue(true);
try {
const program = createProgram({ workflowDeps: deps });
program.exitOverride();
await program.parseAsync([
"node", "rig", "workflow", "instantiate", "/abs/spec.yaml",
"--root-objective", "obj",
"--created-by", "ops@rig",
"--json",
]);
const stderr = stderrSpy.mock.calls.map((c) => String(c[0])).join("");
expect(stderr).toContain("advisory");
expect(stderr).toContain("vanished-rig");
expect(stderr).toContain("UNBOUND");
} finally {
stderrSpy.mockRestore();
}
});
it("run --rig posts targetRig too (the second instantiate call site)", async () => {
const { deps, calls } = makeDeps({
// No instanceId in the response → run prints and returns without
// entering the follow loop (keeps this a pure body-shape test).
routes: { "POST /api/workflow/instantiate": { status: 200, data: {} } },
});
const program = createProgram({ workflowDeps: deps });
program.exitOverride();
await program.parseAsync([
"node", "rig", "workflow", "run", "/abs/spec.yaml",
"--root-objective", "obj",
"--created-by", "ops@rig",
"--rig", "factory-a",
"--json",
]);
const call = calls.find((c) => c.path === "/api/workflow/instantiate");
expect((call!.body as Record<string, string>).targetRig).toBe("factory-a");
});
it("renderInstanceShow: bound instance renders a rig line; unbound renders none (byte-stable)", () => {
const bound = renderInstanceShow(
{ instanceId: "WF01", status: "active", boundRig: "factory-a" },
"2026-07-07T10:00:00.000Z",
);
expect(bound.some((l) => l.includes("rig: factory-a"))).toBe(true);
const unbound = renderInstanceShow(
{ instanceId: "WF01", status: "active", boundRig: null },
"2026-07-07T10:00:00.000Z",
);
expect(unbound.some((l) => l.includes("rig:"))).toBe(false);
});
it("renderTraceTree: the header carries rig=<name> when bound, nothing when unbound", () => {
const bound = renderTraceTree(
{ instanceId: "WF01", status: "active", workflowName: "wf", boundRig: "factory-b" },
[],
"2026-07-07T10:00:00.000Z",
);
expect(bound[0]).toContain("rig=factory-b");
const unbound = renderTraceTree(
{ instanceId: "WF01", status: "active", workflowName: "wf" },
[],
"2026-07-07T10:00:00.000Z",
);
expect(unbound[0]).not.toContain("rig=");
});
});
+143
View File
@@ -0,0 +1,143 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { describeDaemonRejection, formatThreePart } from "../src/commands/workflow-errors.js";
import type { WorkflowDeps } from "../src/commands/workflow.js";
import { createProgram } from "../src/index.js";
vi.mock("../src/daemon-lifecycle.js", async () => {
const actual = await vi.importActual<Record<string, unknown>>("../src/daemon-lifecycle.js");
return {
...actual,
getDaemonStatus: vi.fn(async () => ({ state: "running", healthy: true, pid: 1, port: 7433 })),
getDaemonUrl: vi.fn(() => "http://localhost:7433"),
};
});
/**
* OPR.0.4.6.WF3 FR-5 — error-UX pins (commit 4). Every named daemon
* rejection renders the 3-part what/why/fix in human mode with the
* correct fix pointer; --json stays the raw body byte-identically;
* exit codes unchanged.
*/
describe("describeDaemonRejection (WF3 FR-5)", () => {
it("packet_not_on_frontier → explains the moved frontier, points at trace", () => {
const rej = describeDaemonRejection({ error: "packet_not_on_frontier", instanceId: "WF1" });
expect(rej?.fact).toContain("not on the instance frontier");
expect(rej?.consequence).toContain("frontier has moved");
expect(rej?.action).toContain("rig workflow trace WF1");
});
it("instance_not_active → names the actual state; fix pointer names ONLY shipped verbs (BR-1 — flipped at WF-5: resume is real now)", () => {
const rej = describeDaemonRejection({ error: "instance_not_active", message: "failed", instanceId: "WF1" });
expect(rej?.fact).toContain("failed");
expect(rej?.action).toContain("rig workflow show WF1");
// OPR.0.4.6.WF5 FR-4: the WF-3-era negative asserted NO resume
// pointer because the verb did not ship yet (BR-1: never point at
// a verb that does not exist). WF-5 shipped it, and the pointer
// upgraded exactly as the WF-3 comment promised — same BR-1
// principle, inverted assertion.
expect(rej?.action).toContain("rig workflow resume");
});
it("instance_version_conflict → names expected/actual, says whole-rollback, fix = re-read + retry", () => {
const rej = describeDaemonRejection({ error: "instance_version_conflict", expectedVersion: 4, actualVersion: 5 });
expect(rej?.fact).toContain("expected version 4");
expect(rej?.fact).toContain("actual 5");
expect(rej?.consequence).toContain("rolled back whole");
expect(rej?.action).toContain("retry");
});
it("exit_not_allowed → lists allowed exits when the body carries them", () => {
const rej = describeDaemonRejection({ error: "exit_not_allowed", allowedExits: ["handoff", "failed"] });
expect(rej?.action).toBe("Use one of: handoff | failed.");
});
it("no_next_step / next_owner_unresolved / instance_not_found all render with fixes", () => {
expect(describeDaemonRejection({ error: "no_next_step" })?.action).toContain("--exit done | failed");
expect(describeDaemonRejection({ error: "next_owner_unresolved" })?.action).toContain("--next-owner");
expect(describeDaemonRejection({ error: "instance_not_found" })?.action).toContain("rig workflow list");
});
it("unrecognized bodies return null (raw-JSON fallback preserved)", () => {
expect(describeDaemonRejection({ error: "something_new" })).toBeNull();
expect(describeDaemonRejection({ message: "no error code" })).toBeNull();
expect(describeDaemonRejection("string body")).toBeNull();
expect(describeDaemonRejection(null)).toBeNull();
});
it("formatThreePart renders the emit3PartError shape", () => {
const lines = formatThreePart({ fact: "f", consequence: "c", action: "a" });
expect(lines).toEqual(["Error: f", "c", "a"]);
});
});
describe("wire-level FR-5 behavior", () => {
afterEach(() => vi.restoreAllMocks());
function makeDeps(routes: Record<string, { status: number; data: unknown }>): WorkflowDeps {
return {
lifecycleDeps: {} as WorkflowDeps["lifecycleDeps"],
clientFactory: () =>
({
get: async (path: string) => routes[`GET ${path}`] ?? { status: 200, data: {} },
post: async (path: string, _body: unknown) => routes[`POST ${path}`] ?? { status: 200, data: {} },
}) as never,
};
}
const CONFLICT = {
status: 409,
data: { error: "instance_version_conflict", message: "conflict", expectedVersion: 1, actualVersion: 2 },
};
it("human mode: named 409 renders 3-part on stderr, exit code stays 1", async () => {
const errSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const program = createProgram({
workflowDeps: makeDeps({ "POST /api/workflow/project": CONFLICT }),
});
program.exitOverride();
process.exitCode = undefined;
await program.parseAsync([
"node", "rig", "workflow", "project",
"--instance", "WF1", "--current-packet", "Q1", "--exit", "handoff", "--actor-session", "a@r",
]);
const stderrText = errSpy.mock.calls.map((c) => String(c[0])).join("");
expect(stderrText).toContain("Error: A concurrent writer advanced the instance first");
// The raw JSON blob is NOT dumped in human mode for a named rejection.
expect(logSpy.mock.calls.map((c) => String(c[0])).join("")).not.toContain("instance_version_conflict");
expect(process.exitCode).toBe(1);
process.exitCode = undefined;
});
it("--json: the raw daemon error body passes through byte-identically", async () => {
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const program = createProgram({
workflowDeps: makeDeps({ "POST /api/workflow/project": CONFLICT }),
});
program.exitOverride();
process.exitCode = undefined;
await program.parseAsync([
"node", "rig", "workflow", "project",
"--instance", "WF1", "--current-packet", "Q1", "--exit", "handoff", "--actor-session", "a@r", "--json",
]);
expect(logSpy).toHaveBeenCalledWith(JSON.stringify(CONFLICT.data));
expect(process.exitCode).toBe(1);
process.exitCode = undefined;
});
it("human mode: unrecognized 500 keeps the raw-JSON fallback and exit 2", async () => {
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const program = createProgram({
workflowDeps: makeDeps({
"GET /api/workflow/WF1": { status: 500, data: { error: "internal_error", message: "boom" } },
}),
});
program.exitOverride();
process.exitCode = undefined;
await program.parseAsync(["node", "rig", "workflow", "show", "WF1"]);
expect(logSpy.mock.calls.map((c) => String(c[0])).join("")).toContain("internal_error");
expect(process.exitCode).toBe(2);
process.exitCode = undefined;
});
});
+282
View File
@@ -0,0 +1,282 @@
import { describe, it, expect, vi } from "vitest";
import {
followInstance,
outcomeExitCode,
EXIT_WORKFLOW_FAILED,
type FollowIo,
} from "../src/commands/workflow-follow.js";
import type { DaemonClient } from "../src/client.js";
vi.mock("../src/daemon-lifecycle.js", async () => {
const actual = await vi.importActual<Record<string, unknown>>("../src/daemon-lifecycle.js");
return {
...actual,
getDaemonStatus: vi.fn(async () => ({ state: "running", healthy: true, pid: 1, port: 7433 })),
getDaemonUrl: vi.fn(() => "http://localhost:7433"),
};
});
/**
* OPR.0.4.6.WF3 FR-1 — follow-engine pins (commit 1).
*
* Everything here runs against injected IO + stubbed streams: no
* daemon, no network, no timers (sleep is injected). The live legs
* (real SSE, real daemon) are the VM proof walk's job, not this
* file's.
*/
function sseStream(events: Array<Record<string, unknown>>): ReadableStream<Uint8Array> {
const encoder = new TextEncoder();
return new ReadableStream<Uint8Array>({
start(controller) {
for (const event of events) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
}
controller.close();
},
});
}
/** A stream that emits nothing and closes — the drop shape. */
function droppedStream(): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({
start(controller) {
controller.close();
},
});
}
interface HarnessOpts {
traceResponses: Array<{ status: number; data: unknown }>;
streams: Array<ReadableStream<Uint8Array> | null>;
}
function makeHarness(opts: HarnessOpts) {
const outLines: string[] = [];
const errLines: string[] = [];
let traceCall = 0;
let streamCall = 0;
const client = {
baseUrl: "http://localhost:7433",
get: async (_path: string) => {
const res = opts.traceResponses[Math.min(traceCall, opts.traceResponses.length - 1)];
traceCall += 1;
return res;
},
} as unknown as DaemonClient;
const io: FollowIo = {
out: (line) => outLines.push(line),
err: (line) => errLines.push(line),
sleep: async () => {},
fetchImpl: (async () => {
const body = opts.streams[Math.min(streamCall, opts.streams.length - 1)];
streamCall += 1;
if (body === null) throw new Error("connect refused");
return { ok: true, body } as unknown as Response;
}) as unknown as typeof fetch,
};
return { client, io, outLines, errLines };
}
const ACTIVE_SNAPSHOT = {
status: 200,
data: {
instance: {
instanceId: "WF1",
workflowName: "conveyor",
status: "active",
currentStepId: "build",
currentFrontier: ["Q2"],
},
trail: [
{
stepId: "plan",
closureReason: "handoff",
actorSession: "a@r",
nextQitemId: "Q2",
priorQitemId: "Q1",
},
],
},
};
describe("workflow-follow (WF3 FR-1)", () => {
it("outcome exit codes: completed=0, failed=3, distinct from 1/2", () => {
expect(outcomeExitCode("completed")).toBe(0);
expect(outcomeExitCode("failed")).toBe(EXIT_WORKFLOW_FAILED);
expect(EXIT_WORKFLOW_FAILED).toBe(3);
});
it("streams to completion and returns 0", async () => {
const h = makeHarness({
traceResponses: [ACTIVE_SNAPSHOT],
streams: [
sseStream([
{ type: "workflow.step_closed", instanceId: "WF1", stepId: "build", closureReason: "handoff", actorSession: "b@r", priorQitemId: "Q2" },
{ type: "workflow.completed", instanceId: "WF1" },
]),
],
});
const code = await followInstance(h.client, "WF1", { json: false, io: h.io });
expect(code).toBe(0);
expect(h.outLines.join("\n")).toContain("workflow completed");
});
it("a failed workflow exits 3 with the failing step visible", async () => {
const h = makeHarness({
traceResponses: [ACTIVE_SNAPSHOT],
streams: [
sseStream([
{ type: "workflow.step_closed", instanceId: "WF1", stepId: "build", closureReason: "failed", actorSession: "b@r", priorQitemId: "Q2" },
{ type: "workflow.failed", instanceId: "WF1", reason: "boom" },
]),
],
});
const code = await followInstance(h.client, "WF1", { json: false, io: h.io });
expect(code).toBe(EXIT_WORKFLOW_FAILED);
const text = h.outLines.join("\n");
expect(text).toContain("✖ build");
expect(text).toContain("FAILED: boom");
});
it("snapshot-first dedup: a step already in the snapshot trail renders exactly once (arch R2)", async () => {
const h = makeHarness({
traceResponses: [ACTIVE_SNAPSHOT],
streams: [
sseStream([
// Replay of the closure the snapshot already carries (priorQitemId Q1).
{ type: "workflow.step_closed", instanceId: "WF1", stepId: "plan", closureReason: "handoff", actorSession: "a@r", priorQitemId: "Q1" },
{ type: "workflow.completed", instanceId: "WF1" },
]),
],
});
await followInstance(h.client, "WF1", { json: false, io: h.io });
const planRows = h.outLines.filter((l) => l.includes("plan") && l.includes("handoff"));
expect(planRows).toHaveLength(1);
});
it("filters events from other instances", async () => {
const h = makeHarness({
traceResponses: [ACTIVE_SNAPSHOT],
streams: [
sseStream([
{ type: "workflow.step_closed", instanceId: "OTHER", stepId: "x", closureReason: "handoff", actorSession: "z@r", priorQitemId: "QX" },
{ type: "workflow.completed", instanceId: "OTHER" },
{ type: "workflow.completed", instanceId: "WF1" },
]),
],
});
const code = await followInstance(h.client, "WF1", { json: false, io: h.io });
expect(code).toBe(0);
expect(h.outLines.join("\n")).not.toContain("OTHER");
});
it("attach to an already-terminal instance resolves immediately from the snapshot", async () => {
const h = makeHarness({
traceResponses: [
{ status: 200, data: { instance: { instanceId: "WF1", status: "failed" }, trail: [] } },
],
streams: [droppedStream()],
});
const code = await followInstance(h.client, "WF1", { json: false, io: h.io });
expect(code).toBe(EXIT_WORKFLOW_FAILED);
});
it("drop → announced reconnect; exhausted reconnects → announced poll fallback that resolves the outcome", async () => {
const h = makeHarness({
traceResponses: [
ACTIVE_SNAPSHOT,
{
status: 200,
data: {
instance: { instanceId: "WF1", status: "completed" },
trail: [
...(ACTIVE_SNAPSHOT.data.trail as unknown[]),
{ stepId: "build", closureReason: "done", actorSession: "b@r", nextQitemId: null, priorQitemId: "Q2" },
],
},
},
],
streams: [droppedStream(), droppedStream()],
});
const code = await followInstance(h.client, "WF1", {
json: false,
io: h.io,
maxReconnects: 1,
pollIntervalMs: 1,
});
expect(code).toBe(0);
const err = h.errLines.join("\n");
expect(err).toContain("reconnecting");
expect(err).toContain("poll fallback");
// The poll rendered the new trail row exactly once, never silently frozen.
expect(h.outLines.filter((l) => l.includes("build") && l.includes("done"))).toHaveLength(1);
});
it("--json emits machine lines (snapshot + events), no human table", async () => {
const h = makeHarness({
traceResponses: [ACTIVE_SNAPSHOT],
streams: [sseStream([{ type: "workflow.completed", instanceId: "WF1" }])],
});
const code = await followInstance(h.client, "WF1", { json: true, io: h.io });
expect(code).toBe(0);
for (const line of h.outLines) expect(() => JSON.parse(line)).not.toThrow();
expect(JSON.parse(h.outLines[0]).type).toBe("snapshot");
});
it("trace 404 maps to transport exit 1, not an outcome code", async () => {
const h = makeHarness({
traceResponses: [{ status: 404, data: { error: "instance_not_found" } }],
streams: [droppedStream()],
});
const code = await followInstance(h.client, "WF1", { json: false, io: h.io });
expect(code).toBe(1);
});
});
describe("run proceeds to follow on the daemon's NESTED instantiate shape (walk-caught regression)", () => {
it("run with {instance:{instanceId}} response follows (fetches trace) instead of print-and-exit", async () => {
const { createProgram } = await import("../src/index.js");
const calls: string[] = [];
const deps = {
lifecycleDeps: {} as never,
clientFactory: () =>
({
baseUrl: "http://127.0.0.1:1", // SSE fetch fails fast; snapshot resolves terminally
post: async (path: string) => {
calls.push(`POST ${path}`);
// The REAL daemon shape: nested InstantiateResult.
return { status: 201, data: { instance: { instanceId: "WF-NEST" }, entryQitemId: "Q1" } };
},
get: async (path: string) => {
calls.push(`GET ${path}`);
return { status: 200, data: { instance: { instanceId: "WF-NEST", status: "completed" }, trail: [] } };
},
}) as never,
};
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const program = createProgram({ workflowDeps: deps as never });
program.exitOverride();
await program.parseAsync([
"node", "rig", "workflow", "run", "spec.yaml",
"--root-objective", "x", "--created-by", "a@r",
]);
logSpy.mockRestore();
// The regression: with the nested shape, run must REACH the follow
// phase (trace snapshot fetched), never print-and-return exit 0.
expect(calls).toContain("GET /api/workflow/WF-NEST/trace");
});
});
describe("run/watch verb honesty (BR-1)", () => {
it("help text matches wire behavior: run runs, watch watches, neither advances", async () => {
const { workflowCommand } = await import("../src/commands/workflow.js");
const cmd = workflowCommand();
const run = cmd.commands.find((c) => c.name() === "run");
const watch = cmd.commands.find((c) => c.name() === "watch");
expect(run?.description()).toContain("follow");
expect(watch?.description()).toContain("read-only");
expect(run?.description()).not.toContain("advance");
expect(watch?.description()).not.toContain("advance");
});
});
+174
View File
@@ -0,0 +1,174 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import {
renderTraceTree,
renderInstanceList,
renderInstanceShow,
humanDuration,
statusGlyph,
} from "../src/commands/workflow-render.js";
import type { WorkflowDeps } from "../src/commands/workflow.js";
import { createProgram } from "../src/index.js";
vi.mock("../src/daemon-lifecycle.js", async () => {
const actual = await vi.importActual<Record<string, unknown>>("../src/daemon-lifecycle.js");
return {
...actual,
getDaemonStatus: vi.fn(async () => ({ state: "running", healthy: true, pid: 1, port: 7433 })),
getDaemonUrl: vi.fn(() => "http://localhost:7433"),
};
});
/**
* OPR.0.4.6.WF3 FR-2 — renderer pins (commit 2). Render-side only:
* the BYTE-STABILITY of --json is additionally pinned here at the
* unit level (raw body verbatim through the json branch); the
* before/after diff harness (commit 8) is the binding proof.
*/
const NOW = "2026-07-06T21:10:00.000Z";
const INSTANCE = {
instanceId: "WF01ABC",
workflowName: "conveyor",
workflowVersion: "2",
status: "active",
createdBySession: "orch@rig",
createdAt: "2026-07-06T21:00:00.000Z",
currentStepId: "review",
currentFrontier: ["Q3"],
hopCount: 2,
};
const TRAIL = [
{
stepId: "plan",
closedAt: "2026-07-06T21:02:00.000Z",
closureReason: "handoff",
actorSession: "planner@rig",
nextQitemId: "Q2",
priorQitemId: "Q1",
},
{
stepId: "build",
closedAt: "2026-07-06T21:08:00.000Z",
closureReason: "handoff",
closureEvidence: { branch_taken: "remediate" },
actorSession: "builder@rig",
nextQitemId: "Q3",
priorQitemId: "Q2",
},
];
describe("workflow-render (WF3 FR-2)", () => {
it("trace tree: one screen with step, actor, exit, duration, and the current frontier", () => {
const lines = renderTraceTree(INSTANCE, TRAIL, NOW);
const text = lines.join("\n");
expect(text).toContain("WF01ABC");
expect(text).toContain("conveyor v2");
expect(text).toContain("STEP");
expect(text).toContain("ACTOR");
expect(text).toContain("plan");
expect(text).toContain("planner@rig");
expect(text).toContain("2m"); // plan duration from createdAt→closedAt
expect(text).toContain("6m"); // build duration from prior close
expect(text).toContain("▸ review"); // where it is now
expect(text).toContain("frontier=[Q3]");
// No raw JSON leakage — the whole point of FR-2.
expect(text).not.toContain("{");
});
it("branch-taken renders when the trail carries it, leaves no residue when absent (present-tolerant)", () => {
const withBranch = renderTraceTree(INSTANCE, TRAIL, NOW).join("\n");
expect(withBranch).toContain("↳ branch: remediate");
const noBranch = renderTraceTree(INSTANCE, [TRAIL[0]], NOW).join("\n");
expect(noBranch).not.toContain("branch:");
});
it("failed steps carry the ✖ glyph", () => {
const lines = renderTraceTree(
{ ...INSTANCE, status: "failed", currentStepId: null },
[{ ...TRAIL[0], closureReason: "failed" }],
NOW,
);
expect(lines.join("\n")).toContain("✖");
expect(statusGlyph("failed")).toBe("✖");
});
it("list table: columns + one row per instance + age", () => {
const lines = renderInstanceList([INSTANCE, { ...INSTANCE, instanceId: "WF02", status: "waiting", currentStepId: null }], NOW);
expect(lines[0]).toMatch(/INSTANCE\s+WORKFLOW\s+STATUS\s+STEP\s+AGE\s+ATTN/);
expect(lines).toHaveLength(3);
expect(lines[1]).toContain("WF01ABC");
expect(lines[1]).toContain("10m");
expect(lines[2]).toContain("◐");
});
it("FR-3a attention markers: failed and waiting rows marked; active/completed unmarked", () => {
const lines = renderInstanceList(
[
INSTANCE,
{ ...INSTANCE, instanceId: "WF-F", status: "failed" },
{ ...INSTANCE, instanceId: "WF-W", status: "waiting" },
{ ...INSTANCE, instanceId: "WF-C", status: "completed" },
],
NOW,
);
expect(lines.find((l) => l.includes("WF-F"))).toContain("▲ failed");
expect(lines.find((l) => l.includes("WF-W"))).toContain("▲ waiting");
expect(lines.find((l) => l.includes("WF01ABC"))).not.toContain("▲");
expect(lines.find((l) => l.includes("WF-C"))).not.toContain("▲");
});
it("empty list renders the explicit empty statement, never a blank", () => {
expect(renderInstanceList([], NOW)).toEqual(["No workflow instances."]);
});
it("show: status-line-headed summary with a next pointer at trace", () => {
const lines = renderInstanceShow(INSTANCE, NOW);
expect(lines[0]).toBe("● WF01ABC status=active");
expect(lines.join("\n")).toContain("rig workflow trace WF01ABC");
});
it("humanDuration compacts sanely", () => {
expect(humanDuration("2026-07-06T21:00:00Z", "2026-07-06T21:00:30Z")).toBe("30s");
expect(humanDuration("2026-07-06T21:00:00Z", "2026-07-06T22:30:00Z")).toBe("90m");
expect(humanDuration(undefined, NOW)).toBe("");
});
});
describe("BR-2 byte-stability of --json (unit pin; commit-8 harness is the binding proof)", () => {
afterEach(() => vi.restoreAllMocks());
function makeDeps(routes: Record<string, { status: number; data: unknown }>): WorkflowDeps {
return {
lifecycleDeps: {} as WorkflowDeps["lifecycleDeps"],
clientFactory: () =>
({
get: async (path: string) => routes[`GET ${path}`] ?? { status: 200, data: {} },
post: async (path: string) => routes[`POST ${path}`] ?? { status: 200, data: {} },
}) as never,
};
}
it("trace --json emits the raw daemon body verbatim (no renderer involvement)", async () => {
const body = { instance: { instanceId: "i1", status: "active" }, trail: [{ stepId: "s", extra: 1 }] };
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const program = createProgram({
workflowDeps: makeDeps({ "GET /api/workflow/i1/trace": { status: 200, data: body } }),
});
program.exitOverride();
await program.parseAsync(["node", "rig", "workflow", "trace", "i1", "--json"]);
expect(logSpy).toHaveBeenCalledWith(JSON.stringify(body));
});
it("list --json emits the raw daemon body verbatim", async () => {
const body = [{ instanceId: "i1", status: "active" }];
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const program = createProgram({
workflowDeps: makeDeps({ "GET /api/workflow/list": { status: 200, data: body } }),
});
program.exitOverride();
await program.parseAsync(["node", "rig", "workflow", "list", "--json"]);
expect(logSpy).toHaveBeenCalledWith(JSON.stringify(body));
});
});
+198
View File
@@ -0,0 +1,198 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import {
composeAttentionRollup,
attentionMarker,
renderStatus,
renderInstanceList,
humanSeconds,
} from "../src/commands/workflow-render.js";
import type { WorkflowDeps } from "../src/commands/workflow.js";
import { createProgram } from "../src/index.js";
vi.mock("../src/daemon-lifecycle.js", async () => {
const actual = await vi.importActual<Record<string, unknown>>("../src/daemon-lifecycle.js");
return {
...actual,
getDaemonStatus: vi.fn(async () => ({ state: "running", healthy: true, pid: 1, port: 7433 })),
getDaemonUrl: vi.fn(() => "http://localhost:7433"),
};
});
/**
* OPR.0.4.6.WF3 FR-3 part B — the `status` rollup pins (commit 5).
*
* RAIL 1 is structurally pinned here: every fixture provides ONLY the
* API-carried deadline.state/evidence — no timestamps the CLI could
* do threshold math on — and classification still works, proving the
* composer consumes and never recomputes.
*/
const HEALTHY = {
instanceId: "WF-H",
workflowName: "conveyor",
status: "active",
deadline: { state: "healthy", evidence: null },
};
const FAILED = {
instanceId: "WF-F",
workflowName: "conveyor",
status: "failed",
deadline: { state: "healthy", evidence: null },
};
const STUCK = {
instanceId: "WF-S",
workflowName: "pipeline",
status: "active",
deadline: {
state: "overdue-unclaimed",
evidence: { stepId: "review", ownerSession: "rev@rig", overdueBySeconds: 7200 },
},
};
const WAITING = {
instanceId: "WF-W",
workflowName: "pipeline",
status: "waiting",
deadline: { state: "healthy", evidence: null },
lastContinuationDecision: { blockedOn: "founder-gate-2" },
};
// The dedup fixture: waiting AND overdue at once (waiting keeps its
// frontier packet per WF-1, so both classes are simultaneously real).
const WAITING_AND_STUCK = {
instanceId: "WF-WS",
workflowName: "pipeline",
status: "waiting",
deadline: {
state: "overdue-unclaimed",
evidence: { stepId: "gate", ownerSession: "human@rig", overdueBySeconds: 60 },
},
lastContinuationDecision: { blockedOn: "external-gate" },
};
const COMPLETED = { instanceId: "WF-C", workflowName: "conveyor", status: "completed" };
describe("composeAttentionRollup (WF3 FR-3b)", () => {
it("mixed fixture: every attention-worthy instance appears exactly once with class + reason + affordance; healthy only in counts", () => {
const rollup = composeAttentionRollup([HEALTHY, FAILED, STUCK, WAITING, COMPLETED]);
expect(rollup.counts).toEqual({ total: 5, active: 2, waiting: 1, completed: 1, failed: 1 });
expect(rollup.attention).toHaveLength(3);
const ids = rollup.attention.map((r) => r.instanceId);
expect(ids).toEqual(expect.arrayContaining(["WF-F", "WF-S", "WF-W"]));
expect(ids).not.toContain("WF-H");
expect(ids).not.toContain("WF-C");
const stuck = rollup.attention.find((r) => r.instanceId === "WF-S");
expect(stuck?.classes).toEqual(["stuck"]);
expect(stuck?.reasons[0]).toContain("overdue-unclaimed at step review (owner rev@rig)");
expect(stuck?.reasons[0]).toContain("120m"); // 7200s consumed, not computed
expect(stuck?.affordance).toContain("rig workflow route WF-S");
const failed = rollup.attention.find((r) => r.instanceId === "WF-F");
expect(failed?.affordance).toContain("rig workflow trace WF-F");
const waiting = rollup.attention.find((r) => r.instanceId === "WF-W");
expect(waiting?.reasons[0]).toBe("waiting on founder-gate-2");
});
it("RAIL 2 dedup: waiting+stuck instance renders ONCE with BOTH classes and reasons", () => {
const rollup = composeAttentionRollup([WAITING_AND_STUCK]);
expect(rollup.attention).toHaveLength(1);
const row = rollup.attention[0];
expect(row?.classes).toEqual(["stuck", "waiting"]);
expect(row?.reasons).toHaveLength(2);
});
it("RAIL 1 structural pin: classification works from deadline.state ALONE — no timestamps provided, none needed", () => {
const bare = { instanceId: "X", status: "active", deadline: { state: "overdue-claimed" } };
const rollup = composeAttentionRollup([bare]);
expect(rollup.attention[0]?.classes).toEqual(["stuck"]);
// And absent deadline (pre-fixback payloads) degrades to not-stuck, never a crash.
const legacy = { instanceId: "Y", status: "active" };
expect(composeAttentionRollup([legacy]).attention).toHaveLength(0);
});
it("proven-empty: clean fleet renders counts + the explicit empty statement, never blank", () => {
const lines = renderStatus(composeAttentionRollup([HEALTHY, COMPLETED]));
expect(lines[0]).toBe("2 instances: 1 active · 0 waiting · 1 completed · 0 failed");
expect(lines[1]).toContain("No instances need attention");
});
it("human render: table with CLASS column and per-row affordance line", () => {
const lines = renderStatus(composeAttentionRollup([WAITING_AND_STUCK, FAILED]));
const text = lines.join("\n");
expect(text).toMatch(/INSTANCE\s+WORKFLOW\s+CLASS\s+REASON/);
expect(text).toContain("stuck+waiting");
expect(text).toContain("└ ");
});
});
describe("attentionMarker stuck upgrade (list column)", () => {
it("stuck marker consumed from the API field; priority failed > stuck > waiting", () => {
expect(attentionMarker(STUCK)).toBe("▲ stuck");
expect(attentionMarker({ ...STUCK, status: "failed" })).toBe("▲ failed");
expect(attentionMarker(WAITING)).toBe("▲ waiting");
expect(attentionMarker(WAITING_AND_STUCK)).toBe("▲ stuck");
expect(attentionMarker(HEALTHY)).toBe("");
});
it("list rows render the stuck marker", () => {
const lines = renderInstanceList([STUCK], "2026-07-07T00:00:00Z");
expect(lines[1]).toContain("▲ stuck");
});
});
describe("humanSeconds", () => {
it("compacts plain seconds on the shared scale", () => {
expect(humanSeconds(30)).toBe("30s");
expect(humanSeconds(7200)).toBe("120m");
});
});
describe("status verb wire behavior", () => {
afterEach(() => vi.restoreAllMocks());
function makeDeps(routes: Record<string, { status: number; data: unknown }>): WorkflowDeps {
return {
lifecycleDeps: {} as WorkflowDeps["lifecycleDeps"],
clientFactory: () =>
({
get: async (path: string) => routes[`GET ${path}`] ?? { status: 200, data: [] },
post: async () => ({ status: 200, data: {} }),
}) as never,
};
}
it("status --json emits the rollup structurally (counts + attention)", async () => {
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const program = createProgram({
workflowDeps: makeDeps({ "GET /api/workflow/list": { status: 200, data: [FAILED, HEALTHY] } }),
});
program.exitOverride();
await program.parseAsync(["node", "rig", "workflow", "status", "--json"]);
const out = JSON.parse(String(logSpy.mock.calls.at(-1)?.[0]));
expect(out.counts.failed).toBe(1);
expect(out.attention).toHaveLength(1);
expect(out.attention[0].instanceId).toBe("WF-F");
});
it("status reads ONLY the shipped list surface (no new daemon route)", async () => {
const calls: string[] = [];
const deps: WorkflowDeps = {
lifecycleDeps: {} as WorkflowDeps["lifecycleDeps"],
clientFactory: () =>
({
get: async (path: string) => {
calls.push(path);
return { status: 200, data: [] };
},
post: async () => ({ status: 200, data: {} }),
}) as never,
};
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const program = createProgram({ workflowDeps: deps });
program.exitOverride();
await program.parseAsync(["node", "rig", "workflow", "status"]);
logSpy.mockRestore();
expect(calls).toEqual(["/api/workflow/list"]);
});
});
@@ -7,14 +7,12 @@ metadata:
last_verified: "2026-07-06"
distribution_scope: product-bound
source_evidence: |
Authored + embedded in release-0.4.3 slice 32 (mission-slice-sop);
moved into the canonical product skill source and updated in
release-0.4.4 slice 23 (operationalize the SDLC control plane) to
teach the full SDLC flow the Living Notes UI projects: the convention
sections, the proof-contract pairing, the two staged-approval locks,
C1 proof drops, and the three role contracts. Conventions SSOT:
docs/reference/sdlc-conventions.md (shipped with the CLI package).
The deterministic backstop is the `rig scope audit` classifier.
The canonical product SDLC skill: it teaches the full SDLC flow the
Living Notes UI projects — the convention sections, the proof-contract
pairing, the two staged-approval locks, C1 proof drops, and the three
role contracts. Conventions SSOT: docs/reference/sdlc-conventions.md
(shipped with the CLI package). The deterministic backstop is the
`rig scope audit` classifier.
sibling_skills:
- queue-handoff
- seat-continuity-and-handover
@@ -0,0 +1,86 @@
---
name: openrig-cmux
description: >
Use when opening OpenRig fleet terminals into cmux — turning a rig, pod, mission, slice, or saved
view into live agent tiles via `rig terminal --provider cmux`, or driving cmux on an agent's
request. Same OpenRig view semantics as openrig-herdr (the verbs, honest-partial/degrade, read-only
cross-rig, scroll/copy, same-size-only duplicates); cmux is the **best-effort** provider (herdr is
the default and the proof-gated one). Prefer openrig-herdr unless cmux is specifically wanted.
metadata:
openrig:
stage: candidate
distribution_scope: product-bound
source_evidence: |
cmux is OpenRig's shipped terminal provider generalized onto `rig terminal <provider>` —
best-effort, not proof-gated. Authored clean-room.
transfer_test: pending
sibling_skills:
- openrig-herdr
- openrig-user
---
# openrig-cmux
cmux is a **provider** for OpenRig views — the same `rig terminal` surface as herdr, rendering the
tiles into cmux instead. It is the provider OpenRig already shipped (the existing "Launch in cmux"
affordance generalizes onto `rig terminal <provider>`), kept working. The OpenRig-semantic half is
identical to openrig-herdr: OpenRig decides **which** agents form a view; the provider renders the
pixels. **Read openrig-herdr first for the full model** — this skill only calls out what differs for
cmux.
## Provider status — cmux is best-effort
- **herdr is the default and the proof-gated provider**; **cmux is best-effort** — it ships on the
same `rig terminal <provider>` + web-launcher neighborhood, but a cmux miss is not a slice failure.
Reach for cmux when it is specifically wanted; otherwise default to herdr (`rig terminal open <view>`
with no `--provider`).
- Views are **provider-agnostic**: the same view + the same agents + the same semantics carry across
herdr and cmux. A saved view opens in either.
## The surface (same three verbs, `--provider cmux`)
```bash
rig terminal open <view> --provider cmux [--json] # open a view into cmux (herdr is the no-flag default)
rig terminal views [--json] # the same view library, provider-agnostic
rig terminal status --provider cmux [--json] # cmux liveness / health
```
`<view>` resolves exactly as with herdr: a **rig name** · **`pod:<rig>/<podNamespace>`** ·
**`mission:<id>`** · **`slice:<id>`** (derived live) · a **saved-view name**.
## What carries over unchanged from openrig-herdr
All of these behave identically — see openrig-herdr for the detail:
- **Honest-partial / honest-degrade** — opened / absent / degraded, each named; partial opens exit 0,
zero-pane exits non-zero; an http-registered host's agents degrade with the reason ("host `<id>` is
http-registered; tiles need ssh"), never silently dropped.
- **Read-only policy** — a rig or `pod:` view is interactive; `mission:` / `slice:` views are
read-only by construction; a saved view is per-member (`readOnly`). A read-only pane is
`tmux attach -r` (client `readonly=1`, keystrokes physically cannot reach the agent). See openrig-herdr.
- **Safety rails (the fleet-safety rail)** — a tile is a *view* (nested `tmux attach`) of a
daemon-owned session; never move/join/kill/re-parent a daemon-owned pane; closing a tile detaches
one client and leaves the session untouched; never live-flip tmux options on a running seat in
production (prove it in an isolated environment first).
- **Scroll + copy out of the box** — the daemon's terminal defaults (per-session scroll at launch +
the server's clipboard defaults); running seats pick up wheel-scroll at their next relaunch.
- **Limits** — tile chrome v1 = a plain label; same-size panes for duplicate/multi-view membership
(the different-size resize mismatch is an inherent tmux multi-client limit, documented not fixed).
## cmux-specific notes
- **Open-or-focus per agent** — cmux's shipped integration opens a node or focuses it if already open;
expect focus (not a duplicate) when a seat is already tiled in cmux.
- **The shipped "Launch in cmux" affordance is preserved** (byte-compatible) and generalizes into the
provider + view picker; opening cmux from the web launcher still works.
- **No AGPL arm's-length concern** — unlike herdr, cmux is OpenRig's shipped provider integration; the
clean-room/never-embed rail that applies to herdr is not a cmux constraint. (This skill itself is
still authored clean-room.)
## Saved views — provider-agnostic
Saved views are **hand-authored** in `terminal-views.yaml` (v1 has no save/write verb) and are
**provider-agnostic** — see **openrig-herdr** for the exact schema, the store facts (atomic tmp+rename,
byte-stable, at the OpenRig home root), and the derived-views-never-persisted rule. Reopen any saved
view in cmux with `rig terminal open <id> --provider cmux`. Derived views (a rig, `pod:<rig>/<pod>`,
`mission:<id>`, `slice:<id>`) are computed live and never written to the file.
@@ -0,0 +1,158 @@
---
name: openrig-herdr
description: >
Use when opening OpenRig fleet terminals as a herdr wall — turning a rig, pod, mission, slice, or
saved view into live interactive agent tiles via `rig terminal`, watching another rig read-only,
or driving herdr on an agent's request ("open all my rigs + a mission as views"). Covers the
`rig terminal open|views|status` verbs, the honest-partial/degrade reading of the result, the
read-only-by-construction rail for cross-rig views, scroll/copy out of the box, and the
same-size-only duplicate-pane limit. herdr is the default, proof-gated provider.
metadata:
openrig:
stage: candidate
distribution_scope: product-bound
source_evidence: |
Empirical herdr 0.7.1 hands-on findings + the ratified implementation PRD. Authored
clean-room (patterns only, no herdr source text; AGPL arm's-length).
transfer_test: pending
sibling_skills:
- openrig-cmux
- openrig-user
---
# openrig-herdr
OpenRig decides **which** agents make up a view (a rig, a pod, a mission, a slice, or a saved
group); **herdr** renders the pixels. A view opens as live, interactive terminal tiles — each tile
is a nested `tmux attach` to a daemon-owned agent session, so you get the real session, not a
snapshot. OpenRig owns the semantics; herdr owns the surface. You drive it entirely through the
`rig terminal` CLI, which rides the installed herdr binary at arm's length — never link, embed, or
plugin it.
## The whole surface — three verbs
```bash
rig terminal open <view> [--provider herdr|cmux] [--json] # herdr is the default provider
rig terminal views [--json] # list openable views (saved + derived)
rig terminal status [--provider] [--json] # provider liveness / health
```
`<view>` resolves, in order, to one of:
- a **rig name** — every live agent in that rig, auto-laid-out;
- **`pod:<rig>/<podNamespace>`** — every live agent in one pod of a rig (the rig's inventory filtered by pod);
- **`mission:<id>`** — the agents working that mission (derived live from topology);
- **`slice:<id>`** — the agents working that slice (derived live);
- a **saved-view name** — a user-defined group (see Saved views).
## Compose a view from a sentence
An agent asked "open all my rigs plus a mission as views" runs one `open` per target:
```bash
rig terminal open acme-web # a whole rig, live agents as tiles
rig terminal open mission:site-relaunch # exactly the agents working the mission
rig terminal open slice:02-search-filters # the agents working one slice
```
No hand-listing of seats: the mission/slice membership is derived from live topology at open time.
## Read the result honestly — partial and degrade
The result is a partition — every seat lands in exactly one bucket, each **named**:
- **opened** — the live agents now showing as tiles.
- **absent** — seats in the view that aren't currently live: **named and skipped, never silently
dropped.** A view that opens *some* of its seats is a success (the partial is disclosed) and exits
**0**. Only a view where **no** pane opens exits **non-zero**.
- **degraded** — an agent that structurally cannot tile, named **with the reason**. The v1 case:
an agent on a host registered over HTTP has no ssh path, so its tile can't be composed — it reads
as **"host `<id>` is http-registered; tiles need ssh"** and is skipped, never dropped. (ssh-reachable
hosts tile via an ssh-wrapped attach; full http-host tiling is deferred to the cross-host transport
seam.)
Read `--json` to branch on the partition programmatically; the exit code alone tells you opened-something
(0) vs opened-nothing (non-zero).
## Read-only — who's interactive, who's watch-only
Read-only is composed into the pane at open time — a read-only tile is a `tmux attach -r`, the client
reports `readonly=1`, and it **physically cannot send input**. The current policy, by view kind:
- **A rig view or a `pod:` view is interactive** — you asked for that rig (or pod) directly, so you
can drive its agents.
- **`mission:` and `slice:` views are read-only by construction** — these derived views cut across
rigs; you watch and scroll, you don't keystroke into them.
- **A saved view is per-member** — each member carries its own `readOnly` (omit = interactive;
`true` = `attach -r`).
So you don't have to remember to be careful: the view kind (and, for a saved view, the per-member
flag) sets it at open time.
## Safety rails (hard — the fleet-safety rail)
- A tile is a **view** (a nested `tmux attach`) of a daemon-owned session. **Never** move, join, kill,
or re-parent a daemon-owned pane. Closing a tile detaches one tmux client — the daemon's session is
untouched and its addressing (send/capture/nudge) is unaffected.
- Host-local read-only viewing mutates nothing. Anything that would change a **live** daemon session's
shared state (e.g. flipping a tmux option on a running seat) is a config/design change — prove it in
an isolated environment, never live-flip in production.
## Scroll + copy work out of the box
On a freshly-launched agent tile the mouse wheel scrolls the pane's history and a drag-select copies
to your **system** clipboard — with no tmux commands typed. This rides the daemon's terminal defaults
(a per-session scroll option set at launch, plus the daemon tmux server's clipboard defaults). Honest
timing: agents that were already running before this shipped pick up wheel-scroll at their **next
natural relaunch** (the scroll default is per-session and running seats are never retro-flipped);
system-clipboard copy works immediately (it's server-wide).
## Limits — state them, don't paper over them
- **Tile chrome v1 = a plain label** (agent + slice). Rich per-tile status is roadmap.
- **Duplicate / multi-view membership** (the same agent live in two views at once) works — put the
duplicates in **same-size panes**. Different-sized duplicate panes have an inherent tmux
multi-client resize mismatch (tmux clamps to the smallest client); it is **documented, not fixed** —
don't expect a setting to remove it.
- **Inner tmux status bar** is hidden by default (herdr provides the chrome); one config key
(`terminal.status_bar`) flips it back on for raw-tmux / no-provider surfaces, and the flip applies
to **future launches only**.
## Saved views — the library (`terminal-views.yaml`)
In v1 you **create** a saved view by **hand-authoring** `terminal-views.yaml` — there is no save/write
verb (`open` / `views` / `status` are the whole surface; a `rig terminal save <id>` verb is a named
stretch/follow-up). You **reopen** it like any other view: `rig terminal open <id>`.
The file lives at the OpenRig home root (resolved via `getDefaultOpenRigPath()`), is written
atomically (tmp + rename), and is byte-stable. **Only hand-authored saved views live here — derived
views (a rig, `pod:<rig>/<pod>`, `mission:<id>`, `slice:<id>`) are computed live and never written to
this file.**
Schema — use these field names **exactly**, in this order; **omit** optional fields when absent
(never write `null`):
```yaml
version: 1
views:
- id: my-view-id # required — the id `rig terminal open <id>` takes
name: Human Name # required
members:
- seat: pod-member@rig # required — the canonical session name
label: agent . slice # optional — pane label
host: some-host-id # optional — STRUCTURED host id (NEVER a `member@rig@host` string);
# omit for local. ssh-registered hosts tile;
# http-registered hosts honest-degrade (named + skipped, with reason)
tmuxSession: sessname # optional — defaults to `seat`
readOnly: true # optional — omit = interactive; true = `tmux attach -r`
```
An agent composing a saved view on request **writes this YAML** and then opens the id:
`rig terminal open my-view-id` (add `--provider cmux` for cmux). The library is provider-agnostic —
the same saved view opens in herdr or cmux.
## AGPL / clean-room
Driving herdr through the `rig terminal` CLI (which talks to the installed herdr binary over its
CLI/socket) is arm's-length and fine. Do **not** link herdr's source, embed it in-process, or ship a
herdr plugin — a plugin needs legal review. This skill and its docs are clean-room: patterns only,
never herdr source text.
@@ -1,70 +1,649 @@
---
name: openrig-user
description: Use when operating OpenRig with the `rig` CLI and you need the shipped command surface for identity, inventory, communication, lifecycle, specs, recovery, or agent-facing JSON output. NOT for debugging host-side runtime issues (use openrig-operator) or for changing OpenRig itself (work in the openrig product repo).
metadata:
openrig:
stage: factory-approved
last_verified: "2026-05-04"
distribution_scope: product-bound
source_evidence: |
Bootstrap skill — NPM install lands this in personal homes (~/.claude/skills/, ~/.agents/skills/) so agents have it at every boot.
sibling_skills:
- openrig-operator
- openrig-architect
- forming-an-openrig-mental-model
transfer_test: pending
notes: |
Description was already correct (starts with "Use when..."; lists triggering domains without summarizing workflow). No frontmatter rewrite needed.
2026-05-04 sync: body content updated for OpenRig v0.2.0 release — adds Runtime-Gated Coordination Primitives section (PL-004 Phase A-D commands: rig stream / rig queue / rig project / rig view / rig watchdog / rig workflow), default posture for daemon vs substrate coordination commands, capture discipline section. Frontmatter metadata.openrig.* preserved across the sync.
description: Use when operating OpenRig with the `rig` CLI and you need the shipped command surface for identity, inventory, communication, lifecycle, specs, recovery, or agent-facing JSON output.
---
# OpenRig User
This is an as-built guide to the shipped `rig` CLI.
Use current code and `rig ... --help` as ground truth if anything here ever conflicts with older planning docs.
This is the daily-driving CLI guide for an OpenRig operator. It is not
a guide to OpenRig internals or to changing OpenRig behavior — for that,
work in the openrig product repo.
This is not the config-layer or builder guide. Use the substrate control-plane guidance for `rigx`
and experimental overlays. Use the OpenRig builder guidance when changing OpenRig behavior,
doctrine, or release posture.
## Coordination primitives — when to use which
Three coordination surfaces, used together for forward-momentum work. Internalize this
on first read — it shapes every turn you take in a rig.
### `rig send <seat> "<text>"` — intra-pod direct messaging / nudges
Use when you need to ask a quick question or give a teammate context that does not
carry handoff semantics. **NOT for durable work.** NOT for state that must survive
across turns. The message lands in the target's pane; there is no durable queue
record. The CLI prints `Sent to ...` (and `Verified: yes/no` with `--verify`); read
the receipt and move on.
Example:
```bash
rig send reviewer@acme-web "Heads up — filing the per-commit handoff on the search-filters slice at the current tip." --verify
```
### `rig queue create --source <X> --destination <Y> --tags <...> --body "<...>"` — durable work item
Use for any substantive work that must not fall through chat — slice handoffs,
guard verdicts, QA results, full-tip reviews, multi-item batches. Survives agent
restarts. Tracked in the daemon SQLite schema. Surfaces in Project / queue views
+ in the destination seat's inbox. Tag with mission / slice / gate / checkpoint
so future-you (and any peer) can find it.
Body discipline: substantive bodies go through a temp-file pattern, not inline
`--body` with raw backticks — `rig queue create` body parsing breaks on
unescaped backticks and rejects flag-like tokens.
Example:
```bash
rig queue create \
--source builder@acme-web \
--destination reviewer@acme-web \
--tags "mission:site-relaunch,slice:02-search-filters,gate:guard,handoff:per-commit,checkpoint:bug-1" \
--body "$(cat /tmp/per-commit-body.txt)"
```
### `rig queue handoff <qitem-id> --to <next> ...` — hot-potato handoff
Use when you have completed your turn on a qitem and the work moves to the next
owner. **This is forward momentum.** The ball passes to the destination seat;
chain-of-record (the prior qitem id) is preserved so the verdict trail is intact;
tags carry the phase boundary forward (e.g. `gate:guard` → `gate:qa`).
Example:
```bash
rig queue handoff qitem-20260601012431-d78aa805 \
--to qa@acme-web \
--tags "mission:site-relaunch,slice:02-search-filters,gate:qa,handoff:adversarial-dogfood"
```
### §1b doctrine — turn ends by passing the ball
**A turn ends by passing the ball, never by going idle holding the slice waiting
on a confirmation the process does not include.** If the work was authorized, the
per-commit guard + adversarial QA + orch heavy-verify are the guardrails — not an
operator pre-commit gate. Do the authorized work and pass the ball.
Valid pauses are only:
- A genuine blocker — file a blocked-state qitem against the blocking peer or
surface explicitly to orch.
- A scope-or-architecture question that requires owner input and changes the
plan — surface to orch with the specific decision needed.
Implementing already-authorized work is neither of these. Proceed without
phantom-gating on an imagined "next prompt" or "operator confirmation" that the
process does not require.
### Anti-patterns
- Using `rig send` for durable work → use `rig queue create` instead. Sends do
not survive restarts and do not show up in queue/project views.
- Idle-holding a slice for an imagined "next prompt" or "operator confirmation"
that the process does not require → pass the ball via `rig queue handoff` and
proceed to the next slice or stand by for the inbound verdict. See the §1b
doctrine above.
- Hand-coding `rigx queue` for new work → `rig queue` is the daemon-backed
canonical surface since the 2026-05-11 host-CLI fix. `rigx queue` is a
recovery-only fallback; qitems written via `rigx queue` are invisible to
daemon-backed reads and break fleet-wide routing discipline.
- Inlining a multi-line / backtick-heavy body into `rig queue create --body`
→ write the body to `/tmp/<descriptive-name>.txt` first, then
`--body "$(cat /tmp/<file>.txt)"`. The body parser does not tolerate raw
backticks or flag-like tokens inline.
## Runtime-Gated Coordination Primitives
OpenRig v0.2.0 is published publicly as `@openrig/cli@0.2.0` and GitHub Release
`v0.2.0`. It includes the bundled PL-004 Coordination Primitive System: Phase A
OpenRig v0.3.1 is published publicly as `@openrig/cli@0.3.1` and GitHub Release
`v0.3.1`. It includes the bundled PL-004 Coordination Primitive System: Phase A
`rig stream` / `rig queue`, Phase B `rig project` / `rig view`, Phase C
`rig watchdog`, and Phase D `rig workflow` / `workflow-keepalive`.
Do not confuse public package truth with this host's active daemon truth. As of 2026-05-04, the
human reports the production daemon has been upgraded to v0.2.0; a local check showed `rig
--version` and the product package version at `0.2.0`, with the daemon running on port 7433.
These are shipped product surfaces in v0.3.x, but they require a compatible
v0.3.x daemon and matching SQLite schema at runtime — the installed package
version is not automatically the version of the daemon serving you. If a
coordination command behaves unexpectedly, confirm the running daemon with
`rig whoami --json` and daemon status before assuming a product bug.
Default posture on this host now:
Default posture:
- Treat daemon `rig queue`, `rig stream`, `rig project`, `rig view`, `rig watchdog`, and
`rig workflow` as active-host coordination surfaces for PL-004-backed work.
- **CANONICAL SURFACE NOTE (2026-05-11)**: `rig queue` (daemon-backed SQLite) is the
canonical surface for queue routing since the 2026-05-11 host-CLI fix landed. Use
`rig queue create / handoff / update / show / list` for all substantive work.
- Use substrate `rigx queue`, `rigx stream`, `rigx project`, and `rigx view-proto` only where the
current workflow is still explicitly operating on the temporary substrate coordination layer or
a legacy artifact has not migrated. For queue specifically, `rigx queue` is recovery-only
fallback; qitems written via `rigx queue` are invisible to daemon-backed reads and break
fleet-wide routing discipline.
`rig workflow` as the product coordination surfaces when the active daemon is v0.2.0 or newer.
- **CANONICAL SURFACE NOTE (2026-05-11)**: `rig queue` (daemon-backed SQLite) became
the canonical queue-routing surface when the 2026-05-11 host-CLI fix landed. The
coordination model is now load-bearing at the top of this skill — see
"Coordination primitives — when to use which" above for the send / queue /
queue-handoff usage model and the §1b doctrine. Auxiliary queue verbs:
`rig queue update / show / list` complement `rig queue create / handoff`
for in-flight inspection and state mutation.
- Use temporary substrate overlays such as `rigx queue`, `rigx stream`, `rigx project`, and
`rigx view-proto` only where the current OpenRig workstream explicitly says that legacy/control
layer is still in use. For queue specifically, `rigx queue` is recovery-only fallback;
qitems written via `rigx queue` are invisible to daemon-backed reads and break fleet-wide
routing discipline.
- If a daemon-backed coordination command fails, debug the command/runtime/schema edge directly;
do not fall back to stale pre-upgrade assumptions about this host.
- Do not run daemon stop/start, wrapper mutation, production DB copy/mutation, git push, tags, npm
publish, or GitHub release unless a human grants the specific bounded gate. The public release
already exists; further release operations need explicit scope.
do not assume the right workaround is to drop back to a config-layer primitive.
- Do not perform daemon stop/start, production DB copy/mutation, release, publish, or other
consequence-boundary actions unless the operator/workstream has granted that specific gate.
## First-user workspace setup
When booting into a rig on a host where the workspace is unset, gap-ridden, or
points at a stale layout, address that before substantive project work. The
shipped surface is small + bounded — reach for the canonical commands rather
than improvising.
### Detect workspace state at boot
Agent-actionable when the daemon is reachable.
```bash
rig workspace validate --json
rig workspace validate <path> --kind <user|project|knowledge|lab|delivery> --json
```
`rig workspace validate` walks the workspace root and emits a structured
frontmatter-gap report against the v0 contract. Exit code is non-zero when
gaps exist (operators chain into hygiene fix loops). Default root is the
current directory; pass a positional path to validate elsewhere. `--kind`
scopes the contract to a specific workspace kind; omit for a kind-agnostic
structural check.
If `rig workspace validate` reports a non-zero `gapCount` OR the workspace
root is unset / unwritable, the workspace needs instantiation — see the next
section.
### Instantiate the canonical workspace scaffold
Agent-actionable. Idempotent on existing dirs without `--force`.
```bash
rig config init-workspace
rig config init-workspace --root <path>
rig config init-workspace --dry-run --json
```
`rig config init-workspace` scaffolds the canonical workspace layout at the
configured `workspace.root` (default `~/.openrig/workspace`):
- `missions/` — release missions + slices
- `artifacts/` — work artifacts produced inside the workspace
- `evidence/` — non-dogfood evidence (release evidence, proof packets, etc.)
- `progress/` — progress index + per-mission rails
- `field-notes/` — operator + agent observations
- `specs/` — spec library (rig + agent + workflow YAML lives here)
- `dogfood-evidence/` — dogfood proof packets + run artifacts
The scaffold seeds one example mission (`getting-started`) with multiple
slices, and drops a workspace README.md + STEERING.md so a fresh install has
browsable Project content. `--root <path>` targets a non-default root for
this call; `--dry-run` reports what would be created without writing.
`--force` overwrites existing FILES but never deletes
directories — operator content is safe.
### Redirect the workspace root
Operator-gated when persistent. Agent-actionable when one-shot via env-var.
For a single command:
```bash
OPENRIG_WORKSPACE_ROOT=<path> rig <command> ...
```
For a persistent host-level redirect, the operator changes the config file or
runs the setter:
```bash
rig config set workspace.root <path>
```
ConfigStore precedence: `OPENRIG_WORKSPACE_ROOT` env > config-file
`workspace.root` > built-in default `~/.openrig/workspace`. The same
precedence governs `OPENRIG_WORKSPACE_SPECS_ROOT` → `workspace.specs_root`
(default `<workspace_root>/specs`).
Prefer the env-var form for one-shot redirects (transparent to operators);
reserve `rig config set` for changes the operator owns.
### Build a workspace from scratch
Agent-actionable. Same surface as the canonical scaffold above; the
`workspace.root` cascade handles non-existent host paths.
```bash
rig config init-workspace --root /path/to/new/workspace
```
The command creates the root dir if missing (idempotent: existing root +
populated subdirs is a no-op). Run
`rig workspace validate /path/to/new/workspace --json` after to confirm the
contract holds.
### Create a workflow inside an existing workspace
Authoring is operator-or-agent; validation + instantiation are
agent-actionable.
Workflow spec files live at:
```
<workspace_root>/specs/workflows/<name>.yaml
```
`<workspace_root>` resolves via the ConfigStore precedence named above.
There is no `rig workflow create` verb in v0.3.x — the spec YAML is authored
directly. Template by hand from the documented schema, or copy a built-in
starter from `<openrig install>/dist/builtins/workflow-specs/` and adapt.
Once written:
```bash
rig workflow validate <workspace_root>/specs/workflows/<name>.yaml --json
rig workflow instantiate <workspace_root>/specs/workflows/<name>.yaml \
--root-objective "<one-line objective for the run>" \
--created-by <your-session>@<your-rig> \
--json
```
Both `--root-objective <text>` and `--created-by <session>` are REQUIRED
on `instantiate` — omitting either yields a Commander required-option
error before the daemon is contacted. `--entry-owner <session>` is an
optional override for the entry-step owner; default routing is per the
workflow spec.
`validate` returns a structured ok/error report; `instantiate` creates a
workflow instance + entry-step qitem. Inspect existing surface state with:
```bash
rig workflow specs --json # list registered specs (built-in + operator-authored)
rig workflow list --json # list active workflow instances
rig workflow show <instanceId> --json
```
## v0.3.x Starter, Workspace, And Plugin Surfaces
OpenRig v0.3.0 adds `rig agent-image`, `rig context-pack`, `rig workspace`, and
`rig config init-workspace`. It also shifts fresh-user starter guidance toward
`product-team` for human-directed work and `conveyor` for workflow-oriented
work. Treat `demo` as legacy/test content unless a task specifically asks for
the old demo spec.
OpenRig v0.3.1 adds public package/source surfaces for Plugin Primitive v0,
Claude Auto-Compaction Policy, migration `040_workflow_specs_diagnostic`,
Library Explorer finishing, Settings Destination Explorer, Dashboard/For You
vellum refresh, storytelling adapter, and action outcome + inline error UX.
`rig plugin` is read-only at v0:
```bash
rig plugin list
rig plugin show <id>
rig plugin used-by <id>
rig plugin validate <path>
```
There is no `rig plugin install` verb in v0.3.1. Plugin installation remains
explicit operator copy/symlink to `$OPENRIG_HOME/plugins/<plugin-id>/`.
Claude auto-compaction policy is opt-in default-off. The v0.3.1 package and
this host's active daemon ship `policies.claude_compaction.*` ConfigStore keys,
but no behavior changes unless the operator enables the policy.
Known v0.3.0/v0.3.1 caveats:
- `rig down` now accepts a rig name or id (symmetric with `rig up`): the earlier
name-to-404 caveat (the D1 path) is resolved in v0.3.3. An ambiguous name
matching more than one active rig is refused with the matching ids; re-run
with `rig down <id>`.
- `rig queue` / `rig view` JSON and limit compatibility drift is an open
follow-up from host-adoption proof; treat it as a compatibility caveat, not a
daemon-health failure.
- Queue/view JSON/limit drift is now refined as a wrapper-layer routing issue,
not a daemon-layer issue; use human-readable output for affected wrapper
commands until v0.3.2.
- First v0.3.1 daemon start hit a plugin-vendor fallback health-probe timeout;
controlled retry succeeded. Manual retry is the current workaround.
- Topology mobile drawer restoration and plugin source-label taxonomy are
v0.3.2 carry-forwards.
## Recovery and Resilience (v0.3.4+)
v0.3.4's theme is Recovery + Resilience. The surfaces below compose into a
single boot-to-running-rig path that survives crashes, hand-resumed sessions,
profile-load drift, and partial workspace state without silently fudging
status.
### `rig start` — recovery entrypoint
`rig start` is the top-level recovery sequencer. It does not invent recovery;
it composes existing primitives (daemon start + kernel verify + per-rig
restore) into one call.
```bash
rig start # interactive: daemon + kernel + pick-and-restore
rig start --last # headless: restore all rigs that were last running
rig start --all # headless: restore all rigs with restore-usable snapshots
rig start --rigs <name> [<name>...] # headless: restore only the named rigs
rig start --json # JSON output for agents
```
Framing: `rig start` is the RECOVERY entry point, not the getting-started
hero. The fresh-user boot hero remains `rig up <starter>` (typically
`rig up product-team`). Reach for `rig start` after a host reboot, daemon
restart, or any "bring my rigs back" moment.
### `rig reconcile-session` — no-launch adopt of a hand-resumed session
When an operator has externally resumed an agent session (e.g. attached a
shell, restarted a runtime by hand) and you want OpenRig to reconcile its
lifecycle state without re-launching or sending input, use:
```bash
rig reconcile-session <session>
rig reconcile-session <session> --rig <rigId> --node <logicalId>
rig reconcile-session <session> --no-launch
rig reconcile-session <session> --json
```
This is a no-launch, no-input adopt. `--rig`/`--node` disambiguate when the
canonical session name does not uniquely resolve. `--no-launch` is accepted
for explicitness (it is the only mode this command has).
### Five-term restore status vocabulary
The shipped restore vocabulary is intentionally honest. It surfaces in
`rig up` / `rig restore` / `rig ps`. Use the term that fits — do not collapse
to a generic "ok/failed":
- `resumed` — seat resumed from its original session/snapshot and is live.
- `fresh-primed` — seat opted into `--fresh` and was freshly started.
- `awaiting-decision` — zero-session honest state. There is no resumable
session AND no `--fresh` opt-in was given; the seat is waiting for an
operator decision. Previously fudged as `failed`; that was wrong — nothing
is broken, the system is asking for input.
- `attention_required` — seat is in a state needing operator attention; not
a transport failure. Clear via `rig seat clear-attention` once the
attention has been resolved.
- `failed` — the send transport or launch genuinely failed.
This replaces the prior collapsed model (the v0.3.3 four-term vocabulary, in
which `rebuilt` was a term, is retired).
### `rig seat clear-attention` — audited reconcile of stuck attention
When a seat is stuck in `attention_required`, do NOT hand-edit SQLite to
fake-clear the state. Use the evidence-gated, operator-attested, audited
reconcile:
```bash
rig seat clear-attention <session>
rig seat clear-attention <session> --reason "operator attested: founder re-authed, confirmed live"
rig seat clear-attention <session> --json
```
`--reason <text>` is the operator-attestation override path; without it the
command runs the evidence gate. Either way the action is audited.
### Periodic snapshots — crash-insurance floor
The daemon ships a periodic-snapshot scheduler. It runs independently of
teardown events and provides the crash-insurance floor that prior
event-only/teardown-only snapshots could not provide on hard crashes.
Config keys (SettingsStore):
- `snapshots.periodic.enabled` — default `true`
- `snapshots.periodic.interval_seconds` — default `300`
- `snapshots.periodic.retention_keep` — default `10`
Newest-wins semantics: when both `auto-periodic` and `auto-pre-down`
snapshots exist for a rig, the freshest of the two is selected for restore.
A newer `auto-periodic` beats a stale `auto-pre-down` (the crash fix); a
genuinely-fresher `auto-pre-down` still wins on graceful cycles. Manual
snapshots are handled separately. See
`packages/daemon/src/domain/snapshot-repository.ts` for the ordering rule.
The last-snapshot floor surfaces in `rig ps` / status output so an operator
can see at a glance how recent the crash-insurance floor is.
### Codex profile-v2 preflight
Profile-bearing launch/restore surfaces run a profile-load preflight. When
profile-load issues are detected, the failure is honest and actionable
(named error + remediation pointer) instead of a silent partial launch that
would later look like an attention_required seat with no explanation.
### cmux launch readiness
cmux-backed launches no longer produce silent partial workspace state. When
parts of the workspace are missing, the launch surfaces partial state
honestly and the UI exposes a one-click open-missing affordance.
(See also `## Token-Efficient Defaults (v0.4.0+)` below for the compact-by-default read-command surface that lands in 0.4.0.)
## Token-Efficient Defaults (v0.4.0+)
v0.4.0 flips the five most frequently invoked read-commands from firehose-by-default to compact-by-default, and `rig queue list` adopts the docker / kubectl read-command grammar. **All defaults preserve breadth and capability — the firehose is one explicit flag away.**
### `rig ps` — consolidated all-rigs default + the disclosure ladder (v0.4.4)
```bash
rig ps # ALL ACTIVE RIGS, one compact row each + rollup line + count line + ladder footer (default)
rig ps --json # bare array of ALL non-archived rigs (incl. stopped; existing keys + additive attentionCount)
rig ps --rig <name> # one rig's detail
rig ps --nodes # compact node inventory (current rig — session default, LOCAL only)
rig ps --nodes --rig <name> # compact node inventory, named rig
rig ps --nodes -A # fleet node inventory, projected rows
rig ps --nodes -A --full # complete per-node records (the ONLY full fan-out; resumeToken VALUE retained here for downstream consumers)
rig ps --nodes --session <sess> # narrow to one canonical session
rig ps --include-archived # archived history as rows (otherwise ONE count line)
rig ps --active # opt-in active-state filter (does NOT change the all-states default — ps surfaces topology/readiness, where stopped/recoverable/attention IS the actionable signal)
```
**v0.4.4 consolidated default + disclosure ladder (OPR.0.4.4.21)**:
- **The default is the fleet MAP**: every active rig as one O(rigs) compact row, with the host rollup ("N rigs · M seats · K need attention"), the archived/stopped count line, and the drill-ladder footer. The v0.4.0 current-rig default is RETIRED — it hid running rigs from the operator's field of view.
- **`-A` has exactly ONE meaning**: the `--nodes` fleet widener. Bare `rig ps -A` errors (all-rigs IS the default; archived history stays behind `--include-archived`).
- **The session-rig default applies ONLY to `--nodes`, and only locally.** Implicit scope defaults don't cross host boundaries: `rig ps --host <id> --nodes` requires an explicit `--rig` or `-A`; multi-host fan-out is rollup-only by default; the full explicit ladder (`--all-hosts --nodes -A`, `--full` for complete records) fans out per-node with hostId-stamped projected rows.
- **STATED JSON contract**: default `--json` = bare array of ALL non-archived rigs INCLUDING stopped ones (scope-not-shape: existing keys preserved, additive `attentionCount`); only the human table folds stopped rigs into the count line.
- **Resume-token security unchanged**: compact output never carries token material; `--full` retains it for consumers that legitimately need it.
**The casual status glance is just `rig ps`.** The old fleet firehose is the explicit last rung: `rig ps --nodes -A --full`. The ~77,000-token incident class stays closed (compact + O(rigs) default + explicit ladder).
### `rig host` — the multi-host registry verbs (v0.4.4)
```bash
rig host add --id <id> --transport ssh --target <tailnet-alias> --user openrig
rig host add --id <id> --transport http --url http://100.x.y.z:7433 --bearer-env MY_TOKEN
rig host list # pointers only — never secret values
rig host doctor <id> # stepwise: transport -> rig binary -> daemon -> identity
rig host doctor <id> --posture product-factory-vps [--public-addr <ip>]
```
Exactly three verbs (capped); the VPS factory bootstrap is script + runbook
(`docs/reference/product-factory-vps-runbook.md`). **Transport posture is
DECIDED and documented** (cli-reference §Cross-host execution): ssh = pane ops
(`send`/`capture`), http = daemon REST (`up`/`down`/`launch`), `ps`/`whoami`
follow the DECLARED transport; fan-out is http-only; no cross-transport
fallback; no http parity for send/capture in 0.4.4. Posture/doctor UNKNOWN is
never pass — each unknown carries the command that verifies it.
### `rig whoami` — compact-by-default + `--full` (`--verbose` alias)
```bash
rig whoami # compact: identity + peers names + edges + transcript path
rig whoami --json # compact JSON (~192 tokens)
rig whoami --full # complete payload (~909 tokens; v0.3.4 default shape)
rig whoami --verbose # alias of --full
```
The first command every agent runs on boot AND every compaction-restore. The compact default keeps identity-recovery essentials (`identity`, `peers` names + sessionNames, `edges` directional `kind` + `to.sessionName`, `transcriptPath`). `--full` adds `contextUsage`, `commands`, `peersNote`, `runtimeContext`. The compact-default is an ALLOWLIST projection — future payload fields default to `--full` and cannot silently re-bloat the every-boot path.
### `rig queue list` — active-frontier + docker/kubectl grammar
```bash
rig queue list # active, compact, CURRENT-rig (docker-ps default)
rig queue list -a # + closed/done history within current breadth (docker -a)
rig queue list -A # cross-rig breadth (kubectl -A)
rig queue list --full # add body + chain-of-record + transition history
rig queue list -o json # compact JSON (token-safe, machine-parseable)
rig queue list --full -o json # full JSON
rig queue list --mine # just the caller's items
rig queue list --destination <s> # destined to <s>
rig queue list --source <s> # sourced by <s>
rig queue show <qitemId> # full single item (kubectl describe)
```
Four orthogonal axes (scope × history × field-breadth × encoding), all composable. **STOP using bare `rig queue list` as the cross-rig firehose.** Default is now active + compact + current-rig. The cross-rig + history + full-body firehose (the ~64,000-token bomb on this host) is opt-in via `-A -a --full`.
### `rig restore-check` — summary + not-ready-only default + `--full`
```bash
rig restore-check # summary counts + not-ready seats (with reasons) only
rig restore-check --full # complete per-seat readiness across the fleet (v0.3.4 default)
rig restore-check --rig <name> # narrow
rig restore-check --as <session> # narrow to one seat
```
Closes the largest measured bomb (~79,000 → low thousands). Summary correctly identifies EVERY not-ready seat (no false-ready omission); detail is dropped only for ready seats.
### `rig context` — compact summary + `--full`
```bash
rig context # compact summary
rig context --full # complete current payload
rig context --rig <name> # narrow to one rig
rig context --threshold 80 # filter to seats at/above 80%
```
Lower leverage than the others but keeps the read-command surface compact-by-default after the upgrade.
### Why this matters
This release closes the host-version-aged token-burn class: on this host the read-commands accumulated to ~225,000 tokens of context-window cost over a typical orchestrator session, almost all of it firehose-when-a-glance-was-wanted. Compact defaults restore the lean-monitoring doctrine: a narrow status check must be cheap. The full payloads remain one flag away when actually needed.
### Token-efficiency-boot-guardrail pack (interim) — CLI-prohibitions RETIRE at host-upgrade
The interim `token-efficiency-boot-guardrail` pack (the CLI-command prohibitions on `rig queue list` unfiltered, `rig ps --nodes --json` unfiltered, `rig restore-check`, `rig context`, `rig whoami --json`) is a host-version workaround for the bloated defaults this release closes. **The CLI-command-prohibitions half retires when 0.4.0 lands on the host.** The pack's bounded-local-search rule + scope / over-flag discipline GRADUATE to a standing convention (`conventions/bounded-local-search-and-flag-scope`) and continue to apply host-independently.
### `rig scope mission|slice progress` — deterministic progress updates (slice 33)
```bash
rig scope mission progress <mission> --status <state> --milestone <text>
rig scope slice progress <slice-path> --status <state> --note <text>
```
Replaces hand-editing `PROGRESS.md` with markdown. Writes the canonical structure the OpenRig PROGRESS UI page reads. `rig scope mission create` + `rig scope slice create` now scaffold `PROGRESS.md` automatically per `conventions/scope-and-versioning/README.md`.
### `rig scope mission|slice stage / verified / reconcile` — deterministic maturity vocabulary (slice 35)
```bash
rig scope slice stage <slice> <new-stage> # wip / provisional / established / canonical / superseded / retired
rig scope slice stage <slice> superseded --successor <id> # superseded REQUIRES --successor (rejected otherwise)
rig scope mission stage <mission> <new-stage> # same enum + rules at mission tier
rig scope slice verified <slice> --against "<source>" # stamp `verified: <today> against <source>`; --against MANDATORY
rig scope mission verified <mission> --against "<source>"
rig scope slice reconcile <slice> # idempotent repair: backfill PROGRESS.md, conform id/stage/verified, repair ghosts
rig scope mission reconcile <mission> # mission-tier idempotent repair
rig scope slice show <slice> # derives read-time effective-reliability from (stage × verified)
# — stale-`verified` `canonical` reported as effectively `provisional`
```
Composes with slice 33's `progress` + scaffolding to make `rig scope` the **deterministic enforcer** of `conventions/scope-and-versioning` §1 (dot-IDs) + §2 (maturity vocabulary). Agents update `stage` / `verified` / `id` through commands rather than hand-editing markdown and drifting. The `--against` MANDATORY rule on `verified` is the anti-stale keystone: bare timestamps are rejected because a bare timestamp is exactly what lets stale trackers lie while looking fresh. **STOP hand-editing the `stage` / `verified` / `id` fields in scope frontmatter; use the new verbs.** Existing missions / slices with `id:null` ghosts or missing `PROGRESS.md` are repaired idempotently via `reconcile`.
### The SDLC control plane — convention sections, the two locks, `rig proof` (v0.4.4)
The Living Notes UI is a plain projection of well-formed on-disk markdown. The conventions live in ONE shipped document — **`docs/reference/sdlc-conventions.md`** (in the CLI package) — and the operating procedure is the **`mission-slice-sop`** skill. The command surface:
```bash
rig scope slice create <mission> <slug> [--template <kind>] # scaffolds ## Intent / ## Mini-requirements / ## Proof contract
# + proof/ + PROOF.md + IMPLEMENTATION-PRD.md — EVERY template kind
rig scope slice approve <slice> --scope spec # PLAN-LOCK: "the PRD matches my intent; this set gets built"
rig scope slice approve <slice> --scope delivery # PROOF-LOCK: the terminal sign-off (default scope; fires the freeze)
rig proof add <slice> --artifact-type qa --verdict PASS \
--candidate-sha <tip> --money-evidence "<one line>" \
--evidences "1,3" --media "walk.webm" \
--self-check "<looked at it>" # C1 proof drop into proof/; --evidences joins the drop to its
# proof-contract items and --media names the curated proof/-relative
# media it stands behind (what the DELIVERED section pairs + renders)
rig scope audit <mission> # deterministic backstop — flags missing sections / malformed
# proof contract / invalid C1 headers; ADVISORY, never blocks
```
The flow: intent → mini-requirements + proof contract (→ mockups for UI slices) → plan-lock → build the locked set → QA visual compare → `rig proof` drops → proof-lock. Approval is freeze/sign-off, **never** proven-green — proven-green requires recorded C1 verdicts.
### `rig skill audit` — skill cascade provenance (slice 10)
```bash
rig skill audit # human report of findings
rig skill audit --json # structured findings
rig skill audit --severity warn # stale + mirror-drift only
rig skill audit --rig <name> # narrow to embedded skill copies for one rig
```
Read-only audit of the skill cascade. Detects `missing` / `stale` / `self-referential` / `invalid-date` / `mirror-drift` across the canonical `openrig-work/skills/` → product mirror → hub cwd → installed plugin chain. Findings route back to the lifecycle for shaped propagation runs. **False-green prevention**: when audit evidence is unavailable, the CLI emits `unable-to-audit` with exit code `2` rather than reporting `clean`.
### `rig seat clear-attention` — extended to derived projection staleness (slice 16)
v0.3.4 shipped `clear-attention` gating on `session.startupStatus` only. v0.4.0 extends the verb to also reach **restoreOutcome-derived** attention (seat is `startupStatus=ready` + `sessionStatus=running` but carries `restoreOutcome=failed` / `continuityOutcome=failed`). Same evidence-gated audit row applies; the `--reason <text>` operator-attestation override carries the runtime / cwd-uncertainty disclosure honestly.
## Core Loop
Most work in OpenRig reduces to this loop:
- recover identity: `rig whoami --json`
- inspect inventory: `rig ps` (fleet map) / `rig ps --nodes -A --json` (fleet nodes)
- recover identity: `rig whoami` (compact default; add `--full` only when you need the heavy payload)
- inspect inventory: `rig ps --nodes` (compact default; add `--full` only when you need the firehose)
- read context: `rig transcript ...`, `rig ask ...`, `rig chatroom history ...`
- act: `rig send`, `rig capture`, `rig broadcast`, lifecycle commands
## Agent-Managed Apps
An agent-managed app is a deployable OpenRig unit made of:
- the software or service
- one specialist agent dedicated to that software
Treat the specialist as the domain delegate for that app.
The current canonical example is:
- rig: `secrets-manager`
- pod: `vault`
- member: `specialist`
- logical ID: `vault.specialist`
- session: `vault-specialist@secrets-manager`
Typical operator loop:
```bash
rig up secrets-manager --cwd /path/to/project
rig ps --nodes --rig secrets-manager --json
rig send vault-specialist@secrets-manager "Check Vault health and report back." --verify
rig env status secrets-manager
rig env logs secrets-manager
```
Cross-rig communication is valid when the target session resolves uniquely.
Example:
```bash
rig send vault-specialist@secrets-manager "Read secret/data/dogfood and report the value." --verify
```
Use the specialist instead of teaching every peer the same app-specific toolchain.
For Vault, ask `vault.specialist` to do secrets-domain work rather than improvising curl or Vault CLI usage in unrelated agents.
## Identity and Recovery
Start here after launch, compaction, or confusion:
@@ -87,16 +666,24 @@ rig whoami --node-id <id>
If the daemon is unreachable but identity can still be inferred, `--json` may return a partial result instead of crashing.
`WhoamiResult` (v0.3.3+) carries a required `peersNote` field with three pointers
the agent can use to navigate the rest of the rig from a cold start. The
human-formatted CLI output preserves the literal `Peers:` line prefix verbatim
(parser/test compatibility) and surfaces the clarifier in-band beneath it; the
JSON form exposes `peersNote` directly for programmatic consumers.
## Inventory and Monitoring
```bash
rig ps # ALL active rigs, one compact row each (v0.4.4 default)
rig ps --json # bare array, all non-archived rigs
rig ps --nodes # current rig's nodes (session default, local only)
rig ps --nodes -A --json # fleet node inventory (explicit)
rig ps
rig ps --json
rig ps --nodes
rig ps --nodes --rig <name> --json
rig ps --nodes -A --json
rig ps --nodes -A --full --json
```
Use `rig ps --nodes -A --json` for the current node inventory across rigs (v0.4.4: `--nodes` needs an explicit scope outside a managed session). It is the best machine-readable operator surface for:
Use `rig ps --nodes --rig <name> --json` for one rig's node inventory, or `rig ps --nodes -A --json` for fleet-wide projected node inventory. Use `--full` only when a downstream consumer truly needs complete per-node records. The node inventory surface carries:
- session name
- runtime
- session/startup status
@@ -112,6 +699,9 @@ rig daemon status
rig config
rig preflight
rig doctor
rig env status <rig>
rig env logs <rig>
rig env down <rig>
```
## Transcript and Communication
@@ -135,9 +725,28 @@ rig send <session> "message" --json
Use `--verify` when you want delivery evidence. Use `--force` only when you intentionally want to bypass activity-risk checks.
As of v0.3.3 (slice 17), content beginning with `--` or `-` is safe:
`rig send <session> "content starting with -- or - is now safe"` delivers
literally. The daemon's `send_text` path carries an explicit `--`
end-of-options sentinel so tmux no longer parses dash-prefixed content
as its own flags. The CLI surface itself is unchanged. For multi-line
or large bodies handed off as durable work, use
`rig queue create --body-file <path>` (`-` for stdin) — that's the
queue-side surface, not `rig send`.
`--verify` delivery outcomes (v0.3.3+):
- `delivered` — text + Enter both succeeded and capture re-confirmed the body landed.
- `rendered-unconfirmed` — text + Enter both succeeded but capture could not re-confirm the body (TUI redraw race or scroll). The message landed; the post-send re-check could not prove it. Treat as landed-but-unconfirmable, NOT failure.
- `failed` — the send transport itself failed.
The legacy `Verified: yes/no` line is preserved verbatim (parser/test
compatibility). A new `Delivery: <outcome>` line carries the named outcome
above.
Observed operator nuance for `--verify`:
- `Sent to ...` + `Verified: yes` = strong positive delivery evidence.
- `Sent to ...` + `Verified: no` = ambiguous delivery, not automatic failure. We have repeatedly seen the message still land; treat this as verification drift until disproven.
- `Sent to ...` + `Verified: yes` (`Delivery: delivered`) = strong positive delivery evidence.
- `Sent to ...` + `Verified: no` + `Delivery: rendered-unconfirmed` = the message landed; capture could not re-prove it. Don't blind-retry — check reply / `rig capture` / transcript before sending again.
- `Sent to ...` + `Verified: no` + `Delivery: failed` = send-transport failure.
- no `Sent to ...` line or a hard error = send failure.
When you get `Verified: no`, do not immediately retry blindly. First check one of:
@@ -156,13 +765,6 @@ rig capture --pod <name> --rig <name>
rig capture --rig <name> --json
```
Capture discipline:
- Default to bounded captures: `rig capture <session> --lines 80`.
- Use `--lines 120` when the latest activity is ambiguous.
- Use `--lines 200` or more only for explicit recovery/debug reconstruction, such as post-compaction restore, context-wall diagnosis, or a failed handoff.
- Prefer targeted `rig transcript <session> --tail ...`, `rig transcript <session> --grep ...`, queue files, or status commands over large pane captures.
- Do not use large captures as routine monitoring; they waste operator context and can hide the actual latest state in old scrollback.
### Broadcast
```bash
@@ -228,6 +830,9 @@ This is an evidence/context command. It is not a hidden second-LLM call.
rig up <source>
rig up <source> --plan
rig up <source> --yes
rig up <source> --cwd /path/to/project
rig up <source> --existing
rig up <source> --fresh <seat...>
rig up <source> --json
```
@@ -241,14 +846,22 @@ Bare names are special:
- if they do not match a library spec, `rig up` treats the name as an existing-rig restore/power-on target
- if both exist, `rig up` fails loudly on ambiguity
Resume-original-by-default (v0.3.4+):
- For an existing rig, `rig up <name>` resumes each seat from its original session/snapshot by default (operation A). Seats that successfully resume report `resumed`.
- `--fresh <seat...>` is the per-seat opt-in for deliberate fresh-prime (operation B). Named seats are reported as `fresh-primed`.
- `--existing` forces existing-rig restore semantics on a bare name, bypassing library-spec resolution. Useful when a rig name collides with a library spec name.
- Example: `rig up --existing my-rig --fresh dev-impl` — resume everything in `my-rig` except `dev-impl`, which is freshly primed.
- Seats with no resumable session land in `awaiting-decision` (zero-session honest state, NOT `failed`); see the five-term restore vocabulary in "Recovery and Resilience" below.
`--plan` (v0.3.4+):
- `rig up <source> --plan` produces a read-only restore plan preview. It surfaces per-seat resume/fresh-prime intent and any awaiting-decision seats without mutating state. Honest async timeout: a stuck plan reports the timeout rather than hanging silently.
Current behavior notes:
- `--target <root>` is only for `.rigbundle` / package installation. It does not change agent cwd.
- `rig up --cwd` is shipped. `rig up --cwd <path>` sends a per-run cwd override for all members in that launch.
- `local:` `agent_ref` values resolve relative to the rig spec directory, not your shell cwd.
- if you copy a built-in spec elsewhere, keep its `agents/` tree beside the YAML or rewrite those refs to `path:/absolute/path`
- `rig up --cwd <path>` exists as a launch working-directory override applied to all members for that run (verify with `rig up --help`)
- Source at OpenRig `6af2754` adds member-level `starter_ref` for named Agent
Starter registry entries. Active host availability still depends on the
running daemon; `rig-real-17812d5` does not resolve `starter_ref`.
- `rig specs add <directory>` installs a full spec tree when the directory contains `rig.yaml` or `agent.yaml`.
Legacy/spec-specific surfaces still ship too:
@@ -257,27 +870,6 @@ rig bootstrap <spec> [--plan] [--yes] [--json]
rig requirements <spec> [--json]
```
### Agent Starter `starter_ref`
Source `>= 6af2754` lets a RigSpec member set:
```yaml
starter_ref:
name: my-team-starter--claude-code
```
The daemon resolves the named entry from the Agent Starter registry, applies the
credential scan, and prepends the starter content as `guidance_merge` on fresh
launch. It supports Claude and Codex members, rejects terminal members, rejects
`starter_ref + session_source.mode: fork`, and allows
`starter_ref + session_source.mode: rebuild`.
Runtime-truth rule: verify active daemon/runtime provenance before depending on
this. If the live daemon is still `rig-real-17812d5`, `starter_ref` is source
truth only and should not be used against the active control plane. After a
runtime at or after `6af2754` is active, `rig up --plan --json` should expose a
`resolve_starter` stage with `detail.starterContent`.
### Tear a rig down
```bash
@@ -288,12 +880,44 @@ rig down <rig> --force
rig down <rig> --json
```
`rig down` accepts a rig name or id (symmetric with `rig up`); the earlier
name-to-404 caveat is resolved in v0.3.3. An ambiguous name matching more than
one active rig is refused with the matching ids; re-run with `rig down <id>`.
If `--snapshot` succeeds, human output includes the restore hint.
### Archive a stopped rig (recoverable) — v0.3.3+
```bash
rig archive <rig> [--json]
rig unarchive <rig> [--json]
```
`rig archive` marks a stopped rig as archived (sets `archivedAt`) without
discarding it. The rig is preserved for later restoration via `rig unarchive`,
which clears `archivedAt` and returns the rig to the active set.
Archive vs delete:
- `rig down --delete` — permanent removal; not recoverable.
- `rig archive` — recoverable; the rig is hidden from the default active view but its record + snapshots are preserved.
Visibility in `rig ps`:
- `rig ps` — active rigs only (default).
- `rig ps --include-archived` — includes archived rigs, marked with `*`.
SSE events `rig.archived` / `rig.unarchived` drive Project / dashboard updates;
consumers that depend on the rig list should subscribe rather than poll.
### Environment services
```bash
rig env status <rig>
rig env logs <rig> [service]
rig env down <rig>
```
Use these for service-backed rigs and agent-managed apps.
For `secrets-manager`, these are the fastest CLI surfaces for:
- confirming whether Vault is healthy
- reading Vault container logs
- stopping the Vault env without tearing down the specialist session first
### Release management without killing live claimed sessions
```bash
@@ -386,7 +1010,7 @@ rig whoami --json
Notes:
- for tmux-backed self-attach, `rig whoami --json` is the right verification
- for raw/external self-attach, `rig ps --nodes -A --json` is currently the more reliable verification surface
- for raw/external self-attach, `rig ps --nodes --rig <rigId-or-name> --json` is currently the more reliable verification surface
- if the current shell is outside tmux, pass `--display-name <name>` when you want a stable human session label recorded
### Adopt a topology and bind live sessions
@@ -404,8 +1028,8 @@ A bindings file is the durable map from authored logical IDs to live sessions. S
```yaml
bindings:
dev1.impl2: dev1-impl2@rigged-buildout
dev1.qa: dev1-qa@rigged-buildout
dev1.impl2: dev1.impl2@rigged-buildout
dev1.qa: dev1.qa@rigged-buildout
```
Spec + bindings is the proven recovery pair for adopted rigs.
@@ -453,13 +1077,13 @@ Verification loop:
```bash
rig discover --json
rig adopt <fragment.yaml> --bindings-file <bindings.yaml> --target-rig <rigId>
rig ps --nodes -A --json
rig ps --nodes --rig <target-rig-name> --json
rig export <rigId> -o rig.yaml
```
Success looks like:
- the new sessions stop appearing in `rig discover`
- the new logical IDs appear in `rig ps --nodes -A --json`
- the new logical IDs appear in `rig ps --nodes --rig <target-rig-name> --json`
- `rig export` includes the new pod
### Mixed-origin rigs are allowed
@@ -487,11 +1111,39 @@ This lets ordinary agents ask the manager for OpenRig help instead of every agen
```bash
rig expand <rig-id> <pod-fragment-path> [--rig-root <path>] [--json]
rig launch <rigId> <nodeRef> [--json]
rig launch <rigId> --seats <a,b,c> [--hold-reason <text>] [--json]
rig remove <rigId> <nodeRef> [--json]
rig shrink <rigId> <podRef> [--json]
rig unclaim <sessionRef> [--json]
```
Node-granular managed partial restore (v0.3.4+):
- `rig launch <rigId> <nodeRef>` relaunches a single seat by logical id or node id through orchestration.
- `rig launch <rigId> --seats <a,b,c>` relaunches a comma-separated subset of seats.
- `--hold-reason <text>` records a reason for holding non-target seats during the partial launch.
- This is a SUPPORTED managed path. The prior `pod_aware_launch_unsupported` dead-end is retired; pod-aware narrow launch now goes through this surface rather than ad-hoc rebuilds.
### Add a member to an existing pod — v0.3.3+
```bash
rig add <rig> <member-fragment-path> [--json]
rig add-member <rig> <member-fragment-path> [--json]
```
`rig add` (alias `rig add-member`) is the top-level verb for the `add_member`
converge op. It adds a single member to an existing pod from a YAML/JSON member
fragment file. The fragment must declare the target pod; the daemon resolves
the pod by that declared identity, validates the member, runs preflight, and
launches the member in place.
HTTP outcomes:
- `201` — member added; per-node launch state included in the response.
- `400` — `validation_failed` or `preflight_failed` (the fragment or its launch posture is rejected before any state change).
- `409` — `member_conflict` (a member with that identity already exists in the pod).
Use `rig add` when you want additive growth inside a pod without re-running
the full `rig expand` pod-fragment path or rebuilding the rig.
## Specs and Validation
### Validate specs
@@ -514,8 +1166,6 @@ rig specs remove <name-or-id> [--json]
rig specs rename <name-or-id> <new-name> [--json]
```
`rig specs add <directory>` installs a full spec tree when the directory contains `rig.yaml` or `agent.yaml`.
## MCP
```bash
@@ -548,7 +1198,7 @@ When the CLI behaves strangely, use the smallest truthful check first:
```bash
rig whoami --json
rig daemon status
rig ps --nodes -A --json
rig ps --nodes
```
Specific operator rules:
@@ -570,7 +1220,7 @@ Do not mass-kill:
- `codex ...`
- `claude ...`
For deeper host/runtime triage, use the companion `openrig-operator` skill.
For deeper host/runtime triage, use the companion `openrig-operator` skill if it is available in your seat.
## JSON and Error Posture
@@ -584,7 +1234,7 @@ Design assumptions that hold in the shipped CLI:
1. `rig whoami --json`
2. `rig transcript <your-session> --tail 100`
3. `rig ps --nodes -A --json`
3. `rig ps --nodes`
4. `rig chatroom history <rig> --limit 50`
## Commands That Do Not Exist
@@ -593,5 +1243,3 @@ Do not assume these exist unless the shipped help starts listing them:
- `rig claim`
- `rig blame`
- `rig replay`
`rig env` IS shipped (was previously listed as not-present). It exposes `status`, `logs`, and `down` for service-backed rigs and managed apps; verify with `rig env --help`.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/daemon",
"version": "0.4.4",
"version": "0.4.6",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -0,0 +1,29 @@
name: factory-rsi-dogfood
version: "1.0"
description: Dogfood agent — continuously uses the SHIPPED product out-of-band and records findings that feed the next RSI plan cycle
defaults:
runtime: claude-code
imports:
- ref: local:../../shared
profiles:
default:
uses:
skills: [using-superpowers, openrig-user, mission-slice-sop, dogfood, systematic-debugging, verification-before-completion]
guidance: []
subagents: []
runtime_resources: [shared:claude-default-settings, shared:claude-default-mcp, shared:codex-default-config]
resources:
guidance:
- id: role
path: guidance/role.md
startup:
files:
- path: guidance/role.md
delivery_hint: send_text
required: true
actions: []
@@ -0,0 +1,37 @@
# Role — Dogfood tester (factory-rsi)
You are the **dogfood** seat of the single-rig RSI factory. Your job is the one
hop the rest of the loop never proves: turn real usage of the SHIPPED product
into the next plan.
## What you do
You run **out-of-band**, decoupled from the gated inner loop (plan → build →
check → review → release). You continuously **use the SHIPPED product for
real** — not a smoke test, not a checklist tick, and NOT a second QA on a
pre-release build artifact. Exercise the actual shipped thing the way a real
user or operator would, hard enough to surface what building, checking, and
review never see in isolation: rough edges, wrong defaults, missing affordances,
broken assumptions.
Then record your **findings** as durable closure evidence. These findings ARE
the input to the next plan cycle, so write them for the planner: concrete,
prioritized, tied to what you saw.
## The RSI edge (ungated)
- Your findings feed the **next plan** — that feedback edge is the recursion. It
is **ungated**: no human gate on the loop and no loop-stop in the MVP (a human
MAY steer via the roadmap but is never required).
- You are **not an inner-loop gate.** You do not pass or fail the build artifact;
the inner loop's own check and review do that. You dogfood what already
shipped and feed the next cycle.
## Discipline
- Record findings as durable evidence (the packet trail / `evidence_ref`), never
as ambient chat — the next cycle reads recorded state, not your memory.
- Do not route the inner loop yourself. Raise real blockers as exceptions (they
go orchestrator-first).
- Bias toward finding real problems over declaring success — you exist to surface
what shipped but should not have, not to bless it.
@@ -0,0 +1,29 @@
name: factory-rsi-release-manager
version: "1.0"
description: Release-manager agent — prepares the release (notes, docs, PR) and holds the ship decision at the human gate
defaults:
runtime: claude-code
imports:
- ref: local:../../shared
profiles:
default:
uses:
skills: [using-superpowers, openrig-user, mission-slice-sop, verification-before-completion]
guidance: []
subagents: []
runtime_resources: [shared:claude-default-settings, shared:claude-default-mcp, shared:codex-default-config]
resources:
guidance:
- id: role
path: guidance/role.md
startup:
files:
- path: guidance/role.md
delivery_hint: send_text
required: true
actions: []
@@ -0,0 +1,40 @@
# Role — Release manager (factory-rsi)
You are the **release** seat of the single-rig RSI factory. You run when the
cycle's review passes and the inner loop hands off to release prep. Your job is
to prepare a release and then **stop at the human gate** — you never publish.
## What you do
Prepare the shippable artifacts:
- release notes for the slice that just passed the loop,
- any docs / website updates it needs,
- the release PR (staged, not merged).
Reference the cycle's recorded proof (`proof/PROOF.md`) as the evidence the
human signs off against.
## Two steps: you PREPARE, then the human signs off
The workflow gives you two steps, in order:
1. **`release_prep`** — YOUR executable step. You do the actual preparation here
(notes, docs, the staged PR) and record the evidence. This runs BEFORE any
gate — the prepared artifacts exist first.
2. **`release_signoff`** — the **human gate**. When your prep is ready you hand
off to this step, which holds the ship decision at the human seat.
## The hard rule: publishing is a human act
You do NOT push, tag, publish, `npm publish`, cut a GitHub release, or upgrade
any host — those acts happen only after, and only by, the human at
`release_signoff`. If you are tempted to "just finish the release," stop: the
sign-off gate is the whole reason your prep stays prep.
## Discipline
- Prepare and hand to the gate; close honestly. Do not route past the gate.
- Raise anything blocking as an exception (orchestrator-first).
- Keep the prep faithful to what actually passed the loop; never invent a
cleaner story than the proof supports.
@@ -7,14 +7,12 @@ metadata:
last_verified: "2026-07-06"
distribution_scope: product-bound
source_evidence: |
Authored + embedded in release-0.4.3 slice 32 (mission-slice-sop);
moved into the canonical product skill source and updated in
release-0.4.4 slice 23 (operationalize the SDLC control plane) to
teach the full SDLC flow the Living Notes UI projects: the convention
sections, the proof-contract pairing, the two staged-approval locks,
C1 proof drops, and the three role contracts. Conventions SSOT:
docs/reference/sdlc-conventions.md (shipped with the CLI package).
The deterministic backstop is the `rig scope audit` classifier.
The canonical product SDLC skill: it teaches the full SDLC flow the
Living Notes UI projects — the convention sections, the proof-contract
pairing, the two staged-approval locks, C1 proof drops, and the three
role contracts. Conventions SSOT: docs/reference/sdlc-conventions.md
(shipped with the CLI package). The deterministic backstop is the
`rig scope audit` classifier.
sibling_skills:
- queue-handoff
- seat-continuity-and-handover
@@ -0,0 +1,86 @@
---
name: openrig-cmux
description: >
Use when opening OpenRig fleet terminals into cmux — turning a rig, pod, mission, slice, or saved
view into live agent tiles via `rig terminal --provider cmux`, or driving cmux on an agent's
request. Same OpenRig view semantics as openrig-herdr (the verbs, honest-partial/degrade, read-only
cross-rig, scroll/copy, same-size-only duplicates); cmux is the **best-effort** provider (herdr is
the default and the proof-gated one). Prefer openrig-herdr unless cmux is specifically wanted.
metadata:
openrig:
stage: candidate
distribution_scope: product-bound
source_evidence: |
cmux is OpenRig's shipped terminal provider generalized onto `rig terminal <provider>` —
best-effort, not proof-gated. Authored clean-room.
transfer_test: pending
sibling_skills:
- openrig-herdr
- openrig-user
---
# openrig-cmux
cmux is a **provider** for OpenRig views — the same `rig terminal` surface as herdr, rendering the
tiles into cmux instead. It is the provider OpenRig already shipped (the existing "Launch in cmux"
affordance generalizes onto `rig terminal <provider>`), kept working. The OpenRig-semantic half is
identical to openrig-herdr: OpenRig decides **which** agents form a view; the provider renders the
pixels. **Read openrig-herdr first for the full model** — this skill only calls out what differs for
cmux.
## Provider status — cmux is best-effort
- **herdr is the default and the proof-gated provider**; **cmux is best-effort** — it ships on the
same `rig terminal <provider>` + web-launcher neighborhood, but a cmux miss is not a slice failure.
Reach for cmux when it is specifically wanted; otherwise default to herdr (`rig terminal open <view>`
with no `--provider`).
- Views are **provider-agnostic**: the same view + the same agents + the same semantics carry across
herdr and cmux. A saved view opens in either.
## The surface (same three verbs, `--provider cmux`)
```bash
rig terminal open <view> --provider cmux [--json] # open a view into cmux (herdr is the no-flag default)
rig terminal views [--json] # the same view library, provider-agnostic
rig terminal status --provider cmux [--json] # cmux liveness / health
```
`<view>` resolves exactly as with herdr: a **rig name** · **`pod:<rig>/<podNamespace>`** ·
**`mission:<id>`** · **`slice:<id>`** (derived live) · a **saved-view name**.
## What carries over unchanged from openrig-herdr
All of these behave identically — see openrig-herdr for the detail:
- **Honest-partial / honest-degrade** — opened / absent / degraded, each named; partial opens exit 0,
zero-pane exits non-zero; an http-registered host's agents degrade with the reason ("host `<id>` is
http-registered; tiles need ssh"), never silently dropped.
- **Read-only policy** — a rig or `pod:` view is interactive; `mission:` / `slice:` views are
read-only by construction; a saved view is per-member (`readOnly`). A read-only pane is
`tmux attach -r` (client `readonly=1`, keystrokes physically cannot reach the agent). See openrig-herdr.
- **Safety rails (the fleet-safety rail)** — a tile is a *view* (nested `tmux attach`) of a
daemon-owned session; never move/join/kill/re-parent a daemon-owned pane; closing a tile detaches
one client and leaves the session untouched; never live-flip tmux options on a running seat in
production (prove it in an isolated environment first).
- **Scroll + copy out of the box** — the daemon's terminal defaults (per-session scroll at launch +
the server's clipboard defaults); running seats pick up wheel-scroll at their next relaunch.
- **Limits** — tile chrome v1 = a plain label; same-size panes for duplicate/multi-view membership
(the different-size resize mismatch is an inherent tmux multi-client limit, documented not fixed).
## cmux-specific notes
- **Open-or-focus per agent** — cmux's shipped integration opens a node or focuses it if already open;
expect focus (not a duplicate) when a seat is already tiled in cmux.
- **The shipped "Launch in cmux" affordance is preserved** (byte-compatible) and generalizes into the
provider + view picker; opening cmux from the web launcher still works.
- **No AGPL arm's-length concern** — unlike herdr, cmux is OpenRig's shipped provider integration; the
clean-room/never-embed rail that applies to herdr is not a cmux constraint. (This skill itself is
still authored clean-room.)
## Saved views — provider-agnostic
Saved views are **hand-authored** in `terminal-views.yaml` (v1 has no save/write verb) and are
**provider-agnostic** — see **openrig-herdr** for the exact schema, the store facts (atomic tmp+rename,
byte-stable, at the OpenRig home root), and the derived-views-never-persisted rule. Reopen any saved
view in cmux with `rig terminal open <id> --provider cmux`. Derived views (a rig, `pod:<rig>/<pod>`,
`mission:<id>`, `slice:<id>`) are computed live and never written to the file.
@@ -0,0 +1,158 @@
---
name: openrig-herdr
description: >
Use when opening OpenRig fleet terminals as a herdr wall — turning a rig, pod, mission, slice, or
saved view into live interactive agent tiles via `rig terminal`, watching another rig read-only,
or driving herdr on an agent's request ("open all my rigs + a mission as views"). Covers the
`rig terminal open|views|status` verbs, the honest-partial/degrade reading of the result, the
read-only-by-construction rail for cross-rig views, scroll/copy out of the box, and the
same-size-only duplicate-pane limit. herdr is the default, proof-gated provider.
metadata:
openrig:
stage: candidate
distribution_scope: product-bound
source_evidence: |
Empirical herdr 0.7.1 hands-on findings + the ratified implementation PRD. Authored
clean-room (patterns only, no herdr source text; AGPL arm's-length).
transfer_test: pending
sibling_skills:
- openrig-cmux
- openrig-user
---
# openrig-herdr
OpenRig decides **which** agents make up a view (a rig, a pod, a mission, a slice, or a saved
group); **herdr** renders the pixels. A view opens as live, interactive terminal tiles — each tile
is a nested `tmux attach` to a daemon-owned agent session, so you get the real session, not a
snapshot. OpenRig owns the semantics; herdr owns the surface. You drive it entirely through the
`rig terminal` CLI, which rides the installed herdr binary at arm's length — never link, embed, or
plugin it.
## The whole surface — three verbs
```bash
rig terminal open <view> [--provider herdr|cmux] [--json] # herdr is the default provider
rig terminal views [--json] # list openable views (saved + derived)
rig terminal status [--provider] [--json] # provider liveness / health
```
`<view>` resolves, in order, to one of:
- a **rig name** — every live agent in that rig, auto-laid-out;
- **`pod:<rig>/<podNamespace>`** — every live agent in one pod of a rig (the rig's inventory filtered by pod);
- **`mission:<id>`** — the agents working that mission (derived live from topology);
- **`slice:<id>`** — the agents working that slice (derived live);
- a **saved-view name** — a user-defined group (see Saved views).
## Compose a view from a sentence
An agent asked "open all my rigs plus a mission as views" runs one `open` per target:
```bash
rig terminal open acme-web # a whole rig, live agents as tiles
rig terminal open mission:site-relaunch # exactly the agents working the mission
rig terminal open slice:02-search-filters # the agents working one slice
```
No hand-listing of seats: the mission/slice membership is derived from live topology at open time.
## Read the result honestly — partial and degrade
The result is a partition — every seat lands in exactly one bucket, each **named**:
- **opened** — the live agents now showing as tiles.
- **absent** — seats in the view that aren't currently live: **named and skipped, never silently
dropped.** A view that opens *some* of its seats is a success (the partial is disclosed) and exits
**0**. Only a view where **no** pane opens exits **non-zero**.
- **degraded** — an agent that structurally cannot tile, named **with the reason**. The v1 case:
an agent on a host registered over HTTP has no ssh path, so its tile can't be composed — it reads
as **"host `<id>` is http-registered; tiles need ssh"** and is skipped, never dropped. (ssh-reachable
hosts tile via an ssh-wrapped attach; full http-host tiling is deferred to the cross-host transport
seam.)
Read `--json` to branch on the partition programmatically; the exit code alone tells you opened-something
(0) vs opened-nothing (non-zero).
## Read-only — who's interactive, who's watch-only
Read-only is composed into the pane at open time — a read-only tile is a `tmux attach -r`, the client
reports `readonly=1`, and it **physically cannot send input**. The current policy, by view kind:
- **A rig view or a `pod:` view is interactive** — you asked for that rig (or pod) directly, so you
can drive its agents.
- **`mission:` and `slice:` views are read-only by construction** — these derived views cut across
rigs; you watch and scroll, you don't keystroke into them.
- **A saved view is per-member** — each member carries its own `readOnly` (omit = interactive;
`true` = `attach -r`).
So you don't have to remember to be careful: the view kind (and, for a saved view, the per-member
flag) sets it at open time.
## Safety rails (hard — the fleet-safety rail)
- A tile is a **view** (a nested `tmux attach`) of a daemon-owned session. **Never** move, join, kill,
or re-parent a daemon-owned pane. Closing a tile detaches one tmux client — the daemon's session is
untouched and its addressing (send/capture/nudge) is unaffected.
- Host-local read-only viewing mutates nothing. Anything that would change a **live** daemon session's
shared state (e.g. flipping a tmux option on a running seat) is a config/design change — prove it in
an isolated environment, never live-flip in production.
## Scroll + copy work out of the box
On a freshly-launched agent tile the mouse wheel scrolls the pane's history and a drag-select copies
to your **system** clipboard — with no tmux commands typed. This rides the daemon's terminal defaults
(a per-session scroll option set at launch, plus the daemon tmux server's clipboard defaults). Honest
timing: agents that were already running before this shipped pick up wheel-scroll at their **next
natural relaunch** (the scroll default is per-session and running seats are never retro-flipped);
system-clipboard copy works immediately (it's server-wide).
## Limits — state them, don't paper over them
- **Tile chrome v1 = a plain label** (agent + slice). Rich per-tile status is roadmap.
- **Duplicate / multi-view membership** (the same agent live in two views at once) works — put the
duplicates in **same-size panes**. Different-sized duplicate panes have an inherent tmux
multi-client resize mismatch (tmux clamps to the smallest client); it is **documented, not fixed** —
don't expect a setting to remove it.
- **Inner tmux status bar** is hidden by default (herdr provides the chrome); one config key
(`terminal.status_bar`) flips it back on for raw-tmux / no-provider surfaces, and the flip applies
to **future launches only**.
## Saved views — the library (`terminal-views.yaml`)
In v1 you **create** a saved view by **hand-authoring** `terminal-views.yaml` — there is no save/write
verb (`open` / `views` / `status` are the whole surface; a `rig terminal save <id>` verb is a named
stretch/follow-up). You **reopen** it like any other view: `rig terminal open <id>`.
The file lives at the OpenRig home root (resolved via `getDefaultOpenRigPath()`), is written
atomically (tmp + rename), and is byte-stable. **Only hand-authored saved views live here — derived
views (a rig, `pod:<rig>/<pod>`, `mission:<id>`, `slice:<id>`) are computed live and never written to
this file.**
Schema — use these field names **exactly**, in this order; **omit** optional fields when absent
(never write `null`):
```yaml
version: 1
views:
- id: my-view-id # required — the id `rig terminal open <id>` takes
name: Human Name # required
members:
- seat: pod-member@rig # required — the canonical session name
label: agent . slice # optional — pane label
host: some-host-id # optional — STRUCTURED host id (NEVER a `member@rig@host` string);
# omit for local. ssh-registered hosts tile;
# http-registered hosts honest-degrade (named + skipped, with reason)
tmuxSession: sessname # optional — defaults to `seat`
readOnly: true # optional — omit = interactive; true = `tmux attach -r`
```
An agent composing a saved view on request **writes this YAML** and then opens the id:
`rig terminal open my-view-id` (add `--provider cmux` for cmux). The library is provider-agnostic —
the same saved view opens in herdr or cmux.
## AGPL / clean-room
Driving herdr through the `rig terminal` CLI (which talks to the installed herdr binary over its
CLI/socket) is arm's-length and fine. Do **not** link herdr's source, embed it in-process, or ship a
herdr plugin — a plugin needs legal review. This skill and its docs are clean-room: patterns only,
never herdr source text.
@@ -28,7 +28,7 @@ the receipt and move on.
Example:
```bash
rig send velocity-guard@openrig-velocity "Heads up — filing per-commit handoff on slice-22 Bug 1 BONUS at tip 6b8673b6." --verify
rig send reviewer@acme-web "Heads up — filing the per-commit handoff on the search-filters slice at the current tip." --verify
```
### `rig queue create --source <X> --destination <Y> --tags <...> --body "<...>"` — durable work item
@@ -47,9 +47,9 @@ Example:
```bash
rig queue create \
--source redo-driver-2@openrig-velocity \
--destination redo-guard-2@openrig-velocity \
--tags "mission:release-0.3.2,slice:22-rig-up-paper-cut-fix-round,gate:guard,handoff:per-commit,checkpoint:bug-1-bonus" \
--source builder@acme-web \
--destination reviewer@acme-web \
--tags "mission:site-relaunch,slice:02-search-filters,gate:guard,handoff:per-commit,checkpoint:bug-1" \
--body "$(cat /tmp/per-commit-body.txt)"
```
@@ -64,8 +64,8 @@ Example:
```bash
rig queue handoff qitem-20260601012431-d78aa805 \
--to velocity-qa@openrig-velocity \
--tags "mission:release-0.3.2,slice:22-rig-up-paper-cut-fix-round,gate:qa,handoff:adversarial-dogfood"
--to qa@acme-web \
--tags "mission:site-relaunch,slice:02-search-filters,gate:qa,handoff:adversarial-dogfood"
```
### §1b doctrine — turn ends by passing the ball
@@ -0,0 +1,47 @@
# factory-rsi — the single-rig RSI factory MVP
This rig IS a recursive-self-improvement loop. One workflow (`factory-rsi`)
moves one slice through the inner loop **plan → implement → check → review →
release**. Dogfood is decoupled from that gated loop: it continuously uses the
**shipped** product out-of-band and feeds its findings into the **next** plan
cycle. That feedback edge is the whole point — and it needs no human in the loop.
## How the loop runs
- The **engine routes the inner loop**, not the orchestrator. A failing check or
review routes back to build for bounded remediation; a clean review proceeds
to release prep. Nobody hand-relays a packet around the loop.
- **Dogfood is out-of-band.** It runs continuously against the SHIPPED product —
not a second QA on the pre-release build artifact — and records findings that
become the next plan's input. The RSI recursion stays ungated: dogfood findings
feed the next plan with no human gate (a human MAY steer via the roadmap but is
never required; there is no loop-stop in the MVP).
- **Findings are recorded state, not memory.** The next plan builds from the
recorded dogfood findings — never a seat's chat history.
- **Exceptions go orchestrator-first** (the WF-5 dial); the human knows at
altitude. A `max_hops` trip is an exception, and a resume grants exactly one
more bounded window — the loop never runs away.
- **Publishing is a human act.** The release seat PREPARES notes/docs/PR and
holds the ship decision at a human gate. No seat pushes, tags, or publishes.
## The seats
| Seat | Runtime | Does |
|---|---|---|
| `plan-planner` | claude-code | turns the corpus / previous findings into ONE buildable slice spec |
| `build-implementer` | claude-code | builds the slice, produces proof |
| `check-qa` | codex | checks the artifact; a failing check loops back to build |
| `review-reviewer` | codex | reviews (cross-runtime vs the builder) |
| `dogfood-tester` | codex | continuously USES the shipped product out-of-band; records findings that feed the next plan |
| `release-manager` | claude-code | prepares the release, holds the human gate |
| `orch-lead` | claude-code | the exception dial target — exceptions only |
Seats inherit their runtime's default model (no per-seat model pin): plan,
build, release, and orchestration run on claude-code; check, review, and dogfood
run on codex for cross-runtime diversity against the builder.
## Deployment
As a shipped starter this rig is workspace-agnostic — point
`rig up factory-rsi --cwd <repo>` at whatever real repository the loop should
improve, and the planner's product-intent corpus is that repo's real specs.
@@ -0,0 +1,115 @@
version: "0.2"
name: factory-rsi
culture_file: CULTURE.md
summary: >
The single-rig RSI factory MVP (OPR.0.4.6.FAC2): ONE rig whose seats map 1:1
to the `factory-rsi` workflow — the inner loop plans, builds, checks, and
reviews one slice, then prepares its release. Dogfood is decoupled: it
continuously uses the SHIPPED product out-of-band and feeds findings back into
the next plan — the recursive-self-improvement edge, with no human required in
the loop. Run it with `rig up factory-rsi`, then instantiate the `factory-rsi`
workflow and watch the loop close. Seats inherit their runtime's default
model; qa, review, and dogfood run on the alternate runtime for cross-runtime
diversity against the builder.
# Seven pods, one member each, so every canonical session name is
# `<pod>-<member>@factory-rsi` and matches the workflow's preferred_targets 1:1.
pods:
- id: plan
label: Planning
members:
- id: planner
agent_ref: "local:../../../agents/conveyor/planner"
runtime: claude-code
profile: default
cwd: "."
edges: []
- id: build
label: Build
members:
- id: implementer
agent_ref: "local:../../../agents/development/implementer"
runtime: claude-code
profile: default
cwd: "."
edges: []
- id: check
label: Check
members:
- id: qa
agent_ref: "local:../../../agents/development/qa"
runtime: codex
profile: default
cwd: "."
edges: []
- id: review
label: Review
members:
- id: reviewer
agent_ref: "local:../../../agents/review/independent-reviewer"
runtime: codex
profile: default
cwd: "."
edges: []
- id: dogfood
label: Dogfood
members:
- id: tester
agent_ref: "local:../../../agents/factory-rsi/dogfood"
runtime: codex
profile: default
cwd: "."
edges: []
- id: release
label: Release
members:
- id: manager
agent_ref: "local:../../../agents/factory-rsi/release-manager"
runtime: claude-code
profile: default
cwd: "."
edges: []
- id: orch
label: Orchestration
members:
- id: lead
agent_ref: "local:../../../agents/orchestration/orchestrator"
runtime: claude-code
profile: default
cwd: "."
edges: []
# The orchestrator delegates across the pipeline; review and check can observe
# the build. Workflow ROUTING is the `factory-rsi` spec's job (the engine
# routes the loop-close); these edges are topology hints, not the routing.
edges:
- kind: delegates_to
from: orch.lead
to: plan.planner
- kind: delegates_to
from: orch.lead
to: build.implementer
- kind: delegates_to
from: orch.lead
to: check.qa
- kind: delegates_to
from: orch.lead
to: review.reviewer
- kind: delegates_to
from: orch.lead
to: dogfood.tester
- kind: delegates_to
from: orch.lead
to: release.manager
- kind: can_observe
from: check.qa
to: build.implementer
- kind: can_observe
from: review.reviewer
to: build.implementer
@@ -19,6 +19,7 @@ import {
} from "../domain/codex-thread-id.js";
import { assessNativeResumeProbe, buildCodexResumeCore, type NativeResumeProbeResult } from "../domain/native-resume-probe.js";
import { mergeManagedBlock } from "../domain/managed-blocks.js";
import { parseSessionName } from "../domain/session-name.js";
import { shellQuote } from "./shell-quote.js";
export interface CodexAdapterFsOps {
@@ -966,17 +967,20 @@ export function upsertCodexHookTrust(content: string, key: string, hash: string)
}
function parseCanonicalSessionName(sessionName: string): { pod: string; member: string; rig: string } | null {
// OPR.0.4.6.MH1 FR-8: the member/rig split rides the shared parse
// contract. A multi-@ name now parses with a greedy rig ("rig@x"),
// which isSafeQueueSegment rejects ("@" is unsafe) — the same null this
// site returned via its old single-@ check.
const trimmed = sessionName.trim();
const atIndex = trimmed.indexOf("@");
if (atIndex <= 0 || atIndex !== trimmed.lastIndexOf("@") || atIndex === trimmed.length - 1) return null;
const parsed = parseSessionName(trimmed);
if (parsed.kind !== "canonical") return null;
const local = trimmed.slice(0, atIndex);
const rig = trimmed.slice(atIndex + 1);
const separatorIndex = local.indexOf("-");
if (separatorIndex <= 0 || separatorIndex === local.length - 1) return null;
const rig = parsed.rig;
const separatorIndex = parsed.member.indexOf("-");
if (separatorIndex <= 0 || separatorIndex === parsed.member.length - 1) return null;
const pod = local.slice(0, separatorIndex);
const member = local.slice(separatorIndex + 1);
const pod = parsed.member.slice(0, separatorIndex);
const member = parsed.member.slice(separatorIndex + 1);
if (!isSafeQueueSegment(pod) || !isSafeQueueSegment(member) || !isSafeQueueSegment(rig)) return null;
return { pod, member, rig };
+166
View File
@@ -0,0 +1,166 @@
// OPR.0.4.6.PI1 FR-6 — Pi seat resume (mirrors codex-resume.ts).
//
// Resume is HONEST session-file continuation: relaunch the pi-runner with
// `--session <persisted sessionFile>` (exact file resume — NEVER `--resume`,
// which opens an interactive picker and is forbidden in managed paths). The
// seat never claims warm-process resume (BR-6 / architecture rule 15
// posture). A missing session file returns `retry_fresh`, which the restore
// orchestrator maps to the awaiting-decision stop-and-ask — never a silent
// fresh start.
import { setTimeout as sleep } from "node:timers/promises";
import { randomUUID } from "node:crypto";
import type { TmuxAdapter } from "./tmux.js";
import type { ResumeResult } from "./claude-resume.js";
import {
piSeatPaths, parsePiRunnerState, buildPiRunnerCommand, buildPendingRunnerState,
} from "./pi-runner-protocol.js";
export { type ResumeResult };
export interface PiResumeFsOps {
readFile(path: string): string;
writeFile(path: string, content: string): void;
exists(path: string): boolean;
mkdirp(path: string): void;
}
interface PiResumeOptions {
pollMs?: number;
maxWaitMs?: number;
sleep?: (ms: number) => Promise<void>;
trustPosture?: "approve" | "no-approve";
/** Launch-attempt id minting (tests inject; defaults to randomUUID). */
newLaunchId?: () => string;
}
export class PiResumeAdapter {
constructor(
private tmux: TmuxAdapter,
private fs: PiResumeFsOps,
private paths: { stateRoot: string; runnerEntryPath: string },
private options: PiResumeOptions = {},
) {}
canResume(resumeType: string | null, resumeToken: string | null): boolean {
return resumeType === "pi_session_file" && !!resumeToken;
}
async resume(
tmuxSessionName: string,
resumeType: string | null,
resumeToken: string | null,
cwd: string,
model?: string | null,
): Promise<ResumeResult> {
if (!this.canResume(resumeType, resumeToken)) {
return { ok: false, code: "no_resume", message: "Pi resume not available" };
}
const sessionFile = resumeToken!;
if (!this.fs.exists(sessionFile)) {
// The honest zero-session outcome: the caller's retry_fresh mapping
// realizes the awaiting-decision stop-and-ask (BR-6).
return { ok: false, code: "retry_fresh", message: "Pi resume failed: the persisted session file no longer exists" };
}
const seat = piSeatPaths(this.paths.stateRoot, tmuxSessionName);
this.fs.mkdirp(seat.agentDir);
this.fs.mkdirp(seat.sessionsDir);
// Launch-attempt scoping (guard fold): overwrite any stale sidecar from a
// prior runner instance BEFORE typing the command; verifyResume trusts
// only states stamped with THIS attempt's launchId. The prior record is
// read FIRST so the durable catch-up cursor (lastEntryId, FR-5) survives.
const launchId = (this.options.newLaunchId ?? (() => randomUUID()))();
const prior = this.fs.exists(seat.runnerStatePath) ? parsePiRunnerState(this.readSafe(seat.runnerStatePath)) : null;
this.fs.writeFile(
seat.runnerStatePath,
JSON.stringify(buildPendingRunnerState(launchId, new Date().toISOString(), prior)),
);
const cmd = buildPiRunnerCommand({
runnerEntryPath: this.paths.runnerEntryPath,
sessionName: tmuxSessionName,
stateRoot: this.paths.stateRoot,
cwd,
// The model declaration must survive resume: the runner's provider-key
// allowlist keys off the declared provider (VM leg finding).
model: model ?? undefined,
trust: this.options.trustPosture ?? "no-approve",
sessionFile,
launchId,
});
const textResult = await this.tmux.sendText(tmuxSessionName, cmd);
if (!textResult.ok) {
return { ok: false, code: "resume_failed", message: textResult.message };
}
const keyResult = await this.tmux.sendKeys(tmuxSessionName, ["Enter"]);
if (!keyResult.ok) {
// Partial failure: command text is in the buffer but Enter failed.
// Best-effort cleanup: send C-c to clear the typed command.
await this.tmux.sendKeys(tmuxSessionName, ["C-c"]);
return { ok: false, code: "resume_failed", message: keyResult.message };
}
return this.verifyResume(tmuxSessionName, sessionFile, launchId);
}
// Poll the runner's launch-scoped sidecar ONLY — never stale pane
// scrollback (a prior instance's READY/ERROR markers survive in the pane,
// and a prior resume of the SAME file would even match; guard fold,
// code-review qitem-20260707011908). Resumed = THIS attempt's sidecar is
// ready AND names exactly the requested session file.
private async verifyResume(tmuxSessionName: string, sessionFile: string, launchId: string): Promise<ResumeResult> {
const pollMs = this.options.pollMs ?? 250;
const maxWaitMs = this.options.maxWaitMs ?? 15_000;
const sleepFn = this.options.sleep ?? sleep;
const attempts = Math.max(1, Math.floor(maxWaitMs / Math.max(pollMs, 1)) + 1);
for (let attempt = 0; attempt < attempts; attempt++) {
const { runnerStatePath } = piSeatPaths(this.paths.stateRoot, tmuxSessionName);
const state = this.fs.exists(runnerStatePath) ? parsePiRunnerState(this.readSafe(runnerStatePath)) : null;
if (state?.launchId === launchId) {
if (state.exited) {
const paneContent = (await this.tmux.capturePaneContent(tmuxSessionName, 40)) ?? "";
return {
ok: false,
code: "resume_failed",
message: `Pi resume failed: the runner exited (code ${state.exited.code ?? "unknown"})`,
evidence: paneContent.split("\n").slice(-12).join("\n"),
} as ResumeResult;
}
if (state.ready) {
if (state.sessionFile !== sessionFile) {
return {
ok: false,
code: "resume_failed",
message: "Pi resume failed: the runner is ready but does not report the requested session file",
};
}
return { ok: true };
}
}
if (attempt < attempts - 1) {
await sleepFn(pollMs);
}
}
return {
ok: false,
code: "resume_failed",
message: "Pi resume failed: timed out waiting for the runner to prove the requested session",
};
}
private readSafe(path: string): string {
try {
return this.fs.readFile(path);
} catch {
return "";
}
}
}
@@ -0,0 +1,231 @@
// OPR.0.4.6.PI1 — the shared, PURE contract between the Pi runtime adapter,
// the Pi resume adapter, and the pane-hosted pi-runner process.
//
// Everything here is side-effect-free (constants + string/argv/env builders +
// the runner-state sidecar shape) so the runner entry can import it without
// dragging daemon dependencies into the pane process, and the adapter/resume
// tests can assert command construction hermetically.
//
// Contract summary (PRD FR-2/FR-3/FR-5/FR-7):
// - The adapter launches `node <runnerEntry> …` inside the seat's tmux pane.
// - The runner spawns `pi --mode rpc` with seat-scoped PI_CODING_AGENT_DIR /
// PI_CODING_AGENT_SESSION_DIR and a deny-by-default env allowlist (BR-3).
// - Every managed launch carries an EXPLICIT trust flag (BR-5) — ambient
// `ask` silently skips in RPC mode, it never asks.
// - The runner persists `runner-state.json` (the sidecar) from RPC
// `get_state`, and prints the READY marker to the pane; the daemon reads
// ONLY runner-authored surfaces — never Pi TUI heuristics (BR-1).
import nodePath from "node:path";
import { shellQuote } from "./shell-quote.js";
// ── Seat state layout ────────────────────────────────────────────────────────
// <stateRoot>/<sessionName>/agent → PI_CODING_AGENT_DIR (auth.json, models.json, skills, …)
// <stateRoot>/<sessionName>/sessions → PI_CODING_AGENT_SESSION_DIR (+ explicit --session-dir; flag wins)
// <stateRoot>/<sessionName>/runner-state.json → the runner's session-identity sidecar
export interface PiSeatPaths {
seatRoot: string;
agentDir: string;
sessionsDir: string;
runnerStatePath: string;
}
export function piSeatPaths(stateRoot: string, sessionName: string): PiSeatPaths {
const seatRoot = nodePath.join(stateRoot, sessionName);
return {
seatRoot,
agentDir: nodePath.join(seatRoot, "agent"),
sessionsDir: nodePath.join(seatRoot, "sessions"),
runnerStatePath: nodePath.join(seatRoot, "runner-state.json"),
};
}
// ── Pane markers (runner-authored; the adapter greps for THESE, never Pi UI) ─
export const PI_RUNNER_READY_MARKER = "[pi-runner] READY";
export const PI_RUNNER_EXIT_MARKER = "[pi-runner] EXITED";
export const PI_RUNNER_ERROR_MARKER = "[pi-runner] ERROR";
// ── Runner-state sidecar ─────────────────────────────────────────────────────
export interface PiRunnerState {
ready: boolean;
/** Launch-attempt scope (guard fold, code-review qitem-20260707011908):
* the adapter mints a launchId per attempt, pre-writes a pending sidecar
* carrying it, and passes --launch-id to the runner; the runner stamps it
* into every sidecar write. Readiness/resume polls IGNORE any state whose
* launchId differs — durable artifacts from prior runner instances can
* never false-green a new launch or false-fail it with a stale exit. */
launchId?: string;
/** Absolute session-file path from RPC get_state — the resume token. */
sessionFile?: string;
/** UUIDv7 session id from get_state — display/fallback metadata. */
sessionId?: string;
/** Durable catch-up cursor: last session-entry id projected to the bus. */
lastEntryId?: string;
/** ISO timestamp of the last sidecar write. */
updatedAt: string;
/** Set when the pi process exited; the seat is honestly non-running. */
exited?: { code: number | null; at: string };
}
/** The launch-scoped pending record every writer uses when resetting the
* sidecar for a new attempt. It deliberately CARRIES the prior lastEntryId:
* the durable catch-up cursor (FR-5) must survive the stale-artifact reset —
* erasing it broke get_entries-since on resume (guard re-verdict,
* qitem-20260707013815). Everything else from the prior record is exactly
* the stale state the reset exists to scope away. */
export function buildPendingRunnerState(
launchId: string,
updatedAt: string,
prior: PiRunnerState | null,
): PiRunnerState {
return {
ready: false,
launchId,
lastEntryId: prior?.lastEntryId,
updatedAt,
};
}
export function parsePiRunnerState(raw: string): PiRunnerState | null {
try {
const parsed = JSON.parse(raw);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
const state = parsed as Record<string, unknown>;
if (typeof state.ready !== "boolean" || typeof state.updatedAt !== "string") return null;
return parsed as unknown as PiRunnerState;
} catch {
return null;
}
}
// ── Provider env passthrough (BR-3 / FR-7) ──────────────────────────────────
// Deny-by-default: the runner passes the pi child ONLY the baseline vars plus
// the DECLARED provider's key var. Extending this map is a reviewed change,
// never a convenience edit. Custom/local providers configure keys via the
// seat's managed models.json instead (their vars are not ambient-forwarded).
export const PI_PROVIDER_ENV_VARS: Record<string, string> = {
// Founder ruling 2026-07-06 (supersedes the PRD FR-7 zai/kimi-first framing):
// OpenRouter is the PREFERRED provider path — one key covers the GLM and
// Kimi model families and is the cheaper/easier route users actually take.
// The native providers stay supported as secondary paths.
"openrouter": "OPENROUTER_API_KEY",
"zai": "ZAI_API_KEY",
"kimi-coding": "KIMI_API_KEY",
};
// Baseline process needs for a spawned pi child. No OPENRIG_*, no host
// credential families, no shell customization vars.
export const PI_ENV_BASELINE_VARS = ["PATH", "HOME", "TERM", "LANG", "LC_ALL", "SHELL", "TMPDIR"] as const;
/** Model declaration: Pi accepts `--model provider/id`. The provider segment
* (before the first "/") selects the env passthrough var, if any. */
export function providerFromModel(model: string | undefined): string | null {
const trimmed = model?.trim() ?? "";
const slash = trimmed.indexOf("/");
if (slash <= 0) return null;
return trimmed.slice(0, slash);
}
/** Build the deny-by-default env for the pi child process (BR-3). `source` is
* the runner's own env; only allowlisted names cross the boundary. */
export function buildPiChildEnv(
source: Record<string, string | undefined>,
opts: { agentDir: string; sessionsDir: string; model?: string },
): Record<string, string> {
const env: Record<string, string> = {};
for (const name of PI_ENV_BASELINE_VARS) {
const value = source[name];
if (value !== undefined) env[name] = value;
}
env.PI_CODING_AGENT_DIR = opts.agentDir;
env.PI_CODING_AGENT_SESSION_DIR = opts.sessionsDir;
const provider = providerFromModel(opts.model);
const providerVar = provider ? PI_PROVIDER_ENV_VARS[provider] : undefined;
if (providerVar && source[providerVar] !== undefined) {
env[providerVar] = source[providerVar]!;
}
return env;
}
// ── Command construction ─────────────────────────────────────────────────────
export interface PiRunnerLaunchOpts {
/** Absolute path to the compiled runner entry (daemon dist). */
runnerEntryPath: string;
/** The seat's canonical session name (identity + sidecar key). */
sessionName: string;
/** Seat state root (the <stateRoot> piSeatPaths derives from). */
stateRoot: string;
/** Managed working directory for the Pi session. */
cwd: string;
/** Optional `provider/id` model declaration (FR-7). */
model?: string;
/** Explicit trust posture — REQUIRED, never ambient (BR-5). */
trust: "approve" | "no-approve";
/** Exact session file to resume (FR-6). Mutually exclusive with forkRef. */
sessionFile?: string;
/** Session file path or id to fork from via CLI --fork (FR-6). */
forkRef?: string;
/** Launch-attempt scope stamped into every runner sidecar write. */
launchId: string;
}
/** The command typed into the seat's tmux pane. The runner owns everything
* past this boundary (pi spawn, env allowlist, RPC, mirror, sidecar). */
export function buildPiRunnerCommand(opts: PiRunnerLaunchOpts): string {
const parts = [
"node",
shellQuote(opts.runnerEntryPath),
"--session-name", shellQuote(opts.sessionName),
"--state-root", shellQuote(opts.stateRoot),
"--cwd", shellQuote(opts.cwd),
"--launch-id", shellQuote(opts.launchId),
`--${opts.trust}`,
];
if (opts.model?.trim()) {
parts.push("--model", shellQuote(opts.model.trim()));
}
if (opts.sessionFile) {
parts.push("--session", shellQuote(opts.sessionFile));
}
if (opts.forkRef) {
parts.push("--fork", shellQuote(opts.forkRef));
}
return parts.join(" ");
}
/** Argv for the `pi` child the RUNNER spawns (argv-style, no shell). The
* explicit `--session-dir` wins over env per Pi's documented precedence —
* both are set so the isolation holds even if one layer regresses. */
export function buildPiChildArgs(opts: {
sessionsDir: string;
sessionName: string;
model?: string;
trust: "approve" | "no-approve";
sessionFile?: string;
forkRef?: string;
}): string[] {
const args = [
"--mode", "rpc",
"--session-dir", opts.sessionsDir,
"--name", opts.sessionName,
`--${opts.trust}`,
];
if (opts.model?.trim()) {
args.push("--model", opts.model.trim());
}
if (opts.sessionFile) {
// Exact file resume — NEVER --resume (interactive picker; forbidden in
// managed paths, PRD FR-6).
args.push("--session", opts.sessionFile);
} else if (opts.forkRef) {
// Whole-session fork with parentSession linkage — CLI --fork, NOT RPC
// fork (which is by-entryId on the active session; a different operation).
args.push("--fork", opts.forkRef);
}
return args;
}
+552
View File
@@ -0,0 +1,552 @@
// OPR.0.4.6.PI1 — the pane-hosted pi-runner (compiled entry in the daemon dist).
//
// The runner is what makes a Pi seat behave like a normal OpenRig tmux seat
// while everything underneath stays structured RPC:
//
// pane stdin (rig send / human typing) ──▶ RPC prompt / steer / follow_up
// pi RPC events (typed JSONL) ──▶ (a) human-readable pane mirror
// (b) activity + session_identity
// POSTs to the daemon
// (c) runner-state.json sidecar
//
// BR-1: activity/session identity derive ONLY from Pi's typed events +
// get_state — never pane scraping. BR-3: the pi child gets a deny-by-default
// env allowlist. BR-5: the trust flag is always explicit. Honest failure:
// a dead pi process prints the EXIT/ERROR marker and records `exited` in the
// sidecar — never a silently frozen pane.
//
// Only node builtins + pi-runner-protocol are imported so the compiled entry
// stays runnable as `node <dist>/adapters/pi-runner.js` with no daemon deps.
import fs from "node:fs";
import nodePath from "node:path";
import readline from "node:readline";
import { spawn } from "node:child_process";
import { pathToFileURL } from "node:url";
import {
piSeatPaths, buildPiChildArgs, buildPiChildEnv, buildPendingRunnerState, parsePiRunnerState,
PI_RUNNER_READY_MARKER, PI_RUNNER_EXIT_MARKER, PI_RUNNER_ERROR_MARKER,
type PiRunnerState,
} from "./pi-runner-protocol.js";
// ── Paste aggregation (arch n1) ──────────────────────────────────────────────
// `rig send` delivers a multi-line body via send-keys -l and THEN a separate
// Enter, so a multi-line paste arrives as several stdin lines in quick
// succession. The aggregator treats the whole quiet-window batch as ONE
// prompt — never N prompts.
export class PasteAggregator {
private buffer: string[] = [];
private timer: ReturnType<typeof setTimeout> | null = null;
constructor(
private onFlush: (block: string) => void,
// 600ms: the daemon transport's two-step send pastes the body, waits
// 200ms, THEN submits Enter — so the final line's newline arrives ~200ms
// after the paste. A 200ms quiet window raced that gap and split one
// rig-send envelope into two prompts (VM leg-4 finding); 3x the transport
// gap absorbs it while staying far below human message cadence.
private quietMs = 600,
private schedule: (fn: () => void, ms: number) => ReturnType<typeof setTimeout> = setTimeout,
private cancel: (t: ReturnType<typeof setTimeout>) => void = clearTimeout,
) {}
addLine(line: string): void {
this.buffer.push(line);
if (this.timer !== null) this.cancel(this.timer);
this.timer = this.schedule(() => this.flush(), this.quietMs);
}
flush(): void {
if (this.timer !== null) {
this.cancel(this.timer);
this.timer = null;
}
if (this.buffer.length === 0) return;
const block = this.buffer.join("\n").trim();
this.buffer = [];
if (block.length > 0) this.onFlush(block);
}
}
// ── Pi event → mirror + activity mapping (pure, hermetically testable) ──────
export interface MirrorAndActivity {
/** Lines to print to the pane (already human-readable). */
mirrorLines: string[];
/** Raw text to append to the current mirror line (streamed deltas). */
mirrorAppend?: string;
/** Activity POST payload (hookEvent/subtype), when the event maps to one. */
activity?: { hookEvent: string; subtype: string | null };
/** Streaming-state transition, when the event carries one. */
streaming?: boolean;
}
/** Tolerant text extraction from a Pi message shape (string, {text}, or
* content-block arrays). Exact field calibration is a VM-proof concern; this
* covers the documented shapes without throwing on unknowns. */
export function extractMessageText(message: unknown): string {
if (typeof message === "string") return message;
if (message === null || typeof message !== "object") return "";
const m = message as Record<string, unknown>;
if (typeof m.text === "string") return m.text;
if (Array.isArray(m.content)) {
return m.content
.map((block) => {
if (typeof block === "string") return block;
if (block !== null && typeof block === "object" && typeof (block as Record<string, unknown>).text === "string") {
return (block as Record<string, unknown>).text as string;
}
return "";
})
.join("");
}
if (typeof m.content === "string") return m.content;
return "";
}
export function mapPiEvent(event: Record<string, unknown>): MirrorAndActivity {
const type = typeof event.type === "string" ? event.type : "";
switch (type) {
case "agent_start":
return { mirrorLines: [], activity: { hookEvent: "active", subtype: "agent_start" }, streaming: true };
case "agent_end":
return { mirrorLines: [""], activity: { hookEvent: "Stop", subtype: "agent_end" }, streaming: false };
case "turn_start":
case "turn_end":
case "message_start":
return { mirrorLines: [] };
case "message_update": {
const delta = extractMessageText(event.message ?? event.delta);
return delta ? { mirrorLines: [], mirrorAppend: delta } : { mirrorLines: [] };
}
case "message_end": {
// The message text already streamed via message_update appends; this
// terminates the line. (mapPiEvent is stateless, so a hypothetical
// updates-carried-nothing case is a VM-calibration follow-up, not
// silently guessed here.)
return { mirrorLines: [""] };
}
case "tool_execution_start": {
const tool = typeof event.toolName === "string" ? event.toolName : (typeof event.name === "string" ? event.name : "tool");
return { mirrorLines: [` ⚙ ${tool} …`], activity: { hookEvent: "PreToolUse", subtype: tool } };
}
case "tool_execution_end": {
const tool = typeof event.toolName === "string" ? event.toolName : (typeof event.name === "string" ? event.name : "tool");
const failed = event.isError === true || event.error != null;
return { mirrorLines: [` ⚙ ${tool} ${failed ? "FAILED" : "done"}`] };
}
case "queue_update":
return { mirrorLines: [] };
case "compaction_start":
return { mirrorLines: ["[pi] compacting context…"], activity: { hookEvent: "active", subtype: "compaction" } };
case "compaction_end":
return { mirrorLines: ["[pi] compaction done"] };
case "auto_retry_start":
return { mirrorLines: ["[pi] transient error — retrying"], activity: { hookEvent: "active", subtype: "auto_retry" } };
case "auto_retry_end":
return { mirrorLines: [] };
case "extension_error": {
const message = typeof event.message === "string" ? event.message : "extension error";
return { mirrorLines: [`${PI_RUNNER_ERROR_MARKER} extension: ${message}`] };
}
default:
return { mirrorLines: [] };
}
}
// ── The runner core (injected effects; owns protocol state) ─────────────────
export interface RunnerIo {
/** Write one JSONL command to pi stdin. */
sendRpc(cmd: Record<string, unknown>): void;
/** Print a full line to the pane. */
mirrorLine(line: string): void;
/** Append raw text to the current pane line (streamed deltas). */
mirrorAppend(text: string): void;
/** Fire-and-forget POST to the daemon activity endpoint. */
postActivity(payload: Record<string, unknown>): void;
/** Persist the runner-state sidecar. */
writeSidecar(state: PiRunnerState): void;
now(): string;
}
const GET_STATE_ID = "pi-runner-get-state";
const CATCH_UP_ID = "pi-runner-catch-up";
const CURSOR_REFRESH_ID = "pi-runner-cursor-refresh";
export class RunnerCore {
private streaming = false;
private sessionFile: string | undefined;
private sessionId: string | undefined;
private lastEntryId: string | undefined;
private ready = false;
constructor(
private io: RunnerIo,
private identity: { sessionName: string; nodeId?: string; launchId?: string },
private opts: { catchUpSince?: string } = {},
) {
// The durable cursor seeds from the carried-over value (FR-5) so this
// instance's own sidecar writes never regress it to undefined before a
// newer entry supersedes it.
this.lastEntryId = opts.catchUpSince;
}
/** Kick off identity capture. Called once pi's RPC stream is up. */
start(): void {
this.io.sendRpc({ type: "get_state", id: GET_STATE_ID });
if (this.opts.catchUpSince) {
// Durable catch-up cursor (FR-5): replay session entries the previous
// runner instance had not yet projected. Mirror-only; activity states
// are live-only signals.
this.io.sendRpc({ type: "get_entries", since: this.opts.catchUpSince, id: CATCH_UP_ID });
}
}
/** One LF-delimited JSONL record from pi stdout. */
handlePiLine(rawLine: string): void {
const line = rawLine.trim();
if (!line) return;
let record: Record<string, unknown>;
try {
const parsed = JSON.parse(line);
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return;
record = parsed as Record<string, unknown>;
} catch {
// Non-JSON noise on pi stdout — mirror it verbatim so nothing hides.
this.io.mirrorLine(line);
return;
}
if (record.type === "response") {
this.handleResponse(record);
return;
}
this.handleEvent(record);
}
/** One aggregated paste block from pane stdin. */
handleUserBlock(block: string): void {
if (block === "/abort") {
this.io.sendRpc({ type: "abort" });
this.io.mirrorLine("[pi-runner] abort sent");
return;
}
if (block.startsWith("/followup ")) {
const message = block.slice("/followup ".length);
this.io.sendRpc({ type: "follow_up", message });
this.io.mirrorLine(`you (follow-up) ▸ ${message}`);
return;
}
if (this.streaming) {
// Mid-stream: steer delivers after the current turn's tool calls,
// before the next model call (Pi's documented semantics).
this.io.sendRpc({ type: "steer", message: block });
this.io.mirrorLine(`you (steer) ▸ ${block}`);
return;
}
this.io.sendRpc({ type: "prompt", message: block });
this.io.mirrorLine(`you ▸ ${block}`);
}
/** Pi process exit — honest, loud, durable. */
handlePiExit(code: number | null): void {
this.ready = false;
this.io.mirrorLine(`${PI_RUNNER_EXIT_MARKER} pi exited (code ${code ?? "unknown"})`);
this.writeSidecar({ exited: { code, at: this.io.now() } });
this.io.postActivity(this.activityPayload("Stop", "pi_exited"));
}
private handleResponse(record: Record<string, unknown>): void {
if (record.id === GET_STATE_ID) {
const data = (record.data ?? record.state ?? record) as Record<string, unknown>;
const sessionFile = typeof data.sessionFile === "string" ? data.sessionFile : undefined;
const sessionId = typeof data.sessionId === "string" ? data.sessionId : undefined;
this.sessionFile = sessionFile ?? this.sessionFile;
this.sessionId = sessionId ?? this.sessionId;
this.ready = true;
this.writeSidecar({});
this.io.mirrorLine(`${PI_RUNNER_READY_MARKER} session=${this.sessionFile ?? "unknown"}`);
this.io.postActivity({
eventFamily: "session_identity",
sessionName: this.identity.sessionName,
nodeId: this.identity.nodeId ?? null,
runtime: "pi",
hookEvent: "SessionStart",
sessionId: this.sessionId ?? "unknown",
sessionFile: this.sessionFile ?? null,
occurredAt: this.io.now(),
});
return;
}
if (record.id === CURSOR_REFRESH_ID || record.id === CATCH_UP_ID) {
const data = (record.data ?? record) as Record<string, unknown>;
const entries = Array.isArray(data.entries) ? data.entries : (Array.isArray(record.entries) ? record.entries : []);
const last = entries.at(-1);
const lastId = last !== null && typeof last === "object" && typeof (last as Record<string, unknown>).id === "string"
? (last as Record<string, unknown>).id as string
: undefined;
if (lastId) {
this.lastEntryId = lastId;
this.writeSidecar({});
}
return;
}
// Other responses (prompt accepted, …) — surface errors.
if (record.success === false || record.error != null) {
const message = typeof record.error === "string" ? record.error : "request failed";
this.io.mirrorLine(`${PI_RUNNER_ERROR_MARKER} rpc: ${message}`);
}
}
private handleEvent(event: Record<string, unknown>): void {
// Durable cursor: any event carrying a session-entry id advances it.
const entryId = typeof event.entryId === "string" ? event.entryId : (typeof event.id === "string" ? event.id : undefined);
if (entryId) {
this.lastEntryId = entryId;
this.writeSidecar({});
}
const mapped = mapPiEvent(event);
if (mapped.streaming !== undefined) this.streaming = mapped.streaming;
if (event.type === "agent_end") {
// QA RED fold (qitem-20260707020922): live events do not reliably carry
// session-entry ids, so the durable cursor starved (lastEntryId stayed
// null in real runs). Refresh it from the source of truth after every
// completed turn — get_entries returns append-order entries with stable
// ids; the response handler advances the cursor from the tail.
this.io.sendRpc({ type: "get_entries", id: CURSOR_REFRESH_ID });
}
if (mapped.mirrorAppend) this.io.mirrorAppend(mapped.mirrorAppend);
for (const line of mapped.mirrorLines) this.io.mirrorLine(line);
if (mapped.activity) {
this.io.postActivity(this.activityPayload(mapped.activity.hookEvent, mapped.activity.subtype));
}
}
private activityPayload(hookEvent: string, subtype: string | null): Record<string, unknown> {
return {
sessionName: this.identity.sessionName,
nodeId: this.identity.nodeId ?? null,
runtime: "pi",
hookEvent,
subtype,
occurredAt: this.io.now(),
};
}
private writeSidecar(patch: Partial<PiRunnerState>): void {
this.io.writeSidecar({
ready: this.ready,
// Launch-attempt scope: every write is stamped so the daemon can
// distinguish THIS runner instance's truth from stale artifacts.
launchId: this.identity.launchId,
sessionFile: this.sessionFile,
sessionId: this.sessionId,
lastEntryId: this.lastEntryId,
updatedAt: this.io.now(),
...patch,
});
}
}
// ── CLI entry ────────────────────────────────────────────────────────────────
interface RunnerArgs {
sessionName: string;
stateRoot: string;
cwd: string;
launchId: string;
model?: string;
trust: "approve" | "no-approve";
sessionFile?: string;
forkRef?: string;
}
export function parseRunnerArgs(argv: string[]): RunnerArgs {
const args: Partial<RunnerArgs> & { trust?: "approve" | "no-approve" } = {};
for (let i = 0; i < argv.length; i++) {
const flag = argv[i]!;
const next = () => {
const value = argv[++i];
if (value === undefined) throw new Error(`${flag} requires a value`);
return value;
};
switch (flag) {
case "--session-name": args.sessionName = next(); break;
case "--state-root": args.stateRoot = next(); break;
case "--cwd": args.cwd = next(); break;
case "--launch-id": args.launchId = next(); break;
case "--model": args.model = next(); break;
case "--session": args.sessionFile = next(); break;
case "--fork": args.forkRef = next(); break;
case "--approve": args.trust = "approve"; break;
case "--no-approve": args.trust = "no-approve"; break;
default: throw new Error(`unknown flag: ${flag}`);
}
}
if (!args.sessionName) throw new Error("--session-name is required");
if (!args.stateRoot) throw new Error("--state-root is required");
if (!args.cwd) throw new Error("--cwd is required");
if (!args.launchId) throw new Error("--launch-id is required (launch-attempt scoping)");
if (!args.trust) throw new Error("an explicit trust flag is required: --approve or --no-approve");
if (args.sessionFile && args.forkRef) throw new Error("--session and --fork are mutually exclusive");
return args as RunnerArgs;
}
function resolveActivityEndpoint(env: NodeJS.ProcessEnv): { baseUrl: string; token: string } | null {
let baseUrl = env.OPENRIG_URL?.trim() || null;
let token = env.OPENRIG_ACTIVITY_HOOK_TOKEN?.trim() || null;
if (!baseUrl && env.OPENRIG_PORT) {
baseUrl = `http://${env.OPENRIG_HOST?.trim() || "127.0.0.1"}:${env.OPENRIG_PORT.trim()}`;
}
if (!baseUrl || !token) {
try {
const home = env.OPENRIG_HOME?.trim() || nodePath.join(process.env.HOME ?? "", ".openrig");
const parsed = JSON.parse(fs.readFileSync(nodePath.join(home, "activity-endpoint.json"), "utf8"));
if (!baseUrl && typeof parsed.baseUrl === "string") baseUrl = parsed.baseUrl;
if (!token && typeof parsed.token === "string") token = parsed.token;
} catch {
// absent/malformed — activity POSTs no-op; the sidecar + mirror still work.
}
}
return baseUrl && token ? { baseUrl, token } : null;
}
/** The runner-side sidecar handshake, extracted for hermetic testing (guard
* re-verdict, qitem-20260707013815): read the PRIOR record's durable cursor
* FIRST, then stamp the launch-scoped pending record — the write carries the
* cursor forward so no reset in the chain can erase it. `catchUpSince` is
* only surfaced when resuming: a fresh/fork session has no prior projection
* to catch up. */
export function prepareRunnerSidecar(
fsOps: { readFile(p: string): string; writeFile(p: string, c: string): void; exists(p: string): boolean },
runnerStatePath: string,
launchId: string,
resuming: boolean,
now: () => string,
): { catchUpSince: string | undefined } {
let prior: PiRunnerState | null = null;
try {
prior = fsOps.exists(runnerStatePath) ? parsePiRunnerState(fsOps.readFile(runnerStatePath)) : null;
} catch { /* unreadable prior sidecar — treated as absent */ }
try {
fsOps.writeFile(runnerStatePath, JSON.stringify(buildPendingRunnerState(launchId, now(), prior)));
} catch { /* best-effort; the adapter pre-writes an equivalent pending record */ }
return { catchUpSince: resuming ? prior?.lastEntryId : undefined };
}
export async function main(argv = process.argv.slice(2)): Promise<void> {
let args: RunnerArgs;
try {
args = parseRunnerArgs(argv);
} catch (err) {
console.error(`${PI_RUNNER_ERROR_MARKER} ${(err as Error).message}`);
process.exitCode = 2;
return;
}
const paths = piSeatPaths(args.stateRoot, args.sessionName);
fs.mkdirSync(paths.agentDir, { recursive: true });
fs.mkdirSync(paths.sessionsDir, { recursive: true });
const { catchUpSince } = prepareRunnerSidecar(
{
readFile: (p) => fs.readFileSync(p, "utf8"),
writeFile: (p, c) => fs.writeFileSync(p, c),
exists: (p) => fs.existsSync(p),
},
paths.runnerStatePath,
args.launchId,
!!args.sessionFile,
() => new Date().toISOString(),
);
const endpoint = resolveActivityEndpoint(process.env);
const childEnv = buildPiChildEnv(process.env as Record<string, string | undefined>, {
agentDir: paths.agentDir,
sessionsDir: paths.sessionsDir,
model: args.model,
});
const childArgs = buildPiChildArgs({
sessionsDir: paths.sessionsDir,
sessionName: args.sessionName,
model: args.model,
trust: args.trust,
sessionFile: args.sessionFile,
forkRef: args.forkRef,
});
console.log(`[pi-runner] starting pi --mode rpc (seat ${args.sessionName})`);
console.log(`[pi-runner] send text normally; prefixes: "/followup <text>" queues after the turn, "/abort" cancels`);
const child = spawn("pi", childArgs, {
cwd: args.cwd,
env: childEnv,
stdio: ["pipe", "pipe", "pipe"],
});
const io: RunnerIo = {
sendRpc: (cmd) => {
try { child.stdin.write(`${JSON.stringify(cmd)}\n`); } catch { /* exit handler reports */ }
},
mirrorLine: (line) => process.stdout.write(`${line}\n`),
mirrorAppend: (text) => process.stdout.write(text),
postActivity: (payload) => {
if (!endpoint || typeof fetch !== "function") return;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 1500);
fetch(new URL("/api/activity/hooks", endpoint.baseUrl).toString(), {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${endpoint.token}` },
body: JSON.stringify(payload),
signal: controller.signal,
}).catch(() => { /* best-effort — never blocks the loop */ }).finally(() => clearTimeout(timeout));
},
writeSidecar: (state) => {
try {
fs.writeFileSync(paths.runnerStatePath, JSON.stringify(state));
} catch { /* best-effort; adapter falls back to pane markers */ }
},
now: () => new Date().toISOString(),
};
const core = new RunnerCore(io, { sessionName: args.sessionName, nodeId: process.env.OPENRIG_NODE_ID, launchId: args.launchId }, { catchUpSince });
const aggregator = new PasteAggregator((block) => core.handleUserBlock(block));
readline.createInterface({ input: child.stdout }).on("line", (line) => core.handlePiLine(line));
readline.createInterface({ input: child.stderr }).on("line", (line) => {
if (line.trim()) process.stdout.write(`[pi:err] ${line}\n`);
});
readline.createInterface({ input: process.stdin }).on("line", (line) => aggregator.addLine(line));
child.on("error", (err) => {
console.error(`${PI_RUNNER_ERROR_MARKER} failed to spawn pi: ${err.message}`);
core.handlePiExit(null);
process.exitCode = 1;
});
child.on("exit", (code) => {
core.handlePiExit(code);
process.exitCode = code ?? 1;
});
core.start();
}
// Compiled-entry guard: run main() only when executed directly (not imported
// by tests). import.meta.url === file URL of process.argv[1] when direct.
const invokedDirectly = (() => {
try {
const entry = process.argv[1];
if (!entry) return false;
// pathToFileURL handles percent-encoding (spaces etc.) the way
// import.meta.url does — a hand-built `file://${path}` string does not.
return import.meta.url === pathToFileURL(nodePath.resolve(entry)).href;
} catch {
return false;
}
})();
if (invokedDirectly) {
void main();
}
@@ -0,0 +1,421 @@
// OPR.0.4.6.PI1 — the Pi runtime adapter (RPC-first, runner-in-a-pane).
//
// The adapter launches the OpenRig-owned pi-runner inside the seat's normal
// tmux pane; the runner hosts `pi --mode rpc` (headless JSONL — Pi's
// first-class integration surface). send/capture stay tmux-native and are NOT
// adapter methods (the runner forwards pane stdin to RPC prompt/steer and
// mirrors a legible transcript to pane stdout); activity + session identity
// come from Pi's typed RPC events + get_state via the runner's sidecar and
// bus emission — never pane scraping (BR-1). TUI-native Pi is a SEPARATE
// future contract, never a hidden mode here (BR-2).
import nodePath from "node:path";
import { randomUUID } from "node:crypto";
import type { TmuxAdapter } from "./tmux.js";
import type {
RuntimeAdapter, NodeBinding, ResolvedStartupFile,
InstalledResource, ProjectionResult, StartupDeliveryResult, ReadinessResult,
HarnessLaunchResult, ForkSource,
} from "../domain/runtime-adapter.js";
import { resolveConcreteHint } from "../domain/runtime-adapter.js";
import type { ProjectionPlan, ProjectionEntry } from "../domain/projection-planner.js";
import { validateResumeToken } from "../domain/resume-token-validation.js";
import { mergeManagedBlock } from "../domain/managed-blocks.js";
import {
piSeatPaths, parsePiRunnerState, buildPiRunnerCommand, buildPendingRunnerState,
PI_RUNNER_READY_MARKER, PI_RUNNER_ERROR_MARKER, PI_RUNNER_EXIT_MARKER,
type PiRunnerState,
} from "./pi-runner-protocol.js";
const SHELL_COMMANDS = new Set(["bash", "fish", "nu", "sh", "tmux", "zsh"]);
export interface PiAdapterFsOps {
readFile(path: string): string;
writeFile(path: string, content: string): void;
exists(path: string): boolean;
mkdirp(path: string): void;
listFiles?(dirPath: string): string[];
}
export interface PiRuntimeAdapterDeps {
tmux: TmuxAdapter;
fsOps: PiAdapterFsOps;
/** Root under which every Pi seat gets its isolated state dir (FR-7).
* Typically <OPENRIG_HOME>/state/pi. */
stateRoot: string;
/** Absolute path to the compiled pi-runner entry in the daemon dist. */
runnerEntryPath: string;
/** Trust posture for managed launches (BR-5 — always explicit; ambient
* `ask` silently skips in RPC mode). Default: "no-approve" (the
* conservative floor; seat-level guidance/skills live in the seat's
* managed agent dir, which needs no project trust). */
trustPosture?: "approve" | "no-approve";
sleep?: (ms: number) => Promise<void>;
/** Launch-attempt id minting (tests inject; defaults to randomUUID). */
newLaunchId?: () => string;
}
export class PiRuntimeAdapter implements RuntimeAdapter {
readonly runtime = "pi";
private tmux: TmuxAdapter;
private fs: PiAdapterFsOps;
private stateRoot: string;
private runnerEntryPath: string;
private trustPosture: "approve" | "no-approve";
private sleep: (ms: number) => Promise<void>;
private newLaunchId: () => string;
constructor(deps: PiRuntimeAdapterDeps) {
this.tmux = deps.tmux;
this.fs = deps.fsOps;
this.stateRoot = deps.stateRoot;
this.runnerEntryPath = deps.runnerEntryPath;
this.trustPosture = deps.trustPosture ?? "no-approve";
this.sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
this.newLaunchId = deps.newLaunchId ?? (() => randomUUID());
}
/** The pi-runner sidecar reader shape resume-token-capture consumes
* (deriveResumeToken's piRunnerStateStore dep). */
readSessionFile(sessionName: string): { ok: true; sessionFile: string } | { ok: false; reason: string } {
const state = this.readRunnerState(sessionName);
if (state === null) {
const { runnerStatePath } = piSeatPaths(this.stateRoot, sessionName);
return this.fs.exists(runnerStatePath)
? { ok: false, reason: "parse_error" }
: { ok: false, reason: "missing_sidecar" };
}
if (!state.sessionFile) return { ok: false, reason: "missing_sidecar" };
return { ok: true, sessionFile: state.sessionFile };
}
async listInstalled(binding: NodeBinding): Promise<InstalledResource[]> {
const results: InstalledResource[] = [];
const sessionName = binding.tmuxSession;
if (!sessionName) return results;
const { agentDir } = piSeatPaths(this.stateRoot, sessionName);
const skillsDir = nodePath.join(agentDir, "skills");
if (this.fs.exists(skillsDir) && this.fs.listFiles) {
for (const file of this.fs.listFiles(skillsDir)) {
results.push({ effectiveId: file, category: "skill", installedPath: nodePath.join(skillsDir, file) });
}
}
return results;
}
async project(plan: ProjectionPlan, binding: NodeBinding): Promise<ProjectionResult> {
const projected: string[] = [];
const skipped: string[] = [];
const failed: Array<{ effectiveId: string; error: string }> = [];
for (const entry of plan.entries) {
if (entry.classification === "no_op") {
skipped.push(entry.effectiveId);
continue;
}
try {
if (this.projectEntry(entry, binding)) {
projected.push(entry.effectiveId);
} else {
skipped.push(entry.effectiveId);
}
} catch (err) {
failed.push({ effectiveId: entry.effectiveId, error: (err as Error).message });
}
}
return { projected, skipped, failed };
}
async deliverStartup(files: ResolvedStartupFile[], binding: NodeBinding): Promise<StartupDeliveryResult> {
let delivered = 0;
const failed: Array<{ path: string; error: string }> = [];
for (const file of files) {
try {
const content = this.fs.readFile(file.absolutePath);
const hint = file.deliveryHint === "auto" ? resolveConcreteHint(file.path, content) : file.deliveryHint;
switch (hint) {
case "guidance_merge": {
// ADDITIVE context-file merge (Omnigent best-practice: never
// replace Pi's default system prompt). Pi reads AGENTS.md from
// the managed cwd as a project context file.
const targetPath = nodePath.join(binding.cwd, "AGENTS.md");
const merged = this.mergeGuidance(targetPath, file.path, content);
if (!merged) continue; // rig-role skip: do not count as delivered
break;
}
case "skill_install": {
if (!binding.tmuxSession) throw new Error("No tmux session bound — cannot resolve the Pi seat state dir");
const { agentDir } = piSeatPaths(this.stateRoot, binding.tmuxSession);
const targetDir = nodePath.join(agentDir, "skills", nodePath.basename(nodePath.dirname(file.absolutePath)));
this.fs.mkdirp(targetDir);
this.fs.writeFile(nodePath.join(targetDir, nodePath.basename(file.path)), content);
break;
}
case "send_text": {
if (binding.tmuxSession) {
// The runner reads pane stdin and forwards as RPC prompt (FR-3).
const textResult = await this.tmux.sendText(binding.tmuxSession, content);
if (!textResult.ok) throw new Error(textResult.message);
await this.sleep(200);
const submitResult = await this.tmux.sendKeys(binding.tmuxSession, ["C-m"]);
if (!submitResult.ok) throw new Error(submitResult.message);
}
break;
}
}
delivered++;
} catch (err) {
if (file.required) {
failed.push({ path: file.path, error: (err as Error).message });
}
}
}
return { delivered, failed };
}
async launchHarness(
binding: NodeBinding,
opts: { name: string; resumeToken?: string; forkSource?: ForkSource },
): Promise<HarnessLaunchResult> {
if (!binding.tmuxSession) {
return { ok: false, error: "No tmux session bound — cannot launch the Pi harness" };
}
if (opts.resumeToken && opts.forkSource) {
return { ok: false, error: "resumeToken and forkSource are mutually exclusive — pick one" };
}
let forkRef: string | undefined;
if (opts.forkSource) {
if (opts.forkSource.kind !== "native_id") {
return {
ok: false,
error: `pi fork: ref.kind="${opts.forkSource.kind}" is not supported in v1; use ref.kind="native_id" with the parent session file path or session id`,
};
}
forkRef = opts.forkSource.value?.trim();
if (!forkRef) {
return { ok: false, error: "pi fork: forkSource.value is required (parent session file path or session id)" };
}
}
if (opts.resumeToken) {
// Validity floor before we type anything into the pane.
const validation = validateResumeToken("pi", opts.resumeToken);
if (!validation.ok) {
return { ok: false, error: `pi resume: ${validation.error}` };
}
if (!this.fs.exists(opts.resumeToken)) {
// Session file gone — the honest outcome is the caller's stop-and-ask
// (awaiting-decision), never a silent fresh start (BR-6).
return { ok: false, error: "pi resume: the persisted session file no longer exists", recovery: "retry_fresh" };
}
}
const sessionName = binding.tmuxSession;
const paths = piSeatPaths(this.stateRoot, sessionName);
this.fs.mkdirp(paths.agentDir);
this.fs.mkdirp(paths.sessionsDir);
// Launch-attempt scoping (guard fold): overwrite any stale sidecar from a
// prior runner instance with a pending record BEFORE the command is typed,
// and only trust sidecar states stamped with THIS attempt's launchId.
// The prior record is read FIRST so the durable catch-up cursor
// (lastEntryId, FR-5) survives the reset.
const launchId = this.newLaunchId();
const prior = this.readRunnerState(sessionName);
this.fs.writeFile(
paths.runnerStatePath,
JSON.stringify(buildPendingRunnerState(launchId, new Date().toISOString(), prior)),
);
const cmd = buildPiRunnerCommand({
runnerEntryPath: this.runnerEntryPath,
sessionName,
stateRoot: this.stateRoot,
cwd: binding.cwd,
model: binding.model,
trust: this.trustPosture,
sessionFile: opts.resumeToken,
forkRef,
launchId,
});
const textResult = await this.tmux.sendText(sessionName, cmd);
if (!textResult.ok) {
return { ok: false, error: `Failed to send launch command: ${textResult.message}` };
}
const enterResult = await this.tmux.sendKeys(sessionName, ["Enter"]);
if (!enterResult.ok) {
return { ok: false, error: `Failed to send Enter: ${enterResult.message}` };
}
// The runner writes the sidecar after its first successful get_state;
// that sidecar (not pane content) is the token source of truth (FR-5).
const state = await this.waitForRunnerReady(sessionName, launchId);
if (!state.ok) return state.failure;
const sessionFile = state.value.sessionFile;
if (!sessionFile) {
return { ok: false, error: "pi launch: the runner became ready but reported no session file" };
}
if (forkRef && sessionFile === forkRef) {
// The adapter contract requires the NEW post-fork token, never the
// parent's (runtime-adapter.ts fork rule).
return { ok: false, error: "pi fork: the runner reported the parent session file instead of the post-fork child" };
}
const validation = validateResumeToken("pi", sessionFile);
if (!validation.ok) {
return { ok: false, error: `pi launch: the runner reported a malformed session file (${validation.error})` };
}
return { ok: true, resumeToken: validation.token, resumeType: "pi_session_file" };
}
async checkReady(binding: NodeBinding): Promise<ReadinessResult> {
if (!binding.tmuxSession) {
return { ready: false, reason: "No tmux session bound" };
}
const alive = await this.tmux.hasSession(binding.tmuxSession);
if (!alive) {
return { ready: false, reason: "tmux session not responsive" };
}
// Guard fold (stale artifacts): a sidecar or scrollback marker can outlive
// the runner process, so a ready signal counts ONLY while the pane's
// foreground process is not back at a shell (a dead runner leaves the
// pane at the shell; READY scrollback and a stale ready sidecar do not
// make a stopped seat ready).
const paneCommand = (await this.tmux.getPaneCommand(binding.tmuxSession)) ?? "";
const atShell = SHELL_COMMANDS.has(paneCommand);
const state = this.readRunnerState(binding.tmuxSession);
if (state?.exited) {
return { ready: false, reason: `pi-runner exited (code ${state.exited.code ?? "unknown"})`, code: "runner_exited" };
}
if (state?.ready) {
if (atShell) {
return { ready: false, reason: "pi-runner sidecar says ready but the pane is back at a shell (runner process gone)", code: "runner_exited" };
}
return { ready: true };
}
// Runner-authored pane marker as the secondary signal (FR-2) — still the
// runner's own output, never Pi TUI heuristics; same foreground guard.
const paneContent = (await this.tmux.capturePaneContent(binding.tmuxSession, 40)) ?? "";
if (paneContent.includes(PI_RUNNER_ERROR_MARKER)) {
return { ready: false, reason: "pi-runner reported an error in the pane", code: "runner_error" };
}
if (paneContent.includes(PI_RUNNER_EXIT_MARKER)) {
return { ready: false, reason: "pi-runner exited", code: "runner_exited" };
}
if (paneContent.includes(PI_RUNNER_READY_MARKER)) {
if (atShell) {
return { ready: false, reason: "READY marker is stale scrollback; the pane is back at a shell", code: "runner_exited" };
}
return { ready: true };
}
return { ready: false, reason: "pi-runner has not reported ready yet", code: "awaiting_runtime" };
}
// ── internals ──────────────────────────────────────────────────────────────
private readRunnerState(sessionName: string): PiRunnerState | null {
const { runnerStatePath } = piSeatPaths(this.stateRoot, sessionName);
if (!this.fs.exists(runnerStatePath)) return null;
try {
return parsePiRunnerState(this.fs.readFile(runnerStatePath));
} catch {
return null;
}
}
private async waitForRunnerReady(
sessionName: string,
launchId: string,
): Promise<{ ok: true; value: PiRunnerState } | { ok: false; failure: HarnessLaunchResult }> {
const pollMs = 250;
const attempts = 60; // ~15s: runner boot + pi spawn + first get_state
for (let attempt = 0; attempt < attempts; attempt++) {
const state = this.readRunnerState(sessionName);
// Launch-attempt scoping: only THIS attempt's sidecar states count.
// Stale ready/exited records from prior instances are ignored, and
// stale pane markers in scrollback are never consulted here — the
// launch-scoped sidecar is authoritative (the runner writes a pending
// record immediately at startup and exited on death).
if (state?.launchId === launchId) {
if (state.exited) {
const paneContent = (await this.tmux.capturePaneContent(sessionName, 40)) ?? "";
return {
ok: false,
failure: {
ok: false,
error: `pi launch failed: the runner exited (code ${state.exited.code ?? "unknown"})`,
recovery: "attention_required",
evidence: paneContent.split("\n").slice(-12).join("\n"),
},
};
}
if (state.ready) return { ok: true, value: state };
}
if (attempt < attempts - 1) await this.sleep(pollMs);
}
return {
ok: false,
failure: {
ok: false,
error: "pi launch: timed out waiting for the runner to report ready",
recovery: "attention_required",
},
};
}
private projectEntry(entry: ProjectionEntry, binding: NodeBinding): boolean {
if (entry.category === "guidance" && entry.mergeStrategy === "managed_block") {
const targetPath = nodePath.join(binding.cwd, "AGENTS.md");
const content = this.fs.readFile(entry.absolutePath);
return this.mergeGuidance(targetPath, entry.effectiveId, content);
}
if (entry.category === "skill") {
if (!binding.tmuxSession) return false;
const { agentDir } = piSeatPaths(this.stateRoot, binding.tmuxSession);
const targetDir = nodePath.join(agentDir, "skills", entry.effectiveId);
this.fs.mkdirp(targetDir);
const isDir = this.fs.listFiles ? this.fs.listFiles(entry.absolutePath).length > 0 : false;
if (isDir && this.fs.listFiles) {
for (const file of this.fs.listFiles(entry.absolutePath)) {
const dest = nodePath.join(targetDir, file);
this.fs.mkdirp(nodePath.dirname(dest));
this.fs.writeFile(dest, this.fs.readFile(nodePath.join(entry.absolutePath, file)));
}
} else {
this.fs.writeFile(
nodePath.join(targetDir, nodePath.basename(entry.absolutePath)),
this.fs.readFile(entry.absolutePath),
);
}
return true;
}
// Plugins / subagents / runtime resources have no Pi projection target at
// MVP (PRD §7 out-of-scope) — an honest skip, never a misdelivery.
return false;
}
private mergeGuidance(targetPath: string, blockId: string, content: string): boolean {
// Mirrors the Claude/Codex adapters: per-seat `rig-role` content collides
// across pod-mates when merged into a shared cwd file; it is delivered via
// send_text instead. See ADR-0006.
if (blockId === "rig-role") {
console.log(
`[openrig] skip: effectiveId is rig-role, per-seat delivery via send_text path required (target=${targetPath})`
);
return false;
}
mergeManagedBlock(this.fs, targetPath, blockId, content, {
replaceBlockIds: blockId === "openrig-start.md" ? ["using-openrig.md"] : [],
});
return true;
}
}
+40 -1
View File
@@ -493,7 +493,14 @@ export class TmuxAdapter {
}
}
/** Set a session-scoped user option (@-prefixed key). */
/**
* Set a SESSION-scoped option via `set-option -t <session>` (OPR.0.4.6.02
* N1 JSDoc fix, arch): this is the GENERIC session-scope writer — it takes
* ANY session option, not only `@`-prefixed user options (it is how the
* launcher sets built-in session options like `mouse` and `status`). For
* SERVER-scope options use `setServerOption` (`set-option -s`); the two
* scopes are never crossed (guard b2).
*/
async setSessionOption(sessionName: string, key: string, value: string): Promise<TmuxResult> {
const cmd = `tmux set-option -t ${shellQuote(sessionName)} ${shellQuote(key)} ${shellQuote(value)}`;
try {
@@ -504,6 +511,38 @@ export class TmuxAdapter {
}
}
/**
* OPR.0.4.6.02 S1 (guard b2): set a SERVER-scoped option via
* `set-option -s <option> <value>` — the daemon configuring its OWN tmux
* server (NOT a live-flip of anyone's session). NEVER targets a session
* (`-t`): server scope and session scope are distinct and never crossed.
* Used for `set-clipboard` / `copy-command`.
*/
async setServerOption(option: string, value: string): Promise<TmuxResult> {
const cmd = `tmux set-option -s ${shellQuote(option)} ${shellQuote(value)}`;
try {
await this.exec(cmd);
return { ok: true };
} catch (err) {
return classifyWriteError(err);
}
}
/**
* OPR.0.4.6.02 S1: read a SERVER-scoped option value via
* `show-options -sv <option>` (the `-s` server-scope reader — mirrors
* `setServerOption`). Returns null if unset or on error. For tests/proof.
*/
async showServerOption(option: string): Promise<string | null> {
try {
const output = await this.exec(`tmux show-options -sv ${shellQuote(option)}`);
const v = output.trim();
return v.length > 0 ? v : null;
} catch {
return null;
}
}
/** Get a session-scoped user option value. Returns null if not set or error. */
async getSessionOption(sessionName: string, key: string): Promise<string | null> {
try {
@@ -0,0 +1,31 @@
# OpenRig 0.4.6 spec-language example — the BRANCHED REMEDIATION shape.
# `failed` branches to a remediation step (branch keys = the recorded
# exit enum ONLY); the remediate → verify → remediate loop is a
# branch-created cycle, legitimate ONLY under the declared max_hops
# (remove loop_guards and validation fails naming the cycle + the fix).
workflow:
id: branched-remediation
version: 1
objective: Build with a bounded failed-path remediation loop.
loop_guards:
max_hops: 8
roles:
builder:
preferred_targets: [flow-builder@acme-flow]
fixer:
preferred_targets: [flow-fixer@acme-flow]
prover:
preferred_targets: [flow-prover@acme-flow]
steps:
- id: build
actor_role: builder
next_hop:
on: { failed: remediate }
- id: verify
actor_role: prover
next_hop:
on: { failed: remediate }
- id: remediate
actor_role: fixer
next_hop:
suggested_roles: [prover]
@@ -0,0 +1,152 @@
# OpenRig 0.4.6 canonical single-rig RSI factory MVP (OPR.0.4.6.FAC2).
#
# ONE rig + ONE workflow = the recursive-self-improvement factory MVP. The
# in-band INNER LOOP is a bounded, engine-routed SDLC:
# plan → implement → qa_check → review → release
# under the enforceable max_hops guard (WF-1 sanction); a failing check or
# review routes back to implement for bounded remediation; exceptions route via
# the WF-5 dial (orchestrator-first); the release leg PREPARES and holds the ship
# decision at a human gate (publish is a human act). Runs on the shipped engine
# with the v0 hardcode seam (target.rig baked + preferred_targets) — no binding
# layer needed.
#
# The RSI recursion is the DECOUPLED dogfood edge: the dogfood seat runs
# out-of-band and continuously against the SHIPPED product (not a second QA on
# the build artifact) and feeds its findings into the next plan — ungated, with
# no loop-stop in the MVP. The exact continuous out-of-band runtime mechanism is
# refined in a later release; here the inner loop is in-band and dogfood is the
# out-of-band feed, referenced by the `dogfood` role below.
workflow:
id: factory-rsi
version: 1
objective: >
Plan, build, check, review, and release one slice as a bounded,
deterministic, engine-routed inner loop on a single rig. Dogfood runs
out-of-band against the shipped product and feeds its findings into the next
plan — the RSI edge, with no human required.
target:
rig: factory-rsi
entry:
role: planner
coordination_terminal_turn_rule: hot_potato
# Exceptions route orchestrator-first (the WF-5 v1.3 dial); the human knows
# at altitude via the awareness row. The orchestrator role below is the dial
# target — resolved through the same role→preferred_targets mechanism.
exception_routing:
default: orchestrator
orchestrator_role: orchestrator
roles:
planner:
skill_refs: [openrig-user, requirements-writer, writing-plans]
preferred_targets: [plan-planner@factory-rsi]
implementer:
skill_refs: [openrig-user, development-team, test-driven-development]
preferred_targets: [build-implementer@factory-rsi]
qa:
skill_refs: [openrig-user, verification-before-completion]
preferred_targets: [check-qa@factory-rsi]
reviewer:
skill_refs: [openrig-user, review-team, plan-review]
preferred_targets: [review-reviewer@factory-rsi]
release_manager:
skill_refs: [openrig-user, release-manager]
preferred_targets: [release-manager@factory-rsi]
orchestrator:
skill_refs: [openrig-user, orchestration-team]
preferred_targets: [orch-lead@factory-rsi]
# The dogfood role is DECOUPLED from the inner loop: it runs out-of-band
# against the SHIPPED product and feeds findings into the next plan (the RSI
# edge). It is intentionally NOT an inner-loop step here; the continuous
# out-of-band runtime mechanism is refined in a later release.
dogfood:
skill_refs: [openrig-user, dogfood, systematic-debugging]
preferred_targets: [dogfood-tester@factory-rsi]
steps:
- id: plan
actor_role: planner
objective: >
Turn the current input — the product corpus on cycle 1, or the recorded
out-of-band dogfood findings thereafter — into ONE buildable slice spec.
The findings arrive as recorded closure evidence, never chat.
allowed_exits: [handoff, waiting, failed]
next_hop:
mode: require
suggested_roles: [implementer]
- id: implement
actor_role: implementer
objective: Build the slice per the plan and produce proof for the check.
allowed_exits: [handoff, waiting, failed]
next_hop:
mode: require
suggested_roles: [qa]
- id: qa_check
actor_role: qa
objective: >
Check the built artifact. Passing hands to review; a FAILING check (the
artifact's verdict, not the step's dysfunction) routes back to implement
for bounded remediation.
allowed_exits: [handoff, waiting, failed]
next_hop:
mode: require
suggested_roles: [reviewer]
on:
failed: implement
- id: review
actor_role: reviewer
objective: >
Review the artifact (cross-runtime diversity vs the builder). Passing
hands to the release prep; a failing review routes back to implement.
allowed_exits: [handoff, waiting, failed]
next_hop:
mode: require
suggested_roles: [release_manager]
on:
failed: implement
- id: release_prep
actor_role: release_manager
objective: >
Prepare the release notes, docs/website updates, and the release PR, and
record the cycle's release evidence at `proof/PROOF.md` in the workspace
(the `evidence_ref` the sign-off gate points at). This is the
release-manager's ACTUAL work and it runs BEFORE any gate — the prepared
artifacts exist first; publish stays a human act (held at release_signoff).
allowed_exits: [handoff, waiting, failed]
next_hop:
# Explicit step-id route (NOT suggested_roles): release_prep and
# release_signoff share the release_manager actor_role, so a role-based
# hop would be ambiguous (it would match the first release_manager step).
# The prepared artifacts therefore exist BEFORE the human gate below.
on:
handoff: release_signoff
- id: release_signoff
actor_role: release_manager
objective: >
Hold the ship decision at the human gate, against the release artifacts
already prepared in release_prep. Approval lets the human proceed to
publish; nothing is published automatically.
allowed_exits: [done, waiting, failed]
gate:
target: human@kernel
summary: "RSI cycle clean; release artifacts prepared — sign off to publish (publish is a human act)."
# Workspace-relative pointer to the release evidence release_prep records
# (the same convention the shipped gated-release.yaml example uses); it is
# produced in the running workspace per cycle, not a slice file.
evidence_ref: proof/PROOF.md
next_hop:
mode: forbid
invariants:
continuation_required: true
preserve_lineage: true
closure_required: true
allowed_exits: [handoff, waiting, done, failed]
loop_guards:
# ENFORCEABLE (integer >= 1) — sanctions the bounded remediation loops
# (qa_check→implement, review→implement); the WF-1 validator requires it and
# the Argo unbounded-recursion lesson demands it. A trip becomes a WF-5
# exception; a resume grants one more bounded window (WF-5 FR-4 livelock rail).
max_hops: 20
spawn_budget: 0
closure:
success: RSI cycle clean; release candidate prepared and held at the human ship gate.
degraded: Cycle stopped with a named blocker or a bounded max_hops trip awaiting resume.
failed: Step failed as an exception; preserve the finding and route via the dial.
@@ -0,0 +1,26 @@
# OpenRig 0.4.6 spec-language example — the GATED STAGE shape.
# A human sign-off gate on the final step (compiles to the shipped
# human-route machinery: tier human-gate + summary + evidence_ref,
# resolved by `rig queue resolve`), plus per-step harness pins.
workflow:
id: gated-release
version: 1
objective: Build, prove, then hold the ship decision at a human gate.
roles:
builder:
preferred_targets: [flow-builder@acme-flow]
prover:
preferred_targets: [flow-prover@acme-flow, flow-prover2@acme-flow]
steps:
- id: build
actor_role: builder
harness: claude-code
- id: prove
actor_role: prover
harness: codex
- id: ship-signoff
actor_role: prover
gate:
target: human@kernel
summary: "Release candidate proven; sign off to ship"
evidence_ref: proof/PROOF.md
@@ -0,0 +1,21 @@
# OpenRig 0.4.6 spec-language example — the LINEAR PIPELINE shape.
# Zero WF-2 features: declaration-order routing, byte-identical to the
# WF-1 engine's behavior (the zero-regression reference shape).
workflow:
id: linear-build
version: 1
objective: Move one packet through plan, build, prove in declaration order.
roles:
planner:
preferred_targets: [flow-planner@acme-flow]
builder:
preferred_targets: [flow-builder@acme-flow]
prover:
preferred_targets: [flow-prover@acme-flow]
steps:
- id: plan
actor_role: planner
- id: build
actor_role: builder
- id: prove
actor_role: prover
@@ -0,0 +1,25 @@
import type { Migration } from "../migrate.js";
/**
* OPR.0.4.6.WF1 FR-5 — workflow_instances.version column.
*
* The optimistic-concurrency guard for instance advancement: every
* project() carries the version it read into the transaction, and
* updateFrontier's UPDATE is qualified `WHERE version = ?` with a
* `version = version + 1` bump. Zero rows changed = a concurrent
* writer advanced the instance first → structured
* `instance_version_conflict` (the whole scribe transaction rolls
* back). SQLite's single-writer serialization orders the writes;
* the version guard is what stops the SECOND writer from operating on
* the stale instance state it read before the first committed.
*
* Additive, NOT NULL with DEFAULT 0 so existing in-flight instances
* adopt version 0 and their next projection bumps normally — no
* backfill required.
*/
export const workflowInstanceVersionSchema: Migration = {
name: "049_workflow_instance_version.sql",
sql: `
ALTER TABLE workflow_instances ADD COLUMN version INTEGER NOT NULL DEFAULT 0;
`,
};
@@ -0,0 +1,27 @@
import type { Migration } from "../migrate.js";
/**
* OPR.0.4.6.WF1 FR-6/FR-9 (source discovery during build) —
* workflow_specs.spec_json column.
*
* At ac75777a the cache stored only roles_json + steps_json (+ scalar
* columns), so `getByNameVersion` — the path the PROJECTOR resolves
* specs through at every project() — rebuilt a spec with
* `loop_guards`, `invariants`, `closure`, and `entry` silently DROPPED
* (rowToWorkflowSpec's column-only branch). readThrough compensated
* for VALIDATION by overriding with the freshly file-parsed spec, but
* projection-time enforcement (FR-6 max_hops) could never see the
* declared guard. This column stores the FULL parsed spec at cache
* time so the runtime enforces exactly what the author declared, even
* after the source file moves or disappears (the cache's own survival
* contract).
*
* NULLABLE: legacy rows fall back to the column-only reconstruction
* (honest degradation) and self-heal on their next readThrough.
*/
export const workflowSpecJsonSchema: Migration = {
name: "050_workflow_spec_json.sql",
sql: `
ALTER TABLE workflow_specs ADD COLUMN spec_json TEXT;
`,
};
@@ -0,0 +1,28 @@
import type { Migration } from "../migrate.js";
/**
* OPR.0.4.6.WF5 FR-4 — workflow_instances resume columns.
*
* `resume_count`: the recorded redrive fact (the AWS Step Functions
* `redriveCount` shape — a first-class recorded field, never inferred
* from the trail).
*
* `hops_baseline`: the LIVELOCK RAIL (arch return R1). max_hops bounds
* each DRIVE, not the instance lifetime: the projection guard compares
* hops accrued since the LATEST instantiate-OR-resume, so a human
* resume sanctions exactly one more bounded window instead of
* re-tripping instantly on the first post-resume projection. WF-1's
* guard comment left this exact seam open ("WF-5 FR-4's resume later
* amends the baseline").
*
* Additive, NOT NULL DEFAULT 0: existing instances keep the v1
* baseline (0) and zero resumes — no backfill, byte-identical behavior
* until the first resume.
*/
export const workflowResumeSchema: Migration = {
name: "051_workflow_resume.sql",
sql: `
ALTER TABLE workflow_instances ADD COLUMN resume_count INTEGER NOT NULL DEFAULT 0;
ALTER TABLE workflow_instances ADD COLUMN hops_baseline INTEGER NOT NULL DEFAULT 0;
`,
};
@@ -0,0 +1,31 @@
import type { Migration } from "../migrate.js";
/**
* OPR.0.4.6.FAC1 — workflow_instances.bound_rig column.
*
* The binding layer's A1 substrate: a workflow instance is pointed at a
* rig at INSTANTIATION (`--rig` / route `targetRig`, defaulting to the
* spec's `target.rig`), and the binding persists on the instance so
* every owner-resolution site (projection, gate compile, entry, resume,
* exception routing) resolves roles against THAT rig's inventory.
*
* Persists the rig NAME, not the id (ARCH Q4): the name is the durable
* operator-space coordinate — consistent with the seat-name doctrine; a
* torn-down-and-recreated rig keeps the binding. Name→id is resolved
* FRESH at each resolution site (an exists-check-class read); a
* vanished rig fails loud there, never silently.
*
* NULLABLE, no default: NULL = unbound = byte-identical pre-FAC-1
* behavior for every pre-existing row and every instantiate that names
* no rig. No backfill.
*
* NUMBERING (binding, arch supersession 2026-07-07): FAC-1 fixed-claims
* 052; FS-1 renumbers its in-flight 051/052-adjacent migrations to 053+
* at its own rebase regardless of merge order.
*/
export const workflowInstanceBoundRigSchema: Migration = {
name: "052_workflow_instance_bound_rig.sql",
sql: `
ALTER TABLE workflow_instances ADD COLUMN bound_rig TEXT;
`,
};
@@ -0,0 +1,54 @@
import type { Migration } from "../migrate.js";
/**
* OPR.0.4.6 FS-1 (daemon read-path hardening, W1.1) — sessions per-node recency index.
*
* ROOT CAUSE (arch grounding ARCH-DESIGN-GROUNDING-fs1-2026-07-06, F1;
* re-verified firsthand at 384e60f1): the `sessions` table
* (002_bindings_sessions.ts) was created with ZERO indexes on `node_id` — its
* only index is the `id` PRIMARY-KEY autoindex. The "latest session per node"
* subquery `(SELECT s2.id FROM sessions s2 WHERE s2.node_id = ? ORDER BY … LIMIT 1)`
* is therefore a FULL SCAN of `sessions`, and it appears PER NODE at THREE sites
* on the hot `/api/ps` path — multiplied by the rig-level N+1 (getEntries loops
* rigs), one poll scanned `sessions` many times over (the 0.6-0.9s wedge at the
* host shape). The three sites, and their ORDER BY (why the index is 3-column):
* - ps-projection.ts:157 ORDER BY created_at DESC, id DESC ← the load-bearing shape
* - node-inventory.ts:442 ORDER BY id DESC
* - ps-projection.ts:256 ORDER BY id DESC
*
* INDEX SHAPE = (node_id, created_at DESC, id DESC) — arch RULING (B), decided
* by FIXTURE-MEASURED evidence, NOT preference. Measured before/after on the real
* incident fixture (24 rigs / 175 nodes / 445 sessions) — evidence:
* missions/release-0.4.6/slices/01-fs1-daemon-read-path-hardening/proof/
* perf-hardening-evidence/W1.1-sessions-index-measure-2026-07-06-dev44-driver2.md
* - id-DESC sites (:442, :256): full-scan → covering seek, ~63x (0.677s→0.011s / 52.5k evals).
* - created_at site (:157): under a 2-col (node_id, id DESC) index it SEARCHes
* node_id but keeps a TEMP B-TREE sort (~18.6x); under THIS 3-col index it is
* `SEARCH … USING COVERING INDEX` with NO sort (~95x, 0.856s→0.009s) — the
* measured ~5x that decided the shape.
* - Bound proven at 10x scale (4450 sessions): full-scan 8.4s (grows with data)
* vs seek 0.024s (~flat) = ~350x. CREATE INDEX = 0.013s, safe on populated data.
* The 3-column index leads with `node_id` (the equality seek), so the two id-DESC
* sites still get the node_id seek (full-scan eliminated); their residual is a
* per-node sort over ~2-3 rows (445 sessions / 175 nodes) — arch-accepted as noise.
* ONE index by ruling: this migration WIDENS to the 3-column shape and never grows
* a sibling (a second index = write amplification + space for no measured need).
*
* CREATE INDEX IF NOT EXISTS is idempotent + safe on a live/populated table
* (SQLite builds it in one pass; no schema/data change).
*
* Sibling audit (arch D1.1): the snapshots(rig_id, created_at) subqueries in the
* rigs projection are a separate index-audit item in W1, not this migration.
*
* Number 053: main shipped 051_workflow_resume and FAC-1 reserves
* 052_workflow_instance_bound_rig, so FS-1's index migration renumbered
* 051→053 (orch deconflict 2026-07-06; re-verify the free slot against the
* final merged tree at rebase/proof time). The index NAME + shape are unchanged.
*/
export const sessionsNodeIdIndexSchema: Migration = {
name: "053_sessions_node_id_index.sql",
sql: `
CREATE INDEX IF NOT EXISTS idx_sessions_node_created_id
ON sessions(node_id, created_at DESC, id DESC);
`,
};
@@ -0,0 +1,50 @@
import type { Migration } from "../migrate.js";
/**
* OPR.0.4.6 FS-1 (daemon read-path hardening, W2) — queue_transitions ARCHIVE
* sibling table (arch D3: ARCHIVE-never-plain-delete for the audit surface).
*
* `queue_transitions` is an AUDIT surface with product meaning (chain of record;
* `rig queue resolve` records decision text here) — INSERT-ONLY today, no prune
* path exists, so it grows unbounded (13691 rows on the incident fixture) and
* bloats backups + any transitions-reading surface. Arch D3 ruling: NEVER
* plain-delete; MOVE terminal-and-aged transitions into this sibling table, in
* the SAME transaction, so the audit trail is preserved (queryable in place),
* not lost.
*
* Schema MIRRORS `queue_transitions` (025) column-for-column so a move is a
* single `INSERT INTO queue_transitions_archive (...) SELECT ... FROM
* queue_transitions WHERE ...` + `DELETE`, both inside one txn. `transition_id`
* is a plain PRIMARY KEY (NOT AUTOINCREMENT) so archived rows KEEP their original
* id — audit references stay resolvable across the move. `archived_at` records
* when the row was archived (provenance; the source `ts` is preserved untouched).
*
* THE ACTIVE-FRONTIER INVARIANT (enforced by the W2 runner, not this migration):
* only transitions of TERMINAL qitems whose LAST transition is older than the
* retention window (default 30d) are ever moved; a non-terminal qitem's full
* transition history is never touched, at any age. The move runner + retention
* knobs land in the W2 maintenance-tick change; this migration is the sibling
* table + its read index only (additive; no data change to `queue_transitions`).
*
* Number 054: renumbered 052→054 with the FS-1 index (053) after the
* 051_workflow_resume ship + FAC-1's 052 reservation (orch deconflict
* 2026-07-06; re-verify against the final merged tree at rebase/proof time).
* The archive table + index names are unchanged.
*/
export const queueTransitionsArchiveSchema: Migration = {
name: "054_queue_transitions_archive.sql",
sql: `
CREATE TABLE IF NOT EXISTS queue_transitions_archive (
transition_id INTEGER PRIMARY KEY,
qitem_id TEXT NOT NULL,
ts TEXT NOT NULL,
state TEXT NOT NULL,
transition_note TEXT,
actor_session TEXT NOT NULL,
closure_reason TEXT,
closure_target TEXT,
archived_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_queue_transitions_archive_qitem ON queue_transitions_archive(qitem_id, ts);
`,
};
+7 -1
View File
@@ -70,6 +70,10 @@ interface ClaimServiceDeps {
resumeTokenCapturer?: {
captureCodexThreadId(sessionName: string): Promise<string | undefined>;
};
/** OPR.0.4.6.PI1 FR-6 — pi-runner sidecar reader for Pi resume-token capture. */
piRunnerStateStore?: {
readSessionFile(sessionName: string): { ok: true; sessionFile: string } | { ok: false; reason: string };
};
}
interface BindOptions {
@@ -102,6 +106,7 @@ export class ClaimService {
private claudeContextProvisioner: ClaimServiceDeps["claudeContextProvisioner"] | null;
private contextUsageStore: ClaimServiceDeps["contextUsageStore"] | null;
private resumeTokenCapturer: ClaimServiceDeps["resumeTokenCapturer"] | null;
private piRunnerStateStore: ClaimServiceDeps["piRunnerStateStore"] | null;
constructor(deps: ClaimServiceDeps) {
if (deps.db !== deps.rigRepo.db) throw new Error("ClaimService: rigRepo must share the same db handle");
@@ -118,6 +123,7 @@ export class ClaimService {
this.claudeContextProvisioner = deps.claudeContextProvisioner ?? null;
this.contextUsageStore = deps.contextUsageStore ?? null;
this.resumeTokenCapturer = deps.resumeTokenCapturer ?? null;
this.piRunnerStateStore = deps.piRunnerStateStore ?? null;
}
/** Best-effort: set OpenRig-owned tmux metadata on an adopted session. */
@@ -184,7 +190,7 @@ export class ClaimService {
// FR-3's adoption provenance/audit semantics are unchanged.
const derived = await deriveResumeToken(
{ runtime: input.runtime, sessionName: input.sessionName },
{ contextUsageStore: this.contextUsageStore, resumeTokenCapturer: this.resumeTokenCapturer },
{ contextUsageStore: this.contextUsageStore, resumeTokenCapturer: this.resumeTokenCapturer, piRunnerStateStore: this.piRunnerStateStore },
);
if (derived.outcome === "exempt" || derived.outcome === "noop") return;
const runtime = input.runtime as string; // non-null past exempt
@@ -1,5 +1,5 @@
/** Runtime hint from fingerprinting */
export type RuntimeHint = "claude-code" | "codex" | "terminal" | "unknown";
export type RuntimeHint = "claude-code" | "codex" | "pi" | "terminal" | "unknown";
/** Confidence level of the runtime detection */
export type Confidence = "highest" | "high" | "medium" | "low";
@@ -46,6 +46,11 @@ export type HostResolution =
| { ok: true; host: HostEntry }
| { ok: false; error: string };
// OPR.0.4.6.MH1 FR-7 — reserved host ids, MIRRORED verbatim from the
// CLI twin (packages/cli/src/host-registry.ts RESERVED_HOST_IDS; the
// parity test pins both). See the CLI twin for the collision rationale.
export const RESERVED_HOST_IDS = new Set(["kernel", "host", "local"]);
const KNOWN_TRANSPORTS = new Set(["ssh", "http"]);
export function defaultHostRegistryPath(): string {
@@ -102,6 +107,21 @@ export function validateHostRegistry(parsed: unknown, sourcePath: string): HostR
if (seenIds.has(id)) {
return { ok: false, error: `${prefix}.id: duplicate host id '${id}' (each host id must be unique within the registry)` };
}
if (RESERVED_HOST_IDS.has(id)) {
return {
ok: false,
error: `${prefix}.id: '${id}' is a reserved host id (reserved set: ${[...RESERVED_HOST_IDS].sort().join(", ")}). 'kernel' and 'host' collide with human-seat session classification (@kernel/@host), and 'local' is the local host itself — pick a different id.`,
};
}
// OPR.0.4.6.MH1 rev1-r2 B1 — host ids name FILES (the pair verb's
// bearer_file path embeds the id) and render in tables: path-bearing
// ids are rejected at the registry door, same home as reserved ids.
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(id)) {
return {
ok: false,
error: `${prefix}.id: '${id}' is not a valid host id — allowed: letters, digits, dot, underscore, dash (starting with a letter or digit). Host ids name credential files; path characters are not allowed.`,
};
}
seenIds.add(id);
const transport = entry["transport"];
if (typeof transport !== "string" || !KNOWN_TRANSPORTS.has(transport)) {
@@ -0,0 +1,65 @@
// OPR.0.4.6.MH1 FR-5/FR-6 — the daemon-side hosts-registry WRITE twin
// (arch B1 ruling, pins P1–P4).
//
// The browser surface cannot reach the CLI's fs code, so the dashboard
// add/pair path writes through THIS module — a deliberate VERBATIM MIRROR
// of packages/cli/src/host-registry.ts addHostEntry, byte-parity-pinned
// by packages/daemon/test/hosts-add-pair-routes.test.ts (same entries in
// → identical yaml bytes out; P3) and sharing the reader twin's
// validateHostRegistry (identical validation: exactly-one-bearer,
// reserved ids, duplicate ids — P3). The reader module stays READ-ONLY
// FOREVER; writes flow ONLY through the single parity-pinned write
// contract (CLI addHostEntry + this twin), reachable daemon-side solely
// via the narrow named add/pair routes (P1 — no generic registry-write
// route exists).
//
// Concurrency (P4): atomic tmp+rename, whole-file LAST-WRITE-WINS on a
// concurrent add — the ceiling is one operator-scale registry file; NO
// locking machinery by design. Two simultaneous adds may drop one entry;
// re-running the add re-converges (add-time validation = load-time
// validation, verbatim).
import { existsSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { stringify as stringifyYaml } from "yaml";
import {
defaultHostRegistryPath,
loadHostRegistry,
validateHostRegistry,
type HostEntry,
} from "./hosts-registry-reader.js";
export type AddHostResult =
| { ok: true; path: string; entry: HostEntry }
| { ok: false; error: string };
export function addHostEntry(rawEntry: Record<string, unknown>, path: string = defaultHostRegistryPath()): AddHostResult {
// Load what exists; a MISSING file is a valid starting point for `add`
// (the verb exists so operators never hand-create the YAML), but a present-
// but-invalid file is a loud error — never silently clobber operator state.
let existing: HostEntry[] = [];
if (existsSync(path)) {
const loaded = loadHostRegistry(path);
if (!loaded.ok) {
return { ok: false, error: `refusing to modify an invalid registry: ${loaded.error}` };
}
existing = loaded.registry.hosts;
}
const candidate = { hosts: [...existing, rawEntry] };
const validated = validateHostRegistry(candidate, path);
if (!validated.ok) {
return { ok: false, error: validated.error };
}
const entry = validated.registry.hosts[validated.registry.hosts.length - 1]!;
try {
mkdirSync(dirname(path), { recursive: true });
const tmp = join(dirname(path), `.hosts.yaml.tmp-${process.pid}`);
writeFileSync(tmp, stringifyYaml({ hosts: validated.registry.hosts }), { mode: 0o600 });
renameSync(tmp, path);
} catch (err) {
return { ok: false, error: `failed to write host registry at ${path}: ${(err as Error).message}` };
}
return { ok: true, path, entry };
}
@@ -0,0 +1,160 @@
// OPR.0.4.6.MH2 FR-2 + FR-7 — the general single-host READ-THROUGH.
//
// The read twin of the shipped forward-then-strip write pattern
// (routes/mission-control.ts remote-forward leg): the browser stays
// same-origin against the LOCAL daemon; a `?host=<id>` query param is the
// out-of-band host envelope (MH-1 BR-1 rail — host never rides in a path or
// session identity). The daemon edge consumes the envelope: resolve the
// registry, forward the SAME path (envelope stripped) to the origin daemon
// over the shared bearer transport, and return the origin's response
// VERBATIM — status + content-type + body untouched (arch P3). Origin
// shapes stay the law: no AggregatedPayload wrapping on single-host reads.
//
// READ-ONLY IS ENFORCED HERE, AT THE EDGE (FR-7, arch R2): a non-GET
// request carrying a remote host envelope, or a GET outside the named
// allowlist below, is REFUSED with a structured error naming the MH-3
// boundary and is NEVER forwarded. The UI hiding mutation affordances is
// necessary but not the enforcement.
//
// local / absent host param short-circuits to the existing handler before
// any of this logic runs — the local path is unchanged by construction
// (FR-2 zero-regression negative).
import type { Context, Next } from "hono";
import { LOCAL_HOST_ID } from "./fanout-contract.js";
import { loadHostRegistry, resolveHost } from "./hosts-registry-reader.js";
import { remoteRawRequest } from "./remote-daemon-http.js";
// The 5s single-host READ deadline class (matches the shipped aggregate
// read class in attention-aggregator.ts; deadline is a REQUIRED arg on the
// transport by arch ruling — named here, passed explicitly at the call).
export const READ_THROUGH_TIMEOUT_MS = 5_000;
// THE NAMED CLOSED ALLOWLIST (arch P1). One exported constant; additions
// are deliberate cross-review extensions, never drive-by edits — flag arch.
// Grammar: `:seg` matches exactly one non-empty path segment; a trailing
// `/*` matches one-or-more tail segments. v1 = the MVP read screens
// (topology / project / library / dashboard-ps) exactly.
//
// DELIBERATE EXCLUSIONS (recorded, not oversights): /api/queue/* (queue is
// the MH-3 lane — MH-1's queue-negative boundary, read included for now);
// /api/files/* (local FS discovery; the twin derives remote missions from
// the host-keyed slice list instead); /api/slices/:name/proof-asset/*
// (binary — the raw transport is text-only by design);
// /api/specs/library/active-lens (a local operator preference with write
// verbs — its literal segment cannot match `:id/review`'s shape).
// NUANCE (pinned by test): the slices REFRESH write is excluded by the
// METHOD tooth, not by path shape — `:name` matches the literal "refresh",
// so a GET there forwards and the origin resolves it as a slice named
// "refresh" (its own 404, verbatim); the POST can never cross the edge.
export const READ_THROUGH_ALLOWLIST = [
"/api/rigs/summary",
"/api/rigs/:rigId/graph",
"/api/rigs/:rigId/nodes",
// OPR.0.4.6.MH2 rev1-r2 B2 + arch ruling (qitem-…c5402960): seat detail
// is the LEAF of the FR-2 hierarchy — same read class as its /nodes
// sibling. STRICT SEGMENT-SHAPE match only (the arch tooth): the deeper
// ACTION routes under this prefix (…/:logicalId/focus, …/open-cmux)
// must stay REFUSED — pinned by named negatives.
"/api/rigs/:rigId/nodes/:logicalId",
"/api/ps",
"/api/slices",
"/api/slices/:name",
"/api/slices/:name/doc/*",
"/api/missions/:missionId",
"/api/specs/library",
"/api/specs/library/:id/review",
] as const;
export function isReadThroughPath(path: string): boolean {
const parts = path.split("/").filter((s) => s !== "");
return READ_THROUGH_ALLOWLIST.some((pattern) => {
const pat = pattern.split("/").filter((s) => s !== "");
const tailWild = pat[pat.length - 1] === "*";
const fixed = tailWild ? pat.slice(0, -1) : pat;
if (tailWild ? parts.length <= fixed.length : parts.length !== fixed.length) return false;
return fixed.every((seg, i) => (seg.startsWith(":") ? parts[i] !== "" : seg === parts[i]));
});
}
const MH3_BOUNDARY_MESSAGE =
"acting on a remote host is the MH-3 routing lane; the MH-2 read-through forwards allowlisted GET reads only";
/** The param-intercept middleware (arch E2 ruling). Mounted on /api/* ahead
* of the route table; consumes the `?host=` envelope for remote reads and
* passes everything else through untouched. */
export function hostReadThrough() {
return async (c: Context, next: Next) => {
const hostParam = c.req.query("host");
if (hostParam === undefined || hostParam === "" || hostParam === LOCAL_HOST_ID) {
return next();
}
const hostId = hostParam;
const path = c.req.path;
if (c.req.method !== "GET") {
// FR-7 tooth 1: a mutating request never crosses the edge.
return c.json(
{ error: "cross_host_write_refused", boundary: "MH-3", hostId, method: c.req.method, path, message: MH3_BOUNDARY_MESSAGE },
405,
);
}
if (!isReadThroughPath(path)) {
// FR-7 tooth 2: reads outside the named closed set never cross either.
return c.json(
{ error: "read_through_path_not_allowed", boundary: "MH-3", hostId, path, message: MH3_BOUNDARY_MESSAGE },
403,
);
}
// Arch P2 leg 1: registry-validated BEFORE any dial — an unknown id is a
// structured error, never a blind network attempt. Same injected-loader
// seam as the write twin (tests swap it; production reads the registry).
const registryLoader =
(c.get("hostRegistryLoader" as never) as (() => ReturnType<typeof loadHostRegistry>) | undefined) ?? loadHostRegistry;
const fetchImpl = c.get("remoteFetchImpl" as never) as typeof fetch | undefined;
const fail = (detail: string, failureClass: string, remoteStatus?: number) =>
c.json({ error: "remote_read_failed", hostId, failureClass, ...(remoteStatus !== undefined ? { remoteStatus } : {}), detail }, 502);
const reg = registryLoader();
if (!reg.ok) return fail(reg.error, "registry");
const resolved = resolveHost(reg.registry, hostId);
if (!resolved.ok) return fail(resolved.error, "unknown-host");
if (resolved.host.transport !== "http") {
return fail(`host '${hostId}' is SSH-declared; the read-through requires an http-transport registry entry (url + bearer)`, "unsupported-transport");
}
// Arch P2 leg 2: STRIP IS TOTAL — the forwarded request carries no host
// param in any form, so the origin never sees the envelope and a
// forwarded request structurally cannot re-forward. Every OTHER query
// param rides through untouched (e.g. the slices filter).
const url = new URL(c.req.url);
url.searchParams.delete("host");
const forwardPath = `${path}${url.searchParams.size > 0 ? `?${url.searchParams.toString()}` : ""}`;
const res = await remoteRawRequest(resolved.host, forwardPath, {
timeoutMs: READ_THROUGH_TIMEOUT_MS,
fetchImpl,
});
if (res.ok) {
// Arch P3: the origin ANSWERED — its status/content-type/body ARE the
// answer, passed through untouched (its own 404/500 included). Edge
// taxonomy errors below are only for the forward itself failing.
return c.body(res.bodyText, res.status as never, { "Content-Type": res.contentType });
}
switch (res.kind) {
case "bearer":
return fail(res.detail, "auth-failed");
case "timeout":
return fail(
res.phase === "body"
? `remote read timed out: response headers arrived (HTTP ${res.status}) but the body never completed`
: `remote read timed out after ${READ_THROUGH_TIMEOUT_MS}ms`,
"unreachable",
res.status,
);
case "network":
return fail(res.detail, "unreachable");
}
};
}
@@ -52,6 +52,23 @@ export interface RemoteJsonFailure {
export type RemoteJsonResult = { ok: true; status: number; payload: unknown } | RemoteJsonFailure;
export interface RemoteRawOptions extends RemoteJsonDeps {
/** REQUIRED — the caller names its deadline class explicitly. */
timeoutMs: number;
}
// OPR.0.4.6.MH2 FR-2/FR-7 — the read-through's transport leg. Verbatim
// passthrough means STATUS + CONTENT-TYPE + BODY (arch P3): the origin's own
// 404/500 IS the answer, so unlike remoteJsonRequest this variant returns the
// full body TEXT for EVERY origin status and never collapses error bodies to
// a detail string. Failure kinds stay transport-only (bearer/timeout/network)
// — an origin that ANSWERED is always ok:true here. Text-only by design: the
// v1 read allowlist carries JSON endpoints; binary surfaces (proof-asset)
// are deliberately not allowlisted.
export type RemoteRawResult =
| { ok: true; status: number; contentType: string; bodyText: string }
| { ok: false; kind: Exclude<RemoteJsonFailureKind, "http">; phase?: "request" | "body"; status?: number; detail: string };
/** Mirrors the CLI's resolveRemoteBearer (host-registry.ts): exactly-one of
* bearer_env / bearer_file, resolved at call time. */
function resolveBearer(host: HttpHostEntry, deps: RemoteJsonDeps): { ok: true; token: string } | { ok: false; detail: string } {
@@ -129,3 +146,49 @@ export async function remoteJsonRequest(host: HttpHostEntry, path: string, opts:
: "";
return { ok: false, kind: "http", status: res.status, detail: remoteError };
}
/** One bounded daemon→daemon GET, body passed through as text for ANY origin
* status (arch P3 verbatim rule). Same bearer resolution + single-deadline +
* explicit body race discipline as remoteJsonRequest; never hangs, never
* throws. */
export async function remoteRawRequest(host: HttpHostEntry, path: string, opts: RemoteRawOptions): Promise<RemoteRawResult> {
const bearer = resolveBearer(host, opts);
if (!bearer.ok) return { ok: false, kind: "bearer", detail: bearer.detail };
const fetchImpl = opts.fetchImpl ?? fetch;
const url = `${host.url.replace(/\/$/, "")}${path}`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), opts.timeoutMs);
let res: Response;
try {
res = await fetchImpl(url, {
method: "GET",
headers: { Authorization: `Bearer ${bearer.token}` },
signal: controller.signal,
});
} catch (err) {
clearTimeout(timer);
if (controller.signal.aborted) return { ok: false, kind: "timeout", phase: "request", detail: "" };
return { ok: false, kind: "network", detail: (err as Error).message };
}
const abortRace = new Promise<never>((_resolve, reject) => {
const onAbort = () => reject(new Error("body read aborted"));
if (controller.signal.aborted) onAbort();
else controller.signal.addEventListener("abort", onAbort, { once: true });
});
let bodyText = "";
let bodyTimedOut = false;
try {
bodyText = await Promise.race([res.text(), abortRace]);
} catch {
if (controller.signal.aborted) bodyTimedOut = true;
} finally {
clearTimeout(timer);
}
if (bodyTimedOut) return { ok: false, kind: "timeout", phase: "body", status: res.status, detail: "" };
return { ok: true, status: res.status, contentType: res.headers.get("content-type") ?? "application/json", bodyText };
}
+226 -98
View File
@@ -1,15 +1,15 @@
import type Database from "better-sqlite3";
import type { NodeInventoryEntry, NodeDetailEntry, NodeDetailPeer, NodeDetailEdge, NodeDetailCompactSpec, NodeRestoreOutcome, NodeLifecycleState, Binding, RestoreResult, NodeRecoveryGuidance, Snapshot, WorkspaceSpec, SeatIdentityVerdict, SeatIdentityVerdictKind } from "./types.js";
import type { NodeInventoryEntry, NodeDetailEntry, NodeDetailPeer, NodeDetailEdge, NodeDetailCompactSpec, NodeRestoreOutcome, NodeOriented, NodeLifecycleState, Binding, RestoreResult, NodeRecoveryGuidance, Snapshot, WorkspaceSpec, SeatIdentityVerdict, SeatIdentityVerdictKind } from "./types.js";
import { identityVerdictDownranksRunning } from "./types.js";
import { SeatIdentityStore } from "./seat-identity-store.js";
import { deriveOriented } from "./startup-proof.js";
import { buildOrientedMap } from "./startup-proof.js";
import type { RuntimeAdapter } from "./runtime-adapter.js";
import type { ContextUsageStore } from "./context-usage-store.js";
import type { AgentActivityStore } from "./agent-activity-store.js";
import type { SeatActivityService } from "./seat-activity-service.js";
import type { TmuxAdapter } from "../adapters/tmux.js";
import { probeSessionActivity } from "./session-transport.js";
import { findLatestUsableSnapshot } from "./rig-repository.js";
import { findLatestUsableSnapshot, findLatestUsableSnapshotsForAllRigs } from "./rig-repository.js";
import { resolveNodeWorkspace } from "./workspace/workspace-resolver.js";
import { deriveCanonicalSessionName } from "./session-name.js";
import { buildNativeResumeCommand, buildCodexResumeCore } from "./native-resume-probe.js";
@@ -23,6 +23,7 @@ interface InventoryRow {
logical_id: string;
pod_id: string | null;
pod_namespace: string | null;
role: string | null;
runtime: string | null;
model: string | null;
codex_config_profile: string | null;
@@ -250,33 +251,46 @@ function deriveContinuityOutcome(
return restoreOutcome;
}
function deriveRestoreOutcome(db: Database.Database, rigId: string, nodeId: string): NodeRestoreOutcome {
// OPR.0.3.4.11 + 0.4.0.16: per-node-latest across restore.completed,
// restore.subset_completed, AND restore.outcome_reconciled.
// restore.outcome_reconciled has a different shape: top-level nodeId/to,
// not result.nodes[]. A newer reconcile overrides an older failure; an
// older reconcile must not override a newer failure.
const rows = db.prepare(
"SELECT type, payload, seq FROM events WHERE rig_id = ? AND type IN ('restore.completed', 'restore.subset_completed', 'restore.outcome_reconciled') ORDER BY seq DESC"
).all(rigId) as { type: string; payload: string; seq: number }[];
// FS-1 W1.3 S1 — hoist restore-outcome derivation to ONCE-PER-RIG.
// Prior shape: deriveRestoreOutcome(db, rigId, nodeId) fetched + JSON-parsed the
// rig's ENTIRE restore-event set once PER NODE inside buildInventoryEntry (K
// nodes x E events per rig per poll = the dominant W3 residual). This builds a
// nodeId->outcome map in ONE seq-DESC pass and buildInventoryEntry does an O(1)
// lookup.
// OPR.0.3.4.11 + 0.4.0.16: per-node-latest across restore.completed,
// restore.subset_completed, AND restore.outcome_reconciled. reconciled has a
// different shape (top-level nodeId/to, not result.nodes[]).
// BYTE-IDENTICAL BY CONSTRUCTION: the prior per-node reader returned the FIRST
// event in seq-DESC order that referenced the node. This single seq-DESC pass
// sets a node's outcome ONLY IF ABSENT — so the first (highest-seq) event
// referencing a node wins, reproducing exactly that (incl.
// newer-reconcile-overrides-older-failure). rigId given -> WHERE rig_id=? (the
// prior single-rig filter); rigId omitted -> all rigs in one pass (a nodeId is
// referenced only by its own rig's events, so the per-node value is identical).
// [GUARD AT CODE REVIEW: the only-if-absent set is the one load-bearing
// semantics cell — it is what preserves first/newest-wins.]
function buildRestoreOutcomeMap(db: Database.Database, rigId?: string): Map<string, NodeRestoreOutcome> {
const stmt = db.prepare(
`SELECT type, payload, seq FROM events WHERE type IN ('restore.completed', 'restore.subset_completed', 'restore.outcome_reconciled')${rigId ? " AND rig_id = ?" : ""} ORDER BY seq DESC`
);
const rows = (rigId ? stmt.all(rigId) : stmt.all()) as { type: string; payload: string; seq: number }[];
const map = new Map<string, NodeRestoreOutcome>();
for (const row of rows) {
try {
if (row.type === "restore.outcome_reconciled") {
const event = JSON.parse(row.payload) as { nodeId: string; to: string };
if (event.nodeId !== nodeId) continue;
return mapStatus(event.to);
if (!map.has(event.nodeId)) map.set(event.nodeId, mapStatus(event.to));
continue;
}
const event = JSON.parse(row.payload) as { result: RestoreResult };
const nodeResult = event.result.nodes.find((n) => n.nodeId === nodeId);
if (!nodeResult) continue;
return mapStatus(nodeResult.status);
for (const nodeResult of event.result.nodes) {
if (!map.has(nodeResult.nodeId)) map.set(nodeResult.nodeId, mapStatus(nodeResult.status));
}
} catch {
continue;
}
}
return "n-a";
return map;
}
function mapStatus(status: string): NodeRestoreOutcome {
@@ -396,7 +410,32 @@ export function getNodeInventory(db: Database.Database, rigId: string): NodeInve
// per projection (cheap indexed read, defensive to a missing table). Gates
// the running/active green derivations below.
const identityVerdicts = new SeatIdentityStore(db).getForRig(rigId);
// FS-1 W1.3 S1/S2 — the per-node restore-outcome + oriented reads built ONCE
// for the rig (was a query per node inside the rows.map). rig-scoped restore
// map = the prior WHERE rig_id=? filter; oriented map is node-scoped/global.
const restoreOutcomes = buildRestoreOutcomeMap(db, rigId);
const orienteds = buildOrientedMap(db);
const rows = queryInventoryRows(db, rigId);
return rows.map((row) =>
buildInventoryEntry(db, row, { usableSnapshot, workspaceSpec, identityVerdicts, restoreOutcomes, orienteds }),
);
}
/**
* FS-1 W1.2 — the shared inventory-row query. Extracted verbatim from the prior
* inline `getNodeInventory` SELECT so the single-rig and all-rigs paths run the
* IDENTICAL query.
* - `rigId` given → one rig (`WHERE n.rig_id = ?`, `ORDER BY n.created_at`) —
* byte-identical to the prior per-rig read.
* - `rigId` omitted → ALL rigs in one pass (no WHERE; `ORDER BY n.rig_id,
* n.created_at` so JS grouping preserves each rig's `created_at` order,
* matching the per-rig ordering exactly).
* The latest-session subquery (`s2.node_id = n.id ORDER BY id DESC LIMIT 1`)
* rides the W1.1 index (051, idx_sessions_node_created_id).
*/
function queryInventoryRows(db: Database.Database, rigId?: string): InventoryRow[] {
// Join nodes with newest session (max ULID = max session.id string comparison)
// and the rig name
const hasCodexConfigProfile = db.prepare("PRAGMA table_info(nodes)").all()
@@ -404,7 +443,7 @@ export function getNodeInventory(db: Database.Database, rigId: string): NodeInve
const codexConfigProfileSelect = hasCodexConfigProfile
? "n.codex_config_profile"
: "NULL";
const rows = db.prepare(`
const stmt = db.prepare(`
SELECT
n.id as node_id,
n.rig_id,
@@ -412,6 +451,7 @@ export function getNodeInventory(db: Database.Database, rigId: string): NodeInve
n.logical_id,
n.pod_id,
p.namespace as pod_namespace,
n.role,
n.runtime,
n.model,
${codexConfigProfileSelect} as codex_config_profile,
@@ -441,83 +481,167 @@ export function getNodeInventory(db: Database.Database, rigId: string): NodeInve
LEFT JOIN sessions s ON s.node_id = n.id
AND s.id = (SELECT s2.id FROM sessions s2 WHERE s2.node_id = n.id ORDER BY s2.id DESC LIMIT 1)
LEFT JOIN bindings b ON b.node_id = n.id
WHERE n.rig_id = ?
ORDER BY n.created_at
`).all(rigId) as InventoryRow[];
${rigId ? "WHERE n.rig_id = ?" : ""}
ORDER BY ${rigId ? "n.created_at" : "n.rig_id, n.created_at"}
`);
return (rigId ? stmt.all(rigId) : stmt.all()) as InventoryRow[];
}
return rows.map((row) => {
const restoreOutcome = deriveRestoreOutcome(db, rigId, row.node_id);
// OPR.0.4.3.19 rev1-r2 B1 — a durable verdict is keyed only by node_id, so
// after a rebind/relaunch (same node, NEW session + NEW pane) a stale
// `verified` verdict for the OLD pane would otherwise be served for the new
// pane until the next 5s reconcile tick, suppressing down-rank and rendering
// a fresh squat/orphan false-green. Make applicability LOAD-BEARING at read
// time: a stored verdict applies ONLY when it was computed against the
// current binding (its sessionName === the latest session AND its
// registeredPane === the current binding pane). Otherwise treat it as ABSENT
// (null) — a STALE verdict becomes fail-open (never down-ranks a running
// seat), it does NOT itself down-rank. Only a MATCHING mismatch/pane_missing
// verdict down-ranks.
const identityVerdict = applicableVerdict(identityVerdicts.get(row.node_id) ?? null, row);
const lifecycleState = deriveNodeLifecycleState({
sessionStatus: row.session_status,
restoreOutcome,
nodeId: row.node_id,
usableSnapshot,
identityVerdict: identityVerdict?.verdict ?? null,
});
return {
rigId: row.rig_id,
rigName: row.rig_name,
logicalId: row.logical_id,
podId: row.pod_id,
podNamespace: row.pod_namespace,
canonicalSessionName: row.session_name,
attachmentType: (row.binding_attachment_type as NodeInventoryEntry["attachmentType"]) ?? null,
nodeKind: deriveNodeKind(row.runtime),
runtime: row.runtime,
sessionStatus: row.session_status,
startupStatus: row.startup_status as NodeInventoryEntry["startupStatus"],
restoreOutcome,
oriented: deriveOriented(db, row.node_id),
lifecycleState,
occupantLifecycle: deriveOccupantLifecycle(row, identityVerdict?.verdict ?? null),
continuityOutcome: deriveContinuityOutcome(row, restoreOutcome),
handoverResult: row.handover_result as NodeInventoryEntry["handoverResult"] ?? null,
previousOccupant: row.previous_occupant,
handoverAt: row.handover_at,
tmuxAttachCommand: row.binding_attachment_type === "tmux" && row.session_name ? `tmux attach -t ${row.session_name}` : null,
resumeCommand: computeResumeCommand(row.runtime, row.resume_token, row.codex_config_profile),
// OPR.0.4.0.26: recoveryGuidance is NOT inlined per node in the LIST
// payload. It duplicated ~47KB of templated prose across all nodes and
// no node-list consumer reads it. The full guidance is recomputed on
// the single-node detail path (getNodeDetail / GET
// /api/rigs/:rigId/nodes/:logicalId) — relocation, not loss.
recoveryGuidance: null,
latestError: row.startup_status === "ready" ? null : getLatestError(db, rigId, row.node_id),
// Extended fields
model: row.model,
agentRef: row.agent_ref,
profile: row.profile,
codexConfigProfile: row.codex_config_profile,
resolvedSpecName: row.resolved_spec_name,
resolvedSpecVersion: row.resolved_spec_version,
resolvedSpecHash: row.resolved_spec_hash,
cwd: row.cwd,
restorePolicy: row.restore_policy,
resumeType: row.resume_type,
resumeToken: row.resume_token,
startupCompletedAt: row.startup_completed_at,
// PL-007 Workspace Primitive — per-node workspace summary derived
// from cwd against the rig's typed workspace block. null when the
// rig has no workspace declaration.
workspace: resolveNodeWorkspace({ spec: workspaceSpec, cwd: row.cwd }),
// OPR.0.4.3.19 — the liveness identity verdict (third axis). null when
// never observed; carries evidence on mismatch/missing.
identityVerdict,
heldReason: deriveHeldReason(db, rigId, row.node_id, row.session_status),
};
/** Per-rig setup context an inventory row is built against. FS-1 W1.2: the
* single-rig (`getNodeInventory`) and all-rigs (`getNodeInventoryForAllRigs`)
* paths both build entries through THIS one function, so their output is
* byte-identical by construction — the collapse changes only HOW the context +
* rows are fetched (per-rig vs batched), never how an entry is derived. */
interface InventoryBuildContext {
usableSnapshot: Snapshot | null;
workspaceSpec: WorkspaceSpec | null;
identityVerdicts: Map<string, SeatIdentityVerdict>;
// FS-1 W1.3 S1/S2 — the once-per-rig-batched per-node reads, keyed by node_id.
// Built ONCE per projection (rig-scoped for single-rig, all-rigs for the
// batched path) and looked up O(1) here instead of a query per node.
restoreOutcomes: Map<string, NodeRestoreOutcome>;
orienteds: Map<string, NodeOriented>;
}
function buildInventoryEntry(
db: Database.Database,
row: InventoryRow,
ctx: InventoryBuildContext,
): NodeInventoryEntry {
const { usableSnapshot, workspaceSpec, identityVerdicts, restoreOutcomes, orienteds } = ctx;
// FS-1 W1.3 S1 — O(1) lookup into the once-per-rig restore-outcome map
// (byte-identical to the prior per-node deriveRestoreOutcome; "n-a" when a node
// is referenced by no restore event, matching the prior fall-through).
const restoreOutcome = restoreOutcomes.get(row.node_id) ?? "n-a";
// OPR.0.4.3.19 rev1-r2 B1 — a durable verdict is keyed only by node_id, so
// after a rebind/relaunch (same node, NEW session + NEW pane) a stale
// `verified` verdict for the OLD pane would otherwise be served for the new
// pane until the next 5s reconcile tick, suppressing down-rank and rendering
// a fresh squat/orphan false-green. Make applicability LOAD-BEARING at read
// time: a stored verdict applies ONLY when it was computed against the
// current binding (its sessionName === the latest session AND its
// registeredPane === the current binding pane). Otherwise treat it as ABSENT
// (null) — a STALE verdict becomes fail-open (never down-ranks a running
// seat), it does NOT itself down-rank. Only a MATCHING mismatch/pane_missing
// verdict down-ranks.
const identityVerdict = applicableVerdict(identityVerdicts.get(row.node_id) ?? null, row);
const lifecycleState = deriveNodeLifecycleState({
sessionStatus: row.session_status,
restoreOutcome,
nodeId: row.node_id,
usableSnapshot,
identityVerdict: identityVerdict?.verdict ?? null,
});
return {
rigId: row.rig_id,
rigName: row.rig_name,
logicalId: row.logical_id,
podId: row.pod_id,
podNamespace: row.pod_namespace,
// OPR.0.4.6.FAC1: the seat-side role dimension (nodes.role,
// declared in the pod-member spec) — the workflow binding layer's
// candidate filter. null = role-less (never role-resolved).
role: row.role,
canonicalSessionName: row.session_name,
attachmentType: (row.binding_attachment_type as NodeInventoryEntry["attachmentType"]) ?? null,
nodeKind: deriveNodeKind(row.runtime),
runtime: row.runtime,
sessionStatus: row.session_status,
startupStatus: row.startup_status as NodeInventoryEntry["startupStatus"],
restoreOutcome,
// FS-1 W1.3 S2 — O(1) lookup into the fleet-batched oriented map
// (byte-identical to the prior per-node deriveOriented; "n-a" when a node has
// no proof events, matching deriveOriented's no-challenge branch).
oriented: orienteds.get(row.node_id) ?? "n-a",
lifecycleState,
occupantLifecycle: deriveOccupantLifecycle(row, identityVerdict?.verdict ?? null),
continuityOutcome: deriveContinuityOutcome(row, restoreOutcome),
handoverResult: row.handover_result as NodeInventoryEntry["handoverResult"] ?? null,
previousOccupant: row.previous_occupant,
handoverAt: row.handover_at,
tmuxAttachCommand: row.binding_attachment_type === "tmux" && row.session_name ? `tmux attach -t ${row.session_name}` : null,
resumeCommand: computeResumeCommand(row.runtime, row.resume_token, row.codex_config_profile),
// OPR.0.4.0.26: recoveryGuidance is NOT inlined per node in the LIST
// payload. It duplicated ~47KB of templated prose across all nodes and
// no node-list consumer reads it. The full guidance is recomputed on
// the single-node detail path (getNodeDetail / GET
// /api/rigs/:rigId/nodes/:logicalId) — relocation, not loss.
recoveryGuidance: null,
latestError: row.startup_status === "ready" ? null : getLatestError(db, row.rig_id, row.node_id),
// Extended fields
model: row.model,
agentRef: row.agent_ref,
profile: row.profile,
codexConfigProfile: row.codex_config_profile,
resolvedSpecName: row.resolved_spec_name,
resolvedSpecVersion: row.resolved_spec_version,
resolvedSpecHash: row.resolved_spec_hash,
cwd: row.cwd,
restorePolicy: row.restore_policy,
resumeType: row.resume_type,
resumeToken: row.resume_token,
startupCompletedAt: row.startup_completed_at,
// PL-007 Workspace Primitive — per-node workspace summary derived
// from cwd against the rig's typed workspace block. null when the
// rig has no workspace declaration.
workspace: resolveNodeWorkspace({ spec: workspaceSpec, cwd: row.cwd }),
// OPR.0.4.3.19 — the liveness identity verdict (third axis). null when
// never observed; carries evidence on mismatch/missing.
identityVerdict,
heldReason: deriveHeldReason(db, row.rig_id, row.node_id, row.session_status),
};
}
/** FS-1 W1.2 — all-rigs batched form of `readRigWorkspaceJson`: one query,
* `rigId → WorkspaceSpec`. Rigs with no/malformed workspace are absent (callers
* default to null — byte-identical to `readRigWorkspaceJson`'s null return). */
function readAllRigWorkspaceJson(db: Database.Database): Map<string, WorkspaceSpec> {
const out = new Map<string, WorkspaceSpec>();
try {
const rows = db.prepare("SELECT id, workspace_json FROM rigs").all() as Array<{ id: string; workspace_json: string | null }>;
for (const row of rows) {
if (!row.workspace_json) continue;
try { out.set(row.id, JSON.parse(row.workspace_json) as WorkspaceSpec); } catch { /* malformed → absent → caller null (matches per-rig) */ }
}
} catch { /* column/table absent → empty → caller null (matches per-rig defensive path) */ }
return out;
}
/**
* FS-1 W1.2 — the rig-level N+1 collapse. Builds inventory for ALL rigs in a
* bounded, rig-count-INDEPENDENT set of queries: 3 batched setup reads
* (snapshots / workspaces / identity verdicts) + 1 all-rigs node SELECT, grouped
* by rig_id. Every entry is built through the SAME `buildInventoryEntry` that the
* per-rig `getNodeInventory` uses, so `getNodeInventoryForAllRigs(db).get(rigId)`
* is byte-identical to `getNodeInventory(db, rigId)` — the collapse changes only
* HOW the context + rows are fetched (batched vs per-rig), never how an entry is
* derived. Per-node reads inside `buildInventoryEntry` (`deriveRestoreOutcome`
* etc.) remain per-node and ride the 047 index — NOT the rig-level N+1 removed here.
*/
export function getNodeInventoryForAllRigs(db: Database.Database): Map<string, NodeInventoryEntry[]> {
const snapshotByRig = findLatestUsableSnapshotsForAllRigs(db);
const workspaceByRig = readAllRigWorkspaceJson(db);
const verdictsByRig = new SeatIdentityStore(db).getForAllRigs();
// FS-1 W1.3 S1/S2 — the per-node reads built ONCE for ALL rigs (O(1) queries,
// not O(nodes)). Restore map unscoped: a nodeId is referenced only by its own
// rig's restore events, so each node's value equals the single-rig path.
const restoreOutcomes = buildRestoreOutcomeMap(db);
const orienteds = buildOrientedMap(db);
const rows = queryInventoryRows(db);
const out = new Map<string, NodeInventoryEntry[]>();
for (const row of rows) {
const entry = buildInventoryEntry(db, row, {
usableSnapshot: snapshotByRig.get(row.rig_id) ?? null,
workspaceSpec: workspaceByRig.get(row.rig_id) ?? null,
identityVerdicts: verdictsByRig.get(row.rig_id) ?? new Map(),
restoreOutcomes,
orienteds,
});
let list = out.get(row.rig_id);
if (!list) { list = []; out.set(row.rig_id, list); }
list.push(entry);
}
return out;
}
/** PL-007 — read the rig's typed workspace block from `rigs.workspace_json`
@@ -848,7 +972,11 @@ function countPendingForEntry(
return count;
}
function deriveCanonicalFromEntry(entry: NodeInventoryEntry): string | null {
/** EXPORTED (OPR.0.4.6.FAC1): the derived canonical coordinate
* `{pod}-{member}@{rig}` for an inventory entry — the binding layer's
* ONE string rule (tiebreak key AND recorded destination) reuses
* exactly this dual-key derivation, never a parallel one. */
export function deriveCanonicalFromEntry(entry: NodeInventoryEntry): string | null {
if (!entry.rigName || !entry.logicalId) return null;
const dotIdx = entry.logicalId.indexOf(".");
if (dotIdx <= 0 || dotIdx === entry.logicalId.length - 1) return null;
@@ -857,7 +985,7 @@ function deriveCanonicalFromEntry(entry: NodeInventoryEntry): string | null {
return deriveCanonicalSessionName(pod, member, entry.rigName);
}
function readPendingWorkBySession(db: Database.Database): Map<string, number> {
export function readPendingWorkBySession(db: Database.Database): Map<string, number> {
const rows = db.prepare(`
SELECT destination_session, COUNT(*) as c
FROM queue_items
@@ -10,6 +10,7 @@ import {
startTranscriptRotation,
getTranscriptRotationOptionsFromEnv,
} from "./transcript-rotation.js";
import type { TmuxOptionDefaultsApplier } from "./tmux-option-defaults.js";
import type { Session, Binding } from "./types.js";
@@ -41,6 +42,15 @@ interface NodeLauncherDeps {
* SeatActivityService global default (3s). Per-seat override via
* LaunchOpts is currently inert. */
defaultSilenceWindowSeconds?: number;
/**
* OPR.0.4.6.02 S1 — the SHARED tmux option-defaults applier (mouse/status
* session-scope + set-clipboard/copy-command server-scope), applied to the
* just-created session at launch. Injected from startup so the same
* instance (and its once-per-daemon server-defaults memo) is shared with
* SuccessorSessionLauncher. When omitted (most unit tests), the launch path
* skips option application entirely.
*/
tmuxOptionDefaults?: TmuxOptionDefaultsApplier;
}
export class NodeLauncher {
@@ -52,6 +62,7 @@ export class NodeLauncher {
private transcriptStore: TranscriptStore | null;
private sessionEnv: Record<string, string>;
private defaultSilenceWindowSeconds: number;
private tmuxOptionDefaults: TmuxOptionDefaultsApplier | null;
constructor(deps: NodeLauncherDeps) {
// Hard runtime invariant: all domain services must share the same db handle.
@@ -74,6 +85,7 @@ export class NodeLauncher {
this.transcriptStore = deps.transcriptStore ?? null;
this.sessionEnv = compactEnv(deps.sessionEnv ?? {});
this.defaultSilenceWindowSeconds = deps.defaultSilenceWindowSeconds ?? 3;
this.tmuxOptionDefaults = deps.tmuxOptionDefaults ?? null;
}
async launchNode(
@@ -125,6 +137,15 @@ export class NodeLauncher {
const launchWarnings: string[] = [];
// 3a2. OPR.0.4.6.02 S1 — apply the daemon's tmux option defaults to the
// JUST-CREATED session via the shared applier (mouse/status session-scope
// + set-clipboard/copy-command server-scope). Touches only this new
// session, never an existing one (BR-1 never-retro). Option-set failures
// come back as non-fatal launch warnings.
if (this.tmuxOptionDefaults) {
launchWarnings.push(...(await this.tmuxOptionDefaults.applyToFreshSession(sessionName)));
}
// 3b. Start transcript rotation (V1 pre-release CLI/daemon Item 1:
// bounded capture-pane overwrite replaces the unbounded pipe-pane
// mechanism). Failures inside individual rotation ticks are silent
@@ -26,12 +26,25 @@
import type Database from "better-sqlite3";
import type { Policy, PolicyEvaluation, PolicyJob } from "./types.js";
import {
evaluateStepDeadline,
type WorkflowDeadlineVerdict,
} from "../workflow-deadline.js";
interface WorkflowKeepaliveContext {
/** ULID of the workflow_instances row to keep alive. Required. */
workflow_instance_id?: string;
observer_session?: string;
observer_sessions?: string[];
/**
* OPR.0.4.6.WF1 FR-3: set true on AUTO-ARMED jobs (see
* workflow-keepalive-arming.ts). A deadline-gated job is QUIET while
* the FR-2 evaluator reports healthy and sends only when a frontier
* packet is overdue — preserving FR-2's zero-noise-on-the-happy-path
* AC. Operator-registered jobs (flag absent) keep the shipped POC
* always-send parity unchanged.
*/
deadline_gated?: boolean;
}
interface InstanceRow {
@@ -41,15 +54,27 @@ interface InstanceRow {
created_by_session: string;
status: string;
current_frontier_json: string;
current_step_id: string | null;
}
interface QueueOwnerRow {
qitem_id: string;
destination_session: string;
state: string;
ts_created: string;
claimed_at: string | null;
closure_required_at: string | null;
}
export interface WorkflowKeepaliveDeps {
db: Database.Database;
/** OPR.0.4.6.WF5 FR-2 class (b): when supplied, a non-healthy deadline
* verdict ENSURES the durable exception item at detection time as a
* side effect of this evaluation. The PolicyEvaluation SHAPE is
* untouched (send|skip|terminal stands — X5); the item is the
* injected helper's concern, dedup by occurrence. Failures are
* non-fatal to the evaluation. */
ensureStuckExceptionItem?: import("../workflow-exception-escalation.js").EnsureStuckExceptionItem;
}
export function makeWorkflowKeepalivePolicy(deps: WorkflowKeepaliveDeps): Policy {
@@ -75,7 +100,7 @@ export function makeWorkflowKeepalivePolicy(deps: WorkflowKeepaliveDeps): Policy
const instance = db
.prepare(
`SELECT instance_id, workflow_name, workflow_version, created_by_session,
status, current_frontier_json
status, current_frontier_json, current_step_id
FROM workflow_instances WHERE instance_id = ?`,
)
.get(instanceId) as InstanceRow | undefined;
@@ -99,16 +124,45 @@ export function makeWorkflowKeepalivePolicy(deps: WorkflowKeepaliveDeps): Policy
const frontier = (JSON.parse(instance.current_frontier_json) as string[]) ?? [];
// Resolve frontier qitem owners from queue_items.
// Resolve frontier qitem owners (+ FR-2 anchor fields) from queue_items.
const resolvedFrontierOwners: string[] = [];
let frontierRows: QueueOwnerRow[] = [];
if (frontier.length > 0) {
const placeholders = frontier.map(() => "?").join(",");
const rows = db
frontierRows = db
.prepare(
`SELECT qitem_id, destination_session FROM queue_items WHERE qitem_id IN (${placeholders})`,
`SELECT qitem_id, destination_session, state, ts_created, claimed_at, closure_required_at
FROM queue_items WHERE qitem_id IN (${placeholders})`,
)
.all(...frontier) as QueueOwnerRow[];
for (const r of rows) resolvedFrontierOwners.push(r.destination_session);
for (const r of frontierRows) resolvedFrontierOwners.push(r.destination_session);
}
// OPR.0.4.6.WF1 FR-2+FR-3: evaluate the step deadline (derived,
// never stored). Deadline-gated (auto-armed) jobs stay quiet
// while healthy; ANY job's send message carries the stuck
// evidence + re-project steering when overdue.
const verdict: WorkflowDeadlineVerdict = evaluateStepDeadline(
{
instanceId: instance.instance_id,
status: instance.status,
currentFrontier: frontier,
currentStepId: instance.current_step_id,
},
frontierRows.map((r) => ({
qitemId: r.qitem_id,
state: r.state,
destinationSession: r.destination_session,
tsCreated: r.ts_created,
claimedAt: r.claimed_at,
closureRequiredAt: r.closure_required_at,
})),
new Date(),
);
if (context.deadline_gated === true && verdict.state === "healthy") {
// Quiet skip — not recorded in watchdog history (POC parity for
// quiet reasons); zero noise on the happy path.
return { action: "skip", reason: "workflow_healthy_deadline_gated" };
}
// Combine with explicit additional targets.
@@ -126,17 +180,55 @@ export function makeWorkflowKeepalivePolicy(deps: WorkflowKeepaliveDeps): Policy
return { action: "skip", reason: "empty_frontier" };
}
const primary = allSessions[0]!;
const others = allSessions.slice(1);
const message =
job.message ??
buildKeepaliveMessage({
workflowName: instance.workflow_name,
workflowVersion: instance.workflow_version,
instanceId: instance.instance_id,
status: instance.status,
allSessions,
});
// Deadline-gated (auto-armed) jobs past the deadline steer the
// nudge at the OVERDUE packet's owner with the stuck evidence —
// a restored or replacement agent reads the steering and
// re-projects; the stuck marker self-clears on recomposition
// (FR-2 AC). Operator-registered jobs (no flag) keep the shipped
// POC message + first-resolved-target parity EXACTLY, even when
// a packet is overdue — their contract predates the deadline
// model and is pinned by the shipped policy tests.
const stuck =
context.deadline_gated === true && verdict.state !== "healthy" && verdict.evidence
? verdict.evidence
: null;
// OPR.0.4.6.WF5 FR-2 class (b): detection-time exception item —
// fires on ANY non-healthy verdict (gated or operator-registered
// jobs; the operator-job MESSAGE contract below stays pinned and
// untouched). Dedup by occurrence keeps this idempotent across
// every keepalive cadence tick.
if (deps.ensureStuckExceptionItem && verdict.state !== "healthy" && verdict.evidence) {
try {
await deps.ensureStuckExceptionItem({
workflowName: instance.workflow_name,
workflowVersion: instance.workflow_version,
createdBySession: instance.created_by_session,
verdict,
});
} catch {
// Non-fatal: the nudge below still fires; the sweep/next tick
// re-detects (the crash-surviving guarantee).
}
}
const primary = stuck ? stuck.ownerSession : allSessions[0]!;
const others = stuck
? allSessions.filter((s) => s !== primary)
: allSessions.slice(1);
const message = stuck
? buildStuckNudgeMessage({
workflowName: instance.workflow_name,
workflowVersion: instance.workflow_version,
verdictState: verdict.state,
evidence: stuck,
})
: (job.message ??
buildKeepaliveMessage({
workflowName: instance.workflow_name,
workflowVersion: instance.workflow_version,
instanceId: instance.instance_id,
status: instance.status,
allSessions,
}));
return {
action: "send",
@@ -148,6 +240,7 @@ export function makeWorkflowKeepalivePolicy(deps: WorkflowKeepaliveDeps): Policy
workflowStatus: instance.status,
frontierLength: frontier.length,
additionalRoutingTargets: others,
...(stuck ? { deadline: { state: verdict.state, ...stuck } } : {}),
},
};
},
@@ -160,6 +253,36 @@ const POC_KEEPALIVE_TRAILER =
"Keep communications bridged, name exact blockers, and self-scout deterministic bias: " +
"add only the minimum deterministic code required for reliable agent operation, then rely on agent judgment for routing, adaptation, and edge handling.";
/**
* OPR.0.4.6.WF1 FR-2: the overdue re-nudge. Written for a RESTORED or
* REPLACEMENT agent with no conversational memory of the step: it names
* the instance, the step, the packet, the anchor evidence, and the
* exact re-project move.
*/
function buildStuckNudgeMessage(input: {
workflowName: string;
workflowVersion: string;
verdictState: string;
evidence: {
instanceId: string;
stepId: string | null;
packetId: string;
ownerSession: string;
anchor: string;
anchorAt: string;
overdueBySeconds: number;
ageSeconds: number;
};
}): string {
const e = input.evidence;
return [
`Workflow STUCK (${input.verdictState}): ${input.workflowName}@${input.workflowVersion} instance ${e.instanceId} step ${e.stepId ?? "(unknown)"} is overdue by ${Math.floor(e.overdueBySeconds / 60)}m (anchor: ${e.anchor} @ ${e.anchorAt}; packet age ${Math.floor(e.ageSeconds / 60)}m).`,
`You own frontier packet ${e.packetId}. If you lost context: run 'rig whoami --json', read the packet with 'rig queue show ${e.packetId}', do (or verify) the step's work, then close it truthfully via 'rig workflow project --instance ${e.instanceId} --current-packet ${e.packetId} --exit <handoff|waiting|done|failed> --actor-session ${e.ownerSession}'.`,
"Projecting normally clears the stuck state automatically - do not hand-edit any workflow state.",
POC_KEEPALIVE_TRAILER,
].join("\n");
}
function buildKeepaliveMessage(input: {
workflowName: string;
workflowVersion: string;
+37 -27
View File
@@ -1,6 +1,6 @@
import type Database from "better-sqlite3";
import type { AgentActivity, NodeInventoryEntry, NodeLifecycleState, RigLifecycleState } from "./types.js";
import { getNodeInventory } from "./node-inventory.js";
import { getNodeInventoryForAllRigs, readPendingWorkBySession } from "./node-inventory.js";
import { archiveWhereClause, type RigArchiveFilter } from "./rig-repository.js";
import type { SeatActivityService } from "./seat-activity-service.js";
import type { AgentActivityStore } from "./agent-activity-store.js";
@@ -174,6 +174,15 @@ export class PsProjectionService {
const now = Date.now();
// FS-1 W1.2 (rig-level N+1 collapse): the pending-work map is host-global and
// bounded by distinct destination sessions (seat count), so read it ONCE here
// instead of one countNodesWithPendingWork query per rig. Per-rig hasWorkCount
// is then a pure JS derivation over the already-fetched inventory below.
const pendingByDest = readPendingWorkBySession(this.db);
// FS-1 W1.2: node inventory for ALL rigs built in ONE batched pass (was one
// getNodeInventory query-set PER RIG — the rig-level N+1); indexed per rig below.
const inventoryByRig = getNodeInventoryForAllRigs(this.db);
return rows.map((r) => {
const status: PsEntry["status"] =
r.node_count === 0 ? "stopped"
@@ -181,9 +190,10 @@ export class PsProjectionService {
: r.running_count > 0 ? "partial"
: "stopped";
// Derive rig-level lifecycleState by folding per-node states. The cost is one
// node-inventory pass per rig; this matches the v0 "derive, no migration" decision.
const inventory = getNodeInventory(this.db, r.rig_id);
// Derive rig-level lifecycleState by folding per-node states. FS-1 W1.2:
// inventory for ALL rigs was built in ONE batched pass above (previously one
// getNodeInventory query-set per rig — the rig-level N+1); index it here.
const inventory = inventoryByRig.get(r.rig_id) ?? [];
const lifecycleState = deriveRigLifecycleState(inventory.map((e) => e.lifecycleState));
// Slice 15 — `terminal-active` count. Subset of running tmux-bound
@@ -204,7 +214,29 @@ export class PsProjectionService {
// least one pending qitem whose `destination_session` matches the
// node's `canonicalSessionName`. Sourced ONLY from the queue
// projection; never from tmux output.
const hasWorkCount = countNodesWithPendingWork(this.db, r.rig_id);
// FS-1 W1.2: derived in JS from the single global `pendingByDest` map,
// byte-identical to the deleted per-rig `countNodesWithPendingWork` query.
// That query matched `q.destination_session = s.session_name` (SINGLE-KEY)
// where `s` is the node's latest session; `canonicalSessionName` IS that
// latest `session_name` (node-inventory.ts return), and the map is
// `destination_session -> pending count`, so `> 0` reproduces its `EXISTS`.
// NAMED DIVERGENCE (arch pin, FS-1): this derivation DELIBERATELY keeps the
// legacy rollup's SINGLE-KEY match (canonicalSessionName == the latest
// session_name) and does NOT use the per-node DUAL-KEY sibling
// `countPendingForEntry` (node-inventory.ts) — the canonical + derived
// `{pod}-{member}@{rig}` match that is the BLOCKING-A2 adopted-seat fix.
// The rollup and the per-node enrichment already diverged here BEFORE FS-1;
// FS-1 is a perf slice whose proof spine is byte-identity, so it preserves
// the rollup's exact prior semantics on purpose — this is preserved, not a
// bug. KNOWN CONSEQUENCE: the rollup undercounts pending work for adopted
// seats. The future correctness fix now sits ONE LINE away — swap this loop
// for the shared dual-key `countPendingForEntry` derivation + update the
// byte-identity harness. Arch-routed to pm as a named small correctness
// candidate (a glance-layer undercount).
let hasWorkCount = 0;
for (const node of inventory) {
if (node.canonicalSessionName && (pendingByDest.get(node.canonicalSessionName) ?? 0) > 0) hasWorkCount++;
}
// OPR.0.4.4.21 — attention fold, same inventory pass. Hook activity
// is a synchronous events lookup by session name (NodeInventoryEntry
@@ -242,28 +274,6 @@ export class PsProjectionService {
}
}
/**
* Slice 15 — count nodes in this rig that have at least one pending
* qitem assigned to them (queue.destination_session matches a node's
* latest session_name). Pure SQL query — does NOT consult tmux output
* or SeatActivity (non-inference contract).
*/
function countNodesWithPendingWork(db: Database.Database, rigId: string): number {
const row = db.prepare(`
SELECT COUNT(DISTINCT n.id) as c
FROM nodes n
JOIN sessions s ON s.node_id = n.id
AND s.id = (SELECT s2.id FROM sessions s2 WHERE s2.node_id = n.id ORDER BY s2.id DESC LIMIT 1)
WHERE n.rig_id = ?
AND EXISTS (
SELECT 1 FROM queue_items q
WHERE q.destination_session = s.session_name
AND q.state = 'pending'
)
`).get(rigId) as { c: number } | undefined;
return row?.c ?? 0;
}
/**
* Slice 15 — count of pending qitems assigned to a specific canonical
* session name. Exposed for per-node enrichment in node-inventory.
+252 -4
View File
@@ -1,4 +1,5 @@
import type Database from "better-sqlite3";
import { createHash } from "node:crypto";
import type { EventBus } from "./event-bus.js";
import type { PersistedEvent } from "./types.js";
import { QueueTransitionLog } from "./queue-transition-log.js";
@@ -22,6 +23,18 @@ export const QUEUE_STATES = [
] as const;
export type QueueState = (typeof QUEUE_STATES)[number];
/** OPR.0.4.6.FS-1 (W2 P1): the queue's terminal state set, named ONCE. The
* archiver (queue-retention.ts) AND the inline closure guards below all consume
* THIS predicate, so a future terminal-state addition can never silently
* diverge the archiver from the queue (arch D3-REFINEMENT P1; widen-never-sibling).
* `['done','handed-off']` is the full terminal set — workflow step closures exit
* `handoff -> state=handed-off`, the highest-volume terminal class. The `satisfies`
* clause is the compile guard: removing a state from QUEUE_STATES fails here. */
export const TERMINAL_QUEUE_STATES = ["done", "handed-off"] as const satisfies readonly QueueState[];
export function isTerminalState(state: string): boolean {
return (TERMINAL_QUEUE_STATES as readonly string[]).includes(state);
}
export const QUEUE_PRIORITIES = ["routine", "urgent", "critical"] as const;
export type QueuePriority = (typeof QUEUE_PRIORITIES)[number];
@@ -142,6 +155,13 @@ export interface QueueUpdateInput {
qitemId: string;
actorSession: string;
state: QueueState;
/**
* OPR.0.4.6.WF3 FR-6 — set ONLY by the workflow domain's own write
* paths (projector close, route close): they hold the frontier
* invariant, so the close-path guard exempts them. Not a security
* boundary — a correctness foot-gun guard (pm ruling: prevention).
*/
viaWorkflowVerb?: boolean;
transitionNote?: string;
closureReason?: string;
closureTarget?: string;
@@ -230,7 +250,13 @@ export class QueueRepositoryError extends Error {
}
}
function newQitemId(): string {
/** OPR.0.4.6.WF5 (guard-named fix shape): exported so the workflow
* domain can PREALLOCATE a gate packet's id — the class-(c) exception
* identity tags need occurrence:<gatePacketId> ON the packet at create
* (one item, tagged in its own create — never a second item, never a
* post-create tag rewrite). The queue still mints ids for every caller
* that does not preallocate. */
export function newQitemId(): string {
const ts = new Date().toISOString().replace(/[-:T.Z]/g, "").slice(0, 14);
const hex = Math.floor(Math.random() * 0xffffffff)
.toString(16)
@@ -238,6 +264,52 @@ function newQitemId(): string {
return `qitem-${ts}-${hex}`;
}
/**
* OPR.0.4.6.MH3 Q-a: is this the SQLite PRIMARY KEY conflict on
* queue_items.qitem_id? better-sqlite3 sets `.code` on its SqliteError; the
* message check is a defensive twin so a driver-name change never silently
* turns an idempotent absorb into a 500.
*/
export function isQitemPrimaryKeyConflict(err: unknown): boolean {
if (!(err instanceof Error)) return false;
const code = (err as { code?: string }).code ?? "";
if (code === "SQLITE_CONSTRAINT_PRIMARYKEY" || code === "SQLITE_CONSTRAINT_UNIQUE") return true;
return /UNIQUE constraint failed: queue_items\.qitem_id/.test(err.message);
}
/**
* OPR.0.4.6.MH3 D-1 (FR-4/FR-5): the deterministic cross-host SUCCESSOR id.
*
* A cross-host handoff exposes no caller `--id`, so the successor's dedup
* identity must come from the operation itself: the id is a PURE, STATELESS
* function of (source qitemId, destination session, destination host). Same
* arguments → same id on every re-drive, across daemon restarts, with zero
* local state — so the origin-side PRIMARY KEY absorb (Q-a) converges every
* interrupted-close re-drive. Source→successor is 1:1 by construction (a
* closed source is terminal; nothing re-opens it). The `qitem-xh-` namespace
* makes collision with organic `qitem-<ts>-<hex>` ids structurally impossible
* (plan R-2). The compound key is JSON-encoded — no hand-rolled separators.
*
* n1 residual (arch-named, inherent to the ratified at-least-once/no-2PC
* fence — NOT a dedup bug): a re-drive naming a DIFFERENT destination before
* the source close lands is a NEW handoff decision and derives a DIFFERENT
* id, so it cannot absorb the earlier successor — that earlier successor can
* remain live on the target host. The chain_of_record + cross-host provenance
* tags keep such an orphan visible/traceable; the source-close conflict check
* surfaces the disagreement rather than overwriting it.
*/
export function deriveCrossHostSuccessorId(
sourceQitemId: string,
destinationSession: string,
hostId: string,
): string {
const digest = createHash("sha256")
.update(JSON.stringify([sourceQitemId, destinationSession, hostId]))
.digest("hex")
.slice(0, 16);
return `qitem-xh-${digest}`;
}
/** PL-007 — defensive column probe. Older test fixtures bypass the
* canonical migration list, so target_repo may be absent. Mirrors
* the `hasNodeColumn` pattern in rig-repository.ts. */
@@ -273,6 +345,11 @@ export class QueueRepository {
private readonly hasTargetRepoColumn: boolean;
private readonly hasSummaryColumn: boolean;
private readonly hasEvidenceRefColumn: boolean;
/** OPR.0.4.6.WF3 FR-6 — injected by startup (never imported): the
* workflow domain's is-live-frontier-packet predicate. */
private readonly workflowFrontierPredicate:
| ((qitemId: string) => { instanceId: string; workflowName: string } | null)
| undefined;
constructor(
db: Database.Database,
@@ -288,6 +365,15 @@ export class QueueRepository {
* a test or daemon-bootstrap path where transport is not yet wired).
*/
transport?: QueueNudgeTransport;
/**
* OPR.0.4.6.WF3 FR-6 — the frontier close-path guard's INJECTED
* predicate (the validateRig injection precedent: the queue is
* the lower primitive and NEVER imports the workflow domain;
* startup wires the workflow domain's exported predicate in).
* Absent (tests, bootstrap, pre-workflow schemas) = zero new
* behavior.
*/
workflowFrontierPredicate?: (qitemId: string) => { instanceId: string; workflowName: string } | null;
}
) {
this.db = db;
@@ -295,6 +381,7 @@ export class QueueRepository {
this.transitionLog = new QueueTransitionLog(db);
this.validateRig = opts?.validateRig ?? (() => true);
this.transport = opts?.transport;
this.workflowFrontierPredicate = opts?.workflowFrontierPredicate;
this.hasTargetRepoColumn = detectQueueColumn(db, "target_repo");
this.hasSummaryColumn = detectQueueColumn(db, "summary");
this.hasEvidenceRefColumn = detectQueueColumn(db, "evidence_ref");
@@ -384,7 +471,41 @@ export class QueueRepository {
}
const txn = this.db.transaction(() => this.createInTransactionalContext(input));
const { qitemId: id, persistedEvent } = txn();
let id: string;
let persistedEvent: PersistedEvent;
try {
({ qitemId: id, persistedEvent } = txn());
} catch (err) {
// OPR.0.4.6.MH3 Q-a (FR-5): at-least-once cross-host forwards retry with
// the SAME minted qitemId, so a PK conflict on an EXISTING row is an
// idempotent RE-DELIVERY when the identity fields match — return the
// stored row (no second insert, no second event/nudge). A conflict whose
// identity fields DIFFER (same id, different destination/source) is a
// caller id-reuse bug — a structured error, never a silent overwrite.
// Local (non-forwarded) creates that pass an explicit --id keep the same
// safety for free.
if (input.qitemId && isQitemPrimaryKeyConflict(err)) {
const existing = this.getById(input.qitemId);
if (existing) {
if (
existing.destinationSession === input.destinationSession &&
existing.sourceSession === input.sourceSession
) {
return existing;
}
throw new QueueRepositoryError(
"qitem_id_reuse",
`qitem ${input.qitemId} already exists with a different destination/source — id reuse is a caller bug, not an idempotent retry`,
{
qitemId: input.qitemId,
existingDestination: existing.destinationSession,
existingSource: existing.sourceSession,
},
);
}
}
throw err;
}
this.eventBus.notifySubscribers(persistedEvent);
await this.maybeNudge(id, input.destinationSession, input.nudge, input.sourceSession);
return this.getByIdOrThrow(id);
@@ -519,7 +640,7 @@ export class QueueRepository {
`qitem ${input.qitemId} not found`
);
}
if (source.state === "done" || source.state === "handed-off") {
if (isTerminalState(source.state)) {
throw new QueueRepositoryError(
"qitem_already_terminal",
`qitem ${input.qitemId} is already in terminal state ${source.state}`
@@ -660,7 +781,7 @@ export class QueueRepository {
`qitem ${input.qitemId} not found`
);
}
if (source.state === "done" || source.state === "handed-off") {
if (isTerminalState(source.state)) {
throw new QueueRepositoryError(
"qitem_already_terminal",
`qitem ${input.qitemId} is already in terminal state ${source.state}`
@@ -784,6 +905,111 @@ export class QueueRepository {
};
}
/**
* OPR.0.4.6.MH3 FR-4 (C2, arch Q-c): the LOCAL half of a cross-host
* handoff — close the source row AFTER the successor-create was forwarded
* to (and accepted by) the target host. The two sides live in two DBs, so
* this is deliberately NOT the atomic close+create of {@link handoff}: the
* boundary is bridged by message-passing (successor-create FIRST on the
* origin host, this source-close SECOND — never the reverse, so a crash
* between the two leaves a live duplicate the idempotent re-drive
* converges, never a dropped potato).
*
* Re-drive semantics (FR-4/FR-5, the interrupted-close case):
* - source already terminal WITH a MATCHING closureTarget → idempotent
* absorb: return the stored row unchanged (`absorbed: true`) — no
* second close, no second event.
* - source already terminal with a MISMATCHED closureTarget → structured
* `cross_host_close_conflict` (someone else closed it meanwhile —
* surface, never overwrite).
* - otherwise → close exactly like the local handoff's close leg:
* `closure_reason=handed_off_to`; `closure_target` carries the OPAQUE
* three-part `<member@rig>@<host>` form (arch R1 — presence-checked
* display/audit metadata, NEVER parsed for routing); `handed_off_to`
* stays the two-part `member@rig` (BR-1 — session-string carriers
* never gain `@host`).
*/
closeCrossHostHandoffSource(input: {
qitemId: string;
fromSession: string;
/** Two-part `member@rig` destination — the session-string carrier (BR-1). */
toSession: string;
/** Opaque three-part `<member@rig>@<host>` closure target (arch R1). */
closureTarget: string;
/** `handed-off` for /handoff; `done` for /handoff-and-complete. */
terminalState: "handed-off" | "done";
transitionNote?: string;
}): { item: QueueItem; absorbed: boolean } {
const source = this.getById(input.qitemId);
if (!source) {
throw new QueueRepositoryError(
"qitem_not_found",
`qitem ${input.qitemId} not found`
);
}
if (isTerminalState(source.state)) {
if (source.closureTarget === input.closureTarget) {
return { item: source, absorbed: true };
}
throw new QueueRepositoryError(
"cross_host_close_conflict",
`qitem ${input.qitemId} is already closed toward ${source.closureTarget ?? "<no closure_target>"} — this re-drive names ${input.closureTarget}; surfacing the conflict, never overwriting`,
{
qitemId: input.qitemId,
existingClosureTarget: source.closureTarget,
attemptedClosureTarget: input.closureTarget,
},
);
}
const ts = new Date().toISOString();
const events: Array<import("./types.js").RigEvent> = [];
const txn = this.db.transaction(() => {
this.db
.prepare(
`UPDATE queue_items
SET state = ?,
ts_updated = ?,
handed_off_to = ?,
closure_reason = 'handed_off_to',
closure_target = ?
WHERE qitem_id = ?`
)
.run(input.terminalState, ts, input.toSession, input.closureTarget, input.qitemId);
this.transitionLog.append({
qitemId: input.qitemId,
state: input.terminalState,
actorSession: input.fromSession,
// BR-1: the minted note carries the TWO-PART toSession only — the
// host-qualified 3-part form is allowed in closure_target and nowhere
// else, and transition_note is a durable carrier.
transitionNote: input.transitionNote ?? `cross-host handoff to ${input.toSession}`,
closureReason: "handed_off_to",
closureTarget: input.closureTarget,
});
const handoffEvent = this.eventBus.persistWithinTransaction({
type: "queue.handed_off",
qitemId: input.qitemId,
fromSession: input.fromSession,
// The event body is a session-string carrier — two-part only (BR-1).
toSession: input.toSession,
closureReason: "handed_off_to",
summary: source.summary ?? null,
});
events.push(handoffEvent);
});
txn();
for (const e of events) {
this.eventBus.notifySubscribers(e as PersistedEvent);
}
return { item: this.getByIdOrThrow(input.qitemId), absorbed: false };
}
/**
* `whoami` — return the seat's queue position from the daemon's perspective.
* Counts active qitems (pending + in-progress + blocked) destined for the
@@ -1017,6 +1243,28 @@ export class QueueRepository {
});
}
// OPR.0.4.6.WF3 FR-6 — the frontier close-path guard (pm ruling:
// PREVENTION over detection). A TERMINAL closure (done/handed-off)
// of a LIVE workflow-frontier packet from a NON-workflow verb
// would strand the instance: the frontier would reference a
// closed packet and the workflow's own bookkeeping (trail,
// rebind, events) would never happen. Reject LOUD with
// what/why/fix naming the workflow verbs. The workflow domain's
// own writers pass viaWorkflowVerb (they hold the invariant);
// non-workflow qitems return null from the predicate — closure
// behavior byte-identical (the zero-friction negative).
const isTerminalClosure = isTerminalState(input.state);
if (isTerminalClosure && !input.viaWorkflowVerb && this.workflowFrontierPredicate) {
const binding = this.workflowFrontierPredicate(input.qitemId);
if (binding) {
throw new QueueRepositoryError(
"workflow_frontier_packet",
`qitem ${input.qitemId} is the LIVE frontier packet of workflow instance ${binding.instanceId} (${binding.workflowName}). Closing it out-of-band would strand the workflow. Use the workflow verbs instead: rig workflow project (advance) | rig workflow route (re-target the owner).`,
{ instanceId: binding.instanceId, workflowName: binding.workflowName, qitemId: input.qitemId },
);
}
}
// OPR.0.4.4.19 FR-6 — leg-1 park (state=blocked on a HUMAN-seat blocker):
// enforce summary + evidence_ref at the park moment, evaluated on the
// EFFECTIVE values (provided on this call, else already on the item) so
@@ -0,0 +1,292 @@
import type Database from "better-sqlite3";
import { TERMINAL_QUEUE_STATES } from "./queue-repository.js";
/**
* OPR.0.4.6 FS-1 (daemon read-path hardening) — W2 retention/prune runner.
*
* Two tables, two CONTRACTS (arch D3 — never collapsed into one policy):
*
* 1. `queue_transitions` = an AUDIT surface with product meaning (chain of
* record; `rig queue resolve` decision text). Contract: ARCHIVE, never
* plain-delete. A terminal qitem whose LAST transition is older than the
* retention window is MOVED (INSERT..SELECT + DELETE in ONE txn) into the
* `queue_transitions_archive` sibling (migration 054) — the trail is
* preserved, queryable in place, never lost.
* THE ACTIVE-FRONTIER INVARIANT (binding, testable): transitions of any
* NON-terminal qitem are NEVER touched, at any age. Enforced structurally —
* only qitems whose `queue_items.state` is terminal are ever selected.
*
* 2. `watchdog_history` = telemetry, no audit contract. Contract: plain DELETE
* older than the window, PLUS keep the most recent K per job regardless of
* age (respects `idx_watchdog_history_job_recent`, the recent-per-job reader).
*
* Runner mechanics: a boot-time sweep + a daily in-daemon maintenance tick (NOT
* a watchdog policy). Every DB pass is a BOUNDED batch (LIMIT-ed) and the async
* orchestrator yields to the event loop between batches, so the prune itself can
* NEVER wedge the loop — the fix must not carry the disease it cures. `nowIso` is
* injectable for deterministic VM seeding.
*
* HONESTY LINE (arch F3): this bounds DB growth, backup size, and any
* transitions-reading surface. It does NOT move `/api/ps` latency (that is W1's
* sessions index + N+1 collapse). Kept separate so the ship-gate credits the
* right fix.
*
* ── REBASE-RECONCILE (this module is greenfield-authored on worktree 384e60f1;
* the following land against the merged tip, per PLAN §J/§K, NOT this stale
* checkout) ──
* • P1 — ONE SHARED TERMINAL PREDICATE. Today `queue-repository.ts` has the
* terminal set only as INLINE literals (`state === "done" || state ===
* "handed-off"` at :522/:663 here; arch cites :550/:691/:1058 at the merged
* tip a6c27e74). At rebase, export a single named `TERMINAL_QUEUE_STATES`
* from queue-repository, refactor those inline sites to consume it, and
* replace this module's local `DEFAULT_TERMINAL_STATES` with that import — a
* pure-naming, byte-identical change swept by FUNCTION not line#. Until then
* the local default IS the code's terminal set (`['done','handed-off']`), so
* behavior is identical; only the SSOT wiring is deferred.
* • P2 — FRONTIER-LIVENESS EXCLUSION. Add one WHERE clause (+ one named test)
* excluding any qitem referenced in a NON-terminal workflow instance's
* current frontier, regardless of state/age. VACUOUS today (single-frontier
* close mechanics already remove terminal packets from the frontier — the
* non-terminal invariant covers it); armed for the WF-6/parallel-frontier era.
* Authored at the merged tip where the frontier schema is final.
* • WIRING — register the retention settings keys in `SETTINGS_VALID_KEYS`
* (settings-store) and call `runQueueRetentionSweep` from the daemon's
* maintenance scheduler (where the watchdog runner is started, index.ts/
* server.ts) at boot + on a daily tick. Both integration points live at the
* merged tip; wired there.
*/
/**
* The queue's terminal state set — P1 LANDED: sourced from the SINGLE shared
* `TERMINAL_QUEUE_STATES` SSOT exported by queue-repository (the same predicate
* the queue's own closure guards consume via `isTerminalState`), so a future
* terminal-state addition can never silently diverge the archiver from the queue
* (arch D3-REFINEMENT P1; widen-never-sibling). `['done','handed-off']` is the
* full terminal set — done-only would exempt the highest-volume class (workflow
* step closures exit `handoff -> state=handed-off`).
*/
export const DEFAULT_TERMINAL_STATES = TERMINAL_QUEUE_STATES;
/** Retention defaults (arch D3). BAKED closed-set knobs — registered as settings
* keys at rebase; no free-form config. */
export const RETENTION_DEFAULTS = {
/** archive terminal transitions whose last transition is older than this */
transitionsRetentionDays: 30,
/** delete watchdog_history older than this ... */
watchdogRetentionDays: 14,
/** ... EXCEPT always keep this many most-recent rows per job */
watchdogKeepPerJob: 50,
/** rows/qitems touched per bounded batch (the anti-wedge bound) */
batchSize: 500,
/** safety cap on batches per table per sweep (defense-in-depth vs a runaway loop) */
maxBatchesPerTable: 10_000,
} as const;
export interface RetentionOptions {
/** injected clock (ISO-8601); deterministic for VM seeding */
nowIso: string;
terminalStates?: readonly string[];
transitionsRetentionDays?: number;
watchdogRetentionDays?: number;
watchdogKeepPerJob?: number;
batchSize?: number;
maxBatchesPerTable?: number;
}
/** ISO cutoff = now minus `days`. Pure; derived from the injected `nowIso` so
* the same seed always yields the same boundary (no ambient Date). */
function cutoffIso(nowIso: string, days: number): string {
const now = new Date(nowIso);
if (Number.isNaN(now.getTime())) {
throw new Error(`queue-retention: invalid nowIso ${JSON.stringify(nowIso)}`);
}
return new Date(now.getTime() - days * 24 * 60 * 60 * 1000).toISOString();
}
/** Cooperative yield between bounded batches so a large prune cannot starve the
* event loop (better-sqlite3 is synchronous — each batch runs to completion,
* the yield is what keeps the loop responsive across batches). */
function yieldToLoop(): Promise<void> {
return new Promise((resolve) => setImmediate(resolve));
}
export interface ArchiveBatchResult {
/** distinct terminal qitems whose transitions were archived this batch */
archivedQitems: number;
/** transition rows moved this batch */
archivedRows: number;
}
/**
* ONE bounded batch: move (archive-then-delete, single txn per qitem) the
* transitions of up to `batchSize` TERMINAL qitems whose LAST transition is
* older than the cutoff. Returns the counts so the orchestrator can loop until
* a batch is empty. Non-terminal qitems are never selected (active-frontier
* invariant, structural).
*/
export function archiveAgedTerminalTransitions(
db: Database.Database,
opts: RetentionOptions,
): ArchiveBatchResult {
const terminalStates = opts.terminalStates ?? DEFAULT_TERMINAL_STATES;
const batchSize = opts.batchSize ?? RETENTION_DEFAULTS.batchSize;
const cutoff = cutoffIso(
opts.nowIso,
opts.transitionsRetentionDays ?? RETENTION_DEFAULTS.transitionsRetentionDays,
);
// Eligible = terminal qitems whose newest transition predates the cutoff.
// Driven off queue_items (terminal filter) with a correlated MAX(ts) that
// rides idx_queue_transitions_qitem (qitem_id, ts). LIMIT bounds the batch.
//
// P2 (arch, frontier-liveness exclusion): the AND NOT EXISTS below NEVER
// archives a terminal qitem still referenced by a LIVE (active/waiting)
// workflow instance's current frontier. It MIRRORS the queue-CLOSE-path
// sibling `createWorkflowFrontierPredicate` (workflow-frontier-guard.ts,
// WF3 FR-6) — same invariant (`status IN ('active','waiting')` +
// `current_frontier_json LIKE '%"<qitemId>"%'`), enforced here at the
// ARCHIVAL-SELECTION seam instead of the close seam (orch ruling: keep this
// maintenance module standalone/DB-scoped rather than threading the injected
// predicate). VACUOUS today (single-frontier close mechanics remove terminal
// packets from the frontier, and the terminal filter already excludes the
// non-terminal frontier residents); armed for WF-6/parallel-frontier, where
// it becomes mechanical rather than assumed. `workflow_instances` is a core
// migrated table (always present when the tick runs post-migration).
const placeholders = terminalStates.map(() => "?").join(", ");
const eligible = db
.prepare(
`SELECT q.qitem_id AS qitemId
FROM queue_items q
WHERE q.state IN (${placeholders})
AND (
SELECT MAX(t.ts) FROM queue_transitions t WHERE t.qitem_id = q.qitem_id
) < ?
AND NOT EXISTS (
SELECT 1 FROM workflow_instances wi
WHERE wi.status IN ('active','waiting')
AND wi.current_frontier_json LIKE '%"' || q.qitem_id || '"%'
)
LIMIT ?`,
)
.all(...terminalStates, cutoff, batchSize) as Array<{ qitemId: string }>;
if (eligible.length === 0) {
return { archivedQitems: 0, archivedRows: 0 };
}
const selectRows = db.prepare(
`INSERT INTO queue_transitions_archive (
transition_id, qitem_id, ts, state, transition_note,
actor_session, closure_reason, closure_target, archived_at
)
SELECT transition_id, qitem_id, ts, state, transition_note,
actor_session, closure_reason, closure_target, ?
FROM queue_transitions
WHERE qitem_id = ?`,
);
const deleteRows = db.prepare(`DELETE FROM queue_transitions WHERE qitem_id = ?`);
// One transaction PER qitem: the move for a given qitem is all-or-nothing, and
// a batch that fails partway leaves already-moved qitems durably archived.
const moveOne = db.transaction((qitemId: string): number => {
const inserted = selectRows.run(opts.nowIso, qitemId).changes;
deleteRows.run(qitemId);
return inserted;
});
let archivedRows = 0;
for (const { qitemId } of eligible) {
archivedRows += moveOne(qitemId);
}
return { archivedQitems: eligible.length, archivedRows };
}
export interface PruneBatchResult {
/** watchdog_history rows deleted this batch */
deletedRows: number;
}
/**
* ONE bounded batch: DELETE up to `batchSize` watchdog_history rows that are
* older than the cutoff AND outside the most-recent-K per job. The per-job
* recency rank rides idx_watchdog_history_job_recent (job_id, evaluated_at DESC).
* `>=` in the rank subquery means exact-timestamp ties over-KEEP (never
* over-delete) — the safe direction.
*/
export function pruneWatchdogHistory(
db: Database.Database,
opts: RetentionOptions,
): PruneBatchResult {
const batchSize = opts.batchSize ?? RETENTION_DEFAULTS.batchSize;
const keepPerJob = opts.watchdogKeepPerJob ?? RETENTION_DEFAULTS.watchdogKeepPerJob;
const cutoff = cutoffIso(
opts.nowIso,
opts.watchdogRetentionDays ?? RETENTION_DEFAULTS.watchdogRetentionDays,
);
const result = db
.prepare(
`DELETE FROM watchdog_history
WHERE history_id IN (
SELECT wh.history_id
FROM watchdog_history wh
WHERE wh.evaluated_at < ?
AND (
SELECT COUNT(*) FROM watchdog_history w2
WHERE w2.job_id = wh.job_id
AND w2.evaluated_at >= wh.evaluated_at
) > ?
LIMIT ?
)`,
)
.run(cutoff, keepPerJob, batchSize);
return { deletedRows: result.changes };
}
export interface RetentionSweepSummary {
archivedQitems: number;
archivedRows: number;
watchdogDeleted: number;
transitionBatches: number;
watchdogBatches: number;
}
/**
* The boot-sweep / daily-tick entry point: drain both retention passes in
* bounded batches, yielding to the event loop between batches so a large
* backlog can never wedge the daemon. Idempotent and safe to run on every boot.
* Each pass stops when a batch is empty or the safety batch-cap is hit.
*/
export async function runQueueRetentionSweep(
db: Database.Database,
opts: RetentionOptions,
): Promise<RetentionSweepSummary> {
const maxBatches = opts.maxBatchesPerTable ?? RETENTION_DEFAULTS.maxBatchesPerTable;
const summary: RetentionSweepSummary = {
archivedQitems: 0,
archivedRows: 0,
watchdogDeleted: 0,
transitionBatches: 0,
watchdogBatches: 0,
};
for (let i = 0; i < maxBatches; i++) {
const batch = archiveAgedTerminalTransitions(db, opts);
if (batch.archivedQitems === 0) break;
summary.archivedQitems += batch.archivedQitems;
summary.archivedRows += batch.archivedRows;
summary.transitionBatches++;
await yieldToLoop();
}
for (let i = 0; i < maxBatches; i++) {
const batch = pruneWatchdogHistory(db, opts);
if (batch.deletedRows === 0) break;
summary.watchdogDeleted += batch.deletedRows;
summary.watchdogBatches++;
await yieldToLoop();
}
return summary;
}
@@ -11,6 +11,7 @@ import type { NodeLauncher } from "./node-launcher.js";
import type { TmuxAdapter } from "../adapters/tmux.js";
import type { ClaudeResumeAdapter } from "../adapters/claude-resume.js";
import type { CodexResumeAdapter } from "../adapters/codex-resume.js";
import type { PiResumeAdapter } from "../adapters/pi-resume.js";
import type { TranscriptStore } from "./transcript-store.js";
import { assessNativeResumeProbe } from "./native-resume-probe.js";
import type {
@@ -95,6 +96,9 @@ interface RestoreOrchestratorDeps {
tmuxAdapter: TmuxAdapter;
claudeResume: ClaudeResumeAdapter;
codexResume: CodexResumeAdapter;
/** OPR.0.4.6.PI1 FR-6 — optional so older wiring/tests keep working; a Pi
* resume without the adapter falls through to the honest no-adapter error. */
piResume?: PiResumeAdapter;
transcriptStore?: TranscriptStore;
serviceOrchestrator?: import("./service-orchestrator.js").ServiceOrchestrator;
}
@@ -111,6 +115,7 @@ export class RestoreOrchestrator {
private tmuxAdapter: TmuxAdapter;
private claudeResume: ClaudeResumeAdapter;
private codexResume: CodexResumeAdapter;
private piResume: PiResumeAdapter | null;
private transcriptStore: TranscriptStore | null;
private serviceOrchestrator: import("./service-orchestrator.js").ServiceOrchestrator | null;
@@ -147,6 +152,7 @@ export class RestoreOrchestrator {
this.tmuxAdapter = deps.tmuxAdapter;
this.claudeResume = deps.claudeResume;
this.codexResume = deps.codexResume;
this.piResume = deps.piResume ?? null;
this.transcriptStore = deps.transcriptStore ?? null;
this.serviceOrchestrator = deps.serviceOrchestrator ?? null;
}
@@ -977,7 +983,7 @@ export class RestoreOrchestrator {
await this.rollbackToZeroSession(node.id, sessionName, launchResult?.session.id, priorState);
return { nodeId: node.id, logicalId: node.logicalId, status: "awaiting-decision", error: `Original session unresumable: resume requested but no token available. No session is running. Re-run with --fresh ${node.logicalId} for a deliberate fresh-primed seat, or restore the original session manually.` };
} else {
const resumeOutcome = await this.attemptResume(sessionName, resumeType, resumeToken, node.cwd ?? "/", node.codexConfigProfile);
const resumeOutcome = await this.attemptResume(sessionName, resumeType, resumeToken, node.cwd ?? "/", node.codexConfigProfile, node.model);
if (resumeOutcome.kind === "resumed") {
baseStatus = "resumed";
} else if (resumeOutcome.kind === "attention_required") {
@@ -1098,6 +1104,13 @@ export class RestoreOrchestrator {
...launchResult.binding,
cwd: node.cwd ?? ".",
codexConfigProfile: node.codexConfigProfile ?? undefined,
// OPR.0.4.6.PI1 VM leg finding: the restore binding dropped the
// node's model declaration, so a resumed Pi seat relaunched with
// no --model — and the runner's provider-key allowlist (keyed off
// the declared provider) passed nothing through ("No API key
// found" on every resumed Pi seat). Claude/Codex silently lost
// their -m/--model on restore the same way.
model: node.model ?? undefined,
};
try {
@@ -1228,6 +1241,7 @@ export class RestoreOrchestrator {
resumeToken: string | null,
cwd: string,
codexConfigProfile?: string | null,
model?: string | null,
): Promise<
| { kind: "resumed" }
| { kind: "retry_fresh" }
@@ -1267,6 +1281,23 @@ export class RestoreOrchestrator {
return { kind: "failed", message: result.message };
}
// OPR.0.4.6.PI1 FR-6 — honest session-file continuation. A missing
// session file returns retry_fresh, which the caller maps to the
// awaiting-decision stop-and-ask — never a silent fresh start (BR-6).
if (this.piResume?.canResume(resumeType, resumeToken)) {
const result = await this.piResume.resume(sessionName, resumeType, resumeToken, cwd, model);
if (result.ok) return { kind: "resumed" };
if (result.code === "retry_fresh") return { kind: "retry_fresh" };
if (result.code === "attention_required") {
return {
kind: "attention_required",
message: result.message,
evidence: (result as { evidence?: string }).evidence,
};
}
return { kind: "failed", message: result.message };
}
return { kind: "failed", message: "No resume adapter available for this runtime/token combination." };
}
@@ -25,6 +25,12 @@ export interface ResumeTokenCaptureDeps {
resumeTokenCapturer?: {
captureCodexThreadId(sessionName: string): Promise<string | undefined>;
} | null;
/** OPR.0.4.6.PI1 FR-6 — reads the pi-runner's session-state sidecar (the
* runner persists sessionFile/sessionId from RPC get_state; a file read,
* same posture as the claude-code status-line sidecar). */
piRunnerStateStore?: {
readSessionFile(sessionName: string): { ok: true; sessionFile: string } | { ok: false; reason: string };
} | null;
}
export type ResumeTokenDeriveResult =
@@ -41,6 +47,7 @@ export type ResumeTokenDeriveResult =
* Derive a runtime's resume token from live, read-only sources:
* claude-code → the status-line sidecar's session_id (a file read)
* codex → the thread id derived from live pid-keyed logs
* pi → the pi-runner state sidecar's sessionFile (a file read)
* Returns a structured outcome; never throws for a missing/invalid token
* (those are honest skips). ANY unexpected throw from a dependency is the
* caller's to swallow (capture must never fail or block its lifecycle op).
@@ -52,7 +59,7 @@ export async function deriveResumeToken(
const resumeType = resumeTypeForRuntime(input.runtime);
if (!resumeType) return { outcome: "exempt" }; // terminal / unknown — exempt, not a failure
const runtime = input.runtime as string; // non-null: resumeType is set only for claude-code / codex
const runtime = input.runtime as string; // non-null: resumeType is set only for claude-code / codex / pi
let token: string | undefined;
if (runtime === "claude-code") {
@@ -68,6 +75,14 @@ export async function deriveResumeToken(
if (!deps.resumeTokenCapturer) return { outcome: "noop" }; // dep absent — silent no-op
token = await deps.resumeTokenCapturer.captureCodexThreadId(input.sessionName);
if (!token) return { outcome: "skipped", reason: "probe_timeout" };
} else if (runtime === "pi") {
if (!deps.piRunnerStateStore) return { outcome: "noop" }; // dep absent — silent no-op
const state = deps.piRunnerStateStore.readSessionFile(input.sessionName);
if (!state.ok) {
return { outcome: "skipped", reason: state.reason === "parse_error" ? "parse_error" : "missing_sidecar" };
}
if (state.sessionFile.trim().length > 0) token = state.sessionFile.trim();
else return { outcome: "skipped", reason: "missing_sidecar" };
} else {
return { outcome: "noop" }; // resumeType set but runtime is not one we derive — defensive
}
@@ -1,4 +1,7 @@
// OPR.0.4.0.22 FR-2 — per-runtime resume-token validation.
// OPR.0.4.6.PI1 FR-6 — validation is PER-RESUME-TYPE: id-shaped tokens
// (claude/codex) keep the original rules unchanged; pi_session_file is a
// PATH-shaped token (an absolute session-file path) with its own floor.
//
// The floor is FORMAT validation: reject malformed input, never fabricate a
// token, and NEVER quote the raw token in an error message (it is
@@ -7,7 +10,7 @@
// actually resume" probe is intentionally out of scope (heavy + must not
// mutate live state); format validation is the safe, side-effect-free floor.
export type ResumeType = "claude_id" | "codex_id";
export type ResumeType = "claude_id" | "codex_id" | "pi_session_file";
export interface ResumeTokenValidationOk {
ok: true;
@@ -24,32 +27,33 @@ export interface ResumeTokenValidationErr {
const SAFE_TOKEN_RE = /^[A-Za-z0-9._-]+$/;
const MAX_TOKEN_LEN = 200;
// pi_session_file floor: absolute path, no ".." segment (checked on the raw
// operand — normalization collapses "..", so a post-normalize check would be
// dead code; same posture as the shipped `rig file` traversal guard), the
// shell-inert path charset, a 1024 cap, and the Pi session-file suffix (one
// constant if Pi ever renames its session format).
//
// "@" is in the set deliberately (a delta from the PRD's literal
// [A-Za-z0-9._/-], caught by the VM hermetic run): the Pi seat state layout
// keys on the CANONICAL session name (pod-member@rig), so every real Pi
// session-file path contains "@". It is shell-inert here — the token is
// always argv/shellQuote-passed, never a remote scp/rsync operand where
// user@host parsing would matter (that ambiguity is why `rig file` excludes
// it; this surface has no such parse).
const PI_SESSION_FILE_CHARSET_RE = /^[A-Za-z0-9._/@-]+$/;
const MAX_PI_SESSION_FILE_LEN = 1024;
const PI_SESSION_FILE_SUFFIX = ".jsonl";
/** Resume-id type for a runtime, or null when the runtime has no resume token
* (terminal / unknown). */
export function resumeTypeForRuntime(runtime: string | null): ResumeType | null {
if (runtime === "claude-code") return "claude_id";
if (runtime === "codex") return "codex_id";
if (runtime === "pi") return "pi_session_file";
return null;
}
export function validateResumeToken(
runtime: string | null,
rawToken: unknown,
): ResumeTokenValidationOk | ResumeTokenValidationErr {
const resumeType = resumeTypeForRuntime(runtime);
if (!resumeType) {
return {
ok: false,
error: `set-resume-token is not supported for runtime "${runtime ?? "unknown"}" (only claude-code and codex have resume tokens).`,
};
}
if (typeof rawToken !== "string") {
return { ok: false, error: "Resume token is missing or not a string." };
}
const token = rawToken.trim();
if (token.length === 0) {
return { ok: false, error: "Resume token is empty." };
}
function validateIdShapedToken(resumeType: ResumeType, token: string): ResumeTokenValidationOk | ResumeTokenValidationErr {
if (token.length > MAX_TOKEN_LEN) {
return { ok: false, error: `Resume token is too long (max ${MAX_TOKEN_LEN} characters).` };
}
@@ -61,3 +65,49 @@ export function validateResumeToken(
}
return { ok: true, resumeType, token };
}
function validatePiSessionFileToken(token: string): ResumeTokenValidationOk | ResumeTokenValidationErr {
if (token.length > MAX_PI_SESSION_FILE_LEN) {
return { ok: false, error: `Pi session-file token is too long (max ${MAX_PI_SESSION_FILE_LEN} characters).` };
}
if (!token.startsWith("/")) {
return { ok: false, error: "Pi session-file token must be an absolute path (starting with '/')." };
}
if (token.split("/").includes("..")) {
return { ok: false, error: "Pi session-file token must not contain a '..' path segment." };
}
if (!PI_SESSION_FILE_CHARSET_RE.test(token)) {
return {
ok: false,
error: "Pi session-file token contains disallowed characters (allowed: letters, digits, '.', '_', '/', '@', '-').",
};
}
if (!token.endsWith(PI_SESSION_FILE_SUFFIX)) {
return { ok: false, error: `Pi session-file token must end with '${PI_SESSION_FILE_SUFFIX}'.` };
}
return { ok: true, resumeType: "pi_session_file", token };
}
export function validateResumeToken(
runtime: string | null,
rawToken: unknown,
): ResumeTokenValidationOk | ResumeTokenValidationErr {
const resumeType = resumeTypeForRuntime(runtime);
if (!resumeType) {
return {
ok: false,
error: `set-resume-token is not supported for runtime "${runtime ?? "unknown"}" (only claude-code, codex, and pi have resume tokens).`,
};
}
if (typeof rawToken !== "string") {
return { ok: false, error: "Resume token is missing or not a string." };
}
const token = rawToken.trim();
if (token.length === 0) {
return { ok: false, error: "Resume token is empty." };
}
if (resumeType === "pi_session_file") {
return validatePiSessionFileToken(token);
}
return validateIdShapedToken(resumeType, token);
}
+158 -2
View File
@@ -40,6 +40,7 @@ import {
type LockedArtifact,
type NeedsYouBand,
type NeedsYouItem,
type WorkflowRowRef,
type ProofArtifact,
type ReviewMedia,
type ReviewPhase,
@@ -484,6 +485,9 @@ export interface AttentionInput {
unblocks: string | null;
destinationSession: string | null;
closureRequiredAtIso: string | null;
/** OPR.0.4.6.WF4 Q6 — set by the gatherer when the item carries an
* `instance:<id>` workflow-exception tag; carried verbatim to the row. */
workflow?: WorkflowRowRef;
}
export interface AgentInput {
@@ -608,6 +612,9 @@ export function composeNeedsYou(
): NeedsYouBand {
const agentItems: NeedsYouItem[] = attention.map((q) => ({
source: "agent",
// OPR.0.4.6.WF4 Q6 — carry the gatherer's pointer verbatim; OMITTED for
// non-workflow items (byte-identity-by-omission).
...(q.workflow ? { workflow: q.workflow } : {}),
identity: q.qitemId,
summary: q.summary ?? q.qitemId,
leg: q.leg,
@@ -647,6 +654,146 @@ export function composeNeedsYou(
// --- OPR.0.4.4.22 (slice 22): agent-scope exceptions + the rig read root (KEEP) ---
/** OPR.0.4.6.WF5 FR-3 — one recorded workflow-instance view for the ▲
* band. The GATHERER assembles these from recorded state only
* (workflow_instances + queue rows + the WF-1 evaluator verdict + the
* open exception item by tag query); this module derives rows PURELY. */
export interface WorkflowExceptionInput {
instanceId: string;
workflowName: string;
status: string;
currentStepId: string | null;
/** The WF-1 evaluator's verdict state for the frontier ("healthy" or
* the overdue states) + its evidence line, precomposed by the
* gatherer. Never recomputed here (one-threshold-home). */
deadlineState: string;
deadlineEvidence: string | null;
/** True when a frontier id resolves to a NON-OPEN packet (the
* out-of-band corruption state — WF-3 FR-6's guard is the
* prevention; this row is the detection backstop). */
frontierRefsNonOpenPacket: boolean;
/** The OPEN exception item for this instance (tag query), if any. */
openItem: {
qitemId: string;
destinationSession: string;
humanRouted: boolean;
createdAtIso: string | null;
summary: string | null;
} | null;
}
/**
* OPR.0.4.6.WF5 FR-3 — the workflow_instances ▲ source + THE AWARENESS
* CHANNEL. One-count across channels (BR-3): an exception with a live
* HUMAN-routed ● item renders NOTHING here (the item IS the human's
* row); an ORCHESTRATOR-routed ● item renders exactly ONE awareness row
* (same identity, second projection — the human KNOWS at altitude); a
* failed instance with NO item renders the ▲ backstop naming BOTH the
* exception AND the missing-item anomaly (the backstop firing is itself
* evidence of a bug). Healthy instances render zero rows (the band's
* zero-noise negative). Durable-by-derivation: recomputed from recorded
* state on every composition; rows clear on state-exit (recomposition,
* never hand-clearing).
*/
export function deriveWorkflowExceptions(
workflows: WorkflowExceptionInput[],
scopeLabel: string,
nowIso: string,
): NeedsYouItem[] {
const items: NeedsYouItem[] = [];
const push = (
identity: string,
summary: string,
d: DerivedException,
evidenceRef: string | null,
workflow: WorkflowRowRef,
) => {
items.push({
source: "derived",
// OPR.0.4.6.WF4 Q6 — every derived workflow row carries the pointer.
workflow,
identity,
summary,
leg: d.kind,
where: scopeLabel,
ageIso: null,
priority: null,
tier: null,
evidenceRef,
unblocks: null,
qitemId: null,
destinationSession: null,
derived: d,
});
};
for (const w of workflows) {
const trace = `rig workflow trace ${w.instanceId}`;
// OPR.0.4.6.WF4 Q6 — the pointer for every row this instance emits;
// identity derived exactly once, here (never from prose downstream).
const wref: WorkflowRowRef = {
instanceId: w.instanceId,
workflowName: w.workflowName,
...(w.currentStepId ? { stepId: w.currentStepId } : {}),
};
// The anomaly backstop fires regardless of item state — the
// corruption is orthogonal to exception routing.
if (w.frontierRefsNonOpenPacket) {
push(
`${w.instanceId}|anomaly|frontier-non-open`,
`${w.workflowName} instance frontier references a closed packet`,
{
kind: "anomaly",
evidence: `instance ${w.instanceId} frontier points at a non-open packet — out-of-band closure got past the WF-3 close-path guard`,
threshold: "frontier packet must be open",
},
trace,
wref,
);
}
const exceptional =
w.status === "failed" || (w.deadlineState !== "healthy" && w.deadlineEvidence !== null);
if (!exceptional) continue;
const kindLabel = w.status === "failed" ? "failed" : w.deadlineState;
if (w.openItem && w.openItem.humanRouted) {
// The ● item already sits in the human attention legs — a row
// here would double-render (one-count).
continue;
}
if (w.openItem) {
// ORCHESTRATOR-routed: the awareness row — holder + age +
// evidence; awareness, not assignment. Same recorded identity as
// the ● item, projected into the human band.
const ageMin = w.openItem.createdAtIso ? minutesBetween(w.openItem.createdAtIso, nowIso) : null;
push(
`${w.instanceId}|awareness|${w.openItem.qitemId}`,
`awareness: ${w.workflowName} ${kindLabel} — held by ${w.openItem.destinationSession}`,
{
kind: "awareness",
evidence: `exception item ${w.openItem.qitemId} on ${w.openItem.destinationSession}${ageMin !== null ? ` for ${ageMin}m` : ""}${w.openItem.summary ? ` — ${w.openItem.summary}` : ""}`,
threshold: "awareness (orchestrator acting; step in any time)",
},
trace,
wref,
);
continue;
}
// NO item: the ▲ backstop — names the exception AND the anomaly of
// the missing item.
push(
`${w.instanceId}|workflow-${kindLabel}|no-item`,
`${w.workflowName} instance ${kindLabel} with NO exception item`,
{
kind: w.status === "failed" ? "workflow-failed" : "stuck",
evidence: `${w.deadlineEvidence ?? `instance ${w.instanceId} is ${kindLabel} at step ${w.currentStepId ?? "?"}`} · MISSING-ITEM ANOMALY: the never-lost channel produced no item (itself a bug — report it)`,
threshold: w.status === "failed" ? "failed instances carry an exception item" : "past the WF-1 deadline evaluator threshold",
},
trace,
wref,
);
}
return items;
}
/** The third NAMED ▲ heuristic's visible v1 default (slice-22 FR-3:
* too-long-in-state — no transition beyond threshold). Lives HERE because
* compose.ts is the single threshold home (P2 arch note N2): changes land
@@ -710,6 +857,8 @@ export interface RigComposeInputs {
/** The FR-1 roster display window, named on-surface in provenance
* (plan-review ruling: "computed from queue+ps · window: today"). */
rosterWindow: string;
/** OPR.0.4.6.WF5 FR-3: recorded workflow-instance views (optional). */
workflows?: WorkflowExceptionInput[];
nowIso: string;
}
@@ -718,7 +867,10 @@ export interface RigComposeInputs {
export function composeRigAgents(inputs: RigComposeInputs): ComposedRigAgents {
const { nowIso } = inputs;
const scopeLabel = "rig";
const derived = deriveAgentScopeExceptions(inputs.agents, inputs.overdue, scopeLabel, nowIso);
const derived = [
...deriveAgentScopeExceptions(inputs.agents, inputs.overdue, scopeLabel, nowIso),
...deriveWorkflowExceptions(inputs.workflows ?? [], scopeLabel, nowIso),
];
const needsYou = composeNeedsYou(
inputs.attention,
derived,
@@ -799,6 +951,9 @@ export interface SliceComposeInputs {
proofDirExists: boolean;
attention: AttentionInput[];
agents: AgentInput[];
/** OPR.0.4.6.WF5 FR-3: recorded workflow-instance views (optional —
* absent renders byte-identically to pre-WF-5). */
workflows?: WorkflowExceptionInput[];
activeQitemPresent: boolean;
git: GitFacts;
approval: ApprovalFacts;
@@ -894,7 +1049,8 @@ export function composeSliceReview(inputs: SliceComposeInputs): ComposedSliceRev
latestArtifactIso,
inputs.approval.delivery?.at ?? null,
);
const needsYou = composeNeedsYou(inputs.attention, derived, confirmFaithful, "queue+artifacts", nowIso);
const workflowDerived = deriveWorkflowExceptions(inputs.workflows ?? [], sliceRef, nowIso);
const needsYou = composeNeedsYou(inputs.attention, [...derived, ...workflowDerived], confirmFaithful, "queue+artifacts", nowIso);
const agents = composeAgentsBand(inputs.agents, `slice:${slice.name}`, derived, nowIso);
// FR-5: an out-of-slice media ref = a defect finding, never a silent
@@ -0,0 +1,364 @@
// OPR.0.4.6.MH5 — the daemon-side FLEET composer (the ONE net-new core).
//
// Arch Q1 (ruled): each host's OWN composer is authoritative for its own
// time-derived ▲/● set — this module fans out each registered host's
// ALREADY-COMPOSED rig root (`GET /api/review/rig`, the parameterless
// host-wide altitude root) and only UNIONS + HOST-DIMENSIONS + COUNTS.
// It never recomputes exception truth (no clock, no thresholds, no random
// here — cross-host clock skew can never distort a ▲).
//
// Arch Q2/Q3 (ruled): FLEET is a SIBLING aggregate — the local composer
// files stay pure; THIS module is the ONLY review-domain place that
// imports hosts/transport (the C1 boundary, pinned mechanical by the C5
// import-audit test). One daemon-side composer; every consumer (UI now,
// TUI/CLI follow-ups) reads the same one.
//
// Arch Q4 (ruled): the one-count Set on `${hostId}|${identity}` lives HERE
// — the one place. Within-host N-altitude duplicates collapse to one fleet
// row (seenFrom records the altitudes actually read); two hosts' same-shaped
// identities stay two rows. An MH-3-forwarded qitem lives in its ORIGIN
// host's DB only (source closed on handoff), so no fleet double-count by
// construction.
//
// D-7: per-host reads run concurrently under the named read-class deadline
// (the attention-aggregator discipline) — a slow host degrades to its honest
// per-host status; the glance NEVER stalls on the slowest host. The LOCAL
// host joins via the in-process composer directly (D-1 — one fleet member
// with zero self-transport).
import type {
AgentRow,
ComposedFleet,
ComposedFleetRollup,
ComposedRigAgents,
FleetHostRollup,
FleetNeedsYouItem,
FleetSettledRow,
NeedsYouItem,
SettledRow,
} from "./types.js";
import type { PerHostStatus } from "../hosts/fanout-contract.js";
import { LOCAL_HOST_ID } from "../hosts/fanout-contract.js";
import type { HostRegistryLoadResult } from "../hosts/hosts-registry-reader.js";
import { resolveHost } from "../hosts/hosts-registry-reader.js";
import { remoteJsonRequest } from "../hosts/remote-daemon-http.js";
/** The fleet aggregation READ deadline class (per-host bound) — same class
* as the shipped feed aggregation (a poll, not a bootstrap). Named here,
* passed explicitly at the call-site (D-7). */
export const FLEET_READ_TIMEOUT_MS = 5_000;
/** Fixed fan-out cap — a poll across a handful of hosts (the shipped
* aggregator's v1 posture; adaptive throttling out of scope). */
export const FLEET_FANOUT_CONCURRENCY = 4;
// ---------------------------------------------------------------------------
// The PURE union core (exported for the C5 vectors). No clock, no random,
// no I/O — same inputs, byte-identical output, invariant to input order.
// ---------------------------------------------------------------------------
/** One altitude read of a host's composed needs-you rows. Production (D-1)
* supplies exactly one entry per reachable host — its rig root — so
* seenFrom renders `rig`; the union machinery is correct for any scoped
* set (the C5 3-altitude vector feeds slice+mission+rig). */
export interface ScopedNeedsYou {
scope: string;
items: NeedsYouItem[];
}
/** One reachable fleet member's contribution to the union. */
export interface FleetHostInput {
hostId: string;
kind: "local" | "remote";
scopedNeedsYou: ScopedNeedsYou[];
agents: AgentRow[];
settled: SettledRow[];
}
const PRIORITY_RANK: Record<string, number> = { urgent: 0, high: 1, normal: 2, low: 3 };
/** Total order over deduped fleet rows: priority rank, then age, then the
* fleet key — a TOTAL tiebreak, so permuting the inputs can never change
* the output order (the C5 permutation-stability vector). */
function compareFleetRows(a: FleetNeedsYouItem, b: FleetNeedsYouItem): number {
const ra = PRIORITY_RANK[a.priority ?? "normal"] ?? 2;
const rb = PRIORITY_RANK[b.priority ?? "normal"] ?? 2;
if (ra !== rb) return ra - rb;
const aa = a.ageIso ?? "9999";
const ba = b.ageIso ?? "9999";
if (aa !== ba) return aa < ba ? -1 : 1;
return a.fleetKey < b.fleetKey ? -1 : a.fleetKey > b.fleetKey ? 1 : 0;
}
/** The BR-1 session grammar is member@rig (the @host form never exists in
* session strings) — distinct rig names across a host's agent rows is a
* structured parse of that grammar, never prose. */
function distinctRigCount(agents: AgentRow[]): number {
const rigs = new Set<string>();
for (const a of agents) {
const at = a.sessionName.lastIndexOf("@");
if (at > 0 && at < a.sessionName.length - 1) rigs.add(a.sessionName.slice(at + 1));
}
return rigs.size;
}
/** The worst line for a host — spoken-aloud, derived deterministically from
* its own deduped rows (already in total order; row 0 is the worst). */
function hostTopLine(rows: FleetNeedsYouItem[]): string {
if (rows.length === 0) return "quiet";
const worst = rows[0]!;
return worst.derived ? `▲ ${worst.derived.kind} — ${worst.summary}` : `● ${worst.summary}`;
}
/** Union + host-dimension + count (the ONLY things the fleet root does).
* `statuses` must carry EVERY fleet member (omission-proof); `inputs`
* carries only the members whose composed set was actually read. */
export function unionFleet(
inputs: FleetHostInput[],
statuses: PerHostStatus[],
composedAt: string,
registryError?: string,
): ComposedFleet {
// The Q4 one-count Set — the one place. Key = `${hostId}|${identity}`.
const seen = new Map<string, FleetNeedsYouItem>();
const perHostRows = new Map<string, FleetNeedsYouItem[]>();
for (const host of inputs) {
for (const scoped of host.scopedNeedsYou) {
for (const item of scoped.items) {
const fleetKey = `${host.hostId}|${item.identity}`;
const existing = seen.get(fleetKey);
if (existing) {
if (!existing.seenFrom.includes(scoped.scope)) existing.seenFrom.push(scoped.scope);
continue;
}
const row: FleetNeedsYouItem = { ...item, hostId: host.hostId, fleetKey, seenFrom: [scoped.scope] };
seen.set(fleetKey, row);
if (!perHostRows.has(host.hostId)) perHostRows.set(host.hostId, []);
perHostRows.get(host.hostId)!.push(row);
}
}
}
const rows = [...seen.values()].sort(compareFleetRows);
for (const hostRows of perHostRows.values()) hostRows.sort(compareFleetRows);
// Rollup math computed FROM the deduped rows (header-math-checkable).
const byKind = new Map<string, number>();
let needsYouCount = 0;
let exceptionCount = 0;
for (const r of rows) {
if (r.derived) {
exceptionCount += 1;
byKind.set(r.derived.kind, (byKind.get(r.derived.kind) ?? 0) + 1);
} else {
needsYouCount += 1;
}
}
const rollup: ComposedFleetRollup = {
needsYouCount,
exceptionCount,
exceptionsByKind: [...byKind.entries()].sort(([a], [b]) => (a < b ? -1 : 1)).map(([kind, count]) => ({ kind, count })),
hostCount: statuses.length,
unreachableCount: statuses.filter((s) => s.status !== "ok").length,
};
// HOSTS band rows: statuses order (local first, then registry order);
// counts present ONLY on read members (absent-not-zero on failures).
const inputByHost = new Map(inputs.map((h) => [h.hostId, h]));
const hosts: FleetHostRollup[] = statuses.map((status) => {
const input = inputByHost.get(status.hostId);
const base: FleetHostRollup = {
hostId: status.hostId,
kind: status.hostId === LOCAL_HOST_ID ? "local" : "remote",
status,
};
if (!input || status.status !== "ok") return base;
const hostRows = perHostRows.get(status.hostId) ?? [];
const hostKinds = new Map<string, number>();
let hostNeedsYou = 0;
for (const r of hostRows) {
if (r.derived) hostKinds.set(r.derived.kind, (hostKinds.get(r.derived.kind) ?? 0) + 1);
else hostNeedsYou += 1;
}
return {
...base,
needsYouCount: hostNeedsYou,
exceptionsByKind: [...hostKinds.entries()].sort(([a], [b]) => (a < b ? -1 : 1)).map(([kind, count]) => ({ kind, count })),
seatCount: input.agents.length,
rigCount: distinctRigCount(input.agents),
topLine: hostTopLine(hostRows),
};
});
// SETTLED minimal (D-5), host-chipped, deterministic order (closed-at
// desc, then qitem id — a total tiebreak).
const settled: FleetSettledRow[] = inputs
.flatMap((h) => h.settled.map((s) => ({ ...s, hostId: h.hostId })))
.sort((a, b) => (a.closedAtIso !== b.closedAtIso ? (a.closedAtIso > b.closedAtIso ? -1 : 1) : a.qitemId < b.qitemId ? -1 : 1));
const composingCount = statuses.filter((s) => s.status === "ok").length;
return {
rollup,
needsYou: {
items: rows,
provenance: `fleet union of each host's own composed set · counted once per identity+host · ${composingCount}/${statuses.length} hosts composing${rollup.unreachableCount > 0 ? " (a failed host's items are ABSENT, not zero)" : ""}`,
},
hosts,
settled,
settledProvenance: settled.length === 0 ? `0 settled handoffs across ${composingCount} composing hosts` : `today's closed handoffs across ${composingCount} composing hosts`,
...(registryError !== undefined ? { registryError } : {}),
composedAt,
};
}
// ---------------------------------------------------------------------------
// The fan-out shell (the transport-touching part; mirrors the shipped
// attention-aggregator's per-host outcome discipline, G9).
// ---------------------------------------------------------------------------
export interface FleetComposeDeps {
/** D-1: the LOCAL host joins in-process — zero self-transport. */
composeLocalRig: () => ComposedRigAgents;
/** S11's shared reader — the fleet enumerates EVERY registered host. */
loadRegistry: () => HostRegistryLoadResult;
/** Registry presence probe (a missing registry = a single-host operator —
* clean local-only fleet; an unreadable one is surfaced honestly). */
registryExists: () => boolean;
/** View-time fact, passed in — the composer itself is clock-free. */
nowIso: string;
fetchImpl?: typeof fetch;
env?: Record<string, string | undefined>;
readFile?: (path: string) => string;
/** Test override; production uses FLEET_READ_TIMEOUT_MS. */
timeoutMs?: number;
concurrency?: number;
}
interface PerHostOutcome {
status: PerHostStatus;
input: FleetHostInput | null;
}
/** Minimal structural check on a remote's composed payload — a 200 carrying
* the wrong shape degrades to the honest per-host status, never a crash
* and never silently-empty "ok" data. */
function parseComposedRig(payload: unknown): ComposedRigAgents | null {
if (payload === null || typeof payload !== "object") return null;
const p = payload as Record<string, unknown>;
const needsYou = p["needsYou"] as Record<string, unknown> | undefined;
const agents = p["agents"] as Record<string, unknown> | undefined;
if (!needsYou || !Array.isArray(needsYou["items"])) return null;
if (!agents || !Array.isArray(agents["rows"])) return null;
if (!Array.isArray(p["settled"])) return null;
return payload as ComposedRigAgents;
}
async function readHostComposedRig(hostId: string, reg: HostRegistryLoadResult, deps: FleetComposeDeps): Promise<PerHostOutcome> {
if (!reg.ok) {
return { status: { hostId, status: "unreachable", error: reg.error }, input: null };
}
const resolved = resolveHost(reg.registry, hostId);
if (!resolved.ok) {
return { status: { hostId, status: "unreachable", error: resolved.error }, input: null };
}
if (resolved.host.transport !== "http") {
return {
status: { hostId, status: "unsupported-transport", error: `host '${hostId}' is SSH-declared; the fleet composed read requires an http-transport registry entry (url + bearer)` },
input: null,
};
}
const res = await remoteJsonRequest(resolved.host, "/api/review/rig", {
method: "GET",
timeoutMs: deps.timeoutMs ?? FLEET_READ_TIMEOUT_MS,
fetchImpl: deps.fetchImpl,
env: deps.env,
readFile: deps.readFile,
});
if (res.ok) {
const composed = parseComposedRig(res.payload);
if (!composed) {
return { status: { hostId, status: "unreachable", error: "remote /api/review/rig returned a malformed composed payload", failedStep: "remote-command-failed" }, input: null };
}
return {
status: { hostId, status: "ok" },
input: {
hostId,
kind: "remote",
scopedNeedsYou: [{ scope: "rig", items: composed.needsYou.items }],
agents: composed.agents.rows,
settled: composed.settled,
},
};
}
switch (res.kind) {
case "bearer":
return { status: { hostId, status: "auth-failed", error: res.detail, failedStep: "permission-gate" }, input: null };
case "http":
if (res.status === 401 || res.status === 403) {
return { status: { hostId, status: "auth-failed", error: `HTTP ${res.status}${res.detail ? `: ${res.detail}` : ""}`, failedStep: "permission-gate" }, input: null };
}
return { status: { hostId, status: "unreachable", error: `HTTP ${res.status}${res.detail ? `: ${res.detail}` : ""}`, failedStep: "remote-command-failed" }, input: null };
case "timeout":
return {
status: {
hostId,
status: "unreachable",
error: res.phase === "body" ? `read timed out: response headers arrived (HTTP ${res.status}) but the body never completed` : `read timed out after ${deps.timeoutMs ?? FLEET_READ_TIMEOUT_MS}ms`,
failedStep: "remote-daemon-unreachable",
},
input: null,
};
case "network":
return { status: { hostId, status: "unreachable", error: res.detail, failedStep: "remote-daemon-unreachable" }, input: null };
}
}
/** ONE composed fleet: local always included (in-process, D-1), every
* registered host fanned out concurrently under the named deadline (D-7),
* per-host status complete by construction, union deduped on the Q4 key. */
export async function composeFleet(deps: FleetComposeDeps): Promise<ComposedFleet> {
const local = deps.composeLocalRig();
const localInput: FleetHostInput = {
hostId: LOCAL_HOST_ID,
kind: "local",
scopedNeedsYou: [{ scope: "rig", items: local.needsYou.items }],
agents: local.agents.rows,
settled: local.settled,
};
const statuses: PerHostStatus[] = [{ hostId: LOCAL_HOST_ID, status: "ok" }];
const inputs: FleetHostInput[] = [localInput];
if (!deps.registryExists()) {
// No registry file = a single-host operator: a clean local-only fleet
// (the C4 band renders nothing new in this state).
return unionFleet(inputs, statuses, deps.nowIso);
}
const reg = deps.loadRegistry();
if (!reg.ok) {
// The registry EXISTS but cannot be read/parsed: there is no host list
// to attribute per-host statuses to — surface the error honestly at the
// payload level, never a silently-local-only fleet.
return unionFleet(inputs, statuses, deps.nowIso, reg.error);
}
const hostIds = reg.registry.hosts.map((h) => h.id);
const outcomes = new Array<PerHostOutcome>(hostIds.length);
let next = 0;
const cap = Math.max(1, Math.min(deps.concurrency ?? FLEET_FANOUT_CONCURRENCY, Math.max(hostIds.length, 1)));
const workers = Array.from({ length: cap }, async () => {
while (true) {
const i = next;
if (i >= hostIds.length) return;
next += 1;
outcomes[i] = await readHostComposedRig(hostIds[i]!, reg, deps);
}
});
await Promise.all(workers);
// Registry order preserved (deterministic payload).
for (const outcome of outcomes) {
statuses.push(outcome.status);
if (outcome.input) inputs.push(outcome.input);
}
return unionFleet(inputs, statuses, deps.nowIso);
}
+199 -42
View File
@@ -12,6 +12,7 @@ import * as path from "node:path";
import { execFileSync } from "node:child_process";
import YAML from "yaml";
import type Database from "better-sqlite3";
import { sessionMemberLabel } from "../session-name.js";
import type { SliceIndexer, SliceRecord } from "../slices/slice-indexer.js";
import {
composeMissionReview,
@@ -28,9 +29,11 @@ import {
type GitFacts,
type MissionSliceEntry,
type SliceComposeInputs,
type WorkflowExceptionInput,
} from "./compose.js";
import type { AgentsBand, AgentsScope, ComposedMissionReview, ComposedRigAgents, ComposedSliceReview, LockedArtifact, SettledRow } from "./types.js";
import type { AgentsBand, AgentsScope, ComposedMissionReview, ComposedRigAgents, ComposedSliceReview, LockedArtifact, SettledRow, WorkflowRowRef } from "./types.js";
import { composeAgentsBand, composeRigAgents } from "./compose.js";
import { evaluateStepDeadline } from "../workflow-deadline.js";
import type { AgentActivityStore } from "../agent-activity-store.js";
import { isHumanSeatSession } from "../human-route-enforcer.js";
@@ -63,6 +66,33 @@ interface QitemRow {
const ACTIVE_STATES = ["pending", "in-progress", "claimed", "blocked", "handed-off"];
/** OPR.0.4.6.WF4 Q6 — the ● (agent-leg) workflow-identity stamp, derived from
* the item's OWN STRUCTURED TAGS (`workflow:<name>` / `instance:<id>` /
* `step:<id>` — the WF-5-ratified queryable identity), NEVER from summary /
* identity / evidenceRef prose. Returns the pointer only when BOTH required
* keys are present; a non-workflow row (no `instance:`/`workflow:` tag) →
* `undefined`, so its AttentionInput stays byte-identical (omit-when-absent).
* Pointer-only: the three identity keys, never status/deadline/class. */
export function workflowRefFromTags(tagsJson: string | null): WorkflowRowRef | undefined {
if (!tagsJson) return undefined;
let tags: string[];
try {
tags = (JSON.parse(tagsJson) as string[]) ?? [];
} catch {
return undefined;
}
let instanceId: string | undefined;
let workflowName: string | undefined;
let stepId: string | undefined;
for (const t of tags) {
if (t.startsWith("instance:")) instanceId = t.slice("instance:".length);
else if (t.startsWith("workflow:")) workflowName = t.slice("workflow:".length);
else if (t.startsWith("step:")) stepId = t.slice("step:".length);
}
if (!instanceId || !workflowName) return undefined;
return { instanceId, workflowName, ...(stepId ? { stepId } : {}) };
}
export class ReviewGatherer {
private readonly db: Database.Database;
private readonly indexer: SliceIndexer;
@@ -140,6 +170,7 @@ export class ReviewGatherer {
handoffsToday,
overdueCount: overdue.length,
rosterWindow: "today",
workflows: this.gatherWorkflowExceptions(nowIso),
nowIso,
});
}
@@ -194,6 +225,7 @@ export class ReviewGatherer {
proofDirExists: fs.existsSync(path.join(slice.slicePath, "proof")),
attention,
agents,
workflows: this.gatherWorkflowExceptions(this.now()),
activeQitemPresent: this.hasActiveQitem(name, slice),
git: this.gatherGitFacts(frontmatter, candidateRef),
approval,
@@ -323,6 +355,113 @@ export class ReviewGatherer {
/** Human-routed attention rows carrying the given tag (the §5 predicate is
* Packet 1's; until it lands, human-tier/human-dest/park-on-human rows are
* selected with the same shape). */
/** OPR.0.4.6.WF5 FR-3 — recorded workflow-instance views for the ▲
* band: failed + in-flight instances, the WF-1 evaluator verdict
* (consumed, never recomputed — the module import IS the single
* home), the open exception item by TAG QUERY (never summary
* parsing), and the non-open-frontier anomaly check. Read-only;
* absent tables (pre-workflow DBs) return []. */
private gatherWorkflowExceptions(nowIso: string): WorkflowExceptionInput[] {
try {
const instances = this.db
.prepare(
`SELECT instance_id, workflow_name, status, current_step_id, current_frontier_json
FROM workflow_instances WHERE status IN ('failed','active','waiting')`,
)
.all() as Array<{
instance_id: string;
workflow_name: string;
status: string;
current_step_id: string | null;
current_frontier_json: string;
}>;
const now = new Date(nowIso);
const out: WorkflowExceptionInput[] = [];
for (const row of instances) {
let frontier: string[] = [];
try {
frontier = (JSON.parse(row.current_frontier_json) as string[]) ?? [];
} catch {
frontier = [];
}
const packets = frontier.map(
(id) =>
this.db
.prepare(
`SELECT qitem_id, state, destination_session, ts_created, claimed_at, closure_required_at
FROM queue_items WHERE qitem_id = ?`,
)
.get(id) as
| { qitem_id: string; state: string; destination_session: string; ts_created: string; claimed_at: string | null; closure_required_at: string | null }
| undefined,
);
const frontierRefsNonOpenPacket =
(row.status === "active" || row.status === "waiting") &&
packets.some((p) => p && !["pending", "in-progress", "blocked"].includes(p.state));
const verdict = evaluateStepDeadline(
{
instanceId: row.instance_id,
status: row.status,
currentFrontier: frontier,
currentStepId: row.current_step_id,
},
packets.map((p) =>
p
? {
qitemId: p.qitem_id,
state: p.state,
destinationSession: p.destination_session,
tsCreated: p.ts_created,
claimedAt: p.claimed_at,
closureRequiredAt: p.closure_required_at,
}
: null,
),
now,
);
const item = this.db
.prepare(
`SELECT qitem_id, destination_session, tier, ts_created, summary
FROM queue_items
WHERE state IN ('pending','in-progress','blocked')
AND tags LIKE ? AND tags LIKE ?
ORDER BY ts_created DESC LIMIT 1`,
)
.get(`%"instance:${row.instance_id}"%`, `%"workflow-exception"%`) as
| { qitem_id: string; destination_session: string; tier: string | null; ts_created: string; summary: string | null }
| undefined;
out.push({
instanceId: row.instance_id,
workflowName: row.workflow_name,
status: row.status,
currentStepId: row.current_step_id,
deadlineState: verdict.state,
deadlineEvidence: verdict.evidence
? `step ${verdict.evidence.stepId ?? "?"} packet ${verdict.evidence.packetId} held by ${verdict.evidence.ownerSession} — ${verdict.evidence.overdueBySeconds}s past the ${verdict.evidence.anchor} anchor`
: null,
frontierRefsNonOpenPacket,
openItem: item
? {
qitemId: item.qitem_id,
destinationSession: item.destination_session,
humanRouted:
item.tier === "human-gate" || /^human(?:-[A-Za-z0-9._-]+)?@(kernel|host)$/.test(item.destination_session),
createdAtIso: item.ts_created,
summary: item.summary,
}
: null,
});
}
return out;
} catch (err) {
// Pre-workflow DBs (no tables) compose byte-identically to
// pre-WF-5; anything else fails LOUD (the WF-3 narrowed-catch
// lesson — a broad catch here would silently blind the band).
if (err instanceof Error && /no such table/i.test(err.message)) return [];
throw err;
}
}
private attentionForTag(tag: string, excludeTagPrefix?: string): AttentionInput[] {
if (!this.tableExists("queue_items")) return [];
const hasEvidenceRef = this.columnExists("queue_items", "evidence_ref");
@@ -349,19 +488,25 @@ export class ReviewGatherer {
return true;
}
})
.map((r) => ({
qitemId: r.qitem_id,
summary: r.summary,
leg: r.state === "blocked" ? "park-on-human" : "human-routed",
where: r.destination_session,
createdAtIso: r.ts_created,
priority: r.priority,
tier: r.tier,
evidenceRef: r.evidence_ref ?? null,
unblocks: r.state === "blocked" ? r.qitem_id : null,
destinationSession: r.destination_session,
closureRequiredAtIso: r.closure_required_at,
}));
.map((r) => {
// OPR.0.4.6.WF4 Q6 — stamp the ● workflow pointer from the item's own
// tags; OMITTED for non-workflow rows (byte-identity-by-omission).
const workflow = workflowRefFromTags(r.tags);
return {
qitemId: r.qitem_id,
summary: r.summary,
leg: r.state === "blocked" ? "park-on-human" : "human-routed",
where: r.destination_session,
createdAtIso: r.ts_created,
priority: r.priority,
tier: r.tier,
evidenceRef: r.evidence_ref ?? null,
unblocks: r.state === "blocked" ? r.qitem_id : null,
destinationSession: r.destination_session,
closureRequiredAtIso: r.closure_required_at,
...(workflow ? { workflow } : {}),
};
});
}
/** Sessions holding active work on the named slices (null = rig-wide).
@@ -402,7 +547,7 @@ export class ReviewGatherer {
.filter((r) => !!r.blocked_on)
.reduce<QitemRow | null>((acc, r) => (!acc || r.ts_updated > acc.ts_updated ? r : acc), null);
return {
agentName: session.split("@")[0] ?? session,
agentName: sessionMemberLabel(session), // OPR.0.4.6.MH1 FR-8: shared contract
sessionName: session,
runtime: "unknown" as const,
parkedOn: parked?.blocked_on ?? null,
@@ -459,7 +604,7 @@ export class ReviewGatherer {
const recentInputs: AgentInput[] = [...recent.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([session, e]) => ({
agentName: session.split("@")[0] ?? session,
agentName: sessionMemberLabel(session), // OPR.0.4.6.MH1 FR-8: shared contract
sessionName: session,
runtime: "unknown" as const,
parkedOn: null,
@@ -539,19 +684,25 @@ export class ReviewGatherer {
OR (state = 'blocked' AND blocked_on LIKE 'human%'))`,
)
.all(...ACTIVE_STATES) as Array<QitemRow & { evidence_ref?: string | null }>;
return rows.map((r) => ({
qitemId: r.qitem_id,
summary: r.summary,
leg: r.state === "blocked" ? "park-on-human" : "human-routed",
where: r.destination_session,
createdAtIso: r.ts_created,
priority: r.priority,
tier: r.tier,
evidenceRef: r.evidence_ref ?? null,
unblocks: r.state === "blocked" ? r.qitem_id : null,
destinationSession: r.destination_session,
closureRequiredAtIso: r.closure_required_at,
}));
return rows.map((r) => {
// OPR.0.4.6.WF4 Q6 — stamp the ● workflow pointer from the item's own
// tags; OMITTED for non-workflow rows (byte-identity-by-omission).
const workflow = workflowRefFromTags(r.tags);
return {
qitemId: r.qitem_id,
summary: r.summary,
leg: r.state === "blocked" ? "park-on-human" : "human-routed",
where: r.destination_session,
createdAtIso: r.ts_created,
priority: r.priority,
tier: r.tier,
evidenceRef: r.evidence_ref ?? null,
unblocks: r.state === "blocked" ? r.qitem_id : null,
destinationSession: r.destination_session,
closureRequiredAtIso: r.closure_required_at,
...(workflow ? { workflow } : {}),
};
});
}
/** FR-4: today's closed handoffs from the transitions log — the SETTLED
@@ -603,19 +754,25 @@ export class ReviewGatherer {
AND tags LIKE '%"slice:%'`,
)
.all(nowIso) as QitemRow[];
return rows.map((r) => ({
qitemId: r.qitem_id,
summary: r.summary,
leg: "overdue",
where: r.destination_session,
createdAtIso: r.ts_created,
priority: r.priority,
tier: r.tier,
evidenceRef: null,
unblocks: null,
destinationSession: r.destination_session,
closureRequiredAtIso: r.closure_required_at,
}));
return rows.map((r) => {
// OPR.0.4.6.WF4 Q6 — stamp the ● workflow pointer from the item's own
// tags; OMITTED for non-workflow rows (byte-identity-by-omission).
const workflow = workflowRefFromTags(r.tags);
return {
qitemId: r.qitem_id,
summary: r.summary,
leg: "overdue",
where: r.destination_session,
createdAtIso: r.ts_created,
priority: r.priority,
tier: r.tier,
evidenceRef: null,
unblocks: null,
destinationSession: r.destination_session,
closureRequiredAtIso: r.closure_required_at,
...(workflow ? { workflow } : {}),
};
});
} catch {
return [];
}
Loaded 100 of 303 files, more files were not shown because too many files have changed in this diff. Show more