mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-10 03:31:40 -10:00
Compare commits
7
Commits
main
...
release/0.6.11
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
379f0a0d10 | ||
|
|
af1b61bf52 | ||
|
|
1a73ec57bc | ||
|
|
1711698dba | ||
|
|
098f73eac3 | ||
|
|
e47c158004 | ||
|
|
b194394fa4 |
No files matched your search
+237
@@ -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
@@ -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
|
||||
|
||||
Generated
+5
-5
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openrig",
|
||||
"version": "0.6.10",
|
||||
"version": "0.6.11",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,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 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@openrig/ui",
|
||||
"version": "0.6.10",
|
||||
"version": "0.6.11",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
|
||||
Reference in new issue
Block a user