Compare commits

..
Author SHA1 Message Date
Mike Schwarz 75a56e530c fix(context): work-install says when the selected mission doesn't exist (#3141)
rig context work-install --mission <missing> exited 0 with no warning and a missionRoot path that doesn't exist, quietly serving the project's context. A missing mission now adds a warning to the existing warnings list (with --slice, it says the slice wasn't looked up), printed to stderr as "Warning: ..." in text mode, and missionRoot and sliceRoot read null. The exit code, the project-level pieces and every existing mission's and slice's handling are unchanged; a missing slice under an existing mission still refuses with slice_not_found.
2026-11-13 06:47:55 -08:00
Mike Schwarz 1a2b1d5b75 fix(mcp): an external inbox grant's instructions name only the tools it offers (#3140)
An assistant connected through an external grant that works its own inbox was told about rig_whoami, rig_queue_handoff and rig_queue_decide, which no external grant is offered. The shared instruction paragraphs are now built from the tools the connection offers, the same set that decides its tools/list, so an inbox grant's instructions name only tools it can call. Member and front-door instructions are unchanged word for word. The other external instruction branches aren't changed here and still name some tools without checking the grant.
2026-11-13 06:47:27 -08:00
NIKHILA 2ae1df25a4 fix(daemon): recognize aliased terminal seats for delivery (#3074)
A terminal seat addressed by its canonical name, whose tmux session runs under another name, got OpenRig's From/To envelope typed into its shell on the first send: a queued wake typed the handoff envelope and recorded it as verified, and rig send typed the envelope with the text. Both paths now check the seat's physical session name as well as its address, the same two-name check wake recovery already uses, so a queued wake treats it as a terminal seat and a deliberate send delivers the exact text. The regression tests use real SQLite and the production transport and repository with a simulated tmux adapter; they don't run a live tmux-attached terminal.
2026-11-13 06:44:21 -08:00
Mike Schwarz 3d3eb9f09f docs: carry 0.7.13's capability teaching to main (#3116)
The capability guide on main now carries the teaching added for 0.7.13 (runner-answer receipts, check-in status, unclaim and relaunch, handover warnings, stated usage resets and the experimental rig network note, in 0.7.13's published wording) while keeping main's newer lines on relay bindings and skill restore. Its registration paragraph describes the kernel operator's greeting offer instead of the older limitation. The as-built regression log gains the 0.7.13 queue-create latency entry. Docs only.
2026-11-13 05:38:32 -08:00
Leon.C 9efe42b257 Fix #2990: answer replies in a superseded Slack root in their arrival thread (#3120)
When a person replied in an older Slack thread after an agent's parked question had moved to a newer one, the reply carried no thread link, so answering it with --reply-to posted a new top-level message. The thread a reply arrives in is now recorded, and the answer posts back into that thread, including the mapped channel of a person-started thread. The question gate is unchanged: only the conversation's newest thread answers the parked question. An arrival record is trusted only when OpenRig itself wrote it. Two limits: the arrival record is best effort, so if writing it fails it isn't retried and that one answer posts top-level (the person's message is kept); and publication is not atomic, since creating the message and recording the arrival note are separate writes, so a notification can be posted before the note is recorded, with the same top-level outcome for that answer.
2026-11-13 05:35:55 -08:00
Edward Bowie 835526b565 fix(daemon): name rig launch when a recoverable seat can't restart in place (#3131) (#3135)
When rig seat restart --keep-conversation refuses a seat with session_not_live because its tmux session is gone, and OpenRig shows that seat as recoverable, the refusal now names the way back: rig launch '<rig>' '<node>' (add --plan to preview). That launch restores the seat in its saved conversation and leaves the other seats alone. A seat that isn't recoverable gets the same refusal as before, with no guidance, and other session_not_live refusals are unchanged. The hint reads the seat's recoverable state from the inventory entry the command already resolved, so it matches what rig ps shows.
2026-11-13 05:32:52 -08:00
Mike Schwarz 3bb7a410d5 feat(daemon): name a slow node inventory step in the slow-operation log (#3138)
The nodes route (GET /api/rigs/<id>/nodes) now runs each synchronous inventory step through the existing slow-site recorder, timed inside the step's own turn. A step at or above the recorder's slow threshold is logged once, by name: nodes.inventory.build, nodes.inventory.activity_sync (the default read, up to the promise it returns) and nodes.inventory.terminal_work. Steps under the threshold write nothing, and responses are unchanged. A full or refresh read's activity step and the model observation stay unnamed. A record's duration is wall time, and no record doesn't rule out a step that never finished or work outside these steps. This is an observation: it doesn't cure a stall or attribute past ones.
2026-11-13 05:29:48 -08:00
Mike Schwarz 602ba44ff1 docs(plugins): the openrig plugin needs Claude Code 2.1.119 or later (#3137)
The openrig plugin's README now lists Claude Code 2.1.119 or later as a requirement. On earlier versions, while the plugin's OpenRig seat setting is left blank, Claude Code silently drops the plugin's MCP server: the skill loads, but the agent has no OpenRig tools. The plugin manifest's engines field is left unset, because Claude Code reads it only from 2.1.323 and no client is affected today. Docs only.
2026-11-13 02:01:46 -08:00
Mike Schwarz 2d0280dc63 fix(watchdog): keep context watches when tenure is unknown (#3139)
A follow-up to #3027. Whether a context watch's name is still carried no longer depends on OpenRig's best-effort occupant tenure record: any session row or tmux or external binding on an unarchived node counts. So a seat whose tenure wasn't recorded keeps its watch, skipping that tick with the reason recorded, instead of ending it. A removed seat still ends its watch, and so does a name held only by an archived rig.

Known cost: a tenure-less seat on a live rig keeps its watch skipping (one history record per scan) until a tenure is recorded or the seat or rig is removed.
2026-11-13 01:36:53 -08:00
Samuel 5dbc554b76 feat(daemon): bind runner credentials to actual launches (#3060)
A seat generation can span several runner processes, and generation-scoped credentials couldn't tell those runs apart, so a replaced or restored runner's predecessor could keep reporting for the seat. Runner activity grants are now bound to the actual launch attempt, its originating session and the pane or process OpenRig selected, using the existing launch, restore, successor-cutover, stop and observation owners rather than a parallel lifecycle. Explicit same-process adoption keeps the original authority until the existing cutover. A stop, abandonment or positively observed exit revokes the exact grant by id.

When a binding can't be established it fails open: an optional grant failure never blocks a launch or restore, an unreadable or unknown observation never revokes a live run, and an unknown binding reports a retryable unverified result. This attributes reports to the run OpenRig selected. It does not prove a runner's identity, isolate it from the same local user, or add an input or cancel capability. Adds migration 165 beside 166; the migrator applies by name.

Thanks to smota.
2026-11-13 01:34:56 -08:00
billbenetti 93600b92ca fix(daemon): stop counting daemon@kernel and system:* as ceremony coordination (#2889 follow-up) (#3124)
A follow-up to #2889. process.ceremony-amplification also stops counting transitions by OpenRig's own sender: exactly daemon@kernel, and any actor that starts with system:. Wake escalations created by daemon@kernel, queue fallback routing and compaction restore notices no longer lift a handoff family toward the threshold. These transitions leave the count, the basis and the reported transitionIds together. Other kernel seats, such as a person creating rows or a lead closing one, still count, and so do names that only look alike. #2889's exclusions and the threshold are unchanged.

After upgrade, an open finding keeps its id in the normal case; a resumed segment whose first counted transition was an excluded actor is re-keyed once.

Thanks to billbenetti.
2026-11-13 01:16:54 -08:00
Mike Schwarz 53b6c1e253 fix(mcp): say how to see your own team, and how a member without a trigger picks up work (#3104)
Two lines a connected assistant read and repeated wrongly now say what's true. When an assistant on an external grant passes the team's display name as rig_team's rigId, the out-of-scope refusal now adds "Leave rigId out to see this connection's own team.", and rigId's description says a connection from outside the rig usually leaves it out, and that a value it does pass must be the granted rig's name, never its id or the team's display name. A hosted member with no wake trigger is now told that it picks up its work when its person asks it to, or on a schedule they set. No routing or scope change.
2026-11-13 00:28:02 -08:00
Mike Schwarz eae973d9ec fix(compaction): the post-restore audit message matches the restore skill (#3127)
The managed post-restore audit message now matches the restore skill. It asks for the required reads before the seat acts on the restored work, keeps any hold the seat verified as live, and has the read-depth table and note written as the seat resumes rather than holding unaffected work. The required reads are still demanded in full, and the FULL, PARTIAL and NOT_READ accounting is unchanged. Two message strings and three test pins.
2026-11-13 00:22:05 -08:00
Mike Schwarz ed5a47b7af fix(daemon): a context-usage watchdog reads its own seat's occupant, not an archived node's (#3027)
The watchdog engine resolved a target's current occupant generation from the newest session row with that name. An archived generation of the same seat could hold a newer row, so until the seat next started, its context-usage watchdog could read the archived node's transcript and context use and fire a context wake from another node's numbers.

The engine now resolves the name with the delivery guard's resolver, which keeps an unarchived seat over an archived generation, and reads that node's current generation. An external-CLI seat addressed by its external session name resolves through its one unarchived binding. When the name can't be pinned to exactly one seat, the generation reads unknown and the gate fails open: a context watch skips that tick with its reason recorded and stays active while some node still carries the name, and ends only when nothing does. A watch now ends only in cases where it already ended before.
2026-11-13 00:20:36 -08:00
Mike Schwarz b0ff239df6 test(cli): give the front-door and slice-claim spawns room on loaded runners (#3126)
Two CLI tests failed on a loaded hosted runner without a defect: the piped front-door test took 5,060 ms against a 5 s spawn budget, and the four-batch slice-claims test took 30.2 s against a 30 s test timeout. The front-door spawns now get 30 s (with a 40 s test timeout), and each slice-claims create gets 60 s, with batch timeouts sized to match. A process that never exits is still killed and still fails its test, and every assertion is unchanged. If everything hung at once, the CLI job could take about 24 minutes longer before failing.

Test-only: no source change.
2026-11-13 00:18:47 -08:00
Duc 3949c8adb7 fix(terminal): resolve tmux again when the cached path is gone (#3129)
The daemon's terminal service kept the first tmux path it resolved for its whole lifetime, so after that file went away (for example when a version manager rebuilt its shims) every new terminal view used a dead path and its tiles exited with code 126 until a daemon restart. Now the cached path is reused while it exists and resolved again on the daemon's PATH once it doesn't, matching the tmux adapter's own re-lookup. A tmux that was never found stays cached as before, and a failed re-resolution falls back to plain tmux on the pane's PATH. Each view open adds one file-existence check.

Thanks to vanducng.
2026-11-13 00:15:59 -08:00
Mike Schwarz 07ed2ebb7f perf(daemon): read the wake ladder's prompt-refusal candidates from the open rows (#3098)
The wake ladder's prompt-refusal candidate read now starts from the open rows (active items, plus the rows that open prompt alerts name as recovery targets) and uses the existing transition index, instead of scanning the whole transition log on every tick. It returns the same rows, each with its first refusal, in the order the old read returned under its observed plan; that order is now explicit, and it is observed rather than guaranteed. A recovered id keeps its full bytes, including an embedded NUL, and the join stays exact. Malformed tags can't throw.

This removes one repeated scan from the ladder's tick. It is not a measured fix for every daemon stall.
2026-11-13 00:14:14 -08:00
Mike Schwarz db713f34e6 fix(cli): wait longer for the current daemon child to become ready (#3118)
rig daemon start now waits up to 180 s, instead of 20 s, for the daemon child it started to become ready, and prints a progress line about every 20 s while it waits. It still spawns once, a quick start still returns at once, and a child that exits ends the wait. The PID, listener, liveness, publication and reservation checks are unchanged. The TUI's three daemon-start paths now use a shared 199 s budget that covers the wait, one probe, owned-child cleanup and a margin, and the CLI and TUI take the readiness and probe values from one module.

Limits: the deadline bounds scheduling, not synchronous work. A child still not ready at the cap stays pending and unpublished, with its reservation kept. A verified primary listener with only an uncertain secondary still publishes with its existing warning. This doesn't identify or cure the cause of slow starts.
2026-11-12 23:31:49 -08:00
Mike Schwarz 5d7ee4ffb7 docs(daemon): scope the draft-aware gap comments to the tested schedule (#3121)
Two code-comment precisions on #3097's paste-side mitigation. In seat-delivery-guard.ts, what happens when typing lands between the final check and the paste is stated for the tested append-mode schedule only. In tmux.ts, the guard's checks run last, rather than its read being the last wait before the paste.

In the tested append-mode schedule, Enter is refused and nothing is submitted or deleted; this is no guarantee for every interleaving, and #3076 stays open. A held paste can be delivered by a later attempt once the input reads quiet and empty; no automatic replay is promised.

This narrows #3097's merge message, which stated that outcome without the limit. Comments only: no behaviour or test change.
2026-11-12 23:29:12 -08:00
James B. Shields dd02787090 fix(daemon): build Codex resume display from resume inputs and label launch differences (#2989) (#3043)
The Codex resume command shown in node inventory and the node detail panel is now built from the inputs a real resume uses: the member permission envelope, the seat's resolved restore posture and the operational launch argument. It no longer reads the daemon's own OPENRIG_YOLO. A label beside the command says when the current generation's recorded launch differs, when no launch was recorded, or when the saved posture is empty and fallback flags are shown. The display shares launchOverride's decision through resolveLaunchOverrideFromFacts, and restore's posture lookup is unchanged.

Known limit: workspace options that aren't recorded per launch, such as a change to only network access or writable roots, can still read as a match.
2026-11-12 23:21:37 -08:00
117 changed files with 7303 additions and 774 deletions

No files matched your search

+1
View File
@@ -119,3 +119,4 @@ When a regression reaches a release, add one line: date, issue, artery, and whic
- 2026-11-10, F4 (0.7.11, #2662 leg (a)), delivery (draft-aware, under Claude's update banner): beneath Claude's bare update banner, a draft-aware delivery pasted the message and withheld Enter, leaving it in the composer, where 0.7.10 held it. Caught at the cut by a native delivery run and accepted with disclosure, so it reached 0.7.11. Missed by footer-grammar fixtures that had no paste hint beside the update notice. Fixed in #2786 for 0.7.12.
- 2026-11-10, Pi case 1c (0.7.11, #2630), launch (Pi account home): a Pi seat with its own Pi login and a declared absolute `PI_CODING_AGENT_DIR` stopped at launch even with an `auth.json` there, with a misleading missing-account-file error, where 0.7.10 started it. Found at the cut by source and function-level tests, not natively, and accepted with disclosure, so it reached 0.7.11. Not fixed: #2803 (0.7.12) makes the launch error name the real cause, and the stop stands. Scenario owed: a Pi seat with its own login and a declared account home.
- 2026-11-12, a tracked `AGENTS.md` (0.7.12, repaired by #3039), restore and relaunch (Codex guidance blocks): on a Codex seat, a snapshot `rig restore`, and `rig down` followed by `rig up --existing`, each wrote OpenRig's managed guidance blocks back into the person's tracked `AGENTS.md` (249 B to 17,316 B), leaving the worktree modified. Seen at the cut by a native Codex run through restore and `up --existing`, and accepted with disclosure, so it reached 0.7.12: its release text says to check `git status` after either. Snapshot restore was already a disclosed 0.7.11 limit; `up --existing` was newly observed, and whether it regressed isn't established. The check that would tell, the same `up --existing` run on the previous release, hasn't run. #3039 merged after 0.7.13's cut, so the repair is on main for 0.7.14, not in 0.7.13. Scenario owed: a Codex seat in a repository with a tracked `AGENTS.md`, checked with `git status` after a restore and after `up --existing`.
- 2026-11-12, `rig queue create` latency (0.7.13), queue writes (the CLI-to-daemon create path): on one performance host, under a before/after rule frozen ahead of the run (six alternating arms of 100 creates each, at the same offered load), whole `rig queue create` invocations had a median of 304.6 ms against 0.7.12's 276.0 ms, +10.39% (about +29 ms), and the two versions' per-arm medians didn't overlap. The p95 moved from 320.1 to 349.3 ms. Send latency, and memory and CPU under load, stayed within the comparison's unchanged range; the idle-memory comparison was inconclusive. Split at the daemon's insert timestamp, both the part before the insert (CLI start, transport, the daemon's handling before the insert) and the part after it moved, so no single segment carries the change, and the cause is unassigned. Caught at the cut by the release's representative-load performance comparison and accepted with disclosure, so it reached 0.7.13. Missed by the package suites and stub scenarios, which don't time whole invocations against the previous release. Not fixed: 0.7.14's stability comparison times the same path with CLI startup, transport, daemon handling and exit recorded separately. Scenario owed: the same before/after create timing on retained history, on one host at a fixed offered load, with those boundaries recorded.
+27 -1
View File
@@ -10,7 +10,9 @@
> runner is qualified beyond that.
> - **Existing interfaces with author obligations:** launch-result identity, exact resume, first input and model
> handoff. These interfaces do not establish that a particular runner implements them correctly.
> - **Unchanged:** plain community presets and the built-in runtimes (Claude Code, Codex, Pi, OMP, terminal).
> - **Current source:** per-launch credentials for hosted Pi, Pi-RPC presets and declared community runners (section 4).
> This credential does not qualify new native input or cancellation behavior.
> - **Unchanged:** plain community presets and the other built-in runtimes (Claude Code, Codex, OMP, terminal).
>
> **What has run natively, in 0.7.8's release testing, against a Claude Haiku model:**
> - **dsh-seat 0.3.0**, an outside runner for dsh 0.2.1-rc.2 over ACP: launch and first input, useful queue work, a
@@ -127,6 +129,29 @@ immediately before the preset's native command, after OpenRig's launcher assignm
## 4. Activity: report turn boundaries
Managed hosted Pi, Pi-RPC presets and declared community runners can receive an actual-launch credential in
`OPENRIG_ACTIVITY_HOOK_TOKEN`, with their attempt in `OPENRIG_RUN_ID`. Keep this credential in the runner's own
process; remove it from the native agent and tool environment. It is delivered through tmux's private environment,
never as a literal launch argument or launch-result field. A report that supplies `runId` requires this bound
credential; knowing a generation or the install token does not supply run authority.
Issuing or delivering this optional credential never blocks an otherwise supported launch or restore. If the launch
anchor, PID, generation or private credential channel is unavailable, the existing unbound install-token path remains;
that path adds no new run-authority claim and is not attributed to a selected run. A refused bound report stays
refused: it is never silently retried with the install token. If native dispatch already occurred before activation
failed, Core preserves the dispatched outcome and does not replay input; cleanup addresses only that launch's grant.
Unreadable observations cannot revoke a grant. Confirmed exit/replacement or intentional lifecycle cleanup still can.
Core activates a pending credential after dispatch against its selected session, pane and recorded runner. A staged
successor waits for the existing binding cutover; pending posts receive `activity_grant_pending` and must retry.
An unreadable binding is `activity_grant_unverified` (409, retryable): it could not be verified now, not proven stale.
Nothing is recorded and the grant is retained; retry when its binding can be checked again.
Failed launches revoke their own credential. Relaunch replaces the active run; an explicit no-launch adoption can
retain it only with the original concrete pane/process evidence and an adoption-only tenure continuation. These
checks bind a runner's reports to its launch; they do not independently verify its claimed native identity. Existing
host hooks, contained guest grants and install-token Pi usage keep their legacy behavior. Pi usage also accepts a
current bound Pi credential. No native input channel or cancellation capability is implied by this credential.
Post each event to `POST <base>/api/activity/hooks` with `Authorization: Bearer <token>`.
- **Finding the base and token:** take them from `OPENRIG_URL` (or `OPENRIG_PORT`, with `OPENRIG_HOST` defaulting to
`127.0.0.1`) and `OPENRIG_ACTIVITY_HOOK_TOKEN`, or else from `$OPENRIG_HOME/activity-endpoint.json`.
@@ -401,6 +426,7 @@ answer. Only the seat's opt-in refuses and holds attention.
| `stale_generation` | 409 | With the activity token: the post isn't from the seat's current occupant. | answers `cancelled` |
| `activity_grant_wrong_seat` | 403 | The grant speaks for another seat. | answers `cancelled` |
| `activity_grant_stale` | 409 | The grant's launch generation no longer occupies the seat. | answers `cancelled` |
| `activity_grant_unverified` | 409 | The binding could not be verified now; this is retryable, not evidence of replacement or cancellation. | permits under the existing unavailable-answer policy |
| `activity_grant_revoked` | 401 | The grant was revoked while its launch generation still occupies the seat, or before it has registered, so this may still be the live request; a replaced occupant's grant reads `activity_grant_stale` instead, even once revoked. | permits |
| `activity_grant_pending` | 409 | The grant's launch hasn't registered its occupant yet. | permits |
| `duplicate_request` | 409 | This request id was already submitted in another turn; an agent may reuse an id. | permits |
+5 -3
View File
@@ -92,8 +92,9 @@ warning. Enabled diagnosis policy admits one bounded packet even though the
product denominator is unknown. Ordinary unavailable/stale detector records
remain ineligible. Diagnosis and human-notification traffic are excluded from
the numerator and cannot recursively generate another investigation. So is the
daemon's own bookkeeping: transitions by system actors (`*@system` and the
bare `system` of claim handover) and daemon notes recorded under any actor
daemon's own bookkeeping: transitions by system actors (`*@system`, the
bare `system` of claim handover, `daemon@kernel` and every `system:*` actor;
other kernel seats such as `human@kernel` still count) and daemon notes recorded under any actor
(`auto-unparked:`, `blocker custody moved `, `park wake `, `parked-owner `).
The reported `transitionIds` are the counted transitions.
@@ -143,7 +144,8 @@ explicitly linked project or mission work resolves a scope. Its bounds: at most
qitems, 1,000 members / 10,000 transitions per family, 200 workflow
receipts, 200 declared slices, and 100 proof files per selected slice. Overflow
refuses visibly. When more than 200 families (roots) were touched, it evaluates the
200 with the most transitions in the window, then by lineage ID, and reports the
200 with the most transitions in the window (every transition, daemon bookkeeping
included, which bounds the counted ones), then by lineage ID, and reports the
result as partial coverage with the total and omitted counts. Omitted families
were not evaluated, so having no finding for them is not a healthy verdict.
Context files are bounded to 64 KiB. A lineage beginning before
+3 -1
View File
@@ -72,6 +72,7 @@ export interface NodeEntry {
lifecycleState?: "running" | "detached" | "recoverable" | "attention_required";
tmuxAttachCommand: string | null;
resumeCommand: string | null;
resumeCommandLabel?: string | null;
latestError: string | null;
/** Slice 15 — `terminal-active` primitive. true=producing output,
* false=silent past threshold, null=no signal. NEVER derived from
@@ -231,6 +232,7 @@ const ALLOWED_NODE_FIELDS = new Set([
"lifecycleState",
"tmuxAttachCommand",
"resumeCommand",
"resumeCommandLabel",
"latestError",
"terminalActive",
"hasAssignedWork",
@@ -957,7 +959,7 @@ latestSnapshot.
--fields accepts (node-level, with --nodes): rigId, rigName, logicalId, podId,
podNamespace, hostSelfId, canonicalSessionName, nodeKind, runtime, communityHarness, launchWrapper, model, sessionStatus,
intentionallyStopped, startupStatus, restoreOutcome, lifecycleState, tmuxAttachCommand,
resumeCommand, latestError, terminalActive, hasAssignedWork,
resumeCommand, resumeCommandLabel, latestError, terminalActive, hasAssignedWork,
assignedWorkCount, pendingWorkCount, inProgressWorkCount, blockedWorkCount,
agentActivity, needsAttention, arbitratedNeedsInput, needsInputEvidence,
contextUsage, heldReason, startedWithoutRequired, storedSessionStatus, identityVerdict.
+2 -2
View File
@@ -1402,8 +1402,8 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
.option("--blocked-on <blocker>", "For state=blocked: the blocker — a qitem id (must exist and be live), a human seat (FR-6 park; requires summary + evidence_ref), or a typed non-qitem gate 'fold:<what>' / 'auth:<what>' / 'external:<what>'")
.option("--wake-watchdog <jobId>", "For state=blocked: attach an existing live watchdog id targeting the row owner")
.option("--wake-after <duration>", "For state=blocked: atomically arm a timer (for example 90s, 15m, 2h)", wakeDurationSeconds)
.option("--summary <text>", "Summary persisted onto the item, with or without a state change (a blank one isn't stored)")
.option("--evidence-ref <path>", "Pointer to the durable artifact, persisted onto the item with a state change (a park, a done close; a blank one isn't stored). For a code result, a JSON file of kind openrig.code-result/v1 (repo, base and revision as full commit ids, paths) lets a reader diff it exactly")
.option("--summary <text>", "Summary persisted onto the item (human-seat parks, or standalone summary update without --state)")
.option("--evidence-ref <path>", "Pointer to the durable artifact, persisted onto the item on a human-seat park or a done close (--state done). For a code result, a JSON file of kind openrig.code-result/v1 (repo, base and revision as full commit ids, paths) lets a reader diff it exactly")
.option("--note <text>", "Transition note for the audit log")
.addHelpText("after", `
After reading an existing remote successor, record its exact closure target, including the host:
+10 -5
View File
@@ -5,7 +5,7 @@ import { DaemonClient, ForeignLocalDaemonError, connectionErrorCode, formatDaemo
import { blockedConnectionGuidance, daemonMayAnswer, isBlockedConnectionCode } from "./daemon-reachability.js";
export { daemonMayAnswer } from "./daemon-reachability.js";
import type { DaemonStartLock } from "./daemon-start-lock.js";
import { DAEMON_STOP_WAIT_MS, DAEMON_SHUTDOWN_RECEIPT, type DaemonShutdownReceipt } from "@openrig/daemon/daemon-shutdown";
import { DAEMON_START_READINESS_WAIT_MS as STARTUP_READINESS_DEADLINE_MS, DAEMON_START_PROBE_TIMEOUT_MS as STARTUP_PROBE_TIMEOUT_MS, DAEMON_STOP_WAIT_MS, DAEMON_SHUTDOWN_RECEIPT, type DaemonShutdownReceipt } from "@openrig/daemon/daemon-shutdown";
import { ConfigStore } from "./config-store.js";
import { OPENRIG_HOME, LEGACY_RIGGED_HOME, readOpenRigEnv, readOpenRigUrl } from "./openrig-compat.js";
// Types only: the module itself (with the daemon's yaml-reading system world) is imported where an
@@ -297,7 +297,7 @@ const DEFAULT_DB = "openrig.sqlite";
// bounded, but do not kill a healthy daemon during first-boot slack.
// #1786: one elapsed deadline, started when the readiness loop begins (after the
// child is spawned and its handlers are attached), covers every primary probe,
// listener check and backoff (the 20s the old 80 x 250ms delays gave). Each probe
// listener check and backoff in one 180-second owned-child wait. Each probe
// may take up to STARTUP_PROBE_TIMEOUT_MS, so a busy child that answers correctly
// in a second is accepted, but no probe timer outlives the deadline (a 2s startup
// ceiling, not a universal header wait). The deadline only stops new probes and
@@ -305,8 +305,7 @@ const DEFAULT_DB = "openrig.sqlite";
// answer already in hand when the clock passes it is still used, with the usual
// liveness and publication rechecks. A failed start may add up to
// DAEMON_STOP_WAIT_MS of owned-child cleanup.
const STARTUP_READINESS_DEADLINE_MS = 20_000;
const STARTUP_PROBE_TIMEOUT_MS = 2_000;
const STARTUP_PROGRESS_INTERVAL_MS = 20_000;
const HEALTHZ_DELAY_MS = 250;
// OPR.0.4.7 slice-05 item-4: the WHOLE status health probe stays within ONE normal
// CLI request window (~5s, matching DaemonClient's timeoutMs) — immediate connection
@@ -859,7 +858,9 @@ async function startOwnedDaemon(opts: StartOptions, deps: LifecycleDeps, lock: D
let databaseWarnings: string[] = [];
let lastProbe = "health endpoint not responding";
let unverified: string[] | null = null;
deadline = Date.now() + STARTUP_READINESS_DEADLINE_MS;
const readinessStartedAt = Date.now();
deadline = readinessStartedAt + STARTUP_READINESS_DEADLINE_MS;
let nextProgressAt = readinessStartedAt + STARTUP_PROGRESS_INTERVAL_MS;
while (remaining() > 0) {
unverified = null;
try {
@@ -891,6 +892,10 @@ async function startOwnedDaemon(opts: StartOptions, deps: LifecycleDeps, lock: D
if (error instanceof StartupIdentityError) throw error;
lastProbe = error instanceof Error ? error.message : String(error);
}
if (remaining() > 0 && Date.now() >= nextProgressAt) {
console.error(`Waiting for daemon child ${pid} readiness (${Math.floor((Date.now() - readinessStartedAt) / 1000)}s elapsed; ${STARTUP_READINESS_DEADLINE_MS / 1000}s limit). This start has not published readiness.`);
nextProgressAt = Date.now() + STARTUP_PROGRESS_INTERVAL_MS;
}
await Promise.race([new Promise((resolve) => setTimeout(resolve, Math.min(HEALTHZ_DELAY_MS, remaining()))), failed]);
}
// In the last round the child's own endpoint named its PID, and only another listener's check stayed
+6 -3
View File
@@ -412,9 +412,12 @@ export function resolveWorkPosition(opts: {
let sliceName: string | null = null;
let frontier: WorkInstallAltitude = "project";
if (opts.mission !== undefined) {
missionRoot = join(missionsRoot, opts.mission);
if (existsSync(missionRoot)) {
const canonicalMissionRoot = canonicalExisting(missionRoot);
const nominalMissionRoot = join(missionsRoot, opts.mission);
if (!existsSync(nominalMissionRoot)) {
// Not a refusal: the project's context still serves. Say so, and never report a folder that doesn't exist.
warnings.push(`mission '${opts.mission}' not found under ${missionsRoot}${opts.slice !== undefined ? `, so slice '${opts.slice}' wasn't looked up` : ""}; using the project's context`);
} else {
const canonicalMissionRoot = canonicalExisting(nominalMissionRoot);
if (!canonicalMissionRoot || !inside(projectRoot, canonicalMissionRoot)) {
return failure("mission_root_escape", `mission '${opts.mission}' resolves outside the selected project root`);
}
+28 -14
View File
@@ -632,12 +632,23 @@ export const MEMBER_WORKFLOW = [
// Short guidance a client may show the model (the server's MCP instructions). An external caller's connection asks the
// team for work and isn't one of its members, so it isn't given the member workflow.
const SHARED_INSTRUCTIONS = [
"This server reaches an OpenRig team as one seat: the one named by OPENRIG_SESSION_NAME where the server was configured, or, over authenticated HTTP, the seat of the caller's grant (rig_whoami shows which, and whether the caller is authenticated; rig_team shows a rig's seats and its lead). The daemon attributes this server's writes to that seat.",
"To hand work to a seat, use rig_queue_create and keep its qitemId; read the answer later with rig_queue_show, which names the next item when the work was handed on. To pass on work this seat holds, use rig_queue_handoff rather than closing it and creating another, so the work stays one chain. Queue items are durable: they outlive this conversation and this server.",
"Tools marked read-only change nothing. A seat whose activity is unknown is not idle, and an accepted request (a restore's attemptId) is not a finished one.",
"A question reserved for a person is theirs to answer: don't answer it on their behalf. rig_queue_decide records a decision only inside the decision room of the mode they have on, beside their answers and never as one.",
];
/** The paragraphs every seat's connection shares, naming a tool only where `has` says the connection offers it. A local or
* authenticated member is offered every tool named here; an external grant is offered only the ones its grant names. */
function sharedInstructions(has: (tool: string) => boolean): string[] {
const finding = [has("rig_whoami") ? "rig_whoami shows which, and whether the caller is authenticated" : "",
has("rig_team") ? "rig_team shows a rig's seats and its lead" : ""].filter(Boolean).join("; ");
const showing = "rig_queue_show, which names the next item when the work was handed on";
const handing = has("rig_queue_create")
? `To hand work to a seat, use rig_queue_create and keep its qitemId${has("rig_queue_show") ? `; read the answer later with ${showing}` : ""}. `
: has("rig_queue_show") ? `Read an item's answer with ${showing}. ` : "";
return [
`This server reaches an OpenRig team as one seat: the one named by OPENRIG_SESSION_NAME where the server was configured, or, over authenticated HTTP, the seat of the caller's grant${finding ? ` (${finding})` : ""}. The daemon attributes this server's writes to that seat.`,
`${handing}${has("rig_queue_handoff") ? "To pass on work this seat holds, use rig_queue_handoff rather than closing it and creating another, so the work stays one chain. " : ""}Queue items are durable: they outlive this conversation and this server.`,
"Tools marked read-only change nothing. A seat whose activity is unknown is not idle, and an accepted request (a restore's attemptId) is not a finished one.",
`A question reserved for a person is theirs to answer: don't answer it on their behalf.${has("rig_queue_decide") ? " rig_queue_decide records a decision only inside the decision room of the mode they have on, beside their answers and never as one." : ""}`,
];
}
const SHARED_INSTRUCTIONS = sharedInstructions(() => true);
const INSTRUCTIONS = [...SHARED_INSTRUCTIONS, MEMBER_WORKFLOW].join("\n");
/** rig mcp serve --front-door: the seat is a person's agent in their own app, the front door to their team, not a member
@@ -671,15 +682,18 @@ const EXTERNAL_PERSON_INSTRUCTIONS = [
"To answer one, show the person the question and its options as rig_question_list gives them. Then pass rig_question_answer its qitemId, the revision you showed, and either the options they chose or exactly what they typed, with no label or words of your own. If the question changed, show them the new one; if it was already answered or closed, say so. It's recorded as their answer, given through this connection. A question reserved for a person is theirs to answer, never yours.",
].join("\n");
/** For an external grant that names claim and update: how it works the items addressed to it, messages included. */
const EXTERNAL_INBOX_INSTRUCTIONS = [...SHARED_INSTRUCTIONS,
"Items addressed to your seat are yours: rig_queue_list with owned set lists them. For each, claim it (rig_queue_claim), do it, then close it done with closure reason no-follow-on and the answer in resolution (rig_queue_update); the sender reads that answer on the same item. A message is an item too: read it and close it done, with your reply in resolution when it needs one; its sender reads that reply on the same item. Nothing promises to wake you, so check your items again.",
].join("\n");
/** For an external grant that names claim and update: how it works the items addressed to it, messages included. It names
* only the tools that grant offers. */
function externalInboxInstructions(tools: ReadonlySet<string>): string {
return [...sharedInstructions((tool) => tools.has(tool)),
`Items addressed to your seat are yours${tools.has("rig_queue_list") ? ": rig_queue_list with owned set lists them" : ""}. For each, claim it (rig_queue_claim), do it, then close it done with closure reason no-follow-on and the answer in resolution (rig_queue_update); the sender reads that answer on the same item. A message is an item too: read it and close it done, with your reply in resolution when it needs one; its sender reads that reply on the same item. Nothing promises to wake you, so check your items again.`,
].join("\n");
}
function instructionsFor(access: McpAccess | undefined, frontDoor = false): string {
if (!access?.external) return frontDoor ? FRONT_DOOR_INSTRUCTIONS : INSTRUCTIONS;
const tools = externalTools(access.external);
const instructions = tools.has("rig_queue_claim") && tools.has("rig_queue_update") ? EXTERNAL_INBOX_INSTRUCTIONS
const instructions = tools.has("rig_queue_claim") && tools.has("rig_queue_update") ? externalInboxInstructions(tools)
: tools.has("rig_question_list") || tools.has("rig_question_answer") ? EXTERNAL_PERSON_INSTRUCTIONS : EXTERNAL_INSTRUCTIONS;
return tools.has("rig_team") ? `Discover the team this grant connects you to by calling rig_team without rigId. A sole granted rig is selected automatically; with several, it returns the granted names to choose from.\n${instructions}` : instructions;
}
@@ -1675,7 +1689,7 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
resolution: z.string().optional().describe("The outcome the item keeps, stored only by the update that closes an open item (as rig queue update --resolution)"),
reopen: z.boolean().optional().describe("Reopen a closed item: give an active state (pending, in-progress or blocked) and a note (as rig queue update --reopen)"),
summary: z.string().optional().describe("Replace the one-line summary"),
evidenceRef: z.string().optional().describe("Pointer to the durable artifact, stored on the item with a state change (a park, a done close; a blank one isn't stored). For a code result, a JSON file of kind openrig.code-result/v1 with repo, base and revision (full commit ids) and paths lets a reader diff it exactly"),
evidenceRef: z.string().optional().describe("Pointer to the durable artifact, stored on the item when closing with state done or parking on a human seat; other updates refuse it. For a code result, a JSON file of kind openrig.code-result/v1 with repo, base and revision (full commit ids) and paths lets a reader diff it exactly"),
blockedOn: z.string().optional().describe("What it waits on, when blocking: another qitemId (this item returns to pending when that one closes), or the address of the person this item came from, not a shared channel (they're asked with this item's summary, and their answer returns it to you in progress)"),
wakeAfterSeconds: z.number().int().positive().optional().describe("Wake this seat after this many seconds, when blocking. Delivered only to a seat with a terminal: an attached app can't receive it today, so the item just waits until it is checked."),
}),
@@ -1823,7 +1837,7 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
{
...worded("rig_team", "Show a rig's team. For an external grant, omit rigId to use its sole rig or see its granted names to choose from. The answer includes its lead, the rig's one delegates_to root (the seat that delegates to others and that nothing delegates to: the seat to delegate to), and each seat's pod, role, runtime, delegation (reportsTo, delegatesTo) and activity as the daemon reports it. Activity the daemon doesn't know reads unknown, never idle. hostedMembers are the rig's members that live outside a terminal (roster entries marked hosted): their authored capabilities and limits, with no pane and activity unknown.", READ_ONLY),
inputSchema: z.object({
rigId: z.string().optional().describe("Rig name or identifier; optional for an external grant to discover its team"),
rigId: z.string().optional().describe("Rig name or id. A connection from outside the rig (an external grant) usually leaves this out, and the tool finds its team; if it passes one, it must be the granted rig's name, never its id or the team's display name."),
}),
},
async ({ rigId }) => {
@@ -1840,7 +1854,7 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
rigId = rigs[0]!;
}
// An external caller names one of its grant's rigs, so a lookup never answers about another workspace's rig.
if (access?.external && !scope?.rig(rigId)) return outOfScope(`Rig ${rigId}`);
if (access?.external && !scope?.rig(rigId)) return outOfScope(`Rig ${rigId}`, "Leave rigId out to see this connection's own team.");
const rig = await rigIdFor(client, rigId);
if ("content" in rig) return rig;
if (inferred && !rig.resolved) {
@@ -42,7 +42,7 @@ it.each(["0", "person-terminal"])("real attach -> returned env -> actual From ->
const db = createDb(); migrate(db, ALL_MIGRATIONS);
const rigs = new RigRepository(db), pods = new PodRepository(db), sessions = new SessionRegistry(db), bus = new EventBus(db);
const rig = rigs.createRig("sample-team"), person = rigs.addNode(rig.id, "person.agent", { runtime: "terminal" });
const operator = rigs.addNode(rig.id, "operator.agent", { runtime: "terminal" });
const operator = rigs.addNode(rig.id, "operator.agent", { runtime: "claude-code" });
sessions.updateBinding(operator.id, { tmuxSession: "operator-physical", tmuxPane: "%43" }); sessions.registerClaimedSession(operator.id, "operator-physical");
const writes: Array<{ target: string; pane: string; text: string }> = [];
const tmux = { hasSession: async () => true, probeSession: async () => ({ state: "present" }),
@@ -92,7 +92,7 @@ it.each(["0", "person-terminal"])("real attach -> returned env -> actual From ->
vi.stubEnv("OPENRIG_SESSION_NAME", "operator-agent@sample-team"); vi.stubEnv("OPENRIG_NODE_ID", operator.id);
await run(sendCommand(deps), ["send", from, "A reply to the actual From address.", "--json"]);
expect(writes[1]).toMatchObject({ target: physical, pane: "%42" });
expect(writes[1]?.text).toContain("A reply to the actual From address.");
expect(writes[1]?.text).toBe("A reply to the actual From address.");
expect(sessions.getBindingForNode(person.id)?.tmuxSession).toBe(physical);
expect(sessions.getSessionsForRig(rig.id).filter(s => s.nodeId === person.id).map(s => s.sessionName)).toEqual([physical]);
expect(rigs.getRig(rig.id)?.nodes.find(n => n.id === person.id)?.label).toBe("Taylor");
@@ -718,6 +718,32 @@ id: OPR.0.5.8.13
});
});
it("warns and keeps the project's context when the selected mission doesn't exist, with or without --slice", async () => {
const missionsRoot = join(alphaRoot, "missions");
for (const slice of [undefined, "01-live-work"]) {
const result = await captureLogs(async () => {
await makeCommand().parseAsync([
"node", "rig", "context", "work-install",
"--project", "alpha", "--mission", "release-0.7.13", ...(slice ? ["--slice", slice] : []), "--json",
]);
});
expect(result.exitCode).toBeUndefined();
const plan = JSON.parse(result.logs.join("")) as {
position: { mission: string; missionRoot: string | null; sliceRoot: string | null; frontier: string };
pieces: Array<{ altitude: string }>;
warnings: string[];
};
expect(plan.position).toMatchObject({ mission: "release-0.7.13", missionRoot: null, sliceRoot: null, frontier: "project" });
expect(plan.pieces.every((piece) => piece.altitude === "project")).toBe(true);
expect(plan.warnings).toContain(`mission 'release-0.7.13' not found under ${missionsRoot}${slice ? `, so slice '${slice}' wasn't looked up` : ""}; using the project's context`);
}
const text = await captureLogs(async () => {
await makeCommand().parseAsync(["node", "rig", "context", "work-install", "--project", "alpha", "--mission", "release-0.7.13"]);
});
expect(text.exitCode).toBeUndefined();
expect(text.errLogs).toContain(`Warning: mission 'release-0.7.13' not found under ${missionsRoot}; using the project's context`);
});
it("uses explicit mission composition as the strict slice-selection boundary", async () => {
const missionRoot = join(alphaRoot, "missions", "alpha-active");
writeFileSync(join(missionRoot, "slices", "01-live-work", "slice.yaml"), `schema: openrig.slice/v0alpha1
@@ -6,6 +6,8 @@ import os from "node:os";
import path from "node:path";
import { startDaemon, getDaemonStatus, STATE_FILE, type LifecycleDeps } from "../src/daemon-lifecycle.js";
import { acquireDaemonStartLock, currentBootId } from "../src/daemon-start-lock.js";
// Resolve the startup import before a fake clock can finish with no timer yet scheduled.
import "@openrig/daemon/instance-initialization";
import { createProcessLiveness, readProcessState } from "../src/commands/daemon.js";
afterEach(() => vi.useRealTimers());
@@ -117,13 +119,78 @@ describe("child-bound startup", () => {
expect((outcome as Error).message).toMatch(/identity mismatch.*observed 888/);
expect(f.files.has(STATE_FILE)).toBe(false);
});
it("keeps waiting for the same child after 20 seconds and publishes its verified readiness at 45 seconds", async () => {
vi.useFakeTimers();
const progress = vi.spyOn(console, "error").mockImplementation(() => {});
try {
const f = fixture(); const fetch = f.deps.fetch; const began = Date.now();
f.deps.fetch = async (url) => {
const response = await fetch(url);
if (Date.now() - began < 45_000) throw Object.assign(new Error("still initializing"), { code: "ECONNREFUSED" });
return response;
};
const pending = startDaemon(opts, f.deps).catch((error: Error) => error);
await vi.advanceTimersByTimeAsync(25_000);
expect(f.files.has(STATE_FILE)).toBe(false);
expect(f.deps.spawn).toHaveBeenCalledTimes(1);
expect(f.lock.recordChild).toHaveBeenCalledWith(f.child.pid);
expect(f.lock.release).not.toHaveBeenCalled();
expect(progress).toHaveBeenCalledWith(expect.stringContaining("Waiting for daemon child 12345 readiness"));
await vi.runAllTimersAsync();
const outcome = await pending;
expect(outcome instanceof Error ? outcome.message : outcome.pid).toBe(f.child.pid);
expect(Date.now() - began).toBe(45_000);
expect(JSON.parse(f.files.get(STATE_FILE)!)).toMatchObject({ pid: f.child.pid, port: opts.port, host: opts.host });
expect(f.deps.spawn).toHaveBeenCalledTimes(1);
expect(f.lock.release).toHaveBeenCalledWith(false);
expect(f.deps.kill).not.toHaveBeenCalled();
expect(progress.mock.calls.length).toBeLessThanOrEqual(3);
} finally { progress.mockRestore(); }
});
it("stops on a child exit after the old deadline instead of waiting out the new cap", async () => {
vi.useFakeTimers();
const progress = vi.spyOn(console, "error").mockImplementation(() => {});
try {
const f = fixture(); const fetch = f.deps.fetch;
f.deps.fetch = async (url) => (await fetch(url), new Promise(() => {}));
setTimeout(() => { f.child.exitCode = 1; f.child.emit("exit", 1, null); }, 35_000);
const { outcome, elapsed } = await settle(f);
expect((outcome as Error).message).toMatch(/exited/);
expect(elapsed).toBe(35_000);
expect(f.files.has(STATE_FILE)).toBe(false);
expect(f.deps.spawn).toHaveBeenCalledTimes(1);
expect(f.deps.kill).not.toHaveBeenCalled();
expect(f.lock.release).toHaveBeenCalledWith(false);
} finally { progress.mockRestore(); }
});
it("keeps a quick verified start immediate without a waiting progress line", async () => {
vi.useFakeTimers();
const progress = vi.spyOn(console, "error").mockImplementation(() => {});
try {
const f = fixture(); const { outcome, elapsed } = await settle(f);
expect(outcome instanceof Error ? outcome.message : outcome.pid).toBe(f.child.pid);
expect(elapsed).toBe(0);
expect(f.deps.spawn).toHaveBeenCalledTimes(1);
expect(progress).not.toHaveBeenCalled();
} finally { progress.mockRestore(); }
});
it("returns pending within one readiness deadline without stopping its still-starting child", async () => {
vi.useFakeTimers();
const f = fixture(); const fetch = f.deps.fetch;
f.deps.fetch = async (url) => (await fetch(url), new Promise(() => {}));
const probeStarts: number[] = [];
const began = Date.now();
f.deps.fetch = async (url) => {
await fetch(url);
probeStarts.push(Date.now());
return new Promise(() => {});
};
const { outcome, elapsed } = await settle(f);
expect(elapsed).toBe(180_000);
expect(probeStarts.length).toBeGreaterThan(60);
expect(probeStarts.every(at => at < began + 180_000)).toBe(true);
expect(probeStarts.slice(1).every((at, i) => at - probeStarts[i]! <= 2_250)).toBe(true);
expect((outcome as Error).message).toMatch(/Last probe: .*timed out|Last probe: .*healthz/);
expect(elapsed).toBeLessThanOrEqual(20_000);
expect(elapsed).toBeLessThanOrEqual(180_000);
expect(f.files.has(STATE_FILE)).toBe(false);
expect(f.deps.kill).not.toHaveBeenCalled();
expect(f.lock.release).toHaveBeenCalledWith(true);
@@ -137,7 +204,7 @@ describe("child-bound startup", () => {
const fetch = f.deps.fetch; const urls: string[] = [];
f.deps.fetch = async (url) => {
const res = await fetch(url);
if (urls.push(url) - 1 === at) vi.setSystemTime(Date.now() + 21_000);
if (urls.push(url) - 1 === at) vi.setSystemTime(Date.now() + 181_000);
if (secondary && url.includes("127.0.0.2")) throw new Error("timeout");
return res;
};
@@ -183,7 +250,7 @@ describe("child-bound startup", () => {
f.deps.fetch = async (url) => (await fetch(url), calls++ % 2 === 0 ? { ok: true, json: async () => f.body } : new Promise(() => {}));
const { outcome, elapsed } = await settle(f);
expect((outcome as Error).message).toMatch(/listener verification is incomplete.*reservation retained/);
expect(elapsed).toBeLessThanOrEqual(20_000);
expect(elapsed).toBeLessThanOrEqual(180_000);
expect(f.files.has(STATE_FILE)).toBe(false);
expect(f.deps.kill).not.toHaveBeenCalled();
});
@@ -433,7 +500,7 @@ it.each(["missing", "stale"] as const)("keeps a slow child discoverable with %s
expect(message).toContain(path.join(path.dirname(STATE_FILE), "daemon-start.lock"));
expect(message).toContain("only after both recorded launcher and child PIDs are absent, archive it and run 'rig daemon start'");
expect(message).toContain("'rig daemon stop' cannot target this unpublished child");
expect(Date.now() - began).toBeLessThanOrEqual(20_000);
expect(Date.now() - began).toBeLessThanOrEqual(180_000);
expect(f.deps.kill).not.toHaveBeenCalled();
expect(f.child.exitCode).toBeNull();
expect(f.files.get(STATE_FILE)).toBe(previousState);
+15 -9
View File
@@ -14,6 +14,12 @@ import { runFrontDoor, resolveTuiPath, probeFrontDoor, type FrontDoorIo } from "
import { diagnoseRuntimePosture, observeClaudePermission } from "../../daemon/src/domain/permission-drift.js";
import { DaemonClient, DaemonConnectionError, DaemonResponseError, DaemonTimeoutError } from "../src/client.js";
// The compiled front door's spawns guard against a hang (the piped TUI never exiting). A hang never ends, so a budget well
// past a slow start still catches one: loaded CI runners took just over 5 s to start the CLI, and 30 s leaves room.
const HANG_LIMIT_MS = 30_000;
/** Those tests outlive their spawn's budget, so the spawn's own result is what reports a hang. */
const HANG_TEST_TIMEOUT_MS = HANG_LIMIT_MS + 10_000;
// A GUARANTEED-refused local port (bind an ephemeral port, capture it, close → connecting refuses).
// A REAL socket refusal, not a hand-built error — buys the actual Node/undici rejection shape.
async function refusedPort(): Promise<number> {
@@ -487,7 +493,7 @@ describe("PUBLIC bin ownership (guard finding 1 — the wrapper is the real fron
const started = Date.now();
const result = spawnSync(process.execPath, [binWrapper], {
input: "x\n",
timeout: 5000,
timeout: HANG_LIMIT_MS,
encoding: "utf-8",
});
expect(result.status, JSON.stringify({
@@ -496,13 +502,13 @@ describe("PUBLIC bin ownership (guard finding 1 — the wrapper is the real fron
stdout: result.stdout?.slice(-4096), stderr: result.stderr?.slice(-4096),
})).toBe(0);
expect(`${result.stderr}${result.stdout}`).toMatch(/Usage: rig/);
});
}, HANG_TEST_TIMEOUT_MS);
it.skipIf(!existsSync(binWrapper))("PUBLIC bin subcommands are untouched (rig --version via wrapper)", () => {
const result = spawnSync(process.execPath, [binWrapper, "--version"], { timeout: 5000, encoding: "utf-8" });
const result = spawnSync(process.execPath, [binWrapper, "--version"], { timeout: HANG_LIMIT_MS, encoding: "utf-8" });
expect(result.status).toBe(0);
expect(result.stdout.trim()).toMatch(/^\d+\.\d+\.\d+/);
});
}, HANG_TEST_TIMEOUT_MS);
});
describe("script-safety integration (the compiled front door)", () => {
@@ -511,22 +517,22 @@ describe("script-safety integration (the compiled front door)", () => {
const started = Date.now();
const result = spawnSync(process.execPath, [cliEntry], {
input: "x\n",
timeout: 5000,
timeout: HANG_LIMIT_MS,
encoding: "utf-8",
env: { ...process.env, OPENRIG_URL: "", OPENRIG_PORT: "" },
});
expect(Date.now() - started).toBeLessThan(5000);
expect(Date.now() - started).toBeLessThan(HANG_LIMIT_MS);
// pre-slice-17 baseline: commander's clean-help path prints usage and
// exits 0 — the front door PRESERVES the piped path byte-for-byte
expect(result.status).toBe(0);
expect(`${result.stderr}${result.stdout}`).toMatch(/Usage: rig/);
expect(`${result.stderr}${result.stdout}`).not.toMatch(/\n\s+at /);
});
}, HANG_TEST_TIMEOUT_MS);
it.skipIf(!existsSync(cliEntry))("`rig --help` still exits 0 with the full usage (front-door regression)", () => {
const result = spawnSync(process.execPath, [cliEntry, "--help"], { timeout: 5000, encoding: "utf-8" });
const result = spawnSync(process.execPath, [cliEntry, "--help"], { timeout: HANG_LIMIT_MS, encoding: "utf-8" });
expect(result.status).toBe(0);
expect(result.stdout).toMatch(/Usage: rig/);
expect(result.stdout).toMatch(/daemon/);
});
}, HANG_TEST_TIMEOUT_MS);
});
+31
View File
@@ -882,6 +882,37 @@ describe("MCP Server", () => {
await server.close();
});
it.each([
["a full inbox grant", { queue: ["create", "show", "list", "claim", "update"], team: ["list"] }],
["a narrow inbox grant", { queue: ["claim", "update"] }],
])("%s's instructions name only tools its connection offers", async (_label, objects) => {
const server = createMcpServer(mockClient(), { seat: "cus-1-caller@team",
access: { principal: "cus-1", owner: false, rigs: ["team"], roots: [], workspace: false, external: { objects } } });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const external = new Client({ name: "external-client", version: "1.0.0" });
await server.connect(serverTransport);
await external.connect(clientTransport);
const instructions = external.getInstructions() ?? "";
const offered = new Set((await external.listTools()).tools.map((tool) => tool.name));
const named = [...new Set(instructions.match(/\brig_[a-z_]+/g) ?? [])];
expect(instructions).toContain("claim it (rig_queue_claim)");
expect(named.filter((tool) => !offered.has(tool))).toEqual([]);
expect(instructions).not.toMatch(/rig_whoami|rig_queue_handoff|rig_queue_decide/);
expect(instructions.includes("rig_team")).toBe(offered.has("rig_team"));
await external.close();
await server.close();
});
it("a member's instructions keep every shared paragraph word for word", async () => {
const instructions = mcpClient.getInstructions() ?? "";
for (const paragraph of [
"This server reaches an OpenRig team as one seat: the one named by OPENRIG_SESSION_NAME where the server was configured, or, over authenticated HTTP, the seat of the caller's grant (rig_whoami shows which, and whether the caller is authenticated; rig_team shows a rig's seats and its lead). The daemon attributes this server's writes to that seat.",
"To hand work to a seat, use rig_queue_create and keep its qitemId; read the answer later with rig_queue_show, which names the next item when the work was handed on. To pass on work this seat holds, use rig_queue_handoff rather than closing it and creating another, so the work stays one chain. Queue items are durable: they outlive this conversation and this server.",
"Tools marked read-only change nothing. A seat whose activity is unknown is not idle, and an accepted request (a restore's attemptId) is not a finished one.",
"A question reserved for a person is theirs to answer: don't answer it on their behalf. rig_queue_decide records a decision only inside the decision room of the mode they have on, beside their answers and never as one.",
]) expect(instructions).toContain(paragraph);
});
it("shows a connected assistant's person plain titles and openings, keeping each tool's rules and hints", async () => {
// What a person sees: each tool's title and the first sentence of its description.
const plain: Record<string, [title: string, sentence: string]> = {
@@ -0,0 +1,109 @@
import { describe, expect, it, vi } from "vitest";
import { Command } from "commander";
import { psCommand } from "../src/commands/ps.js";
import { STATE_FILE, type LifecycleDeps } from "../src/daemon-lifecycle.js";
import type { DaemonClient } from "../src/client.js";
function makeDeps(nodePayload: Record<string, unknown>) {
const lifecycleDeps: LifecycleDeps = {
spawn: vi.fn(() => ({ pid: 1, unref: vi.fn() }) as never),
fetch: vi.fn(async () => ({ ok: true })),
kill: vi.fn(() => true),
readFile: vi.fn((p: string) =>
p === STATE_FILE ? JSON.stringify({ pid: 4242, port: 9999, host: "127.0.0.1" }) : null,
),
writeFile: vi.fn(),
removeFile: vi.fn(),
exists: vi.fn((p: string) => p === STATE_FILE),
mkdirp: vi.fn(),
openForAppend: vi.fn(() => 3),
isProcessAlive: vi.fn(() => true),
};
const client = {
get: vi.fn(async (url: string) => {
if (url.startsWith("/api/ps")) {
return {
status: 200,
data: [{ rigId: "rig-1", rigName: "demo", name: "demo", status: "running", lifecycleState: "running" }],
};
}
if (url.startsWith("/api/rigs/rig-1/nodes")) {
return { status: 200, data: [nodePayload] };
}
return { status: 404, data: { error: "not found" } };
}),
} as unknown as DaemonClient;
return { lifecycleDeps, clientFactory: () => client };
}
async function runPs(args: string[], nodePayload: Record<string, unknown>) {
const logs: string[] = [];
const errs: string[] = [];
const origLog = console.log;
const origErr = console.error;
const origExit = process.exitCode;
process.exitCode = undefined;
console.log = (...a: unknown[]) => logs.push(a.join(" "));
console.error = (...a: unknown[]) => errs.push(a.join(" "));
try {
const program = new Command();
program.addCommand(psCommand(makeDeps(nodePayload)));
await program.parseAsync(["node", "rig", "ps", ...args]);
} finally {
console.log = origLog;
console.error = origErr;
}
const exitCode = process.exitCode;
process.exitCode = origExit;
return { logs, errs, exitCode };
}
describe("rig ps --nodes resumeCommandLabel field projection and compact redaction", () => {
const node = {
rigId: "rig-1",
rigName: "demo",
logicalId: "dev.codex",
canonicalSessionName: "dev-codex@demo",
nodeKind: "agent",
runtime: "codex",
sessionStatus: "running",
startupStatus: "ready",
restoreOutcome: "n-a",
lifecycleState: "running",
tmuxAttachCommand: "tmux attach -t dev-codex@demo",
resumeCommand: "codex -s workspace-write '-c' 'check_for_update_on_startup=false' resume 'tok-1'",
resumeCommandLabel: "launched with -s danger-full-access -a never; a resume now uses -s workspace-write",
resumeType: "codex_id",
resumeToken: "tok-1",
latestError: null,
};
it("projects resumeCommandLabel alongside resumeCommand when requested via --fields", async () => {
const { logs, errs, exitCode } = await runPs(
["--nodes", "--rig", "demo", "--json", "--fields", "logicalId,resumeCommand,resumeCommandLabel"],
node,
);
expect(exitCode).toBeUndefined();
expect(errs).toEqual([]);
const parsed = JSON.parse(logs[0]!) as { entries: Array<Record<string, unknown>> };
expect(parsed.entries).toEqual([
{
logicalId: "dev.codex",
resumeCommand: node.resumeCommand,
resumeCommandLabel: node.resumeCommandLabel,
},
]);
});
it("redacts both resumeCommand and resumeCommandLabel in compact --nodes --json mode while keeping both in --full", async () => {
const compactRes = await runPs(["--nodes", "--rig", "demo", "--json"], node);
const compactRows = JSON.parse(compactRes.logs[0]!) as Array<Record<string, unknown>>;
expect(compactRows[0]).not.toHaveProperty("resumeCommand");
expect(compactRows[0]).not.toHaveProperty("resumeCommandLabel");
const fullRes = await runPs(["--nodes", "--rig", "demo", "--full", "--json"], node);
const fullRows = JSON.parse(fullRes.logs[0]!) as Array<Record<string, unknown>>;
expect(fullRows[0]?.resumeCommand).toBe(node.resumeCommand);
expect(fullRows[0]?.resumeCommandLabel).toBe(node.resumeCommandLabel);
});
});
@@ -165,7 +165,7 @@ describe("queue writes refused for a host token the daemon doesn't know", () =>
["a remote's refusal", { status: 502, data: { error: "remote_queue_write_failed", hostId: "vps-b", failureClass: "remote-error", remoteStatus: 400, detail: "HTTP 400" } }],
["an auth failure", { status: 502, data: { error: "remote_queue_write_failed", hostId: "vps-b", failureClass: "auth-failed", detail: "401" } }],
["an internal error", { status: 500, data: { error: "internal_error", message: "boom" } }],
["a 400 this list doesn't name", { status: 400, data: { error: "closure_fields_not_admitted", message: "x" } }],
["a 400 this list doesn't name", { status: 400, data: { error: "summary_evidence_not_persistable", message: "x" } }],
];
for (const [label, reply] of UNPROVEN) {
it(`never says it after ${label}`, async () => {
@@ -1,7 +1,9 @@
// 51-06 D2, narrowed — REAL CLI acceptance boundary. A genuine `queueCommand` invocation talks over
// LOOPBACK HTTP to the real candidate queueRoutes + QueueRepository, backed by a migration-faithful test DB
// (incl. 044 summary + 048 evidence_ref). No pre-baked response. A state change that carries --summary or
// --evidence-ref is applied and stores them; a blank --summary is reported as not stored, never dropped silently.
// 51-06 D2 — REAL CLI acceptance boundary (Guard implementation-verdict correction). A genuine
// `queueCommand` invocation talks over LOOPBACK HTTP to the real candidate queueRoutes +
// QueueRepository, backed by a migration-faithful test DB (incl. 044 summary + 048 evidence_ref).
// No pre-baked response: if the daemon route/repo stopped rejecting (or mutated before responding),
// these tests would fail. Proves normal + --json reject, UNCHANGED item/transition/event state after
// the HTTP call, and the successful human-park control — all end to end.
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from "vitest";
import { serve } from "@hono/node-server";
import { Hono } from "hono";
@@ -105,29 +107,27 @@ describe("51-06 D2 — real CLI->loopback HTTP->queueRoutes/QueueRepository acce
});
afterEach(() => { vi.unstubAllEnvs(); });
it("normal: --summary and --evidence-ref on a state change are applied and stored (exit 0)", async () => {
const beforeTxns = txnCount(id);
const { exit } = await runCli(["update", id, "--state", "in-progress", "--summary", "KEEP", "--evidence-ref", "/proof/e.md"]);
expect(exit).toBeFalsy();
expect(repo.getByIdOrThrow(id)).toMatchObject({ state: "in-progress", summary: "KEEP", evidenceRef: "/proof/e.md" });
expect(txnCount(id)).toBe(beforeTxns + 1);
it("normal: --summary on a non-park update -> real 400, nonzero exit, item/transition/event UNCHANGED", async () => {
const beforeTxns = txnCount(id), beforeEvents = eventCount();
const { out, exit } = await runCli(["update", id, "--actor", "dev-x@rig", "--state", "in-progress", "--summary", "DROPME"]);
expect(exit).toBe(1);
expect(out).toContain("summary_evidence_not_persistable");
expect(out).toContain("invalidFields");
// unchanged item/metadata/transition/event state AFTER the real HTTP call
const item = repo.getByIdOrThrow(id);
expect(item.state).toBe("pending");
expect(item.summary).toBeNull();
expect(txnCount(id)).toBe(beforeTxns);
expect(eventCount()).toBe(beforeEvents);
});
it("a finished seat's park on a typed gate with --summary and --evidence-ref parks and stores both (--json)", async () => {
await runCli(["update", id, "--state", "in-progress"]);
const { out, exit } = await runCli(["block", id, "--on", "external:relay-reply", "--summary", "Relayed; waiting on the reply", "--evidence-ref", "/proof/relay.md", "--json"]);
expect(exit).toBeFalsy();
expect(JSON.parse(out)).toMatchObject({ state: "blocked", summary: "Relayed; waiting on the reply", evidenceRef: "/proof/relay.md" });
expect(repo.getByIdOrThrow(id)).toMatchObject({ state: "blocked", blockedOn: "external:relay-reply", summary: "Relayed; waiting on the reply", evidenceRef: "/proof/relay.md" });
});
it.each([false, true])("a blank --summary is applied without it and reported as not stored (json=%s)", async (json) => {
db.prepare("UPDATE queue_items SET summary = ? WHERE qitem_id = ?").run("Original subject", id);
const { out, exit } = await runCli(["update", id, "--state", "in-progress", "--summary", " ", ...(json ? ["--json"] : [])]);
expect(exit).toBeFalsy();
expect(repo.getByIdOrThrow(id)).toMatchObject({ state: "in-progress", summary: "Original subject" });
if (json) expect(JSON.parse(out).advisories).toEqual([expect.objectContaining({ code: "summary_not_stored", field: "summary" })]);
else expect(out).toContain(`The summary was blank, so it wasn't stored: ${id} keeps its summary`);
it("--json: same reject as structured JSON with nonzero exit, state UNCHANGED", async () => {
const { out, exit } = await runCli(["update", id, "--actor", "dev-x@rig", "--state", "in-progress", "--evidence-ref", "/e.md", "--json"]);
expect(exit).toBe(1);
const body = JSON.parse(out) as { error: string; invalidFields?: string[] };
expect(body.error).toBe("summary_evidence_not_persistable");
expect(body.invalidFields).toEqual(["evidenceRef"]);
expect(repo.getByIdOrThrow(id).evidenceRef).toBeNull();
});
it("CONTROL: real-CLI human-seat park PERSISTS summary + evidence (exit 0)", async () => {
+10 -3
View File
@@ -14,6 +14,13 @@ interface Result {
stderr: string;
}
// A create waits on nothing (each claim is one exclusive file create), so one still running after a minute is hung; a
// slow start on a loaded CI runner took over 15 s. A batch's creates run together, so the batch tests allow each batch the
// full limit plus one for setup and checks, and a hung create there is reported by its own limit. A single-create test
// keeps the suite's default timeout, which reports a hang first.
const CREATE_LIMIT_MS = 60_000;
const batchesTimeout = (batches: number) => (batches + 1) * CREATE_LIMIT_MS;
describe("public slice creation ordinal claims", () => {
let root: string;
let workspace: string;
@@ -49,7 +56,7 @@ describe("public slice creation ordinal claims", () => {
];
try {
const result = await promisify(execFile)(process.execPath, args, {
env: ownedEnv, encoding: "utf8", timeout: 15000,
env: ownedEnv, encoding: "utf8", timeout: CREATE_LIMIT_MS,
});
return { exitCode: 0, ...result };
} catch (error) {
@@ -95,7 +102,7 @@ describe("public slice creation ordinal claims", () => {
expect(fs.readdirSync(path.join(mission, "slices"), { withFileTypes: true }).filter((entry) => entry.isDirectory())).toHaveLength(33);
expect(fs.readFileSync(path.join(mission, "README.md"), "utf8")).toBe(original);
expect(fs.existsSync(path.join(mission, ".openrig-slice-create-lock"))).toBe(false);
}, 30000);
}, batchesTimeout(4));
it("twenty eight-process manifest-backed batches preserve the admitted concurrency contract", async () => {
const ref = "slices/01-existing/slice.yaml";
@@ -144,7 +151,7 @@ describe("public slice creation ordinal claims", () => {
expect(new Set(successful(results).map((slice) => slice.id)).size).toBe(160);
expect(fs.readFileSync(path.join(unrelated, "PROOF.md"), "utf8")).toBe("# unrelated authored proof\n");
expect(fs.existsSync(path.join(mission, ".openrig-slice-create-lock"))).toBe(false);
}, 180000);
}, batchesTimeout(20));
it("a later create repairs only pending complete registrations and preserves authored membership", async () => {
const ref = "slices/01-existing/slice.yaml";
@@ -30,6 +30,8 @@ on an OpenRig team load `openrig-core` instead.
1. OpenRig is installed and runs a team on this machine: `rig ps` lists it.
2. Pick an address for this app on that team, such as `cursor@my-team`. The queue takes work for it; nothing is
launched. To tell the team what it's for, add it to a roster as a hosted member (`docs/reference/hosted-members.md`).
3. **Claude Code 2.1.119 or later.** On earlier versions, while the plugin's OpenRig seat is blank, Claude Code silently
drops its MCP server: the skill loads, but your agent has no OpenRig tools.
## Install
@@ -10,8 +10,8 @@ already does it, or two verbs crossed already answer it. **You cannot look up a
not know exists.** So the point of what follows is not skill. It is recognition: that when a task
lands, something rhymes, and you go check instead of building.
Ninety-two top-level verbs ship (capability canon refreshed through
`capability-delta-v0.7.12`). This marker describes the pack's teaching, not publication or live
Ninety-three top-level verbs ship (capability canon refreshed through
`capability-delta-v0.7.13`). This marker describes the pack's teaching, not publication or live
adoption. Read this once for shape, and let it make you suspicious that a thing already exists.
What changed in the installed version, and what to do about it, is one read away:
`rig context get reference/whats-new.md`.
@@ -749,7 +749,10 @@ not have to tear one down to change it, and you rarely have to start from nothin
conversation continuity stays unverified. Both refuse while typing protection is on.
- **`rig unclaim` / `rig release`** — stop managing one adopted session, or all of them,
**without killing the process or the agent inside it**. Handing something back to its human is a
first-class move, not an abandonment.
first-class move, not an abandonment. To stop an adopted session after unclaiming it, end its
process and its tmux session; closing only the viewing window detaches it. Since 0.7.13 a fresh
`rig launch` after `rig unclaim` ended the server's last session reopens the server (a released
session that's still running blocks it, as before).
> **Two words that mean different things at different altitudes.** `unclaim` and `release` each
> exist both here — *stop managing a live session* — and in the queue — *put a work row back down*.
@@ -866,7 +869,9 @@ version, from checking the environment to a useful report to the OpenRig team.
effectful handover path.** Both forms perform the operation by default; pass `--dry-run`
to plan without changing the seat. Check the selected source and continuity evidence first. A
seat can be handed over again into its own pane, so a seat left half-handed-over recovers by
running the handover again. A Claude seat started through a `#!` wrapper that runs Claude without
running the handover again. A handover whose successor was launched but not ready says it may be
running: look at the pane before retrying. A handover cleans up the retiring occupant's own claims
and jobs. A Claude seat started through a `#!` wrapper that runs Claude without
`exec`, or through the installer's `~/.local/bin/claude` symlink, rebuilds with `--source
rebuild`; a failed rebuild leaves the resume target on the earlier conversation, so rebuild again
rather than relaunching it fresh.
@@ -1231,6 +1236,9 @@ reaching them is ordinary work rather than an escalation.
see which host you are acting on, and check a registered host is *actually* usable end to end.
`rig host edit <id>` corrects an entry (its id and transport stay fixed), and `rig host remove <id>` deletes
only the registry entry: credential files, the learned identity and the selection stay as they are.
- **`rig network`** — **experimental, unsupported and at your own risk.** It connects one local seat
to a hub you already have. The daemon starts the connector, sweeps leftover credential folders and
schedules local rounds even with no binding, so it isn't inert, and its migrations apply on upgrade.
- **A cross-host queue write that times out reads INDETERMINATE, not failed.** The CLI waits 15
seconds for the daemon's answer, and `rig queue create` with no answer reads the row back once by
its ID. Reconcile by that ID before any retry; don't send the write twice.
@@ -1275,6 +1283,11 @@ reaching them is ordinary work rather than an escalation.
a person can decide directly in the TUI's pairing settings. A relay connector that can't accept
incoming connections can pull instead (`OPENRIG_RELAY_MODE=pull` in the relay secrets file); push
stays the default, and a person's connector binding can be unset.
**Since 0.7.13 a person's runner answer gets a note back:** who decides and in what state, or that
the answer is kept and the tool hasn't run, and a runner waiting on it wakes on the decision. Read
that note before asking the person to answer again. The answer a receiving agent reads says what an
answer typed at the machine's terminal means, to act on a clear one with the request's own decision
command, and how to ask the person back, with a new ask to them, when it's unclear.
- **The `relay` gateway connector** carries a registered person's questions, updates and notices to a
bridge process you run, over loopback HTTP, and takes their replies back with the sender resolved
against their bindings. It's off until a person has a relay binding (`rig gateway human add <id> …
@@ -1413,7 +1426,8 @@ your circumstances is configuration, and the ones that are not, another agent ca
conversation it is in the middle of. **`provider signals` reports anomalies rather than a
listing** — unbound seats, accounts shared across several seats. A Codex seat showing its
usage-limit banner reads activity `unknown` with reason `usage_limit`, and keeps it while its
screen redraws; `provider signals` can show a provider-event signal for that seat's account that
screen redraws (a banner with a curly apostrophe included); a Claude seat whose usage-limit message
states a reset time is parked until then, instead of being retried every few minutes; `provider signals` can show a provider-event signal for that seat's account that
allows a switch decision. With `provider.usage_windows` on (off by default), `rig provider status`
shows Codex's windows and Claude's 5-hour and weekly windows from the seats' status lines, each
seat's configured account, and with `--all-hosts` or `--hosts <ids>` every registered machine's
@@ -1484,7 +1498,11 @@ your circumstances is configuration, and the ones that are not, another agent ca
(empty, the default, means every signed-in one). `--router-line yes|no` decides whether OpenRig
adds its one line to shared guidance files (`skills.router_line.enabled`).
Setup offers to register the person when none is registered, and a team started from the
person's own assistant fits their own logins, with a review before anything is committed.
person's own assistant fits their own logins, with a review before anything is committed. Since
0.7.13, `rig daemon start` ends with that offer when the person registry can be read and holds
nobody. The kernel operator's startup is also taught to run `rig gateway participants` before its
first greeting and offer registration when nobody is registered. The agent asks first and
registers only on the person's yes.
- **`rig usage series`** — what a seat's token curve has looked like over time: climbing steadily,
reset, or stopped reporting entirely. The last one is a signal, not a gap.
- **`rig usage top`** — which of this machine's seats used the most tokens over a window (`--window
@@ -1581,6 +1599,9 @@ your circumstances is configuration, and the ones that are not, another agent ca
with secret names, never values): OpenRig then POSTs a pointer to the row when work lands, and a
2xx records only that the ring was accepted, not that anything woke. When work waits for a
member nothing can wake, or a rung member doesn't claim it in time, its person is told.
A hosted member's roster entry shows whether it's checking in: its expected cadence and its last
check-in, read as checking in, overdue, not yet checked in or checking in on request, and unknown
when that can't be read. It's a status, never a gate on delivery.
**Internet-facing hosted members are experimental, unsupported, off by default and at your
own risk.** Local assistants can connect locally. Internet access needs your own TLS tunnel or
proxy and a narrowly scoped, revocable token; see `reference/hosted-members.md`. Task terminal
@@ -1676,6 +1697,6 @@ deliberate writing.
**`rig --help`**, then `rig <verb> --help`.
Ninety-two verbs ship, and this page named most of them once. **You will not remember which — you
Ninety-three verbs ship, and this page named most of them once. **You will not remember which — you
are meant to remember only that the list is long enough to be worth reading before you build
anything.** The question is never *how do I write this*; it is **does this already exist**.
@@ -1,3 +1,5 @@
import type { RunnerGrantIssuer, RunnerGrantLaunch } from "../domain/runner-grant-launch.js";
import { randomUUID } from "node:crypto";
import type { EnvironmentStartup } from "../domain/environment-startup.js";
import type {
RuntimeAdapter,
@@ -28,6 +30,7 @@ import { PiCommunityRuntimeAdapter } from "./pi-community-runtime-adapter.js";
export interface CommunityHarnessAdapterDeps {
environmentStartup?: EnvironmentStartup;
runnerGrants?: RunnerGrantIssuer;
tmux: Pick<TmuxAdapter, "hasSession" | "getPaneCommand" | "sendShellCommand"> & Partial<Pick<TmuxAdapter, "capturePaneContent" | "listPanes">>;
seatLaunchEnvironment?: Pick<SeatLaunchEnvironment, "command" | "resolveLaunchWrapper">;
presetsDir?: () => string;
@@ -173,6 +176,8 @@ export class CommunityHarnessRuntimeAdapter implements RuntimeAdapter {
onSent?: () => void, launcherPrefix = ""): Promise<HarnessLaunchResult> {
if (preset.namesConversation) return this.launchNamed(binding, session, preset.command, resumeRef, beforeInput, onSent, launcherPrefix);
let attempt: LaunchAttempt | undefined;
let authority: RunnerGrantLaunch | undefined;
let launchSucceeded = false;
// The record is optional: a folder that can't be made leaves the identity unavailable, and the harness still launches.
let storageNote: string | undefined;
if (preset.launchResult) {
@@ -188,41 +193,72 @@ export class CommunityHarnessRuntimeAdapter implements RuntimeAdapter {
storageNote = `community harness "${this.runtime}": its launch identity is unavailable: its launch-result folder couldn't be made (${(error as Error).message})`;
}
}
let command: string;
const runId = attempt?.attempt ?? randomUUID();
let authorityUnavailable = false;
let dispatchedWithoutAuthority = false;
if (preset.runner) {
try { authority = await this.deps.runnerGrants?.(binding, this.runtime, runId, 1); }
catch { authorityUnavailable = true; }
}
try {
command = this.deps.seatLaunchEnvironment
? await this.deps.seatLaunchEnvironment.command(session, preset.command, { runtime: this.runtime, nodeId: binding.nodeId, launcherPrefix,
...(binding.launchWrapper?.length ? { failClosed: true, ...(!preset.runner ? { launchWrapper: binding.launchWrapper } : {}) } : {}) })
: launcherPrefix + preset.command;
} catch (error) {
if (!binding.launchWrapper?.length) throw error;
return { ok: false, error: "launch_wrapper unavailable; no agent started." };
let command: string;
try {
command = this.deps.seatLaunchEnvironment
? await this.deps.seatLaunchEnvironment.command(session, preset.command, { runtime: this.runtime, nodeId: binding.nodeId, launcherPrefix, ...(authority ? { generation: authority.generation } : {}),
...(binding.launchWrapper?.length ? { failClosed: true, ...(!preset.runner ? { launchWrapper: binding.launchWrapper } : {}) } : {}) })
: launcherPrefix + preset.command;
} catch (error) {
if (!binding.launchWrapper?.length) throw error;
return { ok: false, error: "launch_wrapper unavailable; no agent started." };
}
const fallback = command;
if (authority) command = authority.wrap(command);
let sent = await this.deps.tmux.sendShellCommand(session, command, beforeInput, { stageIfLong: true, execInScript: true });
if (!sent.ok && sent.code === "launch_path_too_long" && authority && command !== fallback) {
// This result proves no input was dispatched. Retry only the same pre-grant command.
try { await authority.finish(false); } catch { /* optional cleanup cannot block the original launch */ }
authority = undefined; authorityUnavailable = true;
sent = await this.deps.tmux.sendShellCommand(session, fallback, beforeInput, { stageIfLong: true, execInScript: true });
}
if (sent.ok) {
try { authority?.dispatched(); }
catch {
try { await authority?.finish(false); } catch { /* preserve the already dispatched effect */ }
authority = undefined; authorityUnavailable = true; dispatchedWithoutAuthority = true;
}
}
if (!sent.ok) return { ok: false, error: `Couldn't start community harness "${this.runtime}": ${sent.message}` };
onSent?.();
// A runner reports its conversation in this record; without one, its seat needs a person rather than reading ready.
if (!attempt && storageNote && preset.runner) return { ok: false, recovery: "attention_required", error: storageNote };
if (!attempt) { launchSucceeded = true; return storageNote ? { ok: true, warnings: [storageNote] } : { ok: true }; }
const read = await waitForLaunchResult(attempt, this.runtime, { ...this.deps.launchResultWait, root: this.deps.launchResultRoot?.() ?? launchResultRoot() });
if (resumeRef) {
// A conversation that's gone is never replaced by a new one: nothing started, and the person decides.
if (!read.ok && "missing" in read) {
return { ok: false, missingConversation: true, error: `Couldn't resume community harness "${this.runtime}": conversation ${resumeRef}: ${read.reason}.` };
}
// Unconfirmed or different: the seat keeps the conversation it asked for, and the person decides. No retry.
if (!read.ok) {
return { ok: false, recovery: "attention_required", resumeUnproven: true,
error: `Community harness "${this.runtime}": resuming conversation ${resumeRef} isn't confirmed: ${read.reason}. The seat keeps conversation ${resumeRef}; nothing was restarted.` };
}
if (read.nativeRef !== resumeRef) {
return { ok: false, recovery: "attention_required", resumeMismatch: { opened: read.nativeRef },
error: `Community harness "${this.runtime}": asked to resume conversation ${resumeRef}, its launcher reports conversation ${read.nativeRef} instead. The seat keeps conversation ${resumeRef}, and OpenRig doesn't switch it to ${read.nativeRef}. Nothing was restarted.` };
}
}
if (read.ok) { launchSucceeded = true; return { ok: true, resumeToken: read.nativeRef, resumeType: `community:${this.runtime}`, resumeProvenance: "launcher",
...(authority ? { activityGrantId: authority.grantId } : {}),
...(authorityUnavailable ? { warnings: [dispatchedWithoutAuthority
? "Native command dispatched; run-bound reporting could not be confirmed. Cleanup of its grant was attempted; nothing was replayed."
: "Run-bound reporting unavailable; using existing unbound install-token reporting."] } : {}) }; }
const unavailable = `community harness "${this.runtime}": its launch identity is unavailable: ${read.reason}`;
return preset.runner ? { ok: false, recovery: "attention_required", error: unavailable } : { ok: true, warnings: [unavailable] };
} finally {
try { await authority?.finish(launchSucceeded); } catch { /* optional reporting cannot change a native launch outcome */ }
}
const sent = await this.deps.tmux.sendShellCommand(session, command, beforeInput, { stageIfLong: true, execInScript: true });
if (!sent.ok) return { ok: false, error: `Couldn't start community harness "${this.runtime}": ${sent.message}` };
onSent?.();
// A runner reports its conversation in this record; without one, its seat needs a person rather than reading ready.
if (!attempt && storageNote && preset.runner) return { ok: false, recovery: "attention_required", error: storageNote };
if (!attempt) return storageNote ? { ok: true, warnings: [storageNote] } : { ok: true };
const read = await waitForLaunchResult(attempt, this.runtime, { ...this.deps.launchResultWait, root: this.deps.launchResultRoot?.() ?? launchResultRoot() });
if (resumeRef) {
// A conversation that's gone is never replaced by a new one: nothing started, and the person decides.
if (!read.ok && "missing" in read) {
return { ok: false, missingConversation: true, error: `Couldn't resume community harness "${this.runtime}": conversation ${resumeRef}: ${read.reason}.` };
}
// Unconfirmed or different: the seat keeps the conversation it asked for, and the person decides. No retry.
if (!read.ok) {
return { ok: false, recovery: "attention_required", resumeUnproven: true,
error: `Community harness "${this.runtime}": resuming conversation ${resumeRef} isn't confirmed: ${read.reason}. The seat keeps conversation ${resumeRef}; nothing was restarted.` };
}
if (read.nativeRef !== resumeRef) {
return { ok: false, recovery: "attention_required", resumeMismatch: { opened: read.nativeRef },
error: `Community harness "${this.runtime}": asked to resume conversation ${resumeRef}, its launcher reports conversation ${read.nativeRef} instead. The seat keeps conversation ${resumeRef}, and OpenRig doesn't switch it to ${read.nativeRef}. Nothing was restarted.` };
}
}
if (read.ok) return { ok: true, resumeToken: read.nativeRef, resumeType: `community:${this.runtime}`, resumeProvenance: "launcher" };
const unavailable = `community harness "${this.runtime}": its launch identity is unavailable: ${read.reason}`;
return preset.runner ? { ok: false, recovery: "attention_required", error: unavailable } : { ok: true, warnings: [unavailable] };
}
/** OpenRig names the conversation: a new one gets a fresh name in OPENRIG_CONVERSATION, and a resume passes the saved name
+86 -55
View File
@@ -1,3 +1,4 @@
import type { RunnerGrantLaunch } from "../domain/runner-grant-launch.js";
// 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
@@ -29,6 +30,8 @@ export interface PiResumeFsOps {
}
interface PiResumeOptions {
/** Core resolves the registered restore binding; the runner never supplies it. */
runnerGrant?: (session: string, runId: string, cwd: string) => Promise<RunnerGrantLaunch>;
/** Resolve the persisted/configured account without relying on live tmux metadata. */
accountHomeForSession?: (session: string) => string | undefined;
stateRootForSession?: (session: string) => string;
@@ -105,64 +108,92 @@ export class PiResumeAdapter {
// 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)),
);
let authority: RunnerGrantLaunch | undefined;
let authorityUnavailable = false;
let dispatchedWithoutAuthority = false;
if (this.runtime === "pi") {
try { authority = await this.options.runnerGrant?.(tmuxSessionName, launchId, cwd); }
catch { authorityUnavailable = true; }
}
let launchSucceeded = false;
try {
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 trust = this.runtime === "omp"
? (yoloEnabled(process.env, resolvedPosture) ? "approve" : "no-approve")
: piTrust(this.options.trustPosture, process.env, resolvedPosture);
const appliedLaunch = this.runtime === "omp"
? observeOmpApprovalMode(`--approval-mode ${trust === "approve" ? "yolo" : "always-ask"}`)
: observePiResourceTrust(trust);
let nodeId: string | undefined;
if (this.runtime === "pi" && this.options.seatLaunchEnvironment) {
try { nodeId = await this.tmux.getSessionEnv(tmuxSessionName, "OPENRIG_NODE_ID") ?? undefined; }
catch { /* Account-free resume retains best-effort identity correction. */ }
}
const accountHome = this.runtime === "pi" ? this.options.accountHomeForSession?.(tmuxSessionName)
?? (nodeId ? this.options.seatLaunchEnvironment?.accountHome(nodeId, this.runtime) : undefined) : undefined;
if (!nodeId && this.runtime === "pi" && (accountHome || this.fs.exists(`${seat.agentDir}/.openrig-account`))) {
return { ok: false, code: "resume_failed", message: "Pi resume needs seat identity to preserve its selected account; restore identity and retry resume." };
}
if (!nodeId && this.runtime === "pi" && this.options.seatLaunchEnvironment) console.warn("[pi-resume] Seat identity unavailable; account-free resume continues with best-effort routing.");
const cmd = buildPiRunnerCommand({
accountHome,
runtime: this.runtime,
runnerEntryPath: this.paths.runnerEntryPath,
sessionName: tmuxSessionName,
stateRoot: this.options.stateRootForSession?.(tmuxSessionName) ?? 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,
thinking: this.runtime === "pi" ? effort ?? undefined : undefined,
// OPR.0.4.8.2: Pi RESOURCE TRUST (not a permission policy). Same decision as the launch path:
// YOLO forces `approve` on every restored seat; otherwise the configured posture.
trust,
sessionFile,
launchId,
usageFacts: this.runtime === "pi" && (this.options.usageFactsEnabled?.() ?? false),
openRouterCharge: this.runtime === "pi" && (this.options.openRouterChargeEnabled?.() ?? false),
...(resolvedWrapper ? { launchWrapper: resolvedWrapper } : {}),
});
const trust = this.runtime === "omp"
? (yoloEnabled(process.env, resolvedPosture) ? "approve" : "no-approve")
: piTrust(this.options.trustPosture, process.env, resolvedPosture);
const appliedLaunch = this.runtime === "omp"
? observeOmpApprovalMode(`--approval-mode ${trust === "approve" ? "yolo" : "always-ask"}`)
: observePiResourceTrust(trust);
let nodeId: string | undefined;
if (this.runtime === "pi" && this.options.seatLaunchEnvironment) {
try { nodeId = await this.tmux.getSessionEnv(tmuxSessionName, "OPENRIG_NODE_ID") ?? undefined; }
catch { /* Account-free resume retains best-effort identity correction. */ }
}
const accountHome = this.runtime === "pi" ? this.options.accountHomeForSession?.(tmuxSessionName)
?? (nodeId ? this.options.seatLaunchEnvironment?.accountHome(nodeId, this.runtime) : undefined) : undefined;
if (!nodeId && this.runtime === "pi" && (accountHome || this.fs.exists(`${seat.agentDir}/.openrig-account`))) {
return { ok: false, code: "resume_failed", message: "Pi resume needs seat identity to preserve its selected account; restore identity and retry resume." };
}
if (!nodeId && this.runtime === "pi" && this.options.seatLaunchEnvironment) console.warn("[pi-resume] Seat identity unavailable; account-free resume continues with best-effort routing.");
const cmd = buildPiRunnerCommand({
accountHome,
runtime: this.runtime,
runnerEntryPath: this.paths.runnerEntryPath,
sessionName: tmuxSessionName,
stateRoot: this.options.stateRootForSession?.(tmuxSessionName) ?? 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,
thinking: this.runtime === "pi" ? effort ?? undefined : undefined,
// OPR.0.4.8.2: Pi RESOURCE TRUST (not a permission policy). Same decision as the launch path:
// YOLO forces `approve` on every restored seat; otherwise the configured posture.
trust,
sessionFile,
launchId,
usageFacts: this.runtime === "pi" && (this.options.usageFactsEnabled?.() ?? false),
openRouterCharge: this.runtime === "pi" && (this.options.openRouterChargeEnabled?.() ?? false),
...(resolvedWrapper ? { launchWrapper: resolvedWrapper } : {}),
});
const fallback = cmd;
const command = this.runtime === "pi" && this.options.seatLaunchEnvironment
? await this.options.seatLaunchEnvironment.command(tmuxSessionName, cmd, { runtime: this.runtime, nodeId, accountHome })
: fallback;
// Short commands retain the direct path; long commands exec from a private script.
let textResult = await this.tmux.sendShellCommand(tmuxSessionName, command, undefined, { stageIfLong: true, execInScript: true });
if (!textResult.ok && textResult.code === "launch_path_too_long" && command !== fallback && accountHome === undefined) {
textResult = await this.tmux.sendShellCommand(tmuxSessionName, fallback, undefined, { stageIfLong: true, execInScript: true });
const fallback = cmd;
let command = this.runtime === "pi" && this.options.seatLaunchEnvironment
? await this.options.seatLaunchEnvironment.command(tmuxSessionName, cmd, { runtime: this.runtime, nodeId, accountHome, ...(authority ? { generation: authority.generation } : {}) })
: fallback;
const originalCommand = command;
if (authority) command = authority.wrap(command);
// Short commands retain the direct path; long commands exec from a private script.
let textResult = await this.tmux.sendShellCommand(tmuxSessionName, command, undefined, { stageIfLong: true, execInScript: true });
if (!textResult.ok && textResult.code === "launch_path_too_long" && authority && command !== originalCommand) {
try { await authority.finish(false); } catch { /* optional authority cleanup cannot prevent the original resume */ }
authority = undefined; authorityUnavailable = true;
textResult = await this.tmux.sendShellCommand(tmuxSessionName, originalCommand, undefined, { stageIfLong: true, execInScript: true });
}
if (!textResult.ok && textResult.code === "launch_path_too_long" && originalCommand !== fallback && accountHome === undefined) {
textResult = await this.tmux.sendShellCommand(tmuxSessionName, fallback, undefined, { stageIfLong: true, execInScript: true });
}
if (!textResult.ok) {
return { ok: false, code: "resume_failed", message: textResult.message };
}
try { authority?.dispatched(); }
catch {
try { await authority?.finish(false); } catch { /* native dispatch already happened; never replay */ }
authority = undefined; authorityUnavailable = true; dispatchedWithoutAuthority = true;
}
const result = await this.verifyResume(tmuxSessionName, sessionFile, launchId);
launchSucceeded = result.ok;
return result.ok ? { ...result, appliedLaunch,
...(authorityUnavailable ? { warnings: [dispatchedWithoutAuthority
? "Native command dispatched; run-bound reporting could not be confirmed. Cleanup of its grant was attempted; nothing was replayed."
: "Run-bound reporting unavailable; using existing unbound install-token reporting."] } : {}) } : result;
} finally {
try { await authority?.finish(launchSucceeded); } catch { /* optional reporting cannot turn a completed resume into failure */ }
}
if (!textResult.ok) {
return { ok: false, code: "resume_failed", message: textResult.message };
}
const result = await this.verifyResume(tmuxSessionName, sessionFile, launchId);
return result.ok ? { ...result, appliedLaunch } : result;
}
// Poll the runner's launch-scoped sidecar ONLY — never stale pane
+16 -10
View File
@@ -827,7 +827,7 @@ export class RunnerCore {
constructor(
private io: RunnerIo,
private identity: { sessionName: string; nodeId?: string; launchId?: string; generation?: string },
private identity: { sessionName: string; nodeId?: string; launchId?: string; generation?: string; runId?: string },
private opts: { catchUpSince?: string; runtime?: RunnerRuntime | string; usageFacts?: boolean; openRouterCharge?: boolean;
/** K4: the credential observation taken before Pi was spawned. */
launchCredentialEvidence?: PiCredentialEvidence;
@@ -1314,11 +1314,11 @@ export class RunnerCore {
}
}
/** Re-deliver OMP identity until the daemon confirms the resume token.
/** Re-deliver OMP or managed run identity until the daemon confirms the resume token.
* Called from a timer so a daemon restart during the first turn cannot
* leave a seat with history but no restorable token. */
retrySessionIdentity(): void {
if (this.runtime === "omp" && this.ready) this.postSessionIdentity();
if ((this.runtime === "omp" || this.identity.runId) && this.ready) this.postSessionIdentity();
}
/** A resume that came up on another session file started a different session: the requested one stays the seat's. */
@@ -1330,9 +1330,10 @@ export class RunnerCore {
// Never the identity of a session other than the one this launch resumes: it would replace the seat's resume token.
if (this.resumedElsewhere()) return;
if (this.runtime === "omp" && (!this.sessionFile || !this.io.sessionFileExists?.(this.sessionFile))) return;
if (this.runtime === "omp" && (this.identityPersisted || this.identityInFlight)) return;
if ((this.runtime === "omp" || this.identity.runId) && (this.identityPersisted || this.identityInFlight)) return;
const result = this.io.postActivity({
eventFamily: "session_identity",
...(this.identity.runId ? { runId: this.identity.runId } : {}),
sessionName: this.identity.sessionName,
nodeId: this.identity.nodeId ?? null,
runtime: this.runtime,
@@ -1342,7 +1343,7 @@ export class RunnerCore {
sessionFile: this.sessionFile ?? null,
occurredAt: this.io.now(),
});
if (this.runtime !== "omp" || !result) return;
if ((this.runtime !== "omp" && !this.identity.runId) || !result) return;
this.identityInFlight = true;
void result.then(
(body) => { if (body?.tokenPersisted === true) this.identityPersisted = true; },
@@ -1360,6 +1361,7 @@ export class RunnerCore {
if (hookEvent === "Stop") this.lastHeartbeatMs = Date.parse(this.io.now());
return {
sequence: ++this.activitySequence,
...(this.identity.runId ? { runId: this.identity.runId } : {}),
sessionName: this.identity.sessionName,
nodeId: this.identity.nodeId ?? null,
generation: this.identity.generation ?? null,
@@ -1689,6 +1691,9 @@ export async function main(argv = process.argv.slice(2)): Promise<void> {
);
const endpoint = resolveActivityEndpoint(process.env);
// A legacy install-token launch does not acquire run authority from inherited environment.
// Preserve a bound credential's supplied claim so Core can refuse a stale or mismatched report.
const runId = endpoint?.token.startsWith("oag_") ? process.env.OPENRIG_RUN_ID : undefined;
const childEnv = buildPiChildEnv(process.env as Record<string, string | undefined>, {
agentDir: paths.agentDir,
sessionsDir: paths.sessionsDir,
@@ -1822,7 +1827,7 @@ export async function main(argv = process.argv.slice(2)): Promise<void> {
body: JSON.stringify(payload),
signal: controller.signal,
})
.then(async (response) => response.ok && runtime === "omp" ? await response.json() as Record<string, unknown> : null)
.then(async (response) => response.ok && (runtime === "omp" || runId) ? await response.json() as Record<string, unknown> : null)
.catch(() => null)
.finally(() => clearTimeout(timeout));
});
@@ -1838,9 +1843,9 @@ export async function main(argv = process.argv.slice(2)): Promise<void> {
postActivity: (payload) => {
if (!endpoint || typeof fetch !== "function") return;
const result = postInOrder(payload).catch(() => null);
// OMP reads the acknowledgement so identity delivery can retry; null
// Managed runs and OMP read the acknowledgement so identity delivery can retry; null
// (transport failure or a non-OK reply) means "not yet persisted".
if (runtime === "omp") return result;
if (runtime === "omp" || runId) return result;
},
writeSidecar: (state) => {
try {
@@ -1889,6 +1894,7 @@ export async function main(argv = process.argv.slice(2)): Promise<void> {
sessionName: args.sessionName, nodeId: process.env.OPENRIG_NODE_ID, launchId: args.launchId,
// Carry the emitting tenure; never infer it from a later daemon read or Pi event.
generation: process.env.OPENRIG_OCCUPANT_GENERATION,
runId: runId,
}, { catchUpSince, runtime, usageFacts: args.usageFacts === true, openRouterCharge: args.openRouterCharge === true, launchCredentialEvidence,
requestedSessionFile: args.sessionFile });
@@ -1905,8 +1911,8 @@ export async function main(argv = process.argv.slice(2)): Promise<void> {
}
core.handlePiLine(line);
});
// Re-deliver OMP identity until the daemon confirms the resume token.
const identityRetry = runtime === "omp" ? setInterval(() => core.retrySessionIdentity(), IDENTITY_RETRY_MS) : undefined;
// Re-deliver managed run identity after pending activation/cutover, and legacy OMP identity as before.
const identityRetry = runtime === "omp" || runId ? setInterval(() => core.retrySessionIdentity(), IDENTITY_RETRY_MS) : undefined;
identityRetry?.unref();
const heartbeatTimer = startHeartbeatTimer(core, runtime);
// The last few stderr lines, so an OMP that exits during startup is
@@ -1,3 +1,4 @@
import type { RunnerGrantLaunch, RunnerGrantIssuer } from "../domain/runner-grant-launch.js";
import { piAccountSwitchStateRoot } from "../domain/pi-account-state.js";
// OPR.0.4.6.PI1 — the Pi runtime adapter (RPC-first, runner-in-a-pane).
//
@@ -49,6 +50,7 @@ export interface PiAdapterFsOps {
export interface PiRuntimeAdapterDeps {
tmux: TmuxAdapter;
runnerGrants?: RunnerGrantIssuer;
seatLaunchEnvironment?: SeatLaunchEnvironment;
fsOps: PiAdapterFsOps;
/** Root under which every Pi seat gets its isolated state dir (FR-7).
@@ -77,6 +79,7 @@ export interface PiRuntimeAdapterDeps {
export class PiRuntimeAdapter implements RuntimeAdapter {
readonly runtime: string = "pi";
protected tmux: TmuxAdapter;
protected runnerGrants?: RunnerGrantIssuer;
protected seatLaunchEnvironment?: SeatLaunchEnvironment;
protected fs: PiAdapterFsOps;
protected stateRoot: string;
@@ -94,6 +97,7 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
this.usageFactsEnabled = deps.usageFactsEnabled ?? (() => false);
this.openRouterChargeEnabled = deps.openRouterChargeEnabled ?? (() => false);
this.tmux = deps.tmux;
this.runnerGrants = deps.runnerGrants;
this.seatLaunchEnvironment = deps.seatLaunchEnvironment;
this.fs = deps.fsOps;
this.stateRoot = deps.stateRoot;
@@ -107,6 +111,7 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
getCommunityDeps(): PiRuntimeAdapterDeps {
return {
tmux: this.tmux,
runnerGrants: this.runnerGrants,
seatLaunchEnvironment: this.seatLaunchEnvironment,
fsOps: this.fs,
stateRoot: this.stateRoot,
@@ -280,7 +285,7 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
async launchHarness(
binding: NodeBinding,
opts: { name: string; resumeToken?: string; forkSource?: ForkSource },
opts: { name: string; resumeToken?: string; forkSource?: ForkSource; beforeNativeDispatch?: () => boolean },
): Promise<HarnessLaunchResult> {
const wrapped = binding.launchWrapper?.length ? binding.launchWrapper : undefined;
if (wrapped && (this.runtime !== "pi" || !this.seatLaunchEnvironment)) {
@@ -341,90 +346,123 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
// 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(location.sessionName, location.stateRoot);
this.fs.writeFile(
paths.runnerStatePath,
JSON.stringify(buildPendingRunnerState(launchId, new Date().toISOString(), prior)),
);
let authority: RunnerGrantLaunch | undefined;
let authorityUnavailable = false;
let dispatchedWithoutAuthority = false;
if (this.runtime !== "omp") {
try { authority = await this.runnerGrants?.(binding, this.runtime, launchId, this.runtime === "pi" ? 0 : 2); }
catch { authorityUnavailable = true; }
}
let authorizedLaunchSucceeded = false;
try {
const prior = this.readRunnerState(location.sessionName, location.stateRoot);
this.fs.writeFile(
paths.runnerStatePath,
JSON.stringify(buildPendingRunnerState(launchId, new Date().toISOString(), prior)),
);
const trust = this.runtime === "omp"
? (yoloEnabled(process.env, binding.launchPosture) ? "approve" : "no-approve")
: piTrust(this.trustPosture, process.env, binding.launchPosture);
const appliedLaunch = this.runtime === "omp"
? observeOmpApprovalMode(`--approval-mode ${trust === "approve" ? "yolo" : "always-ask"}`)
: observePiResourceTrust(trust);
// An account home is Pi's own, as on main: OMP and a pi-rpc fork take none, and the runner refuses --account-home
// for any runtime but pi.
const accountHome = this.runtime !== "pi" ? undefined
: binding.accountHome !== undefined ? binding.accountHome
: binding.nodeId ? this.seatLaunchEnvironment?.accountHome(binding.nodeId, this.runtime) : undefined;
const preparedHome = accountHome === null && binding.nodeId
? this.seatLaunchEnvironment?.accountHome(binding.nodeId, this.runtime, true) : accountHome;
const cmd = buildPiRunnerCommand({
accountHome: preparedHome ?? undefined,
runtime: this.runtime,
command: this.commandName,
runnerEntryPath: this.runnerEntryPath,
sessionName: location.sessionName,
stateRoot: location.stateRoot,
cwd: binding.cwd,
model: binding.model,
// The seat's resolved effort, sent as Pi's --thinking; OMP unchanged.
thinking: this.runtime === "omp" ? undefined : binding.effort,
// OPR.0.4.8.2: Pi RESOURCE TRUST (not a permission policy). YOLO forces `approve` on every
// seat; otherwise the configured posture. Same decision used on the restore path (pi-resume).
trust,
sessionFile: opts.resumeToken,
forkRef,
launchId,
usageFacts: this.runtime === "pi" && this.usageFactsEnabled(),
openRouterCharge: this.runtime === "pi" && this.openRouterChargeEnabled(),
...(resolvedWrapper ? { launchWrapper: resolvedWrapper } : {}),
});
const trust = this.runtime === "omp"
? (yoloEnabled(process.env, binding.launchPosture) ? "approve" : "no-approve")
: piTrust(this.trustPosture, process.env, binding.launchPosture);
const appliedLaunch = this.runtime === "omp"
? observeOmpApprovalMode(`--approval-mode ${trust === "approve" ? "yolo" : "always-ask"}`)
: observePiResourceTrust(trust);
// An account home is Pi's own, as on main: OMP and a pi-rpc fork take none, and the runner refuses --account-home
// for any runtime but pi.
const accountHome = this.runtime !== "pi" ? undefined
: binding.accountHome !== undefined ? binding.accountHome
: binding.nodeId ? this.seatLaunchEnvironment?.accountHome(binding.nodeId, this.runtime) : undefined;
const preparedHome = accountHome === null && binding.nodeId
? this.seatLaunchEnvironment?.accountHome(binding.nodeId, this.runtime, true) : accountHome;
const cmd = buildPiRunnerCommand({
accountHome: preparedHome ?? undefined,
runtime: this.runtime,
command: this.commandName,
runnerEntryPath: this.runnerEntryPath,
sessionName: location.sessionName,
stateRoot: location.stateRoot,
cwd: binding.cwd,
model: binding.model,
// The seat's resolved effort, sent as Pi's --thinking; OMP unchanged.
thinking: this.runtime === "omp" ? undefined : binding.effort,
// OPR.0.4.8.2: Pi RESOURCE TRUST (not a permission policy). YOLO forces `approve` on every
// seat; otherwise the configured posture. Same decision used on the restore path (pi-resume).
trust,
sessionFile: opts.resumeToken,
forkRef,
launchId,
usageFacts: this.runtime === "pi" && this.usageFactsEnabled(),
openRouterCharge: this.runtime === "pi" && this.openRouterChargeEnabled(),
...(resolvedWrapper ? { launchWrapper: resolvedWrapper } : {}),
});
const fallback = cmd;
// Every Pi-protocol runtime (Pi and its pi-rpc forks) takes the seat launch environment; OMP unchanged.
const command = this.runtime !== "omp" && this.seatLaunchEnvironment
? await this.seatLaunchEnvironment.command(sessionName, cmd, { nodeId: binding.nodeId, generation: binding.launchGeneration, runtime: this.runtime, accountHome })
: fallback;
// Stage long commands without leaving a shell above the live runner.
let textResult = await this.tmux.sendShellCommand(sessionName, command, undefined, { stageIfLong: true, execInScript: true });
if (!textResult.ok && textResult.code === "launch_path_too_long" && command !== fallback && accountHome === undefined) {
textResult = await this.tmux.sendShellCommand(sessionName, fallback, undefined, { stageIfLong: true, execInScript: true });
}
if (!textResult.ok) {
return { ok: false, error: `Failed to send launch command: ${textResult.message}` };
}
const fallback = cmd;
// Every Pi-protocol runtime (Pi and its pi-rpc forks) takes the seat launch environment; OMP unchanged.
let command = this.runtime !== "omp" && this.seatLaunchEnvironment
? await this.seatLaunchEnvironment.command(sessionName, cmd, { nodeId: binding.nodeId, generation: authority?.generation ?? binding.launchGeneration, runtime: this.runtime, accountHome })
: fallback;
const originalCommand = command;
if (authority) command = authority.wrap(command);
if (opts.beforeNativeDispatch && !opts.beforeNativeDispatch()) return { ok: false, error: "Runner launch selection changed before dispatch." };
// Stage long commands without leaving a shell above the live runner.
let textResult = await this.tmux.sendShellCommand(sessionName, command, undefined, { stageIfLong: true, execInScript: true });
if (!textResult.ok && textResult.code === "launch_path_too_long" && authority && command !== originalCommand) {
try { await authority.finish(false); } catch { /* optional authority cleanup cannot prevent the original launch */ }
authority = undefined; authorityUnavailable = true;
if (opts.beforeNativeDispatch && !opts.beforeNativeDispatch()) return { ok: false, error: "Runner launch selection changed before dispatch." };
textResult = await this.tmux.sendShellCommand(sessionName, originalCommand, undefined, { stageIfLong: true, execInScript: true });
}
if (!textResult.ok && textResult.code === "launch_path_too_long" && originalCommand !== fallback && accountHome === undefined) {
if (opts.beforeNativeDispatch && !opts.beforeNativeDispatch()) return { ok: false, error: "Runner launch selection changed before dispatch." };
textResult = await this.tmux.sendShellCommand(sessionName, fallback, undefined, { stageIfLong: true, execInScript: true });
}
if (!textResult.ok) {
return { ok: false, error: `Failed to send launch command: ${textResult.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(location.sessionName, launchId, location.stateRoot);
if (!state.ok) return state.failure;
try { authority?.dispatched(); }
catch {
try { await authority?.finish(false); } catch { /* dispatch already happened; never retry native input */ }
authority = undefined; authorityUnavailable = true; dispatchedWithoutAuthority = true;
}
const sessionFile = state.value.sessionFile;
if (!sessionFile) {
return { ok: false, error: `${this.runtime} launch: the runner became ready but reported no session file` };
}
if (opts.resumeToken && sessionFile !== opts.resumeToken) {
// An exact resume must continue the requested history; a runner
// that came up on any other file started a different session. Same
// contract as the separate resume adapter's verifyResume.
return { ok: false, error: `${this.runtime} resume: the runner is ready but does not report the requested 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: `${this.runtime} fork: the runner reported the parent session file instead of the post-fork child` };
}
const validation = this.validateToken(sessionFile);
if (!validation.ok) {
return { ok: false, error: `${this.runtime} launch: the runner reported a malformed session file (${validation.error})` };
}
// 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(location.sessionName, launchId, location.stateRoot);
if (!state.ok) return state.failure;
// OMP lazily creates its JSONL on the first persisted turn. Readiness
// is real, but until then there is no history to resume after a reboot.
if (this.runtime === "omp" && !this.fs.exists(validation.token)) return { ok: true, appliedLaunch };
return { ok: true, resumeToken: validation.token, resumeType: this.resumeType, appliedLaunch };
const sessionFile = state.value.sessionFile;
if (!sessionFile) {
return { ok: false, error: `${this.runtime} launch: the runner became ready but reported no session file` };
}
if (opts.resumeToken && sessionFile !== opts.resumeToken) {
// An exact resume must continue the requested history; a runner
// that came up on any other file started a different session. Same
// contract as the separate resume adapter's verifyResume.
return { ok: false, error: `${this.runtime} resume: the runner is ready but does not report the requested 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: `${this.runtime} fork: the runner reported the parent session file instead of the post-fork child` };
}
const validation = this.validateToken(sessionFile);
if (!validation.ok) {
return { ok: false, error: `${this.runtime} launch: the runner reported a malformed session file (${validation.error})` };
}
// OMP lazily creates its JSONL on the first persisted turn. Readiness
// is real, but until then there is no history to resume after a reboot.
if (this.runtime === "omp" && !this.fs.exists(validation.token)) return { ok: true, appliedLaunch };
authorizedLaunchSucceeded = true;
return { ok: true, resumeToken: validation.token, resumeType: this.resumeType, appliedLaunch,
...(authority ? { activityGrantId: authority.grantId } : {}),
...(authorityUnavailable ? { warnings: [dispatchedWithoutAuthority
? "Native command dispatched; run-bound reporting could not be confirmed. Cleanup of its grant was attempted; nothing was replayed."
: "Run-bound reporting unavailable; using existing unbound install-token reporting."] } : {}) };
} finally {
try { await authority?.finish(authorizedLaunchSucceeded); } catch { /* optional reporting cannot change the native launch outcome */ }
}
}
async checkReady(binding: NodeBinding): Promise<ReadinessResult> {
+2 -3
View File
@@ -1103,9 +1103,8 @@ export class TmuxAdapter {
// A paste the guard proves it left unsubmitted (#2429) is submitted first, under this lease, once this send's own
// checks pass; they then aren't run a second time.
beforePaste: async () => {
// This send's own checks can wait (a conversation lookup, an input capture), so they run first: the guard's
// read of the input is then the last wait before the paste (#3076). A check that refuses leaves a leftover's
// record for the next delivery.
// This send's own checks can wait (a conversation lookup, an input capture), so they run first, and the
// guard's checks run last (#3076). A check that refuses leaves a leftover's record for the next delivery.
await options?.beforePaste?.();
const checks = async () => { beforeWrite(); beforeInput?.(); };
// The synchronous checks run again just before the key: the guard's re-read awaits after `checks`.
+6
View File
@@ -2,6 +2,12 @@ import { writeFileSync, renameSync } from "node:fs";
export const DAEMON_SHUTDOWN_TIMEOUT_MS = 10_000;
export const DAEMON_STOP_WAIT_MS = DAEMON_SHUTDOWN_TIMEOUT_MS + 2_000;
// Shared lifecycle budgets: an async command caller must outlive the owned-child
// readiness wait, one probe, shutdown cleanup, and a small process/I/O margin.
export const DAEMON_START_READINESS_WAIT_MS = 180_000;
export const DAEMON_START_PROBE_TIMEOUT_MS = 2_000;
export const DAEMON_START_COMMAND_TIMEOUT_MS = DAEMON_START_READINESS_WAIT_MS
+ DAEMON_START_PROBE_TIMEOUT_MS + DAEMON_STOP_WAIT_MS + 5_000;
export const DAEMON_HTTP_CONNECTION_GRACE_MS = 250;
export const DAEMON_SHUTDOWN_RECEIPT = "daemon-shutdown.json";
export interface DaemonShutdownReceipt {
+2
View File
@@ -1,3 +1,4 @@
import { runBoundActivityGrantsSchema } from "./migrations/165_run_bound_activity_grants.js";
import { piAccountSwitchesSchema } from "./migrations/151_pi_account_switches.js";
import { workflowInstanceSpecSchema } from "./migrations/152_workflow_instance_spec.js";
import { eventsRetentionMarksSchema } from "./migrations/153_events_retention_marks.js";
@@ -332,5 +333,6 @@ export const ALL_MIGRATIONS: Migration[] = [
sessionAddressReadIndexesSchema,
networkInstallationsSchema,
queueItemsReplyToIndexSchema,
runBoundActivityGrantsSchema,
queueListReadIndexesSchema,
];
@@ -0,0 +1,15 @@
import type { Migration } from "../migrate.js";
/** Durable actual-run binding; legacy grants remain explicitly unbound. */
export const runBoundActivityGrantsSchema: Migration = {
name: "165_run_bound_activity_grants.sql",
sql: `
ALTER TABLE activity_grants ADD COLUMN run_id TEXT;
ALTER TABLE activity_grants ADD COLUMN session_id TEXT;
ALTER TABLE activity_grants ADD COLUMN activated_at TEXT;
ALTER TABLE activity_grants ADD COLUMN binding_json TEXT;
CREATE INDEX idx_activity_grants_run ON activity_grants(node_id, run_id);
CREATE UNIQUE INDEX idx_activity_grants_active_run ON activity_grants(node_id)
WHERE run_id IS NOT NULL AND activated_at IS NOT NULL AND revoked_at IS NULL;
`,
};
+173 -9
View File
@@ -1,17 +1,25 @@
// A seat activity grant: the authority a workload outside the host's own hook setup, such as
// a guest in another environment or a runner inside one, uses to post activity for ONE seat and ONE launch generation.
// a guest in another environment or a runner inside one, uses to post activity for ONE seat. Managed hosted runners
// additionally bind that authority to one actual launch, its concrete Core pane and originating session.
// The install-wide hook token (activity-endpoint.ts) speaks for every seat, so it never goes into a guest; a grant goes
// there instead, under the same variable name, so runners need no change. The ingress (routes/activity.ts) resolves
// the grant first and only then accepts the seat and generation a post claims.
import type Database from "better-sqlite3";
import { createHash, randomBytes } from "node:crypto";
import { SessionRegistry } from "./session-registry.js";
import { SeatIdentityStore } from "./seat-identity-store.js";
import { monotonicFactory } from "ulid";
const ulid = monotonicFactory();
const TOKEN_PREFIX = "oag_";
export type RunGrantBinding = { runtime: string; runner: number; pane: string; panePid: number };
export type ActivityGrant = {
runId: string | null;
sessionId: string | null;
activatedAt: string | null;
binding: RunGrantBinding | null;
grantId: string;
nodeId: string;
generation: string;
@@ -28,12 +36,15 @@ export class ActivityGrantError extends Error {
}
type Row = { grant_id: string; node_id: string; generation: string; issued_at: string; issued_by: string;
revoked_at: string | null; revoke_reason: string | null };
revoked_at: string | null; revoke_reason: string | null; run_id?: string | null; session_id?: string | null;
activated_at?: string | null; binding_json?: string | null };
const sha256 = (token: string): string => createHash("sha256").update(token).digest("hex");
function toGrant(row: Row): ActivityGrant {
return { grantId: row.grant_id, nodeId: row.node_id, generation: row.generation, issuedAt: row.issued_at,
let binding: RunGrantBinding | null = null;
try { binding = row.binding_json ? JSON.parse(row.binding_json) : null; } catch { /* unavailable */ }
return { runId: row.run_id ?? null, sessionId: row.session_id ?? null, activatedAt: row.activated_at ?? null, binding, grantId: row.grant_id, nodeId: row.node_id, generation: row.generation, issuedAt: row.issued_at,
issuedBy: row.issued_by, revokedAt: row.revoked_at, revokeReason: row.revoke_reason };
}
@@ -45,13 +56,17 @@ export function hasActivityGrantsTable(db: Database.Database): boolean {
export class ActivityGrantStore {
constructor(private readonly db: Database.Database, private readonly now: () => Date = () => new Date()) {}
/** Issue the node's grant for `generation`: the occupant generation its launch reserved, which may not be registered
* yet. One active grant per node, so this revokes any earlier one ("reissued"): a lost response is reconciled by
* issuing again. The token is returned once; only its sha256 is stored. */
issue(input: { nodeId: string; generation: string; issuedBy: string }): ActivityGrant & { token: string } {
/** Legacy issuance revokes earlier grants as before. An actual-run issuance stays pending and preserves
* the active predecessor; only another pending credential for this same run is superseded. Activation owns cutover.
* The token is returned once; only its sha256 is stored. */
issue(input: { nodeId: string; generation: string; issuedBy: string; run?: { runId: string; sessionId?: string; binding: RunGrantBinding } }): ActivityGrant & { token: string } {
if (!input.nodeId.trim() || !input.generation.trim() || !input.issuedBy.trim()) {
throw new ActivityGrantError("invalid_grant_input", "An activity grant needs a node, a launch generation and who issued it.");
}
if (input.run && (!input.run.runId.trim() || !input.run.binding.runtime || !input.run.binding.pane
|| !Number.isSafeInteger(input.run.binding.panePid) || input.run.binding.panePid <= 0)) {
throw new ActivityGrantError("invalid_grant_input", "A run grant needs a concrete Core launch binding.");
}
const token = `${TOKEN_PREFIX}${randomBytes(32).toString("hex")}`;
const grantId = `agrant-${ulid()}`;
const issuedAt = this.now().toISOString();
@@ -59,9 +74,14 @@ export class ActivityGrantStore {
if (!this.db.prepare("SELECT 1 FROM nodes WHERE id = ?").get(input.nodeId)) {
throw new ActivityGrantError("node_not_found", `No seat with node ${input.nodeId}.`);
}
this.revokeActive(input.nodeId, "reissued", issuedAt);
if (input.run) {
this.db.prepare("UPDATE activity_grants SET revoked_at = ?, revoke_reason = 'reissued' WHERE node_id = ? AND run_id = ? AND activated_at IS NULL AND revoked_at IS NULL")
.run(issuedAt, input.nodeId, input.run.runId);
} else this.revokeActive(input.nodeId, "reissued", issuedAt);
this.db.prepare(`INSERT INTO activity_grants (grant_id, node_id, generation, token_sha256, issued_by, issued_at)
VALUES (?, ?, ?, ?, ?, ?)`).run(grantId, input.nodeId, input.generation, sha256(token), input.issuedBy, issuedAt);
if (input.run) this.db.prepare("UPDATE activity_grants SET run_id = ?, session_id = ?, binding_json = ? WHERE grant_id = ?")
.run(input.run.runId, input.run.sessionId ?? null, JSON.stringify(input.run.binding), grantId);
})();
return { ...this.get(grantId)!, token };
}
@@ -80,10 +100,154 @@ export class ActivityGrantStore {
}
activeForNode(nodeId: string): ActivityGrant | null {
const row = this.db.prepare("SELECT * FROM activity_grants WHERE node_id = ? AND revoked_at IS NULL").get(nodeId) as Row | undefined;
const row = this.db.prepare(this.hasRunColumns()
? "SELECT * FROM activity_grants WHERE node_id = ? AND revoked_at IS NULL AND (run_id IS NULL OR activated_at IS NOT NULL) ORDER BY activated_at DESC LIMIT 1"
: "SELECT * FROM activity_grants WHERE node_id = ? AND revoked_at IS NULL").get(nodeId) as Row | undefined;
return row ? toGrant(row) : null;
}
/** Activate only against the committed Core binding. Staged launches remain pending until cutover. */
activate(grantId: string): boolean {
return this.db.transaction(() => {
const grant = this.get(grantId);
if (!grant?.runId || grant.revokedAt || !this.matchesBinding(grant, false, !grant.activatedAt)) return false;
if (grant.activatedAt) return true;
const active = this.activeForNode(grant.nodeId);
if (active?.runId && active.grantId > grant.grantId && this.bindingMatch(active, true) !== false) return false;
const session = this.currentSession(grant.nodeId)!;
// Only this pending, Core-selected launch may fill an empty declaration; never overwrite a conflicting record.
if (grant.binding!.runtime !== "pi" && session.agent_app == null && session.agent_runner == null) {
this.db.prepare("UPDATE sessions SET agent_app = ?, agent_runner = ? WHERE id = ?")
.run(grant.binding!.runtime, grant.binding!.runner, session.id);
}
const at = this.now().toISOString();
this.db.prepare("UPDATE activity_grants SET revoked_at = ?, revoke_reason = 'run_replaced' WHERE node_id = ? AND grant_id <> ? AND revoked_at IS NULL AND (run_id IS NULL OR activated_at IS NOT NULL)")
.run(at, grant.nodeId, grantId);
this.db.prepare("UPDATE activity_grants SET activated_at = ?, session_id = ? WHERE grant_id = ? AND revoked_at IS NULL")
.run(at, session.id, grantId);
return true;
})();
}
/** Core's existing claimed-session commit is the staged activation point. Only an unambiguous
* matching launch may restore its recorded runner declaration onto that new session row. */
reconcileBinding(nodeId: string): void {
if (!hasActivityGrantsTable(this.db) || !this.hasRunColumns()) return;
try { this.db.transaction(() => {
const grants = (this.db.prepare("SELECT * FROM activity_grants WHERE node_id = ? AND run_id IS NOT NULL AND revoked_at IS NULL")
.all(nodeId) as Row[]).map(toGrant);
if (grants.length === 0) return;
const session = this.currentSession(nodeId);
if (!session) return;
const registry = new SessionRegistry(this.db);
const generation = registry.currentOccupantTenure(nodeId)?.generationUuid;
const candidates = grants.filter(grant => grant.binding?.pane === session.tmux_pane && grant.binding.runtime === session.runtime
&& (grant.generation === generation || (grant.activatedAt && registry.isOccupantContinuedByAdoption(nodeId, grant.generation)
&& this.adoptionAnchorMatches(grant, session.session_name))));
if (candidates.length === 1) {
const grant = candidates[0]!;
if (grant.binding!.runtime !== "pi" && session.agent_app == null && session.agent_runner == null) {
this.db.prepare("UPDATE sessions SET agent_app = ?, agent_runner = ? WHERE id = ?")
.run(grant.binding!.runtime, grant.binding!.runner, session.id);
}
if (!grant.activatedAt) this.activate(grant.grantId);
}
for (const grant of grants) {
if (grant.activatedAt && this.bindingMatch(grant, true) === false) this.revoke({ grantId: grant.grantId }, "binding_replaced");
}
})(); } catch { /* Optional reconciliation cannot turn an unreadable binding into revocation or fail registration. */ }
}
/** Pending issuance IDs for a Core-reserved operation, captured before its recovery awaits.
* This selects cleanup work; generation alone never authenticates a report or activates a grant. */
pendingForGeneration(nodeId: string, generation: string): ActivityGrant[] {
if (!hasActivityGrantsTable(this.db) || !this.hasRunColumns()) return [];
return (this.db.prepare(`SELECT * FROM activity_grants WHERE node_id = ? AND generation = ?
AND run_id IS NOT NULL AND activated_at IS NULL AND revoked_at IS NULL`).all(nodeId, generation) as Row[]).map(toGrant);
}
/** A status observation for an old row cannot revoke the replacement's run. */
revokeForSession(sessionId: string, reason: string): void {
if (!hasActivityGrantsTable(this.db) || !this.hasRunColumns()) return;
const row = this.db.prepare("SELECT node_id FROM sessions WHERE id = ?").get(sessionId) as { node_id: string } | undefined;
if (!row) return;
const current = this.currentSession(row.node_id);
const grants = (this.db.prepare("SELECT * FROM activity_grants WHERE node_id = ? AND run_id IS NOT NULL AND revoked_at IS NULL")
.all(row.node_id) as Row[]).map(toGrant);
for (const grant of grants) {
if ((grant.sessionId === sessionId && (current?.id === sessionId || this.bindingMatch(grant, true) === false))
|| (grant.activatedAt && current?.id === sessionId)) {
this.revoke({ grantId: grant.grantId }, reason);
}
}
}
standing(grantId: string): "current" | "pending" | "stale" | "unverified" | "revoked" {
const grant = this.get(grantId);
if (!grant || grant.revokedAt) return "revoked";
if (!grant.runId || !this.hasRunColumns()) return "stale";
if (!grant.activatedAt) return "pending";
const binding = this.bindingMatch(grant, true);
return binding === null ? "unverified" : binding ? "current" : "stale";
}
private hasRunColumns(): boolean {
return (this.db.prepare("PRAGMA table_info(activity_grants)").all() as Array<{ name: string }>).some(row => row.name === "run_id");
}
private currentSession(nodeId: string): { id: string; session_name: string; agent_app: string | null; agent_runner: number | null; tmux_pane: string | null; runtime: string | null } | undefined {
return this.db.prepare(`SELECT s.id, s.session_name, s.agent_app, s.agent_runner, b.tmux_pane, n.runtime
FROM sessions s JOIN nodes n ON n.id=s.node_id JOIN bindings b ON b.node_id=n.id AND b.tmux_session=s.session_name
WHERE s.node_id=? AND s.id=(SELECT id FROM sessions WHERE node_id=? ORDER BY id DESC LIMIT 1)
AND s.status NOT IN ('exited','detached','superseded')`).get(nodeId, nodeId) as ReturnType<ActivityGrantStore["currentSession"]>;
}
private matchesBinding(grant: ActivityGrant, allowAdoption: boolean, allowUnrecorded = false): boolean {
return this.bindingMatch(grant, allowAdoption, allowUnrecorded) === true;
}
/** Unknown evidence refuses reports, but cannot authorize destructive lifecycle cleanup. */
private bindingMatch(grant: ActivityGrant, allowAdoption: boolean, allowUnrecorded = false): boolean | null {
try {
const anchor = grant.binding, session = this.currentSession(grant.nodeId);
if (!anchor || !session || session.tmux_pane !== anchor.pane || session.runtime !== anchor.runtime) return false;
if (anchor.runtime !== "pi" && ((session.agent_app != null && session.agent_app !== anchor.runtime)
|| (session.agent_runner != null && session.agent_runner !== anchor.runner))) return false;
const declarationMissing = anchor.runtime !== "pi" && (session.agent_app == null || session.agent_runner == null)
&& !(allowUnrecorded && session.agent_app == null && session.agent_runner == null && [1, 2].includes(anchor.runner));
const registry = new SessionRegistry(this.db);
const current = registry.currentOccupantTenure(grant.nodeId)?.generationUuid;
if (!current) return null;
const adoption = current !== grant.generation;
if (adoption && (!allowAdoption || !registry.isOccupantContinuedByAdoption(grant.nodeId, grant.generation))) return false;
if (!adoption && grant.sessionId && grant.sessionId !== session.id) return false;
const evidence = new SeatIdentityStore(this.db).getForNode(grant.nodeId);
if (adoption) {
const match = this.adoptionAnchorMatch(grant, session.session_name);
if (match !== true) return match;
}
if (evidence?.sessionName === session.session_name && evidence.evidence.registeredPane === anchor.pane
&& Date.parse(evidence.observedAt) >= Date.parse(grant.issuedAt)
&& evidence.evidence.observedPanePid != null && evidence.evidence.observedPanePid !== anchor.panePid) return false;
// Missing declaration is unknown only after ruling out positive binding/tenure/process replacement.
return declarationMissing ? null : true;
} catch { return null; }
}
private adoptionAnchorMatches(grant: ActivityGrant, sessionName: string): boolean {
return this.adoptionAnchorMatch(grant, sessionName) === true;
}
private adoptionAnchorMatch(grant: ActivityGrant, sessionName: string): boolean | null {
const store = new SeatIdentityStore(this.db), evidence = store.getForNode(grant.nodeId);
const process = store.getProcessEvidence(grant.nodeId);
if (!evidence || !process || evidence.sessionName !== sessionName || process.sessionName !== sessionName
|| evidence.evidence.observedPanePid == null || process.panePid == null) return null;
return !!grant.binding && evidence.sessionName === sessionName && process.sessionName === sessionName
&& evidence.evidence.registeredPane === grant.binding.pane
&& evidence.evidence.observedPanePid === grant.binding.panePid && process.panePid === grant.binding.panePid;
}
/** The grant a presented token belongs to, revoked or not, or null for any other token. */
authenticate(token: string | null | undefined): ActivityGrant | null {
if (!token?.startsWith(TOKEN_PREFIX) || !hasActivityGrantsTable(this.db)) return null;
@@ -1,5 +1,10 @@
import type Database from "better-sqlite3";
import type { AppliedLaunchAxis, AppliedLaunchObservation, AppliedLaunchState } from "./permission-drift.js";
import type {
AppliedLaunchAxis,
AppliedLaunchObservation,
AppliedLaunchState,
CodexObservedApprovalPolicy,
} from "./permission-drift.js";
export interface StoredAppliedLaunchObservation extends AppliedLaunchObservation {
generationUuid: string;
@@ -13,11 +18,25 @@ interface ObservationRow {
observation_state: string;
value: string | null;
reason: string | null;
approval_policy: "never" | null;
approval_policy: CodexObservedApprovalPolicy | null;
daemon_opt_out?: number | null;
observed_at: string;
}
function rowToStoredObservation(row: ObservationRow): StoredAppliedLaunchObservation {
return {
generationUuid: row.generation_uuid,
runtime: row.runtime,
axis: row.axis as AppliedLaunchAxis,
state: row.observation_state as AppliedLaunchState,
value: row.value,
...(row.reason ? { reason: row.reason } : {}),
...(row.approval_policy ? { approvalPolicy: row.approval_policy } : {}),
...(row.daemon_opt_out === 0 || row.daemon_opt_out === 1 ? { daemonOptOut: row.daemon_opt_out === 1 } : {}),
observedAt: row.observed_at,
};
}
/**
* Best-effort, generation-scoped persistence for the exact argument value
* returned by a successful managed launch. Missing migrations and DB failures
@@ -102,41 +121,39 @@ export class AppliedLaunchObservationStore {
WHERE invalidated.generation_uuid IS NULL
`).get(nodeId) as ObservationRow | undefined;
if (!row) return null;
return {
generationUuid: row.generation_uuid,
runtime: row.runtime,
axis: row.axis as AppliedLaunchAxis,
state: row.observation_state as AppliedLaunchState,
value: row.value,
...(row.reason ? { reason: row.reason } : {}),
...(row.approval_policy ? { approvalPolicy: row.approval_policy } : {}),
...(row.daemon_opt_out === 0 || row.daemon_opt_out === 1 ? { daemonOptOut: row.daemon_opt_out === 1 } : {}),
observedAt: row.observed_at,
};
return rowToStoredObservation(row);
} catch {
return null;
}
}
/** readCurrent's daemon choice for every node that has one recorded, in one read; unknown nodes are absent. */
readCurrentDaemonOptOuts(): Map<string, boolean> {
/** readCurrent's observation for every node that has one recorded, in one read; unknown nodes are absent. */
readCurrentObservations(): Map<string, StoredAppliedLaunchObservation> {
try {
const rows = this.db.prepare(`
SELECT t.node_id, o.daemon_opt_out
SELECT t.node_id, o.*
FROM occupant_tenures t
JOIN applied_launch_observations o
ON o.generation_uuid = t.generation_uuid
LEFT JOIN applied_launch_observation_invalidations invalidated
ON invalidated.generation_uuid = t.generation_uuid
WHERE invalidated.generation_uuid IS NULL
AND o.daemon_opt_out IN (0, 1)
AND t.generation_ordinal = (
SELECT MAX(latest.generation_ordinal) FROM occupant_tenures latest WHERE latest.node_id = t.node_id
)
`).all() as Array<{ node_id: string; daemon_opt_out: number }>;
return new Map(rows.map((row) => [row.node_id, row.daemon_opt_out === 1]));
`).all() as Array<ObservationRow & { node_id: string }>;
return new Map(rows.map((row) => [row.node_id, rowToStoredObservation(row)]));
} catch {
return new Map();
}
}
/** readCurrent's daemon choice for every node that has one recorded, in one read; unknown nodes are absent. */
readCurrentDaemonOptOuts(): Map<string, boolean> {
const out = new Map<string, boolean>();
for (const [nodeId, obs] of this.readCurrentObservations()) {
if (obs.daemonOptOut !== undefined) out.set(nodeId, obs.daemonOptOut);
}
return out;
}
}
@@ -398,7 +398,7 @@ export function buildPostCompactRestorePrompt(input: {
function buildPostCompactCompliancePrompt(postRestoreAuditInstruction?: string | null): string {
const pieces = [
"Now audit your compaction restore before doing any other work.",
"Now audit your compaction restore: read the required items below before acting on the restored work, and keep any hold you verified as live.",
];
const instruction = postRestoreAuditInstruction?.trim();
if (instruction) {
@@ -409,7 +409,7 @@ function buildPostCompactCompliancePrompt(postRestoreAuditInstruction?: string |
"For each item, mark read depth as FULL, PARTIAL, or NOT_READ.",
"Include the refocusing skill and the topology and work trace sources. Account for the current traces actually delivered during restore; do not rerun Python to duplicate them. If no current trace arrived, report that delivery gap. Read required notes and full sources with the native file-read tool; pointers, compact summaries and truncated extracts do not count as full source reads.",
"Required items are your restore map's ranked entries above your restore class's tier line (with no map: the instruction files and the packet's restore-instructions.md); the other restore packet files and the session JSONL stay lookup-only.",
"Read every required item that is not FULL in full now, without skimming, then report the final read-depth table before continuing.",
"Read every required item that is not FULL in full now, without skimming. Report the final read-depth table and the reconnect note as you resume; don't hold unaffected work for them.",
);
return pieces.join(" ");
}
@@ -65,20 +65,33 @@ export function codexMemberEnvelope(permissions: CodexMemberPermissions): CodexP
/** What an emitted envelope's arguments ask for; the native result still needs its own observation. */
export function observeCodexEnvelope(envelope: CodexPermissionEnvelope): AppliedLaunchObservation {
return { runtime: "codex", axis: "sandbox", state: "observed", value: envelope.sandboxPolicy.type,
reason: "emitted_launch_arguments", ...(envelope.approvalPolicy === "never" ? { approvalPolicy: "never" } : {}) };
approvalPolicy: envelope.approvalPolicy, reason: "emitted_launch_arguments" };
}
/** Parse and validate a seat's stored codex_permissions_json column with the canonical seat repair error. */
export function parseStoredCodexMemberPermissions(
nodeId: string,
codexPermissionsJson: string | null | undefined,
logicalId?: string | null,
): CodexMemberPermissions | undefined {
if (codexPermissionsJson == null) return undefined;
const seat = logicalId ? `${nodeId} (${logicalId})` : nodeId;
try {
return validateCodexMemberPermissions(JSON.parse(codexPermissionsJson));
} catch (error) {
throw new Error(`Seat ${seat}: cannot read codex_permissions (${(error as Error).message}). Repair it with rig seat set-permissions <seat> --codex-permissions <file> --reason <reason>, or choose a mode with --mode, before launching.`);
}
}
/** Read the current seat, not its original spec or a snapshot. Minimal old fixtures have no column. */
export function readCodexMemberPermissions(db: Database.Database, nodeId: string): CodexMemberPermissions | undefined {
let seat = nodeId;
let row: { logical_id?: string | null; codex_permissions_json?: string | null } | undefined;
try {
const row = db.prepare("SELECT * FROM nodes WHERE id = ?").get(nodeId) as { logical_id?: string; codex_permissions_json?: string | null } | undefined;
if (row?.codex_permissions_json == null) return undefined;
if (row.logical_id) seat += ` (${row.logical_id})`;
return validateCodexMemberPermissions(JSON.parse(row.codex_permissions_json));
row = db.prepare("SELECT * FROM nodes WHERE id = ?").get(nodeId) as typeof row;
} catch (error) {
throw new Error(`Seat ${seat}: cannot read codex_permissions (${(error as Error).message}). Repair it with rig seat set-permissions <seat> --codex-permissions <file> --reason <reason>, or choose a mode with --mode, before launching.`);
throw new Error(`Seat ${nodeId}: cannot read codex_permissions (${(error as Error).message}). Repair it with rig seat set-permissions <seat> --codex-permissions <file> --reason <reason>, or choose a mode with --mode, before launching.`);
}
return parseStoredCodexMemberPermissions(nodeId, row?.codex_permissions_json, row?.logical_id);
}
/** Clear a seat's envelope inside the caller's transaction, when an explicit mode replaces it; returns what it held, or
@@ -32,8 +32,8 @@
// specimen class) is gateway-routable: a PANELLESS virtual identity whose
// delivery rides the gateway, exactly like its @external address.
// 4. A hosted member whose roster entry declares a trigger is hosted. One with no
// trigger stays unroutable, so its person is told; its teaching says it reads the
// queue when its owner next starts a session, not to fix the address.
// trigger stays unroutable, so its person is told; its teaching says it picks up its
// work when its person asks it to or on a schedule they set, not to fix the address.
// 5. Anything else is unroutable, with teaching that names BOTH checks.
import { parseSessionName } from "../session-name.js";
import type { RosterWake } from "../rosters.js";
@@ -77,7 +77,7 @@ export function classifyDestination(destination: string, deps: ClassifyDeps): De
return {
class: "unroutable",
teaching: `unroutable: '${destination}' is a hosted member with no wake trigger, so nothing can push work to it. ` +
`It reads the queue when its owner next starts a session.`,
`It picks up its work when its person asks it to, or on a schedule they set.`,
};
}
return {
@@ -199,10 +199,13 @@ export interface InboundDeps {
/** S10 thread routing (deterministic, zero inference): resolve the destination + tags for an
* admitted event. Absent → every event lands on the static `destination` (the pre-routing
* shape, and the fallback the tests pin). */
resolveRoute?: (ev: SlackEvent) => { destination: string; tags?: string[]; correlationQitemId?: string; routeClass?: string };
resolveRoute?: (ev: SlackEvent) => { destination: string; tags?: string[]; correlationQitemId?: string; arrivalThread?: { threadTs: string; channel: string }; routeClass?: string };
/** Open a thread root for a person's top-level message (its own ts), so the seat's
* `--reply-to` reply and later updates land under it. Absent: nothing is minted. */
mintThread?: (m: { threadTs: string; channel: string; human: string; seat: string; conversationId: string }) => void;
/** Record the thread a reply arrived in without correlating it to a human gate, so the seat's
* `--reply-to` answer returns there. Absent: nothing is recorded. */
recordArrivalThread?: (input: { qitemId: string; threadTs: string; channel: string }) => void;
/** Continue an exact human gate through the existing Mission Control resolve primitive. */
/** `typedReply`: a typed thread reply, whose words a decision parked on its person stores as its answer; a click's return never does. */
resolveHumanReply?: (input: { qitemId: string; actorSession: string; decision: string; messageTs?: string; typedReply?: boolean }) => Promise<"resolved" | "resolved-uncertain" | "already-resolved" | "not-applicable">;
@@ -481,6 +484,9 @@ export class InboundRouter {
this.deps.log?.(`thread root not recorded for ${qitemId} (ts=${ts}): ${(e as Error).message}; its reply will post top-level`);
}
}
if (!replayedAnswer && route.arrivalThread) {
this.deps.recordArrivalThread?.({ qitemId, threadTs: route.arrivalThread.threadTs, channel: route.arrivalThread.channel });
}
this.deps.seen.mark(eventId, "landed"); // durable qitem exists → safe to mark
this.deps.log?.(`qitem ${qitemId} -> ${route.destination} (ts=${ts})`);
return { landed: true, qitemId, correlationQitemId: route.correlationQitemId, replyResolution };
@@ -44,7 +44,7 @@ import { HUMAN_ALERT_TAG, loadHumanRegistry, resolveSlackHandle } from "../human
import { sharedChannelAddresses, sharedChannelFor } from "../participants.js";
import { hasLiveHumanGate, isBlockerLive, type QueueRepository } from "../../queue-repository.js";
import { humanAnswerText } from "../../human-questions.js";
import { formatReplyToChoice, formatReplyToThreadMissing, REPLY_TO_CHOICE_ACTOR, REPLY_TO_CHAIN_LIMIT, type ReplyToChoice } from "../../reply-to-choice.js";
import { formatReplyToChoice, formatArrivalThread, formatReplyToThreadMissing, REPLY_TO_CHOICE_ACTOR, REPLY_TO_CHAIN_LIMIT, type ReplyToChoice } from "../../reply-to-choice.js";
import { parseSessionName } from "../../session-name.js";
import type { FetchImpl } from "./slack-api.js";
import {
@@ -544,7 +544,21 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire {
if (root.channel !== channel) return { kind: "fallback", reason: "root-other-channel", threadTs: root.threadTs };
return { kind: "thread", threadTs: root.threadTs };
}
if (!item.replyTo) return { kind: "fallback", reason: "root-missing", qitemId: item.qitemId };
if (!item.replyTo) {
// #2990: this reply has no gate correlation, but its arrival thread was recorded when
// it landed. Its answer returns there; the conversation's gate still recognizes only
// the newest root. Consult this record only at the end of the replyTo chain.
const arrival = opts.queueRepo.arrivalThreadFor(item.qitemId);
const root = arrival ? threadMap.resolveByThread(arrival.threadTs, arrival.channel) : null;
if (root) {
if (root.state === "closed") return { kind: "fallback", reason: "root-closed", threadTs: root.threadTs };
if (root.human !== (p.destinationSession ?? "")) return { kind: "fallback", reason: "root-other-human", threadTs: root.threadTs };
if (root.seat !== (p.sourceSession ?? "")) return { kind: "fallback", reason: "root-other-seat", threadTs: root.threadTs };
if (root.channel !== channel) return { kind: "fallback", reason: "root-other-channel", threadTs: root.threadTs };
return { kind: "thread", threadTs: root.threadTs };
}
return { kind: "fallback", reason: "root-missing", qitemId: item.qitemId };
}
item = opts.queueRepo.getById(item.replyTo);
}
return item
@@ -558,14 +572,21 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire {
// person, a closed root, a live gate) posts top-level in the replying seat's own channel, as before.
const personRootChannel = (p: OutboundPostPayload): string | null => {
let item = p.replyTo ? opts.queueRepo.getById(p.replyTo) : null;
let lastItem = item;
for (let depth = 0; item && depth < MAX_REPLY_TO_CHAIN; depth++) {
const root = threadMap.resolveByConversation(item.qitemId);
if (root) {
if (!item.tags?.includes(SLACK_THREAD_ROOT_TAG)) return null;
return deriveReplyToChoice(p, root.channel).kind === "thread" ? root.channel : null;
}
lastItem = item;
item = item.replyTo ? opts.queueRepo.getById(item.replyTo) : null;
}
// #2990: A reply in a superseded person-owned root uses that root's channel when a channel map would otherwise move it.
const arrival = !item && lastItem ? opts.queueRepo.arrivalThreadFor(lastItem.qitemId) : null;
const root = arrival ? threadMap.resolveByThread(arrival.threadTs, arrival.channel) : null;
const owner = root ? opts.queueRepo.getById(root.conversationId) : null;
if (root && owner?.tags?.includes(SLACK_THREAD_ROOT_TAG) && deriveReplyToChoice(p, root.channel).kind === "thread") return root.channel;
return null;
};
// Decided ONCE and recorded on the row before the first post. Every later attempt reuses
@@ -1392,6 +1413,13 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire {
log(`thread stamp failed for ${m.conversationId}: ${(e as Error).message}`);
}
},
recordArrivalThread: (input) => {
try {
opts.queueRepo.update({ qitemId: input.qitemId, actorSession: REPLY_TO_CHOICE_ACTOR, transitionNote: formatArrivalThread(input) });
} catch (e) {
log(`arrival thread record failed for ${input.qitemId}: ${(e as Error).message}`);
}
},
resolveHumanReply: opts.resolveHumanReply,
// #193 — a button click records its answer on the decision the clicked root belongs to;
// a failed hand-back is retried with the event dead-letters, and each click is confirmed
@@ -22,6 +22,8 @@ export interface InboundRoute {
tags: string[];
/** Exact human-gate qitem this thread was opened for. Absent on unmapped traffic. */
correlationQitemId?: string;
/** The thread a reply arrived in when its superseded root no longer correlates to the gate. */
arrivalThread?: { threadTs: string; channel: string };
/** The routing class that fired — receipts per class ride the row tags + logs. */
routeClass: "existing-thread" | "closed-thread" | "unmapped-thread" | "human-initiated";
}
@@ -77,7 +79,7 @@ export function makeThreadRouteResolver(opts: {
return {
destination: mapping.seat,
tags: [...BASE_TAGS, "thread", `reply-to:${mapping.conversationId}`],
...(current ? { correlationQitemId: mapping.conversationId } : {}),
...(current ? { correlationQitemId: mapping.conversationId } : { arrivalThread: { threadTs, channel: mapping.channel } }),
routeClass,
};
}
@@ -8,6 +8,7 @@ import { readHealthArtifact, healthSelectedContext } from "./health-context.js";
import { validateMissionComposition } from "./lifecycle-manifest.js";
import { healthHash, type HealthPolicyStore } from "./health-policy.js";
import type { HealthDetectorObservation, HealthObservationSource, HealthSourceCoverage } from "./health-detectors.js";
import { isOpenRigSender } from "../lib/pane-envelope.js";
import { adaptQueueTransitionEvidence, boundHealthEvidence, deriveHealthSourceFreshness, healthEpisodeId, type HealthScope, type PassiveCeremony, type CeremonyProgressAssessment } from "./health-projection.js";
const detector = "process.ceremony-amplification";
@@ -22,9 +23,13 @@ const tagged = (tags: string[], prefix: string) => tags.filter((t) => t.startsWi
* parked-owner wakes (queue-repository.ts, policies/parked-owner-consumer.ts) are excluded under any actor. */
const DAEMON_BOOKKEEPING_NOTE_PREFIXES = ["auto-unparked:", "blocker custody moved ", "park wake ", "parked-owner "] as const;
/** The daemon's own actors: `*@system` (watchdog, queue, stuck-sweep, wake-ladder, claim, ...) and the
* bare `system` that releases or transfers claims when a seat's occupant changes (queue-repository.ts). */
const isSystemActor = (actor: string) => actor === "system" || actor.endsWith("@system");
/** The daemon's own actors: `*@system` (watchdog, queue, stuck-sweep, wake-ladder, claim, ...), the
* bare `system` that releases or transfers claims when a seat's occupant changes (queue-repository.ts),
* and OpenRig's own sender (`isOpenRigSender`): the exact `daemon@kernel` (wake escalations, stream intake,
* the Slack gateway, quiet-release, delivery-deferral, ...) and every `system:*` actor (queue fallback
* routing, compaction restore notices, health, ...). Other kernel seats (`human@kernel`,
* `advisor-lead@kernel`) are seats and still count. */
const isSystemActor = (actor: string) => actor === "system" || actor.endsWith("@system") || isOpenRigSender(actor);
/** #2762: a coordination transition is one a seat decided; system actors and daemon bookkeeping notes are not. */
function isCoordinationTransition(t: { actorSession: string; transitionNote: string | null }): boolean {
@@ -85,10 +90,11 @@ export class PassiveCeremonySource implements HealthObservationSource {
if (!row) throw new Error("health_passive_lineage_parent_unavailable");
}
}
// Past the limit, evaluate the busiest families instead of none. A family qualifies
// by its transition count, so ranking by transitions in the window (then lineage ID,
// for a stable order) keeps every family that could qualify unless more than the
// limit do. The rest are reported as omitted: unevaluated, never healthy.
// Past the limit, evaluate the busiest families instead of none. The ranking counts every
// transition in the window, daemon bookkeeping included (then lineage ID, for a stable order).
// That raw count bounds the counted transitions a family qualifies by, so a family that could
// qualify is left out only when more than the limit reach the threshold on raw traffic.
// The rest are reported as omitted: unevaluated, never healthy.
const selectedFamilies = new Set([...roots.keys()]
.sort((a, b) => activity.get(b)! - activity.get(a)! || (a < b ? -1 : a > b ? 1 : 0))
.slice(0, familyLimit));
@@ -1,5 +1,5 @@
import { readClaudePermissionRules } from "./claude-permission-rules.js";
import { readCodexMemberPermissions } from "./codex-member-permissions.js";
import { readClaudePermissionRules, type ClaudePermissionRules } from "./claude-permission-rules.js";
import { parseStoredCodexMemberPermissions, type CodexMemberPermissions } from "./codex-member-permissions.js";
import type Database from "better-sqlite3";
import type { NodeBinding } from "./runtime-adapter.js";
import { permissionBindingOverride, type NativePermissionSelection } from "./native-permission-selection.js";
@@ -21,13 +21,102 @@ export interface ResolvedSeatPermission {
fallbackReason?: string;
}
export type LaunchOverrideSelectionFact =
| { state: "available"; value: { runtime: string; mode: string } | null | undefined }
| { state: "unavailable"; error: unknown };
export interface LaunchDefaultMetadata {
rigName: string;
memberPolicy: string | null;
rigPolicy: string | null;
codexProfile: string | null;
}
export interface LaunchOverrideFacts {
nodeId: string;
runtime: string;
selection: LaunchOverrideSelectionFact;
logicalId?: string | null;
codexPermissionsJson?: string | null;
codexEnvelopeReadError?: unknown;
codexPermissions?: CodexMemberPermissions;
claudePermissions?: ClaudePermissionRules;
launchDefaultMetadata?: LaunchDefaultMetadata | null;
target?: { selection: NativePermissionSelection | null; codexProfile: string | null };
}
export function validatePersistedPermissionSelection(row: { runtime: string; mode: string }): NativePermissionSelection {
if ((row.runtime !== "codex" && row.runtime !== "claude-code") || !/^[A-Za-z][A-Za-z0-9_]*$/.test(row.mode)
|| (row.runtime === "codex" && row.mode !== "floor" && row.mode !== "full_bypass")) {
throw new Error("Invalid persisted native permission selection; launch refused.");
}
return { runtime: row.runtime, mode: row.mode };
}
function resolveLaunchDefaultFromMetadata(
runtime: string,
meta: LaunchDefaultMetadata | null | undefined,
codexProfileOverride?: string | null,
): "kernel" | "team" | undefined {
if (runtime !== "claude-code" && runtime !== "codex") return undefined;
if (!meta || meta.memberPolicy != null || meta.rigPolicy != null
|| (runtime === "codex" && (codexProfileOverride === undefined ? meta.codexProfile : codexProfileOverride)?.trim())) {
return undefined;
}
return meta.rigName === "kernel" ? "kernel" : "team";
}
/** Pure launch-permission decision over already-read facts, shared by NativePermissionStore.launchOverride and batched node inventory. */
export function resolveLaunchOverrideFromFacts(
facts: LaunchOverrideFacts,
): Pick<NodeBinding, "launchPosture" | "permissionMode" | "kernelAuthority" | "teamPermissionDefault" | "claudePermissions" | "codexPermissions"> {
const ruleBinding = facts.claudePermissions === undefined ? {} : { claudePermissions: facts.claudePermissions };
let selection: NativePermissionSelection | null;
if (facts.target) {
selection = facts.target.selection;
} else {
if (facts.selection.state === "unavailable") {
throw facts.selection.error;
}
selection = facts.selection.value ? validatePersistedPermissionSelection(facts.selection.value) : null;
}
// A runtime switch's explicit target mode is a new choice that replaces the envelope: its successor launches with
// the mode, and its commit clears the envelope. So that launch doesn't read the envelope at all.
let codexPermissions: CodexMemberPermissions | undefined;
if (facts.runtime === "codex" && !(facts.target && selection)) {
if (facts.codexEnvelopeReadError !== undefined) {
throw new Error(`Seat ${facts.nodeId}: cannot read codex_permissions (${(facts.codexEnvelopeReadError as Error).message}). Repair it with rig seat set-permissions <seat> --codex-permissions <file> --reason <reason>, or choose a mode with --mode, before launching.`);
}
codexPermissions = facts.codexPermissions !== undefined
? facts.codexPermissions
: parseStoredCodexMemberPermissions(facts.nodeId, facts.codexPermissionsJson, facts.logicalId);
}
if (selection && selection.runtime !== facts.runtime) {
throw new Error("Seat runtime changed since permission selection; explicitly select again or inherit.");
}
// The envelope is this seat's explicit choice, like a mode: no kernel or team default, and the posture decision is
// replaced by its flags. The setter keeps one of the two; both stored means another writer, so refuse this seat
// rather than guess which one the person meant.
if (codexPermissions !== undefined) {
if (selection) throw new Error(`Seat ${facts.nodeId} records both a Codex permission envelope and the ${selection.mode} mode; launch refused. Choose one with rig seat set-permissions <seat> --codex-permissions <file> or --mode <mode> --reason <reason>.`);
return { codexPermissions, kernelAuthority: false, teamPermissionDefault: false };
}
const launchDefault = selection
? undefined
: resolveLaunchDefaultFromMetadata(facts.runtime, facts.launchDefaultMetadata, facts.target?.codexProfile);
if (launchDefault === "kernel") {
return { ...ruleBinding, kernelAuthority: true, teamPermissionDefault: false, launchPosture: facts.runtime === "codex" ? "full_bypass" : "floor" };
}
return { ...ruleBinding, kernelAuthority: false, teamPermissionDefault: launchDefault === "team", ...permissionBindingOverride(selection) };
}
/** The stable node owns the desired setting. Native history and current processes are untouched. */
export class NativePermissionStore {
constructor(private readonly db: Database.Database) {}
/** Kernel is the persisted class-of-one rig, never a pane-name or cwd heuristic.
* Authored policies and named Codex profiles keep their existing meaning. */
private launchDefault(nodeId: string, runtime: string, codexProfile?: string | null): "kernel" | "team" | undefined {
private readLaunchDefaultMetadata(nodeId: string, runtime: string): LaunchDefaultMetadata | undefined {
if (runtime !== "claude-code" && runtime !== "codex") return undefined;
try {
const row = this.db.prepare(`SELECT r.name, n.permission_policy AS member_policy,
@@ -35,9 +124,13 @@ export class NativePermissionStore {
FROM nodes n JOIN rigs r ON r.id = n.rig_id WHERE n.id = ?`).get(nodeId) as {
name: string; member_policy: string | null; rig_policy: string | null; profile: string | null;
} | undefined;
if (!row || row.member_policy != null || row.rig_policy != null
|| (runtime === "codex" && (codexProfile === undefined ? row.profile : codexProfile)?.trim())) return undefined;
return row.name === "kernel" ? "kernel" : "team";
if (!row) return undefined;
return {
rigName: row.name,
memberPolicy: row.member_policy,
rigPolicy: row.rig_policy,
codexProfile: row.profile,
};
} catch {
// This optional default must not block otherwise supported launches when
// its metadata lookup is unavailable. Keep the existing permission path.
@@ -45,32 +138,54 @@ export class NativePermissionStore {
}
}
private launchDefault(nodeId: string, runtime: string, codexProfile?: string | null): "kernel" | "team" | undefined {
return resolveLaunchDefaultFromMetadata(runtime, this.readLaunchDefaultMetadata(nodeId, runtime), codexProfile);
}
/** One decision shared by fresh/continue, legacy restore and same-seat handover. Slice 16's runtime switch passes
* `target`: the target runtime's explicit selection (or none, to inherit policy) and its Codex profile, since the
* stored ones belong to the runtime being left. A Codex envelope, like Claude rules, stays with the seat and applies
* whenever it runs Codex, until an explicit mode replaces it. */
launchOverride(nodeId: string, runtime: string, target?: { selection: NativePermissionSelection | null; codexProfile: string | null }): Pick<NodeBinding, "launchPosture" | "permissionMode" | "kernelAuthority" | "teamPermissionDefault" | "claudePermissions" | "codexPermissions"> {
const rules = runtime === "claude-code" ? readClaudePermissionRules(this.db, nodeId) : undefined;
const ruleBinding = rules === undefined ? {} : { claudePermissions: rules };
const selection = target ? target.selection : this.read(nodeId);
// A runtime switch's explicit target mode is a new choice that replaces the envelope: its successor launches with
// the mode, and its commit clears the envelope. So that launch doesn't read the envelope at all.
const codexPermissions = runtime === "codex" && !(target && selection) ? readCodexMemberPermissions(this.db, nodeId) : undefined;
if (selection && selection.runtime !== runtime) {
throw new Error("Seat runtime changed since permission selection; explicitly select again or inherit.");
const claudePermissions = runtime === "claude-code" ? readClaudePermissionRules(this.db, nodeId) : undefined;
let selection: LaunchOverrideSelectionFact;
if (target) {
selection = { state: "available", value: target.selection };
} else {
try {
const row = this.db.prepare("SELECT runtime, mode FROM node_permission_selections WHERE node_id = ?").get(nodeId) as {
runtime: string; mode: string;
} | undefined;
selection = { state: "available", value: row ?? null };
} catch (error) {
selection = { state: "unavailable", error };
}
}
// The envelope is this seat's explicit choice, like a mode: no kernel or team default, and the posture decision is
// replaced by its flags. The setter keeps one of the two; both stored means another writer, so refuse this seat
// rather than guess which one the person meant.
if (codexPermissions !== undefined) {
if (selection) throw new Error(`Seat ${nodeId} records both a Codex permission envelope and the ${selection.mode} mode; launch refused. Choose one with rig seat set-permissions <seat> --codex-permissions <file> or --mode <mode> --reason <reason>.`);
return { codexPermissions, kernelAuthority: false, teamPermissionDefault: false };
let logicalId: string | null | undefined;
let codexPermissionsJson: string | null | undefined;
let codexEnvelopeReadError: unknown;
if (runtime === "codex" && !(target && target.selection)) {
try {
const nodeRow = this.db.prepare("SELECT * FROM nodes WHERE id = ?").get(nodeId) as {
logical_id?: string | null; codex_permissions_json?: string | null;
} | undefined;
logicalId = nodeRow?.logical_id;
codexPermissionsJson = nodeRow?.codex_permissions_json;
} catch (error) {
codexEnvelopeReadError = error;
}
}
const launchDefault = selection ? undefined : this.launchDefault(nodeId, runtime, target?.codexProfile);
if (launchDefault === "kernel") {
return { ...ruleBinding, kernelAuthority: true, teamPermissionDefault: false, launchPosture: runtime === "codex" ? "full_bypass" : "floor" };
}
return { ...ruleBinding, kernelAuthority: false, teamPermissionDefault: launchDefault === "team", ...permissionBindingOverride(selection) };
return resolveLaunchOverrideFromFacts({
nodeId,
runtime,
selection,
logicalId,
codexPermissionsJson,
codexEnvelopeReadError,
claudePermissions,
launchDefaultMetadata: this.readLaunchDefaultMetadata(nodeId, runtime),
target,
});
}
read(nodeId: string): StoredNativePermissionSelection | null {
@@ -78,11 +193,8 @@ export class NativePermissionStore {
runtime: string; mode: string; actor: string; reason: string; updated_at: string;
} | undefined;
if (!row) return null;
if ((row.runtime !== "codex" && row.runtime !== "claude-code") || !/^[A-Za-z][A-Za-z0-9_]*$/.test(row.mode)
|| (row.runtime === "codex" && row.mode !== "floor" && row.mode !== "full_bypass")) {
throw new Error("Invalid persisted native permission selection; launch refused.");
}
return { runtime: row.runtime, mode: row.mode, actor: row.actor, reason: row.reason, updatedAt: row.updated_at };
const validated = validatePersistedPermissionSelection(row);
return { ...validated, actor: row.actor, reason: row.reason, updatedAt: row.updated_at };
}
write(nodeId: string, selection: NativePermissionSelection | null, actor: string, reason: string): void {
+216 -10
View File
@@ -1,7 +1,15 @@
import { currentClaudeMcpMenu } from "./claude-mcp-menu.js";
import { shellQuote } from "../adapters/shell-quote.js";
import { claudePostureFlag, codexPostureArg } from "../adapters/yolo-mode.js";
import { operationalLaunchArg } from "../adapters/kernel-authority.js";
import { claudeMcpArgs, type ClaudeMcp } from "./claude-mcp.js";
import { codexEnvelopeArgs } from "./codex-rebuild-permissions.js";
import { codexMemberEnvelope, type CodexMemberPermissions } from "./codex-member-permissions.js";
import {
observeCodexSandbox,
type AppliedLaunchObservation,
type CodexObservedApprovalPolicy,
} from "./permission-drift.js";
// L3 adds `attention_required` for the Claude resume-selection prompt proxy.
// Distinct from `inconclusive` (we don't know yet) and `failed` (terminal
@@ -32,6 +40,33 @@ export interface ProbeShellReadyInput {
paneContent: string | null;
}
export type CodexResumeLabelStatus =
| "recorded_match"
| "recorded_difference"
| "recorded_uncertain"
| "no_recorded_launch";
export interface CodexResumeDisplayInput {
codexConfigProfile?: string | null;
codexDaemonOptOut?: boolean;
codexPermissions?: CodexMemberPermissions;
resolvedPosture?: "floor" | "full_bypass" | "auto";
/** True when the effective persisted restore posture is the empty string (""), so managed resume falls back to
* daemon OPENRIG_YOLO at restore time while the displayed command uses the normalized floor fallback. */
emptySavedPosture?: boolean;
nonInterruptive?: boolean;
kernelAuthority?: boolean;
teamPermissionDefault?: boolean;
recordedLaunch?: AppliedLaunchObservation | null;
launchWrapper?: readonly string[] | null;
}
export interface CodexResumeDisplay {
command: string;
status: CodexResumeLabelStatus;
label: string | null;
}
const SHELL_COMMANDS = new Set(["bash", "fish", "nu", "sh", "tmux", "zsh"]);
// Provider model identifiers are not limited to the default gpt-* family.
// Keep the fallback structural so ordinary status prose cannot prove readiness.
@@ -44,6 +79,175 @@ const CODEX_RENDERED_SUFFIX = "(?: [A-Z][A-Za-z0-9]*)? (?:default|none|minimal|l
const CODEX_RENDERED_GPT_FIELD = new RegExp(`^[Gg][Pp][Tt]-\\d[\\w.-]*${CODEX_RENDERED_SUFFIX}$`);
const CODEX_RENDERED_MODEL_FIELD = new RegExp(`^[A-Za-z][A-Za-z0-9._:/@-]*[._:/@-]\\d[A-Za-z0-9._:/@-]*${CODEX_RENDERED_SUFFIX}(?:\\s+⚠ \\d+ warnings?)?$`);
function formatCurrentCodexFlags(
sandbox: string,
approval: CodexObservedApprovalPolicy | null | undefined,
): string {
return approval ? `-s ${sandbox} -a ${approval}` : `-s ${sandbox}`;
}
function formatRecordedCodexFlags(
sandbox: string,
approval: CodexObservedApprovalPolicy | null | undefined,
): string {
// Historical launch observations (and floor launches without an explicit -a flag) store approval_policy as NULL;
// never infer an approval value from current settings or claim NULL proves no -a flag was emitted.
return approval ? `-s ${sandbox} -a ${approval}` : `-s ${sandbox} (approval unrecorded)`;
}
const CODEX_LAUNCH_WRAPPER_OMISSION_LABEL = "omits launch_wrapper (managed resume uses the wrapper)";
function composeCodexResumeLabel(baseLabel: string | null, hasLaunchWrapper: boolean): string | null {
if (!hasLaunchWrapper) return baseLabel;
return baseLabel ? `${baseLabel}; ${CODEX_LAUNCH_WRAPPER_OMISSION_LABEL}` : CODEX_LAUNCH_WRAPPER_OMISSION_LABEL;
}
export function buildCodexResumeDisplay(
resumeToken: string,
input: CodexResumeDisplayInput = {},
opts?: { useLast?: boolean },
): CodexResumeDisplay {
const profile = input.codexConfigProfile ?? null;
const profileArg = profile ? ` -p ${shellQuote(profile)}` : "";
const effectivePosture = input.resolvedPosture ?? "floor";
const envelope = input.codexPermissions ? codexMemberEnvelope(input.codexPermissions) : undefined;
// Derive displayed flags from the same three inputs adapters/codex-resume.ts uses (member permission envelope,
// resolved posture with an empty env so ambient OPENRIG_YOLO never leaks, and operationalLaunchArg).
const posture = envelope ? profileArg + codexEnvelopeArgs(envelope) : codexPostureArg(profileArg, {}, effectivePosture);
const postureArg = posture + operationalLaunchArg(
"codex",
envelope
? { nonInterruptive: input.nonInterruptive }
: {
kernelAuthority: input.kernelAuthority,
teamPermissionDefault: input.teamPermissionDefault,
nonInterruptive: input.nonInterruptive,
launchPosture: effectivePosture,
},
);
const daemonOptOut = (input.codexDaemonOptOut ?? input.recordedLaunch?.daemonOptOut) === true;
const command = buildCodexResumeCore(
resumeToken,
profile,
opts?.useLast,
undefined,
effectivePosture,
undefined,
postureArg,
daemonOptOut,
);
const hasLaunchWrapper = Boolean(input.launchWrapper && input.launchWrapper.length > 0);
let currentSandbox: string | null = null;
let currentApproval: CodexObservedApprovalPolicy | null | undefined = null;
let currentFlags: string;
if (envelope) {
currentSandbox = envelope.sandboxPolicy.type;
currentApproval = envelope.approvalPolicy;
currentFlags = formatCurrentCodexFlags(currentSandbox, currentApproval);
} else {
const obs = observeCodexSandbox(posture);
if (obs.state === "observed" && obs.value) {
currentSandbox = obs.value;
currentApproval = obs.approvalPolicy ?? null;
currentFlags = formatCurrentCodexFlags(currentSandbox, currentApproval);
} else {
currentFlags = posture.trim();
}
}
const recorded = input.recordedLaunch ?? null;
const hasObservedRecord = recorded !== null
&& recorded.runtime === "codex"
&& recorded.axis === "sandbox"
&& recorded.state === "observed"
&& recorded.value !== null;
if (!envelope && input.emptySavedPosture) {
const fallbackClause = `saved posture empty, showing fallback ${currentFlags} (managed resume posture unknown)`;
if (!hasObservedRecord) {
return {
command,
status: "no_recorded_launch",
label: composeCodexResumeLabel(fallbackClause, hasLaunchWrapper),
};
}
const recordedFlags = formatRecordedCodexFlags(recorded.value!, recorded.approvalPolicy ?? null);
return {
command,
status: "recorded_uncertain",
label: composeCodexResumeLabel(`launched with ${recordedFlags}; ${fallbackClause}`, hasLaunchWrapper),
};
}
if (!hasObservedRecord) {
return {
command,
status: "no_recorded_launch",
label: composeCodexResumeLabel("flags from current settings", hasLaunchWrapper),
};
}
const recordedApproval = recorded.approvalPolicy ?? null;
// A NULL approval_policy cannot distinguish a plain floor launch without -a from a pre-upgrade envelope whose
// on-request/untrusted approval was not recorded, so only a non-null recorded approval can prove recorded_match.
if (
currentSandbox !== null
&& recorded.value === currentSandbox
&& recordedApproval !== null
&& recordedApproval === currentApproval
) {
return {
command,
status: "recorded_match",
label: composeCodexResumeLabel(null, hasLaunchWrapper),
};
}
const recordedFlags = formatRecordedCodexFlags(recorded.value!, recordedApproval);
const isSameSandboxUnrecordedApproval = currentSandbox !== null
&& recorded.value === currentSandbox
&& recorded.value !== "read-only"
&& recordedApproval === null
&& currentApproval === null;
return {
command,
status: isSameSandboxUnrecordedApproval ? "recorded_uncertain" : "recorded_difference",
label: composeCodexResumeLabel(`launched with ${recordedFlags}; a resume now uses ${currentFlags}`, hasLaunchWrapper),
};
}
export function buildNativeResumeDisplay(
runtime: string | null,
resumeToken: string | null,
sessionName?: string | null,
codexConfigProfile?: string | null,
/** Codex: the seat's recorded daemon choice; only a positive true adds --no-daemon (absent and false print as before). */
codexDaemonOptOut?: boolean,
claudeMcp?: ClaudeMcp | null,
codexDisplay?: CodexResumeDisplayInput,
launchWrapper?: readonly string[] | null,
): { command: string; label: string | null; status?: CodexResumeLabelStatus } | null {
if (!resumeToken) return null;
if (runtime === "claude-code") {
const nameSuffix = sessionName ? ` --name ${shellQuote(sessionName)}` : "";
const mcpSuffix = claudeMcpArgs(claudeMcp).map(arg => ` ${shellQuote(arg)}`).join("");
return {
command: `claude ${claudePostureFlag(process.env)} --resume ${shellQuote(resumeToken)}${nameSuffix}${mcpSuffix}`,
label: null,
};
}
if (runtime === "codex") {
return buildCodexResumeDisplay(resumeToken, {
...codexDisplay,
codexConfigProfile: codexDisplay?.codexConfigProfile !== undefined ? codexDisplay.codexConfigProfile : codexConfigProfile,
codexDaemonOptOut: codexDisplay?.codexDaemonOptOut !== undefined ? codexDisplay.codexDaemonOptOut : codexDaemonOptOut,
launchWrapper: codexDisplay?.launchWrapper !== undefined ? codexDisplay.launchWrapper : launchWrapper,
});
}
return null;
}
export function buildNativeResumeCommand(
runtime: string | null,
resumeToken: string | null,
@@ -52,17 +256,19 @@ export function buildNativeResumeCommand(
/** Codex: the seat's recorded daemon choice; only a positive true adds --no-daemon (absent and false print as before). */
codexDaemonOptOut?: boolean,
claudeMcp?: ClaudeMcp | null,
codexDisplay?: CodexResumeDisplayInput,
launchWrapper?: readonly string[] | null,
): string | null {
if (!resumeToken) return null;
if (runtime === "claude-code") {
const nameSuffix = sessionName ? ` --name ${shellQuote(sessionName)}` : "";
const mcpSuffix = claudeMcpArgs(claudeMcp).map(arg => ` ${shellQuote(arg)}`).join("");
return `claude ${claudePostureFlag(process.env)} --resume ${shellQuote(resumeToken)}${nameSuffix}${mcpSuffix}`;
}
if (runtime === "codex") {
return buildCodexResumeCore(resumeToken, codexConfigProfile, undefined, undefined, undefined, undefined, undefined, codexDaemonOptOut === true);
}
return null;
return buildNativeResumeDisplay(
runtime,
resumeToken,
sessionName,
codexConfigProfile,
codexDaemonOptOut,
claudeMcp,
codexDisplay,
launchWrapper,
)?.command ?? null;
}
/** Managed Codex seats run tool commands in a non-login shell, so they keep the seat's own environment, including the
+305 -34
View File
@@ -24,8 +24,19 @@ import { RigRepository, findLatestUsableSnapshot, findLatestUsableSnapshotsForAl
import { hasCompleteGuidanceBlock, seatGuidanceFile } from "./restore-guidance.js";
import { resolveNodeWorkspace } from "./workspace/workspace-resolver.js";
import { deriveCanonicalSessionName } from "./session-name.js";
import { buildNativeResumeCommand, buildCodexResumeCore } from "./native-resume-probe.js";
import { AppliedLaunchObservationStore } from "./applied-launch-observation-store.js";
import {
buildCodexResumeDisplay,
buildNativeResumeCommand,
buildNativeResumeDisplay,
type CodexResumeDisplayInput,
} from "./native-resume-probe.js";
import { AppliedLaunchObservationStore, type StoredAppliedLaunchObservation } from "./applied-launch-observation-store.js";
import { resolveLaunchOverrideFromFacts, type LaunchOverrideSelectionFact } from "./native-permission-store.js";
import {
readRegularCustomPolicyFile,
resolveDisplayedRestorePosture,
resolvePersistedRestorePosture,
} from "./permission-policy/persisted-restore-posture.js";
import { readReadyFreshLaunchFences } from "./fresh-occupant-relation.js";
import { daemonTmuxServerArgs, daemonTmuxServerInfo, tmuxAttachCommand } from "../adapters/tmux-server.js";
import { communityHarnessLabel } from "./community-harness-presets.js";
@@ -50,6 +61,18 @@ interface InventoryRow {
effort: string | null;
effort_choice: string | null;
codex_config_profile: string | null;
codex_permissions_json: string | null;
node_permission_policy: string | null;
node_policy_origin: string | null;
node_policy_resolved_target: string | null;
node_policy_declaring_dir: string | null;
node_policy_launch_posture: string | null;
rig_permission_policy: string | null;
rig_policy_origin: string | null;
rig_policy_resolved_target: string | null;
rig_policy_declaring_dir: string | null;
rig_policy_launch_posture: string | null;
rig_non_interruptive: number | null;
claude_mcp: ClaudeMcp | null;
launch_wrapper: string | null;
agent_ref: string | null;
@@ -114,8 +137,144 @@ interface BindingRow {
// -- Helpers --
function computeResumeCommand(runtime: string | null, resumeToken: string | null, codexConfigProfile?: string | null, codexDaemonOptOut?: boolean, claudeMcp?: ClaudeMcp | null): string | null {
return buildNativeResumeCommand(runtime, resumeToken, null, codexConfigProfile, codexDaemonOptOut, claudeMcp);
function readCustomPolicyCached(
target: string,
cache: Map<string, string | null>,
): string {
if (cache.has(target)) {
const cached = cache.get(target)!;
if (cached === null) throw new Error(`Cached unreadable custom policy target: ${target}`);
return cached;
}
try {
const content = readRegularCustomPolicyFile(target);
cache.set(target, content);
return content;
} catch (error) {
cache.set(target, null);
throw error;
}
}
function resolveBasePolicyPosture(
row: InventoryRow,
customPolicyCache: Map<string, string | null>,
): { posture: "floor" | "full_bypass" | "auto"; emptySavedPosture: boolean } {
const snapshot = {
nodeOrigin: row.node_policy_origin,
nodeLaunchPosture: row.node_policy_launch_posture,
nodeResolvedTarget: row.node_policy_resolved_target,
nodeDeclaringDir: row.node_policy_declaring_dir,
nodeRef: row.node_permission_policy,
rigOrigin: row.rig_policy_origin,
rigLaunchPosture: row.rig_policy_launch_posture,
rigResolvedTarget: row.rig_policy_resolved_target,
rigDeclaringDir: row.rig_policy_declaring_dir,
rigRef: row.rig_permission_policy,
};
const readFile = (target: string) => readCustomPolicyCached(target, customPolicyCache);
const posture = resolveDisplayedRestorePosture(snapshot, { readFile });
const raw = row.node_policy_launch_posture === "" || row.rig_policy_launch_posture === ""
? resolvePersistedRestorePosture({
getNodeProvenance: () =>
snapshot.nodeOrigin != null && snapshot.nodeLaunchPosture != null
? {
origin: snapshot.nodeOrigin,
launchPosture: snapshot.nodeLaunchPosture,
resolvedTarget: snapshot.nodeResolvedTarget,
declaringDir: snapshot.nodeDeclaringDir,
nodeRef: snapshot.nodeRef,
}
: null,
getFallbackRigRef: () => snapshot.rigRef,
getRigProvenance: () =>
snapshot.rigOrigin != null && snapshot.rigLaunchPosture != null
? {
origin: snapshot.rigOrigin,
launchPosture: snapshot.rigLaunchPosture,
resolvedTarget: snapshot.rigResolvedTarget,
declaringDir: snapshot.rigDeclaringDir,
rigRef: snapshot.rigRef,
}
: null,
readFile,
})
: posture;
return { posture, emptySavedPosture: raw === "" };
}
type PermissionSelectionsLookup =
| { state: "available"; byNodeId: Map<string, { runtime: string; mode: string }> }
| { state: "unavailable"; error: unknown };
type CodexResumeResolution =
| { ok: true; input: CodexResumeDisplayInput }
| { ok: false; diagnostic: string };
function resolveCodexResumeResolution(
row: InventoryRow,
recordedLaunch: StoredAppliedLaunchObservation | undefined,
permissionSelections: PermissionSelectionsLookup,
customPolicyCache: Map<string, string | null>,
launchWrapper?: readonly string[] | null,
): CodexResumeResolution {
const selection: LaunchOverrideSelectionFact = permissionSelections.state === "unavailable"
? permissionSelections
: { state: "available", value: permissionSelections.byNodeId.get(row.node_id) ?? null };
let override: ReturnType<typeof resolveLaunchOverrideFromFacts>;
try {
override = resolveLaunchOverrideFromFacts({
nodeId: row.node_id,
logicalId: row.logical_id,
runtime: "codex",
selection,
codexPermissionsJson: row.codex_permissions_json,
launchDefaultMetadata: {
rigName: row.rig_name,
memberPolicy: row.node_permission_policy,
rigPolicy: row.rig_permission_policy,
codexProfile: row.codex_config_profile,
},
});
} catch (error) {
return { ok: false, diagnostic: `Permission selection: ${(error as Error).message}` };
}
const basePosture = override.codexPermissions || override.launchPosture
? undefined
: resolveBasePolicyPosture(row, customPolicyCache);
return {
ok: true,
input: {
codexConfigProfile: row.codex_config_profile,
codexDaemonOptOut: recordedLaunch?.daemonOptOut,
codexPermissions: override.codexPermissions,
resolvedPosture: override.codexPermissions
? "floor"
: (override.launchPosture ?? basePosture!.posture),
...(basePosture?.emptySavedPosture ? { emptySavedPosture: true } : {}),
nonInterruptive: row.rig_non_interruptive === 1,
kernelAuthority: override.kernelAuthority === true,
teamPermissionDefault: override.teamPermissionDefault === true,
recordedLaunch: recordedLaunch ?? null,
...(launchWrapper && launchWrapper.length > 0 ? { launchWrapper } : {}),
},
};
}
function readPermissionSelectionsMap(db: Database.Database): PermissionSelectionsLookup {
try {
const rows = db.prepare("SELECT node_id, runtime, mode FROM node_permission_selections").all() as Array<{
node_id: string;
runtime: string;
mode: string;
}>;
return {
state: "available",
byNodeId: new Map(rows.map((row) => [row.node_id, { runtime: row.runtime, mode: row.mode }])),
};
} catch (error) {
return { state: "unavailable", error };
}
}
const CODEX_DAEMON_CHOICE_NOT_RECORDED = "Not recorded whether this seat's last managed launch used --no-daemon. If `codex --help` lists --no-daemon, "
@@ -129,9 +288,10 @@ function computeRecoveryGuidance(input: {
codexConfigProfile?: string | null;
/** Codex: the current generation's recorded daemon choice; undefined when none was recorded. */
codexDaemonOptOut?: boolean;
codexResolution?: CodexResumeResolution;
claudeMcp?: ClaudeMcp;
}): NodeRecoveryGuidance | null {
const { runtime, resumeToken, cwd, sessionName, codexConfigProfile, codexDaemonOptOut, claudeMcp } = input;
const { runtime, resumeToken, cwd, sessionName, codexConfigProfile, codexDaemonOptOut, codexResolution, claudeMcp } = input;
if (runtime === "claude-code") {
const commands: string[] = [];
@@ -161,35 +321,54 @@ function computeRecoveryGuidance(input: {
}
if (runtime === "codex") {
if (codexResolution && !codexResolution.ok) {
return {
summary: `Cannot derive Codex resume command — ${codexResolution.diagnostic}`,
commands: [],
notes: [codexResolution.diagnostic],
};
}
const commands: string[] = [];
const notes: string[] = [];
const codexDisplay = codexResolution?.input;
const displayInput: CodexResumeDisplayInput = {
...codexDisplay,
codexConfigProfile: codexDisplay?.codexConfigProfile !== undefined ? codexDisplay.codexConfigProfile : codexConfigProfile,
codexDaemonOptOut: codexDisplay?.codexDaemonOptOut !== undefined ? codexDisplay.codexDaemonOptOut : codexDaemonOptOut,
};
const effectiveDaemonChoice = displayInput.codexDaemonOptOut ?? displayInput.recordedLaunch?.daemonOptOut;
const primaryDisplay = resumeToken
? buildCodexResumeDisplay(resumeToken, displayInput)
: buildCodexResumeDisplay("", displayInput, { useLast: true });
const daemonOptOut = codexDaemonOptOut === true;
if (resumeToken) {
commands.push(buildCodexResumeCore(resumeToken, codexConfigProfile, undefined, undefined, undefined, undefined, undefined, daemonOptOut));
commands.push(primaryDisplay.command);
}
if (cwd) {
commands.push(`cd ${cwd}`);
}
if (!resumeToken) {
commands.push(buildCodexResumeCore("", codexConfigProfile, true, undefined, undefined, undefined, undefined, daemonOptOut));
commands.push(primaryDisplay.command);
}
if (primaryDisplay.label) {
notes.push(primaryDisplay.label);
}
notes.push("Use workspace and recent prompt text to identify the right conversation.");
if (codexConfigProfile) {
notes.push(`Preserve Codex config profile: ${codexConfigProfile}`);
if (displayInput.codexConfigProfile) {
notes.push(`Preserve Codex config profile: ${displayInput.codexConfigProfile}`);
}
if (sessionName) {
notes.push(`If the identity anchor was captured, the picker may include: ${sessionName}`);
}
if (codexDaemonOptOut === undefined) {
if (effectiveDaemonChoice === undefined) {
notes.push(CODEX_DAEMON_CHOICE_NOT_RECORDED);
}
return {
summary: resumeToken
? "Try native Codex resume first; the managed launch sets the explicit -s workspace-write floor flag unless a named config profile is present."
: `No stored Codex resume token. Try codex${daemonOptOut ? " --no-daemon" : ""} -s workspace-write resume --last; the managed launch sets the explicit -s workspace-write floor flag unless a named config profile is present.`,
? `Try native Codex resume first (${primaryDisplay.command}).`
: `No stored Codex resume token. Try ${primaryDisplay.command} from the seat workspace.`,
commands,
notes,
};
@@ -591,11 +770,7 @@ export function nodeExecutorConfirmedStopped(db: Database.Database, nodeId: stri
// -- Public API --
/**
* Get the canonical node inventory for a rig.
* Single source of truth consumed by CLI, UI, and MCP.
*/
export function getNodeInventory(db: Database.Database, rigId: string): NodeInventoryEntry[] {
function projectRigInventoryItems(db: Database.Database, rigId: string): ProjectedNodeInventoryItem[] {
// Resolve the rig's latest usable snapshot once for the whole projection so per-node
// recoverability checks share the same source of truth without N extra queries.
const usableSnapshot = findLatestUsableSnapshot(db, rigId);
@@ -611,15 +786,34 @@ export function getNodeInventory(db: Database.Database, rigId: string): NodeInve
// map = the prior WHERE rig_id=? filter; oriented history follows the rig's nodes.
const restoreOutcomes = buildRestoreOutcomeMap(db, rigId);
const orienteds = buildOrientedMap(db, rigId);
const codexDaemonOptOuts = new AppliedLaunchObservationStore(db).readCurrentDaemonOptOuts();
const currentLaunchObservations = new AppliedLaunchObservationStore(db).readCurrentObservations();
const permissionSelections = readPermissionSelectionsMap(db);
const customPolicyCache = new Map<string, string | null>();
const rows = queryInventoryRows(db, rigId);
return rows.map((row) =>
buildInventoryEntry(db, row, { usableSnapshot, workspaceSpec, identityVerdicts, restoreOutcomes, orienteds, codexDaemonOptOuts }),
buildInventoryItem(db, row, {
usableSnapshot,
workspaceSpec,
identityVerdicts,
restoreOutcomes,
orienteds,
currentLaunchObservations,
permissionSelections,
customPolicyCache,
}),
);
}
/**
* Get the canonical node inventory for a rig.
* Single source of truth consumed by CLI, UI, and MCP.
*/
export function getNodeInventory(db: Database.Database, rigId: string): NodeInventoryEntry[] {
return projectRigInventoryItems(db, rigId).map((item) => item.entry);
}
/**
* 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
@@ -640,6 +834,9 @@ function runInventoryRowQuery(db: Database.Database, whereClause: string, orderC
const nodeColumns = new Set(db.prepare("PRAGMA table_info(nodes)").all()
.map((row) => (row as { name?: string }).name));
const nodeColumnOrNull = (name: string) => nodeColumns.has(name) ? `n.${name}` : "NULL";
const rigColumns = new Set(db.prepare("PRAGMA table_info(rigs)").all()
.map((row) => (row as { name?: string }).name));
const rigColumnOrNull = (name: string) => rigColumns.has(name) ? `r.${name}` : "NULL";
const codexConfigProfileSelect = nodeColumnOrNull("codex_config_profile");
const stmt = db.prepare(`
SELECT
@@ -655,6 +852,18 @@ function runInventoryRowQuery(db: Database.Database, whereClause: string, orderC
${nodeColumnOrNull("effort")} as effort,
${nodeColumnOrNull("effort_choice")} as effort_choice,
${codexConfigProfileSelect} as codex_config_profile,
${nodeColumnOrNull("codex_permissions_json")} as codex_permissions_json,
${nodeColumnOrNull("permission_policy")} as node_permission_policy,
${nodeColumnOrNull("policy_origin")} as node_policy_origin,
${nodeColumnOrNull("policy_resolved_target")} as node_policy_resolved_target,
${nodeColumnOrNull("policy_declaring_dir")} as node_policy_declaring_dir,
${nodeColumnOrNull("policy_launch_posture")} as node_policy_launch_posture,
${rigColumnOrNull("permission_policy")} as rig_permission_policy,
${rigColumnOrNull("rig_policy_origin")} as rig_policy_origin,
${rigColumnOrNull("rig_policy_resolved_target")} as rig_policy_resolved_target,
${rigColumnOrNull("rig_policy_declaring_dir")} as rig_policy_declaring_dir,
${rigColumnOrNull("rig_policy_launch_posture")} as rig_policy_launch_posture,
${rigColumnOrNull("non_interruptive")} as rig_non_interruptive,
${nodeColumnOrNull("claude_mcp")} as claude_mcp,
${nodeColumnOrNull("launch_wrapper")} as launch_wrapper,
n.agent_ref,
@@ -729,8 +938,18 @@ interface InventoryBuildContext {
// batched path) and looked up O(1) here instead of a query per node.
restoreOutcomes: Map<string, RestoreProjection>;
orienteds: Map<string, NodeOriented>;
/** Each node's current recorded Codex daemon choice (one read per projection); absent = not recorded. */
codexDaemonOptOuts: Map<string, boolean>;
/** Each node's current recorded launch observation (one read per projection); absent = not recorded. */
currentLaunchObservations: Map<string, StoredAppliedLaunchObservation>;
/** Explicit per-seat native permission selections (one read per projection), or table-unavailable state. */
permissionSelections: PermissionSelectionsLookup;
/** Per-inventory-build custom-policy target cache so shared targets are probed/read at most once per build. */
customPolicyCache: Map<string, string | null>;
}
interface ProjectedNodeInventoryItem {
entry: NodeInventoryEntry;
codexResolution?: CodexResumeResolution;
codexDaemonOptOut?: boolean;
}
/**
@@ -738,17 +957,48 @@ interface InventoryBuildContext {
* Apply only current-occupant identity evidence and retain stored status alongside
* effective status so readers can distinguish absence from registration history.
*/
function buildInventoryEntry(
function buildInventoryItem(
db: Database.Database,
row: InventoryRow,
ctx: InventoryBuildContext,
): NodeInventoryEntry {
const { usableSnapshot, workspaceSpec, identityVerdicts, restoreOutcomes, orienteds, codexDaemonOptOuts } = ctx;
): ProjectedNodeInventoryItem {
const {
usableSnapshot,
workspaceSpec,
identityVerdicts,
restoreOutcomes,
orienteds,
currentLaunchObservations,
permissionSelections,
customPolicyCache,
} = ctx;
const launchWrapper = parseStoredLaunchWrapper(row.launch_wrapper, `Seat ${row.node_id} launch_wrapper`);
if (launchWrapper && !isLaunchWrapperRuntime(row.runtime)) {
throw new Error(`Seat ${row.node_id} launch_wrapper: only valid when runtime is "pi", "codex", "claude-code" or a supported community harness`);
}
const effortResolution = resolveSeatEffort({ effort: row.effort, effortChoice: row.effort_choice });
const recordedLaunch = currentLaunchObservations.get(row.node_id);
const codexResolution = row.runtime === "codex"
? resolveCodexResumeResolution(
row,
recordedLaunch,
permissionSelections,
customPolicyCache,
launchWrapper,
)
: undefined;
const resumeDisplay = codexResolution && !codexResolution.ok
? { command: null, label: row.resume_token ? codexResolution.diagnostic : null }
: buildNativeResumeDisplay(
row.runtime,
row.resume_token,
null,
row.codex_config_profile,
codexResolution?.input.codexDaemonOptOut,
row.claude_mcp,
codexResolution?.input,
launchWrapper,
);
// 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).
@@ -781,7 +1031,7 @@ function buildInventoryEntry(
usableSnapshot,
identityVerdict: identityVerdict?.verdict ?? null,
});
return {
const entry: NodeInventoryEntry = {
nodeId: row.node_id,
rigId: row.rig_id,
rigName: row.rig_name,
@@ -823,7 +1073,8 @@ function buildInventoryEntry(
? { tmuxServerArgs: [...daemonTmuxServerArgs()] } : {}),
...(row.binding_attachment_type === "tmux" && row.session_name && daemonTmuxServerInfo()
? { tmuxServerMode: daemonTmuxServerInfo()!.mode } : {}),
resumeCommand: computeResumeCommand(row.runtime, row.resume_token, row.codex_config_profile, codexDaemonOptOuts.get(row.node_id), row.claude_mcp),
resumeCommand: resumeDisplay?.command ?? null,
resumeCommandLabel: resumeDisplay?.label ?? null,
// 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
@@ -858,6 +1109,19 @@ function buildInventoryEntry(
heldReason: deriveHeldReason(db, row.rig_id, row.node_id, sessionStatus),
...(row.handover_at ? startedWithoutRequired(db, row.node_id, row.session_id) : {}),
};
return {
entry,
codexResolution,
codexDaemonOptOut: recordedLaunch?.daemonOptOut,
};
}
function buildInventoryEntry(
db: Database.Database,
row: InventoryRow,
ctx: InventoryBuildContext,
): NodeInventoryEntry {
return buildInventoryItem(db, row, ctx).entry;
}
/** #2456: the required startup files the seat's current occupant started without, as its handover recorded them. Only
@@ -920,7 +1184,9 @@ function buildInventoryMapFromRows(db: Database.Database, rows: InventoryRow[]):
// events, so each node's value equals the single-rig path.
const restoreOutcomes = buildRestoreOutcomeMap(db); // one fleet restore-outcome scan
const orienteds = buildOrientedMap(db); // one fleet startup-orientation scan
const codexDaemonOptOuts = new AppliedLaunchObservationStore(db).readCurrentDaemonOptOuts(); // one launch-fact read
const currentLaunchObservations = new AppliedLaunchObservationStore(db).readCurrentObservations(); // one launch-fact read
const permissionSelections = readPermissionSelectionsMap(db);
const customPolicyCache = new Map<string, string | null>();
const out = new Map<string, NodeInventoryEntry[]>();
for (const row of rows) {
const entry = buildInventoryEntry(db, row, {
@@ -929,7 +1195,9 @@ function buildInventoryMapFromRows(db: Database.Database, rows: InventoryRow[]):
identityVerdicts: verdictsByRig.get(row.rig_id) ?? new Map(),
restoreOutcomes,
orienteds,
codexDaemonOptOuts,
currentLaunchObservations,
permissionSelections,
customPolicyCache,
});
let list = out.get(row.rig_id);
if (!list) { list = []; out.set(row.rig_id, list); }
@@ -983,10 +1251,12 @@ export function getNodeDetail(
installedResourcesOverride?: Array<{ id: string; category: string; targetPath: string }>;
},
): NodeDetailEntry | null {
// Get the inventory entry first
const allEntries = getNodeInventory(db, rigId);
const entry = allEntries.find((e) => e.logicalId === logicalId);
if (!entry) return null;
// Project the rig's inventory once and reuse the target seat's resolved Codex inputs for recoveryGuidance.
const allItems = projectRigInventoryItems(db, rigId);
const allEntries = allItems.map((item) => item.entry);
const targetItem = allItems.find((item) => item.entry.logicalId === logicalId);
if (!targetItem) return null;
const entry = targetItem.entry;
// Find the node ID
const nodeRow = db.prepare(
@@ -1135,7 +1405,8 @@ export function getNodeDetail(
cwd: entry.cwd,
sessionName: entry.canonicalSessionName,
codexConfigProfile: entry.codexConfigProfile,
codexDaemonOptOut: entry.runtime === "codex" ? new AppliedLaunchObservationStore(db).readCurrent(nodeId)?.daemonOptOut : undefined,
codexDaemonOptOut: targetItem.codexDaemonOptOut,
codexResolution: targetItem.codexResolution,
claudeMcp: entry.runtime === "claude-code" ? readSeatClaudeMcp(db, nodeId) : undefined,
}),
binding,
@@ -2,6 +2,7 @@ import nodePath from "node:path";
export type AppliedLaunchAxis = "permission" | "sandbox" | "resource_trust" | "not_applicable";
export type AppliedLaunchState = "observed" | "unknown";
export type CodexObservedApprovalPolicy = "untrusted" | "on-request" | "never";
export interface AppliedLaunchObservation {
runtime: string;
@@ -10,7 +11,7 @@ export interface AppliedLaunchObservation {
value: string | null;
reason?: string;
/** Emitted Codex approval argument; null/absent does not infer a native default. */
approvalPolicy?: "never" | null;
approvalPolicy?: CodexObservedApprovalPolicy | null;
/** Codex: true when the launch added --no-daemon, false when the probe found no such option; absent when not probed. */
daemonOptOut?: boolean;
}
@@ -0,0 +1,160 @@
import fs from "node:fs";
import { resolvePermissionPolicyAttachment, type PolicyResolveDeps } from "./policy-ref.js";
import type { LaunchPosture } from "./policy-spec.js";
export interface PersistedNodeProvenance {
origin: string;
launchPosture: string;
resolvedTarget: string | null;
declaringDir: string | null;
nodeRef: string | null;
}
export interface PersistedRigProvenance {
origin: string;
launchPosture: string;
resolvedTarget: string | null;
declaringDir: string | null;
rigRef: string | null;
}
export interface PersistedRestorePostureReader {
getNodeProvenance: () => PersistedNodeProvenance | null;
getFallbackRigRef: () => string | null;
getRigProvenance: () => PersistedRigProvenance | null;
readFile?: (target: string) => string;
}
export interface PersistedPolicyProvenanceSnapshot {
nodeOrigin: string | null;
nodeLaunchPosture: string | null;
nodeResolvedTarget: string | null;
nodeDeclaringDir: string | null;
nodeRef: string | null;
rigOrigin: string | null;
rigLaunchPosture: string | null;
rigResolvedTarget: string | null;
rigDeclaringDir: string | null;
rigRef: string | null;
}
function asKnownLaunchPosture(value: string | null | undefined): LaunchPosture | undefined {
return value === "floor" || value === "full_bypass" || value === "auto" ? value : undefined;
}
/**
* Synchronous custom-policy reader used by display projections.
* Rejects non-regular files (such as FIFOs, sockets, device nodes, or directories) via statSync
* before opening the path so special files cannot block synchronous display reads; callers catch
* any stat/read failure and retain the persisted posture.
*/
export function readRegularCustomPolicyFile(target: string): string {
if (!fs.statSync(target).isFile()) {
throw new Error(`Custom policy target is not a regular file: ${target}`);
}
return fs.readFileSync(target, "utf-8");
}
/**
* OPR.0.4.8.3 Seam B — shared persisted-provenance posture resolution with main's exact lazy
* lookup order and raw posture pass-through:
* 1. Persisted node provenance wins over rig provenance. When `origin === "custom"` with both
* `declaringDir` and `resolvedTarget`, lazily falls back to `getFallbackRigRef()` only when
* `nodeRef` is null, and reopens + re-derives readable valid custom policies; unreadable or
* malformed files retain the raw persisted `launchPosture`.
* 2. Only when node provenance is absent, `getRigProvenance()` is read with the same rule.
* 3. No saved provenance (or an unexpected outer lookup error) returns `"floor"`.
*/
export function resolvePersistedRestorePosture(reader: PersistedRestorePostureReader): string {
const readFile = reader.readFile ?? ((target: string) => fs.readFileSync(target, "utf-8"));
try {
const prov = reader.getNodeProvenance();
if (prov) {
if (prov.origin === "custom" && prov.declaringDir && prov.resolvedTarget) {
const ref = prov.nodeRef ?? reader.getFallbackRigRef();
if (ref) {
let content: string | null = null;
try {
content = readFile(prov.resolvedTarget);
} catch {
content = null;
}
if (content !== null) {
const body = content;
const rederived = resolvePermissionPolicyAttachment(ref, prov.declaringDir, {
readFile: () => body,
});
if (rederived.contentResolved) {
return rederived.launchPosture;
}
}
}
}
return prov.launchPosture;
}
const rigProv = reader.getRigProvenance();
if (rigProv) {
if (rigProv.origin === "custom" && rigProv.declaringDir && rigProv.resolvedTarget && rigProv.rigRef) {
let content: string | null = null;
try {
content = readFile(rigProv.resolvedTarget);
} catch {
content = null;
}
if (content !== null) {
const body = content;
const rederived = resolvePermissionPolicyAttachment(rigProv.rigRef, rigProv.declaringDir, {
readFile: () => body,
});
if (rederived.contentResolved) {
return rederived.launchPosture;
}
}
}
return rigProv.launchPosture;
}
} catch {
// Posture resolution must never throw; fall through to the locked minimum floor.
}
return "floor";
}
/**
* Display-only posture resolution for batched node inventory and detail guidance:
* follows `resolvePersistedRestorePosture` so displayed flags match unchanged `main` restore
* (including inconsistent custom-origin rows whose ref resolves to a builtin or `none`),
* normalizes unrecognized or empty persisted posture strings to `"floor"` so ambient OPENRIG_YOLO
* never leaks into displayed commands, and reads custom targets through `readRegularCustomPolicyFile`
* by default.
*/
export function resolveDisplayedRestorePosture(
snapshot: PersistedPolicyProvenanceSnapshot,
deps: PolicyResolveDeps = { readFile: readRegularCustomPolicyFile },
): LaunchPosture {
const raw = resolvePersistedRestorePosture({
getNodeProvenance: () =>
snapshot.nodeOrigin != null && snapshot.nodeLaunchPosture != null
? {
origin: snapshot.nodeOrigin,
launchPosture: snapshot.nodeLaunchPosture,
resolvedTarget: snapshot.nodeResolvedTarget,
declaringDir: snapshot.nodeDeclaringDir,
nodeRef: snapshot.nodeRef,
}
: null,
getFallbackRigRef: () => snapshot.rigRef,
getRigProvenance: () =>
snapshot.rigOrigin != null && snapshot.rigLaunchPosture != null
? {
origin: snapshot.rigOrigin,
launchPosture: snapshot.rigLaunchPosture,
resolvedTarget: snapshot.rigResolvedTarget,
declaringDir: snapshot.rigDeclaringDir,
rigRef: snapshot.rigRef,
}
: null,
readFile: deps.readFile,
});
return asKnownLaunchPosture(raw) ?? "floor";
}
@@ -37,9 +37,13 @@ export function makeContextUsageThresholdPolicy(
if (threshold == null) {
return { action: "terminal", reason: "threshold_invalid" };
}
// A name that names no single seat right now (an archived generation of it restored for a moment, say)
// has no generation to measure. Skip and say so; the watch stays active and is measured again once
// the name resolves, never from another node's numbers. A name nothing carries any more is a seat
// that's gone (its rig or node removed): the watch ends.
if (!job.occupantGeneration) {
return {
action: "terminal",
action: job.occupantGenerationAmbiguous ? "skip" : "terminal",
reason: "occupant_generation_unresolved",
notes: { targetSession: job.target.session },
};
@@ -50,6 +50,8 @@ export interface PolicyJob {
/** The condition this job last told its target about (the receipt below), or null. */
lastFiredCondition: string | null;
occupantGeneration: string | null;
/** With no occupantGeneration: true when the name is still carried but names no single seat, false when it's gone. */
occupantGenerationAmbiguous?: boolean;
currentGenerationTranscriptPending: boolean;
requiredReceiptSatisfied: boolean;
requiredReceiptDeferred: boolean;
+65 -35
View File
@@ -81,7 +81,7 @@ import { getSelfHostId } from "./hosts/fanout-contract.js";
import { parseSessionName, isHumanSeatSessionRef } from "./session-name.js";
import { runnerPermissionOrigin } from "./runner-permissions.js";
import { isHostPairingAsk } from "./hosts/pairing-ask.js";
import { parseReplyToChoice, parseReplyToThreadMissing, formatReplyToChoice, describeReplyToFallback, REPLY_TO_CHOICE_ACTOR, type ReplyToChoice } from "./reply-to-choice.js";
import { parseReplyToChoice, parseArrivalThread, parseReplyToThreadMissing, formatReplyToChoice, describeReplyToFallback, REPLY_TO_CHOICE_ACTOR, type ReplyToChoice } from "./reply-to-choice.js";
import { classifyDestination, type DestinationClass } from "./gateway/destination-resolver.js";
import { join } from "node:path";
import { SettingsStore } from "./user-settings/settings-store.js";
@@ -520,13 +520,14 @@ export interface QueueUpdateInput {
wakeMessage?: string;
/**
* OPR.0.4.4.19 FR-6 — park-time inputs. summary + evidence_ref are
* updatable with any state change, not create-only: `rig queue block
* --summary --evidence-ref` persists them onto the EXISTING item so the
* attention query + Packet 2 read them, and a done close leaves its
* artifact on the finished row. A human-seat park still requires them.
* A blank value is not stored (it would erase the row's subject or
* evidence link); the update route reports any value
* the stored row doesn't carry, so a caller never believes it was saved.
* updatable AT THE PARK MOMENT (state=blocked with a human-seat blocker),
* not create-only: `rig queue block --summary --evidence-ref` persists
* them onto the EXISTING item so the attention query + Packet 2 read
* them. A done close also persists evidence_ref, so a finished row carries
* its artifact. OPR.0.5.1 slice-51-06 D2: supplying either where it can't
* be stored is REJECTED (QueueRepositoryError "summary_evidence_not_persistable")
* before any mutation — not silently ignored — so a caller never believes
* unpersistable metadata was saved.
*/
summary?: string | null;
evidenceRef?: string | null;
@@ -2112,7 +2113,7 @@ export class QueueRepository {
// command line (it carries the row's summary), and nothing there can claim the row. So nothing
// is typed; the row stays queued and visible. `failed:` keeps it on the wake ladder, whose
// retries come back here and type nothing before it escalates.
if (isBoundTerminalSeat(this.db, destinationSession)) {
if (isBoundTerminalSeat(this.db, destinationSession) || isBoundTerminalSeat(this.db, physicalSessionName(this.db, destinationSession))) {
return {
classified: "failed",
nudgeResult: `failed:terminal-seat: '${destinationSession}' is a terminal seat (runtime: terminal), not an agent, so no wake was typed into it and nothing there can claim this row. The row stays queued; route it to an agent seat.`,
@@ -3454,7 +3455,6 @@ export class QueueRepository {
}
const hasNote = typeof input.transitionNote === "string" && input.transitionNote.trim().length > 0;
const hasValidSummary = typeof input.summary === "string" && input.summary.trim().length > 0;
const hasValidEvidenceRef = typeof input.evidenceRef === "string" && input.evidenceRef.trim().length > 0;
const isGuardedTerminal = (["done", "canceled", "handed-off"] as const).includes(
qitem.state as "done" | "canceled" | "handed-off",
);
@@ -3664,15 +3664,35 @@ export class QueueRepository {
throw new QueueRepositoryError("invalid_human_notification", "An informational delivery cannot become a human approval park; author a separate decision request.");
}
// A park on a person by any address (a legacy human seat, or a registered person's own address or alias) keeps the
// summary and evidence it names, or the row's own where it names a blank one. validateHumanPark still requires them
// only for the legacy human-seat form.
// summary and evidence it names. validateHumanPark still requires them only for the legacy human-seat form.
const isHumanPark = input.state === "blocked" && !!effectiveBlockedOn && this.isPersonAddress(effectiveBlockedOn);
// OPR.0.5.1 slice-51-06 D2 refused the whole transition when summary/evidence_ref came with a state it
// didn't store them for, so finished work could stay pending over optional metadata. A state change now
// stores them (below), except a blank value; the route reports what wasn't stored.
// A done close can also store the artifact the work produced, so the finished row carries its link.
const isDoneClose = input.state === "done";
// SWEEP-a (shape f2576102) — closure/blocked-field COHERENCE: an incoherent
// field must never silently persist (worse than a drop —
// OPR.0.5.1 slice-51-06 D2 — summary/evidence_ref are persist-able ONLY at a human-seat park
// (see the FR-6 note below). Silently ignoring them on any other transition is a data-loss trap
// (the operator believes the metadata was saved). HARD-REJECT before ANY UPDATE/log/event so the
// caller learns immediately and nothing is half-applied. null/undefined = absent (allowed);
// empty string = present (a deliberate value → rejected on a non-park transition).
if (!isHumanPark) {
const invalidFields: Array<"summary" | "evidenceRef"> = [];
if (input.summary != null) invalidFields.push("summary");
if (input.evidenceRef != null && !isDoneClose) invalidFields.push("evidenceRef");
if (invalidFields.length > 0) {
const flags = invalidFields.map((f) => (f === "summary" ? "--summary" : "--evidence-ref")).join(" / ");
const where = invalidFields.map((f) => f === "summary"
? "summary persists on a human-seat park, or on its own (rig queue update <id> --summary … with no --state)"
: "evidenceRef persists on a human-seat park or a done close").join("; ");
throw new QueueRepositoryError(
"summary_evidence_not_persistable",
`${where}; the '${input.state}' transition cannot store ${invalidFields.length > 1 ? "them" : "it"}. Remove ${flags}, or use one of those.`,
{ invalidFields },
);
}
}
// SWEEP-a (shape f2576102) — closure/blocked-field COHERENCE, beside the reference
// reject above: an incoherent field must never silently persist (worse than a drop —
// the COALESCE below would write it). Admits-map, derived from LIVE schema use:
// closure_reason/closure_target → state "done", OR the PARK-RECORD form
// (state "blocked" with closureReason "blocked_on" — the workflow gate/park
@@ -3750,8 +3770,8 @@ export class QueueRepository {
let effectiveSummary = qitem.summary;
let effectiveEvidenceRef = qitem.evidenceRef;
if (isHumanPark) {
effectiveSummary = hasValidSummary ? input.summary! : qitem.summary;
effectiveEvidenceRef = hasValidEvidenceRef ? input.evidenceRef! : qitem.evidenceRef;
effectiveSummary = input.summary ?? qitem.summary;
effectiveEvidenceRef = input.evidenceRef ?? qitem.evidenceRef;
const park = validateHumanPark({
blockedOn: effectiveBlockedOn,
summary: effectiveSummary,
@@ -3879,13 +3899,15 @@ export class QueueRepository {
input.qitemId
);
// FR-6: summary/evidence_ref are PERSISTED onto the existing item (not
// merely validated-then-dropped) — visible to the attention query, to
// Packet 2 and to every reader of the row. Only non-blank values are
// stored, on a park on a person too: a blank one would erase the row's
// subject or evidence link, and the route reports it as not stored.
if (hasValidSummary) this.persistSummary(input.qitemId, input.summary!);
if (hasValidEvidenceRef) this.persistEvidenceRef(input.qitemId, input.evidenceRef!);
// FR-6: park-time summary/evidence_ref are PERSISTED onto the existing
// item (not merely validated-then-dropped) — visible to the attention
// query and to Packet 2. A done close writes evidence_ref the same way.
if (isHumanPark) {
this.persistSummary(input.qitemId, input.summary ?? null);
this.persistEvidenceRef(input.qitemId, input.evidenceRef ?? null);
} else if (isDoneClose) {
this.persistEvidenceRef(input.qitemId, input.evidenceRef ?? null);
}
const transition = this.transitionLog.append({
qitemId: input.qitemId,
@@ -3969,9 +3991,9 @@ export class QueueRepository {
closureReason: validation.closureReason ?? null,
closureTarget: validation.closureTarget ?? null,
actorSession: input.actorSession,
// FR-1 × FR-6: the event carries the summary as stored by THIS mutation
// FR-1 × FR-6: the event carries the summary as of THIS mutation
// (park-time summary included) so surfaces refresh without a fetch.
summary: this.getById(input.qitemId)?.summary ?? null,
summary: effectiveSummary ?? null,
});
return { persistedEvent, persistedEvents: [...dependentEvents, persistedEvent] };
}
@@ -4538,11 +4560,9 @@ export class QueueRepository {
}
/** A park's current question: its latest human-required transition, and that notification's final post receipt, if any. */
/** The opaque revision of what a person decides on (#2446): the question (body, intent, questions, and the evidence it
* points to, when it has any) and its decision state (open, blocked or terminal; who it's addressed to or parked on; its
* answers, a decision made for them, the current park, and the replies returned). A label, a wake, a claim or a note on a
* park doesn't change it; a new park does, on any blocker, and so does a new evidence pointer. A row with no evidence
* keeps the revision it had before evidence counted. */
/** The opaque revision of what a person decides on (#2446): the question (body, intent, questions) and its decision state
* (open, blocked or terminal; who it's addressed to or parked on; its answers, a decision made for them, the current park,
* and the replies returned). A label, a wake, a claim or a note on a park doesn't change it; a new park does, on any blocker. */
decisionRevision(item: QueueItem, opts?: DecisionRevisionOptions): string {
const phase = item.state === "blocked" ? "blocked" : item.state === "pending" || item.state === "in-progress" ? "open" : item.state;
const excludeOp =
@@ -4569,8 +4589,7 @@ export class QueueRepository {
if (transitions[i]!.state === "blocked" && transitions[i - 1]?.state !== "blocked") parkEntry = transitions[i]!.transitionId;
}
const decision = [item.body, item.humanIntent ?? null, item.humanQuestions ?? null, phase, item.destinationSession, item.blockedOn,
item.humanAnswers ?? null, item.delegatedDecision ?? null, this.currentHumanRequiredEpisode(item.qitemId)?.key ?? null, parkEntry, replies,
...(item.evidenceRef ? [item.evidenceRef] : [])];
item.humanAnswers ?? null, item.delegatedDecision ?? null, this.currentHumanRequiredEpisode(item.qitemId)?.key ?? null, parkEntry, replies];
return `dr1-${createHash("sha256").update(JSON.stringify(decision)).digest("hex").slice(0, 24)}`;
}
@@ -4806,6 +4825,17 @@ export class QueueRepository {
return null;
}
/** #2990 — the thread an inbound reply actually arrived in, without gate correlation.
* Only an in-process daemon write counts, with the same trust rule as replyToChoiceFor. */
arrivalThreadFor(qitemId: string): { threadTs: string; channel: string } | null {
for (const t of this.transitionLog.listForQitem(qitemId).reverse()) {
if (t.actorSession !== REPLY_TO_CHOICE_ACTOR || t.identityProvenance !== null) continue;
const arrival = parseArrivalThread(t.transitionNote ?? "");
if (arrival) return arrival;
}
return null;
}
/** #96 — whether some update recorded this Slack root as the thread it posts into. Such a
* root is shared: its owner's next decision must not reuse it (see slack-subsystem). */
isReplyToThread(threadTs: string, where?: { channel: string }): boolean {
@@ -13,6 +13,7 @@ import { openPromptRefusals, CLOSE_PREFIX, PARKED_OWNER_POLICY_NAME, REFUSED_PRE
import { findQueueRecovery, recoveryTag } from "./queue-recovery.js";
import { lastMeaningfulTransition, type WaitingView } from "./queue-waiting.js";
import { readWakeOutcome } from "./connection-contract.js";
import { runSyncSite } from "./sync-site-wrap.js";
// S01 (OPR.0.5.5.1) — WAKE OR ESCALATE ON BATONS. A handoff whose wake fails must never
// silently park: today one failed nudge is recorded and nothing follows (the measured
// dominant 0.5.3 failure class — a perfect surviving packet, a recipient never woken).
@@ -1062,6 +1063,33 @@ const PROMPT_CLEARED_NOTE = "prompt escalation: the seat's prompt read clear";
* spares JSON parsing of the rows that cannot match. The alert tag holds no LIKE wildcard. */
const PROMPT_ALERT_ROWS = `(tags LIKE '%"${PROMPT_ALERT_TAG}"%' OR tags LIKE '%\\%')`;
/** The rows a prompt refusal can still concern: open rows, and the rows an open prompt alert recovers (its exact
* `recovery-for:<id>` tags), each kept when one of its own transitions carries a refusal note. Read from the open rows
* (the state index) and then each candidate's own transitions (the qitem index), not by matching the note across the
* whole transition log, which only grows. Ordered by each row's first refusal: the order the whole-log read returned
* under its observed plan (it had no ORDER BY), now explicit. A recovered id is cut after the 13-byte ASCII prefix as
* bytes, because text substr stops at a NUL, which a caller-supplied id can hold; the join then compares it exactly. */
export const PROMPT_REFUSAL_CANDIDATES_SQL = `WITH scored AS MATERIALIZED (
SELECT q.qitem_id, q.destination_session,
(SELECT min(t.transition_id) FROM queue_transitions t
WHERE t.qitem_id = q.qitem_id AND t.transition_note LIKE @refused) AS first_refusal
FROM (
SELECT qitem_id FROM queue_items WHERE state IN ('pending','in-progress','blocked')
UNION
SELECT CAST(substr(CAST(j.value AS BLOB), 14) AS TEXT) FROM queue_items a,
json_each(CASE WHEN json_valid(a.tags) THEN a.tags ELSE '[]' END) j
WHERE a.state IN ('pending','in-progress','blocked') AND json_valid(a.tags)
AND EXISTS (SELECT 1 FROM json_each(CASE WHEN json_valid(a.tags) THEN a.tags ELSE '[]' END) WHERE value = @alertTag)
AND j.type = 'text' AND substr(j.value, 1, 13) = 'recovery-for:'
) c JOIN queue_items q ON q.qitem_id = c.qitem_id)
SELECT qitem_id, destination_session FROM scored WHERE first_refusal IS NOT NULL ORDER BY first_refusal`;
/** Named in the slow-operation log, so a stall during it is attributed to this read: one begin and one end record per tick. */
export function readPromptRefusalCandidates(db: Database.Database): Array<{ qitem_id: string; destination_session: string }> {
return runSyncSite("queue.wake_ladder.prompt_candidates", () => db.prepare(PROMPT_REFUSAL_CANDIDATES_SQL)
.all({ refused: `${REFUSED_PREFIX}%`, alertTag: PROMPT_ALERT_TAG }) as Array<{ qitem_id: string; destination_session: string }>);
}
/** Active watchdog jobs whose latest attempt was refused at a target's prompt. A run of refusals starts
* after the job's latest other entry at that target (looked up past the 50-entry read when the read is all refusals) or
* after this seat's latest prompt-cleared alert retirement, whichever is later; the key names that boundary, so it stays
@@ -1192,13 +1220,7 @@ async function advancePromptRefusals(
return null;
}
};
const candidates = deps.db.prepare(`SELECT DISTINCT q.qitem_id, q.destination_session FROM queue_items q
JOIN queue_transitions t ON t.qitem_id = q.qitem_id
WHERE t.transition_note LIKE ? AND (q.state IN ('pending','in-progress','blocked') OR EXISTS (
SELECT 1 FROM queue_items a WHERE a.state IN ('pending','in-progress','blocked')
AND json_valid(a.tags) AND EXISTS (SELECT 1 FROM json_each(a.tags) WHERE value = ?)
AND EXISTS (SELECT 1 FROM json_each(a.tags) WHERE value = 'recovery-for:' || q.qitem_id)))`)
.all(`${REFUSED_PREFIX}%`, PROMPT_ALERT_TAG) as Array<{ qitem_id: string; destination_session: string }>;
const candidates = readPromptRefusalCandidates(deps.db);
// Every tag value of every final prompt disposition, read once per tick when the first episode needs it,
// rather than one scan of all terminal rows per open episode. The row predicates are the per-episode lookup's;
// membership is the same exact json_each value equality; and nothing below writes before the last episode is
@@ -1,8 +1,14 @@
// #96 — the durable record of where a --reply-to update posts. It is written on the row
// ONCE, before the first post, so every retry, reconcile scan and --verify read the same
// choice instead of re-deriving it after the referenced item or its root may have changed.
//
// #2990 — a reply arriving in a superseded Slack root records that thread separately
// from gate correlation. An update answering the reply returns there, while only the
// conversation's newest root can answer its human gate. The same actor/provenance rule
// as the reply-to choice protects this in-process record.
const REPLY_TO_CHOICE_PREFIX = "slack-reply-to-choice";
export const SLACK_ARRIVAL_THREAD_PREFIX = "slack-arrival-thread";
/** The only actor whose choice note is trusted: an agent can append any transition note, so
* a note from anyone else must never steer where the update posts. */
@@ -67,6 +73,23 @@ export function parseReplyToChoice(note: string): ReplyToChoice | null {
return null;
}
export function formatArrivalThread(arrival: { threadTs: string; channel: string }): string {
return `${SLACK_ARRIVAL_THREAD_PREFIX} thread_ts=${arrival.threadTs} channel=${arrival.channel}`;
}
/** Parse an arrival-thread note (null when the note is not one). Callers check the actor. */
export function parseArrivalThread(note: string): { threadTs: string; channel: string } | null {
if (!note.startsWith(SLACK_ARRIVAL_THREAD_PREFIX + " ")) return null;
const fields = new Map<string, string>();
for (const token of note.slice(SLACK_ARRIVAL_THREAD_PREFIX.length + 1).split(/\s+/)) {
const eq = token.indexOf("=");
if (eq > 0) fields.set(token.slice(0, eq), token.slice(eq + 1));
}
const threadTs = fields.get("thread_ts");
const channel = fields.get("channel");
return threadTs && channel ? { threadTs, channel } : null;
}
/** #2234: where Slack actually put a --reply-to update that its choice threaded. The thread's root was gone, so Slack posted
* the update as a new top-level message. Recorded after the post by the same actor, and read only for the receipt: retries and
* reconciliation keep the recorded choice. */
@@ -15,7 +15,7 @@ import { NativePermissionStore } from "./native-permission-store.js";
import { readSeatClaudeMcp, type ClaudeMcp } from "./claude-mcp.js";
import { isLaunchWrapperRuntime, readSeatLaunchWrapper } from "./launch-wrapper.js";
import type { RigRepository } from "./rig-repository.js";
import { resolvePermissionPolicyAttachment } from "./permission-policy/policy-ref.js";
import { resolvePersistedRestorePosture } from "./permission-policy/persisted-restore-posture.js";
import { claudeResumeRotation, type SessionRegistry } from "./session-registry.js";
import type { EventBus } from "./event-bus.js";
import type { SnapshotRepository } from "./snapshot-repository.js";
@@ -2463,57 +2463,13 @@ export class RestoreOrchestrator {
* 3. Nothing attached (or a resolution error) → EXPLICIT "floor" — the locked
* minimum-floor absence contract; never undefined/env-delegation for managed seats.
*/
/** Pure, read-only policy resolution shared with selected startup review. */
/** Pure, read-only policy resolution shared with selected startup review and node-inventory display. */
resolveRestorePosture(nodeId: string, rigId: string): "floor" | "full_bypass" | "auto" {
try {
const prov = this.rigRepo.getNodePolicyProvenance(nodeId);
if (prov) {
// Guard-F3: the resolver swallows read errors internally (advisory floor), so the
// fallback must be decided BEFORE re-resolution: probe readability of the persisted
// resolvedTarget first — readable → REOPEN + re-derive (the ruling); unreadable /
// invalid → the PERSISTED posture carries (restart-stable), never a silent floor.
if (prov.origin === "custom" && prov.declaringDir && prov.resolvedTarget) {
const ref = prov.nodeRef ?? this.rigRepo.getRigPermissionPolicy(rigId);
if (ref) {
let content: string | null = null;
try { content = readFileSync(prov.resolvedTarget, "utf-8"); } catch { content = null; }
if (content !== null) {
const body = content;
const rederived = resolvePermissionPolicyAttachment(ref, prov.declaringDir, {
readFile: () => body,
});
// Guard round-2: trust the re-derivation ONLY when content resolution
// genuinely succeeded with usable semantics (parse OK + valid flag
// contract) — readable-but-malformed/unusable carries the PERSISTED
// posture, exactly like unreadable. Advisory only; no enforcement.
if (rederived.contentResolved) return rederived.launchPosture;
}
}
}
return prov.launchPosture;
}
// Guard-F1: no node provenance (organic claim/self-attach seats) → the PERSISTED
// rig-level attachment is authoritative — same readable-probe discipline; NEVER
// resolve the raw relative rig ref against this process's cwd.
const rigProv = this.rigRepo.getRigPolicyProvenance(rigId);
if (rigProv) {
if (rigProv.origin === "custom" && rigProv.declaringDir && rigProv.resolvedTarget && rigProv.rigRef) {
let content: string | null = null;
try { content = readFileSync(rigProv.resolvedTarget, "utf-8"); } catch { content = null; }
if (content !== null) {
const body = content;
const rederived = resolvePermissionPolicyAttachment(rigProv.rigRef, rigProv.declaringDir, {
readFile: () => body,
});
if (rederived.contentResolved) return rederived.launchPosture; // same rule as node-level
}
}
return rigProv.launchPosture;
}
} catch { /* posture resolution must never block a restore */ }
// R2 terminal (954d97a0): NO provenance anywhere (and the error path) = the locked
// minimum floor, explicitly — never undefined (which would delegate to ambient YOLO).
return "floor";
return resolvePersistedRestorePosture({
getNodeProvenance: () => this.rigRepo.getNodePolicyProvenance(nodeId),
getFallbackRigRef: () => this.rigRepo.getRigPermissionPolicy(rigId),
getRigProvenance: () => this.rigRepo.getRigPolicyProvenance(rigId),
}) as "floor" | "full_bypass" | "auto";
}
/** The live seat's current model: `rig seat set-model` saves it on the live node after the snapshot, and every
@@ -2682,6 +2638,7 @@ export class RestoreOrchestrator {
...(launchWrapper?.length ? ([launchWrapper] as const) : []),
);
if (result.ok) {
for (const note of result.warnings ?? []) warnings?.push(`${sessionName}: ${note}`);
if (result.appliedLaunch && launchGeneration) this.appliedLaunchStore.recordGeneration(launchGeneration, result.appliedLaunch);
return { kind: "resumed" };
}
@@ -2696,7 +2696,8 @@ export class PodRigInstantiator {
nodeId: input.nodeId,
tmuxSession: launchResult.binding.tmuxSession,
tmuxWindow: null,
tmuxPane: null,
// Only the run-grant launch owners need this anchor; unrelated builtins keep their existing session target.
tmuxPane: input.member.runtime === "pi" || isCommunityHarnessRuntime(input.member.runtime) ? launchResult.binding.tmuxPane : null,
cmuxWorkspace: null,
cmuxSurface: null,
updatedAt: "",
@@ -0,0 +1,84 @@
import type Database from "better-sqlite3";
import type { TmuxAdapter } from "../adapters/tmux.js";
import { shellQuote } from "../adapters/shell-quote.js";
import type { NodeBinding } from "./runtime-adapter.js";
import { ActivityGrantStore } from "./activity-grants.js";
import { SessionRegistry } from "./session-registry.js";
export interface RunnerGrantLaunch {
readonly grantId: string;
readonly generation: string;
wrap(command: string): string;
dispatched(): void;
finish(ok: boolean): Promise<void>;
}
export type RunnerGrantIssuer = (binding: NodeBinding, runtime: string, runId: string, runner: number) => Promise<RunnerGrantLaunch>;
/** Shared by actual Pi/community launch owners. The token crosses only tmux's private environment channel;
* the typed command carries an unpredictable environment KEY, never its value. The runner's shell consumes and
* removes that key before exec. Neither a failed await nor a later readiness result may revoke a successor. */
export function runnerGrantIssuer(db: Database.Database, tmux: Pick<TmuxAdapter, "getPanePid" | "setSessionEnv" | "unsetSessionEnv">): RunnerGrantIssuer {
return async (binding, runtime, runId, runner) => {
const session = binding.tmuxSession, pane = binding.tmuxPane;
if (!session || !pane) throw new Error("Runner authority needs a concrete launch pane.");
// sendShellCommand stages the payload through /bin/sh for non-POSIX pane shells too.
// The private bootstrap itself names /bin/sh; it must not narrow the existing parent-shell support.
const panePid = await tmux.getPanePid(pane);
if (!panePid) throw new Error("Runner authority needs a concrete launch process.");
const registry = new SessionRegistry(db), grants = new ActivityGrantStore(db);
const predecessorGeneration = registry.currentOccupantTenure(binding.nodeId)?.generationUuid;
const generation = binding.launchGeneration ?? predecessorGeneration;
if (!generation) throw new Error("Runner authority needs the reserved launch generation.");
const row = db.prepare("SELECT id FROM sessions WHERE node_id=? AND session_name=? ORDER BY id DESC LIMIT 1")
.get(binding.nodeId, session) as { id: string } | undefined;
// An explicitly staged successor has no managed row until cutover.
const grant = grants.issue({ nodeId: binding.nodeId, generation, issuedBy: "runner-launch",
run: { runId, ...(registry.currentOccupantTenure(binding.nodeId)?.generationUuid === generation && row ? { sessionId: row.id } : {}),
binding: { runtime, runner, pane, panePid } } });
const key = `OPENRIG_RUN_GRANT_${grant.grantId.replace(/[^A-Za-z0-9]/g, "_").toUpperCase()}`;
try { await tmux.setSessionEnv(session, key, grant.token); }
catch {
try { grants.revoke({ grantId: grant.grantId }, "credential_distribution_failed"); }
finally {
try { await tmux.unsetSessionEnv(session, key); } catch { /* private write may have succeeded before failing */ }
}
throw new Error("Runner credential distribution failed; no runner started.");
}
return {
grantId: grant.grantId,
generation: grant.generation,
wrap(command) {
const target = shellQuote(`=${session}`);
// No eval: token text is treated only as data. Disable tracing before reading the private value.
const script = `set +x; unset OPENRIG_RUN_ID; if _openrig_grant=$(tmux show-environment -t ${target} ${key} 2>/dev/null) && `
+ `case "$_openrig_grant" in ${key}=oag_*) true;; *) false;; esac && tmux set-environment -t ${target} -u ${key} 2>/dev/null; then `
+ `OPENRIG_ACTIVITY_HOOK_TOKEN=\${_openrig_grant#*=}; OPENRIG_RUN_ID=${shellQuote(runId)}; OPENRIG_OCCUPANT_GENERATION=${shellQuote(grant.generation)}; `
+ `export OPENRIG_ACTIVITY_HOOK_TOKEN OPENRIG_RUN_ID OPENRIG_OCCUPANT_GENERATION; `
+ `else tmux set-environment -t ${target} -u ${key} 2>/dev/null || :; printf '%s\\n' 'Run-bound reporting unavailable; using existing install-token reporting.' >&2; fi; `
+ `unset _openrig_grant; exec ${command}`;
return `/bin/sh -c ${shellQuote(script)}`;
},
dispatched() {
const currentGeneration = registry.currentOccupantTenure(binding.nodeId)?.generationUuid;
if (currentGeneration !== predecessorGeneration && currentGeneration !== generation) {
throw new Error("Runner launch selection changed during dispatch; authority was refused.");
}
if (!grants.activate(grant.grantId) && registry.isOccupantGenerationRegistered(binding.nodeId, generation)) {
throw new Error("Runner launch binding changed during dispatch; authority was refused.");
}
},
async finish(ok) {
// A staged readiness result can arrive after another cutover. It owns only this issued grant.
try {
const currentGeneration = ok ? registry.currentOccupantTenure(binding.nodeId)?.generationUuid : undefined;
if (!ok || (currentGeneration !== predecessorGeneration && currentGeneration !== generation)) {
grants.revoke({ grantId: grant.grantId }, "launch_failed_or_uncertain");
}
} finally {
// Also retry cleanup when the launch shell continued without the private value.
try { await tmux.unsetSessionEnv(session, key); } catch { /* optional cleanup cannot undo dispatch */ }
}
},
};
};
}
@@ -147,7 +147,10 @@ export type HarnessLaunchRecovery = "retry_fresh" | "attention_required";
// `launchedProcess` (Claude resume launches): the first process the launch's own readiness check
// pinned as the one it started, kept even when a later sample saw it replaced (#1077).
export type HarnessLaunchResult = { firstInput?: import("./community-harness-first-input.js").FirstInputPassed;
export type HarnessLaunchResult = {
/** Non-secret issuance ID for exact cleanup by later Core readiness owners. Never the token. */
activityGrantId?: string;
firstInput?: import("./community-harness-first-input.js").FirstInputPassed;
/** A community launcher that opts in was handed the seat's declared model as OPENRIG_MODEL: "confirmed" when the launch
* command's send succeeded, "uncertain" when typing began and the send then failed. Never proof of the model that runs. */
modelInput?: { model: string; dispatch: "confirmed" | "uncertain" } } & (
@@ -516,7 +516,8 @@ export class SeatDeliveryGuard {
/** No recent writable-client activity before a short wait, the input still empty when read again after it, and still
* no recent activity after that read. A time tmux can't report holds the paste, as an unreadable input does. The
* read it returns is the one the paste goes on. Typing after this final check and before the paste can still leave
* the notice beside the person's draft: the Enter is then refused, nothing is submitted or deleted (#3076). */
* the notice beside the person's draft. In the tested append-mode schedule the Enter is then refused and nothing is
* submitted or deleted; that isn't a guarantee for every interleaving (#3076). */
private async quietBeforePaste(target: GuardTarget, read: () => Promise<ComposerInput | null>,
unknown: (reading: ComposerInput | null) => DeliveryGuardError): Promise<ComposerInput> {
const quiet = async () => {
@@ -649,3 +650,12 @@ export function resolveGuardTarget(db: Database.Database, name: string): GuardTa
const { archivedAt: _archivedAt, physicalName: _physicalName, logicalId: _logicalId, rigName: _rigName, latestStatus: _latestStatus, ...target } = pool[0]!;
return target;
}
/** An external-CLI seat's newest occupant generation by its external session name, which resolveGuardTarget's address
* rules don't match: the one unarchived external_cli binding with that name, else null (none or several). */
export function resolveExternalCliOccupant(db: Database.Database, name: string): string | null {
const rows = db.prepare(`SELECT (SELECT generation_uuid FROM occupant_tenures t WHERE t.node_id=b.node_id ORDER BY generation_ordinal DESC LIMIT 1) AS occupant
FROM bindings b JOIN nodes n ON n.id=b.node_id JOIN rigs r ON r.id=n.rig_id
WHERE b.attachment_type='external_cli' AND b.external_session_name=? AND r.archived_at IS NULL`).all(name) as Array<{ occupant: string | null }>;
return rows.length === 1 ? rows[0]!.occupant : null;
}
@@ -657,7 +657,7 @@ export class SeatHandoverService {
// Partial: the successor is live in the preserved pane but the context packet never landed —
// unwind the discovery candidate (cleanup marks it vanished; it NEVER kills the preserved seat)
// and leave the binding unchanged (no false-green). The seat is re-wakeable from its session file.
await this.successorLauncher.cleanup(launch.tmuxSession, launch.discoveredId);
await this.successorLauncher.cleanup(launch.tmuxSession, launch.discoveredId, launch.activityGrantId);
return {
ok: false,
code: "context_delivery_failed",
@@ -742,7 +742,7 @@ export class SeatHandoverService {
if (!delivered.ok) {
// Same partial-state contract as the fresh packet: unwind the candidate,
// binding unchanged, seat re-wakeable — never a false complete.
await this.successorLauncher.cleanup(launch.tmuxSession, launch.discoveredId);
await this.successorLauncher.cleanup(launch.tmuxSession, launch.discoveredId, launch.activityGrantId);
return {
ok: false,
code: "context_delivery_failed",
@@ -780,7 +780,7 @@ export class SeatHandoverService {
launchAdditionNotes,
startedWithoutRequired,
launchWarnings: coreCopyNotes,
cleanup: () => this.successorLauncher.cleanup(launch.tmuxSession, launch.discoveredId),
cleanup: () => this.successorLauncher.cleanup(launch.tmuxSession, launch.discoveredId, launch.activityGrantId),
});
}
@@ -1458,10 +1458,6 @@ export class SeatHandoverService {
};
}
committed = outcome;
// S19 ruling 01530 — the SOLE narrow call: after the commit lands, the activity
// oracle sees the swap as its own event, keyed by the durable node id, identified
// by the successor tenure (never the retiree's). In-memory, post-commit, optional.
this.activityOracle?.declareOccupantSwap(input.node.id, committed.newSessionId);
} catch (err) {
return {
ok: false,
@@ -1471,6 +1467,16 @@ export class SeatHandoverService {
};
}
// S19 ruling 01530 — the SOLE narrow call: after the commit lands, the activity
// oracle sees the swap as its own event, keyed by the durable node id, identified
// by the successor tenure (never the retiree's). In-memory, post-commit, optional.
try {
this.activityOracle?.declareOccupantSwap(input.node.id, committed.newSessionId);
} catch (err) {
// A notification failure cannot roll back the committed binding or abandon its grant.
console.warn(`Seat activity swap notification failed: ${err instanceof Error ? err.name : typeof err}`);
}
this.eventBus.notifySubscribers(committed.event);
const postStatus = this.statusService.getStatus(`${input.status.logical_id}@${input.status.rig_name}`);
const currentStatus = postStatus.ok
@@ -1,3 +1,5 @@
import type { PiRunnerState } from "../adapters/pi-runner-protocol.js";
import { ActivityGrantStore, hasActivityGrantsTable } from "./activity-grants.js";
import { observeClaudePaneProcess, observeClaudePaneRuntime, observeCodexPaneProcess, listNativeProcesses, type NativeProcessLister, type NativeProcessObservation } from "./native-process-lineage.js";
import { isShellForeground } from "./shell-classifier.js";
import { claudeResumeRotation } from "./session-registry.js";
@@ -91,6 +93,7 @@ export interface SeatIdentityReconcilerDeps {
tmux: Pick<TmuxAdapter, "listSessions" | "getPanePid" | "getPaneCommand"> & Partial<Pick<TmuxAdapter, "readAllPaneProcesses" | "listPanes">>;
now?: () => Date;
listProcesses?: NativeProcessLister;
readPiRunnerState?: (sessionName: string) => PiRunnerState | null;
}
/**
@@ -110,6 +113,7 @@ export class SeatIdentityReconciler {
private readonly now: () => Date;
private readonly store: SeatIdentityStore;
private readonly listProcesses: NativeProcessLister;
private readonly readPiRunnerState?: SeatIdentityReconcilerDeps["readPiRunnerState"];
private timer: ReturnType<typeof setInterval> | null = null;
private reconciling = false;
private generation = 0;
@@ -120,6 +124,7 @@ export class SeatIdentityReconciler {
this.now = deps.now ?? (() => new Date());
this.store = new SeatIdentityStore(deps.db);
this.listProcesses = deps.listProcesses ?? listNativeProcesses;
this.readPiRunnerState = deps.readPiRunnerState;
}
private runningSeats(): RunningSeatRow[] {
@@ -172,6 +177,23 @@ export class SeatIdentityReconciler {
private async reconcileSweep(generation: number): Promise<void> {
const seats = this.runningSeats();
// Capture exact issuance before any observation awaits. A delayed verdict cannot revoke a successor.
const grants = hasActivityGrantsTable(this.db) ? new ActivityGrantStore(this.db) : null;
const observedGrants = new Map(seats.map(seat => {
const grant = grants?.activeForNode(seat.node_id);
let bootstrapComplete = false;
if (grant?.binding?.runtime === "pi" && grant.binding.runner === 0) {
// A shell can be the launch bootstrap. Capture current-attempt readiness BEFORE observing the pane;
// a later ready result cannot turn an earlier bootstrap-shell sample into a definitive exit.
try {
const state = this.readPiRunnerState?.(seat.session_name);
bootstrapComplete = !!state && state.launchId === grant.runId && (state.ready || !!state.exited);
} catch { /* Missing launch evidence is unknown, not proof that the runner exited. */ }
}
return [seat.node_id, grant?.runId && grant.binding?.pane === seat.tmux_pane
? { grantId: grant.grantId, shellExitConfirmed: grant.binding.runtime !== "pi" || grant.binding.runner !== 0 || bootstrapComplete }
: null];
}));
// Prune verdicts for nodes no longer running (keep the table bounded), except a released seat's.
this.store.pruneExcept([...seats.map((s) => s.node_id), ...this.releasedSeats()]);
if (seats.length === 0) return;
@@ -272,6 +294,11 @@ export class SeatIdentityReconciler {
const verdict = await this.computeVerdict(seat, liveSessions, observedAt, nativeProofs.get(seat.node_id) ?? null, panes);
if (generation !== this.generation) return;
this.store.upsert(verdict);
const observedGrant = observedGrants.get(seat.node_id);
if (observedGrant && (verdict.verdict === "pane_missing"
|| (verdict.verdict === "mismatch" && verdict.reason === "process_identity_mismatch" && observedGrant.shellExitConfirmed))) {
grants!.revoke({ grantId: observedGrant.grantId }, "runner_exit_observed");
}
} catch {
if (generation !== this.generation) return;
// A single seat's tmux failure must not crash the loop; record it as
@@ -342,9 +369,15 @@ export class SeatIdentityReconciler {
}
const pid = batched ? batched.pid : await this.tmux.getPanePid(seat.tmux_pane);
if (pid === null) {
// The registered pane no longer resolves. Distinguish "the whole tmux
// session is gone" from "the pane within a live session is gone".
// getPanePid collapses transport/read failures into null. Only a readable census
// showing another pane, or a successful session snapshot proving absence, establishes loss.
const sessionAlive = liveSessions.has(seat.session_name);
if (sessionAlive) {
const currentPanes = await this.tmux.listPanes?.(seat.session_name, true).catch(() => null);
if (!currentPanes?.length || currentPanes.some(pane => pane.id === seat.tmux_pane)) {
return this.tmuxUnavailableVerdict(seat, observedAt);
}
}
return {
...base,
verdict: "pane_missing",
@@ -1940,7 +1940,11 @@ export class SeatLifecycleService {
const probed = await this.probeLiveness(session.session_name, "in-place restart refuses rather than act on an unknown session");
if ("code" in probed) return probed;
if (probed.state === "absent") {
return { ok: false, code: "session_not_live", message: `Session "${session.session_name}" is absent in tmux; there is nothing to restart in place.` };
return { ok: false, code: "session_not_live", message: `Session "${session.session_name}" is absent in tmux; there is nothing to restart in place.`,
// A recoverable seat (#3131) comes back in its saved conversation through the ordinary launch, leaving the rest of the rig alone.
...(resolved.entry.lifecycleState === "recoverable"
? { guidance: `The seat is recoverable from its saved conversation: rig launch ${shellQuote(seat.rigName)} ${shellQuote(seat.logicalId)} (add --plan to preview).` }
: {}) };
}
const binding = this.sessionRegistry.getBindingForNode(node.id);
if (!binding?.tmuxSession || binding.tmuxSession !== session.session_name || !binding.tmuxPane) {
@@ -1,3 +1,4 @@
import { ActivityGrantStore } from "./activity-grants.js";
import { getOpenRigHome } from "../openrig-compat.js";
import { piAccountStateRoot, piAccountSwitchStateRoot } from "./pi-account-state.js";
import { piSeatPaths } from "../adapters/pi-runner-protocol.js";
@@ -416,7 +417,9 @@ export class SeatRuntimeSwitchService {
.run(id, node.id, sessionName, row.staging_session, ctx.predecessorPane, ctx.predecessorGeneration, successorGeneration, node.runtime, target,
ctx.input.reason, ctx.input.requestedBy ?? null, JSON.stringify(ctx.plan), deadlineAt, stamp(), stamp());
let successorPane: string | null = null;
let activityGrantId: string | undefined;
const abandon = async (why: string): Promise<RuntimeSwitchRefusal> => {
if (activityGrantId) new ActivityGrantStore(db).revoke({ grantId: activityGrantId }, "cutover_abandoned");
const retired = await this.retireStaging(row.staging_session, node.id, "Uncommitted successor");
db.prepare(`UPDATE seat_runtime_switches SET phase = 'abandoned', outcome = ?, attention = ?, updated_at = ? WHERE id = ? AND committed_at IS NULL`)
.run(why, retired.ok ? null : `The staged successor couldn't be retired: ${retired.message}`, stamp(), id);
@@ -486,6 +489,7 @@ export class SeatRuntimeSwitchService {
const attention = launch.code === "successor_attention_required" ? " It is waiting at a login, trust or approval prompt." : "";
return abandon(`The ${target} successor didn't start: ${launch.message}.${attention}`);
}
activityGrantId = launch.activityGrantId;
const warnings = [...ctx.plan.warnings, ...(launch.notes ?? [])];
// 2. The handover, as the successor's first turn; the confirmation it asks for is the evidence it can carry on.
@@ -546,6 +550,7 @@ export class SeatRuntimeSwitchService {
committed = this.commit({ ...ctx, id, successorGeneration, successorPane: successorPane!, appliedLaunch: launch.appliedLaunch,
launchResume: launch.resumeToken ? { type: launch.resumeType, token: launch.resumeToken } : null });
} catch (error) {
if (activityGrantId) new ActivityGrantStore(db).revoke({ grantId: activityGrantId }, "cutover_failed");
const back = await tmux.swapRuntimeSwitchPanes({ nodeId: node.id, stagingSession: row.staging_session, canonicalSession: sessionName,
canonicalPane: successorPane!, stagedPane: ctx.predecessorPane });
if (!back.ok) {
@@ -728,6 +733,13 @@ export class SeatRuntimeSwitchService {
const row = this.openSwitch(nodeId);
if (!row) return { ok: true, outcome: "No runtime switch needs recovery.", switchId: null };
const { db, tmuxAdapter: tmux } = this.deps;
if (!row.committed_at) {
const grants = new ActivityGrantStore(db);
// The durable operation owns this unique reservation. Capture/revoke its exact pending IDs before awaits;
// active predecessor authority and other reservations are outside this recovery.
const pending = grants.pendingForGeneration(nodeId, row.successor_generation);
for (const grant of pending) grants.revoke({ grantId: grant.grantId }, "interrupted_cutover_abandoned");
}
const at = () => this.now().toISOString();
const stuck = (message: string) => {
db.prepare("UPDATE seat_runtime_switches SET attention = ?, updated_at = ? WHERE id = ?").run(message, at(), row.id);
@@ -1,3 +1,4 @@
import { ActivityGrantStore } from "./activity-grants.js";
import type Database from "better-sqlite3";
import { monotonicFactory } from "ulid";
import { randomUUID } from "node:crypto";
@@ -327,6 +328,8 @@ export class SessionRegistry {
.run(id, nodeId, sessionName);
this.mintOccupantTenureBestEffort(nodeId, kind, reservedGeneration); // atom-B: mint this occupant generation
this.observeWatchdogRegistration(nodeId, sessionName, kind);
try { new ActivityGrantStore(this.db).reconcileBinding(nodeId); }
catch { /* Optional run attribution must not fail an already registered claimed session. */ }
return this.rowToSession(
this.db.prepare("SELECT * FROM sessions WHERE id = ?").get(id) as SessionRow
@@ -492,6 +495,15 @@ export class SessionRegistry {
}
}
/** Name existence is independent of best-effort tenure recording. */
carriesSessionName(sessionName: string): boolean {
return !!this.db.prepare(`SELECT 1 FROM nodes n JOIN rigs r ON r.id = n.rig_id
WHERE r.archived_at IS NULL AND (
EXISTS (SELECT 1 FROM sessions s WHERE s.node_id = n.id AND s.session_name = ?)
OR EXISTS (SELECT 1 FROM bindings b WHERE b.node_id = n.id AND (b.tmux_session = ? OR b.external_session_name = ?))
) LIMIT 1`).get(sessionName, sessionName, sessionName);
}
private rowToTenure(row: OccupantTenureRow): OccupantTenure {
return {
id: row.id,
@@ -505,6 +517,7 @@ export class SessionRegistry {
}
updateStatus(sessionId: string, status: string): void {
if (["exited", "detached", "superseded"].includes(status)) new ActivityGrantStore(this.db).revokeForSession(sessionId, status);
this.db
.prepare("UPDATE sessions SET status = ?, intentionally_stopped = 0, last_seen_at = datetime('now') WHERE id = ?")
.run(status, sessionId);
@@ -513,6 +526,7 @@ export class SessionRegistry {
/** A supported stop is distinct from an observed exit. Any later status write
* clears the intent, including a resume of this same session row. */
markIntentionallyStopped(sessionId: string): void {
new ActivityGrantStore(this.db).revokeForSession(sessionId, "stopped");
this.db.prepare(
"UPDATE sessions SET status = 'exited', intentionally_stopped = 1, last_seen_at = datetime('now') WHERE id = ?",
).run(sessionId);
@@ -2686,7 +2686,7 @@ export class SessionTransport {
// segment must never edit a line of the exact text.
if (opts?.exactText !== undefined && !opts.dangerouslyInteract) {
const { exactText, ...rest } = opts;
if (!isBoundTerminalSeat(this.db, sessionName)) return this.send(sessionName, text, rest);
if (!isBoundTerminalSeat(this.db, sessionName) && !isBoundTerminalSeat(this.db, physicalSessionName(this.db, sessionName))) return this.send(sessionName, text, rest);
return { ...(await this.send(sessionName, exactText, { ...rest, stampISO: undefined, typedExactly: true })), envelopeOmitted: true };
}
const observe = () => { try { return this.compactionObserver?.(sessionName) ?? null; } catch { return null; } };
@@ -1,3 +1,4 @@
import { ActivityGrantStore } from "./activity-grants.js";
import { nonInterruptiveNotice } from "../adapters/non-interruptive.js";
import { planModelAliasNotice } from "../adapters/yolo-mode.js";
import { randomUUID } from "node:crypto";
@@ -414,6 +415,7 @@ export class StartupOrchestrator {
? "rebuilt"
: "fresh";
let appliedLaunch: AppliedLaunchObservation | undefined;
let activityGrantId: string | undefined;
let resumedProcess: { pid: number; startedAt: string } | undefined;
const forkContext: PendingForkContext | undefined = input.adapter.runtime === "codex"
&& input.forkSource?.kind === "native_id" && input.forkSource.value?.trim()
@@ -691,6 +693,7 @@ export class StartupOrchestrator {
warnings.push(`Model "${launchResult.modelInput.model}": the launch command's send is uncertain, so it isn't recorded as passed.`);
}
if (launchResult.ok) {
activityGrantId = launchResult.activityGrantId;
appliedLaunch = launchResult.appliedLaunch;
resumedProcess = launchResult.resumedProcess;
if (input.adapter.runtime === "claude-code" && launchResumeToken?.trim()) {
@@ -820,6 +823,7 @@ export class StartupOrchestrator {
const readinessTimeoutMs = resolveReadinessTimeoutMs(input.readinessTimeoutMs, this.readinessSettings);
const readiness = await this.waitForReady(input.adapter, input.binding, readinessTimeoutMs);
if (!readiness.ready) {
if (activityGrantId) new ActivityGrantStore(this.db).revoke({ grantId: activityGrantId }, "readiness_failed");
const hasPendingReadinessWork = getPendingReadinessWork();
if (isAttentionRequiredReadinessCode(readiness.code)) {
errors.push(`Startup requires attention: ${readiness.reason ?? "unknown"}`);
@@ -829,6 +833,7 @@ export class StartupOrchestrator {
return this.fail(input, forkContext ? "attention_required" : "failed", errors, undefined, forkContext ?? false, undefined, false, "readiness_check", hasPendingReadinessWork);
}
} catch (err) {
if (activityGrantId) new ActivityGrantStore(this.db).revoke({ grantId: activityGrantId }, "readiness_failed");
errors.push(`Readiness check error: ${(err as Error).message}`);
return this.fail(input, "failed", errors, undefined, false, undefined, false, "readiness_check", getPendingReadinessWork());
}
@@ -994,6 +999,7 @@ export class StartupOrchestrator {
try {
const readiness = await input.adapter.checkReady(input.binding);
if (!readiness.ready && isAttentionRequiredReadinessCode(readiness.code)) {
if (activityGrantId) new ActivityGrantStore(this.db).revoke({ grantId: activityGrantId }, "post_delivery_readiness_failed");
// A fresh Claude start whose last startup turn was refused hasn't read what that turn sent: it's owed too.
const refusedLast = input.adapter.runtime === "claude-code" && isFreshLaunch && deliveryInput.sendOrder > 0
? owed([], readiness) : null;
@@ -1,3 +1,4 @@
import { ActivityGrantStore } from "./activity-grants.js";
import { ulid } from "ulid";
import { seatEnvLaunchVars, type SeatEnv } from "./seat-env.js";
import { SEAT_NODE_ENV, SEAT_PROJECT_DIR_ENV } from "./claude-core-plugin-env.js";
@@ -94,6 +95,7 @@ export type SuccessorLaunchResult =
| {
ok: true;
discoveredId: string;
activityGrantId?: string;
tmuxSession: string;
tmuxPane: string;
resumeToken?: string;
@@ -326,6 +328,7 @@ export class SuccessorSessionLauncher {
resumeToken: started.resumeToken,
resumeType: started.resumeType,
appliedLaunch: started.appliedLaunch,
...(started.activityGrantId ? { activityGrantId: started.activityGrantId } : {}),
retireeExit: terminated.path,
...(respawned.warnings ? { warnings: respawned.warnings } : {}),
};
@@ -345,7 +348,7 @@ export class SuccessorSessionLauncher {
occupantGeneration: string;
onPaneCreated: (pane: string) => void;
staged: Omit<StagedLaunch, "canonicalSession">;
}): Promise<{ ok: true; tmuxPane: string; resumeToken?: string; resumeType?: string; appliedLaunch?: AppliedLaunchObservation; notes?: string[] }
}): Promise<{ ok: true; activityGrantId?: string; tmuxPane: string; resumeToken?: string; resumeType?: string; appliedLaunch?: AppliedLaunchObservation; notes?: string[] }
| { ok: false; code: string; message: string }> {
const env = compactEnv({
OPENRIG_NODE_ID: input.node.id,
@@ -394,7 +397,7 @@ export class SuccessorSessionLauncher {
forkSource?: ForkSource,
launchGeneration?: string | null,
staged?: StagedLaunch,
): Promise<{ ok: true; resumeToken?: string; resumeType?: string; appliedLaunch?: AppliedLaunchObservation; notes?: string[] } | { ok: false; code: string; message: string; launched?: true }> {
): Promise<{ ok: true; activityGrantId?: string; resumeToken?: string; resumeType?: string; appliedLaunch?: AppliedLaunchObservation; notes?: string[] } | { ok: false; code: string; message: string; launched?: true }> {
const adapter = node.runtime ? this.runtimeAdapters[node.runtime] : undefined;
if (!adapter) {
return {
@@ -483,10 +486,14 @@ export class SuccessorSessionLauncher {
// Catch it here and return a STRUCTURED ok:false so the caller fails loudly at
// step=start_agent, exactly like a returned readiness failure. Nothing kills the
// pane (unwind invariant above): the launched successor may still be running there.
const revokeRun = () => {
if (launch.activityGrantId) new ActivityGrantStore(this.discoveryRepo.db).revoke({ grantId: launch.activityGrantId }, "successor_readiness_failed");
};
let readiness: ReadinessResult;
try {
readiness = await this.waitForReady(adapter, binding);
} catch (err) {
revokeRun();
return {
ok: false,
code: "successor_readiness_failed",
@@ -495,6 +502,7 @@ export class SuccessorSessionLauncher {
};
}
if (!readiness.ready) {
revokeRun();
return {
ok: false,
code: isAttentionRequiredReadinessCode(readiness.code) ? "successor_attention_required" : "successor_not_ready",
@@ -503,7 +511,7 @@ export class SuccessorSessionLauncher {
};
}
return { ok: true, resumeToken, resumeType, appliedLaunch: launch.appliedLaunch, ...(notes?.length ? { notes } : {}) };
return { ok: true, resumeToken, resumeType, ...(launch.activityGrantId ? { activityGrantId: launch.activityGrantId } : {}), appliedLaunch: launch.appliedLaunch, ...(notes?.length ? { notes } : {}) };
}
/**
@@ -564,7 +572,8 @@ export class SuccessorSessionLauncher {
* candidate vanished; the re-wakeable shell stays in the pane and the seat is recoverable from its
* provider session file. (`tmuxSession` is retained for signature stability + call-site logging.)
*/
async cleanup(_tmuxSession: string, discoveredId: string | null): Promise<void> {
async cleanup(_tmuxSession: string, discoveredId: string | null, activityGrantId?: string): Promise<void> {
if (activityGrantId) new ActivityGrantStore(this.discoveryRepo.db).revoke({ grantId: activityGrantId }, "cutover_abandoned");
if (discoveredId) this.discoveryRepo.markVanished([discoveredId]);
}
}
@@ -41,6 +41,7 @@ import { getDefaultOpenRigPath } from "../../openrig-compat.js";
import type { TaskTerminalView, TaskMembership } from "./task-terminal-view.js";
import { composeView, type ViewMemberInput } from "./view-composer.js";
import { createHash } from "node:crypto";
import { existsSync } from "node:fs";
import { isAbsolute } from "node:path";
import { buildGridRoot } from "./herdr-adapter.js";
import { daemonTmuxServerArgs, daemonTmuxServerInfo } from "../../adapters/tmux-server.js";
@@ -293,17 +294,21 @@ export class TerminalService {
: errorResult(name, "unsupported_terminal_session", `Provider '${name}' does not support selecting a named session. Use herdr with --session <name>.`);
}
/** Resolved once, so a preview and the open that follows compose the same pane commands. */
private resolveLocalTmux(): Promise<string | undefined> {
this.localTmux ??= (async () => {
try {
const path = await this.deps.resolveLocalTmux?.();
return typeof path === "string" && isAbsolute(path) ? path : undefined;
} catch {
return undefined;
}
})();
return this.localTmux;
/** Resolved once, so a preview and the open that follows compose the same pane commands; resolved again once that
* file is gone (e.g. a removed version-manager shim), so a long-running daemon never hands out a dead tmux path. */
private async resolveLocalTmux(): Promise<string | undefined> {
const cached = await (this.localTmux ??= this.probeLocalTmux());
if (cached === undefined || existsSync(cached)) return cached;
return this.localTmux = this.probeLocalTmux();
}
private async probeLocalTmux(): Promise<string | undefined> {
try {
const path = await this.deps.resolveLocalTmux?.();
return typeof path === "string" && isAbsolute(path) ? path : undefined;
} catch {
return undefined;
}
}
/** An omitted provider takes the first in DEFAULT_PROVIDER_ORDER that is available and alive. With neither, it stays
+3
View File
@@ -814,6 +814,9 @@ export interface NodeInventoryEntry {
/** Effective mode; inherited non-default sockets also have server arguments. */
tmuxServerMode?: "inherit" | "isolated";
resumeCommand: string | null;
/** Codex: label explaining whether the displayed resume flags match the seat's recorded current-generation launch,
* differ from it, or come from current settings because no launch record applies; null otherwise. */
resumeCommandLabel?: string | null;
recoveryGuidance: NodeRecoveryGuidance | null;
latestError: string | null;
// Extended fields
@@ -142,6 +142,10 @@ interface WatchdogPolicyEngineDeps {
* SKIPPING, so an unknown target-generation must never skip a legitimate wake (note-2 inversion).
*/
resolveTargetGeneration?: (sessionName: string) => string | null;
/** Whether an unarchived node still carries this name, even without a recorded tenure. When resolveTargetGeneration is
* unknown, a carried name is unresolved (a context watch skips and measures again); one carried by
* nothing is a seat that's gone (the watch ends). Absent → gone, as before. */
targetNameCarried?: (sessionName: string) => boolean;
/**
* Issue #1180 — the target seat's latest context-usage sample under its live
* occupant generation (generation-guarded by the jobs repository). Null = no
@@ -275,6 +279,7 @@ export class WatchdogPolicyEngine {
private readonly now: () => Date;
private readonly policies: Map<string, Policy>;
private readonly resolveTargetGeneration?: (sessionName: string) => string | null;
private readonly targetNameCarried?: (sessionName: string) => boolean;
private readonly resolveContextUsage?: WatchdogPolicyEngineDeps["resolveContextUsage"];
private readonly resolvePreDeliveryTerminalReason?: (input: { jobId: string }) => string | null;
private readonly resolveQueueWait?: WatchdogPolicyEngineDeps["resolveQueueWait"];
@@ -287,6 +292,7 @@ export class WatchdogPolicyEngine {
this.eventBus = deps.eventBus;
this.deliver = deps.deliver;
this.resolveTargetGeneration = deps.resolveTargetGeneration;
this.targetNameCarried = deps.targetNameCarried;
this.resolveContextUsage = deps.resolveContextUsage;
this.resolvePreDeliveryTerminalReason = deps.resolvePreDeliveryTerminalReason;
this.onWakeAttempt = deps.onWakeAttempt;
@@ -432,6 +438,8 @@ export class WatchdogPolicyEngine {
lastFiredCondition: job.lastFiredCondition,
occupantGeneration,
currentGenerationTranscriptPending,
occupantGenerationAmbiguous: isContextUsageThreshold && !occupantGeneration
&& (this.targetNameCarried?.(job.targetSession) ?? false),
requiredReceiptSatisfied,
requiredReceiptDeferred,
};
+31 -2
View File
@@ -158,6 +158,14 @@ function bindToGrant(c: Context, store: AgentActivityStore, grant: ActivityGrant
if (revoked && !permissionRoute) {
return refuse(401, "activity_grant_revoked", "This activity grant was revoked; nothing was recorded.");
}
if (grant.runId && body.runId !== undefined && body.runId !== grant.runId) {
return refuse(409, "activity_grant_stale", "This post names another run; nothing was recorded.");
}
const claimedRuntime = stringOrNull(body.runtime);
if (grant.binding && ((claimedRuntime && claimedRuntime !== grant.binding.runtime)
|| (body.eventFamily === "session_identity" && !claimedRuntime))) {
return refuse(403, "activity_grant_runtime_mismatch", "This runner grant speaks only for its bound runtime; nothing was recorded.");
}
const claimedNode = stringOrNull(body.nodeId);
const claimedSession = stringOrNull(body.sessionName);
if ((claimedNode && claimedNode !== grant.nodeId)
@@ -178,6 +186,15 @@ function bindToGrant(c: Context, store: AgentActivityStore, grant: ActivityGrant
* hasn't registered yet (pending). A `revoked` grant whose occupant wasn't replaced reads revoked: it was reissued for
* the same launch. `outcome` says what the refusal left undone. */
function grantStandingRefusal(c: Context, store: AgentActivityStore, grant: ActivityGrant, outcome: string, revoked = false): Response | null {
if (grant.runId) {
const standing = new ActivityGrantStore(store.db).standing(grant.grantId);
if (standing === "current") return null;
if (standing === "unverified") {
return c.json({ ok: false, code: "activity_grant_unverified", retryable: true,
error: `This runner grant couldn't be verified now; retry verification; ${outcome}` }, 409);
}
return c.json({ ok: false, code: `activity_grant_${standing}`, error: `This runner grant is ${standing}; ${outcome}` }, standing === "revoked" ? 401 : 409);
}
const registry = (c.get("sessionRegistry" as never) as SessionRegistry | undefined) ?? new SessionRegistry(store.db);
let standing: ReturnType<typeof grantGeneration>;
try {
@@ -211,7 +228,10 @@ async function piUsageRequest(c: Context, what: string, needs: (db: SessionRegis
}
const authHeader = c.req.header("authorization");
const bearerToken = authHeader?.startsWith("Bearer ") ? authHeader.slice("Bearer ".length).trim() : null;
if (bearerToken !== expectedToken && (c.req.header("x-openrig-activity-token") ?? null) !== expectedToken) {
const headerToken = c.req.header("x-openrig-activity-token") ?? null;
const grantStore = new ActivityGrantStore(sessionRegistry.db);
const grant = grantStore.authenticate(bearerToken) ?? grantStore.authenticate(headerToken);
if (bearerToken !== expectedToken && headerToken !== expectedToken && !grant?.runId) {
return { error: c.json({ ok: false, code: "activity_hook_unauthorized", error: `${what} require the configured local hook token.` }, 401) };
}
let body: Record<string, unknown>;
@@ -223,6 +243,12 @@ async function piUsageRequest(c: Context, what: string, needs: (db: SessionRegis
if (body === null || typeof body !== "object" || Array.isArray(body)) {
return { error: c.json({ ok: false, code: "invalid_pi_usage", error: `${what} require a JSON object` }, 400) };
}
if (grant?.runId) {
const bound = bindToGrant(c, store, grant, body);
if ("refused" in bound) return { error: bound.refused };
if (grant.binding?.runtime !== "pi") return { error: c.json({ ok: false, code: "activity_grant_wrong_seat", error: "Pi usage requires a Pi run." }, 403) };
body = bound.body;
}
const sessionName = stringOrNull(body.sessionName);
const piSessionId = stringOrNull(body.piSessionId);
if (!sessionName || !piSessionId) {
@@ -326,6 +352,9 @@ async function postHook(c: Context, installTokenAccepted: boolean): Promise<Resp
return c.json({ ok: false, code: "invalid_json", error: "Request body must be a JSON object." }, 400);
}
if (Object.hasOwn(body, "runId") && !grant?.runId) {
return c.json({ ok: false, code: "run_grant_required", error: "Actual-run reports require their run-bound credential." }, 403);
}
if (typeof body.sessionName === "string") body = { ...body, sessionName: physicalSessionName(store.db, body.sessionName) };
if (grant) {
const bound = bindToGrant(c, store, grant, body);
@@ -466,7 +495,7 @@ function isPiRpcHarness(db: import("better-sqlite3").Database, sessionId: string
if (!generation) reason = "generation_unverifiable";
else if (!current || !sessionRegistry.isOccupantGenerationRegistered(resolved.nodeId, generation)) {
reason = "generation_unresolvable";
} else if (current.generationUuid !== generation) reason = "generation_mismatch";
} else if (current.generationUuid !== generation && !(grant?.runId && grant.generation === generation)) reason = "generation_mismatch";
} catch {
return c.json({
ok: false, code: "generation_resolver_error", tokenPersisted: false,
+5 -21
View File
@@ -211,24 +211,6 @@ export function queueRoutes(): Hono {
message: `No wake is set for this park: nothing wakes ${item.destinationSession} on a timer while ${item.qitemId} waits on ${item.blockedOn}. To add one, park it again with --wake-after <duration> (or --wake-watchdog <job-id>). When ${item.blockedOn} clears, rig queue resolve ${item.qitemId} --decision <evidence> unparks it.`,
};
}
/** A summary or evidence ref the update asked for but the stored row doesn't carry (a blank one, which isn't stored
* because it would erase what the row has, or one the repository couldn't write): read from the stored row, so the
* caller is told whatever the reason. */
function unstoredAdvisories(body: { summary?: unknown; evidenceRef?: unknown }, item: QueueItem) {
return ([["summary", "summary", "summary_not_stored"], ["evidenceRef", "evidence ref", "evidence_ref_not_stored"]] as const)
.flatMap(([field, name, code]) => {
const requested = body[field];
const stored = item[field];
if (typeof requested !== "string" || stored === requested) return [];
const why = requested.trim().length === 0 ? `The ${name} was blank, so it wasn't stored` : `The ${name} wasn't stored`;
return [{
code,
qitemId: item.qitemId,
field,
message: `${why}: ${item.qitemId} keeps ${stored ? `its ${name} "${stored}"` : `no ${name}`}. The rest of the update was applied.`,
}];
});
}
function getInbox(c: { get: (key: string) => unknown }): InboxHandler {
return c.get("inboxHandler" as never) as InboxHandler;
}
@@ -330,6 +312,9 @@ export function queueRoutes(): Hono {
: err.code === "reply_to_requires_update" ? 400
: err.code === "reply_to_not_found" ? 400
: err.code === "invalid_human_questions" ? 400
// OPR.0.5.1 slice-51-06 D2: summary/evidence_ref on a non-park transition — a client
// input error surfaced as a structured 400 (the daemon rejects before any mutation).
: err.code === "summary_evidence_not_persistable" ? 400
// OPR.0.4.6.WF3 FR-6: the frontier close-path guard — operator
// misuse of a queue verb on a live workflow packet; structured
// 400 with the what/why/fix message, never a 500.
@@ -937,9 +922,8 @@ export function queueRoutes(): Hono {
evidenceRef: body.evidenceRef,
...resolveRecordedIdentity(c, identity), // P21 §4 era-stamp, plus the MCP adapter's caller on a direct attributed request (principal:v1)
});
const advisories = [body.state === "blocked" ? parkWithoutWakeAdvisory(item) : null, ...unstoredAdvisories(body, item)]
.filter((advisory) => advisory !== null);
return c.json({ ...item, ...(advisories.length > 0 ? { advisories } : {}) });
const advisory = body.state === "blocked" ? parkWithoutWakeAdvisory(item) : null;
return c.json({ ...item, ...(advisory ? { advisories: [advisory] } : {}) });
} catch (err) {
return errorResponse(c, err);
}
+9 -6
View File
@@ -44,22 +44,25 @@ import { CodexThreadIdResolver } from "../domain/codex-thread-id.js";
import { resolveLiveCodexThreadId } from "../domain/model-divergence/current-generation-record.js";
import { SeatIdentityStore } from "../domain/seat-identity-store.js";
import { parseSqliteUtcMs } from "../domain/sqlite-time.js";
import { runSyncSiteWhenSlow } from "../domain/sync-site-wrap.js";
const generationCensus = new ProcessCensus({ freshnessMs: 0 }); // coalesce concurrent receipts; recheck each later read
const generationThreadIds = new CodexThreadIdResolver({ codexHome: process.env.CODEX_HOME || undefined });
/** The node inventory's synchronous steps run one at a time, each after a turn of the event loop. They're SQLite work
* that can't yield, so concurrent requests ran their whole builds back to back and a read queued behind a burst (a
* /healthz, a queue read) waited for all of them; now it waits for one step. Steps that await I/O run outside. */
* /healthz, a queue read) waited for all of them; now it waits for one step. Steps that await I/O run outside.
* A step that takes the slow threshold or more is named `site` in the slow-operation log. That's its own synchronous
* time, measured once its turn comes: not the wait for the turn, and not the settling of a promise it returns. */
let inventoryTurn: Promise<void> = Promise.resolve();
async function inventoryStep<T>(work: () => T): Promise<Awaited<T>> {
async function inventoryStep<T>(site: string, work: () => T): Promise<Awaited<T>> {
const previous = inventoryTurn;
let release = () => {};
inventoryTurn = new Promise<void>((resolve) => { release = () => resolve(); });
try {
await previous;
await new Promise<void>((resolve) => setImmediate(resolve));
return await work();
return await runSyncSiteWhenSlow(site, work);
} finally {
release();
}
@@ -147,7 +150,7 @@ nodesRoutes.get("/", observeInventoryHandler, async (c) => {
const contextUsageStore = c.get("contextUsageStore" as never) as ContextUsageStore | undefined;
const transcriptStore = c.get("transcriptStore" as never) as TranscriptStore | undefined;
const inventory = await inventoryStep(() => contextUsageStore
const inventory = await inventoryStep("nodes.inventory.build", () => contextUsageStore
? getNodeInventoryWithContext(deps.rigRepo.db, rigId, contextUsageStore, transcriptStore)
: getNodeInventory(deps.rigRepo.db, rigId));
// Slice 15 — enrich with the two new orthogonal primitives. Order is
@@ -181,8 +184,8 @@ nodesRoutes.get("/", observeInventoryHandler, async (c) => {
seatActivity: deps.seatActivityService,
captureFallback: full,
});
const withActivity = full ? await attachActivity() : await inventoryStep(attachActivity);
const withTerminalAndWork = await inventoryStep(() => attachTerminalActivityAndWork(withActivity, {
const withActivity = full ? await attachActivity() : await inventoryStep("nodes.inventory.activity_sync", attachActivity);
const withTerminalAndWork = await inventoryStep("nodes.inventory.terminal_work", () => attachTerminalActivityAndWork(withActivity, {
db: deps.rigRepo.db,
seatActivity: deps.seatActivityService,
agentActivity: deps.agentActivityStore,
+22 -7
View File
@@ -1,3 +1,4 @@
import { runnerGrantIssuer } from "./domain/runner-grant-launch.js";
import { piAccountStateRoot } from "./domain/pi-account-state.js";
import { EnvironmentStartup } from "./domain/environment-startup.js";
import { EnvironmentPlatform } from "./domain/environment-platform.js";
@@ -14,7 +15,7 @@ import { makeWatchdogDeliver } from "./domain/watchdog-delivery.js";
import { fileURLToPath } from "node:url";
import { discoverTailscaleSelfNames } from "./middleware/browser-boundary.js";
import { configureShadowCapture } from "./domain/shadow-capture.js";
import { SeatDeliveryGuard, resolveGuardTarget } from "./domain/seat-delivery-guard.js";
import { SeatDeliveryGuard, resolveExternalCliOccupant, resolveGuardTarget } from "./domain/seat-delivery-guard.js";
import { seatCodexCorePluginDir, vendoredCorePluginDir } from "./domain/claude-core-plugin-env.js";
import { queueRecoveryOwnsWake } from "./domain/queue-wake-ladder.js";
import { HealthPolicyStore } from "./domain/health-policy.js";
@@ -652,6 +653,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
const seatIdentityReconciler = new SeatIdentityReconciler({
db,
tmux: tmuxAdapter,
readPiRunnerState: sessionName => piAdapter.readRunnerSidecar(sessionName).state,
});
// cmuxFactory takes precedence (for tests), then cmuxExec-based CLI transport, then default
const cmuxFactory = opts?.cmuxFactory
@@ -816,11 +818,20 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
const piOpenRouterChargeEnabled = (): boolean => {
try { return new ContextPackSettingsStore().resolveOne("usage.pi_openrouter_charge").value === true; } catch { return false; }
};
const runnerGrants = runnerGrantIssuer(db, tmuxAdapter);
const piResume = new PiResumeAdapter(
tmuxAdapter,
{ readFile: (p: string) => fs.readFileSync(p, "utf-8"), writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"), exists: (p: string) => fs.existsSync(p), mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }) },
{ stateRoot: piStateRoot, runnerEntryPath: piRunnerEntryPath },
{ accountHomeForSession: session => seatRuntimeHomeBySession(db, session, "pi"), stateRootForSession: piStateRootForSession, seatLaunchEnvironment, usageFactsEnabled: piUsageFactsEnabled, openRouterChargeEnabled: piOpenRouterChargeEnabled },
{ runnerGrant: async (session, runId, cwd) => {
const owners = db.prepare(`SELECT b.node_id AS nodeId FROM bindings b JOIN nodes n ON n.id=b.node_id
JOIN sessions s ON s.node_id=n.id AND s.id=(SELECT id FROM sessions WHERE node_id=n.id ORDER BY id DESC LIMIT 1)
WHERE b.tmux_session=? AND s.session_name=? AND n.runtime='pi' AND s.status NOT IN ('superseded','detached','exited')`)
.all(session, session) as Array<{ nodeId: string }>;
const binding = owners.length === 1 ? sessionRegistry.getBindingForNode(owners[0]!.nodeId) : null;
if (!binding) throw new Error("The restored Pi run has no unambiguous Core binding.");
return runnerGrants({ ...binding, cwd }, "pi", runId, 0);
}, accountHomeForSession: session => seatRuntimeHomeBySession(db, session, "pi"), stateRootForSession: piStateRootForSession, seatLaunchEnvironment, usageFactsEnabled: piUsageFactsEnabled, openRouterChargeEnabled: piOpenRouterChargeEnabled },
);
const ompResume = new OmpResumeAdapter(
tmuxAdapter,
@@ -1021,7 +1032,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
} });
// OPR.0.4.6.PI1 — the RPC-first Pi adapter (runner-in-a-pane). Same fsOps
// shape as the Codex adapter; seat isolation roots under piStateRoot.
const piAdapter = new PiRuntimeAdapter({ managedBlockRecords: guidanceBlockRecords, tmux: tmuxAdapter, seatLaunchEnvironment, fsOps: { readFile: (p: string) => fs.readFileSync(p, "utf-8"), writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"), exists: (p: string) => fs.existsSync(p), mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }), listFiles: (dir: string) => { const r: string[] = []; /** Collect relative file paths recursively. */ function w(d: string, pre: string) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name)); else r.push(pre ? nodePath.join(pre, e.name) : e.name); } } w(dir, ""); return r; }, statMode: (p: string) => fs.statSync(p).mode, chmod: (p: string, m: number) => fs.chmodSync(p, m), linkInfo: lstatLinkInfo }, stateRootForSession: piStateRootForSession, stateRoot: piStateRoot, runnerEntryPath: piRunnerEntryPath, usageFactsEnabled: piUsageFactsEnabled, openRouterChargeEnabled: piOpenRouterChargeEnabled });
const piAdapter = new PiRuntimeAdapter({ runnerGrants, managedBlockRecords: guidanceBlockRecords, tmux: tmuxAdapter, seatLaunchEnvironment, fsOps: { readFile: (p: string) => fs.readFileSync(p, "utf-8"), writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"), exists: (p: string) => fs.existsSync(p), mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }), listFiles: (dir: string) => { const r: string[] = []; /** Collect relative file paths recursively. */ function w(d: string, pre: string) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name)); else r.push(pre ? nodePath.join(pre, e.name) : e.name); } } w(dir, ""); return r; }, statMode: (p: string) => fs.statSync(p).mode, chmod: (p: string, m: number) => fs.chmodSync(p, m), linkInfo: lstatLinkInfo }, stateRootForSession: piStateRootForSession, stateRoot: piStateRoot, runnerEntryPath: piRunnerEntryPath, usageFactsEnabled: piUsageFactsEnabled, openRouterChargeEnabled: piOpenRouterChargeEnabled });
const ompAdapter = new OmpRuntimeAdapter({ managedBlockRecords: guidanceBlockRecords, tmux: tmuxAdapter, fsOps: { readFile: (p: string) => fs.readFileSync(p, "utf-8"), writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"), exists: (p: string) => fs.existsSync(p), mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }), listFiles: (dir: string) => { const r: string[] = []; /** Collect relative file paths recursively. */ function w(d: string, pre: string) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name)); else r.push(pre ? nodePath.join(pre, e.name) : e.name); } } w(dir, ""); return r; } }, stateRoot: ompStateRoot, runnerEntryPath: piRunnerEntryPath });
// OPR.0.5.1.1 — the stub runtime adapter (Pi-shaped node-script runner in a pane).
// Same fsOps shape as Pi; the compiled runner entry lives in the daemon dist.
@@ -1214,7 +1225,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
db, rigRepo, podRepo,
sessionRegistry, eventBus, nodeLauncher, startupOrchestrator,
fsOps: { readFile: (p: string) => fs.readFileSync(p, "utf-8"), exists: (p: string) => fs.existsSync(p) },
adapters: withCommunityHarnesses({ "claude-code": claudeAdapter, "codex": codexAdapter, "pi": piAdapter, "omp": ompAdapter, "stub": stubAdapter, "terminal": new (await import("./adapters/terminal-adapter.js")).TerminalAdapter() }, { tmux: tmuxAdapter, seatLaunchEnvironment, environmentStartup, piAdapter }),
adapters: withCommunityHarnesses({ "claude-code": claudeAdapter, "codex": codexAdapter, "pi": piAdapter, "omp": ompAdapter, "stub": stubAdapter, "terminal": new (await import("./adapters/terminal-adapter.js")).TerminalAdapter() }, { tmux: tmuxAdapter, seatLaunchEnvironment, environmentStartup, piAdapter, runnerGrants }),
tmuxAdapter,
agentImageLibrary,
continuityPolicyMaterializer,
@@ -1279,7 +1290,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
"omp": ompAdapter,
"stub": stubAdapter,
"terminal": new (await import("./adapters/terminal-adapter.js")).TerminalAdapter(),
}, { tmux: tmuxAdapter, seatLaunchEnvironment, environmentStartup, piAdapter });
}, { tmux: tmuxAdapter, seatLaunchEnvironment, environmentStartup, piAdapter, runnerGrants });
let kernelBootTracker: import("./domain/kernel-boot-tracker.js").KernelBootTracker | undefined;
try {
const { bootKernelIfNeeded } = await import("./domain/kernel-boot.js");
@@ -2376,8 +2387,12 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
// (i-c) fire-time target-generation gate: resolve the target's LIVE occupant-generation (P12
// occupant_tenures) so a generation-bound wake is refused once the target has handed over.
// UNKNOWN (null) fails open → deliver. A drop of this line disables the gate silently (gen-bound
// wakes would fire at the successor) — pinned in watchdog-target-gen-wiring.test.ts.
resolveTargetGeneration: (s) => sessionRegistry.currentOccupantGenerationForSession(s),
// wakes would fire at the successor) — pinned in watchdog-target-gen-wiring.test.ts. Read by the
// seat the name addresses, not the newest row with that name: an archived generation of the same
// seat can hold a newer one, and a context watchdog would then read that node's telemetry. An external-CLI seat
// addressed by its external session name, which the guard's address rules don't match, reads its own.
resolveTargetGeneration: (s) => { const target = resolveGuardTarget(db, s); return target ? target.occupant ?? null : resolveExternalCliOccupant(db, s); },
targetNameCarried: (s) => sessionRegistry.carriesSessionName(s),
// Issue #1180 — sampled context use for token/percent context watchdogs,
// generation-guarded against a predecessor's frozen telemetry.
resolveContextUsage: (s, occupantGeneration) =>
@@ -1,7 +1,10 @@
import { SeatIdentityStore } from "../src/domain/seat-identity-store.js";
import { piUsageEntriesSchema } from "../src/db/migrations/107_pi_usage_entries.js";
import { piUsageChargesSchema } from "../src/db/migrations/108_pi_usage_charges.js";
// A seat activity grant on the existing hook ingress, through the real route, store, occupant ledger and oracle. The
// grant is resolved first; only then is the seat and generation a post claims accepted. A refused post records nothing,
// so the seat's activity stays unknown. The install-wide token keeps today's behaviour.
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { Hono } from "hono";
import { RigRepository } from "../src/domain/rig-repository.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
@@ -45,6 +48,7 @@ function fixture(options: { guest?: boolean } = {}) {
c.set("agentActivityStore" as never, store as never);
if (!options.guest) c.set("activityHookToken" as never, INSTALL_TOKEN as never);
c.set("sessionRegistry" as never, sessions as never);
c.set("eventBus" as never, new EventBus(db) as never);
c.set("seatActivityService" as never, oracle as never);
await next();
});
@@ -90,7 +94,7 @@ function fixture(options: { guest?: boolean } = {}) {
const recorded = () => (db.prepare("SELECT COUNT(*) AS n FROM events WHERE type = 'agent.activity'").get() as { n: number }).n;
const activity = (nodeId: string) => oracle.getSeatState(nodeId)?.activity ?? "unknown";
const replace = (reserved?: string) => occupy(guest.id, GUEST, "handover", reserved);
return { guest, other, sessions, grants, generation, issue, post, heldPost, raw, event, recorded, activity, replace };
return { db, app, guest, other, sessions, grants, generation, issue, post, heldPost, raw, event, recorded, activity, replace };
}
describe("a seat activity grant on the hook ingress", () => {
@@ -116,6 +120,123 @@ describe("a seat activity grant on the hook ingress", () => {
}
});
it("requires bound authority for a claimed run even when install and generation are known", async () => {
const f = fixture();
const legacy = f.issue();
for (const token of [INSTALL_TOKEN, legacy.token]) {
expect(await f.post(token, f.event("Stop", "end_turn", { sessionName: GUEST, runId: "claimed-run" })))
.toMatchObject({ status: 403, body: { code: "run_grant_required" } });
}
expect(f.recorded()).toBe(0);
});
it("checks activation, run ID and the concrete binding before accepting run activity", async () => {
const f = fixture();
const row = f.db.prepare("SELECT id FROM sessions WHERE node_id = ? ORDER BY id DESC LIMIT 1").get(f.guest.id) as { id: string };
f.db.prepare("INSERT INTO bindings (id,node_id,tmux_session,tmux_pane) VALUES ('b',?,?,'%1')").run(f.guest.id, GUEST);
const grant = f.grants.issue({ nodeId: f.guest.id, generation: f.generation(f.guest.id), issuedBy: "launch", run: {
runId: "actual-attempt", sessionId: row.id, binding: { runtime: "synth", runner: 1, pane: "%1", panePid: 101 } } });
expect(await f.post(grant.token, f.event("Stop", "end_turn", { runId: grant.runId }))).toMatchObject({ status: 409, body: { code: "activity_grant_pending" } });
expect(f.grants.activate(grant.grantId)).toBe(true);
expect(await f.post(grant.token, f.event("Stop", "end_turn", { runId: "forged" }))).toMatchObject({ status: 409 });
expect(await f.post(grant.token, f.event("Stop", "end_turn", { runId: grant.runId }))).toMatchObject({ status: 200 });
f.db.prepare("UPDATE bindings SET tmux_pane = '%2' WHERE node_id = ?").run(f.guest.id);
expect(await f.post(grant.token, f.event("Stop", "end_turn", { runId: grant.runId }))).toMatchObject({ status: 409 });
expect(f.recorded()).toBe(1);
});
it("refuses unverified run activity without recording or revoking, then recovers", async () => {
const f = fixture();
const row = f.db.prepare("SELECT id FROM sessions WHERE node_id = ? ORDER BY id DESC LIMIT 1").get(f.guest.id) as { id: string };
f.db.prepare("INSERT INTO bindings (id,node_id,tmux_session,tmux_pane) VALUES ('b',?,?,'%1')").run(f.guest.id, GUEST);
const grant = f.grants.issue({ nodeId: f.guest.id, generation: f.generation(f.guest.id), issuedBy: "launch", run: {
runId: "temporarily-unverified", sessionId: row.id, binding: { runtime: "synth", runner: 1, pane: "%1", panePid: 101 } } });
expect(f.grants.activate(grant.grantId)).toBe(true);
const read = vi.spyOn(SeatIdentityStore.prototype, "getForNode").mockImplementation(() => { throw new Error("observation unavailable"); });
try {
expect(await f.post(grant.token, f.event("Stop", "end_turn", { runId: grant.runId })))
.toMatchObject({ status: 409, body: { code: "activity_grant_unverified", retryable: true } });
expect(f.recorded()).toBe(0);
expect(f.grants.get(grant.grantId)?.revokedAt).toBeNull();
} finally { read.mockRestore(); }
expect(await f.post(grant.token, f.event("Stop", "end_turn", { runId: grant.runId }))).toMatchObject({ status: 200 });
expect(f.recorded()).toBe(1);
f.db.prepare("UPDATE bindings SET tmux_pane = '%2' WHERE node_id = ?").run(f.guest.id);
expect(await f.post(grant.token, f.event("Stop", "end_turn", { runId: grant.runId })))
.toMatchObject({ status: 409, body: { code: "activity_grant_stale" } });
expect(f.recorded()).toBe(1);
});
it("allows a retained Pi run to reannounce identity after explicit same-process adoption", async () => {
const f = fixture();
f.db.prepare("UPDATE nodes SET runtime = 'pi' WHERE id = ?").run(f.guest.id);
f.db.prepare("INSERT INTO bindings (id,node_id,tmux_session,tmux_pane) VALUES ('b',?,?,'%1')").run(f.guest.id, GUEST);
const row = f.db.prepare("SELECT id FROM sessions WHERE node_id = ? ORDER BY id DESC LIMIT 1").get(f.guest.id) as { id: string };
const grant = f.grants.issue({ nodeId: f.guest.id, generation: f.generation(f.guest.id), issuedBy: "launch", run: {
runId: "retained-pi", sessionId: row.id, binding: { runtime: "pi", runner: 0, pane: "%1", panePid: 101 } } });
f.grants.activate(grant.grantId);
new SeatIdentityStore(f.db).upsert({ nodeId: f.guest.id, verdict: "unconfirmed", evidenceSource: "pane_process", reason: null,
sessionName: GUEST, observedAt: new Date().toISOString(), evidence: { registeredPane: "%1", observedPid: 101,
observedPanePid: 101, observedCommand: "node", matchedLayer: null } });
f.sessions.registerClaimedSession(f.guest.id, GUEST, "adopt");
expect(await f.post(grant.token, { runtime: "pi", eventFamily: "session_identity", hookEvent: "SessionStart",
sessionName: GUEST, sessionId: "native-id", sessionFile: "/tmp/retained.jsonl", generation: grant.generation, runId: grant.runId }))
.toMatchObject({ status: 200, body: { tokenPersisted: true } });
});
it.each(["claude-code", undefined])("refuses another or absent runtime identity on a Pi grant without changing the saved resume pointer: %s", async runtime => {
const f = fixture();
f.db.prepare("UPDATE nodes SET runtime = 'pi' WHERE id = ?").run(f.guest.id);
f.db.prepare("INSERT INTO bindings (id,node_id,tmux_session,tmux_pane) VALUES ('b',?,?,'%1')").run(f.guest.id, GUEST);
const row = f.db.prepare("SELECT id FROM sessions WHERE node_id = ? ORDER BY id DESC LIMIT 1").get(f.guest.id) as { id: string };
f.sessions.updateResumeToken(row.id, "pi_session_file", "/tmp/original-pi.jsonl", "hook");
const grant = f.grants.issue({ nodeId: f.guest.id, generation: f.generation(f.guest.id), issuedBy: "launch", run: {
runId: "pi-attempt", sessionId: row.id, binding: { runtime: "pi", runner: 0, pane: "%1", panePid: 101 } } });
f.grants.activate(grant.grantId);
const response = await f.post(grant.token, { runtime, eventFamily: "session_identity", hookEvent: "SessionStart",
sessionId: "12345678-1234-1234-1234-123456789abc", generation: grant.generation, runId: grant.runId });
expect(response).toMatchObject({ status: 403, body: { code: "activity_grant_runtime_mismatch" } });
expect(f.db.prepare("SELECT resume_type, resume_token FROM sessions WHERE id = ?").get(row.id))
.toEqual({ resume_type: "pi_session_file", resume_token: "/tmp/original-pi.jsonl" });
expect(f.db.prepare("SELECT COUNT(*) AS n FROM events WHERE type = 'agent.session_identity'").get()).toEqual({ n: 0 });
expect(await f.post(grant.token, { runtime: "pi", eventFamily: "session_identity", hookEvent: "SessionStart",
sessionId: "native-id", sessionFile: "/tmp/current-pi.jsonl", generation: grant.generation, runId: grant.runId }))
.toMatchObject({ status: 200, body: { tokenPersisted: true } });
expect(f.db.prepare("SELECT resume_type, resume_token FROM sessions WHERE id = ?").get(row.id))
.toEqual({ resume_type: "pi_session_file", resume_token: "/tmp/current-pi.jsonl" });
// Ordinary posts may omit runtime; a bound credential still supplies seat and generation.
expect(await f.post(grant.token, { eventFamily: "turn_end", hookEvent: "Stop", runId: grant.runId })).toMatchObject({ status: 200 });
expect(await f.post(grant.token, { runtime: "claude-code", eventFamily: "turn_end", hookEvent: "Stop", runId: grant.runId }))
.toMatchObject({ status: 403, body: { code: "activity_grant_runtime_mismatch" } });
// The install token still follows the existing identity path; it is not narrowed into a run-bound credential.
expect(await f.post(INSTALL_TOKEN, { runtime: "claude-code", eventFamily: "session_identity", hookEvent: "SessionStart",
sessionName: GUEST, sessionId: "12345678-1234-1234-1234-123456789abc", generation: grant.generation }))
.toMatchObject({ status: 200, body: { tokenPersisted: true } });
});
it("accepts bound Pi usage while preserving legacy install-token usage", async () => {
const f = fixture();
f.db.exec(piUsageEntriesSchema.sql!);
f.db.exec(piUsageChargesSchema.sql!);
f.db.prepare("UPDATE nodes SET runtime = 'pi' WHERE id = ?").run(f.guest.id);
f.db.prepare("INSERT INTO bindings (id,node_id,tmux_session,tmux_pane) VALUES ('b',?,?,'%1')").run(f.guest.id, GUEST);
const row = f.db.prepare("SELECT id FROM sessions WHERE node_id = ? ORDER BY id DESC LIMIT 1").get(f.guest.id) as { id: string };
const grant = f.grants.issue({ nodeId: f.guest.id, generation: f.generation(f.guest.id), issuedBy: "launch", run: {
runId: "pi-attempt", sessionId: row.id, binding: { runtime: "pi", runner: 0, pane: "%1", panePid: 101 } } });
const post = (token: string, path: string, extra = {}) => f.app.request(`/api/activity/${path}`, { method: "POST",
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
body: JSON.stringify({ sessionName: GUEST, piSessionId: "pi-native-id", facts: [], charges: [], ...extra }) });
for (const path of ["pi-usage", "pi-usage-charges"]) expect((await post(grant.token, path)).status).toBe(409);
f.grants.activate(grant.grantId);
for (const path of ["pi-usage", "pi-usage-charges"]) {
expect((await post(grant.token, path)).status).toBe(200);
expect((await post(INSTALL_TOKEN, path)).status).toBe(200);
expect((await post(grant.token, path, { nodeId: f.other.id })).status).toBe(403);
}
f.grants.revoke({ grantId: grant.grantId }, "stopped");
expect((await post(grant.token, "pi-usage")).status).toBe(401);
});
const wrongSeat: Array<[string, (f: ReturnType<typeof fixture>) => Record<string, unknown>]> = [
["another seat's name", () => ({ sessionName: OTHER })],
["another seat's node", (f) => ({ nodeId: f.other.id })],
+343 -1
View File
@@ -1,7 +1,14 @@
import { spawnSync } from "node:child_process";
import { chmodSync, existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { shellQuote } from "../src/adapters/shell-quote.js";
// The seat activity grant's store and the only activity values a guest receives.
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { RigRepository } from "../src/domain/rig-repository.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
import { SeatIdentityStore } from "../src/domain/seat-identity-store.js";
import { runnerGrantIssuer } from "../src/domain/runner-grant-launch.js";
import { ActivityGrantStore, grantGeneration, guestActivityEnv } from "../src/domain/activity-grants.js";
import { createFullTestDb } from "./helpers/test-app.js";
@@ -23,6 +30,16 @@ function fixture() {
}
describe("issuing a seat activity grant", () => {
it.each(["claude-code", "codex", "terminal", "stub"])("does no run-binding work for an unrelated %s registration", runtime => {
const f = fixture(); f.db.prepare("UPDATE nodes SET runtime = ? WHERE id = ?").run(runtime, f.node.id);
const legacy = f.grants.issue({ nodeId: f.node.id, generation: f.generation, issuedBy: "legacy" });
const session = f.sessions.registerClaimedSession(f.node.id, "dev-guest@guest-rig");
expect(session.status).toBe("running");
expect(f.grants.get(legacy.grantId)?.revokedAt).toBeNull();
expect(f.db.prepare("SELECT count(*) AS n FROM activity_grants WHERE run_id IS NOT NULL").get()).toEqual({ n: 0 });
});
it("returns the token once and stores only its sha256", () => {
const f = fixture();
const issued = f.grants.issue({ nodeId: f.node.id, generation: f.generation, issuedBy: "driver@test" });
@@ -76,6 +93,331 @@ describe("issuing a seat activity grant", () => {
});
});
describe("actual run grants", () => {
function run() {
const f = fixture();
const session = f.db.prepare("SELECT id FROM sessions WHERE node_id = ?").get(f.node.id) as { id: string };
f.db.prepare("INSERT INTO bindings (id,node_id,tmux_session,tmux_pane) VALUES ('b',?,'dev-guest@guest-rig','%1')").run(f.node.id);
const anchor = { runtime: "synth", runner: 1, pane: "%1", panePid: 101 };
f.db.prepare("UPDATE sessions SET agent_app = 'synth', agent_runner = 1 WHERE id = ?").run(session.id);
const issue = (runId: string) => f.grants.issue({ nodeId: f.node.id, generation: f.generation, issuedBy: "launch", run: { runId, sessionId: session.id, binding: anchor } });
return { ...f, session, anchor, issue };
}
it("stages a successor without replacing its predecessor; activation and late cleanup address exact grants", () => {
const f = run();
const first = f.issue("attempt-1");
expect(f.grants.standing(first.grantId)).toBe("pending");
expect(f.grants.activeForNode(f.node.id)).toBeNull();
expect(f.grants.activate(first.grantId)).toBe(true);
const failed = f.issue("attempt-2");
expect(f.grants.activeForNode(f.node.id)?.grantId).toBe(first.grantId);
f.grants.revoke({ grantId: failed.grantId }, "failed launch");
expect(f.grants.activate(failed.grantId)).toBe(false);
const next = f.issue("attempt-3");
expect(f.grants.activate(next.grantId)).toBe(true);
expect(f.grants.standing(first.grantId)).toBe("revoked");
f.grants.revoke({ grantId: first.grantId }, "late completion");
expect(f.grants.standing(next.grantId)).toBe("current");
expect(JSON.stringify(f.db.prepare("SELECT * FROM activity_grants").all())).not.toContain(next.token);
});
it("supersedes pending credentials for the same run without touching another staged attempt", () => {
const f = run();
const a = f.issue("a"), b = f.issue("b"), replacement = f.issue("a");
expect(f.grants.standing(a.grantId)).toBe("revoked");
expect(f.grants.standing(b.grantId)).toBe("pending");
expect(f.grants.standing(replacement.grantId)).toBe("pending");
});
it("does not let an older pending dispatch replace a newer active run", () => {
const f = run();
const older = f.issue("older"), newer = f.issue("newer");
expect(f.grants.activate(newer.grantId)).toBe(true);
expect(f.grants.activate(older.grantId)).toBe(false);
expect(f.grants.standing(newer.grantId)).toBe("current");
});
it("activates a staged runner only at the Core session and binding cutover", () => {
const f = run();
const first = f.issue("first");
f.grants.activate(first.grantId);
const generation = f.sessions.reserveOccupantGeneration()!;
const staged = f.grants.issue({ nodeId: f.node.id, generation, issuedBy: "launch", run: {
runId: "staged", binding: { ...f.anchor, pane: "%2", panePid: 202 } } });
expect(f.grants.activate(staged.grantId)).toBe(false);
expect(f.grants.activeForNode(f.node.id)?.grantId).toBe(first.grantId);
// The reconciler can have sampled the predecessor while the successor was staging.
new SeatIdentityStore(f.db).upsert({ nodeId: f.node.id, verdict: "unconfirmed", evidenceSource: "pane_process",
reason: null, sessionName: "dev-guest@guest-rig", observedAt: "2999-01-01T00:00:00.000Z",
evidence: { registeredPane: "%1", observedPid: 101, observedPanePid: 101, observedCommand: "node", matchedLayer: null } });
f.db.prepare("UPDATE bindings SET tmux_pane = '%2' WHERE node_id = ?").run(f.node.id);
const successor = f.sessions.registerClaimedSession(f.node.id, "dev-guest@guest-rig", "handover", generation);
expect(f.grants.standing(staged.grantId)).toBe("current");
expect(f.grants.get(staged.grantId)?.sessionId).toBe(successor.id);
expect(f.grants.standing(first.grantId)).toBe("revoked");
});
it("preserves explicit same-pane adoption only with retained concrete process evidence", () => {
const f = run();
const first = f.issue("first");
f.grants.activate(first.grantId);
new SeatIdentityStore(f.db).upsert({ nodeId: f.node.id, verdict: "unconfirmed", evidenceSource: "pane_process",
reason: null, sessionName: "dev-guest@guest-rig", observedAt: new Date().toISOString(),
evidence: { registeredPane: "%1", observedPid: 101, observedPanePid: 101, observedCommand: "node", matchedLayer: null } });
f.sessions.registerClaimedSession(f.node.id, "dev-guest@guest-rig", "adopt");
expect(f.grants.standing(first.grantId)).toBe("current");
f.db.prepare("UPDATE seat_identity_verdicts SET evidence_pane_pid = 999 WHERE node_id = ?").run(f.node.id);
expect(f.grants.standing(first.grantId)).toBe("stale");
});
it("revokes an adopted run on current-session stop after its concrete anchors are lost", () => {
const f = run(); const first = f.issue("first"); f.grants.activate(first.grantId);
new SeatIdentityStore(f.db).upsert({ nodeId: f.node.id, verdict: "unconfirmed", evidenceSource: "pane_process", reason: null,
sessionName: "dev-guest@guest-rig", observedAt: new Date().toISOString(), evidence: { registeredPane: "%1", observedPid: 101,
observedPanePid: 101, observedCommand: "node", matchedLayer: null } });
const adopted = f.sessions.registerClaimedSession(f.node.id, "dev-guest@guest-rig", "adopt");
expect(f.grants.get(first.grantId)?.sessionId).toBe(f.session.id); // immutable origin evidence
f.db.prepare("DELETE FROM seat_identity_verdicts WHERE node_id = ?").run(f.node.id);
f.sessions.markIntentionallyStopped(adopted.id);
expect(f.grants.standing(first.grantId)).toBe("revoked");
});
it("preserves an active grant through unreadable binding evidence and recovery", () => {
const f = run(), grant = f.issue("current"); f.grants.activate(grant.grantId);
const prepare = f.db.prepare.bind(f.db);
let bindingReads = 0;
const spy = vi.spyOn(f.db, "prepare").mockImplementation(((sql: string) => {
if (sql.includes("SELECT s.id, s.session_name") && ++bindingReads > 1) throw new Error("observation read unavailable");
return prepare(sql);
}) as typeof f.db.prepare);
try {
f.grants.reconcileBinding(f.node.id);
expect(f.grants.get(grant.grantId)?.revokedAt).toBeNull();
expect(f.grants.standing(grant.grantId)).toBe("unverified"); // refusal is not revocation
} finally { spy.mockRestore(); }
expect(f.grants.standing(grant.grantId)).toBe("current");
});
it("does not fail claimed registration because optional grant reconciliation cannot read", () => {
const f = run();
const spy = vi.spyOn(ActivityGrantStore.prototype, "reconcileBinding").mockImplementation(() => { throw new Error("grant table unavailable"); });
try {
const claimed = f.sessions.registerClaimedSession(f.node.id, "dev-guest@guest-rig");
expect(claimed.status).toBe("running");
expect(f.sessions.currentOccupantTenure(f.node.id)).toBeTruthy();
} finally { spy.mockRestore(); }
});
it("does not activate an older pending grant when the newer active binding cannot be read", () => {
const f = run(), older = f.issue("older"), newer = f.issue("newer"); f.grants.activate(newer.grantId);
const prepare = f.db.prepare.bind(f.db); let bindingReads = 0;
const spy = vi.spyOn(f.db, "prepare").mockImplementation(((sql: string) => {
if (sql.includes("SELECT s.id, s.session_name") && ++bindingReads === 2) throw new Error("newer binding unreadable");
return prepare(sql);
}) as typeof f.db.prepare);
try { expect(f.grants.activate(older.grantId)).toBe(false); }
finally { spy.mockRestore(); }
expect(f.grants.standing(newer.grantId)).toBe("current");
expect(f.grants.standing(older.grantId)).toBe("pending");
});
it.each(["missing", "stale"])("preserves adopted authority when identity evidence is %s, then recovers", missing => {
const f = run(), grant = f.issue("adopted"); f.grants.activate(grant.grantId);
const evidence = { nodeId: f.node.id, verdict: "unconfirmed" as const, evidenceSource: "pane_process" as const,
reason: null, sessionName: "dev-guest@guest-rig", observedAt: new Date().toISOString(),
evidence: { registeredPane: "%1", observedPid: 101, observedPanePid: 101, observedCommand: "node", matchedLayer: null } };
new SeatIdentityStore(f.db).upsert(evidence);
f.sessions.registerClaimedSession(f.node.id, "dev-guest@guest-rig", "adopt");
f.db.prepare("DELETE FROM seat_identity_verdicts WHERE node_id = ?").run(f.node.id);
if (missing === "stale") new SeatIdentityStore(f.db).upsert({ ...evidence, sessionName: "prior-session" });
f.grants.reconcileBinding(f.node.id);
f.grants.revokeForSession(f.session.id, "old_session_stopped");
expect(f.grants.get(grant.grantId)?.revokedAt).toBeNull();
expect(f.grants.standing(grant.grantId)).toBe("unverified");
new SeatIdentityStore(f.db).upsert(evidence);
expect(f.grants.standing(grant.grantId)).toBe("current");
});
it.each(["anchor", "pid", "generation"] as const)("does not issue weaker authority when %s is missing", async missing => {
const f = run();
if (missing === "generation") f.db.prepare("DELETE FROM occupant_tenures WHERE node_id = ?").run(f.node.id);
const setSessionEnv = vi.fn(async () => {});
const begin = runnerGrantIssuer(f.db, { getPanePid: async () => missing === "pid" ? null : 101,
setSessionEnv, unsetSessionEnv: async () => {} });
await expect(begin({ nodeId: f.node.id, tmuxSession: "dev-guest@guest-rig", tmuxPane: missing === "anchor" ? null : "%1" } as never,
"synth", "unbound", 1)).rejects.toThrow(/authority needs/);
expect(setSessionEnv).not.toHaveBeenCalled();
expect(f.db.prepare("SELECT count(*) AS n FROM activity_grants").get()).toEqual({ n: 0 });
});
it.each(["read", "partial read", "remove"] as const)("executes unbound with the original install token when private credential %s fails", async failure => {
const f = run(), dir = mkdtempSync(join(tmpdir(), "grant-fallback-"));
cleanups.push(() => rmSync(dir, { recursive: true, force: true }));
writeFileSync(join(dir, "tmux"), '#!/bin/sh\ncase "$1" in show-environment) [ "$TEST_FAIL" = read ] && exit 1; printf "%s=%s\\n" "$TEST_KEY" "$TEST_PRIVATE"; [ "$TEST_FAIL" != "partial read" ];; set-environment) [ "$TEST_FAIL" != remove ];; *) exit 1;; esac\n');
chmodSync(join(dir, "tmux"), 0o700);
let token = "", key = "";
const begin = runnerGrantIssuer(f.db, { getPanePid: async () => 101,
setSessionEnv: async (_session, name, value) => { key = name; token = value; }, unsetSessionEnv: async () => {} });
const launch = await begin({ nodeId: f.node.id, tmuxSession: "dev-guest@guest-rig", tmuxPane: "%1" } as never, "synth", "not-claimed", 1);
const program = `printf '%s' "$OPENRIG_ACTIVITY_HOOK_TOKEN|\${OPENRIG_RUN_ID-unset}|$OPENRIG_OCCUPANT_GENERATION"`;
const result = spawnSync("/bin/sh", ["-xc", launch.wrap(`/bin/sh -c ${shellQuote(program)}`)], { encoding: "utf8", env: {
...process.env, PATH: `${dir}:${process.env.PATH}`, OPENRIG_ACTIVITY_HOOK_TOKEN: "legacy-install", OPENRIG_RUN_ID: "stale-run",
OPENRIG_OCCUPANT_GENERATION: "legacy-generation", TEST_FAIL: failure, TEST_KEY: key, TEST_PRIVATE: token,
} });
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toBe("legacy-install|unset|legacy-generation");
expect(result.stderr).not.toContain(token);
expect(result.stderr).toContain("using existing install-token reporting");
await launch.finish(false);
});
it.each([false, true])("preserves an adopted grant when declaration is unavailable, without hiding positive replacement=%s", replaced => {
const f = run(), grant = f.issue("adopted"); f.grants.activate(grant.grantId);
f.db.prepare("DELETE FROM seat_identity_verdicts WHERE node_id = ?").run(f.node.id);
f.sessions.registerClaimedSession(f.node.id, "dev-guest@guest-rig", "adopt");
expect(f.grants.get(grant.grantId)?.revokedAt).toBeNull();
expect(f.grants.standing(grant.grantId)).toBe("unverified");
if (replaced) {
f.db.prepare("UPDATE bindings SET tmux_pane = '%2' WHERE node_id = ?").run(f.node.id);
f.grants.reconcileBinding(f.node.id);
expect(f.grants.standing(grant.grantId)).toBe("revoked");
return;
}
new SeatIdentityStore(f.db).upsert({ nodeId: f.node.id, verdict: "unconfirmed", evidenceSource: "pane_process",
reason: null, sessionName: "dev-guest@guest-rig", observedAt: new Date().toISOString(),
evidence: { registeredPane: "%1", observedPid: 101, observedPanePid: 101, observedCommand: "node", matchedLayer: null } });
f.grants.reconcileBinding(f.node.id);
expect(f.grants.standing(grant.grantId)).toBe("current");
});
it("preserves authority through an unavailable occupant generation and recovery", () => {
const f = run(), grant = f.issue("current"); f.grants.activate(grant.grantId);
const spy = vi.spyOn(SessionRegistry.prototype, "currentOccupantTenure").mockReturnValue(null);
try {
f.grants.reconcileBinding(f.node.id);
expect(f.grants.get(grant.grantId)?.revokedAt).toBeNull();
expect(f.grants.standing(grant.grantId)).toBe("unverified");
} finally { spy.mockRestore(); }
expect(f.grants.standing(grant.grantId)).toBe("current");
});
it("records a Core-selected runner declaration on a previously registered empty launch row", async () => {
const f = run();
f.db.prepare("UPDATE sessions SET agent_app = NULL, agent_runner = NULL WHERE id = ?").run(f.session.id);
const begin = runnerGrantIssuer(f.db, { getPanePid: async () => 101, setSessionEnv: async () => {}, unsetSessionEnv: async () => {} });
const launch = await begin({ nodeId: f.node.id, tmuxSession: "dev-guest@guest-rig", tmuxPane: "%1" } as never, "synth", "registered-first", 1);
expect(() => launch.dispatched()).not.toThrow();
expect(f.grants.standing(launch.grantId)).toBe("current");
expect(f.db.prepare("SELECT agent_app, agent_runner FROM sessions WHERE id = ?").get(f.session.id)).toEqual({ agent_app: "synth", agent_runner: 1 });
await launch.finish(true);
});
it("activates an earlier staged grant after a later predecessor rotation becomes stale at cutover", () => {
const f = run(); const first = f.issue("first"); f.grants.activate(first.grantId);
const generation = f.sessions.reserveOccupantGeneration()!;
const staged = f.grants.issue({ nodeId: f.node.id, generation, issuedBy: "launch", run: { runId: "staged",
binding: { ...f.anchor, pane: "%2", panePid: 202 } } });
const rotation = f.issue("predecessor-rotation");
expect(f.grants.activate(rotation.grantId)).toBe(true);
f.db.prepare("UPDATE bindings SET tmux_pane = '%2' WHERE node_id = ?").run(f.node.id);
f.sessions.registerClaimedSession(f.node.id, "dev-guest@guest-rig", "handover", generation);
expect(f.grants.standing(staged.grantId)).toBe("current");
expect(f.grants.standing(rotation.grantId)).toBe("revoked");
});
it("revokes the current run on an explicit stop or observed exit", () => {
const f = run();
const first = f.issue("first");
f.grants.activate(first.grantId);
f.sessions.markIntentionallyStopped(f.session.id);
expect(f.grants.standing(first.grantId)).toBe("revoked");
});
it("distributes privately, and late failed cleanup cannot revoke the replacement", async () => {
const f = run();
const env = new Map<string, string>();
const tmux = { getPaneCommand: async () => "bash", getPanePid: async () => 101,
setSessionEnv: async (_session: string, key: string, value: string) => { env.set(key, value); },
unsetSessionEnv: async (_session: string, key: string) => { env.delete(key); } };
const begin = runnerGrantIssuer(f.db, tmux);
const binding = { nodeId: f.node.id, tmuxSession: "dev-guest@guest-rig", tmuxPane: "%1" } as never;
const first = await begin(binding, "synth", "first", 1);
const token = [...env.values()][0]!;
expect(first.wrap("node runner.js")).not.toContain(token);
expect(first.wrap("node runner.js")).toContain("set +x");
expect(first.wrap("node runner.js")).toContain("set-environment");
first.dispatched();
const second = await begin(binding, "synth", "second", 1);
second.dispatched();
await first.finish(false);
expect(f.grants.activeForNode(f.node.id)?.runId).toBe("second");
expect(env.size).toBe(1);
});
it("preserves the active predecessor when private credential distribution fails after its write", async () => {
const f = run();
const first = f.issue("first"); f.grants.activate(first.grantId);
const env = new Map<string, string>();
const begin = runnerGrantIssuer(f.db, { getPanePid: async () => 101,
setSessionEnv: async (_session: string, key: string, token: string) => { env.set(key, token); throw new Error("private transport unavailable"); },
unsetSessionEnv: async (_session: string, key: string) => { env.delete(key); } });
await expect(begin({ nodeId: f.node.id, tmuxSession: "dev-guest@guest-rig", tmuxPane: "%1" } as never,
"synth", "failed", 1)).rejects.toThrow("credential distribution failed");
expect(f.grants.standing(first.grantId)).toBe("current");
expect(f.grants.activeForNode(f.node.id)?.runId).toBe("first");
expect(env.size).toBe(0);
});
it("keeps the existing staged /bin/sh launch path for non-POSIX parent shells", async () => {
const f = run();
const begin = runnerGrantIssuer(f.db, { getPanePid: async () => 101, setSessionEnv: async () => {}, unsetSessionEnv: async () => {} });
const launch = await begin({ nodeId: f.node.id, tmuxSession: "dev-guest@guest-rig", tmuxPane: "%1" } as never,
"synth", "shell-independent-bootstrap", 1);
expect(launch.wrap("node runner.js")).toMatch(/^\/bin\/sh -c /);
await launch.finish(false);
});
it("consumes the private tmux environment before exec without token text in argv or tracing", async () => {
const f = run();
const dir = mkdtempSync(join(tmpdir(), "run-grant-transfer-"));
cleanups.push(() => rmSync(dir, { recursive: true, force: true }));
const marker = join(dir, "consumed");
writeFileSync(join(dir, "tmux"), '#!/bin/sh\ncase "$1" in show-environment) printf "%s=%s\\n" "$TEST_KEY" "$TEST_PRIVATE";; set-environment) touch "$TEST_REMOVED";; *) exit 1;; esac\n');
chmodSync(join(dir, "tmux"), 0o700);
let token = "", key = "";
const begin = runnerGrantIssuer(f.db, { getPanePid: async () => 101,
setSessionEnv: async (_session, name, value) => { key = name; token = value; }, unsetSessionEnv: async () => {} });
const launch = await begin({ nodeId: f.node.id, tmuxSession: "dev-guest@guest-rig", tmuxPane: "%1" } as never, "synth", "opaque-run", 1);
const program = `const t=process.env.OPENRIG_ACTIVITY_HOOK_TOKEN; process.stdout.write(JSON.stringify({bound:t===process.env.TEST_PRIVATE,run:process.env.OPENRIG_RUN_ID,generation:process.env.OPENRIG_OCCUPANT_GENERATION,argvLeak:process.argv.some(a=>a.includes(t))}));`;
const command = launch.wrap(`${shellQuote(process.execPath)} -e ${shellQuote(program)}`);
expect(command).not.toContain(token);
const result = spawnSync("/bin/sh", ["-xc", command], { encoding: "utf8", env: { ...process.env, PATH: `${dir}:${process.env.PATH}`,
OPENRIG_ACTIVITY_HOOK_TOKEN: "legacy-install", OPENRIG_OCCUPANT_GENERATION: "stale-shell", TEST_KEY: key, TEST_PRIVATE: token, TEST_REMOVED: marker } });
expect(result.status, result.error?.message ?? result.stderr).toBe(0);
expect(JSON.parse(result.stdout)).toEqual({ bound: true, run: "opaque-run", generation: f.generation, argvLeak: false });
expect(result.stderr).not.toContain(token);
expect(existsSync(marker)).toBe(true);
await launch.finish(false);
});
it("refuses activation and standing after concrete pane or recorded runtime changes", () => {
const f = run();
const first = f.issue("first");
expect(f.grants.activate(first.grantId)).toBe(true);
const staged = f.issue("staged");
f.db.prepare("UPDATE bindings SET tmux_pane = '%2' WHERE node_id = ?").run(f.node.id);
expect(f.grants.standing(first.grantId)).toBe("stale");
expect(f.grants.activate(staged.grantId)).toBe(false);
f.db.prepare("UPDATE bindings SET tmux_pane = '%1' WHERE node_id = ?").run(f.node.id);
f.db.prepare("UPDATE sessions SET agent_runner = 0 WHERE id = ?").run(f.session.id);
expect(f.grants.standing(first.grantId)).toBe("stale");
});
});
describe("the activity values a guest receives", () => {
const grant = { token: "oag_guest", nodeId: "node-1", generation: "gen-1" };
@@ -843,6 +843,9 @@ describe("ClaudeCompactionEnforcer", () => {
expect(send.mock.calls[4]![1]).toContain("Required items are your restore map's ranked entries above your restore class's tier line");
expect(send.mock.calls[4]![1]).toContain("the session JSONL stay lookup-only");
expect(send.mock.calls[4]![1]).toContain("Read every required item that is not FULL in full now");
expect(send.mock.calls[4]![1]).toContain("before acting on the restored work, and keep any hold you verified as live");
expect(send.mock.calls[4]![1]).toContain("Report the final read-depth table and the reconnect note as you resume; don't hold unaffected work for them.");
expect(send.mock.calls[4]![1]).not.toContain("before doing any other work");
expect(send.mock.calls[4]![1]).not.toContain("Do not optimize for token conservation");
expect(send.mock.calls[3]![1]).toContain("read your newest restore map and use the restore packet as a lookup");
expect(send.mock.calls[3]![1]).not.toContain("read the restore packet files");
@@ -34,14 +34,18 @@ const withChoice = (daemonOptOut: boolean) => ({ ...codexFloor, daemonOptOut })
describe("the printed Codex resume command", () => {
it("adds --no-daemon right after codex only for a positively recorded opt-out", () => {
const old = buildCodexResumeCore("sess 456", null);
expect(old).toBe("codex -s workspace-write resume 'sess 456'");
const core = buildCodexResumeCore("sess 456", null);
expect(core).toBe("codex -s workspace-write resume 'sess 456'");
const displayedFloor = "codex -s workspace-write '-c' 'check_for_update_on_startup=false' resume 'sess 456'";
const build = buildNativeResumeCommand as (...args: unknown[]) => string | null;
expect(build("codex", "sess 456", null, null)).toBe(old);
expect(build("codex", "sess 456", null, null, false)).toBe(old);
expect(build("codex", "sess 456", null, null, true)).toBe("codex --no-daemon -s workspace-write resume 'sess 456'");
expect(build("codex", "sess 456", null, "profile's name", true))
.toBe(buildCodexResumeCore("sess 456", "profile's name").replace(/^codex /, "codex --no-daemon "));
expect(build("codex", "sess 456", null, null)).toBe(displayedFloor);
expect(build("codex", "sess 456", null, null, false)).toBe(displayedFloor);
expect(build("codex", "sess 456", null, null, true)).toBe(
"codex --no-daemon -s workspace-write '-c' 'check_for_update_on_startup=false' resume 'sess 456'",
);
expect(build("codex", "sess 456", null, "profile's name", true)).toBe(
build("codex", "sess 456", null, "profile's name", false)!.replace(/^codex /, "codex --no-daemon "),
);
// Claude's command has no daemon choice.
expect(build("claude-code", "tok", "dev-impl@r1", null, true)).toBe(buildNativeResumeCommand("claude-code", "tok", "dev-impl@r1"));
});
@@ -107,24 +111,33 @@ describe("rig ps resumeCommand and the node detail's recovery guidance", () => {
it("a recorded opt-out prints --no-daemon in both, with no limitation note", () => {
record(withChoice(true));
const { resumeCommand, guidance } = printed();
expect(resumeCommand).toBe("codex --no-daemon -s workspace-write resume 'sess-456'");
expect(guidance.commands).toEqual(["codex --no-daemon -s workspace-write resume 'sess-456'", "cd /workspace/app"]);
expect(resumeCommand).toBe("codex --no-daemon -s workspace-write '-c' 'check_for_update_on_startup=false' resume 'sess-456'");
expect(guidance.commands).toEqual([
"codex --no-daemon -s workspace-write '-c' 'check_for_update_on_startup=false' resume 'sess-456'",
"cd /workspace/app",
]);
expect(guidance.notes).not.toContain(NOT_RECORDED);
});
it("a recorded legacy launch prints the old command unchanged, with no limitation note", () => {
it("a recorded legacy launch prints the command without --no-daemon, with no limitation note", () => {
record(withChoice(false));
const { resumeCommand, guidance } = printed();
expect(resumeCommand).toBe("codex -s workspace-write resume 'sess-456'");
expect(guidance.commands).toEqual(["codex -s workspace-write resume 'sess-456'", "cd /workspace/app"]);
expect(resumeCommand).toBe("codex -s workspace-write '-c' 'check_for_update_on_startup=false' resume 'sess-456'");
expect(guidance.commands).toEqual([
"codex -s workspace-write '-c' 'check_for_update_on_startup=false' resume 'sess-456'",
"cd /workspace/app",
]);
expect(guidance.notes).not.toContain(NOT_RECORDED);
});
it("an unrecorded choice keeps the old command and says it isn't known", () => {
it("an unrecorded choice omits --no-daemon and says it isn't known", () => {
record(codexFloor);
const { resumeCommand, guidance } = printed();
expect(resumeCommand).toBe("codex -s workspace-write resume 'sess-456'");
expect(guidance.commands).toEqual(["codex -s workspace-write resume 'sess-456'", "cd /workspace/app"]);
expect(resumeCommand).toBe("codex -s workspace-write '-c' 'check_for_update_on_startup=false' resume 'sess-456'");
expect(guidance.commands).toEqual([
"codex -s workspace-write '-c' 'check_for_update_on_startup=false' resume 'sess-456'",
"cd /workspace/app",
]);
expect(guidance.notes).toContain(NOT_RECORDED);
});
@@ -132,7 +145,7 @@ describe("rig ps resumeCommand and the node detail's recovery guidance", () => {
record(withChoice(true));
registry.mintOccupantTenure("node-codex", "handover");
const { resumeCommand, guidance } = printed();
expect(resumeCommand).toBe("codex -s workspace-write resume 'sess-456'");
expect(resumeCommand).toBe("codex -s workspace-write '-c' 'check_for_update_on_startup=false' resume 'sess-456'");
expect(guidance.notes).toContain(NOT_RECORDED);
});
});
@@ -141,15 +154,25 @@ describe("rig ps resumeCommand and the node detail's recovery guidance", () => {
record(withChoice(true));
const { resumeCommand, guidance } = printed();
expect(resumeCommand).toBeNull();
expect(guidance.commands).toEqual(["cd /workspace/app", "codex --no-daemon -s workspace-write resume --last"]);
expect(guidance.summary).toContain("Try codex --no-daemon -s workspace-write resume --last;");
expect(guidance.commands).toEqual([
"cd /workspace/app",
"codex --no-daemon -s workspace-write '-c' 'check_for_update_on_startup=false' resume --last",
]);
expect(guidance.summary).toContain(
"Try codex --no-daemon -s workspace-write '-c' 'check_for_update_on_startup=false' resume --last",
);
expect(guidance.notes).not.toContain(NOT_RECORDED);
});
it("without a stored conversation and an unrecorded choice, the picker command and summary are unchanged", () => {
it("without a stored conversation and an unrecorded choice, the picker command and summary omit --no-daemon", () => {
const { guidance } = printed();
expect(guidance.commands).toEqual(["cd /workspace/app", "codex -s workspace-write resume --last"]);
expect(guidance.summary).toContain("Try codex -s workspace-write resume --last;");
expect(guidance.commands).toEqual([
"cd /workspace/app",
"codex -s workspace-write '-c' 'check_for_update_on_startup=false' resume --last",
]);
expect(guidance.summary).toContain(
"Try codex -s workspace-write '-c' 'check_for_update_on_startup=false' resume --last",
);
expect(guidance.notes).toContain(NOT_RECORDED);
});
});
File diff suppressed because it is too large. Load diff
@@ -3,6 +3,7 @@ import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { communityHarnessLabel, loadCommunityHarnessPreset } from "../src/domain/community-harness-presets.js";
import type { RunnerGrantIssuer } from "../src/domain/runner-grant-launch.js";
import { CommunityHarnessRuntimeAdapter } from "../src/adapters/community-harness-runtime-adapter.js";
import type { NodeBinding } from "../src/domain/runtime-adapter.js";
@@ -15,7 +16,7 @@ const PRESET = "format: 1\nname: synth\ncommand: synth-launch --serve\nmaintaine
const binding = { tmuxSession: "w@r" } as NodeBinding;
/** A seat whose launcher writes `record(attempt)` to the file this launch names (or nothing, for undefined). */
function seat(record?: (attempt: string, file: string) => unknown, preset = PRESET) {
function seat(record?: (attempt: string, file: string) => unknown, preset = PRESET, runnerGrants?: RunnerGrantIssuer) {
const presets = scratch("harness-presets-");
writeFileSync(join(presets, "synth.yaml"), preset);
const root = scratch("harness-results-");
@@ -27,7 +28,7 @@ function seat(record?: (attempt: string, file: string) => unknown, preset = PRES
return { ok: true as const };
});
const app = new CommunityHarnessRuntimeAdapter("synth", { tmux: { hasSession: vi.fn(async () => true), getPaneCommand: vi.fn(async () => "node"), sendShellCommand },
presetsDir: () => presets, launchResultRoot: () => root, launchResultWait: { timeoutMs: 20, intervalMs: 10 } });
runnerGrants, presetsDir: () => presets, launchResultRoot: () => root, launchResultWait: { timeoutMs: 20, intervalMs: 10 } });
return { app, sendShellCommand, presets, root };
}
const good = (attempt: string) => ({ format: 1, attempt, harness: "synth", nativeRef: "chat-123", producer: "synth-launch 1.0" });
@@ -158,3 +159,69 @@ describe("a community harness whose launcher can reopen a reported conversation"
expect(loadCommunityHarnessPreset("synth", presets)).toMatchObject({ ok: false, message: expect.stringContaining("resume needs launch_result: true or names_conversation: true") });
});
});
describe("community run authority", () => {
it.each([true, false])("binds the launch-result attempt and finalizes exact readiness=%s", async ready => {
let runId: string | undefined;
const dispatched = vi.fn(), finish = vi.fn(async (_ok: boolean) => {});
const runnerGrants: RunnerGrantIssuer = async (_binding, runtime, attempt, runner) => {
expect([runtime, runner]).toEqual(["synth", 1]); runId = attempt;
return { grantId: "test-grant", generation: "generation", wrap: command => command, dispatched, finish };
};
const f = seat(ready ? (attempt) => { expect(attempt).toBe(runId); return good(attempt); } : undefined,
`${PRESET}runner: true\n`, runnerGrants);
expect((await f.app.launchHarness(binding, { name: "w@r" })).ok).toBe(ready);
expect(runId).toBeTruthy();
expect(dispatched).toHaveBeenCalledOnce();
expect(finish).toHaveBeenCalledWith(ready);
});
it("allocates and revokes an attempt when the launch-result folder is unavailable", async () => {
const finish = vi.fn(async (_ok: boolean) => {});
const issue = vi.fn(async (_binding, _runtime, _runId, _runner) => ({ grantId: "test-grant", generation: "generation", wrap: (command: string) => command, dispatched: () => {}, finish }));
const f = seat(undefined, `${PRESET}runner: true\n`, issue);
rmSync(f.root, { recursive: true });
writeFileSync(f.root, "blocked");
expect((await f.app.launchHarness(binding, { name: "w@r" })).ok).toBe(false);
expect(issue.mock.calls[0]?.[2]).toMatch(/^[0-9a-f-]{36}$/);
expect(finish).toHaveBeenCalledWith(false);
});
});
it("finalizes its exact community grant when post-dispatch standing refuses", async () => {
const finish = vi.fn(async (_ok: boolean) => {});
const f = seat(good, `${PRESET}runner: true\n`, async () => ({ grantId: "refused", generation: "generation", wrap: command => command,
dispatched: () => { throw new Error("binding changed"); }, finish }));
expect(await f.app.launchHarness(binding, { name: "w@r" })).toMatchObject({ ok: true, resumeToken: "chat-123" });
expect(f.sendShellCommand).toHaveBeenCalledOnce();
expect(finish).toHaveBeenCalledWith(false);
});
describe("community reporting grants are optional to native launch", () => {
it("retries only its original command when the optional grant wrapper cannot be staged", async () => {
const finish = vi.fn(async () => {}), dispatched = vi.fn();
const issue: RunnerGrantIssuer = async () => ({ grantId: "own", generation: "g", wrap: command => `private-wrapper ${command}`, dispatched, finish });
const f = seat(good, `${PRESET}runner: true\n`, issue);
f.sendShellCommand.mockImplementationOnce(async () => ({ ok: false, code: "launch_path_too_long", message: "not dispatched" }) as never);
expect(await f.app.launchHarness(binding, { name: "w@r" })).toMatchObject({ ok: true, resumeToken: "chat-123" });
expect(f.sendShellCommand).toHaveBeenCalledTimes(2);
expect(f.sendShellCommand.mock.calls[0]![1]).toBe(`private-wrapper ${f.sendShellCommand.mock.calls[1]![1]}`);
expect(dispatched).not.toHaveBeenCalled();
expect(finish).toHaveBeenCalledWith(false);
});
it.each(["setup", "activation"] as const)("preserves one dispatched native launch when grant %s fails", async failure => {
const finish = vi.fn(async () => {});
const issue: RunnerGrantIssuer = async () => {
if (failure === "setup") throw new Error("credential environment unavailable");
return { grantId: "affected", generation: "g", wrap: command => command,
dispatched: () => { throw new Error("activation failed after send"); }, finish };
};
const f = seat(good, `${PRESET}runner: true\n`, issue);
expect(await f.app.launchHarness(binding, { name: "w@r" })).toMatchObject({ ok: true, resumeToken: "chat-123" });
expect(f.sendShellCommand).toHaveBeenCalledOnce();
expect(f.sendShellCommand.mock.calls[0]![1]).not.toContain("OPENRIG_RUN_ID=");
if (failure === "activation") expect(finish).toHaveBeenCalledWith(false);
});
});
@@ -24,6 +24,8 @@ import { WatchdogJobsRepository } from "../src/domain/watchdog-jobs-repository.j
import { WatchdogPolicyEngine } from "../src/domain/watchdog-policy-engine.js";
import { WatchdogScheduler } from "../src/domain/watchdog-scheduler.js";
import { watchdogRoutes } from "../src/routes/watchdog.js";
import { resolveGuardTarget } from "../src/domain/seat-delivery-guard.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
describe("context-usage-threshold watchdog", () => {
let db: Database.Database;
@@ -623,3 +625,98 @@ describe("context-usage-threshold registration", () => {
expect(await response.json()).toMatchObject({ error: "watched_file_unresolved" });
});
});
describe("context-usage-threshold watchdog on a name two seat generations share", () => {
let db: Database.Database;
let tmp: string;
beforeEach(() => {
db = createDb();
migrate(db, ALL_MIGRATIONS);
tmp = join(tmpdir(), `context-watchdog-ambiguous-${Date.now()}-${Math.random()}`);
mkdirSync(tmp, { recursive: true });
});
afterEach(() => {
db.close();
rmSync(tmp, { recursive: true, force: true });
});
const seat = (rig: string, node: string, generation: string, transcript: string) => {
db.prepare("INSERT INTO rigs (id, name) VALUES (?, 'seat-rig')").run(rig);
db.prepare("INSERT INTO nodes (id, rig_id, logical_id) VALUES (?, ?, 'dev.impl')").run(node, rig);
db.prepare("INSERT INTO sessions (id, node_id, session_name, status) VALUES (?, ?, 'dev-impl@seat-rig', 'detached')").run(`session-${node}`, node);
db.prepare(`INSERT INTO occupant_tenures (id, node_id, generation_ordinal, generation_uuid, kind, boot_at)
VALUES (?, ?, 1, ?, 'initial', '2026-08-28T09:00:00.000Z')`).run(`tenure-${node}`, node, generation);
writeFileSync(transcript, "1234567890");
db.prepare(`INSERT INTO context_usage (node_id, session_id, session_name, availability, transcript_path, sampled_at)
VALUES (?, ?, 'dev-impl@seat-rig', 'known', ?, '2026-08-28T09:00:01.000Z')`).run(node, `native-${node}`, transcript);
};
// As startup wires it.
const watch = () => {
const repo = new WatchdogJobsRepository(db);
const history = new WatchdogHistoryLog(db);
const deliveries: Array<{ targetSession: string; message: string }> = [];
const sessions = new SessionRegistry(db);
const engine = new WatchdogPolicyEngine({ jobsRepo: repo, historyLog: history, eventBus: new EventBus(db),
deliver: async (request) => { deliveries.push(request); return { status: "ok" as const }; },
resolveTargetGeneration: (s) => resolveGuardTarget(db, s)?.occupant ?? null,
targetNameCarried: (s) => sessions.carriesSessionName(s) });
const job = repo.register({ policy: "context-usage-threshold",
specYaml: "policy: context-usage-threshold\ngenerated_by: continuity-policy-materializer\ncontinuity_mode: managed-compaction\n"
+ "target:\n session: dev-impl@seat-rig\nmessage: Context threshold crossed.\n",
targetSession: "dev-impl@seat-rig", intervalSeconds: 60, registeredBySession: "daemon@kernel", watchedFilePath: null, thresholdBytes: 8 });
return { repo, history, deliveries, engine, job };
};
it("skips while the name names no single seat, stays active, then fires on its own seat's numbers", async () => {
// Two unarchived generations of seat dev-impl@seat-rig (the old rig unarchived for its restore), both stopped.
const oldTranscript = join(tmp, "old.jsonl"), currentTranscript = join(tmp, "current.jsonl");
seat("rig-old", "node-old", "gen-old", oldTranscript);
seat("rig-current", "node-current", "gen-current", currentTranscript);
const { repo, history, deliveries, engine, job } = watch();
expect(resolveGuardTarget(db, "dev-impl@seat-rig")).toBeNull();
const ambiguous = await engine.evaluate(repo.getByIdOrThrow(job.jobId));
expect(ambiguous.outcome).toMatchObject({ action: "skip", reason: "occupant_generation_unresolved" });
expect(repo.getByIdOrThrow(job.jobId)).toMatchObject({ state: "active", watchedFilePath: null });
expect(history.listForJob(job.jobId)[0]).toMatchObject({ outcome: "skipped", skipReason: "occupant_generation_unresolved" });
expect(deliveries).toEqual([]);
db.prepare("UPDATE rigs SET archived_at = datetime('now') WHERE id = 'rig-old'").run();
const resolved = await engine.evaluate(repo.getByIdOrThrow(job.jobId));
expect(resolved.outcome.action).toBe("send");
expect(deliveries).toEqual([{ targetSession: "dev-impl@seat-rig", message: "Context threshold crossed." }]);
expect(repo.getByIdOrThrow(job.jobId)).toMatchObject({ watchedFilePath: currentTranscript, watchedFileGeneration: "gen-current",
lastFiredGeneration: "gen-current" });
});
it.each(["session", "tmux binding", "external binding"])("keeps a watch with missing tenure while a %s carries its name", async (carrier) => {
seat("rig-current", "node-current", "gen-current", join(tmp, "current.jsonl"));
db.prepare("DELETE FROM occupant_tenures WHERE node_id = 'node-current'").run();
if (carrier !== "session") {
new SessionRegistry(db).updateBinding("node-current", carrier === "tmux binding"
? { tmuxSession: "dev-impl@seat-rig", tmuxPane: "%1" }
: { attachmentType: "external_cli", externalSessionName: "dev-impl@seat-rig" });
db.prepare("DELETE FROM sessions WHERE node_id = 'node-current'").run();
}
const { repo, history, deliveries, engine, job } = watch();
expect(new SessionRegistry(db).currentOccupantGenerationForSession("dev-impl@seat-rig")).toBeNull();
const unresolved = await engine.evaluate(repo.getByIdOrThrow(job.jobId));
expect(unresolved.outcome).toMatchObject({ action: "skip", reason: "occupant_generation_unresolved" });
expect(repo.getByIdOrThrow(job.jobId)).toMatchObject({ state: "active", watchedFilePath: null });
expect(history.listForJob(job.jobId)[0]).toMatchObject({ outcome: "skipped", skipReason: "occupant_generation_unresolved" });
expect(deliveries).toEqual([]);
});
it("ends the watch of a seat that's gone: its rig removed, nothing carries the name", async () => {
seat("rig-current", "node-current", "gen-current", join(tmp, "current.jsonl"));
const { repo, deliveries, engine, job } = watch();
db.prepare("DELETE FROM rigs WHERE id = 'rig-current'").run();
expect(new SessionRegistry(db).currentOccupantGenerationForSession("dev-impl@seat-rig")).toBeNull();
const gone = await engine.evaluate(repo.getByIdOrThrow(job.jobId));
expect(gone.outcome).toMatchObject({ action: "terminal", reason: "occupant_generation_unresolved" });
expect(repo.getByIdOrThrow(job.jobId).state).toBe("terminal");
expect(deliveries).toEqual([]);
});
});
@@ -201,29 +201,6 @@ describe("an opt-in decision revision on the settling verbs (#2446)", () => {
expect(await f.act({ verb: "approve", qitemId: id, expectedRevision: read })).toMatchObject({ status: 200, body: { closedQitem: { state: "done" } } });
});
it("a new evidence pointer on an open ask is a new decision: an answer read under the earlier pointer conflicts, with nothing written", async () => {
const f = fixture();
const id = (await f.repo.create({ sourceSession: ASKER, destinationSession: PERSON, body: "Approve the release notes", summary: "Notes?",
humanIntent: "decision", evidenceRef: "/proof/notes-v1.md", nudge: false })).qitemId;
const read = await f.revision(id);
f.repo.update({ qitemId: id, actorSession: ASKER, state: "pending", evidenceRef: "/proof/notes-v2.md" });
expect(f.repo.getById(id)).toMatchObject({ state: "pending", evidenceRef: "/proof/notes-v2.md" });
expect(await f.revision(id)).not.toBe(read);
const before = f.written(id);
expect(await f.act({ verb: "approve", qitemId: id, expectedRevision: read }))
.toMatchObject({ status: 409, body: { error: "decision_revision_changed", currentRevision: await f.revision(id) } });
expect(f.written(id)).toEqual(before);
});
it("a row with no evidence keeps the revision it had before evidence counted", async () => {
// A fixed id, so the revision (which names the row's park episode) is the same on every run; the value is the one
// this ask had before evidence counted.
const f = fixture();
const id = (await f.repo.create({ qitemId: "qitem-revision-control", sourceSession: ASKER, destinationSession: PERSON,
body: "Synthetic two-question ask", summary: "Ship?", humanIntent: "decision", humanQuestions: QUESTIONS, nudge: false })).qitemId;
expect(await f.revision(id)).toBe("dr1-bed30769297c81fe419c3520");
});
it("repairs a direct-answer partial commit on retry without duplicating the reply row, then reconciles subsequent retries", async () => {
const f = fixture();
const row = await f.repo.create({ sourceSession: ASKER, destinationSession: PERSON, body: "Ship the relay?", summary: "Ship?", humanIntent: "decision", nudge: false });
@@ -0,0 +1,65 @@
{
"about": "Read-only capture of one handoff family from a live OpenRig daemon (2026-11-11, follow-up to #2889, family advisor). Seat-authored note prose is replaced; daemon-generated notes are verbatim; every member carries a neutral mission tag; seat-written tags the detector does not read are neutral.",
"family": "advisor",
"lineageId": "qitem-20261107000014-e8f727a826181429",
"now": "2026-11-07T13:27:18.232Z",
"observationWindowSeconds": 86400,
"members": [
{
"qitemId": "qitem-20261107000014-e8f727a826181429",
"handedOffFrom": null,
"sourceSession": "plataforma-supervisor@comando",
"destinationSession": "advisor-lead@kernel",
"tags": [
"mission:family-advisor"
]
}
],
"transitions": [
{
"transitionId": 72302,
"qitemId": "qitem-20261107000014-e8f727a826181429",
"ts": "2026-11-07T00:00:14.182Z",
"state": "pending",
"actorSession": "plataforma-supervisor@comando",
"transitionNote": "created",
"identityProvenance": "claimed:v1"
},
{
"transitionId": 72324,
"qitemId": "qitem-20261107000014-e8f727a826181429",
"ts": "2026-11-07T00:01:13.328Z",
"state": "pending",
"actorSession": "watchdog@system",
"transitionNote": "parked-owner wake reserved: advisor-lead@kernel|6f59a0364b1c615d#429edbed-be21-4065-b316-4b96f9f725be; generation 72e33513-b98d-4034-9126-7e6a9a096bc0; retention-state WyIwMU02OEcwNUtCOVlZS0I5QkZHTk02REI0RCIsImxlZ2FjeS1vZmYiLCJsZWdhY3ktb2ZmIix7ImNvdW50IjowLCJieXRlcyI6MH1d; obligations qitem-20261107000014-e8f727a826181429",
"identityProvenance": null
},
{
"transitionId": 72349,
"qitemId": "qitem-20261107000014-e8f727a826181429",
"ts": "2026-11-07T00:03:13.852Z",
"state": "pending",
"actorSession": "watchdog@system",
"transitionNote": "parked-owner wake delivery refused: advisor-lead@kernel|6f59a0364b1c615d#429edbed-be21-4065-b316-4b96f9f725be; Refused: 'advisor-lead@kernel' is at an interactive prompt (selection_prompt). A message must not select or approve it. To deliberately drive the prompt: rig send advisor-lead@kernel \"<text>\" --dangerously-interact --reason \"<why>\". No text was sent.",
"identityProvenance": null
},
{
"transitionId": 73058,
"qitemId": "qitem-20261107000014-e8f727a826181429",
"ts": "2026-11-07T01:03:31.446Z",
"state": "pending",
"actorSession": "watchdog@system",
"transitionNote": "parked-owner episode closed: advisor-lead@kernel|6f59a0364b1c615d#429edbed-be21-4065-b316-4b96f9f725be (no longer observed parked)",
"identityProvenance": null
},
{
"transitionId": 85090,
"qitemId": "qitem-20261107000014-e8f727a826181429",
"ts": "2026-11-07T13:27:18.232Z",
"state": "done",
"actorSession": "advisor-lead@kernel",
"transitionNote": "owner note (redacted)",
"identityProvenance": "transport:v1"
}
]
}
@@ -0,0 +1,89 @@
{
"about": "Read-only capture of one handoff family from a live OpenRig daemon (2026-11-11, follow-up to #2889, family compaction). Seat-authored note prose is replaced; daemon-generated notes are verbatim; every member carries a neutral mission tag; seat-written tags the detector does not read are neutral.",
"family": "compaction",
"lineageId": "qitem-compaction-restore-70def04e-8fa5-490a-895f-be0acb70d887",
"now": "2026-11-06T04:34:37.633Z",
"observationWindowSeconds": 86400,
"members": [
{
"qitemId": "qitem-20261106043400-7aa6cadd",
"handedOffFrom": "qitem-compaction-restore-70def04e-8fa5-490a-895f-be0acb70d887",
"sourceSession": "coordenacao-orquestrador-tech@fabrica",
"destinationSession": "plataforma-supervisor@comando",
"tags": [
"compaction",
"restore-delivery",
"attempt:70def04e-8fa5-490a-895f-be0acb70d887",
"mission:family-compaction"
]
},
{
"qitemId": "qitem-compaction-restore-70def04e-8fa5-490a-895f-be0acb70d887",
"handedOffFrom": null,
"sourceSession": "build-dev-1@fabrica",
"destinationSession": "coordenacao-orquestrador-tech@fabrica",
"tags": [
"compaction",
"restore-delivery",
"attempt:70def04e-8fa5-490a-895f-be0acb70d887",
"mission:family-compaction"
]
}
],
"transitions": [
{
"transitionId": 54758,
"qitemId": "qitem-compaction-restore-70def04e-8fa5-490a-895f-be0acb70d887",
"ts": "2026-11-06T04:30:12.122Z",
"state": "pending",
"actorSession": "build-dev-1@fabrica",
"transitionNote": "created",
"identityProvenance": null
},
{
"transitionId": 54774,
"qitemId": "qitem-compaction-restore-70def04e-8fa5-490a-895f-be0acb70d887",
"ts": "2026-11-06T04:31:12.534Z",
"state": "pending",
"actorSession": "system:compaction",
"transitionNote": "Managed restore for build-dev-1@fabrica: restore_prompt undeliverable (draft_input_unknown; idle_prompt) (summary updated)",
"identityProvenance": null
},
{
"transitionId": 54804,
"qitemId": "qitem-compaction-restore-70def04e-8fa5-490a-895f-be0acb70d887",
"ts": "2026-11-06T04:34:00.283Z",
"state": "handed-off",
"actorSession": "coordenacao-orquestrador-tech@fabrica",
"transitionNote": "handed off to plataforma-supervisor@comando",
"identityProvenance": "transport:v1"
},
{
"transitionId": 54805,
"qitemId": "qitem-20261106043400-7aa6cadd",
"ts": "2026-11-06T04:34:00.283Z",
"state": "pending",
"actorSession": "coordenacao-orquestrador-tech@fabrica",
"transitionNote": "handoff from qitem-compaction-restore-70def04e-8fa5-490a-895f-be0acb70d887",
"identityProvenance": "transport:v1"
},
{
"transitionId": 54813,
"qitemId": "qitem-20261106043400-7aa6cadd",
"ts": "2026-11-06T04:34:37.389Z",
"state": "in-progress",
"actorSession": "plataforma-supervisor@comando",
"transitionNote": "claimed",
"identityProvenance": "transport:v1"
},
{
"transitionId": 54814,
"qitemId": "qitem-20261106043400-7aa6cadd",
"ts": "2026-11-06T04:34:37.633Z",
"state": "done",
"actorSession": "plataforma-supervisor@comando",
"transitionNote": null,
"identityProvenance": "transport:v1"
}
]
}
@@ -0,0 +1,276 @@
{
"about": "Read-only capture of one handoff family from a live OpenRig daemon (2026-11-11, follow-up to #2889, family fallback). Seat-authored note prose is replaced; daemon-generated notes are verbatim; every member carries a neutral mission tag; seat-written tags the detector does not read are neutral.",
"family": "fallback",
"lineageId": "fc-75d937e8e3e7-1794369652",
"now": "2026-11-11T11:15:02.406Z",
"observationWindowSeconds": 86400,
"members": [
{
"qitemId": "fc-75d937e8e3e7-1794369652",
"handedOffFrom": null,
"sourceSession": "coordenacao-orquestrador-tech@fabrica",
"destinationSession": "build-dev-1-s10@fabrica",
"tags": [
"new-task",
"mission:family-fallback"
]
},
{
"qitemId": "qitem-20261111081824-2a385e85",
"handedOffFrom": "fc-75d937e8e3e7-1794369652",
"sourceSession": "build-dev-1-s10@fabrica",
"destinationSession": "build-dev-10@fabrica",
"tags": [
"new-task",
"mission:family-fallback"
]
},
{
"qitemId": "qitem-20261111111354-7ee72c9b",
"handedOffFrom": "qitem-20261111081824-2a385e85",
"sourceSession": "build-dev-10@fabrica",
"destinationSession": "build-dev-1-s10@fabrica",
"tags": [
"new-task",
"mission:family-fallback"
]
},
{
"qitemId": "qitem-20261111111445-8772f875",
"handedOffFrom": "qitem-20261111111354-7ee72c9b",
"sourceSession": "build-dev-1-s10@fabrica",
"destinationSession": "coordenacao-orquestrador-tech@fabrica",
"tags": [
"new-task",
"mission:family-fallback"
]
}
],
"transitions": [
{
"transitionId": 179738,
"qitemId": "fc-75d937e8e3e7-1794369652",
"ts": "2026-11-11T04:00:52.239Z",
"state": "pending",
"actorSession": "coordenacao-orquestrador-tech@fabrica",
"transitionNote": "created",
"identityProvenance": "transport:v1"
},
{
"transitionId": 180991,
"qitemId": "fc-75d937e8e3e7-1794369652",
"ts": "2026-11-11T05:11:36.858Z",
"state": "pending",
"actorSession": "system:queue-fallback",
"transitionNote": "fallback-routed: build-dev-1@fabrica → build-dev-1-s10@fabrica (seat pool 01M71XNDSMNNYZ7CKNFJ7Z1GY2: build-dev-1@fabrica had not taken it; build-dev-1-s10@fabrica was free)",
"identityProvenance": null
},
{
"transitionId": 180992,
"qitemId": "fc-75d937e8e3e7-1794369652",
"ts": "2026-11-11T05:11:46.865Z",
"state": "in-progress",
"actorSession": "build-dev-1-s10@fabrica",
"transitionNote": "claimed",
"identityProvenance": "transport:v1"
},
{
"transitionId": 181141,
"qitemId": "fc-75d937e8e3e7-1794369652",
"ts": "2026-11-11T05:17:31.372Z",
"state": "blocked",
"actorSession": "build-dev-1-s10@fabrica",
"transitionNote": "parked on external:pronto-t005467",
"identityProvenance": "transport:v1"
},
{
"transitionId": 182496,
"qitemId": "fc-75d937e8e3e7-1794369652",
"ts": "2026-11-11T06:17:32.258Z",
"state": "blocked",
"actorSession": "watchdog@system",
"transitionNote": "park wake fired: watchdog 01M74GMCVAP0QE68TJ63CFH27P; delivery=ok; awaiting owner consumption",
"identityProvenance": null
},
{
"transitionId": 182507,
"qitemId": "fc-75d937e8e3e7-1794369652",
"ts": "2026-11-11T06:17:46.964Z",
"state": "blocked",
"actorSession": "build-dev-1-s10@fabrica",
"transitionNote": "parked on external:pronto-t005467",
"identityProvenance": "transport:v1"
},
{
"transitionId": 183598,
"qitemId": "fc-75d937e8e3e7-1794369652",
"ts": "2026-11-11T07:17:04.397Z",
"state": "blocked",
"actorSession": "build-dev-1-s10@fabrica",
"transitionNote": "parked on external:passos-t005467",
"identityProvenance": "transport:v1"
},
{
"transitionId": 184733,
"qitemId": "fc-75d937e8e3e7-1794369652",
"ts": "2026-11-11T08:17:05.919Z",
"state": "blocked",
"actorSession": "watchdog@system",
"transitionNote": "park wake fired: watchdog 01M74QF9RBWCE48E20X0J8BHF0; delivery=ok; awaiting owner consumption",
"identityProvenance": null
},
{
"transitionId": 184736,
"qitemId": "fc-75d937e8e3e7-1794369652",
"ts": "2026-11-11T08:17:18.591Z",
"state": "blocked",
"actorSession": "build-dev-1-s10@fabrica",
"transitionNote": "parked on external:passos-t005467",
"identityProvenance": "transport:v1"
},
{
"transitionId": 184760,
"qitemId": "fc-75d937e8e3e7-1794369652",
"ts": "2026-11-11T08:18:24.024Z",
"state": "handed-off",
"actorSession": "build-dev-1-s10@fabrica",
"transitionNote": "handed off to build-dev-10@fabrica",
"identityProvenance": "transport:v1"
},
{
"transitionId": 184761,
"qitemId": "qitem-20261111081824-2a385e85",
"ts": "2026-11-11T08:18:24.027Z",
"state": "pending",
"actorSession": "build-dev-1-s10@fabrica",
"transitionNote": "handoff from fc-75d937e8e3e7-1794369652",
"identityProvenance": "transport:v1"
},
{
"transitionId": 184774,
"qitemId": "qitem-20261111081824-2a385e85",
"ts": "2026-11-11T08:18:38.333Z",
"state": "in-progress",
"actorSession": "build-dev-10@fabrica",
"transitionNote": "owner note (redacted)",
"identityProvenance": "transport:v1"
},
{
"transitionId": 184937,
"qitemId": "qitem-20261111081824-2a385e85",
"ts": "2026-11-11T08:29:03.696Z",
"state": "blocked",
"actorSession": "build-dev-10@fabrica",
"transitionNote": "parked on external:pronto-t5467",
"identityProvenance": "transport:v1"
},
{
"transitionId": 185897,
"qitemId": "qitem-20261111081824-2a385e85",
"ts": "2026-11-11T09:29:04.621Z",
"state": "blocked",
"actorSession": "watchdog@system",
"transitionNote": "park wake fired: watchdog 01M74VK3TEJRDTXC9X04Z2B265; delivery=ok; awaiting owner consumption",
"identityProvenance": null
},
{
"transitionId": 185898,
"qitemId": "qitem-20261111081824-2a385e85",
"ts": "2026-11-11T09:29:10.663Z",
"state": "blocked",
"actorSession": "build-dev-10@fabrica",
"transitionNote": "parked on external:pronto-t5467-depois-t4139",
"identityProvenance": "transport:v1"
},
{
"transitionId": 186999,
"qitemId": "qitem-20261111081824-2a385e85",
"ts": "2026-11-11T10:29:12.206Z",
"state": "blocked",
"actorSession": "watchdog@system",
"transitionNote": "park wake fired: watchdog 01M74Z1685DNGGNH2GACG0T127; delivery=ok; awaiting owner consumption",
"identityProvenance": null
},
{
"transitionId": 187008,
"qitemId": "qitem-20261111081824-2a385e85",
"ts": "2026-11-11T10:29:27.376Z",
"state": "blocked",
"actorSession": "build-dev-10@fabrica",
"transitionNote": "parked on external:ci-base-71ad95a20",
"identityProvenance": "transport:v1"
},
{
"transitionId": 187550,
"qitemId": "qitem-20261111081824-2a385e85",
"ts": "2026-11-11T10:59:28.483Z",
"state": "blocked",
"actorSession": "watchdog@system",
"transitionNote": "park wake fired: watchdog 01M752FJ6EBC2BDK9BNCXSN3KM; delivery=ok; awaiting owner consumption",
"identityProvenance": null
},
{
"transitionId": 187571,
"qitemId": "qitem-20261111081824-2a385e85",
"ts": "2026-11-11T11:00:46.707Z",
"state": "blocked",
"actorSession": "build-dev-10@fabrica",
"transitionNote": "parked on external:ci-revert-5da2b6666",
"identityProvenance": "transport:v1"
},
{
"transitionId": 187880,
"qitemId": "qitem-20261111081824-2a385e85",
"ts": "2026-11-11T11:13:54.577Z",
"state": "handed-off",
"actorSession": "build-dev-10@fabrica",
"transitionNote": "handed off to build-dev-1-s10@fabrica",
"identityProvenance": "transport:v1"
},
{
"transitionId": 187881,
"qitemId": "qitem-20261111111354-7ee72c9b",
"ts": "2026-11-11T11:13:54.580Z",
"state": "pending",
"actorSession": "build-dev-10@fabrica",
"transitionNote": "handoff from qitem-20261111081824-2a385e85",
"identityProvenance": "transport:v1"
},
{
"transitionId": 187893,
"qitemId": "qitem-20261111111354-7ee72c9b",
"ts": "2026-11-11T11:14:08.828Z",
"state": "in-progress",
"actorSession": "build-dev-1-s10@fabrica",
"transitionNote": "claimed",
"identityProvenance": "transport:v1"
},
{
"transitionId": 187913,
"qitemId": "qitem-20261111111354-7ee72c9b",
"ts": "2026-11-11T11:14:45.119Z",
"state": "handed-off",
"actorSession": "build-dev-1-s10@fabrica",
"transitionNote": "handed off to coordenacao-orquestrador-tech@fabrica",
"identityProvenance": "transport:v1"
},
{
"transitionId": 187914,
"qitemId": "qitem-20261111111445-8772f875",
"ts": "2026-11-11T11:14:45.120Z",
"state": "pending",
"actorSession": "build-dev-1-s10@fabrica",
"transitionNote": "handoff from qitem-20261111111354-7ee72c9b",
"identityProvenance": "transport:v1"
},
{
"transitionId": 187923,
"qitemId": "qitem-20261111111445-8772f875",
"ts": "2026-11-11T11:15:02.406Z",
"state": "done",
"actorSession": "coordenacao-orquestrador-tech@fabrica",
"transitionNote": "owner note (redacted)",
"identityProvenance": "transport:v1"
}
]
}
@@ -0,0 +1,103 @@
{
"about": "Read-only capture of one handoff family from a live OpenRig daemon (2026-11-11, follow-up to #2889, family human). Seat-authored note prose is replaced; daemon-generated notes are verbatim; every member carries a neutral mission tag; seat-written tags the detector does not read are neutral.",
"family": "human",
"lineageId": "qitem-20261105005757-49f81eb717880063",
"now": "2026-11-05T09:22:15.811Z",
"observationWindowSeconds": 86400,
"members": [
{
"qitemId": "qitem-20261105005757-49f81eb717880063",
"handedOffFrom": null,
"sourceSession": "human@kernel",
"destinationSession": "plataforma-supervisor@comando",
"tags": [
"decision",
"task-ref",
"mission:family-human"
]
}
],
"transitions": [
{
"transitionId": 38763,
"qitemId": "qitem-20261105005757-49f81eb717880063",
"ts": "2026-11-05T02:46:44.412Z",
"state": "pending",
"actorSession": "human@kernel",
"transitionNote": "created",
"identityProvenance": "claimed:v1"
},
{
"transitionId": 38775,
"qitemId": "qitem-20261105005757-49f81eb717880063",
"ts": "2026-11-05T02:47:52.064Z",
"state": "in-progress",
"actorSession": "plataforma-supervisor@comando",
"transitionNote": "claimed",
"identityProvenance": "transport:v1"
},
{
"transitionId": 38776,
"qitemId": "qitem-20261105005757-49f81eb717880063",
"ts": "2026-11-05T02:47:52.560Z",
"state": "blocked",
"actorSession": "plataforma-supervisor@comando",
"transitionNote": "owner note (redacted)",
"identityProvenance": "transport:v1"
},
{
"transitionId": 38841,
"qitemId": "qitem-20261105005757-49f81eb717880063",
"ts": "2026-11-05T02:56:16.367Z",
"state": "pending",
"actorSession": "plataforma-clerigo@comando",
"transitionNote": "auto-unparked: blocker qitem-20261105024741-41cb92793e4d553b reached terminal state 'handed-off'",
"identityProvenance": null
},
{
"transitionId": 38842,
"qitemId": "qitem-20261105005757-49f81eb717880063",
"ts": "2026-11-05T02:56:19.952Z",
"state": "pending",
"actorSession": "queue@system",
"transitionNote": "blocker qitem-20261105024741-41cb92793e4d553b wake attempted; delivery=delivered-ack-pending",
"identityProvenance": null
},
{
"transitionId": 38858,
"qitemId": "qitem-20261105005757-49f81eb717880063",
"ts": "2026-11-05T02:57:49.810Z",
"state": "blocked",
"actorSession": "plataforma-supervisor@comando",
"transitionNote": "owner note (redacted)",
"identityProvenance": "transport:v1"
},
{
"transitionId": 41866,
"qitemId": "qitem-20261105005757-49f81eb717880063",
"ts": "2026-11-05T09:20:37.536Z",
"state": "pending",
"actorSession": "plataforma-clerigo@comando",
"transitionNote": "auto-unparked: blocker qitem-20261105025745-bdfbc9982c8272b5 reached terminal state 'handed-off'",
"identityProvenance": null
},
{
"transitionId": 41869,
"qitemId": "qitem-20261105005757-49f81eb717880063",
"ts": "2026-11-05T09:20:46.792Z",
"state": "pending",
"actorSession": "queue@system",
"transitionNote": "blocker qitem-20261105025745-bdfbc9982c8272b5 wake attempted; delivery=delivered-ack-pending",
"identityProvenance": null
},
{
"transitionId": 41895,
"qitemId": "qitem-20261105005757-49f81eb717880063",
"ts": "2026-11-05T09:22:15.811Z",
"state": "done",
"actorSession": "plataforma-supervisor@comando",
"transitionNote": "owner note (redacted)",
"identityProvenance": "transport:v1"
}
]
}
@@ -0,0 +1,66 @@
{
"about": "Read-only capture of one handoff family from a live OpenRig daemon (2026-11-11, follow-up to #2889, family kernel). Seat-authored note prose is replaced; daemon-generated notes are verbatim; every member carries a neutral mission tag; seat-written tags the detector does not read are neutral.",
"family": "kernel",
"lineageId": "qitem-20261109141133-fdc0cb57",
"now": "2026-11-09T14:16:35.176Z",
"observationWindowSeconds": 86400,
"members": [
{
"qitemId": "qitem-20261109141133-fdc0cb57",
"handedOffFrom": null,
"sourceSession": "daemon@kernel",
"destinationSession": "coordenacao-orquestrador-tech@fabrica",
"tags": [
"wake-escalation",
"prompt-destination:qualidade-revisor@fabrica",
"wake-prompt-refusal",
"prompt-kind:choice",
"prompt-episode:qualidade-revisor@fabrica|03a70bcbe1cf2986#43b51f73-b8bd-4d9d-8bf7-72f587327797",
"recovery-for:ace-2990",
"recovery-for:ace-2991",
"recovery-for:ace-2992",
"recovery-for:qitem-20261109131606-cf420b20",
"recovery-for:qitem-20261109134848-94fa20c6",
"mission:family-kernel"
]
}
],
"transitions": [
{
"transitionId": 129426,
"qitemId": "qitem-20261109141133-fdc0cb57",
"ts": "2026-11-09T14:11:33.557Z",
"state": "pending",
"actorSession": "daemon@kernel",
"transitionNote": "created",
"identityProvenance": null
},
{
"transitionId": 129427,
"qitemId": "qitem-20261109141133-fdc0cb57",
"ts": "2026-11-09T14:11:34.746Z",
"state": "pending",
"actorSession": "wake-ladder@system",
"transitionNote": "escalation-rung: orchestrator -> coordenacao-orquestrador-tech@fabrica outcome=delivered-ack-pending reason=interactive prompt blocks outstanding work; no input was sent",
"identityProvenance": null
},
{
"transitionId": 129428,
"qitemId": "qitem-20261109141133-fdc0cb57",
"ts": "2026-11-09T14:11:34.746Z",
"state": "pending",
"actorSession": "wake-ladder@system",
"transitionNote": "ladder-exhausted: escalated to orchestrator (delivered-ack-pending)",
"identityProvenance": null
},
{
"transitionId": 129501,
"qitemId": "qitem-20261109141133-fdc0cb57",
"ts": "2026-11-09T14:16:35.176Z",
"state": "done",
"actorSession": "daemon@kernel",
"transitionNote": "prompt escalation retired: prompt cleared or original episode ended; not a delivery receipt",
"identityProvenance": null
}
]
}
@@ -0,0 +1,50 @@
{
"about": "Read-only capture of one handoff family from a live OpenRig daemon (2026-11-11, follow-up to #2889, family mixed). Seat-authored note prose is replaced; daemon-generated notes are verbatim; every member carries a neutral mission tag; seat-written tags the detector does not read are neutral.",
"family": "mixed",
"lineageId": "qitem-20261111014734-d87546b4",
"now": "2026-11-11T02:16:38.753Z",
"observationWindowSeconds": 86400,
"members": [
{
"qitemId": "qitem-20261111014734-d87546b4",
"handedOffFrom": null,
"sourceSession": "daemon@kernel",
"destinationSession": "qualidade-qa-s6@fabrica",
"tags": [
"wake-escalation",
"wake-escalation:coordenacao-coord-2@fabrica",
"recovery-for:qitem-20261111011541-dbdc4f53",
"mission:family-mixed"
]
}
],
"transitions": [
{
"transitionId": 176433,
"qitemId": "qitem-20261111014734-d87546b4",
"ts": "2026-11-11T01:47:34.191Z",
"state": "pending",
"actorSession": "daemon@kernel",
"transitionNote": "created",
"identityProvenance": null
},
{
"transitionId": 177190,
"qitemId": "qitem-20261111014734-d87546b4",
"ts": "2026-11-11T02:16:02.712Z",
"state": "pending",
"actorSession": "system:queue-fallback",
"transitionNote": "fallback-routed: qualidade-qa-s5@fabrica → qualidade-qa-s6@fabrica (seat pool 01M7370FNT20116YKCYXXBXTGR: qualidade-qa-s5@fabrica had not taken it; qualidade-qa-s6@fabrica was free)",
"identityProvenance": null
},
{
"transitionId": 177214,
"qitemId": "qitem-20261111014734-d87546b4",
"ts": "2026-11-11T02:16:38.753Z",
"state": "done",
"actorSession": "qualidade-qa-s6@fabrica",
"transitionNote": "owner note (redacted)",
"identityProvenance": "transport:v1"
}
]
}
@@ -0,0 +1,254 @@
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { createDb } from "../src/db/connection.js";
import { migrate } from "../src/db/migrate.js";
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
import { EventBus } from "../src/domain/event-bus.js";
import { QueueRepository } from "../src/domain/queue-repository.js";
import { HealthPolicyStore } from "../src/domain/health-policy.js";
import { PassiveCeremonySource } from "../src/domain/health-passive-ceremony.js";
import { HealthProjectionService } from "../src/domain/health-detectors.js";
import { delegatedPostureFixture } from "./helpers/delegated-posture.js";
// Follow-up to #2762/#2889: `daemon@kernel` and every `system:*` actor are the same class of daemon bookkeeping as
// `*@system`, so process.ceremony-amplification leaves them out of the count, the basis and the reported ids.
// The families are read-only captures of a live daemon (fixtures/health-ceremony-daemon-actors/family-*.json).
// The contract under test:
// - a transition whose actor is exactly `daemon@kernel`, or starts with `system:`, is not a coordination transition,
// whatever its note says;
// - other kernel seats (`human@kernel`, `advisor-lead@kernel`) and seats that merely look alike still count;
// - the #2889 exclusions (`*@system`, daemon note prefixes) and the candidate threshold are unchanged.
// Handoff notes (`handed off to`, `handoff from`) and the `created` the compaction notice writes under the seat are left
// open: this file neither counts nor forbids them.
type FixtureTransition = { transitionId: number; qitemId: string; ts: string; state: string; actorSession: string; transitionNote: string | null; identityProvenance: string | null };
type Family = {
family: string; lineageId: string; now: string; observationWindowSeconds: number;
members: Array<{ qitemId: string; handedOffFrom: string | null; sourceSession: string; destinationSession: string; tags: string[] }>;
transitions: FixtureTransition[];
};
type Expected = { seat: number[]; daemon: number[]; open?: number[] };
const load = (name: string): Family =>
JSON.parse(readFileSync(new URL(`./fixtures/health-ceremony-daemon-actors/${name}.json`, import.meta.url), "utf8")) as Family;
// Per captured family: what a seat decided (must count), what the daemon wrote as daemon@kernel or system:* (must not),
// and what this contract leaves open. Everything else in the capture is a #2889 exclusion (must not count either).
const EXPECTED: Record<string, Expected> = {
fallback: {
seat: [179738, 180992, 181141, 182507, 183598, 184736, 184774, 184937, 185898, 187008, 187571, 187893, 187923],
daemon: [180991],
open: [184760, 184761, 187880, 187881, 187913, 187914],
},
kernel: { seat: [], daemon: [129426, 129501] },
mixed: { seat: [177214], daemon: [176433, 177190] },
compaction: { seat: [54813, 54814], daemon: [54774], open: [54758, 54804, 54805] },
human: { seat: [38763, 38775, 38776, 38858, 41895], daemon: [] },
advisor: { seat: [72302, 85090], daemon: [] },
};
const cleanup: Array<() => void> = [];
afterEach(() => { cleanup.splice(0).reverse().forEach((f) => f()); });
async function setup(family: Family, ceremonyTransitions?: number) {
const home = mkdtempSync(join(tmpdir(), "ceremony-daemon-actors-"));
cleanup.push(() => rmSync(home, { recursive: true, force: true }));
const workspace = join(home, "workspace");
mkdirSync(workspace, { recursive: true });
writeFileSync(join(workspace, "project.yaml"), "kind: project\nmetadata: {id: project}\n");
writeFileSync(join(workspace, "SPEC.md"), "# Product intent\nCaptured coordination families.");
const db = createDb(); migrate(db, ALL_MIGRATIONS); cleanup.push(() => db.close());
const queue = new QueueRepository(db, new EventBus(db), { loadHumanRegistry: () => ({ ok: true, entities: [] }) });
for (const m of family.members) {
await queue.create({ qitemId: m.qitemId, sourceSession: m.sourceSession, destinationSession: m.destinationSession, body: "captured family member", tags: m.tags, nudge: false });
db.prepare("DELETE FROM queue_transitions WHERE qitem_id = ?").run(m.qitemId);
db.prepare("UPDATE queue_items SET handed_off_from = ? WHERE qitem_id = ?").run(m.handedOffFrom, m.qitemId);
}
const insert = db.prepare("INSERT INTO queue_transitions(transition_id,qitem_id,ts,state,actor_session,transition_note,identity_provenance) VALUES(?,?,?,?,?,?,?)");
for (const t of family.transitions) insert.run(t.transitionId, t.qitemId, t.ts, t.state, t.actorSession, t.transitionNote, t.identityProvenance);
const policy = new HealthPolicyStore(home, () => ({ warningPercent: 95, criticalPercent: 99 }));
const p = policy.read().policy;
policy.apply({ ...p, observationWindowSeconds: family.observationWindowSeconds,
thresholds: { ...p.thresholds, ...(ceremonyTransitions ? { ceremonyTransitions } : {}) },
diagnosis: { ...p.diagnosis, enabled: true, owner: "owner@rig" } }, "operator@rig");
const source = new PassiveCeremonySource(workspace, queue, policy, () => family.now);
const projection = new HealthProjectionService(source, () => policy.read(), delegatedPostureFixture);
const observation = () => source.read().filter((o) => o.lineageId === family.lineageId);
const candidates = () => projection.list().records.filter((r) => r.detector === "process.ceremony-amplification" && r.ceremony?.lineageId === family.lineageId);
return { db, queue, observation, candidates };
}
/** The reported ids hold every seat decision, no daemon or #2889 exclusion, and the count is their length. */
function expectSeatDecided(o: ReturnType<Awaited<ReturnType<typeof setup>>["observation"]>[number] | undefined, e: Expected) {
const ids = o?.ceremony?.transitionIds ?? [];
expect(ids).toEqual(expect.arrayContaining(e.seat));
expect(ids.filter((id) => !e.seat.includes(id) && !(e.open ?? []).includes(id))).toEqual([]);
expect(e.daemon.filter((id) => ids.includes(id))).toEqual([]);
expect(o?.coordinationTransitions).toBe(ids.length);
}
const at = (base: string, i: number) => new Date(Date.parse(base) + i * 1000).toISOString();
/** The captured `mixed` family (daemon@kernel creates, system:queue-fallback reroutes, the seat closes) plus later steps. */
function extendMixed(name: string, steps: Array<Pick<FixtureTransition, "actorSession" | "transitionNote" | "identityProvenance">>, firstId: number) {
const mixed = load("family-mixed");
const added = steps.map((s, i) => ({ ...s, transitionId: firstId + i, qitemId: mixed.lineageId, ts: at("2026-11-11T02:17:00.000Z", i), state: "blocked" }));
return { family: { ...mixed, family: name, now: "2026-11-11T02:30:00.000Z", transitions: [...mixed.transitions, ...added] }, added };
}
/** The mixed family plus the seat's own later steps: a real coordination chain that must stay visible. */
function regressionChain(seatSteps: number): { family: Family; expected: number[] } {
// Synthetic (not in the capture), marked: ordinary seat decisions, kernel seats among them, and notes that only quote
// the daemon's actor names.
const steps = [
{ actorSession: "qa@team", transitionNote: "reopened: the fallback seat needs the original evidence", identityProvenance: "transport:v1" },
{ actorSession: "human@kernel", transitionNote: "operator decision: keep the escalation open", identityProvenance: "claimed:v1" },
{ actorSession: "coordinator@team", transitionNote: "checked what daemon@kernel created before acting", identityProvenance: "transport:v1" },
{ actorSession: "coordinator@team", transitionNote: "rerouted by hand after system:queue-fallback picked the wrong slot", identityProvenance: "transport:v1" },
{ actorSession: "qa@team", transitionNote: null, identityProvenance: "transport:v1" },
{ actorSession: "operator@kernel", transitionNote: "operator acknowledged the escalation", identityProvenance: "transport:v1" },
{ actorSession: "qa@team", transitionNote: "parked on qitem-review-2", identityProvenance: "transport:v1" },
{ actorSession: "coordinator@team", transitionNote: "owner decision recorded", identityProvenance: null },
{ actorSession: "qa@team", transitionNote: "evidence attached", identityProvenance: "transport:v1" },
{ actorSession: "builder@team", transitionNote: "fix pushed for review", identityProvenance: "transport:v1" },
{ actorSession: "qa@team", transitionNote: "proof rerun on the new head", identityProvenance: "transport:v1" },
{ actorSession: "coordinator@team", transitionNote: "routed the fix back to the builder", identityProvenance: "transport:v1" },
{ actorSession: "builder@team", transitionNote: "second fix pushed", identityProvenance: "transport:v1" },
{ actorSession: "qa@team", transitionNote: "proof green", identityProvenance: "transport:v1" },
{ actorSession: "reviewer@team", transitionNote: "review returned with one finding", identityProvenance: "transport:v1" },
{ actorSession: "coordinator@team", transitionNote: "finding accepted, back to the builder", identityProvenance: "transport:v1" },
{ actorSession: "builder@team", transitionNote: "finding fixed", identityProvenance: "transport:v1" },
{ actorSession: "reviewer@team", transitionNote: "approved", identityProvenance: "transport:v1" },
{ actorSession: "coordinator@team", transitionNote: "owner decision: release window kept", identityProvenance: "transport:v1" },
].slice(0, seatSteps);
const { family, added } = extendMixed("regression", steps, 177300);
return { family, expected: [...EXPECTED.mixed!.seat, ...added.map((t) => t.transitionId)] };
}
describe("ceremony-amplification ignores daemon@kernel and system:* actors (#2889 follow-up)", () => {
it("family fallback: the fixture is the whole captured family inside the observation window", async () => {
const family = load("family-fallback");
const t = await setup(family);
const start = new Date(Date.parse(family.now) - family.observationWindowSeconds * 1000).toISOString();
const ids = family.members.flatMap((m) => t.queue.transitionLog.listForQitemWindow(m.qitemId, start, family.now, 10001).map((x) => x.transitionId));
expect(ids.sort((x, y) => x - y)).toEqual(family.transitions.map((x) => x.transitionId));
// 25 captured: 5 watchdog@system wakes (#2889), 1 system:queue-fallback routing, 19 under seat actors.
expect(family.transitions.filter((x) => x.actorSession === "system:queue-fallback").map((x) => x.transitionId)).toEqual(EXPECTED.fallback!.daemon);
expect(family.transitions.filter((x) => !x.actorSession.endsWith("@system") && !x.actorSession.startsWith("system:"))).toHaveLength(19);
});
it("family fallback: the queue fallback routing no longer lifts the captured family to the threshold", async () => {
const t = await setup(load("family-fallback"));
expect(t.observation()).toEqual([]);
expect(t.candidates()).toEqual([]);
});
it("family fallback: only seat-decided transitions are counted", async () => {
const t = await setup(load("family-fallback"), 1);
const [o, ...rest] = t.observation();
expect(rest).toEqual([]);
expectSeatDecided(o, EXPECTED.fallback!);
});
it("family kernel: a family only daemon@kernel and the wake ladder touched is never a candidate", async () => {
const t = await setup(load("family-kernel"), 1);
expect(t.observation()).toEqual([]);
expect(t.candidates()).toEqual([]);
});
it("family mixed: daemon@kernel creation and fallback routing do not count, the seat's closure does", async () => {
const t = await setup(load("family-mixed"), 1);
const [o, ...rest] = t.observation();
expect(rest).toEqual([]);
expect(o?.ceremony?.transitionIds).toEqual(EXPECTED.mixed!.seat);
expect(o?.coordinationTransitions).toBe(1);
});
it("family compaction: the compaction restore notice does not count", async () => {
const t = await setup(load("family-compaction"), 1);
const [o, ...rest] = t.observation();
expect(rest).toEqual([]);
expectSeatDecided(o, EXPECTED.compaction!);
});
for (const name of ["human", "advisor"]) {
it(`family ${name}: a kernel seat other than daemon@kernel still counts`, async () => {
const t = await setup(load(`family-${name}`), 1);
const [o, ...rest] = t.observation();
expect(rest).toEqual([]);
expect(o?.ceremony?.transitionIds).toEqual(EXPECTED[name]!.seat);
expect(o?.coordinationTransitions).toBe(EXPECTED[name]!.seat.length);
});
}
it("every system:* actor and only the exact daemon@kernel are excluded; seats that merely look alike still count", async () => {
const neutral = "notice delivered to the owner";
// Synthetic (not in the capture), marked: system:* names the base writes or tests with (system:watchdog is the actor
// of health-correction.test.ts), a system:* name nothing writes yet (the rule is the prefix, not a list), daemon@kernel
// with a neutral note, and seats whose names only resemble them.
const { family, added } = extendMixed("look-alikes", [
{ actorSession: "system:watchdog", transitionNote: neutral, identityProvenance: null },
{ actorSession: "system:health", transitionNote: "created", identityProvenance: null },
{ actorSession: "system:compaction", transitionNote: neutral, identityProvenance: null },
{ actorSession: "system:queue-fallback", transitionNote: neutral, identityProvenance: null },
{ actorSession: "system:future-subsystem", transitionNote: neutral, identityProvenance: null },
{ actorSession: "daemon@kernel", transitionNote: neutral, identityProvenance: null },
{ actorSession: "system-admin@team", transitionNote: "owner decision recorded", identityProvenance: "transport:v1" },
{ actorSession: "ops-1@system-tools", transitionNote: "routed to the next reviewer", identityProvenance: "transport:v1" },
{ actorSession: "daemon@team", transitionNote: "owner decision recorded", identityProvenance: "transport:v1" },
{ actorSession: "daemon-x@team", transitionNote: null, identityProvenance: "transport:v1" },
{ actorSession: "operator@kernel", transitionNote: "operator acknowledged the escalation", identityProvenance: "transport:v1" },
{ actorSession: "build-dev-1@kernel-tools", transitionNote: null, identityProvenance: "transport:v1" },
], 177400);
const t = await setup(family, 1);
const [o, ...rest] = t.observation();
expect(rest).toEqual([]);
const expected = [...EXPECTED.mixed!.seat, ...added.slice(6).map((x) => x.transitionId)];
expect(o?.ceremony?.transitionIds).toEqual(expected);
expect(o?.coordinationTransitions).toBe(expected.length);
});
it("the health-correction shape: system:watchdog bookkeeping does not lift an owner family over the threshold", async () => {
// Synthetic, marked: the family health-correction.test.ts built before this change (one `created`, then 23 in-progress
// transitions alternating owner@rig and system:watchdog). Only the owner's 13 count.
const now = "2026-11-11T02:30:00.000Z";
const transitions: FixtureTransition[] = [{ transitionId: 1, qitemId: "plan", ts: now, state: "pending", actorSession: "owner@rig", transitionNote: "created", identityProvenance: null }];
for (let i = 0; i < 23; i++) transitions.push({ transitionId: i + 2, qitemId: "plan", ts: now, state: "in-progress", actorSession: i % 2 ? "system:watchdog" : "owner@rig", transitionNote: null, identityProvenance: null });
const family: Family = { family: "plan", lineageId: "plan", now, observationWindowSeconds: 3600,
members: [{ qitemId: "plan", handedOffFrom: null, sourceSession: "owner@rig", destinationSession: "owner@rig", tags: ["mission:family-plan"] }], transitions };
const below = await setup(family);
expect(below.observation()).toEqual([]);
expect(below.candidates()).toEqual([]);
const counted = await setup(family, 1);
const owner = transitions.filter((x) => x.actorSession === "owner@rig").map((x) => x.transitionId);
expect(owner).toHaveLength(13);
expect(counted.observation()[0]?.ceremony?.transitionIds).toEqual(owner);
});
it("a real coordination chain at the threshold is still a candidate", async () => {
const { family } = regressionChain(19);
const t = await setup(family);
expect(t.observation()).toHaveLength(1);
expect(t.candidates()).toHaveLength(1);
});
it("a real coordination chain counts every seat-decided transition, including notes that only quote daemon actors", async () => {
const { family, expected } = regressionChain(19);
expect(expected).toHaveLength(20);
const t = await setup(family);
const [o] = t.observation();
expect(o?.ceremony?.transitionIds).toEqual(expected);
expect(o?.coordinationTransitions).toBe(20);
expect(t.candidates()[0]?.ceremony?.transitionIds).toEqual(expected);
});
it("one seat decision below the threshold is not a candidate", async () => {
const { family, expected } = regressionChain(18);
expect(expected).toHaveLength(19);
const t = await setup(family);
expect(t.observation()).toEqual([]);
expect(t.candidates()).toEqual([]);
});
});
@@ -34,7 +34,9 @@ async function setup() {
const sends: unknown[] = []; queue.attachTransport({ send: async (...args) => { sends.push(args); return { ok: true, verified: true }; } });
await queue.create({ qitemId: "plan", sourceSession: "owner@rig", destinationSession: "owner@rig", body: "Independent planning", tags: ["project:demo"], nudge: false });
const now = new Date().toISOString(); db.prepare("UPDATE queue_transitions SET ts = ?").run(now);
for (let i = 0; i < 23; i++) db.prepare("INSERT INTO queue_transitions(qitem_id,ts,state,actor_session) VALUES('plan',?,'in-progress',?)").run(now, i % 2 ? "system:watchdog" : "owner@rig");
// Half the retained transitions are daemon bookkeeping (system:*), which the ceremony count leaves out:
// `created` plus 20 owner transitions keep this family above the default threshold of 20.
for (let i = 0; i < 40; i++) db.prepare("INSERT INTO queue_transitions(qitem_id,ts,state,actor_session) VALUES('plan',?,'in-progress',?)").run(now, i % 2 ? "system:watchdog" : "owner@rig");
const modes = new RigModeStore(db), posture = new OperatingPostureService(db, modes, () => home);
const policy = new HealthPolicyStore(home, () => ({ warningPercent: 95, criticalPercent: 99 }));
const checkpoints = new HealthCheckpointSource(home, queue, policy, () => now, home);
+2 -1
View File
@@ -1,3 +1,4 @@
import { runBoundActivityGrantsSchema } from "../../src/db/migrations/165_run_bound_activity_grants.js";
import { piAccountSwitchesSchema } from "../../src/db/migrations/151_pi_account_switches.js";
import { eventsRetentionMarksSchema } from "../../src/db/migrations/153_events_retention_marks.js";
import { queueErasureSchema } from "../../src/db/migrations/155_queue_erasure.js";
@@ -171,7 +172,7 @@ import fs from "node:fs";
/** Seam B R6: the canonical full-fixture migration list, exported so file-backed
* DB-reopen tests migrate IDENTICALLY to createFullTestDb. */
export const migrationsForFullTestDb = [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema, nodePermissionPolicySchema, rigPermissionPolicySchema, nodePolicyProvenanceSchema, rigPolicyProvenanceSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema, rigPolicySchema, rigArchiveSchema, resumeProvenanceSchema, resumeVerificationSchema, seatIdentityVerdictsSchema, selfHostIdentitySchema, occupantTenuresSchema, daemonLifecycleSchema, watchdogJobsSchema, occupantGenerationStampsSchema, projectionManifestSchema, watchdogTargetGenerationSchema, appliedLaunchObservationsSchema, appliedLaunchObservationInvalidationsSchema, threadSeatMapSchema, queueTransitionWakesSchema, nodeSessionSourceSchema, scopedOperatingPostureSchema, humanNotificationIntentSchema, reviewReadIndexesSchema, inventoryEventIndexesSchema, rigClaudeManagedBlockFileSchema, nodePermissionSelectionsSchema, humanReplyToSchema, humanQuestionsSchema, nodeEffortSchema, rigNonInterruptiveSchema, rigInstallRootSchema, threadPartMapSchema, queueTransitionsQitemIdOrderSchema, queueItemsRecoveryTagsSchema, resumeRotationSchema, sessionStopIntentSchema, nodeEffortChoiceSchema, modePostureCoexistSchema, seatEnvSchema, appliedLaunchDaemonOptOutSchema, sessionCommunityHarnessSchema, threadSeatMapChannelKeySchema, queueItemWakeInputBoxSchema, providerAccountReadingsSchema, humanQuietWindowsSchema, seatIdentityPanePidSchema, seatIdentityProcessEvidenceSchema, rigStartupLayersSchema, humanOperatingModesSchema, queueDelegatedDecisionSchema, nodeAdvisorSchema, operationCorrelationSchema, terminalPolicySchema, seatRuntimeSwitchesSchema, claudeTokenReadingsSchema, nodeLaunchRecipeSchema, nodeClaudeTokenRefSchema, nodeClaudePermissionsSchema, nodeCodexPermissionsSchema, escalationTargetRefsSchema, sessionAgentRunnerSchema, nodeAccountHomeSchema, nodeClaudeMcpSchema, activityGrantsSchema, environmentBindingsSchema, nodePermissionsToSchema, nodeStartupGuidanceFileSchema, managedBlockWritesSchema, streamIntakeCursorSchema, nodeLaunchWrapperSchema, piAccountSwitchesSchema, eventsRetentionMarksSchema, queueErasureSchema, queueErasureBatchesSchema, queueInboxCheckinsSchema, queueItemsHandedOffFromIndexSchema, networkBindingsSchema, networkReceiptsSchema, sessionAddressReadIndexesSchema, networkInstallationsSchema, queueItemsReplyToIndexSchema];
export const migrationsForFullTestDb = [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema, nodePermissionPolicySchema, rigPermissionPolicySchema, nodePolicyProvenanceSchema, rigPolicyProvenanceSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema, rigPolicySchema, rigArchiveSchema, resumeProvenanceSchema, resumeVerificationSchema, seatIdentityVerdictsSchema, selfHostIdentitySchema, occupantTenuresSchema, daemonLifecycleSchema, watchdogJobsSchema, occupantGenerationStampsSchema, projectionManifestSchema, watchdogTargetGenerationSchema, appliedLaunchObservationsSchema, appliedLaunchObservationInvalidationsSchema, threadSeatMapSchema, queueTransitionWakesSchema, nodeSessionSourceSchema, scopedOperatingPostureSchema, humanNotificationIntentSchema, reviewReadIndexesSchema, inventoryEventIndexesSchema, rigClaudeManagedBlockFileSchema, nodePermissionSelectionsSchema, humanReplyToSchema, humanQuestionsSchema, nodeEffortSchema, rigNonInterruptiveSchema, rigInstallRootSchema, threadPartMapSchema, queueTransitionsQitemIdOrderSchema, queueItemsRecoveryTagsSchema, resumeRotationSchema, sessionStopIntentSchema, nodeEffortChoiceSchema, modePostureCoexistSchema, seatEnvSchema, appliedLaunchDaemonOptOutSchema, sessionCommunityHarnessSchema, threadSeatMapChannelKeySchema, queueItemWakeInputBoxSchema, providerAccountReadingsSchema, humanQuietWindowsSchema, seatIdentityPanePidSchema, seatIdentityProcessEvidenceSchema, rigStartupLayersSchema, humanOperatingModesSchema, queueDelegatedDecisionSchema, nodeAdvisorSchema, operationCorrelationSchema, terminalPolicySchema, seatRuntimeSwitchesSchema, claudeTokenReadingsSchema, nodeLaunchRecipeSchema, nodeClaudeTokenRefSchema, nodeClaudePermissionsSchema, nodeCodexPermissionsSchema, escalationTargetRefsSchema, sessionAgentRunnerSchema, nodeAccountHomeSchema, nodeClaudeMcpSchema, activityGrantsSchema, environmentBindingsSchema, nodePermissionsToSchema, nodeStartupGuidanceFileSchema, managedBlockWritesSchema, streamIntakeCursorSchema, nodeLaunchWrapperSchema, piAccountSwitchesSchema, eventsRetentionMarksSchema, queueErasureSchema, queueErasureBatchesSchema, queueInboxCheckinsSchema, queueItemsHandedOffFromIndexSchema, networkBindingsSchema, networkReceiptsSchema, sessionAddressReadIndexesSchema, networkInstallationsSchema, queueItemsReplyToIndexSchema, runBoundActivityGrantsSchema];
/**
* P24 — the DECLARED exclusions for {@link migrationsForFullTestDb}. That list is deliberately a
@@ -122,7 +122,7 @@ describe("a hosted member with a webhook trigger is rung when work lands for it"
trigger = null;
const noTrigger = repo.classifyDestinationOf("grok@lab");
expect(noTrigger).toEqual({ class: "unroutable", teaching: "unroutable: 'grok@lab' is a hosted member with no wake trigger, "
+ "so nothing can push work to it. It reads the queue when its owner next starts a session." });
+ "so nothing can push work to it. It picks up its work when its person asks it to, or on a schedule they set." });
// An address no roster lists keeps the general teaching.
expect(repo.classifyDestinationOf("nobody@lab")).toMatchObject({ class: "unroutable",
teaching: expect.stringContaining("Fix the address, or register the human with `rig gateway human add`.") });
@@ -307,18 +307,26 @@ describe("FR-6 park-on-human (leg-1) — enforcement + persistence + attention",
expect(ev!.toState).toBe("blocked");
});
// OPR.0.5.1 slice-51-06 D2 first replaced a silent drop with a whole-transition refusal. Narrowed: a
// non-park state change now applies and stores them, so nothing is dropped and finished work isn't held.
it("non-park updates STORE summary/evidence_ref inputs and apply the state change", async () => {
// OPR.0.5.1 slice-51-06 D2: non-park summary/evidence used to be SILENTLY dropped (a data-loss
// trap); it is now a HARD REJECT before any mutation. The surface still stays tight (nothing
// persists on a non-park item) — now enforced loudly instead of silently.
it("NEGATIVE: non-park updates REJECT summary/evidence_ref inputs (surface stays tight, loudly)", async () => {
const item = await inProgressItem();
const res = repo.update({
qitemId: item.qitemId,
actorSession: "driver@rig",
state: "in-progress",
summary: "now persists",
evidenceRef: "now-persists.md",
});
expect(res).toMatchObject({ state: "in-progress", summary: "now persists", evidenceRef: "now-persists.md" });
expect(repo.getById(item.qitemId)).toMatchObject({ summary: "now persists", evidenceRef: "now-persists.md" });
let err: unknown;
try {
repo.update({
qitemId: item.qitemId,
actorSession: "driver@rig",
state: "in-progress",
summary: "should NOT persist",
evidenceRef: "should-not-persist.md",
});
} catch (e) { err = e; }
expect(err).toBeInstanceOf(QueueRepositoryError);
expect((err as QueueRepositoryError).code).toBe("summary_evidence_not_persistable");
expect((err as QueueRepositoryError).meta?.invalidFields).toEqual(["summary", "evidenceRef"]);
const read = repo.getById(item.qitemId)!;
expect(read.summary).toBeNull(); // nothing persisted (reject was before any write)
expect(read.evidenceRef).toBeNull();
});
});
@@ -391,15 +391,15 @@ describe("native resume probe", () => {
).toBe("claude --permission-mode acceptEdits --resume 'abc-123' --name 'dev-impl@demo-rig'");
});
it("builds a Codex no-profile resume with the explicit -s workspace-write floor flag", () => {
it("builds a Codex no-profile resume with the explicit -s workspace-write floor flag and operational launch arg", () => {
expect(buildNativeResumeCommand("codex", "019d-token")).toBe(
"codex -s workspace-write resume '019d-token'"
"codex -s workspace-write '-c' 'check_for_update_on_startup=false' resume '019d-token'"
);
});
it("builds a Codex profile resume with -p flag", () => {
it("builds a Codex profile resume with -p flag and operational launch arg", () => {
expect(buildNativeResumeCommand("codex", "019d-token", null, "my-profile")).toBe(
"codex -p 'my-profile' resume '019d-token'"
"codex -p 'my-profile' '-c' 'check_for_update_on_startup=false' resume '019d-token'"
);
});
+12 -5
View File
@@ -231,9 +231,14 @@ describe("Node Inventory Projection", () => {
expect(entry?.resumeCommand).toBeNull();
expect(entry?.recoveryGuidance).toBeNull(); // relocated to detail
const detail = getNodeDetail(db, "rig-2", "dev.qa");
expect(detail?.recoveryGuidance?.summary).toContain("codex -s workspace-write resume --last");
expect(detail?.recoveryGuidance?.summary).toContain("explicit -s workspace-write floor flag");
expect(detail?.recoveryGuidance?.commands).toEqual(["cd /workspace/app", "codex -s workspace-write resume --last"]);
expect(detail?.recoveryGuidance?.summary).toContain(
"codex -s workspace-write '-c' 'check_for_update_on_startup=false' resume --last"
);
expect(detail?.recoveryGuidance?.commands).toEqual([
"cd /workspace/app",
"codex -s workspace-write '-c' 'check_for_update_on_startup=false' resume --last",
]);
expect(detail?.recoveryGuidance?.notes).toContain("flags from current settings");
expect(detail?.recoveryGuidance?.notes).toContain("Use workspace and recent prompt text to identify the right conversation.");
expect(detail?.recoveryGuidance?.notes).toContain("If the identity anchor was captured, the picker may include: dev-qa@test-rig");
});
@@ -252,11 +257,13 @@ describe("Node Inventory Projection", () => {
const entry = getNodeInventory(db, "rig-2").find((e) => e.logicalId === "platform.mac-admin");
expect(entry?.codexConfigProfile).toBe("sysadmin");
expect(entry?.resumeCommand).toBe("codex -p 'sysadmin' resume 'sess-456'");
expect(entry?.resumeCommand).toBe("codex -p 'sysadmin' '-c' 'check_for_update_on_startup=false' resume 'sess-456'");
expect(entry?.resumeCommandLabel).toBe("flags from current settings");
expect(entry?.recoveryGuidance).toBeNull(); // relocated to detail
const detail = getNodeDetail(db, "rig-2", "platform.mac-admin");
expect(detail?.codexConfigProfile).toBe("sysadmin");
expect(detail?.recoveryGuidance?.commands).toContain("codex -p 'sysadmin' resume 'sess-456'");
expect(detail?.recoveryGuidance?.commands).toContain("codex -p 'sysadmin' '-c' 'check_for_update_on_startup=false' resume 'sess-456'");
expect(detail?.recoveryGuidance?.notes).toContain("flags from current settings");
expect(detail?.recoveryGuidance?.notes).toContain("Preserve Codex config profile: sysadmin");
});
@@ -0,0 +1,89 @@
import { afterEach, describe, expect, it } from "vitest";
import { performance } from "node:perf_hooks";
import { createFullTestDb } from "./helpers/test-app.js";
import { seedHostShaped } from "./helpers/seed-host-shaped.js";
import { buildStallApp } from "./fixtures/ps-stall-http-child.js";
import { configureSyncSiteRecorder, runSyncSiteWhenSlow } from "../src/domain/sync-site-wrap.js";
// The node inventory's synchronous steps named in the slow-operation log, so a stall during a nodes read is attributed
// to the step that ran. A fake recorder takes every call; the real recorder's slow threshold is pinned in
// slow-op-recorder.test.ts.
describe("the node inventory's synchronous steps in the slow-operation log", () => {
const slow: Array<{ site: string; durationMs: number; outcome: string; at: number }> = [];
/** Takes every record; the first one then holds the event loop for `holdMs`, after its own time was measured. */
const record = (holdMs = 0) => configureSyncSiteRecorder({
recordSlowSync(site: string, durationMs: number, outcome: "ok" | "failed"): void {
slow.push({ site, durationMs, outcome, at: performance.now() });
if (slow.length === 1 && holdMs > 0) { const until = performance.now() + holdMs; while (performance.now() < until) { /* hold */ } }
},
});
afterEach(() => { configureSyncSiteRecorder(undefined); slow.length = 0; });
function fixture() {
const db = createFullTestDb();
const shape = seedHostShaped(db);
return { db, app: buildStallApp(db, true), rig: shape.activeRigIds[0]! };
}
const ids = (nodes: Array<{ logicalId: string }>) => nodes.map((n) => n.logicalId);
it("names each default step once, in order, and serves the same nodes", async () => {
const { db, app, rig } = fixture();
try {
const bare = await app.request(`/api/rigs/${rig}/nodes`);
expect(bare.status).toBe(200);
const bareNodes = await bare.json() as Array<{ logicalId: string }>;
expect(bareNodes.length).toBeGreaterThan(0);
record();
const res = await app.request(`/api/rigs/${rig}/nodes`);
expect(res.status).toBe(200);
expect(ids(await res.json() as Array<{ logicalId: string }>)).toEqual(ids(bareNodes));
expect(slow.map(({ site, outcome }) => ({ site, outcome }))).toEqual([
{ site: "nodes.inventory.build", outcome: "ok" },
{ site: "nodes.inventory.activity_sync", outcome: "ok" },
{ site: "nodes.inventory.terminal_work", outcome: "ok" },
]);
} finally {
db.close();
}
});
it("leaves a full read's activity step unnamed: it awaits pane captures outside the turns", async () => {
const { db, app, rig } = fixture();
try {
record();
const res = await app.request(`/api/rigs/${rig}/nodes?full=true`);
expect(res.status).toBe(200);
expect(slow.map((s) => s.site)).toEqual(["nodes.inventory.build", "nodes.inventory.terminal_work"]);
} finally {
db.close();
}
});
it("measures a step from its turn, so the wait behind another request's step isn't counted", async () => {
const HOLD_MS = 400;
const { db, app, rig } = fixture();
try {
record(HOLD_MS);
const [a, b] = await Promise.all([app.request(`/api/rigs/${rig}/nodes`), app.request(`/api/rigs/${rig}/nodes`)]);
expect([a.status, b.status]).toEqual([200, 200]);
const builds = slow.filter((s) => s.site === "nodes.inventory.build");
expect(builds).toHaveLength(2);
// The second build began only after the first step's hold ended, and its own time doesn't include that wait.
expect(builds[1]!.at - builds[1]!.durationMs).toBeGreaterThanOrEqual(builds[0]!.at + HOLD_MS - 1);
for (const s of slow) expect(s.durationMs).toBeLessThan(HOLD_MS);
expect(slow).toHaveLength(6);
} finally {
db.close();
}
});
it("counts a returned promise's synchronous start, never its settling, and keeps its rejection", async () => {
record();
const failure = new Error("activity failed");
const settled = runSyncSiteWhenSlow("test.async_start", () => new Promise((_, reject) => setTimeout(() => reject(failure), 300)));
expect(slow.map(({ site, outcome }) => ({ site, outcome }))).toEqual([{ site: "test.async_start", outcome: "ok" }]);
expect(slow[0]!.durationMs).toBeLessThan(300);
await expect(settled).rejects.toBe(failure);
});
});
@@ -28,7 +28,8 @@ function seat(runtime: string, pane: Pane) {
registry.updateBinding(node.id, { tmuxSession: name, tmuxPane: paneId });
const tmux = {
listSessions: vi.fn(async () => [{ name }] as never),
listPanes: vi.fn(async () => [{ id: paneId }] as never),
// A live session still has another pane after the registered pane is gone.
listPanes: vi.fn(async () => [{ id: pane.pid === null ? "%8" : paneId }] as never),
getPanePid: vi.fn(async () => pane.pid),
getPaneCommand: vi.fn(async () => pane.command),
};
@@ -3,6 +3,7 @@ import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { spawn } from "node:child_process";
import { createServer } from "node:http";
import { describe, expect, it } from "vitest";
import { piSeatPaths, PI_RUNNER_READY_MARKER, PI_RUNNER_ERROR_MARKER } from "../src/adapters/pi-runner-protocol.js";
@@ -78,10 +79,24 @@ if(cmd.type==='prompt') { console.error('Unknown option: --name'); console.error
type: "api_key", key: credential === "dynamic" ? "!private-command" : "private-value",
} }));
}
const reports: Array<{ body: Record<string, unknown>; token: string | undefined }> = [];
const endpoint = createServer((req, res) => {
let body = ""; req.on("data", chunk => { body += chunk; });
req.on("end", () => {
if (req.method === "POST") reports.push({ body: JSON.parse(body), token: req.headers.authorization });
res.statusCode = credential === "present" ? 409 : 200;
res.end(JSON.stringify({ tokenPersisted: credential !== "present" }));
});
});
await new Promise<void>(resolve => endpoint.listen(0, "127.0.0.1", resolve));
const port = (endpoint.address() as { port: number }).port;
const token = credential === "absent" ? "fixture-install-token" : "oag_fixture-bound";
const envRunId = credential === "dynamic" ? "owned-attempt" : "inherited-predecessor";
const child = spawn(process.execPath, ["--import", "tsx", fileURLToPath(new URL("../src/adapters/pi-runner.ts", import.meta.url)),
"--session-name", "fixture", "--state-root", stateRoot, "--cwd", temp,
"--launch-id", "owned-attempt", "--no-approve", "--model", "openrouter/fixture"], {
env: { PATH: bin + path.delimiter + path.dirname(process.execPath), HOME: temp, OPENRIG_HOME: path.join(temp, "home") },
env: { PATH: bin + path.delimiter + path.dirname(process.execPath), HOME: temp, OPENRIG_HOME: path.join(temp, "home"),
OPENRIG_URL: `http://127.0.0.1:${port}`, OPENRIG_ACTIVITY_HOOK_TOKEN: token, OPENRIG_RUN_ID: envRunId },
stdio: ["pipe", "pipe", "pipe"],
});
let output = "";
@@ -104,6 +119,15 @@ if(cmd.type==='prompt') { console.error('Unknown option: --name'); console.error
expect(state.launchId).toBe("owned-attempt");
if (success) {
expect(state.sessionFile).toBe(sessionFile);
const reportDeadline = Date.now() + 5000;
while (!reports.some(r => r.body.eventFamily === "session_identity") && Date.now() < reportDeadline) {
await new Promise(resolve => setTimeout(resolve, 20));
}
const identity = reports.find(r => r.body.eventFamily === "session_identity");
expect(identity).toBeDefined();
if (credential === "absent") expect(identity!.body).not.toHaveProperty("runId");
else expect(identity!.body.runId).toBe(envRunId); // a refused bound claim is never rewritten or downgraded
expect(reports.every(r => r.token === `Bearer ${token}`)).toBe(true);
child.stdin.write("emit a runtime warning\n");
const warningDeadline = Date.now() + 5000;
while (!output.includes("AFTER_RUNNING_WARNING") && Date.now() < warningDeadline) {
@@ -122,6 +146,7 @@ if(cmd.type==='prompt') { console.error('Unknown option: --name'); console.error
}
child.kill("SIGTERM");
await new Promise<void>(resolve => { if (child.exitCode !== null || child.signalCode !== null) resolve(); else child.once("exit", () => resolve()); });
await new Promise<void>(resolve => endpoint.close(() => resolve()));
fs.rmSync(temp, { recursive: true, force: true });
}
}, 20000);
+21
View File
@@ -1372,3 +1372,24 @@ describe("cursor refresh via get_entries (QA RED, qitem-20260707020922)", () =>
expect(f.sidecars.at(-1)!.lastEntryId).toBe("e5");
});
});
describe("managed run identity retries", () => {
it("retries a pending run identity until Core confirms persistence, then stops", async () => {
const f = fakeIo();
let persisted = false;
f.io.postActivity = vi.fn(async (payload) => { f.activity.push(payload); return persisted ? { tokenPersisted: true } : null; });
const core = new RunnerCore(f.io, { sessionName: SESSION, launchId: "actual-run", runId: "actual-run" });
core.start();
core.handlePiLine(JSON.stringify({ type: "response", id: "pi-runner-get-state", data: { sessionFile: SESSION_FILE, sessionId: "native" } }));
await new Promise(resolve => setImmediate(resolve));
const count = () => f.activity.filter(p => p.eventFamily === "session_identity").length;
const initial = count();
persisted = true;
core.retrySessionIdentity();
await new Promise(resolve => setImmediate(resolve));
expect(count()).toBe(initial + 1);
expect(f.activity.filter(p => p.eventFamily === "session_identity").at(-1)).toMatchObject({ runId: "actual-run" });
core.retrySessionIdentity();
expect(count()).toBe(initial + 1);
});
});
@@ -1135,3 +1135,175 @@ it("stages a Pi account successor with canonical identity and a separate state d
expect(await adapter.checkReady(binding)).toMatchObject({ ready: true });
expect(fs.files[predecessorPath]).toBe(readyState(SESSION_FILE, "old-launch"));
});
describe("run grant at the actual Pi dispatch", () => {
it.each([true, false])("uses the actual attempt, activates at dispatch, and finalizes readiness=%s", async ready => {
const fs = memFs();
const statePath = piSeatPaths(STATE_ROOT, SESSION).runnerStatePath;
const calls: string[] = [];
const finish = vi.fn(async (ok: boolean) => { calls.push(`finish:${ok}`); });
const runnerGrants = vi.fn(async (_binding, runtime, runId, runner) => {
expect([runtime, runId, runner]).toEqual(["pi", "attempt-bound", 0]);
calls.push("issued");
return { grantId: "test-grant", generation: "generation", wrap: (command: string) => `private-wrapper ${command}`, dispatched: () => { calls.push("dispatched"); }, finish };
});
const sendShellCommand = vi.fn(async (_target: string, command: string) => {
expect(command).toContain("private-wrapper");
expect(command).toContain("--launch-id 'attempt-bound'");
if (ready) fs.files[statePath] = readyState(SESSION_FILE, "attempt-bound");
return { ok: true as const };
});
const adapter = new PiRuntimeAdapter({ tmux: mockTmux({ sendShellCommand }), fsOps: fs, stateRoot: STATE_ROOT,
runnerEntryPath: RUNNER, runnerGrants, newLaunchId: () => "attempt-bound", sleep: async () => {} });
const result = await adapter.launchHarness({ tmuxSession: SESSION, cwd: "/work" } as never, { name: SESSION });
expect(result.ok).toBe(ready);
expect(calls).toEqual(["issued", "dispatched", `finish:${ready}`]);
expect(runnerGrants).toHaveBeenCalledOnce();
});
});
it("keeps the run grant and its private transfer keys out of native Pi/tools", () => {
const child = buildPiChildEnv({ PATH: "/bin", OPENRIG_ACTIVITY_HOOK_TOKEN: "oag_private",
OPENRIG_RUN_ID: "actual-run", OPENRIG_RUN_GRANT_TEST: "oag_private", OPENRIG_NODE_ID: "seat" },
{ agentDir: "/agent", sessionsDir: "/sessions" });
expect(child.OPENRIG_NODE_ID).toBe("seat");
expect(JSON.stringify(child)).not.toContain("oag_private");
expect(child).not.toHaveProperty("OPENRIG_RUN_ID");
});
describe("direct Pi resume run authority", () => {
it("resumes unbound when Core cannot resolve the restored grant binding", async () => {
const fs = memFs({ [SESSION_FILE]: "history" });
const sendShellCommand = vi.fn(async () => {
fs.files[piSeatPaths(STATE_ROOT, SESSION).runnerStatePath] = readyState(SESSION_FILE, "restored");
return { ok: true as const };
});
const adapter = new PiResumeAdapter(mockTmux({ sendShellCommand }), fs,
{ stateRoot: STATE_ROOT, runnerEntryPath: RUNNER }, { runnerGrant: async () => { throw new Error("no Core binding"); },
newLaunchId: () => "restored", sleep: async () => {}, maxWaitMs: 1 });
expect(await adapter.resume(SESSION, "pi_session_file", SESSION_FILE, "/work")).toMatchObject({ ok: true });
expect(sendShellCommand).toHaveBeenCalledOnce();
});
it.each([true, false])("uses its actual new attempt and finishes exact authority, ready=%s", async ready => {
const fs = memFs({ [SESSION_FILE]: "history" });
const statePath = piSeatPaths(STATE_ROOT, SESSION).runnerStatePath;
const dispatched = vi.fn(), finish = vi.fn(async (_ok: boolean) => {});
const runnerGrant = vi.fn(async (_session: string, _runId: string, _cwd: string) => ({ grantId: "resume-grant", generation: "generation",
wrap: (command: string) => `private-resume ${command}`, dispatched, finish }));
const sendShellCommand = vi.fn(async () => {
if (ready) fs.files[statePath] = readyState(SESSION_FILE, "resume-attempt");
return { ok: true as const };
});
const adapter = new PiResumeAdapter(mockTmux({ sendShellCommand }), fs, { stateRoot: STATE_ROOT, runnerEntryPath: RUNNER },
{ runnerGrant, newLaunchId: () => "resume-attempt", maxWaitMs: 1, pollMs: 1, sleep: async () => {} });
const result = await adapter.resume(SESSION, "pi_session_file", SESSION_FILE, "/work");
expect(result.ok).toBe(ready);
expect(runnerGrant).toHaveBeenCalledWith(SESSION, "resume-attempt", "/work");
expect(sendShellCommand.mock.calls[0]?.[1]).toContain("private-resume");
expect(dispatched).toHaveBeenCalledOnce();
expect(finish).toHaveBeenCalledWith(ready);
});
});
describe("optional run authority preserves existing native dispatch", () => {
it.each(["launch", "resume"] as const)("%s continues unbound when grant setup fails", async owner => {
const fs = memFs({ [SESSION_FILE]: "history" });
const sendShellCommand = vi.fn(async (_target: string, command: string) => {
fs.files[piSeatPaths(STATE_ROOT, SESSION).runnerStatePath] = readyState(SESSION_FILE, "maintainer-attempt");
return { ok: true as const };
});
const issue = async () => { throw new Error("missing anchor, PID, generation or restore binding"); };
const tmux = mockTmux({ sendShellCommand });
const result = owner === "launch"
? await new PiRuntimeAdapter({ tmux, fsOps: fs, stateRoot: STATE_ROOT, runnerEntryPath: RUNNER, runnerGrants: issue, newLaunchId: () => "maintainer-attempt", sleep: async () => {} }).launchHarness({ tmuxSession: SESSION, cwd: "/work" } as never, { name: SESSION })
: await new PiResumeAdapter(tmux, fs, { stateRoot: STATE_ROOT, runnerEntryPath: RUNNER }, { runnerGrant: issue, newLaunchId: () => "maintainer-attempt", sleep: async () => {}, maxWaitMs: 1, pollMs: 1 }).resume(SESSION, "pi_session_file", SESSION_FILE, "/work");
expect(result.ok).toBe(true);
expect(sendShellCommand).toHaveBeenCalledOnce();
expect(sendShellCommand.mock.calls[0]![1]).not.toContain("OPENRIG_RUN_ID=");
expect(result).not.toHaveProperty("activityGrantId");
});
it.each([["launch", false, true], ["resume", false, true], ["launch", true, true], ["resume", true, true], ["launch", false, false], ["resume", false, false]] as const)("%s preserves dispatch truth when optional activation fails, cleanup failure=%s, ready=%s", async (owner, cleanupFails, ready) => {
const fs = memFs({ [SESSION_FILE]: "history" });
const finish = vi.fn(async () => { if (cleanupFails) throw new Error("cleanup unreadable"); });
const issue = async () => ({ grantId: "failed-activation", generation: "g", wrap: (s: string) => s,
dispatched: () => { throw new Error("activation unreadable after send"); }, finish });
const sendShellCommand = vi.fn(async (_target: string, command: string) => {
if (ready) fs.files[piSeatPaths(STATE_ROOT, SESSION).runnerStatePath] = readyState(SESSION_FILE, "maintainer-attempt");
return { ok: true as const };
});
const tmux = mockTmux({ sendShellCommand });
const result = owner === "launch"
? await new PiRuntimeAdapter({ tmux, fsOps: fs, stateRoot: STATE_ROOT, runnerEntryPath: RUNNER, runnerGrants: issue, newLaunchId: () => "maintainer-attempt", sleep: async () => {} }).launchHarness({ tmuxSession: SESSION, cwd: "/work" } as never, { name: SESSION })
: await new PiResumeAdapter(tmux, fs, { stateRoot: STATE_ROOT, runnerEntryPath: RUNNER }, { runnerGrant: issue, newLaunchId: () => "maintainer-attempt", sleep: async () => {}, maxWaitMs: 1, pollMs: 1 }).resume(SESSION, "pi_session_file", SESSION_FILE, "/work");
expect(result.ok).toBe(ready);
expect(sendShellCommand).toHaveBeenCalledOnce();
expect(finish).toHaveBeenCalledWith(false);
expect(result).not.toHaveProperty("activityGrantId");
});
it.each(["launch", "resume"] as const)("%s retains a no-effect long-command retry without run authority", async owner => {
const fs = memFs({ [SESSION_FILE]: "history" });
const finish = vi.fn(async () => {}), dispatched = vi.fn();
const issue = async () => ({ grantId: "too-long", generation: "g", wrap: (s: string) => `private-wrapper ${s}`, dispatched, finish });
const sendShellCommand = vi.fn(async (_target: string, command: string) => {
if (command.startsWith("private-wrapper")) return { ok: false as const, code: "launch_path_too_long", message: "not dispatched" };
fs.files[piSeatPaths(STATE_ROOT, SESSION).runnerStatePath] = readyState(SESSION_FILE, "maintainer-attempt");
return { ok: true as const };
});
const tmux = mockTmux({ sendShellCommand });
const result = owner === "launch"
? await new PiRuntimeAdapter({ tmux, fsOps: fs, stateRoot: STATE_ROOT, runnerEntryPath: RUNNER, runnerGrants: issue, newLaunchId: () => "maintainer-attempt", sleep: async () => {} }).launchHarness({ tmuxSession: SESSION, cwd: "/work" } as never, { name: SESSION })
: await new PiResumeAdapter(tmux, fs, { stateRoot: STATE_ROOT, runnerEntryPath: RUNNER }, { runnerGrant: issue, newLaunchId: () => "maintainer-attempt", sleep: async () => {}, maxWaitMs: 1, pollMs: 1 }).resume(SESSION, "pi_session_file", SESSION_FILE, "/work");
expect(result.ok).toBe(true);
expect(sendShellCommand).toHaveBeenCalledTimes(2);
expect(finish).toHaveBeenCalledWith(false);
expect(dispatched).not.toHaveBeenCalled();
});
it.each(["launch", "resume"] as const)("%s preserves the selected account command when only the optional wrapper is too long", async owner => {
const fs = memFs({ [SESSION_FILE]: "history" }), finish = vi.fn(async () => {});
const issue = async () => ({ grantId: "own", generation: "g", wrap: (s: string) => `private-wrapper ${s}`, dispatched: vi.fn(), finish });
const sendShellCommand = vi.fn(async (_session: string, command: string) => {
if (command.startsWith("private-wrapper")) return { ok: false as const, code: "launch_path_too_long", message: "not dispatched" };
expect(command).toContain("ACCOUNT_GUARD=selected");
fs.files[piSeatPaths(STATE_ROOT, SESSION).runnerStatePath] = readyState(SESSION_FILE, "selected");
return { ok: true as const };
});
const seatLaunchEnvironment = { accountHome: () => "/selected", command: async (_session: string, command: string) => `/usr/bin/env ACCOUNT_GUARD=selected ${command}` } as never;
const tmux = mockTmux({ sendShellCommand });
const result = owner === "launch"
? await new PiRuntimeAdapter({ tmux, fsOps: fs, stateRoot: STATE_ROOT, runnerEntryPath: RUNNER, runnerGrants: issue,
seatLaunchEnvironment, newLaunchId: () => "selected", sleep: async () => {} }).launchHarness({ tmuxSession: SESSION, cwd: "/work", nodeId: "node-pi" } as never, { name: SESSION })
: await new PiResumeAdapter(tmux, fs, { stateRoot: STATE_ROOT, runnerEntryPath: RUNNER }, { runnerGrant: issue, seatLaunchEnvironment,
accountHomeForSession: () => "/selected", newLaunchId: () => "selected", sleep: async () => {}, maxWaitMs: 1 }).resume(SESSION, "pi_session_file", SESSION_FILE, "/work");
expect(result.ok).toBe(true);
expect(sendShellCommand).toHaveBeenCalledTimes(2);
expect(sendShellCommand.mock.calls[0]![1]).toBe(`private-wrapper ${sendShellCommand.mock.calls[1]![1]}`);
expect(finish).toHaveBeenCalledWith(false);
});
it("rechecks the selected launch before a known-no-effect long-command retry", async () => {
const finish = vi.fn(async () => {}), callback = vi.fn().mockReturnValueOnce(true).mockReturnValueOnce(false);
const sendShellCommand = vi.fn(async () => ({ ok: false as const, code: "launch_path_too_long", message: "not dispatched" }));
const adapter = new PiRuntimeAdapter({ tmux: mockTmux({ sendShellCommand }), fsOps: memFs(), stateRoot: STATE_ROOT,
runnerEntryPath: RUNNER, runnerGrants: async () => ({ grantId: "own", generation: "g", wrap: s => `wrapped ${s}`, dispatched: () => {}, finish }) });
expect(await adapter.launchHarness({ tmuxSession: SESSION, cwd: "/work" } as never,
{ name: SESSION, beforeNativeDispatch: callback })).toMatchObject({ ok: false, error: expect.stringContaining("selection changed") });
expect(sendShellCommand).toHaveBeenCalledOnce();
expect(callback).toHaveBeenCalledTimes(2);
expect(finish).toHaveBeenCalledWith(false);
});
it("still honors invalidated selection when optional grant setup fails", async () => {
const sendShellCommand = vi.fn(async () => ({ ok: true as const }));
const adapter = new PiRuntimeAdapter({ tmux: mockTmux({ sendShellCommand }), fsOps: memFs(), stateRoot: STATE_ROOT,
runnerEntryPath: RUNNER, runnerGrants: async () => { throw new Error("optional grant unavailable"); } });
expect(await adapter.launchHarness({ tmuxSession: SESSION, cwd: "/work" } as never,
{ name: SESSION, beforeNativeDispatch: () => false })).toMatchObject({ ok: false, error: expect.stringContaining("selection changed") });
expect(sendShellCommand).not.toHaveBeenCalled();
});
});
@@ -127,13 +127,19 @@ setInterval(() => {}, 1000);
expect(fs.existsSync(path.join(temp, "rc-ready"))).toBe(true);
}
const seatLaunchEnvironment = routing ? new SeatLaunchEnvironment(tmux, sessionEnv, cwd, cli) : undefined;
const optionalGrant = routing && longTmp ? {
grantId: "fixture-grant", generation: launchMode === "resume" ? "existing-generation" : "reserved-generation",
wrap: (command: string) => `/bin/sh -c ${quote(`exec ${command}`)}`,
dispatched: vi.fn(), finish: vi.fn(async () => {}),
} : undefined;
if (launchMode === "resume") {
const adapter = new PiResumeAdapter(tmux, fsOps, { stateRoot, runnerEntryPath: runner },
{ seatLaunchEnvironment, maxWaitMs: 4000, pollMs: 25, newLaunchId: () => "attempt" });
{ seatLaunchEnvironment, runnerGrant: optionalGrant ? async () => optionalGrant : undefined, maxWaitMs: 4000, pollMs: 25, newLaunchId: () => "attempt" });
const result = await adapter.resume(session, "pi_session_file", parent, cwd, model);
expect(result, JSON.stringify(result)).toMatchObject({ ok: true });
} else {
const adapter = new PiRuntimeAdapter({ tmux, fsOps, stateRoot, runnerEntryPath: runner, seatLaunchEnvironment,
runnerGrants: optionalGrant ? async () => optionalGrant : undefined,
// The adapter polls 60 times for readiness; 100 ms each gives a loaded runner ~6 s, like the resume path's 4 s.
sleep: () => new Promise(resolve => setTimeout(resolve, 100)), newLaunchId: () => "attempt" });
const result = await adapter.launchHarness({ tmuxSession: session, cwd, model, nodeId: "fixture-node", launchGeneration: "reserved-generation" } as never,
@@ -142,10 +148,13 @@ setInterval(() => {}, 1000);
}
const received = JSON.parse(fs.readFileSync(path.join(stateRoot, session, "received.json"), "utf8"));
if (routing && longTmp) {
expect(send).toHaveBeenCalledTimes(2);
expect(send).toHaveBeenCalledTimes(3); // optional wrapper, original environment command, then main's bare fallback
expect(Buffer.byteLength(send.mock.calls[0]![1])).toBeGreaterThanOrEqual(1024);
expect(await send.mock.results[0]!.value).toMatchObject({ ok: false, code: "launch_path_too_long" });
expect(send.mock.calls[1]![1]).toBe(expected);
expect(await send.mock.results[1]!.value).toMatchObject({ ok: false, code: "launch_path_too_long" });
expect(send.mock.calls[2]![1]).toBe(expected);
expect(optionalGrant!.finish).toHaveBeenCalledWith(false);
expect(optionalGrant!.dispatched).not.toHaveBeenCalled();
// The supported bare fallback launches; routing correction is best-effort here.
expect(received.route).toMatchObject({ body: { instance: "wrong-cli" }, home: "/wrong", node: "wrong" });
} else if (routing) {
@@ -138,6 +138,31 @@ describe("PodRigInstantiator", () => {
expect(done.edges.outgoing).toMatchObject([{ kind: "delegates_to", to: { logicalId: "dev.qa" } }]);
});
it.each(["pi", "claude-code", "codex", "stub"])("limits Core-observed pane forwarding to grant-owning runtimes: %s", async runtime => {
const adapter = mockAdapter(runtime);
const { db, inst, tmux, sessionRegistry } = setup(undefined, { [runtime]: adapter });
try {
vi.mocked(tmux.listPanes).mockResolvedValue([{ id: "%47" } as Awaited<ReturnType<TmuxAdapter["listPanes"]>>[number]]);
const spec = makeRigSpec(); spec.pods[0]!.members[0]!.runtime = runtime;
const result = await inst.instantiate(RigSpecCodec.serialize(spec), RIG_ROOT);
expect(result.ok).toBe(true);
const binding = vi.mocked(adapter.launchHarness!).mock.calls[0]?.[0];
expect(binding).toBeDefined();
expect(binding!.tmuxPane).toBe(runtime === "pi" ? "%47" : null);
expect(sessionRegistry.getBindingForNode(binding!.nodeId)?.tmuxPane).toBe("%47");
} finally { db.close(); }
});
it("does not invent an unresolved launch pane for a grant-owning runtime", async () => {
const adapter = mockAdapter("pi");
const { db, inst } = setup(undefined, { pi: adapter });
try {
const spec = makeRigSpec(); spec.pods[0]!.members[0]!.runtime = "pi";
expect((await inst.instantiate(RigSpecCodec.serialize(spec), RIG_ROOT)).ok).toBe(true);
expect(vi.mocked(adapter.launchHarness!).mock.calls[0]?.[0].tmuxPane).toBeNull();
} finally { db.close(); }
});
// T1: valid rig instantiates pods + nodes + edges
it("instantiates pods + nodes correctly", async () => {
const { db, inst } = setup();
@@ -1,7 +1,7 @@
// 51-06 D2, narrowed — summary/evidence_ref given with a state change are STORED and the transition applies:
// optional metadata never holds finished work at pending. A blank summary is the one value not stored (it would
// erase the row's subject); the route reports it, so a caller never believes it was saved.
// Migration-faithful: the canonical queue set PLUS 044 (summary) + 048 (evidence_ref).
// 51-06 atom D2 — non-persistable queue update metadata (summary/evidence_ref) must HARD-REJECT
// BEFORE any mutation, per the Guard-bound design (no post-commit marker; reject-before-write).
// Migration-faithful: the canonical queue set PLUS 044 (summary) + 048 (evidence_ref) so a drop is
// provably the non-park rule, NOT a missing column (the test-suite parity gap this atom flags).
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { Hono } from "hono";
import type Database from "better-sqlite3";
@@ -17,9 +17,11 @@ import { queueTargetRepoSchema } from "../src/db/migrations/039_queue_target_rep
import { queueItemSummarySchema } from "../src/db/migrations/044_queue_item_summary.js";
import { queueItemEvidenceRefSchema } from "../src/db/migrations/048_queue_item_evidence_ref.js";
import { EventBus } from "../src/domain/event-bus.js";
import { QueueRepository } from "../src/domain/queue-repository.js";
import { QueueRepository, QueueRepositoryError } from "../src/domain/queue-repository.js";
import { queueRoutes } from "../src/routes/queue.js";
const REJECT_CODE = "summary_evidence_not_persistable";
function buildApp(bus: EventBus, queueRepo: QueueRepository): Hono {
const app = new Hono();
app.use("*", async (c, next) => { c.set("eventBus" as never, bus); c.set("queueRepo" as never, queueRepo); await next(); });
@@ -27,7 +29,7 @@ function buildApp(bus: EventBus, queueRepo: QueueRepository): Hono {
return app;
}
describe("51-06 D2 narrowed — queue update metadata on a state change", () => {
describe("51-06 D2 — non-park queue update metadata reject", () => {
let db: Database.Database;
let bus: EventBus;
let repo: QueueRepository;
@@ -44,57 +46,52 @@ describe("51-06 D2 narrowed — queue update metadata on a state change", () =>
const mkItem = async () => (await repo.create({ sourceSession: "orch@rig", destinationSession: "dev-x@rig", body: "d2 item" })).qitemId;
const txnCount = (id: string) => (db.prepare("SELECT count(*) c FROM queue_transitions WHERE qitem_id = ?").get(id) as { c: number }).c;
const setSummary = (id: string, summary: string) => db.prepare("UPDATE queue_items SET summary = ? WHERE qitem_id = ?").run(summary, id);
const setEvidence = (id: string, ref: string) => db.prepare("UPDATE queue_items SET evidence_ref = ? WHERE qitem_id = ?").run(ref, id);
const post = (path: string, body: Record<string, unknown>, session: string) => app.request(path, {
method: "POST", headers: { "content-type": "application/json", "X-OpenRig-Session": session }, body: JSON.stringify(body),
});
const eventCount = () => (db.prepare("SELECT count(*) c FROM events").get() as { c: number }).c;
it.each([
["summary", { summary: "KEEP" }, { summary: "KEEP", evidenceRef: null }],
["evidence_ref", { evidenceRef: "/proof/x.md" }, { summary: null, evidenceRef: "/proof/x.md" }],
["both", { summary: "S", evidenceRef: "/e" }, { summary: "S", evidenceRef: "/e" }],
])("a state change carrying %s applies and stores it, with one transition", async (_label, input, stored) => {
async function expectRejectZeroMutation(input: Record<string, unknown>, invalidFields: string[]) {
const id = await mkItem();
const res = repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "in-progress", ...input });
expect(res).toMatchObject({ state: "in-progress", ...stored });
expect(repo.getByIdOrThrow(id)).toMatchObject({ state: "in-progress", ...stored });
expect(txnCount(id)).toBe(2);
const beforeState = repo.getByIdOrThrow(id).state;
const beforeTxns = txnCount(id);
const beforeEvents = eventCount();
let err: unknown;
try { repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "in-progress", ...input }); }
catch (e) { err = e; }
expect(err).toBeInstanceOf(QueueRepositoryError);
expect((err as QueueRepositoryError).code).toBe(REJECT_CODE);
expect((err as QueueRepositoryError).meta?.invalidFields).toEqual(invalidFields);
// zero mutation: no UPDATE (state), no log (transition), no event
expect(repo.getByIdOrThrow(id).state).toBe(beforeState);
expect(txnCount(id)).toBe(beforeTxns);
expect(eventCount()).toBe(beforeEvents);
}
it("rejects --summary on a non-park transition, before any mutation", async () => {
await expectRejectZeroMutation({ summary: "DROPME" }, ["summary"]);
});
it("rejects --evidence-ref on a non-park transition, before any mutation", async () => {
await expectRejectZeroMutation({ evidenceRef: "/proof/x.md" }, ["evidenceRef"]);
});
it("rejects BOTH, naming both invalidFields, before any mutation", async () => {
await expectRejectZeroMutation({ summary: "S", evidenceRef: "/e" }, ["summary", "evidenceRef"]);
});
it("treats empty-string as PRESENT (null=absent, ''=present) -> rejects", async () => {
await expectRejectZeroMutation({ summary: "" }, ["summary"]);
});
it("a finished seat's park on another row stores its summary and evidence, and the event carries the summary", async () => {
it("also rejects on a terminal non-park transition (in-progress -> done + closure)", async () => {
const id = await mkItem();
const blocker = await mkItem();
repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "in-progress" });
const events: Array<Record<string, unknown>> = [];
bus.subscribe((e) => events.push(e as unknown as Record<string, unknown>));
const res = repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "blocked", blockedOn: blocker, summary: "Relayed; waiting on the reply", evidenceRef: "/proof/relay.md" });
expect(res).toMatchObject({ state: "blocked", blockedOn: blocker, summary: "Relayed; waiting on the reply", evidenceRef: "/proof/relay.md" });
expect(events.find((e) => e.type === "queue.updated")).toMatchObject({ toState: "blocked", summary: "Relayed; waiting on the reply" });
repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "in-progress" }); // claim (metadata-free, ok)
let err: unknown;
try { repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "done", closureReason: "no-follow-on", summary: "S" }); }
catch (e) { err = e; }
expect((err as QueueRepositoryError).code).toBe(REJECT_CODE);
expect(repo.getByIdOrThrow(id).state).toBe("in-progress"); // unchanged by the rejected close
});
it.each(["done", "canceled"] as const)("a %s close stores summary and evidence_ref on the closed row", async (state) => {
const id = await mkItem();
repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "in-progress" });
const close = state === "done" ? { closureReason: "no-follow-on" } : {};
const res = repo.update({ qitemId: id, actorSession: "dev-x@rig", state, ...close, summary: "Finished", evidenceRef: "/proof/result.md" });
expect(res).toMatchObject({ state, summary: "Finished", evidenceRef: "/proof/result.md" });
const shown = await app.request(`/api/queue/${id}`);
expect(shown.status).toBe(200);
expect(await shown.json()).toMatchObject({ state, summary: "Finished", evidenceRef: "/proof/result.md" });
});
it.each(["", " "])("a blank summary (%j) isn't stored: the state still changes and the row keeps its subject", async (blank) => {
const id = await mkItem();
setSummary(id, "Original subject");
const res = repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "in-progress", summary: blank, evidenceRef: "/e" });
expect(res).toMatchObject({ state: "in-progress", summary: "Original subject", evidenceRef: "/e" });
});
it("metadata-free update is UNCHANGED (null = absent)", async () => {
it("metadata-free non-park update is UNCHANGED (null=absent, no reject)", async () => {
const id = await mkItem();
const res = repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "in-progress", summary: null, evidenceRef: null });
expect(res).toMatchObject({ state: "in-progress", summary: null, evidenceRef: null });
expect(res.state).toBe("in-progress");
});
it("CONTROL: human-seat park still PERSISTS summary + evidence byte-for-byte", async () => {
@@ -117,101 +114,43 @@ describe("51-06 D2 narrowed — queue update metadata on a state change", () =>
expect(people.update({ qitemId: bare, actorSession: "orch@rig", state: "blocked", blockedOn })).toMatchObject({ state: "blocked", summary: null });
});
it.each(["", " "])("a blank summary and evidence ref (%j) on a park on a registered person aren't stored: the row keeps its own", async (blank) => {
const person = { entityId: "sarah", class: "human" as const, displayName: "Sarah", address: "sarah@external", connectorBindings: [],
prefs: { deliveryClass: "A" as const } };
const people = new QueueRepository(db, bus, { validateRig: () => true, loadHumanRegistry: () => ({ ok: true, entities: [person] }) });
const id = await mkItem();
setSummary(id, "Which layout?"); setEvidence(id, "/proof/layout.md");
expect(people.update({ qitemId: id, actorSession: "orch@rig", state: "blocked", blockedOn: "sarah@external", summary: blank, evidenceRef: blank }))
.toMatchObject({ state: "blocked", blockedOn: "sarah@external", summary: "Which layout?", evidenceRef: "/proof/layout.md" });
});
it("a legacy human-seat park given a blank summary and evidence ref parks on the row's own, instead of refusing", async () => {
const id = await mkItem();
setSummary(id, "PARK-KEEP"); setEvidence(id, "/proof/park.md");
expect(repo.update({ qitemId: id, actorSession: "orch@rig", state: "blocked", blockedOn: "human@kernel", summary: " ", evidenceRef: " " }))
.toMatchObject({ state: "blocked", summary: "PARK-KEEP", evidenceRef: "/proof/park.md" });
});
it("ROUTE: a blank summary on a park on a person -> 200, parked, and the advisory names the summary as not stored", async () => {
const id = await mkItem();
setSummary(id, "Original subject"); setEvidence(id, "/proof/kept.md");
const res = await post(`/api/queue/${id}/update`, { actorSession: "orch@rig", state: "blocked", blockedOn: "human@kernel", summary: " " }, "orch@rig");
expect(res.status).toBe(200);
const body = (await res.json()) as { state: string; summary: string | null; advisories?: unknown };
expect(body).toMatchObject({ state: "blocked", summary: "Original subject" });
expect(body.advisories).toEqual([expect.objectContaining({ code: "summary_not_stored", field: "summary" })]);
});
it("a park on an unregistered name on another daemon's domain stores its summary too", async () => {
it("an unregistered name on another daemon's domain still refuses summary on a park", async () => {
const nobody = new QueueRepository(db, bus, { validateRig: () => true, loadHumanRegistry: () => ({ ok: true, entities: [] }) });
const id = await mkItem();
expect(nobody.update({ qitemId: id, actorSession: "orch@rig", state: "blocked", blockedOn: "sarah@host", summary: "S" }))
.toMatchObject({ state: "blocked", blockedOn: "sarah@host", summary: "S" });
let err: unknown;
try { nobody.update({ qitemId: id, actorSession: "orch@rig", state: "blocked", blockedOn: "sarah@host", summary: "S" }); }
catch (e) { err = e; }
expect((err as QueueRepositoryError).code).toBe(REJECT_CODE);
});
it("ROUTE: POST /:id/update with a state change + summary -> 200, stored, no advisory", async () => {
const created = await post("/api/queue/create", { sourceSession: "orch@rig", destinationSession: "dev-x@rig", body: "r" }, "orch@rig");
it("a done close stores evidence_ref, and queue show returns it on the closed row", async () => {
const id = await mkItem();
repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "in-progress" });
const res = repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "done", closureReason: "no-follow-on", evidenceRef: "/proof/result.md" });
expect(res).toMatchObject({ state: "done", evidenceRef: "/proof/result.md" });
const shown = await app.request(`/api/queue/${id}`);
expect(shown.status).toBe(200);
expect(((await shown.json()) as { state: string; evidenceRef?: string | null })).toMatchObject({ state: "done", evidenceRef: "/proof/result.md" });
});
it("a canceled close still refuses evidence_ref, before any mutation", async () => {
const id = await mkItem();
let err: unknown;
try { repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "canceled", evidenceRef: "/proof/x.md" }); }
catch (e) { err = e; }
expect((err as QueueRepositoryError).code).toBe(REJECT_CODE);
expect((err as QueueRepositoryError).meta?.invalidFields).toEqual(["evidenceRef"]);
expect(repo.getByIdOrThrow(id)).toMatchObject({ state: "pending", evidenceRef: null });
});
it("ROUTE: POST /:id/update non-park + summary -> HTTP 400 naming invalidFields", async () => {
// P21 I3: create/update derive the sender from the transport header; header==body claim ⇒ tolerated.
const created = await app.request("/api/queue/create", { method: "POST", headers: { "content-type": "application/json", "X-OpenRig-Session": "orch@rig" }, body: JSON.stringify({ sourceSession: "orch@rig", destinationSession: "dev-x@rig", body: "r" }) });
const id = ((await created.json()) as { qitemId: string }).qitemId;
const res = await post(`/api/queue/${id}/update`, { actorSession: "dev-x@rig", state: "in-progress", summary: "KEEP" }, "dev-x@rig");
expect(res.status).toBe(200);
const body = (await res.json()) as { state: string; summary: string | null; advisories?: unknown };
expect(body).toMatchObject({ state: "in-progress", summary: "KEEP" });
expect(body.advisories).toBeUndefined();
});
it("ROUTE: a blank summary with a state change -> 200, state applied, and an advisory names the summary as not stored", async () => {
const created = await post("/api/queue/create", { sourceSession: "orch@rig", destinationSession: "dev-x@rig", body: "r", summary: "Original subject" }, "orch@rig");
const id = ((await created.json()) as { qitemId: string }).qitemId;
const res = await post(`/api/queue/${id}/update`, { actorSession: "dev-x@rig", state: "in-progress", summary: "" }, "dev-x@rig");
expect(res.status).toBe(200);
const body = (await res.json()) as { state: string; summary: string | null; advisories?: Array<Record<string, unknown>> };
expect(body).toMatchObject({ state: "in-progress", summary: "Original subject" });
expect(body.advisories).toEqual([{
code: "summary_not_stored", qitemId: id, field: "summary",
message: `The summary was blank, so it wasn't stored: ${id} keeps its summary "Original subject". The rest of the update was applied.`,
}]);
});
it("a stored evidence pointer survives a blank one on a state change, and a done close keeps it too", async () => {
const id = (await repo.create({ sourceSession: "orch@rig", destinationSession: "dev-x@rig", body: "d2 item", evidenceRef: "/proof/kept.md" })).qitemId;
expect(repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "in-progress", evidenceRef: "" })).toMatchObject({ state: "in-progress", evidenceRef: "/proof/kept.md" });
expect(repo.update({ qitemId: id, actorSession: "dev-x@rig", state: "done", closureReason: "no-follow-on", evidenceRef: " " }))
.toMatchObject({ state: "done", evidenceRef: "/proof/kept.md" });
});
it("ROUTE: a blank evidence ref with a state change -> 200, the pointer kept, and an advisory names it as not stored", async () => {
const created = await post("/api/queue/create", { sourceSession: "orch@rig", destinationSession: "dev-x@rig", body: "r", evidenceRef: "/proof/kept.md" }, "orch@rig");
const id = ((await created.json()) as { qitemId: string }).qitemId;
const res = await post(`/api/queue/${id}/update`, { actorSession: "dev-x@rig", state: "in-progress", evidenceRef: " " }, "dev-x@rig");
expect(res.status).toBe(200);
const body = (await res.json()) as { state: string; evidenceRef: string | null; advisories?: Array<Record<string, unknown>> };
expect(body).toMatchObject({ state: "in-progress", evidenceRef: "/proof/kept.md" });
expect(body.advisories).toEqual([{
code: "evidence_ref_not_stored", qitemId: id, field: "evidenceRef",
message: `The evidence ref was blank, so it wasn't stored: ${id} keeps its evidence ref "/proof/kept.md". The rest of the update was applied.`,
}]);
});
it("ROUTE: when the repository can't write either field, the state still changes, the caller is told, and the event agrees with the stored row", async () => {
const created = await post("/api/queue/create", { sourceSession: "orch@rig", destinationSession: "dev-x@rig", body: "r", summary: "Original subject" }, "orch@rig");
const id = ((await created.json()) as { qitemId: string }).qitemId;
// A column probe that read false at construction (its PRAGMA read failed), while the columns exist.
Object.assign(repo as unknown as Record<string, boolean>, { hasSummaryColumn: false, hasEvidenceRefColumn: false });
const events: Array<Record<string, unknown>> = [];
bus.subscribe((e) => events.push(e as unknown as Record<string, unknown>));
const res = await post(`/api/queue/${id}/update`, { actorSession: "dev-x@rig", state: "in-progress", summary: "New subject", evidenceRef: "/proof/new.md" }, "dev-x@rig");
expect(res.status).toBe(200);
const body = (await res.json()) as { state: string; summary: string | null; evidenceRef: string | null; advisories?: Array<Record<string, unknown>> };
expect(body).toMatchObject({ state: "in-progress", summary: "Original subject", evidenceRef: null });
expect(body.advisories).toEqual([
{ code: "summary_not_stored", qitemId: id, field: "summary",
message: `The summary wasn't stored: ${id} keeps its summary "Original subject". The rest of the update was applied.` },
{ code: "evidence_ref_not_stored", qitemId: id, field: "evidenceRef",
message: `The evidence ref wasn't stored: ${id} keeps no evidence ref. The rest of the update was applied.` },
]);
expect(events.find((e) => e.type === "queue.updated")).toMatchObject({ toState: "in-progress", summary: "Original subject" });
expect(repo.getByIdOrThrow(id)).toMatchObject({ state: "in-progress", summary: "Original subject", evidenceRef: null });
const res = await app.request(`/api/queue/${id}/update`, { method: "POST", headers: { "content-type": "application/json", "X-OpenRig-Session": "dev-x@rig" }, body: JSON.stringify({ actorSession: "dev-x@rig", state: "in-progress", summary: "DROPME" }) });
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string; invalidFields?: string[] };
expect(body.error).toBe(REJECT_CODE);
expect(body.invalidFields).toEqual(["summary"]);
});
});
@@ -5932,6 +5932,30 @@ describe("RestoreOrchestrator", () => {
});
});
it.each([
"Run-bound reporting unavailable; using existing unbound install-token reporting.",
"Native command dispatched; run-bound reporting could not be confirmed. Cleanup of its grant was attempted; nothing was replayed.",
])("surfaces direct Pi restore limitation warnings without repeating resume: %s", async warning => {
const rig = rigRepo.createRig("pi-warning");
const node = rigRepo.addNode(rig.id, "worker", { runtime: "pi", cwd: "/repo/pi" });
const session = sessionRegistry.registerSession(node.id, "r99-pi-warning");
db.prepare("UPDATE sessions SET resume_type = ?, resume_token = ?, restore_policy = ? WHERE id = ?")
.run("pi_session_file", "/tmp/pi-warning.jsonl", "resume_if_possible", session.id);
sessionRegistry.updateStatus(session.id, "running");
const snap = snapshotCapture.captureSnapshot(rig.id, "manual");
sessionRegistry.updateStatus(session.id, "exited");
const resume = vi.fn(async (_session: string) => ({ ok: true as const, warnings: [warning] }));
const orch = createOrchestrator({ pi: {
canResume: vi.fn((type: string | null) => type === "pi_session_file"), resume,
} as unknown as PiResumeAdapter });
const result = await orch.restore(snap.id);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(resume).toHaveBeenCalledTimes(1);
expect(result.result.warnings).toContain(`${resume.mock.calls[0]![0]}: ${warning}`);
});
describe("launch_wrapper native resume, fail-closed, and snapshot retention", () => {
it("passes current launch_wrapper as the new LAST arg after effort on Pi native resume and after beforeNativeDispatch (with undefined padding) on Codex native resume", async () => {
const rig = rigRepo.createRig("r99");
@@ -5967,7 +5991,17 @@ describe("RestoreOrchestrator", () => {
const piResume = {
canResume: vi.fn((type: string | null, token: string | null) => type === "pi_session_file" && !!token),
resume: vi.fn(async () => ({ ok: true as const })),
resume: vi.fn(async (session: string) => {
// The production Pi grant resolver must see the newly committed restore row, not the exited snapshot row.
const owners = db.prepare(`SELECT b.node_id AS nodeId, s.id AS sessionId, s.status FROM bindings b JOIN nodes n ON n.id=b.node_id
JOIN sessions s ON s.node_id=n.id AND s.id=(SELECT id FROM sessions WHERE node_id=n.id ORDER BY id DESC LIMIT 1)
WHERE b.tmux_session=? AND s.session_name=? AND n.runtime='pi' AND s.status NOT IN ('superseded','detached','exited')`)
.all(session, session) as Array<{ nodeId: string; sessionId: string; status: string }>;
expect(owners).toHaveLength(1);
expect(owners[0]).toMatchObject({ nodeId: piNode.id, status: "running" });
expect(owners[0]!.sessionId).not.toBe(piSess.id);
return { ok: true as const };
}),
} as unknown as PiResumeAdapter;
const codexResume = mockCodexResume({ ok: true as const });
@@ -1,3 +1,4 @@
import { SeatIdentityStore } from "../src/domain/seat-identity-store.js";
// A runner seat's answered permission path, through the real activity routes, queue, Mission Control answer route,
// activity store and seat grant. The chosen person's one offered option reaches only that live request; nothing else
// ever reads as an answer. No model, Slack or terminal is involved.
@@ -465,6 +466,30 @@ describe("a runner seat's answered permission path", () => {
}
});
it.each([false, true])("refuses unverified grant reads and writes without changing the live ask, then recovers (guest=%s)", async guest => {
const f = fixture({ guest });
f.db.prepare("INSERT INTO bindings (id,node_id,tmux_session,tmux_pane) VALUES ('b',?,?,'%1')").run(f.runner.id, SEAT);
const session = f.db.prepare("SELECT id FROM sessions WHERE node_id = ? ORDER BY id DESC LIMIT 1").get(f.runner.id) as { id: string };
const grant = f.grants.issue({ nodeId: f.runner.id, generation: f.generation(), issuedBy: "launch", run: {
runId: "permission-run", sessionId: session.id, binding: { runtime: "synth", runner: 1, pane: "%1", panePid: 101 } } });
expect(f.grants.activate(grant.grantId)).toBe(true);
const askId = String((await f.submit({}, grant.token)).body.askId);
const before = f.queueRepo.getById(askId);
const events = f.db.prepare("SELECT COUNT(*) AS n FROM events").get();
const read = vi.spyOn(SeatIdentityStore.prototype, "getForNode").mockImplementation(() => { throw new Error("observation unavailable"); });
try {
for (const reply of [await f.read(askId, 0, grant.token), await f.withdraw(askId, "turn_ended", grant.token),
await f.submit({ requestId: "unverified-new" }, grant.token)]) {
expect(reply).toMatchObject({ status: 409, body: { code: "activity_grant_unverified", retryable: true } });
}
expect(f.queueRepo.getById(askId)).toEqual(before);
expect(f.db.prepare("SELECT COUNT(*) AS n FROM events").get()).toEqual(events);
expect(f.grants.get(grant.grantId)?.revokedAt).toBeNull();
} finally { read.mockRestore(); }
expect(await f.read(askId, 0, grant.token)).toMatchObject({ status: 200, body: { state: "pending" } });
expect(f.grants.standing(grant.grantId)).toBe("current");
});
it("serves the real guest listener's app for a seat grant, and never for the install token", async () => {
const f = fixture({ realGuest: true });
const grant = f.grants.issue({ nodeId: f.runner.id, generation: f.generation(), issuedBy: "test" });
@@ -1,3 +1,4 @@
import { ActivityGrantStore } from "../src/domain/activity-grants.js";
import { SeatLifecycleService } from "../src/domain/seat-lifecycle-service.js";
import { CodexRuntimeAdapter, type CodexAdapterFsOps } from "../src/adapters/codex-runtime-adapter.js";
import { mockShellCommand } from "./helpers/shell-command-mock.js";
@@ -995,6 +996,32 @@ describe("SeatHandoverService", () => {
expect(generation).not.toBe(retiringGen); // the SWAP identity is the successor tenure, not the retiree
});
it.each(["notification", "transaction"])("keeps the committed successor grant and cleans only a failed transaction: %s", async failure => {
const { node } = seedSeat({ runtime: "codex" });
const grants = new ActivityGrantStore(db);
let grantId: string | undefined;
launchHarness.mockImplementation(async (binding) => {
const grant = grants.issue({ nodeId: node.id, generation: binding.launchGeneration!, issuedBy: "launch", run: {
runId: "successor", binding: { runtime: "codex", runner: 0, pane: binding.tmuxPane!, panePid: 101 } } });
grantId = grant.grantId;
return { ok: true, activityGrantId: grantId, resumeToken: "codex-launch-tok", resumeType: "codex_id" };
});
if (failure === "notification") declareOccupantSwap.mockImplementation(() => { throw new Error("oracle notification failed"); });
else vi.spyOn(eventBus, "persistWithinTransaction").mockImplementationOnce(() => { throw new Error("transaction failed"); });
const result = await service.handover({ seatRef: "dev-impl@seat-rig", reason: "notification-failure-proof", source: "fresh" });
expect(grantId).toBeDefined();
if (failure === "transaction") {
expect(result).toMatchObject({ ok: false, code: "handover_commit_failed" });
expect(grants.standing(grantId!)).toBe("revoked");
expect(declareOccupantSwap).not.toHaveBeenCalled();
return;
}
expect(result.ok).toBe(true);
expect(grants.standing(grantId!)).toBe("current");
expect(db.prepare("SELECT handover_result FROM nodes WHERE id = ?").get(node.id)).toEqual({ handover_result: "complete" });
expect(db.prepare("SELECT COUNT(*) AS n FROM events WHERE type = 'seat.handover_completed'").get()).toEqual({ n: 1 });
});
it("S19: a handover that fails before commit never declares a swap (no phantom swap events)", async () => {
seedSeat({ runtime: "codex" });
respawnPane.mockResolvedValue({ ok: false, code: "no_server", message: "no server running" });
@@ -1,3 +1,5 @@
import { ActivityGrantStore } from "../src/domain/activity-grants.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
// OPR.0.4.3.19 — SeatIdentityReconciler unit tests.
//
// The reconciler owns the liveness identity verdict (the THIRD axis, orthogonal
@@ -340,7 +342,8 @@ describe("SeatIdentityReconciler.reconcileAll", () => {
const db = createFullTestDb();
seedSeat(db, { nodeId: "n1", sessionName: "s1@rig", pane: "%1", runtime: "claude-code" });
// session listed live, but the registered pane no longer resolves.
const tmux = makeTmux({ sessions: ["s1@rig"], panePid: { "%1": null } });
const tmux = { ...makeTmux({ sessions: ["s1@rig"], panePid: { "%1": null } }),
listPanes: async () => [{ id: "%2" }] as never };
const rec = new SeatIdentityReconciler({ db, tmux, now: NOW });
await rec.reconcileAll();
@@ -471,7 +474,8 @@ describe("SeatIdentityReconciler.reconcileAll", () => {
db.prepare(
"INSERT INTO queue_items (qitem_id, ts_created, ts_updated, source_session, destination_session, state, body) VALUES (?, ?, ?, ?, ?, ?, ?)",
).run("q1", "2026-07-02T12:00:00Z", "2026-07-02T12:00:00Z", "op@rig", "s1@rig", "pending", "do work");
const tmux = makeTmux({ sessions: ["s1@rig"], panePid: { "%1": null } });
const tmux = { ...makeTmux({ sessions: ["s1@rig"], panePid: { "%1": null } }),
listPanes: async () => [{ id: "%2" }] as never };
const rec = new SeatIdentityReconciler({ db, tmux, now: NOW });
await rec.reconcileAll();
@@ -611,3 +615,120 @@ describe("SeatIdentityReconciler — bounded polling", () => {
} finally { rec.stop(); db.close(); }
});
});
describe("definitive Core exit ends exact run authority", () => {
it.each(["present", "unreadable"] as const)("does not revoke on unreadable pane PID when session census is %s, then recovers", async census => {
const db = createFullTestDb();
try {
seedSeat(db, { nodeId: "n1", sessionName: "s1@rig", runtime: "pi", pane: "%1" });
const generation = new SessionRegistry(db).mintOccupantTenure("n1", "initial").generationUuid;
const grants = new ActivityGrantStore(db);
const grant = grants.issue({ nodeId: "n1", generation, issuedBy: "launch", run: { runId: "current", sessionId: "sess-n1",
binding: { runtime: "pi", runner: 0, pane: "%1", panePid: 101 } } });
grants.activate(grant.grantId);
const state = { sessions: ["s1@rig"], panePid: { "%1": null as number | null }, paneCommand: { "%1": "node" } };
const tmux = { ...makeTmux(state), listPanes: async () => {
if (census === "unreadable") throw new Error("transport unavailable");
return [{ id: "%1" }] as never;
} };
const rec = new SeatIdentityReconciler({ db, tmux, now: NOW });
await rec.reconcileAll();
expect(new SeatIdentityStore(db).getForNode("n1")?.verdict).toBe("tmux_unavailable");
expect(grants.get(grant.grantId)?.revokedAt).toBeNull();
state.panePid["%1"] = 101;
await rec.reconcileAll();
expect(grants.standing(grant.grantId)).toBe("current");
} finally { db.close(); }
});
it.each(["no reader", "unreadable", "missing", "stale", "pending", "ready", "exited"] as const)("requires current launch evidence before shell exit revocation: %s", async launch => {
const db = createFullTestDb();
try {
seedSeat(db, { nodeId: "n1", sessionName: "s1@rig", runtime: "pi", pane: "%1" });
const generation = new SessionRegistry(db).mintOccupantTenure("n1", "initial").generationUuid;
const grants = new ActivityGrantStore(db);
const grant = grants.issue({ nodeId: "n1", generation, issuedBy: "launch", run: { runId: "current", sessionId: "sess-n1",
binding: { runtime: "pi", runner: 0, pane: "%1", panePid: 101 } } });
grants.activate(grant.grantId);
const tmux = makeTmux({ sessions: ["s1@rig"], panePid: { "%1": 101 }, paneCommand: { "%1": "sh" } });
const readPiRunnerState = () => {
if (launch === "unreadable") throw new Error("sidecar unavailable");
return launch === "missing" ? null : {
launchId: launch === "stale" ? "predecessor" : "current", ready: launch === "ready" || launch === "stale",
updatedAt: NOW().toISOString(), ...(launch === "exited" ? { exited: { code: 1, at: NOW().toISOString() } } : {}),
};
};
await new SeatIdentityReconciler({ db, tmux, now: NOW, ...(launch === "no reader" ? {} : { readPiRunnerState }) }).reconcileAll();
expect(grants.standing(grant.grantId)).toBe(launch === "ready" || launch === "exited" ? "revoked" : "current");
// Readiness limits irreversible authority cleanup; it does not turn the shell into a live agent verdict.
expect(new SeatIdentityStore(db).getForNode("n1")?.verdict).toBe("mismatch");
} finally { db.close(); }
});
it.each([false, true])("preserves a starting grant through bootstrap and revokes it only on a later established shell exit, late ready=%s", async lateReady => {
const db = createFullTestDb();
try {
seedSeat(db, { nodeId: "n1", sessionName: "s1@rig", runtime: "pi", pane: "%1" });
const generation = new SessionRegistry(db).mintOccupantTenure("n1", "initial").generationUuid;
const grants = new ActivityGrantStore(db);
const grant = grants.issue({ nodeId: "n1", generation, issuedBy: "launch", run: { runId: "current", sessionId: "sess-n1",
binding: { runtime: "pi", runner: 0, pane: "%1", panePid: 101 } } });
grants.activate(grant.grantId);
const state = { sessions: ["s1@rig"], panePid: { "%1": 101 }, paneCommand: { "%1": "sh" } };
let ready = false;
const tmux = makeTmux(state);
if (lateReady) vi.mocked(tmux.listSessions).mockImplementationOnce(async () => {
ready = true; return [{ name: "s1@rig" }] as never;
});
const rec = new SeatIdentityReconciler({ db, tmux, now: NOW,
readPiRunnerState: () => ({ launchId: "current", ready, updatedAt: NOW().toISOString() }) });
await rec.reconcileAll();
expect(grants.standing(grant.grantId)).toBe("current");
ready = true; state.paneCommand["%1"] = "node";
await rec.reconcileAll();
expect(grants.standing(grant.grantId)).toBe("current");
state.paneCommand["%1"] = "sh";
await rec.reconcileAll();
expect(grants.standing(grant.grantId)).toBe("revoked");
} finally { db.close(); }
});
it.each([true, false])("preserves authority on transient or unconfirmed observations, transient=%s", async transient => {
const db = createFullTestDb();
try {
seedSeat(db, { nodeId: "n1", sessionName: "s1@rig", runtime: "synth", pane: "%1" });
db.prepare("UPDATE sessions SET agent_app = 'synth', agent_runner = 1 WHERE node_id = 'n1'").run();
const generation = new SessionRegistry(db).mintOccupantTenure("n1", "initial").generationUuid;
const grants = new ActivityGrantStore(db);
const grant = grants.issue({ nodeId: "n1", generation, issuedBy: "launch", run: { runId: "current", sessionId: "sess-n1",
binding: { runtime: "synth", runner: 1, pane: "%1", panePid: 101 } } });
expect(grants.activate(grant.grantId)).toBe(true);
const tmux = makeTmux({ sessions: ["s1@rig"], panePid: { "%1": 101 }, paneCommand: { "%1": "node" }, throwListSessions: transient });
await new SeatIdentityReconciler({ db, tmux, now: NOW }).reconcileAll();
expect(grants.get(grant.grantId)?.revokedAt).toBeNull();
expect(new SeatIdentityStore(db).getForNode("n1")?.verdict).toBe(transient ? "tmux_unavailable" : "unconfirmed");
} finally { db.close(); }
});
it.each([false, true])("revokes the sampled run without touching a replacement during the await, replacement=%s", async replace => {
const db = createFullTestDb();
try {
seedSeat(db, { nodeId: "n1", sessionName: "s1@rig", runtime: "pi", pane: "%1" });
const generation = new SessionRegistry(db).mintOccupantTenure("n1", "initial").generationUuid;
const grants = new ActivityGrantStore(db);
const issue = (runId: string) => grants.issue({ nodeId: "n1", generation, issuedBy: "launch", run: {
runId, sessionId: "sess-n1", binding: { runtime: "pi", runner: 0, pane: "%1", panePid: 101 } } });
const first = issue("first"); grants.activate(first.grantId);
let successor: ReturnType<typeof issue> | undefined;
const tmux = makeTmux({ sessions: ["another@rig"] });
tmux.listSessions = vi.fn(async () => {
if (replace) { successor = issue("successor"); grants.activate(successor.grantId); }
return [{ name: "another@rig" }] as never;
});
await new SeatIdentityReconciler({ db, tmux, now: NOW }).reconcileAll();
expect(grants.standing(first.grantId)).toBe("revoked");
if (successor) expect(grants.standing(successor.grantId)).toBe("current");
} finally { db.close(); }
});
});
@@ -20,6 +20,7 @@ import type { ArbitratedSeatState } from "../src/domain/activity-taxonomy.js";
import type { InPlaceRestartIo, ProcessFacts } from "../src/domain/seat-restart-in-place.js";
import { accountLineNames, accountTextMark, defaultInPlaceRestartIo, linesAfterAnchor, parseEffectiveSettings, procCmdlineArgv, shellWords } from "../src/domain/seat-restart-in-place.js";
import { CodexRuntimeAdapter } from "../src/adapters/codex-runtime-adapter.js";
import { SeatIdentityStore } from "../src/domain/seat-identity-store.js";
import { CodexThreadIdResolver, type CodexThreadEvidence } from "../src/domain/codex-thread-id.js";
import { SeatLaunchEnvironment } from "../src/domain/seat-launch-environment.js";
@@ -644,6 +645,28 @@ describe("rig seat restart --keep-conversation: gates before any signal", () =>
expect(guidance).not.toMatch(/run rig down/);
});
it("#3131: names rig launch for a recoverable seat whose tmux session is gone, and nothing for one that isn't", async () => {
const w = world();
w.tmux.probeSession = async () => ({ state: "absent" as const });
const plain = await w.service.restartInPlace({ seatRef: SEAT, plan: true });
expect(plain).toMatchObject({ ok: false, code: "session_not_live" });
if (plain.ok) throw new Error("expected refusal");
expect((plain as { guidance?: string }).guidance).toBeUndefined();
// Recoverable, as OpenRig itself derives it: the session confirmed missing, and a usable snapshot holds this seat's token.
new SeatIdentityStore(w.db).upsert({ nodeId: w.node.id, verdict: "pane_missing", evidenceSource: "tmux_session", reason: "session_missing",
evidence: { registeredPane: "%1", observedPid: null, observedCommand: null, matchedLayer: null }, sessionName: SEAT,
observedAt: new Date(Date.now() + 1000).toISOString() }); // observed after the session's registration
const rigId = w.db.prepare("SELECT rig_id FROM nodes WHERE id = ?").get(w.node.id) as { rig_id: string };
w.db.prepare("INSERT INTO snapshots (id, rig_id, kind, status, data) VALUES (?, ?, ?, ?, ?)").run("snap-3131", rigId.rig_id, "manual", "complete",
JSON.stringify({ rig: { id: rigId.rig_id, name: "r", createdAt: "2026-11-13T00:00:00Z", updatedAt: "2026-11-13T00:00:00Z" }, nodes: [], edges: [],
sessions: [{ id: "sess-snap", nodeId: w.node.id, sessionName: SEAT, status: "detached", resumeType: "codex_id", resumeToken: TOKEN,
restorePolicy: "resume_if_possible", lastSeenAt: null, createdAt: "2026-11-13T00:00:00Z", origin: "launched", startupStatus: "ready",
startupCompletedAt: null }], checkpoints: {} }));
const recoverable = await w.service.restartInPlace({ seatRef: SEAT, plan: true });
expect(recoverable).toMatchObject({ ok: false, code: "session_not_live",
guidance: "The seat is recoverable from its saved conversation: rig launch 'r' 'dev.codex' (add --plan to preview)." });
});
it("names a seat handed over before 0.7.9 instead of pointing at rig unclaim", async () => {
const w = world();
w.db.prepare("UPDATE sessions SET origin = 'claimed' WHERE id = ?").run(w.session.id);
Loaded 100 of 117 files, more files were not shown because too many files have changed in this diff. Show more