Compare commits

...
Author SHA1 Message Date
Mike Schwarz 379f0a0d10 fix(seat): observe the replacement identity before finalizing an in-place restart (#1367)
The 0.6.11 cut of #1361 (main e81e2614): verifyReplacement evaluates the existing strict rebind and secondary observation at once, then resamples within the class's existing timing (31 evaluations, 30 yields of 100 ms by default). Each success needs its own two fresh native observations; only observation repeats; exhaustion stays native_unknown and success restarted_account_unverified. Refs #1196.
2026-10-17 00:04:43 -07:00
Mike Schwarz af1b61bf52 release: 0.6.11 version and release text (#1253)
Version 0.6.10 to 0.6.11 in package.json, packages/{cli,daemon,ui}/package.json and package-lock.json; the 0.6.11 CHANGELOG section, whats-new note and capability page. Refs #1252.
2026-10-13 23:21:16 -07:00
Rem Kim 1a73ec57bc fix(cli): keep the Slack wait in queue create --verify for aregistered person with a pane (#1085)
A registered person can also be a seat with its own pane. The destination resolver classes that address `pane-bound`, so `rig queue create --verify` reported only the terminal wake, even though the gateway may also post the row to that person in Slack. `GET /api/queue/:qitemId` now also returns `ownerNotificationLevel`, the level on the row's latest owner-notification transition (the one the Slack selector keys on), or null. When the terminal side applies and that level is set, or a gateway receipt already exists, `--verify` adds a `gateway` result: `posted`, a failure with its detail, or `still-pending` at the bound. The terminal side (`outcome`, `wakeResult`, `claim`) is reported as before, `humanReadership` stays unknown, and the wait keeps its 30 s default bound. "This is not a failed handoff" is no longer shown when Slack reports a failed delivery. A seat with no owner-notification level settles at once with no `gateway` field; gateway-routable destinations, registered aliases and `@external` keep the existing path; the create request, `--id` and `--no-nudge` are unchanged. With `output.compact` on, the short receipt keeps the whole `delivery` result, Slack side included. Refs #1029.

(cherry picked from commit f07ddfe381)
2026-10-13 22:35:51 -07:00
dev-driver 1711698dba fix(restore): mark a missing conversation first seen on the final read
Claude's resume check reads the pane 16 times, then once more. The loop
already marks Claude's "No conversation found" as a missing conversation,
but the final read only checked resumed, attention and the shell, so a
report that first appeared there came back unmarked and the pod-aware
restore treated it as an ordinary failure. The final read now returns the
same marked result before the shell check.

Refs #1191

Claude-Session: https://claude.ai/code/session_01WfCu2MmcrSGRNX9Rjv1h6u
(cherry picked from commit 0b9704ecff)
2026-10-13 22:35:51 -07:00
dev-driver 098f73eac3 fix(restore): keep a Claude pane that exits to its shell; roll back only a missing conversation
A pod-aware resume rolled the seat back as unresumable whenever the runtime
adapter answered retry_fresh. Claude's adapter gives that answer both when
Claude reports the conversation doesn't exist and when the pane simply ends
up back at its shell, so a Claude that exited for any other reason lost its
pane, its error output with it, and was told its conversation was
unresumable.

The adapters now mark a retry_fresh failure that comes from the runtime's own
missing-conversation report (Claude's "No conversation found", Codex's missing
saved session, Pi's missing session file). The pod-aware restore rolls back
only on that mark. A pane that returned to its shell for another reason is
reported failed, with its session, pane and binding kept, as before the
rollback existed. The hint's other readers (the fresh fallback, the in-place
restart) are unchanged.

Refs #1191

Claude-Session: https://claude.ai/code/session_01WfCu2MmcrSGRNX9Rjv1h6u
(cherry picked from commit fae3e6e307)
2026-10-13 22:35:51 -07:00
orch-lead e47c158004 fix(seat): accept the staged launch script on macOS, where /bin/sh runs as bash
Found in review, measured on macOS: /bin/sh there is a shim that runs bash (or
dash or zsh), so ps names the staged script's process bash and the new link never matched. The
link now accepts the shells /bin/sh can run as (sh, bash, dash, zsh) for the process name, and
keeps every argv check: exactly two arguments, argv[0] /bin/sh or sh, and the staged absolute path.
A macOS-shaped fixture (bash, /bin/sh /var/folders/.../T/openrig-tmux-send-...) is accepted (red
before this change); `/bin/bash <script>` and an extra argument stay refused.

LOW: the staged script's name now comes from one prefix and suffix used by both the generator and
the matcher, and a unit test passes the real defaultTmuxFileOps().tmpName() through the matcher.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit df7ee77433)
2026-10-13 22:35:51 -07:00
orch-lead b194394fa4 fix(seat): restart in place accepts a Codex that OpenRig launched through its staged script
OPR.0.7.0.5. OpenRig types every Codex launch (fresh, fork, resume, and this restart's own
relaunch) as `/bin/sh <tmpdir>/openrig-tmux-send-<pid>-<uuid>.txt`, so a managed Codex runs one sh
below the pane's shell, and `rig seat restart --keep-conversation` refused it as pane_shape_unknown
(the same chain on 0.6.10). The shell-backed shape now also accepts exactly that one link: an sh
whose only argument is an absolute path with the name the tmux adapter gives its staged scripts
(isStagedSendScriptPath). Any other process between the shell and Codex is still refused.

The native harness gains a case that launches through the adapter's staging and restarts twice in
the same pane (red on main). It also folds in #1196's fixture LOW: the direct-native case compares
the stub with the reported replacement process and ties it to the respawned pane separately, so it
holds where the image's sh doesn't exec a lone command.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit e773e5b1f3)
2026-10-13 22:35:51 -07:00
26 changed files with 1106 additions and 222 deletions

No files matched your search

+237
View File
@@ -8,6 +8,243 @@ deprecations, and behavioral changes. Breaking changes are called out explicitly
---
## [0.6.11]
0.6.11 is a fix release about recovery and wasted agent time, most of it from
the community. A send that waits for a seat to go idle no longer holds up every
other send to that seat, a parked seat isn't woken again for rows that haven't
changed, restore stops for a decision instead of starting a seat fresh when an
earlier conversation may still be resumable, a Codex seat at its usage limit
stays marked as limited while its screen redraws, and on Linux a Codex seat can
be restarted in place on the same conversation. It is built from the commit
tagged `v0.6.11`. The changes since 0.6.10:
### Before you upgrade
- Restart the daemon after upgrading the CLI (`rig daemon stop`, then `rig
daemon start`). One migration runs on the first start: `103` adds one column
to `sessions`, `intentionally_stopped`, which is 0 for every existing row
([#1192](https://github.com/mvschwarz/openrig/pull/1192)). There are no
migrations `100` or `102`. openrig-core stays at 0.1.9; two of its files
changed ([#1174](https://github.com/mvschwarz/openrig/pull/1174),
[#1066](https://github.com/mvschwarz/openrig/pull/1066)).
- Expect a restore to stop a seat at `awaiting-decision` and start nothing when
the seat wasn't running at the snapshot but an earlier session recorded a
resume token, because the token doesn't prove that conversation can still
resume. `rig up --existing` and `rig launch` no longer start it fresh, and
`rig launch` of such a seat exits 1; the message names the command that
starts it fresh on purpose
([#1141](https://github.com/mvschwarz/openrig/pull/1141)).
- Expect a restore of a rig whose seats are in pods to roll a seat back to
`awaiting-decision` as unresumable only when Claude, Codex or Pi reports its
conversation missing, starting nothing and leaving no new pane behind. A rig
where every seat ends this way reads `partially_restored` instead of
`failed`, with exit 1 either way. A Claude that exits to its shell for
another reason is reported failed with its pane kept, as in 0.6.10. Restores
of rigs without pods keep their earlier handling
([#1191](https://github.com/mvschwarz/openrig/pull/1191),
[#1218](https://github.com/mvschwarz/openrig/pull/1218)).
- Read `outcome: "claimed"` from `rig queue create --verify` for a claimed row
whose wake wasn't confirmed, instead of `indeterminate`
([#1168](https://github.com/mvschwarz/openrig/pull/1168)).
- Expect `rig send --wait-for-idle N` to wait before it takes the seat's
delivery lease, so other sends and queue wakes to that seat aren't held
behind it. N is now a deadline from the start of the wait, including any time
spent waiting for the lease, and a timeout names the wait you asked for
([#1103](https://github.com/mvschwarz/openrig/pull/1103),
[#1183](https://github.com/mvschwarz/openrig/pull/1183)).
- Expect `rig seat launch --fresh --stop` to refuse a seat with a still-live
older session before it stops the current agent. `--plan` previews a fresh
launch without changing anything and exits 0 even when it would be blocked;
`--reason` is required only to execute
([#1156](https://github.com/mvschwarz/openrig/pull/1156)).
- Expect a seat stopped with `rig seat stop` to be left out of its rig's state,
so the rig no longer reads `degraded` for it. `rig ps --nodes --json` carries
`intentionallyStopped`, and a rig whose seats were all stopped this way reads
`stopped` ([#1192](https://github.com/mvschwarz/openrig/pull/1192)).
- Read `rig queue whoami --work-candidates` as up to 50 held and 50 next rows,
with `heldTruncated` and `nextTruncated`; `heldAndNextTruncated` now means
either list was cut. With `OPENRIG_REFOCUS_WORK_PACKET` on (off by default,
as in 0.6.10), the refocus work packet lists held and next work separately,
up to 50 each ([#1066](https://github.com/mvschwarz/openrig/pull/1066)).
### Highlights
- Restart one Codex seat in place on Linux with `rig seat restart <seat>
--keep-conversation --reason "<why>"`: the same pane and conversation, on
whatever account its Codex home now holds. It doesn't switch accounts itself.
It refuses with nothing stopped unless the seat is a Codex seat OpenRig
launched, in a single pane, with a conversation it can prove and unchanged
running settings. `--plan` runs every check and stops nothing, and `--verify
--expect-account <account>` reports `moved` only when a tool turn has
completed in the same conversation since the restart and Codex's status names
that account. On macOS, a request that gets as
far as reading the running process refuses before stopping the seat
([#1196](https://github.com/mvschwarz/openrig/pull/1196),
[#1195](https://github.com/mvschwarz/openrig/pull/1195),
[#1212](https://github.com/mvschwarz/openrig/pull/1212)).
- Stop waking a parked seat whose occupant is known again for the same rows
after a wake was delivered, until it updates one of them or one changes
state. A failed, refused or unrecorded wake still repeats at the next park,
and a new occupant gets its first wake. Delivered means typed into the
seat's terminal, not read ([#1142](https://github.com/mvschwarz/openrig/pull/1142)).
- Keep a Codex seat at its usage limit `unknown` with reason `usage_limit` while
its screen redraws, instead of `running`, with `usage_limit` under REASON in
`rig ps --nodes`. It clears when a readable screen shows no banner, or when
Codex shows it's working again
([#1185](https://github.com/mvschwarz/openrig/pull/1185)).
- Report the Slack receipt beside the terminal wake from `rig queue create
--verify` for a registered person who is also a seat with a pane, in a
`gateway` object (`posted`, `transport-failed`, `never-posted` or
`still-pending`); posted isn't read. It waits for that receipt up to the
existing 30-second default, even when Slack isn't set up or the row won't be
posted, and then reports the Slack side as pending.
`GET /api/queue/:qitemId` returns `ownerNotificationLevel`
([#1085](https://github.com/mvschwarz/openrig/pull/1085)).
- Launch a forked Claude seat (`rig fork`, or `session_source: fork`) with its
own session ID, so strict restore and clear-attention can verify it as its
own conversation. Forking a Claude seat needs Claude Code 2.0.73 or later,
which OpenRig doesn't check; forks launched before this read `mismatch` until
they're relaunched ([#1144](https://github.com/mvschwarz/openrig/pull/1144)).
### Seats and restore
- Stop passing the launching Claude Code or Herdr session's identity variables,
such as `CLAUDECODE`, to seats; if removing them fails, the launch goes ahead
with a warning ([#1151](https://github.com/mvschwarz/openrig/pull/1151),
finishing [#771](https://github.com/mvschwarz/openrig/pull/771)).
- Warn on stderr when `rig up` chose `/` as the launch folder itself
([#1150](https://github.com/mvschwarz/openrig/pull/1150), from
[#226](https://github.com/mvschwarz/openrig/pull/226)).
- Expand a member `cwd` that is `~`, starts with `~/` or uses `$VAR` or
`${VAR}`, from the daemon's environment, when the path as written doesn't
exist
([#1153](https://github.com/mvschwarz/openrig/pull/1153)).
- Keep a late readiness result from overwriting a newer launch attempt
([#1188](https://github.com/mvschwarz/openrig/pull/1188)).
- Show a Codex seat's `runtimeContext.threadId` in `rig whoami --full --json`
when its stored conversation ID was verified resumable
([#911](https://github.com/mvschwarz/openrig/pull/911)).
### Sends and queue
- Confirm enveloped multi-line messages that the agent's screen indents in
`rig send --verify` and in queue wakes
([#1145](https://github.com/mvschwarz/openrig/pull/1145)).
- Read a Claude footer in auto, bypass or plan mode on the last line as idle
when no activity hook is reporting
([#1179](https://github.com/mvschwarz/openrig/pull/1179)).
- Show the provider's own reason for a failed Pi or OMP turn when it gives one
([#1181](https://github.com/mvschwarz/openrig/pull/1181)).
- Give a `handed-off` closure with no visible successor the same custody
advisory and stuck-sweep finding as a `done` closure
([#1189](https://github.com/mvschwarz/openrig/pull/1189)).
### Status and TUI
- Take a rig's running count and status in `rig ps` from the same set of
sessions as `rig ps --nodes`
([#1186](https://github.com/mvschwarz/openrig/pull/1186)).
- Say in the TUI when a queue sample hit its cap or failed to load: counts read
`N+`, a seat missing from the sample shows `?`, and Pulse no longer lists a
NEEDS YOU item a second time. The caps count the whole host
([#1175](https://github.com/mvschwarz/openrig/pull/1175)).
- Fill the TUI Topology table's Q, WORK and NOW columns again on drilled views
and on a seat's page ([#1173](https://github.com/mvschwarz/openrig/pull/1173)).
### Slack and ports
- Recover a reply sent with "Also send to channel" in the default channel after
a connection gap ([#1187](https://github.com/mvschwarz/openrig/pull/1187)).
- Refuse a `daemon.port` outside 0–65535 in `rig config set`, and fall back to
the file or default with a warning when the environment or `config.json`
holds one ([#1140](https://github.com/mvschwarz/openrig/pull/1140)); refuse
one in `rig daemon start --port`, with exit 1
([#1201](https://github.com/mvschwarz/openrig/pull/1201)).
### Scope, proof and refused requests
- Give parallel `rig scope slice create` calls different slice numbers. A
number is never reused, and each create leaves a hidden `slices/.nn-NN` claim
file ([#1007](https://github.com/mvschwarz/openrig/pull/1007)).
- Take `--project <id>` or a `<project>:` scope prefix in `rig proof show` and
`rig proof judge`; scope readiness for a project selected in the TUI uses
that project's own root
([#1149](https://github.com/mvschwarz/openrig/pull/1149), finishing
[#136](https://github.com/mvschwarz/openrig/pull/136)).
- Return 400 before storing anything from the chat send and topic routes for
payloads that used to fail with 500
([#1122](https://github.com/mvschwarz/openrig/pull/1122)); answer a
fractional or non-numeric generation in `/api/wake-resolve` with the usual
"does not exist" reply ([#1129](https://github.com/mvschwarz/openrig/pull/1129));
refuse a watchdog `scan_interval_seconds` that can't be turned into a number,
such as non-numeric text, a boolean or an empty array
([#1131](https://github.com/mvschwarz/openrig/pull/1131)); and match unmanaged
agent process names exactly, plus `claude.exe` and names cut to 15 or 16
characters ([#1133](https://github.com/mvschwarz/openrig/pull/1133)).
### Off by default
- Add `output.compact` (or `OPENRIG_OUTPUT_COMPACT`): the seven queue write
commands print a short receipt instead of the whole row, and `--full` prints
the whole row. While it's off, their output is unchanged
([#1137](https://github.com/mvschwarz/openrig/pull/1137)).
- Add `provider.usage_windows` (or `OPENRIG_PROVIDER_USAGE_WINDOWS` in the
daemon's environment): `rig provider status --refresh` reads a Codex login's
usage windows once. While it's off, `--refresh` says how to turn it on and
exits 1 ([#1194](https://github.com/mvschwarz/openrig/pull/1194)).
### Guidance and docs
- Split the `rigs` and `openrig-skills` skill descriptions by who is asking: a
person in their own session, or an agent inside a rig
([#1174](https://github.com/mvschwarz/openrig/pull/1174)).
- Add a reference page on keeping a watch for a person with today's queue
commands ([#1064](https://github.com/mvschwarz/openrig/pull/1064)); see Known
limitations.
### Dependency and install-script changes
- No npm dependency or lockfile change beyond OpenRig's own version, the same
`postinstall`, and the same `node >=22`.
### Tests and repository checks
- Fix daemon and CLI tests that failed on some Linux machines: ext4 inode
reuse, dash as `/bin/sh` and a 002 checkout umask
([#1148](https://github.com/mvschwarz/openrig/pull/1148), finishing
[#83](https://github.com/mvschwarz/openrig/pull/83)); clean up after the
pane-isolation tests ([#1169](https://github.com/mvschwarz/openrig/pull/1169));
keep the TUI default socket-path test from failing on macOS
([#1182](https://github.com/mvschwarz/openrig/pull/1182)); and run the
portability report when it's started through a symlinked path
([#1109](https://github.com/mvschwarz/openrig/pull/1109)).
### Known limitations
- `rig seat restart --keep-conversation` supports Linux only. Its automated
tests are hermetic: they don't restart a real Codex seat.
- The rig status API (`GET /api/rigs/:id/status`) can call a running rig
`blocked` because of one seat that isn't running: it folds a restore forecast
(that seat would stop for a decision) into current status. No seat is halted,
and `rig status` and the TUI's rig list don't show it for a running rig
([#1141](https://github.com/mvschwarz/openrig/pull/1141)).
- A Codex seat can keep reading as at its usage limit when an old banner is
followed by a submitted prompt and the agent's reply, with no Codex working
line and no input line below; it's recorded again every five minutes. The
case was constructed from the source and hasn't been seen in a real session
([#1185](https://github.com/mvschwarz/openrig/pull/1185)).
- The keeping-a-watch page says a timer fires once. When the typing guard holds
a one-shot `--wake-after` wake back (it shows as `retained`), that held-back
attempt is recorded as the timer's one fire and later wakes aren't, so the
timer isn't retired: it can wake the owner again at each interval while the
row stays set aside. A row sharing the timer through `--wake-watchdog`, after
the original row was handed off or stopped, loses its one wake if that wake
is held back.
Thanks to everyone whose pull requests are in this release; they are listed in
the release notes, with how it was tested.
## [0.6.10]
0.6.10 is a fix release, most of it from the community. Setup can be told
+131 -130
View File
@@ -1,7 +1,7 @@
# What changed in OpenRig 0.6.10
# What changed in OpenRig 0.6.11
This note is for an agent on an install that was just upgraded to 0.6.10. It lists only what behaves differently from
0.6.9 and what to do about it. For everything OpenRig can do, read the capability map:
This note is for an agent on an install that was just upgraded to 0.6.11. It lists only what behaves differently from
0.6.10 and what to do about it. For everything OpenRig can do, read the capability map:
`rig context get onboarding-width/public-what-you-can-do.md`. Each release replaces this file, so
`rig context get reference/whats-new.md` describes the installed version once the upgraded daemon is running. Without
a running daemon, read `$OPENRIG_HOME/reference/whats-new.md`. The daemon refreshes that copy when it starts, so right
@@ -9,158 +9,159 @@ after an upgrade it can still hold the previous version's note.
## After the upgrade
- **Restart the daemon** (`rig daemon stop`, then `rig daemon start`). Two migrations run on the first start: `099`
adds one partial index to the queue table, and `101` adds four empty columns to `sessions`. Neither changes existing
data. There is no migration `100`. The bundled openrig-core plugin goes from 0.1.8 to 0.1.9.
- **Restart the daemon** (`rig daemon stop`, then `rig daemon start`). One migration runs on the first start: `103`
adds one column to `sessions`, `intentionally_stopped`, which is 0 for every existing row. There are no migrations
`100` or `102`. The bundled openrig-core plugin stays at 0.1.9; two of its files changed.
- **Two new switches, both off by default:**
- `output.compact` (or `OPENRIG_OUTPUT_COMPACT`): the seven queue write commands (`rig queue create`, `claim`,
`unclaim`, `update`, `block`, `handoff` and `handoff-and-complete`) print a short receipt instead of the whole
row, and `--full` prints the whole row. While it's off their output is unchanged, `--full` is accepted, and
`rig config` lists the key.
- `provider.usage_windows` (or `OPENRIG_PROVIDER_USAGE_WINDOWS` in the daemon's environment): `rig provider status
--refresh` reads a Codex login's usage windows once. While it's off, `--refresh` says how to turn it on and exits 1.
## Output that changed shape
- **`rig capture --lines N` is the last N lines,** after blank rows at the bottom are dropped. When earlier lines are
cut, stderr says `[rig capture: <session>: N earlier lines not shown; last M shown]`. In `--json`, `lines` is then
the count returned, and `requestedLines` and `omittedLines` are new (per seat under `--rig` or `--pod`). For the old
output, N history lines plus the whole pane, add `--history-plus-pane`; it keeps the old JSON too, without
`requestedLines` or `omittedLines`. Without `--lines`, or with a non-numeric or zero `--lines`, nothing changed. A
short capture doesn't prove a seat is idle.
- **Compact `rig queue list` rows carry `summary`,** and so do `overdue` and `undelivered`. The fields they leave out
(`body`, `evidenceRef`, `humanDetail`, `waiting`, `chainOfRecord`, `replyTo`, `humanQuestions`, `humanAnswers`) are
now absent, not `null` or `""`, and `fieldsElided` names them. A filter that tested those fields for null must test
for presence. Read a full row with `rig queue show <id> --full --json`.
- **`lastNudgeResult` reads `claimed`** on a claimed row whose wake was recorded as `delivered-ack-pending` or
`indeterminate:…`. The recorded value is unchanged and shows in the new `lastNudgeWireResult`; unclaiming brings it
back. `claimed` is never promoted to `verified`.
- **`rig chatroom history` returns the newest messages** when you give neither `--after` nor `--since`, still listed
oldest first. Before, a room longer than the limit returned its oldest messages. `--after` and `--since` still page
forward from that point, and `rig chatroom wait` is unchanged.
- **`rig queue create --verify` reports `outcome: "claimed"`** for a claimed row whose wake wasn't confirmed, instead
of `indeterminate`. A claim is pickup, not proof the body was read.
- **`rig queue create --verify` to a registered person who is also a seat with a pane reports the Slack side too.**
Beside the terminal wake it adds a `gateway` object: `posted` (`connectorAccepted: true`), `transport-failed` or
`never-posted` (`connectorAccepted: false`, with the detail), or `still-pending` (`connectorAccepted: null`). The
wait is still bounded by the existing 30-second default, and it can use all of it even when Slack isn't set up or the
row won't be posted; the Slack side then reads `still-pending`. Posted isn't read, and "This is not a failed handoff"
isn't shown when Slack reports a failed delivery. `GET /api/queue/:qitemId` returns `ownerNotificationLevel`, or
`null`.
- **`rig queue whoami --work-candidates` returns up to 50 held and 50 next rows,** with `heldTruncated` and
`nextTruncated`. `heldAndNextTruncated` now means either list was cut. With `OPENRIG_REFOCUS_WORK_PACKET` on (off by
default, as in 0.6.10), the refocus work packet lists held and next work separately, up to 50 each.
- **`rig ps --nodes --json` carries `intentionallyStopped`,** and `rig ps --nodes` shows `usage_limit` under REASON for a
Codex seat at its usage limit.
## Seats and restore
- **`rig launch <rigId> <seat>` no longer calls an exited agent running.** When a seat's kept pane is at an idle shell,
the launch returns `attention_required` (HTTP 409, exit 1) with `Session alive, agent not running for '<seat>'` and
the ways back: resume it with `--snapshot-id <id>` when a snapshot can, `rig seat launch <session> --fresh --stop`, or
restart the agent in the pane. The pane and its history are kept. This covers Claude Code, Codex, Pi and OMP seats.
- **`rig up <rig> --existing` puts back the guidance `rig down` removed.** Before a resumed seat starts, the missing
OpenRig managed blocks go back into the rig's Claude file (`CLAUDE.md`, or `CLAUDE.local.md`) or `AGENTS.md` for
Codex. Existing blocks and the person's own text are untouched, and nothing is sent to the conversation. The
warnings say `Restore guidance: <seat>: put back missing blocks …`. A seat still missing a block keeps running, its
node lists `guidanceGaps`, the rig reads `partially_restored` instead of `fully_restored`, and `rig up` exits 1.
- **A resumed Claude seat whose session ID changed can prove.** When Claude continues `--resume T1` under a new ID T2,
and the first SessionStart hook from the process OpenRig launched reports `source: "resume"`, OpenRig records T2. The
seat restores as `resumed`, `rig seat clear-attention` can clear it, and sends stop warning that the launch identity
differs. Resume commands now start with `OPENRIG_RESUME_LAUNCH='<T1>'`. It needs openrig-core 0.1.9; `/clear`, an
in-process `/resume`, or a hand-started process still keep the refusal.
- **A restore no longer proves a seat from a launcher's token** when the launcher isn't Claude and the Claude it
started names a different conversation. That seat reads `mismatch` and the restore returns `attention_required`.
- **A seat whose tmux session is confirmed gone reads `detached`,** with activity `unknown` and reason
`session_missing`. `rig ps --nodes` shows `tmux session missing; restore or relaunch the seat`, `rig seat status`
prints a `Reason:` line, and `--full --json` adds `storedSessionStatus`. The stored status isn't rewritten.
- **A Codex seat at its usage limit reads `unknown` with reason `usage_limit`,** not idle, when its screen ends with
the `■ You've hit your usage limit` banner. For a seat bound to a known Codex account, `rig provider signals` can show
a provider-event signal for that account that allows a switch decision.
- **`rig add <rig> …` takes the rig's name as well as its ID.** A name two active rigs share is refused with
`rig_ambiguous` and the candidates. `rig launch` still takes an ID only.
- **`rig start` says why it skipped an existing kernel:** `Kernel auto-boot skipped (kernel rig already managed).` and,
if its seats aren't running, `rig up kernel --existing`.
- **A restore no longer starts a seat fresh when an earlier occupant left a resume token.** If a seat wasn't running
when the snapshot was taken, but an earlier session recorded a resume token, `rig up --existing` and `rig launch`
stop that seat at `awaiting-decision` and start nothing, because the token doesn't prove that conversation can still
resume. `rig launch` of such a seat exits 1 where it used to start it fresh. The message names the command that
starts it fresh on purpose. See the known gap below for how this shows in the rig status API.
- **In a restore of a rig whose seats are in pods, a seat is rolled back as unresumable only when its runtime reports
the conversation missing.** When Claude, Codex or Pi reports that, the seat goes back to `awaiting-decision` and
nothing starts, instead of a failed launch that left the new pane behind. A rig where every seat ends this way reads
`partially_restored` instead of `failed`; the exit code is 1 either way. A Claude that exits to its shell for another
reason keeps 0.6.10's result: the seat is reported failed and its pane is kept, so you can read what happened. Restores
of rigs without pods keep their earlier handling.
- **`rig seat launch --fresh --stop` checks before it stops anything.** A seat with a still-live older session is
refused before the current agent is stopped; 0.6.10 stopped it first. `--plan` previews a fresh launch without
changing anything and exits 0 even when it would be blocked. `--reason` is required only to execute; leaving it out
reports `missing_reason`.
- **A seat you stop on purpose no longer makes its rig read `degraded`.** `rig seat stop` records the stop, and `rig ps`
and the TUI leave that seat out of the rig's state; running counts still exclude it. A rig whose seats were all
stopped this way reads `stopped`.
- **Restart one Codex seat in place, on Linux:** `rig seat restart <seat> --keep-conversation --reason "<why>"`
restarts it in its own pane on the same conversation, so it starts again on whatever account its Codex home now
holds. It doesn't switch accounts itself. Before it stops anything, it checks that the seat is a Codex seat OpenRig
launched, in a single pane, with a conversation it can prove, and that relaunching wouldn't change the running
settings; any doubt refuses with nothing stopped. `--plan` runs every check and stops nothing.
`--verify --expect-account <account>` reports `moved` only when a tool turn has completed in the same conversation
since the restart and Codex's status names that account.
On macOS, a request that gets as far as reading the running process refuses before stopping the seat, because that
reading depends on `/proc`.
- **A forked Claude seat launches with its own session ID** (`rig fork`, or `session_source: fork`), so strict restore
and clear-attention can verify it as its own conversation. Forking a Claude seat needs Claude Code 2.0.73 or later,
which OpenRig doesn't check. Forks launched before this keep reading `mismatch` until they're relaunched.
- **Seats no longer inherit the launching Claude Code or Herdr session's identity variables,** such as `CLAUDECODE`.
If removing them fails, the launch goes ahead with a warning.
- **A member's `cwd` in a rig spec can be `~`, start with `~/`, or use `$VAR` or `${VAR}`,** expanded from the daemon's
environment, when the path as written doesn't exist. `rig up` warns on stderr when it chose `/` as the launch folder
itself.
- **A late readiness result no longer overwrites a newer launch attempt.**
## Sends and wakes
- **Codex seats behind a launcher receive sends.** When a seat's `codex` is a launcher that starts Codex as a child,
`rig send` and queue wakes used to refuse with "reports sh as the foreground command". The deepest `codex` on one
process chain now counts as the seat. Codex processes on separate branches, or naming different conversations, are
still refused.
- **Claude's faint suggested prompt isn't a person's draft.** A send to an idle Claude seat whose empty input box shows
only that suggestion is no longer refused as `prompt_draft`. OpenRig takes one styled capture to tell; real typed or
pasted text is still refused, and a suggestion that wraps onto a second row still reads as a draft.
- **`rig queue create --verify` reports a seat's wake and claim.** For an agent destination it returns as soon as a
wake result or a claim is recorded, bounded at 30 seconds, with `receiptSource: "terminal"`, `outcome`, `wakeResult`
and `claim {observed, claimedAt, pickup}`. An unconfirmed receipt is not a failed handoff. See the known gap below
for a row the seat already claimed.
- **Keep agent-to-agent escalations off the person's Slack (off by default).** `rig config set queue.wake_human_rung
explicit-only` (or `OPENRIG_QUEUE_WAKE_HUMAN_RUNG`) stops the wake ladder's last step at `rig view show escalations`
unless the row is addressed to a person: an `@external` address or a registered human. A seat stuck at a prompt only
a person can clear still reaches one, and an unreadable human registry posts everything. The default, `always`,
changes nothing.
- **`rig send --wait-for-idle N` waits before it takes the seat's delivery lease,** so other sends and queue wakes to
that seat aren't held behind a long wait. N is now a deadline from the start of the wait, including any time spent
waiting for the lease. A timeout names the wait you asked for.
- **A parked seat whose occupant is known isn't woken again for the same rows after a wake was delivered,** until it
updates one of them or one changes state. A failed, refused or unrecorded wake still repeats at the next park, and a
new occupant gets its first wake. "Delivered" means the wake was typed into the seat's terminal, not that the agent
read it.
- **`rig send --verify` confirms enveloped multi-line messages that the agent's screen indents,** and queue wakes use
the same check.
- **A Claude footer in auto, bypass or plan mode on the last line reads idle** when no activity hook is reporting.
- **A failed Pi or OMP turn shows the provider's own reason** when it gives one.
- **A `handed-off` closure with no visible successor** gets the same custody advisory and stuck-sweep finding as a
`done` closure.
## Slack
## Status
- **A decision click that isn't recorded gets one reply in the thread** saying why: the clicker isn't registered, the
decision was sent to someone else or is no longer waiting, the button no longer matches, or OpenRig can't find the
decision. Where it helps, the reply asks for an answer in words. A recorded click still gets its usual reply, and a
redelivered click gets no second one. It's best effort and needs the bot token. A successful press doesn't exercise
these replies.
## Terminals and attach
- **Attach commands name the daemon's tmux server.** When the daemon was started inside tmux on a non-default socket,
every attach command OpenRig prints carries `-L <name>` or `-S <path>`, including `tmuxAttachCommand` in `rig ps
--json`. From inside tmux, run it as `env -u TMUX <tmuxAttachCommand>`. On the default server nothing changed.
- **When a caller of the daemon's terminal API names no provider, the daemon uses cmux if Herdr isn't available** and
cmux is, and says so. A named `--provider` is used as named. `rig terminal open` without `--provider`, and the TUI's
"Open terminals", open Herdr, or plain tmux when Herdr is missing, never cmux. The TUI's preview now asks for the
same provider as the window it opens, so it isn't refused when cmux is running.
- **A tmux started inside a Herdr pane is recognised** by Herdr's markers (`HERDR_ENV`, `HERDR_PANE_ID`,
`HERDR_SOCKET_PATH`), so `rig terminal open --window` opens in that Herdr.
## Setup
- **`rig setup --providers claude|codex|both`** (or a comma list) sets which harnesses it installs and checks. One not
chosen reads `skipped` with `Not selected: …`, never failed. Without the flag, setup is unchanged. `rig doctor` still
checks both.
- **A first `rig daemon start` after a timed-out probe goes ahead when the port is free,** as on WSL2. A port that's
really held is refused with `Address already in use (EADDRINUSE)` and the `--port` advice.
## Teams
- **Starter and factory teams run their own first steps and checks without stopping for approval** where they can:
plain `rig` commands, the file tool instead of a heredoc, the project's test command first. Their leads read the goal
from `rig queue show <id> --full`.
- **The operator says what a team launch changes in the person's folder before asking** (`CLAUDE.md`, skills,
`.mcp.json`, `.openrig/`, and an accept-edits `.claude/settings.local.json`), and offers a separate copy such as a git
worktree. When a seat shows `needs_input`, the operator or lead tells the person which seat, what it asks, and its
attach command.
- **A Codex seat at its usage limit stays `unknown` with reason `usage_limit`** while its screen redraws, instead of
reading `running`. It clears when a readable screen shows no banner, or when Codex shows it's working again. See the
known gap below.
- **`rig ps` takes a rig's running count and status from the same set of sessions as `rig ps --nodes`.**
- **`rig whoami --full --json` shows a Codex seat's `runtimeContext.threadId`** when its stored conversation ID was
verified resumable.
- **The TUI says when a queue sample hit its cap or failed to load:** counts read `N+`, a seat missing from the sample
shows `?`, and Pulse no longer lists a NEEDS YOU item a second time. The caps count the whole host, so a quiet rig can
read `?` while another rig is busy.
- **The TUI Topology table's Q, WORK and NOW columns fill again** on drilled views and on a seat's page.
## Smaller fixes
- `_` and `%` in a `rig view show <lens> --rig <name>` rig name are no longer wildcards, in all seven lenses that
filter by rig.
- `rig ask` matches chat keywords such as `config.json` or `C++` literally.
- `rig heartbeat` reads CRLF queue files and reads a zone-less proof timestamp as UTC when picking the latest.
- `rig usage top` reads every sample in its window; `rig usage top|series --json` prints the daemon's error as JSON.
- A `?limit=` that isn't a number returns 400, not 500.
- `rig workflow instantiate` and `project` print the instance and packet the daemon returned.
- `rig transcript --tail` keeps a line ending in `%` and drops a repeated prompt.
- `--json` after `--` no longer turns an error into JSON; `rig env` prints the daemon's error.
- A file read over 1 MiB ends at a whole UTF-8 character, and `truncatedAtBytes` says where.
- The TUI counts CR+LF as one Enter, keeps an emoji whole at a wrap, pages Local reading directories by 10, and skips
fenced examples when opening a file at a heading.
- Scope progress rows go only under a matching `##` heading, frontmatter updates stay inside the exact `---`
delimiters, a capability delta expires only with a readable successor file, and fenced examples no longer count as
slice sections or progress tasks.
- `rig package install` looks for a guidance block's closing marker only after its opening one.
- A Codex seat keeps its last context reading across quota-only usage records, and a fresh Codex launch keeps git
metadata paths that end in whitespace.
- A Slack reply sent with "Also send to channel" in the default channel is recovered after a connection gap.
- `rig config set daemon.port` refuses a port outside 0–65535, and a port like that from the environment or
`config.json` falls back to the file or default with a warning. `rig daemon start --port` refuses one too, with exit 1.
- Parallel `rig scope slice create` calls get different slice numbers. A number is never reused, and each create leaves
a hidden `slices/.nn-NN` claim file.
- `rig proof show` and `rig proof judge` take `--project <id>` or a `<project>:` scope prefix. Scope readiness for a
project selected in the TUI uses that project's own root.
- The chat send and topic routes return 400 before storing anything for payloads that used to fail with 500;
`/api/wake-resolve` answers a fractional or non-numeric generation with its usual "does not exist" reply; watchdog
registration refuses a `scan_interval_seconds` that can't be turned into a number (non-numeric text, a boolean, an
empty array); and unmanaged-pane discovery matches agent
process names exactly, plus `claude.exe` and names cut to 15 or 16 characters.
- The `rigs` and `openrig-skills` skill descriptions split by who is asking: a person in their own session, or an agent
inside a rig.
- A new reference page covers keeping a watch for a person with today's queue commands, installed at
`$OPENRIG_HOME/reference/keeping-a-watch.md`. See the known gap below.
## What to stop doing
- **Stop reading "already running" from `rig launch` as healthy** for a seat whose agent may have exited; 0.6.10 says
`attention_required`.
- **Stop reading `rig capture --lines N` as N history lines plus the pane.** Add `--history-plus-pane` for that.
- **Stop testing compact queue rows for null fields.** They're absent; check `fieldsElided`.
- **Stop attaching to the default tmux server** when the printed command names `-L` or `-S`.
- **Stop expecting a restore to start a seat fresh** when an earlier session left a resume token; decide at
`awaiting-decision`, or use the command the message names.
- **Stop reading `indeterminate` as the answer for a claimed row** from `rig queue create --verify`; 0.6.11 says
`claimed`.
- **Stop treating `degraded` as a stopped seat's doing** after `rig seat stop`; the rig leaves that seat out.
- **Stop counting a long `--wait-for-idle` as extra time after the lease;** N is a deadline from the start of the wait.
## Known gaps in 0.6.10
## Known gaps in 0.6.11
- **`rig queue create --verify` can report `outcome: "indeterminate"` for a row the seat already claimed,** with
"Terminal receipt unconfirmed (claimed)." Read `claim` for pickup: neither the claim nor this outcome shows the
message was displayed or read. It isn't a failed handoff, so don't re-run the create.
- **The latest first-install check used 0.6.9 on Linux only.** macOS was last checked with 0.6.8, and native Windows
hasn't been checked.
- **`rig seat restart --keep-conversation` supports Linux only.** Its automated tests are hermetic: they don't restart a
real Codex seat.
- **The rig status API can call a running rig `blocked` because of one seat that isn't running.** It happens when the
latest snapshot recorded no occupant for that seat, an earlier session of the seat left a resume token, and its newest
session's restore policy is `resume_if_possible`. A restore from that snapshot would stop at the seat for a decision,
and `GET /api/rigs/:id/status` folds that forecast into the rig's status, so it mixes current status with restore
readiness: no seat is halted. `rig status` and the TUI's rig list don't show it for a running rig.
- **A Codex seat can keep reading as at its usage limit** when an old usage-limit banner is followed by a submitted
prompt and the agent's reply, with no Codex working line and no input line below. It's recorded again every five
minutes, where 0.6.10 let it age out. The case was constructed from the source and hasn't been seen in a real
session.
- **The keeping-a-watch page says a timer fires once; a held-back wake uses up that fire.** When the typing guard holds
a one-shot `--wake-after` wake back (it shows as `retained`), that held-back attempt is recorded as the timer's one
fire and later wakes aren't, so the timer isn't retired: it can wake the owner again at each interval while the row
stays set aside. A row sharing the timer through `--wake-watchdog`, after the original row was handed off or stopped,
loses its one wake if that wake is held back. Read the row's transitions after each wake.
- **After `rig scope slice create` says it couldn't verify membership, the slice was kept.** Check `mission.yaml` before
creating it again: creating it again makes a second slice with the same name.
- **The latest first-install check before this release used 0.6.10 on Linux only.** macOS was last checked with 0.6.8,
and native Windows hasn't been checked.
- **Only one selected lifecycle-help command has been checked in a live Claude session;** source and parser coverage
is broader. If a help command still asks, report it.
- **Codex team seats aren't asked before lifecycle commands yet.**
- **The send gate trusts the most recently recorded hook.** If hooks are recorded out of order, it can hold a send for
a question that's already answered, or miss one that's waiting. `rig capture` shows the real screen.
- **A Codex seat whose permission request was approved automatically can still read "needs input".**
- **A launched team still writes into its working folder;** the operator now says what changes before you agree.
- **A launched team still writes into its working folder;** the operator says what changes before you agree.
- **After a restart, `rig status` shows `Kernel: skipped` whatever the kernel seats' state.** Read them with
`rig ps --nodes --rig kernel --full`.
- **`rig tui --shared` still attaches on the default tmux server,** so it can't find the dashboard when the daemon runs
+5 -5
View File
@@ -1,12 +1,12 @@
{
"name": "openrig",
"version": "0.6.10",
"version": "0.6.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openrig",
"version": "0.6.10",
"version": "0.6.11",
"license": "Apache-2.0",
"workspaces": [
"packages/daemon",
@@ -7068,7 +7068,7 @@
},
"packages/cli": {
"name": "@openrig/cli",
"version": "0.6.10",
"version": "0.6.11",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -7103,7 +7103,7 @@
},
"packages/daemon": {
"name": "@openrig/daemon",
"version": "0.6.10",
"version": "0.6.11",
"license": "Apache-2.0",
"dependencies": {
"@hono/node-server": "^1.13.0",
@@ -7144,7 +7144,7 @@
},
"packages/ui": {
"name": "@openrig/ui",
"version": "0.6.10",
"version": "0.6.11",
"license": "Apache-2.0",
"dependencies": {
"@fontsource-variable/jetbrains-mono": "^5.2.8",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "openrig",
"version": "0.6.10",
"version": "0.6.11",
"private": true,
"license": "Apache-2.0",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/cli",
"version": "0.6.10",
"version": "0.6.11",
"type": "module",
"description": "Build your own network of agents from Claude Code, Codex and Pi: persistent teams with roles, shared context and owned work.",
"keywords": [
+61 -25
View File
@@ -36,7 +36,8 @@ export interface QueueDeps extends StatusDeps {
}
interface DeliveryResultBase {
/** human: a human-seat or @external address, or a row the gateway owns. agent: any other seat. */
/** human: a human-seat or @external address, a row the gateway owns, or a seat the gateway also
* posts to (a registered person with a pane). agent: any other seat. */
destinationKind: "agent" | "human";
/** Human destinations only. No receipt can prove that a person read the message. */
humanReadership?: "unknown";
@@ -66,6 +67,9 @@ export interface TerminalDeliveryResult extends DeliveryResultBase {
/** A claim shows the seat took the item; it does not prove the body was read.
* pickup is the daemon's derived pickup receipt state, as the row reports it. */
claim: { observed: boolean; claimedAt: string | null; pickup: string | null };
/** Present when the gateway may also post this row: a registered person who is also a seat with
* its own pane. The Slack receipt, kept apart from the terminal side; posted is not read. */
gateway?: GatewayReceipt;
}
export type VerifiedDeliveryResult = GatewayDeliveryResult | TerminalDeliveryResult;
@@ -82,20 +86,46 @@ function stringField(row: QueueRow, key: string): string | null {
return typeof row[key] === "string" ? row[key] as string : null;
}
/** The row's own signals decide who owns delivery: a gateway receipt, then the recorded wake
* result, then the daemon's destination class (present before any wake, e.g. --no-nudge). The
* address pattern is only the fallback for a daemon that reports none of these. */
/** The row's own signals decide who owns delivery: the recorded wake result, then the daemon's
* destination class (present before any wake, e.g. --no-nudge), then a gateway receipt. The
* address pattern is only the fallback for a daemon that reports none of these. A gateway
* receipt beside a terminal signal adds a gateway side (gatewayAlsoPosts); it doesn't replace it. */
function receiptSourceOf(destination: string | null, row: QueueRow): "gateway" | "terminal" {
if (stringField(row, "deliveryOutcome") !== null) return "gateway";
const wakeResult = stringField(row, "lastNudgeResult");
if (wakeResult !== null) return wakeResult.startsWith("gateway-owned:") ? "gateway" : "terminal";
const destinationClass = stringField(row, "destinationClass");
if (destinationClass !== null) return destinationClass === "gateway-routable" ? "gateway" : "terminal";
if (stringField(row, "deliveryOutcome") !== null) return "gateway";
return destination !== null && isHumanSeatSessionRef(destination) ? "gateway" : "terminal";
}
function destinationKindOf(destination: string | null, source: "gateway" | "terminal"): "agent" | "human" {
return source === "gateway" || (destination !== null && isHumanSeatSessionRef(destination)) ? "human" : "agent";
/** A terminal destination the gateway may also post to (a registered person who is also a seat
* with a pane): the row carries the owner notification level the Slack selector keys on, or a
* gateway receipt already exists. */
function gatewayAlsoPosts(row: QueueRow): boolean {
return stringField(row, "ownerNotificationLevel") !== null || stringField(row, "deliveryOutcome") !== null;
}
/** A terminal destination is a person when the gateway also posts to it or its address is a human seat's. */
function terminalDestinationKind(destination: string | null, gatewaySide: boolean): "agent" | "human" {
return gatewaySide || (destination !== null && isHumanSeatSessionRef(destination)) ? "human" : "agent";
}
/** The gateway (Slack connector) receipt on the row, in one place for both result shapes. */
interface GatewayReceipt {
outcome: "posted" | "transport-failed" | "never-posted" | "still-pending";
connectorAccepted: boolean | null;
detail?: string;
}
function gatewayReceiptOf(row: QueueRow): GatewayReceipt {
const outcome = stringField(row, "deliveryOutcome");
if (outcome === "posted") return { outcome, connectorAccepted: true };
if (outcome === "transport-failed" || outcome === "never-posted") {
const detail = stringField(row, "deliveryFailureDetail");
return { outcome, connectorAccepted: false, ...(detail ? { detail } : {}) };
}
return { outcome: "still-pending", connectorAccepted: null };
}
function terminalWakeOutcome(wakeResult: string | null): TerminalDeliveryResult["outcome"] {
@@ -137,8 +167,16 @@ function terminalDeliveryResult(
const claimText = claim.observed
? `Claimed at ${claimedAt}${pickupState ? ` (pickup ${pickupState})` : ""}. A claim shows the seat took the item; it does not prove the body was read.`
: waitedMs === null ? "No claim observed yet." : `No claim observed within ${waitedMs}ms.`;
const notFailed = !claim.observed && isKnownUnconfirmed(wakeResult, outcome) ? " This is not a failed handoff." : "";
const destinationKind = destinationKindOf(destination, "terminal");
const gatewaySide = gatewayAlsoPosts(row);
const gateway = gatewaySide ? gatewayReceiptOf(row) : null;
const slackText = gateway === null ? ""
: gateway.outcome === "posted" ? " Slack receipt: posted."
: gateway.outcome === "still-pending" ? (waitedMs === null ? " No Slack receipt yet." : ` No Slack receipt within ${waitedMs}ms.`)
: ` Slack delivery ${gateway.outcome}${gateway.detail ? `: ${gateway.detail}` : ""}.`;
const gatewayFailed = gateway !== null && gateway.connectorAccepted === false;
const notFailed = !claim.observed && !gatewayFailed && isKnownUnconfirmed(wakeResult, outcome) ? " This is not a failed handoff." : "";
const destinationKind = terminalDestinationKind(destination, gatewaySide);
const settled = claim.observed && (gateway === null || gateway.outcome === "posted");
return {
receiptSource: "terminal",
destinationKind,
@@ -146,8 +184,9 @@ function terminalDeliveryResult(
outcome,
wakeResult,
claim,
detail: `The qitem is saved. ${receipt[outcome]} ${claimText}${notFailed}`,
nextAction: claim.observed ? null : outcome === "retained" ? `rig seat held-messages ${seat}` : `rig queue show ${qitemId} --json`,
...(gateway ? { gateway } : {}),
detail: `The qitem is saved. ${receipt[outcome]}${slackText} ${claimText}${notFailed}`,
nextAction: settled ? null : outcome === "retained" ? `rig seat held-messages ${seat}` : `rig queue show ${qitemId} --json`,
};
}
@@ -213,13 +252,8 @@ export async function waitForDeliveryOutcome(
};
}
if (outcome === "transport-failed" || outcome === "never-posted") {
return {
...gateway,
outcome,
connectorAccepted: false,
detail: stringField(row, "deliveryFailureDetail") ?? undefined,
nextAction: `rig queue show ${qitemId} --json`,
};
const { detail } = gatewayReceiptOf(row);
return { ...gateway, outcome, connectorAccepted: false, ...(detail ? { detail } : {}), nextAction: `rig queue show ${qitemId} --json` };
}
if (timedOut) {
return {
@@ -232,13 +266,15 @@ export async function waitForDeliveryOutcome(
}
} else {
// --no-nudge requested no wake: there is no terminal receipt to wait for.
if (target.nudge === false) return terminalDeliveryResult(qitemId, destination, row, "not-requested", null);
const wakeResult = stringField(row, "lastNudgeResult");
const outcome = terminalWakeOutcome(wakeResult);
// Any recorded wake result (or an existing claim) ends the wait: a later pickup is
// `rig queue show`'s to report. Only a missing wake result waits up to the bound.
// Nothing here re-sends the wake.
if (wakeResult !== null || stringField(row, "claimedAt") !== null) {
const outcome = target.nudge === false ? "not-requested" : terminalWakeOutcome(wakeResult);
// Any recorded wake result (or an existing claim) settles the terminal side: a later pickup
// is `rig queue show`'s to report. When the gateway may also post the row, its receipt is
// awaited too. Only a missing receipt waits, up to the bound. Nothing here re-sends anything.
const gatewaySide = gatewayAlsoPosts(row);
const terminalSettled = outcome === "not-requested" || wakeResult !== null || stringField(row, "claimedAt") !== null;
const gatewaySettled = !gatewaySide || stringField(row, "deliveryOutcome") !== null;
if (terminalSettled && gatewaySettled) {
return terminalDeliveryResult(qitemId, destination, row, outcome, null);
}
if (timedOut) return terminalDeliveryResult(qitemId, destination, row, outcome, timeoutMs);
@@ -647,7 +683,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
.option("--evidence-ref <path>", "OPR.0.4.4.19 FR-5: pointer to the durable artifact a human judges (e.g. a PROOF.md path). Required by the daemon when the item is human-routed; optional otherwise.")
.option("--host <id>", QUEUE_HOST_OPTION_HELP)
.option("--no-nudge", "Suppress the default destination nudge (cold-queue)")
.option("--verify", "Boundedly wait after persistence for the existing receipt: the gateway receipt for a human destination, or the wake result and any claim for an agent. Never retries the create or the wake, and never claims the message was read")
.option("--verify", "Boundedly wait after persistence for the existing receipts: the gateway receipt for a person, or the wake result and any claim already present for a seat, plus the gateway receipt when the gateway may also post to that seat (a registered person with a pane; eligible to post, not guaranteed). Never retries the create or the wake, and never claims the message was read")
.option("--full", "Print the whole response row(s) as today, even when output.compact is on (the receipt's waiting keeps only blocker and nextBackstop)")
.option("--json", "JSON output for agents")
.action(async (opts: {
+79 -3
View File
@@ -433,9 +433,10 @@ describe("rig queue CLI", () => {
});
});
// A terminal (pane-bound) destination never gets a gateway receipt: its receipt is the
// row's wake result (lastNudgeResult) and its pickup is the claim. Keep persistence,
// terminal receipt, claim and human readership apart, and never call a saved row failed.
// A terminal (pane-bound) destination's receipt is the row's wake result (lastNudgeResult) and
// its pickup is the claim; a registered person with a pane may also get a gateway receipt (see
// the nested describe). Keep persistence, terminal receipt, claim, Slack and human readership
// apart, and never call a saved row failed.
describe("--verify for a terminal destination", () => {
const ROW = { qitemId: "qitem-t", state: "pending", claimedAt: null, lastNudgeResult: null, deliveryOutcome: null, pickup: { state: "unclaimed" } };
// Each poll reads the next row; the last row repeats. The caller's array is not mutated.
@@ -615,6 +616,81 @@ describe("rig queue CLI", () => {
expect(reads).toBe(1);
});
// A registered person who is also a seat with its own pane: the gateway may post the row to
// Slack too (the row's owner notification level is what its selector keys on), so --verify
// keeps a bounded wait for that receipt and reports both sides separately.
describe("a pane-bound registered person (owner notification level set)", () => {
const PANE_PERSON = { destinationClass: "pane-bound", ownerNotificationLevel: "ALERT" };
const target = { destinationSession: "dev-a@rig1" };
it("waits for the Slack receipt after the terminal receipt and reports both", async () => {
const { result, reads } = await verify([
PANE_PERSON,
{ ...PANE_PERSON, lastNudgeResult: "verified" },
{ ...PANE_PERSON, lastNudgeResult: "verified", deliveryOutcome: "posted" },
], target);
expect(reads).toBe(3);
expect(result).toMatchObject({
receiptSource: "terminal", destinationKind: "human", humanReadership: "unknown",
outcome: "verified", wakeResult: "verified",
gateway: { outcome: "posted", connectorAccepted: true },
});
expect(result.detail).toContain("Slack receipt: posted.");
});
it("keeps the bounded wait when Slack has not posted, without failing the terminal side", async () => {
const { result, reads } = await verify([{ ...PANE_PERSON, lastNudgeResult: "verified" }], target);
expect(reads).toBeGreaterThan(1);
expect(result).toMatchObject({ outcome: "verified", gateway: { outcome: "still-pending", connectorAccepted: null }, nextAction: "rig queue show qitem-t --json" });
expect(result.detail).toContain("No Slack receipt within 10ms.");
});
it("reports a Slack transport failure on its own side", async () => {
const { result } = await verify([{ ...PANE_PERSON, lastNudgeResult: "delivered-ack-pending", deliveryOutcome: "transport-failed", deliveryFailureDetail: "slack 500" }], target);
expect(result).toMatchObject({ outcome: "delivered-ack-pending", gateway: { outcome: "transport-failed", connectorAccepted: false, detail: "slack 500" } });
expect(result.detail).not.toContain("not a failed handoff");
});
it("with --no-nudge, reports no terminal wake and still waits for Slack", async () => {
const { result } = await verify([PANE_PERSON, { ...PANE_PERSON, deliveryOutcome: "posted" }], { ...target, nudge: false });
expect(result).toMatchObject({ outcome: "not-requested", gateway: { outcome: "posted", connectorAccepted: true } });
});
it("a pane-bound seat with no owner notification level is unchanged: no Slack side, settles at once", async () => {
const { result, reads } = await verify([{ destinationClass: "pane-bound", ownerNotificationLevel: null, lastNudgeResult: "verified" }], target);
expect(reads).toBe(1);
expect(result).toMatchObject({ receiptSource: "terminal", destinationKind: "agent", outcome: "verified" });
expect(result).not.toHaveProperty("gateway");
expect(result).not.toHaveProperty("humanReadership");
});
it("a Slack receipt that lands before the wake result keeps waiting on the terminal side", async () => {
const { result, reads } = await verify([
{ ...PANE_PERSON, deliveryOutcome: "posted" },
{ ...PANE_PERSON, deliveryOutcome: "posted", lastNudgeResult: "delivered-ack-pending" },
], target);
expect(reads).toBe(2);
expect(result).toMatchObject({ outcome: "delivered-ack-pending", gateway: { outcome: "posted" } });
});
it("a claim with Slack still pending keeps the bounded Slack wait", async () => {
const { result, reads } = await verify([{ ...PANE_PERSON, state: "in-progress", claimedAt: "2026-10-08T10:00:02.000Z" }], target);
expect(reads).toBeGreaterThan(1);
expect(result).toMatchObject({ claim: { observed: true }, gateway: { outcome: "still-pending" }, nextAction: "rig queue show qitem-t --json" });
});
it("a recorded terminal wake still wins over a gateway-routable class read later (#1029 order)", async () => {
const { result, reads } = await verify([{ destinationClass: "gateway-routable", ownerNotificationLevel: "ALERT", lastNudgeResult: "verified", deliveryOutcome: "posted" }], target);
expect(reads).toBe(1);
expect(result).toMatchObject({ receiptSource: "terminal", outcome: "verified", wakeResult: "verified", gateway: { outcome: "posted" } });
});
it("an older daemon's posted receipt beside a terminal wake reports both sides", async () => {
const { result } = await verify([{ lastNudgeResult: "verified", deliveryOutcome: "posted" }], target);
expect(result).toMatchObject({ receiptSource: "terminal", destinationKind: "human", outcome: "verified", gateway: { outcome: "posted" } });
});
});
it("--no-nudge to a human-seat address on a daemon without destinationClass keeps the bounded gateway wait", async () => {
const { result, reads } = await verify([{}], { destinationSession: "human-founder@kernel", nudge: false });
expect(result).toMatchObject({ receiptSource: "gateway", destinationKind: "human", outcome: "still-pending", humanReadership: "unknown" });
@@ -11,7 +11,7 @@ not know exists.** So the point of what follows is not skill. It is recognition:
lands, something rhymes, and you go check instead of building.
Eighty-seven top-level verbs ship (capability canon refreshed through
`capability-delta-v0.6.10`). This marker describes the pack's teaching, not publication or live
`capability-delta-v0.6.11`). 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.
Model-divergence proclamations are live product (trust them; pins use canonical
model IDs).
@@ -90,6 +90,8 @@ unseen — which makes messages the one delivery channel that never gets skipped
confirm by effect, never relaunch on it. A send or wake to a seat whose latest hook is a
question waiting for a person is refused with `target_needs_input`, even when its screen
can't be read: read it with `rig capture` and get the question answered instead of retrying.
`--wait-for-idle N` waits before it takes the seat's delivery lease, so other sends and wakes to
that seat aren't held behind it; N is a deadline from the start of the wait.
Over `--host` to an http-registered host, a send that may have reached the remote reads
`remote-outcome-unknown` ("Delivery UNCONFIRMED"): check the target with `rig capture <session>
--host <id>` before any resend. Only a host that was never reached reads as unreachable.
@@ -184,9 +186,11 @@ your context, your compaction and your replacement — and it is the only thing
`handoff`'s, confirms the row was saved; the owner is woken after that. Before you say they were
told, read the row's transitions, or use `--verify`. For a seat it reports the wake's terminal
receipt and any claim separately: an unconfirmed receipt is not a failed handoff, and a claim is
pickup, not proof the body was read. For a row the seat already claimed, `outcome` can read
`indeterminate` while `claim` is accurate: read `claim`. It isn't a failed handoff, so don't
re-run the create.
pickup, not proof the body was read. For a claimed row whose wake wasn't confirmed, `outcome`
reads `claimed`; it isn't a failed handoff, so don't re-run the create. For a registered person
who is also a seat with a pane, it also waits for the Slack receipt and reports it on its own,
up to the 30-second default, even when Slack isn't set up or the row won't be posted. With `output.compact` on (off by default), the queue write commands print a short
receipt and `--full` prints the whole row.
- **`rig queue show`** — what a row *actually says*. The header is not the body.
- **`rig queue claim` / `unclaim`** — is this mine and running, or still pending where two agents
might double-work it. Put it down honestly when it is not yours.
@@ -201,7 +205,11 @@ your context, your compaction and your replacement — and it is the only thing
visible**, with a plain-language summary and a pointer to what a human must judge; `resolve`
writes their decision onto the durable record and wakes the owner. **Closing it would be a lie
and sitting on it silently is indistinguishable from a crash.** `--wake-after <duration>` arms
one wake for this park episode; a new park supersedes it and every exit route retires it.
one wake for this park episode; a new park supersedes it and every exit route retires it. In
this release a wake the typing guard holds back (`retained`) is recorded as that one fire and
later wakes aren't, so the timer isn't retired: it can wake the owner again at each interval
while the row stays parked. A row attached to a shared timer with `--wake-watchdog` loses its
one wake if that wake is held back.
- **`rig queue overdue`** — what was claimed and never closed in time. **Defaults to the current
rig**; ask for more if you mean more.
- **`rig queue undelivered`** — pending create-path nudge failures only. For gateway or human
@@ -359,7 +367,11 @@ not have to tear one down to change it, and you rarely have to start from nothin
is archived), so check `rig ps --nodes` first. Launch-created `AGENTS.md` or `CLAUDE` files now show in `git status`.
`rig up <rig> --existing` puts back the OpenRig guidance blocks `rig down` removed before a
resumed seat starts. A seat still missing one makes the rig `partially_restored` (its node lists
`guidanceGaps`) and `rig up` exits 1.
`guidanceGaps`) and `rig up` exits 1. A seat that wasn't running at the snapshot, but whose
earlier session left a resume token, stops at `awaiting-decision` instead of starting fresh, in
`rig up --existing` and in `rig launch` (exit 1); the message names the command that starts it
fresh on purpose. In a pod-aware rig, a seat is rolled back as unresumable only when its runtime
reports the conversation missing.
- **`rig down`** — stop a rig's seats and take it out of the running set. It ends the agents'
sessions and any work in progress; don't run it on a live team unless the person asked. In
human mode it first prints one line with each agent's last known activity, or “unknown”: a
@@ -372,7 +384,16 @@ not have to tear one down to change it, and you rarely have to start from nothin
`rig seat launch <session> --fresh --stop`.
- **`rig seat launch <seat> --fresh --reason <why>`** — deliberately create a blank occupant for
exactly one existing seat. It uses no resume, fork, rebuild, snapshot, or restore packet;
siblings and durable work stay put, and unmanaged ambiguity refuses.
siblings and durable work stay put, and unmanaged ambiguity refuses. With `--stop`, a seat with a
still-live older session is refused before anything is stopped; `--plan` previews it.
- **`rig seat restart <seat> --keep-conversation --reason <why>`** — restart one Codex seat in its
own pane on the same conversation, so it starts again on whatever account its Codex home now
holds; it doesn't switch accounts itself. It refuses with nothing stopped unless the seat is a
Codex seat OpenRig launched, in a single pane, with a conversation it can prove and unchanged
running settings. `--plan` runs every check and stops nothing; `--verify --expect-account
<account>` reports `moved` only when a tool turn has completed in the same conversation since
the restart and Codex's status names that account. Linux only: on macOS a
request that reaches the running-process check refuses before stopping the seat.
- **`rig add`** / **`rig expand`** — graft one more member, or a whole new pod, onto a rig that is
already running. `rig add` takes the rig's name or ID; a name two active rigs share is refused
with `rig_ambiguous` and the candidates. `rig grow --new-pod` and `rig expand` are one ingress; choose by input shape.
@@ -480,7 +501,8 @@ version, from checking the environment to a useful report to the OpenRig team.
updating Codex is the operator's job.
- **`rig seat set-model` / `stop` / `clean`** — persist the model for later managed resumes, stop
exactly one live seat (ending its session and any work in progress), or clear a dead seat's
stale binding. When the topology is right and only
stale binding. A stop is recorded, so the rig doesn't read `degraded` for that seat, and a rig
whose seats were all stopped this way reads `stopped`. When the topology is right and only
the occupant is wrong, use these seat lifecycle verbs or `rig handover`, not a rig down/up cycle.
Effective-model detection follows the identity-verified current occupant, not a retained
predecessor; a `<synthetic>` transcript record is skipped, so a PENDING model check is not a
@@ -568,7 +590,8 @@ scheme, and nothing downstream can see it.
source actually bound. When someone later asks where the contract items came from, the drop's
own echo is the answer — not a re-read of the files.
- **`rig proof judge` / `rig proof show`** — record an attributed judgment on a contract item once,
then read derived proof, slice, mission and project readiness. The owning scope selects
then read derived proof, slice, mission and project readiness. Both take `--project <id>` or a
`<project>:` scope prefix. The owning scope selects
`proofPolicy.judges` (nearest slice, mission or project policy wins); consult `rig proof --help`
for that setup and the item/evidence selectors. Accept, reject or withdraw against readable
evidence; corrections preserve history and unrelated judgments without ancestor status edits.
@@ -795,8 +818,10 @@ your circumstances is configuration, and the ones that are not, another agent ca
accounts, what the usage signals say, and whether a seat can be moved without stranding the
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 `provider signals` can
show a provider-event signal for that seat's account that allows a switch decision.
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
allows a switch decision. With `provider.usage_windows` on (off by default), `rig provider status
--refresh` reads a Codex login's usage windows once.
- **Codex configuration fragments** — a `codex_config_fragment` must open with a table header and
never overrides a user-declared table; see `docs/reference/agent-spec.md`.
- **`rig env`** — the real services behind a rig: are they up, what are they saying, how do you
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/daemon",
"version": "0.6.10",
"version": "0.6.11",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -432,6 +432,7 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
ok: false,
error: "Claude resume failed: no conversation found for the requested session",
recovery: "retry_fresh",
missingConversation: true,
};
}
@@ -482,6 +483,16 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
};
}
// The wait can end on the read that first shows Claude's missing-conversation report.
if (finalProbe.code === "no_conversation_found") {
return {
ok: false,
error: "Claude resume failed: no conversation found for the requested session",
recovery: "retry_fresh",
missingConversation: true,
};
}
if (finalCommand && SHELL_COMMANDS.has(finalCommand)) {
return {
ok: false,
@@ -920,6 +920,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
ok: false,
error: "Codex resume failed: no saved session found for the requested session",
recovery: "retry_fresh",
missingConversation: true,
};
}
@@ -234,7 +234,7 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
if (!this.fs.exists(opts.resumeToken)) {
// Session file gone — the honest outcome is the caller's stop-and-ask
// (awaiting-decision), never a silent fresh start (BR-6).
return { ok: false, error: `${this.runtime} resume: the persisted session file no longer exists`, recovery: "retry_fresh" };
return { ok: false, error: `${this.runtime} resume: the persisted session file no longer exists`, recovery: "retry_fresh", missingConversation: true };
}
}
+16 -2
View File
@@ -39,11 +39,25 @@ export interface TmuxFileOps {
bufferName(): string;
}
function defaultTmuxFileOps(): TmuxFileOps {
// One source for the staged script's name: the generator (defaultTmuxFileOps) and the matcher
// (isStagedSendScriptPath) are both built from it.
const SEND_SCRIPT_PREFIX = "openrig-tmux-send-";
const SEND_SCRIPT_SUFFIX = ".txt";
const SEND_SCRIPT_NAME = new RegExp(
`^${SEND_SCRIPT_PREFIX}\\d+-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}${SEND_SCRIPT_SUFFIX.replace(".", "\\.")}$`,
);
/** Whether `path` names a launch script sendShellCommand staged (an absolute path with the name
* defaultTmuxFileOps gives it). The script removes itself first, so this is a name check only. */
export function isStagedSendScriptPath(path: string): boolean {
return path.startsWith("/") && SEND_SCRIPT_NAME.test(path.split("/").pop() ?? "");
}
export function defaultTmuxFileOps(): TmuxFileOps {
return {
writeFile: (p, content, options) => fsWriteFile(p, content, { encoding: "utf8", ...options }),
unlink: (p) => fsUnlink(p),
tmpName: () => pathJoin(tmpdir(), `openrig-tmux-send-${process.pid}-${randomUUID()}.txt`),
tmpName: () => pathJoin(tmpdir(), `${SEND_SCRIPT_PREFIX}${process.pid}-${randomUUID()}${SEND_SCRIPT_SUFFIX}`),
bufferName: () => `openrig_${process.pid}_${randomUUID().replace(/-/g, "")}`,
};
}
@@ -1432,7 +1432,10 @@ export class RestoreOrchestrator {
};
}
if (isPodAware && resumeRequested) {
if (startupResult.recovery === "retry_fresh") {
// Only the runtime's own report that the conversation doesn't exist rolls the seat back
// as unresumable. A pane that went back to its shell for another reason stays failed,
// with its pane and binding kept, so its output can still be read.
if (startupResult.recovery === "retry_fresh" && startupResult.missingConversation) {
await this.rollbackToZeroSession(node.id, sessionName, launchResult?.session.id, priorState);
return {
nodeId: node.id,
@@ -102,7 +102,10 @@ export type HarnessLaunchResult =
// `evidence` carries the last-N pane lines for `attention_required` outcomes
// so the failure can flow honest evidence through to RestoreNodeResult's
// attentionEvidence field. Omitted for non-attention recoveries.
| { ok: false; error: string; recovery?: HarnessLaunchRecovery; evidence?: string;
// `missingConversation` is set only when the runtime itself reported that the requested
// conversation doesn't exist; other `retry_fresh` failures (such as a pane back at its shell)
// leave it unset.
| { ok: false; error: string; recovery?: HarnessLaunchRecovery; evidence?: string; missingConversation?: true;
launchedProcess?: import("./native-process-lineage.js").ClaudeLaunchedProcess };
// -- Shared concrete-hint resolver --
@@ -3,7 +3,7 @@ import fs from "node:fs";
import nodePath from "node:path";
import Database from "better-sqlite3";
import type BetterSqlite3 from "better-sqlite3";
import type { TmuxAdapter } from "../adapters/tmux.js";
import { isStagedSendScriptPath, type TmuxAdapter } from "../adapters/tmux.js";
import type { ArbitratedSeatState } from "./activity-taxonomy.js";
import type { EventBus } from "./event-bus.js";
import type { SessionRegistry } from "./session-registry.js";
@@ -395,9 +395,13 @@ export class CodexInPlaceRestart {
const top = chain.at(-1)!;
const root = rows.find((row) => row.pid === panePid);
let paneShape: PaneShape;
// OpenRig types a Codex launch (and this restart's relaunch) as `/bin/sh <staged script>`, so a managed
// Codex runs one sh below the pane's shell. Only that script is accepted between them.
const between = rows.find((row) => row.pid === top.ppid);
const viaLaunchScript = !!between && between.ppid === panePid && isStagedLaunchScript(between);
if (top.pid === panePid) paneShape = "direct_native";
else if (root && top.ppid === panePid && isShellForeground(executableBase(root))) paneShape = "shell_backed";
else return { ok: false, code: "pane_shape_unknown", message: "The pane is neither a shell running Codex nor Codex itself; nothing was stopped." };
else if (root && (top.ppid === panePid || viaLaunchScript) && isShellForeground(executableBase(root))) paneShape = "shell_backed";
else return { ok: false, code: "pane_shape_unknown", message: "The pane is neither a shell running Codex (directly or through OpenRig's own launch script) nor Codex itself; nothing was stopped." };
// Where the relaunched Codex would run, and with which home.
let cwd: string;
@@ -525,25 +529,34 @@ export class CodexInPlaceRestart {
}
private async verifyReplacement(target: InPlaceRestartTarget, report: InPlaceRestartReport, oldExe: string | null): Promise<InPlaceRestartReport> {
const identity = await rebindAndVerifyPaneIdentity({
db: this.deps.db, sessionRegistry: this.deps.sessionRegistry, tmux: this.deps.tmux,
nodeId: target.seat.nodeId, sessionName: target.sessionName, runtime: "codex",
expectedResumeToken: report.conversationId, requireExactResumeLineage: true, listProcesses: this.listProcesses,
});
const now = await this.observeCodex(report.pane, report.conversationId);
if (!identity.ok || !now) {
return { ...report, outcome: "native_unknown", couldNotSee: [`the replacement Codex's identity: ${identity.ok ? "process not observed" : identity.detail}`],
message: "Codex reported a resumed conversation, but the process in the pane could not be proven to be that conversation." };
// Screen readiness can precede native-process visibility. Repeat only the joined
// observations, never the launch. The budget counts yields, not observation I/O.
const polls = Math.max(1, Math.ceil(this.timing.timeoutMs / this.timing.pollMs));
for (let attempt = 0; ; attempt++) {
const identity = await rebindAndVerifyPaneIdentity({
db: this.deps.db, sessionRegistry: this.deps.sessionRegistry, tmux: this.deps.tmux,
nodeId: target.seat.nodeId, sessionName: target.sessionName, runtime: "codex",
expectedResumeToken: report.conversationId, requireExactResumeLineage: true, listProcesses: this.listProcesses,
});
const now = await this.observeCodex(report.pane, report.conversationId);
if (!identity.ok || !now) {
if (attempt < polls) {
await this.timing.sleep(this.timing.pollMs);
continue;
}
return { ...report, outcome: "native_unknown", couldNotSee: [`the replacement Codex's identity: ${identity.ok ? "process not observed" : identity.detail}`],
message: "Codex reported a resumed conversation, but the process in the pane could not be proven to be that conversation." };
}
const after = await this.io.readProcess(now.process.pid);
const before = report.oldProcess;
const startedAt = now.process.startedAt ?? null;
return {
...report, outcome: "restarted_account_unverified",
newProcess: { pid: now.process.pid, startedAt, sameExecutable: after?.exe && oldExe ? after.exe === oldExe : null },
account: this.accountEvidence(report.codexHome, before.startedAt, startedAt),
message: "Restarted in place on the same conversation and pane. The account is not verified: run --verify after a real tool-using turn.",
};
}
const after = await this.io.readProcess(now.process.pid);
const before = report.oldProcess;
const startedAt = now.process.startedAt ?? null;
return {
...report, outcome: "restarted_account_unverified",
newProcess: { pid: now.process.pid, startedAt, sameExecutable: after?.exe && oldExe ? after.exe === oldExe : null },
account: this.accountEvidence(report.codexHome, before.startedAt, startedAt),
message: "Restarted in place on the same conversation and pane. The account is not verified: run --verify after a real tool-using turn.",
};
}
/** A direct-native stop or respawn that failed: report what the pane and the original process are now,
@@ -727,6 +740,16 @@ function executableBase(row: NativeProcessRow): string {
return (name.split("/").pop() ?? name).replace(/^-/, "");
}
/** The shells `/bin/sh` can run as. macOS's /bin/sh is a shim that runs bash (or dash or zsh, if so
* configured), so the OS names the staged script's process after that shell, not sh. */
const SH_RUNS_AS = new Set(["sh", "bash", "dash", "zsh"]);
/** `/bin/sh <path>` running a launch script that OpenRig's tmux adapter staged, and nothing else. */
function isStagedLaunchScript(row: NativeProcessRow): boolean {
const argv = processArgv(row.command);
return SH_RUNS_AS.has(executableBase(row)) && argv.length === 2 && /^(?:\/bin\/)?sh$/.test(argv[0]!) && isStagedSendScriptPath(argv[1]!);
}
/** The Codex process and its Codex launcher ancestors, deepest first, up to the pane root. */
function codexChain(rows: NativeProcessRow[], deepest: NativeProcessRow, panePid: number): NativeProcessRow[] {
const byPid = new Map(rows.map((row) => [row.pid, row]));
@@ -104,7 +104,7 @@ export type StartupResult = { warnings?: string[] } & (
// outcomes so restore-orchestrator's per-node mapping can populate
// `attentionEvidence` on the RestoreNodeResult. Internal type only;
// not persisted on the failure event.
| { ok: false; startupStatus: "attention_required" | "failed"; errors: string[]; evidence?: string; recovery?: string });
| { ok: false; startupStatus: "attention_required" | "failed"; errors: string[]; evidence?: string; recovery?: string; missingConversation?: true });
interface StartupOrchestratorDeps {
db: Database.Database;
@@ -392,7 +392,7 @@ export class StartupOrchestrator {
}
errors.push(`Harness launch failed: ${launchResult.error}`);
return this.fail(input, "failed", errors, undefined, false, launchResult.recovery);
return this.fail(input, "failed", errors, undefined, false, launchResult.recovery, launchResult.missingConversation === true);
}
} catch (err) {
errors.push(`Harness launch error: ${(err as Error).message}`);
@@ -632,6 +632,7 @@ export class StartupOrchestrator {
evidence?: string,
freshContextPending = false,
recovery?: string,
missingConversation = false,
): StartupResult {
if (status === "attention_required" && freshContextPending && input.binding.tmuxSession) {
errors.push(`After resolving it in ${input.binding.tmuxSession}, run: rig seat continue ${shellQuote(input.binding.tmuxSession)}`);
@@ -646,7 +647,8 @@ export class StartupOrchestrator {
...(freshContextPending ? { freshContextPending: true } : {}),
...(input.submissionDiagnostics?.length ? { submissionDiagnostics: input.submissionDiagnostics } : {}),
});
return { ok: false, startupStatus: status, errors, evidence, ...(recovery ? { recovery } : {}) };
return { ok: false, startupStatus: status, errors, evidence, ...(recovery ? { recovery } : {}),
...(missingConversation ? { missingConversation: true as const } : {}) };
}
private async executeActions(
+7 -1
View File
@@ -1099,7 +1099,13 @@ export function queueRoutes(): Hono {
if (!item) return c.json({ error: "qitem_not_found" }, 404);
// #1029: the wake path's own routing decision, so a reader never guesses a registered
// person's alias from its spelling (there may be no wake result yet, e.g. --no-nudge).
return c.json({ ...item, destinationClass: repo.classifyDestinationOf(item.destinationSession).class });
// The owner notification level is what the Slack selector keys on: a registered person who
// is also a seat with a pane is pane-bound, yet the gateway may still post the row to them.
return c.json({
...item,
destinationClass: repo.classifyDestinationOf(item.destinationSession).class,
ownerNotificationLevel: repo.transitionLog.latestOwnerNotificationForQitem(qitemId)?.ownerNotificationLevel ?? null,
});
});
// ---- Inbox routes (mailbox) ----
@@ -123,3 +123,113 @@ describe("managed Claude full down/up", () => {
},
);
});
// A pod-aware resume rolls a seat back as unresumable only on the runtime's own report that the
// conversation doesn't exist. A Claude that returns to its shell for another reason stays failed,
// with its pane and binding kept so its output can still be read.
describe("pod-aware Claude resume failure outcomes", () => {
const dbs: ReturnType<typeof createFullTestDb>[] = [];
afterEach(() => { for (const db of dbs.splice(0)) db.close(); });
const shellScreens: Record<string, string> = {
missing: `$ claude --resume ${token}\nNo conversation found with session ID: ${token}\nuser@host:~/project$ `,
"generic-exit": `$ claude --resume ${token}\nError: Claude exited before its interface opened\nuser@host:~/project$ `,
};
it.each(["generic-exit", "missing", "attention", "success"])("%s", async (mode) => {
const db = createFullTestDb(); dbs.push(db);
const rigRepo = new RigRepository(db);
const sessionRegistry = new SessionRegistry(db);
const eventBus = new EventBus(db);
const snapshotRepo = new SnapshotRepository(db);
const checkpointStore = new CheckpointStore(db);
const snapshotCapture = new SnapshotCapture({ db, rigRepo, sessionRegistry, eventBus, snapshotRepo, checkpointStore });
const rig = rigRepo.createRig("restore-test");
db.prepare("INSERT INTO pods (id, rig_id, label) VALUES (?, ?, ?)").run("restore-pod", rig.id, "Test");
const node = rigRepo.addNode(rig.id, "test.c", { runtime: "claude-code", podId: "restore-pod" });
new NativePermissionStore(db).write(node.id, { runtime: "claude-code", mode: "auto" }, "fixture", "retained posture");
const name = "test-c@restore-test";
const old = sessionRegistry.registerSession(node.id, name);
sessionRegistry.updateStatus(old.id, "running");
sessionRegistry.updateResumeToken(old.id, "claude_id", token, "scrape");
sessionRegistry.updateBinding(node.id, { tmuxSession: name, tmuxPane: "%old" });
db.prepare("INSERT INTO node_startup_context (node_id, projection_entries_json, resolved_files_json, startup_actions_json, runtime) VALUES (?, ?, ?, ?, ?)").run(node.id, "[]", "[]", "[]", "claude-code");
const atShell = mode === "generic-exit" || mode === "missing";
let live = true;
const tmux = {
hasSession: vi.fn(async () => live),
probeSession: vi.fn(async () => ({ state: live ? "present" : "absent" })),
killSession: vi.fn(async () => { live = false; return { ok: true }; }),
createSession: vi.fn(async () => { live = true; return { ok: true }; }),
listSessions: vi.fn(async () => live ? [{ name }] : []),
listWindows: vi.fn(async () => []),
listPanes: vi.fn(async () => live ? [{ id: "%new", index: 0, cwd: "/", width: 80, height: 24, active: true }] : []),
getPanePid: vi.fn(async () => 100),
getPaneCommand: vi.fn(async () => atShell ? "bash" : "sh"),
// A killed session has no screen to read.
capturePaneContent: vi.fn(async () => !live ? null : atShell ? shellScreens[mode] : autoScreen),
sendText: vi.fn(async () => ({ ok: true })),
sendShellCommand: vi.fn(async () => ({ ok: true })),
sendKeys: vi.fn(async () => ({ ok: true })),
} as unknown as TmuxAdapter;
const startedAt = "Thu Oct 1 05:53:16 2026";
const shell = { pid: 100, ppid: 1, pgid: 100, tpgid: mode === "success" ? 101 : 100, executableName: "bash", command: "-bash", startedAt };
const listProcesses = async () => mode !== "success" ? [shell] : [
shell,
{ pid: 101, ppid: 100, pgid: 101, tpgid: 101, executableName: "sh", command: "/bin/sh /tmp/fixture-launch.txt", startedAt },
{ pid: 102, ppid: 101, pgid: 101, tpgid: 101, executableName: "claude", command: `/opt/claude.exe --permission-mode auto --resume ${token} --name ${name}`, startedAt },
];
const down = await new RigTeardownOrchestrator({ db, rigRepo, sessionRegistry, eventBus, snapshotCapture, tmuxAdapter: tmux }).teardown(rig.id);
expect(down.errors).toEqual([]);
const adapter = new ClaudeCodeAdapter({ tmux, listProcesses, sleep: async () => {},
claudeManagedLaunch: {
prepare: async () => ({ command: (args: readonly string[]) => `claude ${args.join(" ")}`, assertCurrent: () => {}, configDir: "/fixture", executable: "/opt/claude.exe" }),
configPaths: () => ({ configDir: "/fixture", statePath: "/fixture/.claude.json" }),
boundCwd: () => "/",
} as unknown as ClaudeManagedLaunch,
fsOps: { exists: () => false, readFile: () => "", writeFile: () => {}, mkdirp: () => {}, copyFile: () => {} } });
const nodeLauncher = new NodeLauncher({ db, rigRepo, sessionRegistry, eventBus, tmuxAdapter: tmux });
const restore = new RestoreOrchestrator({ db, rigRepo, sessionRegistry, eventBus, snapshotRepo, snapshotCapture,
checkpointStore, nodeLauncher, tmuxAdapter: tmux, claudeResume: new ClaudeResumeAdapter(tmux),
codexResume: new CodexResumeAdapter(tmux), listProcesses });
vi.mocked(tmux.killSession).mockClear();
const up = await restore.restore(down.snapshotId!, { adapters: { "claude-code": adapter } });
expect(up.ok).toBe(true);
if (!up.ok) throw new Error(up.message);
const outcome = up.result.nodes[0]!;
const latest = db.prepare("SELECT id, status, startup_status FROM sessions WHERE node_id = ? ORDER BY id DESC LIMIT 1").get(node.id) as { id: string; status: string; startup_status: string };
const binding = sessionRegistry.getBindingForNode(node.id);
// The earlier conversation's token is kept in every case.
expect(db.prepare("SELECT status, resume_type, resume_token FROM sessions WHERE id = ?").get(old.id))
.toEqual({ status: "exited", resume_type: "claude_id", resume_token: token });
if (mode === "generic-exit") {
expect(outcome.status).toBe("failed");
expect(outcome.error).toContain("pane returned to shell instead of entering Claude");
expect(outcome.error).not.toMatch(/unresumable/i);
expect(up.result.rigResult).toBe("failed");
expect(tmux.killSession).not.toHaveBeenCalled();
expect(live).toBe(true);
expect(latest).toMatchObject({ status: "running", startup_status: "failed" });
expect(binding).toMatchObject({ tmuxSession: name, tmuxPane: "%new" });
} else if (mode === "missing") {
expect(outcome.status).toBe("awaiting-decision");
expect(outcome.error).toContain("Original session unresumable");
expect(outcome.error).toContain("no conversation found for the requested session");
expect(up.result.rigResult).toBe("partially_restored");
expect(tmux.killSession).toHaveBeenCalledExactlyOnceWith(name);
expect(live).toBe(false);
expect(latest).toMatchObject({ status: "superseded", startup_status: "failed" });
expect(binding).toBeNull();
} else if (mode === "attention") {
expect(outcome.status).toBe("attention_required");
expect(tmux.killSession).not.toHaveBeenCalled();
expect(latest).toMatchObject({ status: "running", startup_status: "attention_required" });
expect(binding).toMatchObject({ tmuxSession: name, tmuxPane: "%new" });
} else {
expect(outcome.status).toBe("resumed");
expect(tmux.killSession).not.toHaveBeenCalled();
expect(latest).toMatchObject({ status: "running", startup_status: "ready" });
expect(binding).toMatchObject({ tmuxSession: name, tmuxPane: "%new" });
}
});
});
@@ -607,6 +607,42 @@ describe("Claude Code runtime adapter", () => {
ok: false,
error: "Claude resume failed: no conversation found for the requested session",
recovery: "retry_fresh",
missingConversation: true,
});
});
it("launchHarness marks a missing conversation that first shows on the read after the wait", async () => {
const tmux = mockTmux();
(tmux.getPaneCommand as ReturnType<typeof vi.fn>).mockResolvedValue("zsh");
const capture = tmux.capturePaneContent as ReturnType<typeof vi.fn>;
// The wait reads the pane 16 times; the report first appears on the final read after it.
for (let read = 0; read < 16; read++) capture.mockResolvedValueOnce("claude --resume abc-123\[email protected] %");
capture.mockResolvedValue("No conversation found with session ID: abc-123\[email protected] %");
const adapter = new ClaudeCodeAdapter({ tmux, fsOps: mockFs(), sleep: async () => {} });
const result = await adapter.launchHarness(makeBinding(), { name: "dev-impl@test-rig", resumeToken: "abc-123" });
expect(capture).toHaveBeenCalledTimes(17);
expect(result).toEqual({
ok: false,
error: "Claude resume failed: no conversation found for the requested session",
recovery: "retry_fresh",
missingConversation: true,
});
});
it("launchHarness leaves a pane that is back at its shell unmarked", async () => {
const tmux = mockTmux();
(tmux.getPaneCommand as ReturnType<typeof vi.fn>).mockResolvedValue("zsh");
(tmux.capturePaneContent as ReturnType<typeof vi.fn>).mockResolvedValue("Error: Claude exited\[email protected] %");
const adapter = new ClaudeCodeAdapter({ tmux, fsOps: mockFs(), sleep: async () => {} });
const result = await adapter.launchHarness(makeBinding(), { name: "dev-impl@test-rig", resumeToken: "abc-123" });
expect(result).toEqual({
ok: false,
error: "Claude resume failed: pane returned to shell instead of entering Claude",
recovery: "retry_fresh",
});
});
@@ -1074,6 +1074,7 @@ describe("Codex runtime adapter", () => {
ok: false,
error: "Codex resume failed: no saved session found for the requested session",
recovery: "retry_fresh",
missingConversation: true,
});
});
@@ -571,3 +571,49 @@ describe("GET /api/queue/:qitemId carries the destination class", () => {
h.db.close();
});
});
// #1029 follow-up — the single-row read also carries the owner-notification level the Slack
// selector keys on, so `--verify` keeps the Slack wait for a registered person who also has a pane.
describe("GET /api/queue/:qitemId carries the owner notification level", () => {
const PANE_PERSON = { ...FOUNDER_FRAGMENT, entityId: "dev-a", displayName: "Dev A", address: "dev-a@external" } as unknown as HumanFragment;
it.each([
["a registered person with a pane", "dev-a@rig1", [FOUNDER_FRAGMENT, PANE_PERSON], "pane-bound", "ALERT"],
["a seat with a pane and no registration", "dev-a@rig1", [FOUNDER_FRAGMENT], "pane-bound", null],
["a registered person's alias", "human-founder@kernel", [FOUNDER_FRAGMENT], "gateway-routable", "ALERT"],
] as const)("%s reads level %s", async (_label, destinationSession, entities, destinationClass, level) => {
const h = makeHarness({ entities: () => [...entities] });
const app = new Hono();
app.use("*", async (c, next) => { c.set("queueRepo" as never, h.repo); await next(); });
app.route("/api/queue", queueRoutes());
const item = await h.repo.create({
sourceSession: "dev-a@rig1", destinationSession, nudge: false,
summary: "Routing", evidenceRef: EVIDENCE, body: "b",
});
const row = await (await app.request(`/api/queue/${item.qitemId}`)).json() as Record<string, unknown>;
expect(row.destinationClass).toBe(destinationClass);
expect(row.ownerNotificationLevel).toBe(level);
expect(h.repo.getById(item.qitemId)).not.toHaveProperty("ownerNotificationLevel");
h.db.close();
});
it("a pane-bound registered person's row reaches the Slack selector, and its receipt shows on the row read", async () => {
const h = makeHarness({ entities: () => [FOUNDER_FRAGMENT, PANE_PERSON] });
const app = new Hono();
app.use("*", async (c, next) => { c.set("queueRepo" as never, h.repo); await next(); });
app.route("/api/queue", queueRoutes());
const item = await h.repo.create({
sourceSession: "s@r", destinationSession: "dev-a@rig1", nudge: false,
summary: "Decide", evidenceRef: EVIDENCE, body: "b",
});
const alerts = await makeQueuePorts(h.repo, { loadHumanRegistry: () => ({ ok: true, entities: [FOUNDER_FRAGMENT, PANE_PERSON] }) }).listHumanAlerts({});
expect(alerts.map((a) => a.qitemId)).toContain(item.qitemId);
const owner = h.repo.transitionLog.latestOwnerNotificationForQitem(item.qitemId)!;
h.repo.update({
qitemId: item.qitemId, actorSession: "daemon@kernel",
transitionNote: `slack-owner-notification-posted notification_key=${item.qitemId}:${owner.transitionId} level=ALERT kind=unclassified message_ts=1.1 thread_ts=1.1`,
});
const row = await (await app.request(`/api/queue/${item.qitemId}`)).json() as Record<string, unknown>;
expect(row).toMatchObject({ destinationClass: "pane-bound", ownerNotificationLevel: "ALERT", deliveryOutcome: "posted" });
h.db.close();
});
});
@@ -2265,7 +2265,7 @@ describe("RestoreOrchestrator", () => {
db.prepare("DELETE FROM bindings WHERE node_id = ?").run(node.id);
const launchHarness = vi.fn()
.mockResolvedValueOnce({ ok: false as const, error: "Claude resume failed: no conversation found for the requested session", recovery: "retry_fresh" })
.mockResolvedValueOnce({ ok: false as const, error: "Claude resume failed: no conversation found for the requested session", recovery: "retry_fresh", missingConversation: true as const })
.mockResolvedValueOnce({ ok: true as const, resumeToken: "fresh-claude-token", resumeType: "claude_id" });
const mockAdapter = {
runtime: "claude-code",
@@ -2345,7 +2345,7 @@ describe("RestoreOrchestrator", () => {
db.prepare("DELETE FROM bindings WHERE node_id = ?").run(node.id);
const launchHarness = vi.fn()
.mockResolvedValueOnce({ ok: false as const, error: "Codex resume failed: no saved session found for the requested session", recovery: "retry_fresh" })
.mockResolvedValueOnce({ ok: false as const, error: "Codex resume failed: no saved session found for the requested session", recovery: "retry_fresh", missingConversation: true as const })
.mockResolvedValueOnce({ ok: true as const, resumeToken: "fresh-codex-token", resumeType: "codex_id" });
const mockAdapter = {
runtime: "codex",
@@ -2382,7 +2382,7 @@ describe("RestoreOrchestrator", () => {
db.prepare("DELETE FROM bindings WHERE node_id = ?").run(node.id);
const launchHarness = vi.fn()
.mockResolvedValueOnce({ ok: false as const, error: "Codex resume failed: no saved session found for the requested session", recovery: "retry_fresh" })
.mockResolvedValueOnce({ ok: false as const, error: "Codex resume failed: no saved session found for the requested session", recovery: "retry_fresh", missingConversation: true as const })
.mockResolvedValueOnce({ ok: true as const, resumeToken: "stable-codex-token", resumeType: "codex_id" });
const mockAdapter = {
runtime: "codex",
@@ -2426,7 +2426,7 @@ describe("RestoreOrchestrator", () => {
db.prepare("DELETE FROM bindings WHERE node_id = ?").run(node.id);
const launchHarness = vi.fn()
.mockResolvedValueOnce({ ok: false as const, error: "Codex resume failed: no saved session found for the requested session", recovery: "retry_fresh" })
.mockResolvedValueOnce({ ok: false as const, error: "Codex resume failed: no saved session found for the requested session", recovery: "retry_fresh", missingConversation: true as const })
.mockResolvedValueOnce({ ok: true as const, resumeToken: "fresh-codex-token", resumeType: "codex_id" });
const mockAdapter = {
runtime: "codex",
@@ -93,7 +93,9 @@ describe.runIf(available())("in-place restart, native (private tmux socket, stub
/** Each word single-quoted for the harness's sh, so tmux receives them as separate argv words. */
const argv = (...words: string[]) => words.map(q).join(" ");
async function setup(shape: "shell_backed" | "direct_native", opts: { running?: { networkOverride: boolean; daemonOptOut: boolean; addDirs: string[] }; quiet?: boolean; prelude?: string } = {}) {
async function setup(shape: "shell_backed" | "direct_native", opts: { running?: { networkOverride: boolean; daemonOptOut: boolean; addDirs: string[] }; quiet?: boolean; prelude?: string;
/** Shell-backed: type the launch the way OpenRig does (tmux adapter's staged `/bin/sh <script>`). */
staged?: boolean } = {}) {
// One seat at a time: end the previous test's session and wait for its stub to exit.
for (const name of sessions) await tmux(`kill-session -t ${q(name)}`).catch(() => {});
for (let i = 0; i < 50 && stubPids().length > 0; i++) await sleep(100);
@@ -141,8 +143,14 @@ describe.runIf(available())("in-place restart, native (private tmux socket, stub
if (shape === "shell_backed") {
await tmux(`-f /dev/null new-session -d -s ${q(seat)} -x 120 -y 30 -c ${q(work)} ${env} ${argv(...cleanStart)}`);
await sleep(200);
await tmux(`send-keys -t ${q(seat)} -l -- ${q(`${opts.prelude ?? ""}${preflight} && ${built.command} || echo PREFLIGHT-REFUSED`)}`);
await tmux(`send-keys -t ${q(seat)} Enter`);
const launch = `${opts.prelude ?? ""}${preflight} && ${built.command} || echo PREFLIGHT-REFUSED`;
if (opts.staged) {
const sent = await realTmux.sendShellCommand(seat, launch);
if (!sent.ok) throw new Error(sent.message);
} else {
await tmux(`send-keys -t ${q(seat)} -l -- ${q(launch)}`);
await tmux(`send-keys -t ${q(seat)} Enter`);
}
} else {
const script = `${preflight} || { echo PREFLIGHT-REFUSED; exec sleep 30; }; exec ${built.command}`;
await tmux(`-f /dev/null new-session -d -s ${q(seat)} -x 120 -y 30 -c ${q(work)} ${env} ${argv(...cleanStart, "-c", script)}`);
@@ -195,6 +203,28 @@ describe.runIf(available())("in-place restart, native (private tmux socket, stub
expect(verified.body).toMatchObject({ ok: true, outcome: "restarted_account_unverified", accountEvidence: { state: "not_requested" } });
}, 60_000);
it("a Codex launched the way OpenRig launches it (staged sh script), restarted twice in the same pane", async () => {
const { pane, post } = await setup("shell_backed", { staged: true });
const shell = await panePid(pane);
const [oldStub] = stubPids();
const parentOf = (pid: number) => Number(fs.readFileSync(`/proc/${pid}/stat`, "utf8").replace(/^.*\) /, "").split(" ")[1]);
const script = parentOf(oldStub!);
expect(script, "a staged script sits between the shell and Codex").not.toBe(shell);
expect(parentOf(script)).toBe(shell);
expect(fs.readFileSync(`/proc/${script}/cmdline`, "utf8")).toMatch(/openrig-tmux-send-\d+-[0-9a-f-]+\.txt/);
for (const round of [1, 2]) {
const plan = await post("restart-plan");
expect(plan.body, `round ${round}: ${JSON.stringify(plan.body)}`).toMatchObject({ ok: true, paneShape: "shell_backed" });
const result = await post("restart", { reason: `native harness staged round ${round}` });
expect(result.status, JSON.stringify(result.body)).toBe(200);
expect(result.body).toMatchObject({ outcome: "restarted_account_unverified", pane, paneShape: "shell_backed" });
for (const pid of stubPids()) pids.add(pid);
expect(stubPids(), `round ${round}: only the replacement stub runs`).toEqual([result.body.newProcess.pid]);
expect(await panePid(pane), "same pane shell").toBe(shell);
}
}, 90_000);
it("direct-native: respawned without -k, remain-on-exit put back after the verified replacement", async () => {
const { seat, pane, post } = await setup("direct_native");
const before = await panePid(pane);
@@ -206,11 +236,16 @@ describe.runIf(available())("in-place restart, native (private tmux socket, stub
for (const pid of stubPids()) pids.add(pid);
const after = await panePid(pane);
expect(after).not.toBe(before);
expect(stubPids(), "only the replacement stub runs").toEqual([after]);
// The replacement is the respawned pane's root, or its child where the image's sh doesn't exec a lone
// command (review-r2's dash): compare with the reported process, and tie it to the pane separately.
const replacement = result.body.newProcess.pid as number;
expect(stubPids(), "only the replacement stub runs").toEqual([replacement]);
const parent = Number(fs.readFileSync(`/proc/${replacement}/stat`, "utf8").replace(/^.*\) /, "").split(" ")[1]);
expect([replacement, parent], "the replacement is the respawned pane's root or its direct child").toContain(after);
// tmux resets the visible screen on respawn; the original's earlier output is kept in scrollback.
const screen = await tmux(`capture-pane -p -t ${q(pane)} -S -300`);
expect(screen, "the original's scrollback stays in the pane").toContain(`from pid ${before}`);
expect(screen).toContain(`stub conversation ${TOKEN} pid ${after}`);
expect(screen).toContain(`stub conversation ${TOKEN} pid ${replacement}`);
expect((await tmux(`show-options -p -v -t ${q(pane)} remain-on-exit`)).trim(), "no pane-local setting left behind").toBe("");
expect((await tmux(`list-panes -t ${q(seat)} -F '#{pane_id}'`)).trim()).toBe(pane);
}, 60_000);
@@ -1,5 +1,6 @@
// OPR.0.7.0.5 — in-place restart of one Codex seat: every gate and every outcome, driven through the
// public SeatLifecycleService surface against a fake tmux, process table, Codex launcher and OS reads.
import { defaultTmuxFileOps, isStagedSendScriptPath } from "../src/adapters/tmux.js";
import { describe, it, expect, afterEach } from "vitest";
import type Database from "better-sqlite3";
import { createFullTestDb } from "./helpers/test-app.js";
@@ -26,6 +27,11 @@ interface WorldOptions {
shape?: Shape;
runningArgs?: string;
savedModel?: string | null;
/** Shell-backed only: the command line of a process between the pane shell and Codex (OpenRig's
* staged launch script, or something else). */
between?: string;
/** The OS's name for that process (ps ucomm); defaults to argv[0]'s basename. */
betweenName?: string;
activity?: ArbitratedSeatState | null;
launch?: HarnessLaunchResult;
codexIgnoresSignals?: boolean;
@@ -41,6 +47,10 @@ interface WorldOptions {
screenBefore?: string;
throwAt?: "signalPid" | "sendResumeCommand" | "readProcessAfter" | "signalPaneProcess" | "respawnPane" | "verifyResumeInPane" | "restorePaneOption";
daemonStartedAt?: Date;
/** Only post-relaunch process observations; preflight and stop retain the existing table. */
replacementRows?: (read: number, rows: NativeProcessRow[]) => NativeProcessRow[];
replacementTiming?: { pollMs: number; timeoutMs: number };
afterReplacementYield?: (ms: number) => void | Promise<void>;
/** The pane hold fails: the remain-on-exit write errors, or it doesn't read back as on. */
holdFails?: "write" | "readback";
/** The pane disappears when its process is signalled, and tmux doesn't report it dead (review-r2's case). */
@@ -76,7 +86,10 @@ function world(options: WorldOptions = {}) {
const runningArgs = options.runningArgs ?? `-m gpt-5.5 -s workspace-write resume ${TOKEN}`;
let rows: NativeProcessRow[] = shape === "shell_backed"
? [shellRow(100, 200), codexRow(200, 100, runningArgs)]
? options.between
? [shellRow(100, 150), { pid: 150, ppid: 100, pgid: 150, tpgid: 150, executableName: options.betweenName ?? options.between.split(" ")[0]!.split("/").pop()!, command: options.between, startedAt: LSTART },
codexRow(200, 150, runningArgs, LSTART, 150)]
: [shellRow(100, 200), codexRow(200, 100, runningArgs)]
: [codexRow(100, 1, runningArgs, LSTART, 100)];
let paneCommand = "codex";
let dead = false;
@@ -85,6 +98,8 @@ function world(options: WorldOptions = {}) {
const calls: string[] = [];
const paneLines: string[] = (options.screenBefore ?? "").split("\n").filter(Boolean);
let relaunched = false;
let replacementReads = 0;
const replacementYields: number[] = [];
let thrown = false;
const maybeThrow = (at: WorldOptions["throwAt"]) => {
if (options.throwAt === at && !thrown) { thrown = true; throw new Error(`injected failure at ${at}`); }
@@ -109,7 +124,8 @@ function world(options: WorldOptions = {}) {
signalPid: async (pid: number, signal: string): Promise<TmuxResult> => {
calls.push(`signalPid ${pid} ${signal}`);
maybeThrow("signalPid");
if (!options.codexIgnoresSignals) { rows = rows.filter((row) => row.executableName !== "codex"); paneCommand = "bash"; }
// The launch script ends with Codex, so its sh exits too.
if (!options.codexIgnoresSignals) { rows = rows.filter((row) => row.executableName !== "codex" && row.pid !== 150); paneCommand = "bash"; }
return { ok: true };
},
signalPaneProcess: async (_pane: string, signal: string): Promise<TmuxResult> => {
@@ -178,15 +194,27 @@ function world(options: WorldOptions = {}) {
const service = new SeatLifecycleService({
db, rigRepo, sessionRegistry, eventBus, tmuxAdapter: tmux,
runtimeAdapters: { codex: codex as never },
listProcesses: () => (options.identityFails && rows.some((row) => row.pid === 300) ? [] : rows),
listProcesses: () => {
if (relaunched) {
replacementReads++;
if (options.replacementRows) return options.replacementRows(replacementReads, rows);
}
return options.identityFails && rows.some((row) => row.pid === 300) ? [] : rows;
},
activityOracle: { declareOccupantSwap: () => {}, getSeatState: () => options.activity ?? null },
inPlaceRestartIo: io,
inPlaceRestartTiming: { pollMs: 1, timeoutMs: 2, sleep: async () => {} },
inPlaceRestartTiming: { pollMs: 1, timeoutMs: 2, ...options.replacementTiming, sleep: async ms => {
if (relaunched) {
replacementYields.push(ms);
await options.afterReplacementYield?.(ms);
}
} },
inPlaceRestartDaemonStartedAt: options.daemonStartedAt ?? new Date("2000-01-01T00:00:00Z"),
});
const events = () => (db.prepare("SELECT type, payload FROM events WHERE type LIKE 'seat.restart_in_place%' ORDER BY seq").all() as Array<{ type: string; payload: string }>)
.map((row) => ({ type: row.type, ...JSON.parse(row.payload) }));
return { db, service, node, session, calls, launches, builds, events, sessionRegistry,
return { db, service, node, session, calls, launches, builds, events, sessionRegistry, tmux,
replacementYields, get replacementReads() { return replacementReads; }, get relaunched() { return relaunched; },
setRows: (next: NativeProcessRow[]) => { rows = next; },
print: (text: string) => { paneLines.push(...text.split("\n").filter(Boolean)); } };
}
@@ -239,6 +267,34 @@ describe("rig seat restart --keep-conversation: gates before any signal", () =>
expect(await cwd.service.restartInPlace({ seatRef: SEAT, plan: true })).toMatchObject({ code: "settings_drift", detail: { differs: ["cwd"] } });
});
it("accepts a Codex launched through OpenRig's staged launch script, and only that script, between shell and Codex", async () => {
const staged = "/bin/sh /tmp/openrig-tmux-send-1128548-5b249a92-40d6-40c4-b424-57b3b6980529.txt";
const w = world({ between: staged });
expect(await w.service.restartInPlace({ seatRef: SEAT, plan: true })).toMatchObject({ ok: true, paneShape: "shell_backed" });
// macOS (measured on Build): /bin/sh is a shim that runs bash, so ps names the process bash.
const mac = world({ between: "/bin/sh /var/folders/7x/abc123/T/openrig-tmux-send-4242-5b249a92-40d6-40c4-b424-57b3b6980529.txt", betweenName: "bash" });
expect(await mac.service.restartInPlace({ seatRef: SEAT, plan: true })).toMatchObject({ ok: true, paneShape: "shell_backed" });
// The real generator's names are what the matcher accepts.
const generated = defaultTmuxFileOps().tmpName();
expect(isStagedSendScriptPath(generated), generated).toBe(true);
const done = await w.service.restartInPlace({ seatRef: SEAT, reason: "new account" });
expect(done).toMatchObject({ ok: true, outcome: "restarted_account_unverified", paneShape: "shell_backed" });
expect(w.calls).toContain("signalPid 200 TERM");
for (const other of [
"/bin/sh /home/u/run-codex.sh",
"/bin/sh /tmp/openrig-tmux-send-1.txt",
"/bin/sh -c /tmp/openrig-tmux-send-1128548-5b249a92-40d6-40c4-b424-57b3b6980529.txt",
"/usr/bin/script -q /tmp/openrig-tmux-send-1128548-5b249a92-40d6-40c4-b424-57b3b6980529.txt",
"/bin/sh tmp/openrig-tmux-send-1128548-5b249a92-40d6-40c4-b424-57b3b6980529.txt",
"/bin/sh /tmp/openrig-tmux-send-1128548-5b249a92-40d6-40c4-b424-57b3b6980529.txt extra",
"/bin/bash /tmp/openrig-tmux-send-1128548-5b249a92-40d6-40c4-b424-57b3b6980529.txt",
]) {
const refused = world({ between: other });
expect(await refused.service.restartInPlace({ seatRef: SEAT, plan: true }), other).toMatchObject({ ok: false, code: "pane_shape_unknown" });
expect(refused.calls, other).toEqual([]);
}
});
it("refuses when the running Codex is on another conversation, or none can be proven", async () => {
const other = world({ runningArgs: "-m gpt-5.5 -s workspace-write resume 019a-other" });
expect(await other.service.restartInPlace({ seatRef: SEAT, plan: true })).toMatchObject({ ok: false, code: "resume_binding_unverified" });
@@ -594,3 +650,165 @@ describe("#1196 round 2: account evidence only from lines produced after the res
expect(JSON.stringify(finished)).not.toContain("example.com");
});
});
describe("post-relaunch identity observation window", () => {
// Synthetic process tables model the saved launcher-only observation, not native timing.
// Reuse the existing row builders and lifecycle fixture; only observation is staged.
const replacement = (shape: Shape, token = TOKEN): NativeProcessRow[] => {
const root = shape === "shell_backed" ? 100 : 300;
return [shellRow(root, 350),
{ pid: 350, ppid: root, pgid: 350, tpgid: 350, executableName: "sh", startedAt: LATER,
command: "/bin/sh /tmp/openrig-tmux-send-fixture.txt" },
{ pid: 351, ppid: 350, pgid: 350, tpgid: 350, executableName: "node", startedAt: LATER,
command: `/usr/bin/node /opt/codex/bin/codex.js resume ${token}` },
codexRow(400, 351, `-m gpt-5.5 -s workspace-write resume ${token}`, LATER, 350)];
};
const verdicts = (w: ReturnType<typeof world>) => w.db.prepare("SELECT verdict FROM observed_identity ORDER BY rowid")
.all() as Array<{ verdict: string }>;
const auditVerdicts = (w: ReturnType<typeof world>) => w.db.exec(`
CREATE TEMP TABLE observed_identity (verdict TEXT);
CREATE TEMP TRIGGER observe_identity_insert AFTER INSERT ON seat_identity_verdicts
BEGIN INSERT INTO observed_identity VALUES (NEW.verdict); END;
CREATE TEMP TRIGGER observe_identity_update AFTER UPDATE ON seat_identity_verdicts
BEGIN INSERT INTO observed_identity VALUES (NEW.verdict); END;
`);
it.each(["shell_backed", "direct_native"] as const)("%s: waits for the native child, keeps truthful verdicts and launches once", async (shape) => {
const rows = replacement(shape);
const w = world({ shape, replacementRows: read => read <= 4 ? rows.slice(0, -1) : rows,
afterReplacementYield: async () => {
expect(verdicts(w).at(-1)?.verdict).toBe("mismatch");
expect(w.events().map(event => event.type)).toEqual(["seat.restart_in_place_started"]);
expect(w.sessionRegistry.getBindingForNode(w.node.id)?.tmuxPane).toBe("%1");
if (shape === "direct_native") expect(await w.tmux.getPaneLocalOption("%1", "remain-on-exit")).toBe("on");
} });
auditVerdicts(w);
const result = await w.service.restartInPlace({ seatRef: SEAT, reason: "observe the replacement" });
expect(result).toMatchObject({ outcome: "restarted_account_unverified", newProcess: { pid: 400 }, account: { verdict: "unverified" } });
expect(w.replacementYields).toEqual([1, 1]);
// Two null attempts each use one strict OS read and one secondary snapshot.
// The successful attempt uses two fresh strict reads and ONE cached secondary snapshot.
expect(w.replacementReads).toBe(7);
expect(verdicts(w).map(row => row.verdict)).toEqual(["mismatch", "mismatch", "verified"]);
expect(w.events().map(event => [event.type, event.outcome])).toEqual([
["seat.restart_in_place_started", undefined], ["seat.restart_in_place_finished", "restarted_account_unverified"]]);
expect(w.builds).toHaveLength(1);
expect(w.calls.filter(call => /signal.*TERM/.test(call))).toHaveLength(1);
if (shape === "shell_backed") expect(w.launches).toHaveLength(1);
else {
expect(w.calls.filter(call => call.startsWith("respawn "))).toHaveLength(1);
expect(w.calls.filter(call => call.startsWith("restore remain-on-exit"))).toEqual(["restore remain-on-exit null"]);
}
});
it("keeps immediate success immediate, with two fresh strict reads and one secondary snapshot", async () => {
const w = world({ replacementRows: () => replacement("shell_backed") });
expect(await w.service.restartInPlace({ seatRef: SEAT, reason: "ready now" }))
.toMatchObject({ outcome: "restarted_account_unverified" });
expect(w.replacementYields).toEqual([]);
expect(w.replacementReads).toBe(3);
});
it.each(["shell_backed", "direct_native"] as const)("%s: exhausts the default 31 evaluations without another launch", async (shape) => {
const rows = replacement(shape).slice(0, -1);
const w = world({ shape, replacementRows: () => rows, replacementTiming: { pollMs: 100, timeoutMs: 3_000 } });
auditVerdicts(w);
expect(await w.service.restartInPlace({ seatRef: SEAT, reason: "no native evidence" }))
.toMatchObject({ outcome: "native_unknown" });
expect(w.replacementYields).toEqual(Array(30).fill(100));
expect(w.replacementReads).toBe(62);
expect(verdicts(w).map(row => row.verdict)).toEqual(Array(31).fill("mismatch"));
expect(w.events()).toHaveLength(2);
expect(w.builds).toHaveLength(1);
if (shape === "shell_backed") expect(w.launches).toHaveLength(1);
else {
expect(w.calls.filter(call => call.startsWith("respawn "))).toHaveLength(1);
expect(w.calls.some(call => call.startsWith("restore remain-on-exit"))).toBe(false);
expect(await w.tmux.getPaneLocalOption("%1", "remain-on-exit")).toBe("on");
}
});
it.each(["wrong token", "missing token", "conflicting native", "wrong OS executable"])("never promotes stable invalid evidence: %s", async kind => {
const rows = replacement("shell_backed");
if (kind === "wrong token") rows[3] = { ...rows[3]!, command: "/usr/local/bin/codex resume other-thread" };
if (kind === "missing token") rows[3] = { ...rows[3]!, command: "/usr/local/bin/codex" };
if (kind === "conflicting native") rows.push(codexRow(401, 351, `resume ${TOKEN}`, LATER, 350));
if (kind === "wrong OS executable") rows[3] = { ...rows[3]!, executableName: "node" };
const w = world({ replacementRows: () => rows });
expect(await w.service.restartInPlace({ seatRef: SEAT, reason: "invalid evidence" })).toMatchObject({ outcome: "native_unknown" });
expect(w.replacementYields).toEqual([1, 1]);
expect(w.replacementReads).toBe(6);
expect(w.launches).toHaveLength(1);
});
it("allows an earlier token mismatch to resolve only into a later fully valid pair", async () => {
const good = replacement("shell_backed");
const bad = good.map(row => row.pid === 400 ? { ...row, command: "/usr/local/bin/codex resume other-thread" } : row);
const w = world({ replacementRows: read => read <= 2 ? bad : good });
expect(await w.service.restartInPlace({ seatRef: SEAT, reason: "later valid proof" }))
.toMatchObject({ outcome: "restarted_account_unverified" });
expect(w.replacementYields).toEqual([1]);
expect(w.replacementReads).toBe(5);
});
it("does not join a drifting first/second fingerprint across attempts", async () => {
const good = replacement("shell_backed");
const drift = good.map(row => row.pid === 400 ? { ...row, startedAt: LSTART } : row);
const w = world({ replacementRows: read => read === 2 ? drift : good });
expect(await w.service.restartInPlace({ seatRef: SEAT, reason: "stable second attempt" }))
.toMatchObject({ outcome: "restarted_account_unverified" });
expect(w.replacementYields).toEqual([1]);
expect(w.replacementReads).toBe(6);
});
it("keeps a failed secondary snapshot conjunct after each positive strict proof", async () => {
const w = world({ replacementRows: read => read % 3 === 0 ? [] : replacement("shell_backed") });
auditVerdicts(w);
expect(await w.service.restartInPlace({ seatRef: SEAT, reason: "secondary absent" })).toMatchObject({ outcome: "native_unknown" });
expect(w.replacementYields).toEqual([1, 1]);
expect(w.replacementReads).toBe(9);
// These are truthful strict-identity verdicts, not a completed-restart claim.
expect(verdicts(w).map(row => row.verdict)).toEqual(["verified", "verified", "verified"]);
expect(w.events().at(-1)).toMatchObject({ outcome: "native_unknown" });
});
it.each(["lost", "changed", "ambiguous", "pid changed"])("keeps the pane join mandatory: %s", async kind => {
const w = world({ replacementRows: () => replacement("shell_backed") });
const list = w.tmux.listPanes.bind(w.tmux);
const pid = w.tmux.getPanePid.bind(w.tmux);
w.tmux.listPanes = async session => {
const panes = await list(session);
if (!w.relaunched) return panes;
if (kind === "lost") return [];
if (kind === "changed") return panes.map(pane => ({ ...pane, id: "%2" }));
if (kind === "ambiguous") return [...panes, { ...panes[0]!, id: "%2" }];
return panes;
};
w.tmux.getPanePid = async target => w.relaunched && kind === "pid changed" ? 101
: w.relaunched && kind === "changed" && target === "%1" ? null : pid(target);
expect(await w.service.restartInPlace({ seatRef: SEAT, reason: "pane join fails" })).toMatchObject({ outcome: "native_unknown" });
expect(w.replacementYields).toEqual([1, 1]);
expect(w.launches).toHaveLength(1);
});
it.each([[5, "restarted_account_unverified", 7], [7, "native_unknown", 6]] as const)(
"native appears at OS read %i: respects the last allowed attempt", async (appearAt, outcome, reads) => {
const rows = replacement("shell_backed");
const w = world({ replacementRows: read => read < appearAt ? rows.slice(0, -1) : rows });
expect(await w.service.restartInPlace({ seatRef: SEAT, reason: "budget boundary" })).toMatchObject({ outcome });
expect(w.replacementYields).toEqual([1, 1]);
expect(w.replacementReads).toBe(reads);
});
it("preserves thrown-error finalization instead of resampling an exception", async () => {
const w = world({ replacementRows: () => replacement("shell_backed").slice(0, -1),
afterReplacementYield: () => { throw new Error("observation sleep failed"); } });
expect(await w.service.restartInPlace({ seatRef: SEAT, reason: "throw during observation" }))
.toMatchObject({ outcome: "native_unknown", couldNotSee: [expect.stringContaining("observation sleep failed")] });
expect(w.replacementYields).toEqual([1]);
expect(w.replacementReads).toBe(2);
expect(w.events()).toHaveLength(2);
expect(w.launches).toHaveLength(1);
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/ui",
"version": "0.6.10",
"version": "0.6.11",
"private": true,
"license": "Apache-2.0",
"type": "module",