mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 03:39:51 -10:00
Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71389bef06 | ||
|
|
ebf20ae616 | ||
|
|
4c14ca76c4 | ||
|
|
682bede406 | ||
|
|
db45c6bcb8 | ||
|
|
03a2828ce7 | ||
|
|
c57f0173ab | ||
|
|
72086329ae | ||
|
|
8684c981a6 | ||
|
|
e5c4cba385 | ||
|
|
10b5f155b3 | ||
|
|
5700c6c2ff | ||
|
|
d44cf3199a | ||
|
|
c2378fa315 | ||
|
|
884a5bfb6b | ||
|
|
9ba9abe858 | ||
|
|
2648083e96 | ||
|
|
76c0170ec8 | ||
|
|
0224e7710d | ||
|
|
8efb5a59c1 | ||
|
|
36f971fa2c | ||
|
|
b51500f024 | ||
|
|
4926be0c0c | ||
|
|
413504a029 | ||
|
|
a70bf71d5f | ||
|
|
3f17e625af | ||
|
|
470f55241e | ||
|
|
3874c743df | ||
|
|
b0bc678833 | ||
|
|
26a490938c | ||
|
|
afee489108 | ||
|
|
898b39ea5c |
No files matched your search
@@ -26,9 +26,9 @@ use:
|
||||
|
||||
```sh
|
||||
# Claude Code
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.3/skills/rigs --skill rigs --agent claude-code
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.4/skills/rigs --skill rigs --agent claude-code
|
||||
# Codex
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.3/skills/rigs --skill rigs --agent codex
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.4/skills/rigs --skill rigs --agent codex
|
||||
```
|
||||
|
||||
Then type `/rigs` in Claude Code, or `$rigs` in Codex, and say what you want built. The skill has your agent tell
|
||||
@@ -68,11 +68,11 @@ Requires Node.js 22 or 24 and tmux, on macOS or Linux, and one working Claude Co
|
||||
|
||||
### Install it yourself
|
||||
|
||||
This runs OpenRig's install script from the `v0.7.3` release. It checks Node.js and npm, installs the latest published `@openrig/cli` with `npm install -g`, runs the Node.js and SQLite check, then runs `rig setup --dry-run` and `rig setup`. The first line prints that plan and changes nothing:
|
||||
This runs OpenRig's install script from the `v0.7.4` release. It checks Node.js and npm, installs the latest published `@openrig/cli` with `npm install -g`, runs the Node.js and SQLite check, then runs `rig setup --dry-run` and `rig setup`. The first line prints that plan and changes nothing:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.3/scripts/install.sh | sh -s -- --dry-run
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.3/scripts/install.sh | sh
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.4/scripts/install.sh | sh -s -- --dry-run
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.4/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
If a provider isn't signed in yet, the last step reports `FAILED [4/4]`, and `rig setup` lists the sign-in under "Some steps need attention". If the only remaining failures are provider sign-ins, the install steps finished; sign in to each selected provider as below and continue.
|
||||
|
||||
@@ -158,7 +158,7 @@ Exit codes: 0 pass, 1 a leg failed, 2 lane busy, 3 the runner itself errored.
|
||||
| Piece | Where |
|
||||
|---|---|
|
||||
| `runtime: stub` adapter | `packages/daemon/src/adapters/stub-runtime-adapter.ts`, with `stub-runner.ts` (the process that runs in the pane), `stub-script.ts` (the launch-script format) and `stub-compaction.ts` / `stub-restore.ts` |
|
||||
| Scenario format and runner | `packages/daemon/test/helpers/scenario-*.ts`. Step verbs: `up`, `down`, `send`, `restart`, `daemon`, `seed_regression` are bound. `restore`, `emit`, `mutate`, `policy` parse but are unbound. Assertion surfaces: `ps`, `queue`, `stream`, `scope`, `pane`, `transcript`, `tui_socket`, `policy_provenance`. |
|
||||
| Scenario format and runner | `packages/daemon/test/helpers/scenario-*.ts`. Step verbs: `up`, `down`, `send`, `restart`, `daemon`, `seed_regression` are bound. `restore`, `mutate`, `policy` are rejected at load time (`UNBOUND_STEP_VERB`) because the runner has no shipped binding for them; `emit` parses but only `refocus` is bound. Assertion surfaces: `ps`, `queue`, `stream`, `scope`, `pane`, `transcript`, `tui_socket`, `policy_provenance`. |
|
||||
| Scenario library | `packages/test-system/scenarios/`: 20 scenarios, 7 stub topologies, `agents/`, `culture.md`, per-seat stub scripts in `scripts/` |
|
||||
| Runner fixtures | `packages/daemon/test/fixtures/scenarios/` (`scenario-01-per-seat-scripts`, `scenario-02-baton`, `scenario-10-one-view-state`) |
|
||||
| Host runner | `packages/daemon/scripts/run-scenarios.mjs` |
|
||||
@@ -278,7 +278,7 @@ means:
|
||||
| Queue pickup and wake | No stub worker reacts to a nudge by claiming an item, working on it and handing it back. | The baton scenarios prove the claim *survives a restart*. They don't prove delivery, pickup or wake. |
|
||||
| Reboot / tmux reset | `daemon: {op: restart}` restarts only the scenario daemon, and the tmux server keeps running. No step resets tmux. | Recycled pane IDs and stale bindings after a real reboot (the class behind #141) are not covered by a daemon restart. |
|
||||
| Multi-rig and same-name identities | The `up` step always uses the scenario's top-level `topology` and ignores a per-step override. No step archives or removes a rig. | Cross-rig scope, same-name generations and archive/remove routing (the class behind #174) need runner work before they can be tested. |
|
||||
| Step-time restore, compaction, mutation, policy | `restore`, `emit`, `mutate` and `policy` parse, but the runner throws `UnboundActionError` when it reaches them. Behaviours run only from a per-seat launch script (`env.stub_scripts`). A seat `restart` step maps to `rig launch`, which is not native resume or fork. | Step-time compaction and restore, and native resume and fork, are not covered. |
|
||||
| Step-time restore, compaction, mutation, policy | `restore`, `mutate` and `policy` steps are refused at load time (`UNBOUND_STEP_VERB`); `emit` parses but a non-`refocus` behavior throws `UnboundActionError` when the runner reaches it. Behaviours run only from a per-seat launch script (`env.stub_scripts`). A seat `restart` step maps to `rig launch`, which is not native resume or fork. | Step-time compaction and restore, and native resume and fork, are not covered. |
|
||||
| Skill projection | The stub projects skills into `<cwd>/.openrig/stub/skills/`. Claude Code projects a team seat's skills into the seat's generated plugin in OpenRig's state, and into `<cwd>/.claude/skills/` only for a seat without one. | A stub green says nothing about provider-specific projection (the class behind #159). |
|
||||
| Model and provider | No model, no tokens. Stub scripts refuse `usage_limit` because it only exists on real runtimes. | Model behaviour, provider usage limits and real context usage need live evals or a real runtime. |
|
||||
| Platform | CI's scenario job runs in a Linux container. | macOS process behaviour is covered by the macOS `package-tests` jobs, not by scenarios. |
|
||||
@@ -319,9 +319,9 @@ of them. The other nine scenarios are authored but not yet runnable as meaningfu
|
||||
| `send-verify-means-rendered` | The `send` step drops `verify`, and the stub has no input loop | Build the input-consuming stub and assert a response derived from the nonce |
|
||||
| `home-divergence-preseed-visible` | The pipeline ignores `env.pre_existing_tmux`, and the default script never prints the expected `trust reached` | Recreate the real HOME split and whatever consumes it |
|
||||
| `stream-emit-durable-replay-live` | Step-time `emit` is unbound | Assert the actual stream contract |
|
||||
| `policy-posture-survives-structural-edit` | `policy` and `mutate` are unbound | Rewrite around current per-seat permission selection and an observed launch |
|
||||
| `locked-restore-never-permissive` | `policy` and `restore` are unbound | Keep the invariant (the selection survives a restore) and use current modes |
|
||||
| `one-view-state-after-mutation-storm` | The validator rejects its bare-list `equals` (`EQUALS_NOT_DECLARATIVE`), and `mutate` is unbound | Declare the comparable fields and real mutations |
|
||||
| `policy-posture-survives-structural-edit` | The validator refuses its `policy` and `mutate` steps at load time (`UNBOUND_STEP_VERB` — no shipped runner binding) | Rewrite around current per-seat permission selection and an observed launch |
|
||||
| `locked-restore-never-permissive` | The validator refuses its `policy` and `restore` steps at load time (`UNBOUND_STEP_VERB` — no shipped runner binding) | Keep the invariant (the selection survives a restore) and use current modes |
|
||||
| `one-view-state-after-mutation-storm` | The validator rejects its bare-list `equals` (`EQUALS_NOT_DECLARATIVE`) and refuses its `mutate` steps at load time (`UNBOUND_STEP_VERB`) | Declare the comparable fields and real mutations |
|
||||
| `kill-daemon-mid-handoff` | A pane `send` doesn't create the queue item the next assertion expects, and later assertions use a shape the queue reader doesn't return | Rebuild the setup before treating a red result as meaningful |
|
||||
|
||||
In most of these files, `seed_regression` steps that come *after* the assertions are
|
||||
|
||||
@@ -102,7 +102,7 @@ time-bounded evidence. Rungs, top down, with each rung's retirement condition:
|
||||
- **r3 — Claude `sessions/<pid>.json` self-report** (busy/shell/idle/waiting, since
|
||||
v2.1.139): standing, above hooks for working/idle. Undocumented internal — unreadable
|
||||
means fall down the ladder, never error.
|
||||
- **r2 — lifecycle hooks** (Claude SessionStart/UserPromptSubmit/Stop/Notification;
|
||||
- **r2 — lifecycle hooks** (Claude SessionStart/UserPromptSubmit/PostToolBatch/Stop/StopFailure/Notification;
|
||||
Codex SessionStart/UserPromptSubmit/Stop/PermissionRequest): standing for Claude; Codex's
|
||||
hook rung starts at trial and is promoted after 50 agreeing observations over at least an
|
||||
hour. Exactly-once turn boundaries; SubagentStop filtered (it
|
||||
@@ -139,6 +139,26 @@ time-bounded evidence. Rungs, top down, with each rung's retirement condition:
|
||||
idle. Silence never clears a pending attention prompt, and a runner that states no interval (omp, an older pi
|
||||
runner) keeps the hold-until-the-next-boundary behaviour.
|
||||
|
||||
Claude permission notifications raise needs-input immediately. A visible permission can clear
|
||||
after it has been observed and then disappears. A notification whose dialog was never observed
|
||||
can also clear when Claude reports `PostToolBatch`: every tool in that actor's batch has resolved,
|
||||
so its earlier permission requests are no longer pending. The relay carries the native actor
|
||||
identity; a subagent or in-process teammate finishing its batch cannot clear the main session's
|
||||
request, and overlapping actors remain separate. An individual `PostToolUse` is insufficient
|
||||
because another tool in the batch may still be waiting for permission.
|
||||
|
||||
Batch completion must come from the current occupant and have a later source timestamp than
|
||||
the matching notification. Missing actor attribution from an older relay, equal or older
|
||||
timestamps, and another actor's completion retain attention. An unrecognized or missing screen
|
||||
does not expire attention by age. `Stop` and `UserPromptSubmit` remain the turn-boundary fallback.
|
||||
The oracle remembers each actor's latest completed batch for the current occupant, so a strictly
|
||||
earlier notification arriving late cannot restore the mark, even if the completion arrived first.
|
||||
A notification with an equal or later timestamp remains eligible; swapping or retiring the
|
||||
occupant discards its remembered completions.
|
||||
Completing a batch changes the needs-input signal without inferring that the seat is idle or
|
||||
overwriting a later elicitation. The hook contract is documented in
|
||||
[Claude's hooks reference](https://code.claude.com/docs/en/hooks#posttoolbatch).
|
||||
|
||||
Partial-coverage honesty: a source with partial lifecycle coverage gets identity-only
|
||||
trust, unless its inventory declares it positive-only. A positive-only source can report
|
||||
working and nothing else; its silence reads unknown, never idle. The ladder is also the migration path — new oracles slot in above existing
|
||||
|
||||
@@ -191,11 +191,11 @@ You can also read [`skills/rigs/SKILL.md`](../../skills/rigs/SKILL.md) directly.
|
||||
script will do and changes nothing:
|
||||
|
||||
```sh
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.3/scripts/install.sh | sh -s -- --dry-run
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.4/scripts/install.sh | sh -s -- --dry-run
|
||||
```
|
||||
|
||||
The same command without `--dry-run`,
|
||||
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.3/scripts/install.sh | sh`, installs the
|
||||
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.4/scripts/install.sh | sh`, installs the
|
||||
latest published `@openrig/cli` with `npm install -g`,
|
||||
runs the Node.js and SQLite check, then `rig setup --dry-run` and `rig setup`. `rig setup` checks both Claude Code and
|
||||
Codex and may install a missing one, as described below. A failed step prints
|
||||
|
||||
@@ -17,7 +17,7 @@ npx skills add https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/ri
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/rigs --skill rigs --agent codex
|
||||
```
|
||||
|
||||
For a new install, use the latest release's tag, for example `v0.7.3`. If OpenRig is already installed, use the tag
|
||||
For a new install, use the latest release's tag, for example `v0.7.4`. If OpenRig is already installed, use the tag
|
||||
that matches `rig --version`, or upgrade first. If the command fails with `npx: command not found`, Node.js isn't
|
||||
installed or isn't on your PATH yet (see [What you need](#what-you-need)). Then type `/rigs` in Claude Code, or `$rigs` in Codex, and say what
|
||||
you want built.
|
||||
|
||||
@@ -511,9 +511,9 @@ pods:
|
||||
**Where the declared names reach, by runtime.** Declared values are handed to the seat's terminal as tmux environment, so most seats take them as given:
|
||||
|
||||
- Plain Claude Code (no permission mode selected), Codex and terminal seats take a spec `HOME`, `PATH` or `CLAUDE_CONFIG_DIR` as given.
|
||||
- The cleared environment (`env -i`) belongs only to a managed Claude launch, which is the one used when a permission mode is selected for the seat (for example `permission_policy: builtin:auto`). It forwards the declared names through the cleared environment, except `PATH`, `HOME` and `CLAUDE_CONFIG_DIR`, which OpenRig sets for that launch itself, and `CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN`, for which it sets its own renderer setting. A spec value for any of those four is not forwarded as such; an absolute `CLAUDE_CONFIG_DIR` becomes the config dir OpenRig sets (see "A seat's own account" below). A seat that selected the shipped `openrig-core` plugin also has `CLAUDE_CODE_PLUGIN_DIRS` (the vendored plugin, added after a list the seat declares itself) among its declared names, so it is forwarded like the others and the plugin's hooks load in a seat with a permission mode as in one without; a seat that didn't select the plugin has no such name added. Beyond the declared names, the launch also forwards, by name and only when the seat's terminal has them set, the terminal and locale variables (`TERM`, `COLORTERM`, `TERM_PROGRAM`, `TERM_PROGRAM_VERSION`, `LANG` and the `LC_*` names) and the person's editor and display session: `EDITOR`, `VISUAL`, `DISPLAY`, `XAUTHORITY` and `WAYLAND_DISPLAY`. They let Claude's external-editor action open the person's editor and let image paste reach the clipboard, without a value in a spec. `WAYLAND_DISPLAY` is passed as an absolute path: an absolute value is kept, and a relative one is joined to the terminal's `XDG_RUNTIME_DIR`; if the terminal has no `XDG_RUNTIME_DIR` for it, the name is left out. The runtime directory and its `disabled:` bus address (see "The runtime directory, on Linux" above) are forwarded together, and the runtime directory is also forwarded beside a declared `DBUS_SESSION_BUS_ADDRESS`. A terminal's own `XDG_RUNTIME_DIR` or `DBUS_SESSION_BUS_ADDRESS`, from a shell startup file or tmux's `update-environment`, is not, because D-Bus clients look for the session bus at `$XDG_RUNTIME_DIR/bus` when no address is set; declare it in `env:` to forward it. `XDG_SESSION_TYPE`, `XDG_CURRENT_DESKTOP` and `SSH_AUTH_SOCK` are not forwarded. A running seat picks the names up at its next managed Claude launch, not before: `rig seat launch <seat> --fresh --stop --reason "<why>"` for that seat alone (a new conversation), or a restore of the whole rig (`rig down <rig>`, then `rig up <rig> --existing`, which resumes each conversation but restarts every seat in the rig), or a handover. A seat that is up keeps the environment it started with.
|
||||
- The cleared environment (`env -i`) belongs only to a managed Claude launch, which is the one used when a permission mode is selected for the seat (for example `permission_policy: builtin:auto`). It forwards the declared names through the cleared environment, except `PATH`, `HOME` and `CLAUDE_CONFIG_DIR`, which OpenRig sets for that launch itself, and `CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN`, for which it sets its own renderer setting. A spec value for any of those four is not forwarded as such; an absolute `CLAUDE_CONFIG_DIR` becomes the config dir OpenRig sets (see "A seat's own account" below). A seat that selected the shipped `openrig-core` plugin also has `CLAUDE_CODE_PLUGIN_DIRS` (the vendored plugin, added after a list the seat declares itself) among its declared names, so it is forwarded like the others and the plugin's hooks load in a seat with a permission mode as in one without; a seat that didn't select the plugin has no such name added. Beyond the declared names, the launch also forwards, by name and only when the seat's terminal has them set, the terminal and locale variables (`TERM`, `COLORTERM`, `TERM_PROGRAM`, `TERM_PROGRAM_VERSION`, `LANG` and the `LC_*` names) and the person's editor and display session: `EDITOR`, `VISUAL`, `DISPLAY`, `XAUTHORITY` and `WAYLAND_DISPLAY`. They let Claude's external-editor action open the person's editor and let image paste reach the clipboard, without a value in a spec. `WAYLAND_DISPLAY` is passed as an absolute path: an absolute value is kept, and a relative one is joined to the terminal's `XDG_RUNTIME_DIR`; if the terminal has no `XDG_RUNTIME_DIR` for it, the name is left out. The runtime directory and its `disabled:` bus address (see "The runtime directory, on Linux" above) are forwarded together, and the runtime directory is also forwarded beside a declared `DBUS_SESSION_BUS_ADDRESS`. A terminal's own `XDG_RUNTIME_DIR` or `DBUS_SESSION_BUS_ADDRESS`, from a shell startup file or tmux's `update-environment`, is not, because D-Bus clients look for the session bus at `$XDG_RUNTIME_DIR/bus` when no address is set; declare it in `env:` to forward it. `XDG_SESSION_TYPE` and `XDG_CURRENT_DESKTOP` are not forwarded. The SSH agent socket (`SSH_AUTH_SOCK`) is forwarded when OpenRig itself was started with one, so git over SSH works in the seat; a socket only the seat's terminal has stays out. To check, run `ssh-add -l` inside the seat. If it can't reach an SSH agent, start the OpenRig daemon from a terminal where `ssh-add -l` works. A running seat picks the names up at its next managed Claude launch, not before: `rig seat launch <seat> --fresh --stop --reason "<why>"` for that seat alone (a new conversation), or a restore of the whole rig (`rig down <rig>`, then `rig up <rig> --existing`, which resumes each conversation but restarts every seat in the rig), or a handover. A seat that is up keeps the environment it started with.
|
||||
- Codex seats run OpenRig's own Codex executable whatever a spec `PATH` says. They use the seat's own `CODEX_HOME` when its `env:` declares an absolute one, else the daemon's when the daemon has one.
|
||||
- On Pi and OMP seats the values reach the terminal. The agent process gets only what the runner passes on, so a spec or one-off value reaches it only for the names the runner always passes: `PATH`, `HOME`, `USER`, `LOGNAME`, `TERM`, `LANG`, `LC_ALL`, `SHELL` and `TMPDIR`. Pi's agent process receives the runner's `HOME`, so a spec or one-off `HOME` value that reaches the runner is passed to Pi; OMP's agent process gets its seat state directory (`$OPENRIG_HOME/state/omp/<seat>`, see Oh My Pi below) as `HOME`, so a `HOME` value does not reach it. The selected provider's variables are passed separately, as described under each runtime.
|
||||
- On Pi and OMP seats the values reach the terminal. The agent process gets only what the runner passes on, so a spec or one-off value reaches it only for the names the runner always passes: `PATH`, `HOME`, `USER`, `LOGNAME`, `SSH_AUTH_SOCK`, `TERM`, `LANG`, `LC_ALL`, `SHELL` and `TMPDIR`. Pi's agent process receives the runner's `HOME`, so a spec or one-off `HOME` value that reaches the runner is passed to Pi; OMP's agent process gets its seat state directory (`$OPENRIG_HOME/state/omp/<seat>`, see Oh My Pi below) as `HOME`, so a `HOME` value does not reach it. The selected provider's variables are passed separately, as described under each runtime.
|
||||
|
||||
**A seat's own account.** Claude Code keeps its login in its config dir and Codex in its home, so a seat whose `env:`
|
||||
declares an absolute `CLAUDE_CONFIG_DIR` (Claude Code) or `CODEX_HOME` (Codex) runs on whatever login that directory
|
||||
@@ -653,7 +653,7 @@ own defaults/settings apply. Other runtimes ignore this field.
|
||||
- **State:** Each seat uses `$OPENRIG_HOME/state/pi/<session>/agent` as its Pi agent directory, with `sessions/` beside it, instead of your default `~/.pi/agent`. The runner sets `PI_CODING_AGENT_DIR` to that directory. `<session>` is the seat's session name, unchanged; for a pod member it is `{podId}-{memberId}@{rigName}`. `$OPENRIG_HOME` defaults to `~/.openrig`. OpenRig creates these directories at launch and does not copy anything from `~/.pi/agent`.
|
||||
- **Thinking level:** A Pi seat's effort (the choice saved with `rig seat set-effort`, else the spec's `effort`) is sent to Pi as `--thinking <level>` on every managed launch and resume: fresh launch, successor and restore. Pi accepts `off`, `minimal`, `low`, `medium`, `high`, `xhigh` and `max`; OpenRig passes the value as written and does not translate it, so Codex's `none` is not mapped to `off`. Only a value of the shape `set-effort` accepts (a letter, then up to 31 letters, digits, `-` or `_`) is sent; any other spec value, such as one with a space or one that starts with `--`, is left out, and `rig seat effort` says so. An unfamiliar level gets an advisory (a warning from `set-effort`), never a refusal, and Pi itself ignores an unknown level with a warning and keeps the level it would use without the flag: a model suffix, the resumed session's level, or its default. With no effort resolved, no flag is sent. The effort wins over a model suffix, and the model string is never changed. `rig seat effort` reports the level sent at launch and, on Pi 0.99.0 or later, the `thinkingLevel` Pi records on this launch's latest successful assistant turn (error and aborted turns are skipped); that is the session's level after Pi's own cap, not proof the provider applied it, and it is shown apart from the level sent when they differ. On older Pi it is NOT VERIFIED. Without an effort, you can still set the level as a model suffix, for example `model: openrouter/x-ai/grok-4:high`. Pi first looks for a model whose id is the whole pin, and uses it when there is one, suffix included. Only when there is none does Pi read the suffix as the level and record the model without it. Any other suffix, such as OpenRouter's `:free`, stays part of the model id. The model-divergence check reads the pin literally when the seat's `models.json` defines a model with that exact id; otherwise it accepts the model with or without the suffix. If the seat's `models.json` can't be read, a pin with a suffix stays unchecked, with the usual warning, when the recorded model matches the whole pin or the pin without its suffix. A record that matches neither is reported.
|
||||
- **Custom models:** Pi reads `models.json` from its agent directory, so a seat reads `$OPENRIG_HOME/state/pi/<session>/agent/models.json`, not `~/.pi/agent/models.json`. Custom provider and model definitions for a seat go in that file. A seat that uses only providers Pi already includes does not need one. OpenRig does not create or write `models.json`.
|
||||
- **Credentials:** The Pi process receives only `PATH`, `HOME`, `USER`, `LOGNAME`, `TERM`, `LANG`, `LC_ALL`, `SHELL`, `TMPDIR`, a fixed set of OpenRig seat and instance variables, `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, and at most one provider key. That key is `OPENROUTER_API_KEY`, `ZAI_API_KEY`, `KIMI_API_KEY` or `MINIMAX_API_KEY`, passed only when it is set in the seat's environment and the seat's `model` is written as `openrouter/<id>`, `zai/<id>`, `kimi-coding/<id>` or `minimax/<id>`. Naming that variable in `recovery.provider_auth_env_allowlist` (empty by default) makes the daemon add it, when set in the daemon's environment, to the seat's launch environment. No other variable reaches Pi, including other providers' keys and a custom provider's own key variable.
|
||||
- **Credentials:** The Pi process receives only `PATH`, `HOME`, `USER`, `LOGNAME`, `SSH_AUTH_SOCK`, `TERM`, `LANG`, `LC_ALL`, `SHELL`, `TMPDIR`, a fixed set of OpenRig seat and instance variables, `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, and at most one provider key. That key is `OPENROUTER_API_KEY`, `ZAI_API_KEY`, `KIMI_API_KEY` or `MINIMAX_API_KEY`, passed only when it is set in the seat's environment and the seat's `model` is written as `openrouter/<id>`, `zai/<id>`, `kimi-coding/<id>` or `minimax/<id>`. Naming that variable in `recovery.provider_auth_env_allowlist` (empty by default) makes the daemon add it, when set in the daemon's environment, to the seat's launch environment. No other variable reaches Pi, including other providers' keys and a custom provider's own key variable.
|
||||
|
||||
- **Extension dialogs:** The RPC runner shows extension `select`, `confirm`, `input` and `editor` requests in the pane and cancels them because the managed pane cannot answer them. The seat needs attention through the end of the turn, until a new agent run starts. Review the cancelled request and send an instruction the extension can complete without a dialog. Extension `notify` text is shown without raising attention or sending a response.
|
||||
|
||||
|
||||
@@ -30,10 +30,25 @@ With neither a provider nor a session, the daemon's open and preview APIs try
|
||||
a live Herdr first, then a live cmux. An explicit session always selects Herdr
|
||||
and skips that fallback, including when the daemon's default Herdr endpoint is down.
|
||||
|
||||
An explicit session resolves under the daemon user's Herdr session directory;
|
||||
it takes precedence over that daemon's default socket override. The selection
|
||||
belongs to the request. Saved views still describe membership and layout, so
|
||||
pass `--session` again when reopening the view in that session.
|
||||
Session paths use the daemon's `XDG_CONFIG_HOME` when set, or `HOME/.config`
|
||||
when unset. Relative paths (including an empty value) resolve from the daemon's
|
||||
startup directory, matching Herdr's configuration selection. Named sessions live under
|
||||
`herdr/sessions/<name>/herdr.sock`; `default` uses `herdr/herdr.sock` directly.
|
||||
These settings are captured when the daemon starts.
|
||||
|
||||
If the requested name equals the daemon's `HERDR_SESSION`, it keeps that
|
||||
session's explicit `HERDR_SOCKET_PATH`. A different requested name uses the
|
||||
configuration-tree path, without reusing the override or inferring a session
|
||||
from an arbitrary socket path. With an explicit socket but no configured
|
||||
session name, omit `--session` to keep that exact endpoint. A missing selected
|
||||
socket never falls back to another session. Viewer and follow operations carry
|
||||
that exact socket to the CLI through `HERDR_SOCKET_PATH`, without an explicit
|
||||
CLI `--session` that would override it. They check that the CLI reports the
|
||||
selected socket before changing its panes. Daemon-side CLI calls retain the
|
||||
startup environment; desktop follow uses the socket confirmed by the daemon.
|
||||
|
||||
The selection belongs to the request. Saved views still describe membership
|
||||
and layout, so pass `--session` again when reopening the view in that session.
|
||||
|
||||
The existing view rules apply: saved views, rig and pod views, read-only mission
|
||||
and slice views, pagination, absent seats and partial results all use the same
|
||||
|
||||
+8
-33
@@ -1,38 +1,15 @@
|
||||
# Release Notes
|
||||
|
||||
This directory is the lightweight release history for OpenRig.
|
||||
This directory holds one note per release for OpenRig v0.1.12 through v0.6.3, plus v0.6.12 (for example `v0.1.12.md`).
|
||||
|
||||
It is intentionally simpler than a monolithic `CHANGELOG.md`.
|
||||
|
||||
Each shipped release gets its own note:
|
||||
|
||||
- `v0.1.12.md`
|
||||
- `v0.2.0.md`
|
||||
- `v0.3.0.md`
|
||||
- and so on
|
||||
|
||||
## Why This Exists
|
||||
|
||||
We want a practical release-management pattern that works with how OpenRig is actually being shipped today:
|
||||
|
||||
- npm package release
|
||||
- optional Git tag
|
||||
- optional GitHub Release
|
||||
- short, human-written summary of what is included
|
||||
|
||||
This keeps release notes:
|
||||
|
||||
- easy to author
|
||||
- easy to link in GitHub Releases
|
||||
- easy to paste into announcements
|
||||
- durable in the repo
|
||||
Notes for later releases are in [`CHANGELOG.md`](../../CHANGELOG.md) and on the [GitHub Releases page](https://github.com/mvschwarz/openrig/releases).
|
||||
|
||||
## Minimal Process
|
||||
|
||||
For each release:
|
||||
|
||||
1. Copy `_template.md` to `vX.Y.Z.md`.
|
||||
2. Fill in the release summary, included changes, operator notes, known limitations, and verification performed.
|
||||
1. Add the release's section to [`CHANGELOG.md`](../../CHANGELOG.md).
|
||||
2. Write the release's GitHub Release notes. No new file is added to this directory.
|
||||
3. Run the **substance gate** at the exact release cut. The gate derives every
|
||||
file under the shipped pack sources, verifies a hash-bound human judgment
|
||||
and an individual disposition for each mechanical candidate, scans the
|
||||
@@ -79,16 +56,16 @@ For each release:
|
||||
git push origin vX.Y.Z
|
||||
```
|
||||
|
||||
5. Create a GitHub Release using the same file:
|
||||
5. Create the GitHub Release with the notes from step 2:
|
||||
|
||||
```bash
|
||||
gh release create vX.Y.Z \
|
||||
--repo mvschwarz/openrig \
|
||||
--title "OpenRig vX.Y.Z" \
|
||||
--notes-file docs/releases/vX.Y.Z.md
|
||||
--title "OpenRig X.Y.Z" \
|
||||
--notes-file <release-notes.md>
|
||||
```
|
||||
|
||||
6. Publish the npm package if that is part of the release flow.
|
||||
6. Publish the npm package.
|
||||
|
||||
## Guidance
|
||||
|
||||
@@ -101,5 +78,3 @@ For each release:
|
||||
## Scope
|
||||
|
||||
This directory is a release-note archive, not a full historical changelog taxonomy.
|
||||
|
||||
If OpenRig later wants a curated `CHANGELOG.md`, it can be generated or summarized from the release notes here.
|
||||
@@ -3,7 +3,7 @@ import { assessLocalDaemonOwner, ForeignLocalDaemonError, type LocalDaemonOwner
|
||||
export { ForeignLocalDaemonError } from "./local-daemon-owner.js";
|
||||
import path from "node:path";
|
||||
import { ConfigStore } from "./config-store.js";
|
||||
import { getOpenRigHome, readOpenRigEnv } from "./openrig-compat.js";
|
||||
import { getOpenRigHome, readOpenRigEnv, readOpenRigUrl } from "./openrig-compat.js";
|
||||
import { fetchWithTimeout, FetchTimeoutError } from "./fetch-with-timeout.js";
|
||||
import { FETCH_HEADERS_TIMEOUT_MS, nodeHttpFetch } from "./long-request-fetch.js";
|
||||
|
||||
@@ -230,7 +230,7 @@ export class DaemonClient {
|
||||
const localHeaders = senderIdentityHeaders(undefined, this.sessionName);
|
||||
const session = localHeaders[SENDER_IDENTITY_HEADER];
|
||||
if (!session || session.split("@").length >= 3) return { headers: localHeaders, local: false };
|
||||
const directTarget = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL");
|
||||
const directTarget = readOpenRigUrl();
|
||||
if (!this.remoteTarget && !directTarget) return { headers: localHeaders, local: true };
|
||||
// Imported here, not at the top: it loads better-sqlite3 and yaml, which only a seat on a direct endpoint needs.
|
||||
const origin = this.originSelfHostId ?? (await import("./local-origin.js")).readLocalOrigin();
|
||||
@@ -256,7 +256,7 @@ export class DaemonClient {
|
||||
if (baseUrl) {
|
||||
this.baseUrl = baseUrl;
|
||||
} else {
|
||||
const envUrl = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL");
|
||||
const envUrl = readOpenRigUrl();
|
||||
if (envUrl) {
|
||||
this.baseUrl = envUrl;
|
||||
} else {
|
||||
|
||||
@@ -4,7 +4,24 @@ import { readOpenRigEnv } from "../openrig-compat.js";
|
||||
import { getDaemonStatus, getDaemonUrl , daemonStatusGuard} from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
import { execSync } from "node:child_process";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
/** tmux's arguments for the session that runs `rig chatroom watch`. The watcher goes to tmux as separate arguments, so
|
||||
* tmux runs it without a shell and the rig name arrives as one argument; `--` keeps a name that starts with `-` from
|
||||
* reading as an option. tmux reads the `-s` value as a format, where `##` is one `#` and a run of `#` before `[` is a
|
||||
* style it keeps as written, so every other `#` is doubled and the session is `chatroom@<rig>`. Some tmux versions
|
||||
* still change a session name (3.5a writes `_` for `:` and `.`, and escapes a backslash or a control character such
|
||||
* as a newline or tab), so `-P -F` prints the name tmux used. */
|
||||
export function chatroomWatchTmuxArgs(rig: string): string[] {
|
||||
const session = `chatroom@${rig}`.replace(/#+(\[)?/g, (run, style?: string) => style ? run : run + run);
|
||||
return ["new-session", "-d", "-P", "-F", "#{session_name}", "-s", tmuxArg(session),
|
||||
"rig", "chatroom", "watch", "--", tmuxArg(rig)];
|
||||
}
|
||||
|
||||
/** tmux ends a command at an argument that ends in `;`, unless a `\` comes before it, so a final `;` gets that `\`. */
|
||||
function tmuxArg(value: string): string {
|
||||
return value.endsWith(";") ? `${value.slice(0, -1)}\\;` : value;
|
||||
}
|
||||
|
||||
interface RigSummary {
|
||||
id: string;
|
||||
@@ -175,9 +192,11 @@ export function chatroomCommand(depsOverride?: StatusDeps): Command {
|
||||
if (opts.tmux) {
|
||||
const sessionName = `chatroom@${rig}`;
|
||||
try {
|
||||
execSync(`tmux new-session -d -s ${JSON.stringify(sessionName)} "rig chatroom watch ${JSON.stringify(rig)}"`, { stdio: "ignore" });
|
||||
console.log(`Started watch in tmux session: ${sessionName}`);
|
||||
console.log(`Attach with: tmux attach -t ${sessionName}`);
|
||||
// The name tmux used, which differs from sessionName when tmux changed it (a `.` in a rig name, for example).
|
||||
const created = execFileSync("tmux", chatroomWatchTmuxArgs(rig), { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] })
|
||||
.trim() || sessionName;
|
||||
console.log(`Started watch in tmux session: ${created}`);
|
||||
console.log(`Attach with: tmux attach -t ${created}`);
|
||||
} catch {
|
||||
console.error(`Failed to create tmux session '${sessionName}'. It may already exist.`);
|
||||
process.exitCode = 1;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Command } from "commander";
|
||||
import { copyFileSync, lstatSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { getOpenRigHome } from "../openrig-compat.js";
|
||||
import { getOpenRigHome, readOpenRigUrl } from "../openrig-compat.js";
|
||||
import { join } from "node:path";
|
||||
import { ConfigStore } from "../config-store.js";
|
||||
import { formatDaemonHostForUrl } from "../client.js";
|
||||
@@ -54,6 +54,14 @@ function existsOrThrow(path: string): boolean {
|
||||
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; throw error; }
|
||||
}
|
||||
|
||||
/** The evidence probe's URL: OPENRIG_URL with one trailing slash trimmed, as the detector trims it, else daemon.json's
|
||||
* host and port. The shared reader leaves a query or fragment URL as written, so the trim stays here. */
|
||||
export function crashCartEvidenceUrl(openrigUrl: string | undefined, state: { host?: string; port: number } | undefined): string {
|
||||
return openrigUrl
|
||||
? `${openrigUrl.replace(/\/$/, "")}/healthz`
|
||||
: `http://${formatDaemonHostForUrl(state?.host ?? "127.0.0.1")}:${state?.port ?? 7433}/healthz`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The real verdict: LAZY-imports the narrow @openrig/daemon/crash-cart subpath at invocation (dep
|
||||
* rail 2 — rig startup + other verbs never load the daemon module) and composes the shipped detector
|
||||
@@ -63,7 +71,7 @@ function existsOrThrow(path: string): boolean {
|
||||
async function realEmit(): Promise<CrashCartEmit> {
|
||||
const cc = await import("@openrig/daemon/crash-cart");
|
||||
const home = getOpenRigHome();
|
||||
const openrigUrl = process.env.OPENRIG_URL;
|
||||
const openrigUrl = readOpenRigUrl();
|
||||
const probeClassified = (url: string) =>
|
||||
cc.probeHealthz(url, { fetch: (u, init) => fetch(u, init as RequestInit), timeoutMs: 700 });
|
||||
const detectDeps = {
|
||||
@@ -83,9 +91,7 @@ async function realEmit(): Promise<CrashCartEmit> {
|
||||
? `alive (pid ${state.pid})`
|
||||
: `dead (pid ${state.pid})`
|
||||
: "no daemon.json";
|
||||
const url = openrigUrl
|
||||
? `${openrigUrl.replace(/\/$/, "")}/healthz`
|
||||
: `http://${formatDaemonHostForUrl(state?.host ?? "127.0.0.1")}:${state?.port ?? 7433}/healthz`;
|
||||
const url = crashCartEvidenceUrl(openrigUrl, state);
|
||||
const probeResult = await probeClassified(url);
|
||||
return { pidState, probeResult, failedSignal: `healthz ${probeResult} at ${url}` };
|
||||
},
|
||||
|
||||
@@ -125,10 +125,10 @@ export function edgeCommand(depsOverride?: StatusDeps): Command {
|
||||
|
||||
cmd
|
||||
.command("add")
|
||||
.description("Ensure one edge between two existing seats; an existing edge is unchanged")
|
||||
.description("Ensure one typed edge; an existing edge is unchanged")
|
||||
.argument("<rig>", "Target rig name or ID")
|
||||
.argument("[from]", "Source seat logical ID, such as orch.lead")
|
||||
.argument("[to]", "Target seat logical ID, such as review.reviewer")
|
||||
.argument("[from]", "Existing source seat logical ID, such as orch.lead")
|
||||
.argument("[to]", "Target logical ID, or a same-daemon address for escalates_to")
|
||||
.option("--kind <kind>", "delegates_to, spawned_by, can_observe, collaborates_with or escalates_to")
|
||||
.option("--plan", "Validate the request and show the change without writing")
|
||||
.option("--json", "JSON output for agents")
|
||||
@@ -136,8 +136,12 @@ export function edgeCommand(depsOverride?: StatusDeps): Command {
|
||||
Examples:
|
||||
rig edge add my-rig orch.lead review.reviewer --kind delegates_to
|
||||
rig edge add my-rig orch.lead review.reviewer --kind delegates_to --plan --json
|
||||
rig edge add my-rig work.lead review-advisor@coord --kind escalates_to
|
||||
|
||||
Endpoints are fully qualified logical IDs for both same-pod and cross-pod edges.
|
||||
Use existing, fully qualified logical IDs in <rig> for both same-pod and
|
||||
cross-pod edges. For escalates_to only, the target may instead be a canonical
|
||||
same-daemon seat address (pod-member@rig), resolved when used; that rig or seat
|
||||
need not exist yet. Cross-host and synthetic destinations are unsupported.
|
||||
Run it once per edge. A repeat reports the existing edge and its ID, and writes
|
||||
nothing. No seats or sessions are changed.`)
|
||||
.action(async (rig: string, from: string | undefined, to: string | undefined, opts: { kind?: string; plan?: boolean; json?: boolean }) => {
|
||||
|
||||
@@ -72,6 +72,46 @@ export interface DoctorDeps {
|
||||
* sidecar (TOFU + loud-conflict). Optional so existing fixtures keep compiling AND unit runs never
|
||||
* write the real sidecar — absent means learning is skipped, which is the fail-open contract. */
|
||||
learnHostBinding?: (alias: string, observedHostId: string, observedUrl: string) => HostObservationOutcome;
|
||||
/** The registry entry a reply addressed `@<hostId>` resolves to, by the lookup `rig send` makes.
|
||||
* Optional like the learner: absent means doctor makes no claim about where replies go. */
|
||||
replyRoute?: (hostId: string) => HostEntry | null;
|
||||
/** The self-id learned for a registry alias, if any. */
|
||||
learnedHostId?: (alias: string) => string | null;
|
||||
}
|
||||
|
||||
/** Where `rig send` takes a target ending `@<hostId>`: registry id, then a declared hostId, then the
|
||||
* first hosts.yaml entry with that learned binding (cross-host-target.ts). Null when none matches. */
|
||||
export function replyRouteFromDisk(hostId: string): HostEntry | null {
|
||||
const loaded = loadHostRegistry();
|
||||
if (!loaded.ok) return null;
|
||||
const resolved = resolveHost(loaded.registry, hostId, loadHostBindings().bindings);
|
||||
return resolved.ok ? resolved.host : null;
|
||||
}
|
||||
|
||||
const REPLY_ROUTE_ORDER = "rig send resolves it by registry id, then a declared hostId, then the first hosts.yaml entry that learned it";
|
||||
|
||||
/** A reachable entry whose self-id another entry answers for. Unknown, not fail: doctor can't tell
|
||||
* from here whether that other entry still reaches the machine. */
|
||||
function replyRouteElsewhereRow(host: HostEntry, hostId: string, winner: HostEntry): CheckRow {
|
||||
return {
|
||||
step: "reply-route",
|
||||
status: "unknown",
|
||||
detail: `replies addressed @${hostId} go to '${winner.id}', not '${host.id}' (${REPLY_ROUTE_ORDER})`,
|
||||
fix: `run rig host doctor ${winner.id}; if it no longer reaches this machine, delete its entry from ${defaultHostRegistryPath()} (and its line in ${defaultHostBindingsPath()}, if any)`,
|
||||
};
|
||||
}
|
||||
|
||||
/** An unreachable entry that replies still resolve to: the failure a stale entry causes, named on
|
||||
* the entry itself. Null when the entry has no self-id or another entry answers for it. */
|
||||
function replyRouteThroughDeadEntryRow(host: HostEntry, deps: DoctorDeps): CheckRow | null {
|
||||
const hostId = host.hostId ?? deps.learnedHostId?.(host.id) ?? null;
|
||||
if (!hostId || deps.replyRoute?.(hostId)?.id !== host.id) return null;
|
||||
return {
|
||||
step: "reply-route",
|
||||
status: "fail",
|
||||
detail: `replies addressed @${hostId} resolve to '${host.id}', so they fail while it is unreachable`,
|
||||
fix: `if another entry reaches this machine, delete '${host.id}' from ${defaultHostRegistryPath()} (and its line in ${defaultHostBindingsPath()}, if any), then run rig host doctor on that entry; otherwise restore this entry's connectivity`,
|
||||
};
|
||||
}
|
||||
|
||||
function nestedBoolean(obj: unknown, path: readonly string[]): boolean | undefined {
|
||||
@@ -115,6 +155,8 @@ function defaultDoctorDeps(): DoctorDeps {
|
||||
sock.on("error", () => { sock.destroy(); resolve("closed"); });
|
||||
}),
|
||||
learnHostBinding: (alias, observedHostId, observedUrl) => recordHostObservation({ alias, observedHostId, observedUrl }),
|
||||
replyRoute: replyRouteFromDisk,
|
||||
learnedHostId: (alias) => loadHostBindings().bindings[alias]?.hostId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -164,6 +206,8 @@ export async function doctorLegs(host: HostEntry, deps: DoctorDeps): Promise<Che
|
||||
? "SSH auth/permission gate — check the key, agent, and remote authorized_keys"
|
||||
: "verify the ssh target/alias, tailnet connectivity, and that sshd is running",
|
||||
});
|
||||
const routeRow = reach.failedStep === "ssh-unreachable" ? replyRouteThroughDeadEntryRow(host, deps) : null;
|
||||
if (routeRow) rows.push(routeRow);
|
||||
return rows; // later legs are meaningless without the shell
|
||||
}
|
||||
rows.push({ step: "transport-reachability", status: "pass", detail: `ssh to ${host.target} ok` });
|
||||
@@ -256,20 +300,28 @@ export async function doctorLegs(host: HostEntry, deps: DoctorDeps): Promise<Che
|
||||
const parsed = JSON.parse(health.body) as { selfHostId?: unknown };
|
||||
if (typeof parsed.selfHostId === "string" && parsed.selfHostId.length > 0) {
|
||||
const obs = deps.learnHostBinding(host.id, parsed.selfHostId, (host as HttpHostEntry).url);
|
||||
rows.push(obs.outcome === "conflict"
|
||||
? {
|
||||
step: "host-identity-binding",
|
||||
status: "fail",
|
||||
detail: describeBindingConflict(host.id, obs.binding),
|
||||
fix: `if the re-key is legitimate, delete the '${host.id}' entry in ${defaultHostBindingsPath()} and re-run doctor to re-learn`,
|
||||
}
|
||||
: {
|
||||
step: "host-identity-binding",
|
||||
status: "pass",
|
||||
detail: obs.outcome === "bound"
|
||||
? `learned host identity '${obs.binding.hostId}' (first contact) — reply hints ending @${obs.binding.hostId} now resolve to '${host.id}'`
|
||||
: `host identity '${obs.binding.hostId}' confirmed`,
|
||||
});
|
||||
if (obs.outcome === "conflict") {
|
||||
rows.push({
|
||||
step: "host-identity-binding",
|
||||
status: "fail",
|
||||
detail: describeBindingConflict(host.id, obs.binding),
|
||||
fix: `if the re-key is legitimate, delete the '${host.id}' entry in ${defaultHostBindingsPath()} and re-run doctor to re-learn`,
|
||||
});
|
||||
} else {
|
||||
// Ask the route rig send takes rather than assume it: an entry listed earlier in
|
||||
// hosts.yaml with the same identity keeps the replies, even when it no longer answers.
|
||||
const id = obs.binding.hostId;
|
||||
const winner = deps.replyRoute?.(id) ?? null;
|
||||
const routedHere = winner?.id === host.id;
|
||||
rows.push({
|
||||
step: "host-identity-binding",
|
||||
status: "pass",
|
||||
detail: obs.outcome === "bound"
|
||||
? `learned host identity '${id}' (first contact)${routedHere ? ` — reply hints ending @${id} now resolve to '${host.id}'` : ""}`
|
||||
: `host identity '${id}' confirmed`,
|
||||
});
|
||||
if (winner && !routedHere) rows.push(replyRouteElsewhereRow(host, id, winner));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// body not JSON — an older or non-daemon endpoint; learning is skipped, nothing breaks.
|
||||
@@ -277,6 +329,8 @@ export async function doctorLegs(host: HostEntry, deps: DoctorDeps): Promise<Che
|
||||
}
|
||||
} catch (err) {
|
||||
rows.push({ step: "transport-reachability", status: "fail", detail: `daemon unreachable at ${(host as HttpHostEntry).url}: ${(err as Error).message}`, fix: "check tailnet connectivity and that the remote daemon is running (rig daemon start on the host)" });
|
||||
const routeRow = replyRouteThroughDeadEntryRow(host, deps);
|
||||
if (routeRow) rows.push(routeRow);
|
||||
return rows;
|
||||
}
|
||||
rows.push({
|
||||
|
||||
@@ -515,13 +515,10 @@ function notThisCreate(row: Record<string, unknown>, sent: SentCreate, storedSou
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A create whose answer didn't arrive, or arrived unreadable, may still have committed. Read the row back once by the ID it
|
||||
* was sent with, from the daemon that holds it: this one for a local row, the destination's registered daemon for a
|
||||
* cross-host one. Only a row that is this create counts as committed; anything else leaves the outcome unknown. It never
|
||||
* sends the create again. */
|
||||
async function readBackCreate(deps: QueueDeps, client: DaemonClient, hostId: string | undefined, qitemId: string, sent: SentCreate): Promise<{ row: Record<string, unknown> | null; note: string }> {
|
||||
const remote = remoteHostOf(hostId);
|
||||
const where = remote ? ` on host '${remote}'` : "";
|
||||
/** Both reconciliation and --verify read the daemon that holds the row, with its own bearer. */
|
||||
async function queueReadTarget(deps: QueueDeps, client: DaemonClient, hostId: string | undefined) {
|
||||
let remote = remoteHostOf(hostId);
|
||||
if (remote && await daemonSelfHostId(client) === remote) remote = undefined;
|
||||
let reader = client;
|
||||
let options: { timeoutMs: number; headers?: Record<string, string> } = { timeoutMs: CREATE_READ_BACK_TIMEOUT_MS };
|
||||
if (remote) {
|
||||
@@ -529,15 +526,45 @@ async function readBackCreate(deps: QueueDeps, client: DaemonClient, hostId: str
|
||||
const match = registry.ok ? resolveHost(registry.registry, remote, loadHostBindings().bindings) : undefined;
|
||||
if (!match?.ok || match.host.transport !== "http") {
|
||||
const why = match?.ok ? "its entry in this CLI's host registry is SSH, not a daemon URL" : "it isn't in this CLI's host registry";
|
||||
return { row: null, note: `This CLI couldn't read the row back from host '${remote}' (${why}).` };
|
||||
return { error: `from host '${remote}' (${why})` };
|
||||
}
|
||||
const bearer = resolveRemoteBearer(match.host);
|
||||
if (!bearer.ok) return { row: null, note: `This CLI couldn't read the row back from host '${remote}' (${bearer.error}).` };
|
||||
if (!bearer.ok) return { error: `from host '${remote}' (${bearer.error})` };
|
||||
reader = deps.clientFactory(match.host.url);
|
||||
if (bearer.token) options = { ...options, headers: { Authorization: `Bearer ${bearer.token}` } };
|
||||
}
|
||||
return { reader, options };
|
||||
}
|
||||
|
||||
/** A successful forward isn't a delivery receipt. Never accept a different remote row as this create's receipt. */
|
||||
function createReceiptReader(deps: QueueDeps, client: DaemonClient, hostId: string | undefined, qitemId: string, sent: SentCreate): Pick<DaemonClient, "get"> {
|
||||
if (!remoteHostOf(hostId)) return client;
|
||||
let target: ReturnType<typeof queueReadTarget> | undefined;
|
||||
let source: ReturnType<typeof storedSourceOf> | undefined;
|
||||
return {
|
||||
get: async <T>(path: string) => {
|
||||
const selected = await (target ??= queueReadTarget(deps, client, hostId));
|
||||
if ("error" in selected) throw new Error(`receipt could not be read ${selected.error}`);
|
||||
const response = await selected.reader.get<T>(path, selected.options);
|
||||
const row = response.data as Record<string, unknown> | undefined;
|
||||
if (response.status === 200 && (!row || row.qitemId !== qitemId
|
||||
|| notThisCreate(row, sent, await (source ??= storedSourceOf(client, sent))) !== null)) {
|
||||
throw new Error("receipt row does not match the created qitem");
|
||||
}
|
||||
return response;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A create whose answer didn't arrive, or arrived unreadable, may still have committed. Read the row back once by the ID it
|
||||
* was sent with, from the daemon that holds it. Only this create's row counts; it never sends the create again. */
|
||||
async function readBackCreate(deps: QueueDeps, client: DaemonClient, hostId: string | undefined, qitemId: string, sent: SentCreate): Promise<{ row: Record<string, unknown> | null; note: string }> {
|
||||
const remote = remoteHostOf(hostId);
|
||||
const where = remote ? ` on host '${remote}'` : "";
|
||||
const target = await queueReadTarget(deps, client, hostId);
|
||||
if ("error" in target) return { row: null, note: `This CLI couldn't read the row back ${target.error}.` };
|
||||
try {
|
||||
const res = await reader.get<Record<string, unknown>>(`/api/queue/${encodeURIComponent(qitemId)}`, options);
|
||||
const res = await target.reader.get<Record<string, unknown>>(`/api/queue/${encodeURIComponent(qitemId)}`, target.options);
|
||||
if (res.status === 200 && res.data?.qitemId === qitemId) {
|
||||
const differs = notThisCreate(res.data, sent, await storedSourceOf(client, sent));
|
||||
if (differs === "unread") {
|
||||
@@ -1079,7 +1106,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
if (opts.verify && status < 400) {
|
||||
const createdId = typeof created.qitemId === "string" ? created.qitemId : null;
|
||||
const delivery = createdId
|
||||
? await waitForDeliveryOutcome(client, createdId, deps.deliveryVerify, { destinationSession: hostResolved.destination, nudge: opts.nudge })
|
||||
? await waitForDeliveryOutcome(createReceiptReader(deps, client, hostResolved.hostId, qitemId, sent), createdId, deps.deliveryVerify, { destinationSession: hostResolved.destination, nudge: opts.nudge })
|
||||
: unreadableDeliveryResult(null, hostResolved.destination, {}, "create response did not include a qitem id; delivery cannot be correlated");
|
||||
await printWriteResult(client, opts.json ?? false, { ...created, qitemId: createdId, persisted: true, delivery }, status, receipt);
|
||||
return;
|
||||
|
||||
@@ -9,7 +9,7 @@ import { runCrossHostCommand, type RunCrossHostCommandOpts } from "../cross-host
|
||||
import { emitCrossHostError, emitCrossHostFailure, emitRemoteHttpFailure } from "../cross-host-cli-helpers.js";
|
||||
import { resolveCrossHostTarget } from "../cross-host-target.js";
|
||||
import { requestMayHaveArrived, runRemoteHttpOp } from "../remote-host-ops.js";
|
||||
import { readOpenRigEnv } from "../openrig-compat.js";
|
||||
import { readOpenRigUrl } from "../openrig-compat.js";
|
||||
import { resolveSenderSession, SENDER_FALLBACK } from "../sender-identity.js";
|
||||
import { COMPOSER_PROMPT_CLASS } from "@openrig/daemon/composer-prompts";
|
||||
import { resolveContextRef, walkSizedWarning } from "../context-resolve.js";
|
||||
@@ -186,7 +186,7 @@ export function refuseEmptyMessage(body: string, verb: "send" | "broadcast", jso
|
||||
* keeps the single-seat and fan-out callers from having to sequence it
|
||||
* identically in two places. */
|
||||
async function resolveLocalDaemonUrl(deps: SendDeps): Promise<string> {
|
||||
const envUrl = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL");
|
||||
const envUrl = readOpenRigUrl();
|
||||
if (envUrl) return envUrl;
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state === "running" && status.port !== undefined) return getDaemonUrl(status);
|
||||
|
||||
@@ -3,6 +3,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { Command } from "commander";
|
||||
import { formatDaemonHostForUrl } from "../client.js";
|
||||
import { readOpenRigUrl } from "../openrig-compat.js";
|
||||
|
||||
export interface StartupProofDeps {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
@@ -33,7 +34,9 @@ export function resolveStartupProofEndpoint(deps: StartupProofDeps = {}): Endpoi
|
||||
const env = deps.env ?? process.env;
|
||||
const readFile = deps.readFile ?? ((p: string) => fs.readFileSync(p, "utf8"));
|
||||
const home = first(env["OPENRIG_HOME"], env["RIGGED_HOME"]) ?? path.join(os.homedir(), ".openrig");
|
||||
let baseUrl = first(env["OPENRIG_URL"], env["RIGGED_URL"]);
|
||||
// Startup proof has always trimmed these and skipped blank ones; keep that, then pick and
|
||||
// normalize through the shared reader like every other OPENRIG_URL reader.
|
||||
let baseUrl = readOpenRigUrl({ OPENRIG_URL: env["OPENRIG_URL"]?.trim(), RIGGED_URL: env["RIGGED_URL"]?.trim() }) ?? null;
|
||||
let token = first(env["OPENRIG_ACTIVITY_HOOK_TOKEN"], env["RIGGED_ACTIVITY_HOOK_TOKEN"]);
|
||||
|
||||
if (!baseUrl || !token) {
|
||||
|
||||
@@ -126,7 +126,7 @@ export function terminalCommand(depsOverride?: TerminalDeps): Command {
|
||||
|
||||
cmd
|
||||
.command("open")
|
||||
.argument("<view>", "a rig name, mission:<id>, slice:<id>, qitem:<id> (current local obligation owners), or a saved-view id")
|
||||
.argument("<view>", "a rig name, pod:<rig>/<pod>, seat:<rig>/<logical-id>, mission:<id>, slice:<id>, qitem:<id> (current local obligation owners), or a saved-view id")
|
||||
.description("Open a desktop terminal showing the view's live agents as interactive tiles")
|
||||
.option("--provider <name>", "herdr, cmux or rex: open in the running provider without a desktop window; each open adds a new workspace (inside Herdr, --window reuses a matching one); tmux requires --window. Inside Rex, the default opens in Rex")
|
||||
.option("--session <name>", "Herdr session on the daemon's host; --session default selects Herdr's default session")
|
||||
|
||||
@@ -7,7 +7,7 @@ export { daemonMayAnswer } from "./daemon-reachability.js";
|
||||
import type { DaemonStartLock } from "./daemon-start-lock.js";
|
||||
import { DAEMON_STOP_WAIT_MS, DAEMON_SHUTDOWN_RECEIPT, type DaemonShutdownReceipt } from "@openrig/daemon/daemon-shutdown";
|
||||
import { ConfigStore } from "./config-store.js";
|
||||
import { OPENRIG_HOME, LEGACY_RIGGED_HOME, readOpenRigEnv } from "./openrig-compat.js";
|
||||
import { OPENRIG_HOME, LEGACY_RIGGED_HOME, readOpenRigEnv, readOpenRigUrl } from "./openrig-compat.js";
|
||||
// Types only: the module itself (with the daemon's yaml-reading system world) is imported where an
|
||||
// instance is initialized, so a command that only talks to the daemon never loads it.
|
||||
import type {
|
||||
@@ -72,6 +72,18 @@ export interface GetDaemonStatusOptions {
|
||||
cleanupStaleState?: boolean;
|
||||
}
|
||||
|
||||
/** True for `http://host:port` with an explicit, non-default port and no path, query or
|
||||
* fragment: the only OPENRIG_URL shape getDaemonUrl's host-and-port rebuild reaches unchanged
|
||||
* (without a port, or with :80, which URL reports as no port, status falls back to 7433). */
|
||||
function isPlainHttpOrigin(url: string): boolean {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return u.protocol === "http:" && u.port !== "" && u.pathname === "/" && !u.search && !u.hash && !url.endsWith("/");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the daemon HTTP URL from status. Uses persisted host or defaults to 127.0.0.1. */
|
||||
export function getDaemonUrl(status: DaemonStatus): string {
|
||||
return `http://${formatDaemonHostForUrl(status.host ?? DEFAULT_HOST)}:${status.port}`;
|
||||
@@ -905,7 +917,7 @@ function removeMatchingState(deps: LifecycleDeps, stateFile: string, state: Daem
|
||||
export async function stopDaemon(deps: LifecycleDeps): Promise<"stopped" | "no-target"> {
|
||||
const state = readState(deps);
|
||||
const configured = resolveConfiguredDaemonTarget();
|
||||
const explicitUrl = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL");
|
||||
const explicitUrl = readOpenRigUrl();
|
||||
const stateUrl = state ? `http://${formatDaemonHostForUrl(state.host ?? DEFAULT_HOST)}:${state.port}` : undefined;
|
||||
const target = stateUrl ?? explicitUrl?.replace(/\/+$/, "") ?? `http://${formatDaemonHostForUrl(configured.host)}:${configured.port}`;
|
||||
if (stateUrl && explicitUrl && new URL(explicitUrl).origin !== new URL(stateUrl).origin) {
|
||||
@@ -1028,8 +1040,14 @@ export async function getDaemonStatus(
|
||||
deps: LifecycleDeps,
|
||||
options: GetDaemonStatusOptions = {},
|
||||
): Promise<DaemonStatus> {
|
||||
// If OPENRIG_URL is set, bypass daemon.json and probe that URL directly
|
||||
const openrigUrl = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL");
|
||||
// If OPENRIG_URL is set, bypass daemon.json and probe that URL directly. Commands reach
|
||||
// it through getDaemonUrl's http://host:port rebuild, so only a plain http origin uses
|
||||
// the normalized form; any other URL is probed as written, exactly as before.
|
||||
const rawOpenrigUrl = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL");
|
||||
const normalizedOpenrigUrl = readOpenRigUrl();
|
||||
const openrigUrl = normalizedOpenrigUrl !== undefined && isPlainHttpOrigin(normalizedOpenrigUrl)
|
||||
? normalizedOpenrigUrl
|
||||
: rawOpenrigUrl;
|
||||
if (openrigUrl) {
|
||||
// An unverified daemon keeps the endpoint it was probed at, so a command can still make its request.
|
||||
const endpoint = (() => {
|
||||
|
||||
@@ -554,8 +554,9 @@ export function findSlice(
|
||||
continue;
|
||||
}
|
||||
const mission = buildMissionInfo(missionsRoot, owningMissionPath);
|
||||
const sliceRoot = path.dirname(candidate);
|
||||
return buildSliceInfo(mission, sliceRoot, path.basename(candidate));
|
||||
// The directory and the name both come from the resolved path, so `<slice>/.` and `<slice>/sub/..` select the slice.
|
||||
const resolved = path.resolve(candidate);
|
||||
return buildSliceInfo(mission, path.dirname(resolved), path.basename(resolved));
|
||||
}
|
||||
}
|
||||
if (firstContextError) throw firstContextError;
|
||||
@@ -567,9 +568,18 @@ export function findSlice(
|
||||
}
|
||||
|
||||
function findOwningMission(missionsRoot: string, slicePath: string): string | null {
|
||||
// path.dirname never ends in a separator, so compare against the normalized root
|
||||
// (a configured or --workspace root may be written as `/w/missions/`); resolve the
|
||||
// slice the same way so a relative root and a relative candidate stay comparable.
|
||||
// A path that leaves the root, such as `<root>/../other/m1/slices/x`, then falls back
|
||||
// to the walk as written, where it reads as before.
|
||||
return walkToMission(path.resolve(missionsRoot), path.resolve(slicePath)) ?? walkToMission(missionsRoot, slicePath);
|
||||
}
|
||||
|
||||
function walkToMission(root: string, slicePath: string): string | null {
|
||||
let dir = path.dirname(slicePath);
|
||||
while (dir.startsWith(missionsRoot) && dir !== missionsRoot) {
|
||||
if (path.dirname(dir) === missionsRoot) return dir;
|
||||
while (dir.startsWith(root) && dir !== root) {
|
||||
if (path.dirname(dir) === root) return dir;
|
||||
dir = path.dirname(dir);
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -55,12 +55,12 @@ function warnOnce(key: string, message: string): void {
|
||||
console.warn(message);
|
||||
}
|
||||
|
||||
export function readOpenRigEnv(primary: string, legacy?: string): string | undefined {
|
||||
const primaryValue = process.env[primary];
|
||||
export function readOpenRigEnv(primary: string, legacy?: string, env: NodeJS.ProcessEnv = process.env): string | undefined {
|
||||
const primaryValue = env[primary];
|
||||
if (primaryValue !== undefined && primaryValue !== "") return primaryValue;
|
||||
|
||||
if (legacy) {
|
||||
const legacyValue = process.env[legacy];
|
||||
const legacyValue = env[legacy];
|
||||
if (legacyValue !== undefined && legacyValue !== "") {
|
||||
warnOnce(`env:${legacy}`, `Warning: ${legacy} is deprecated; use ${primary} instead.`);
|
||||
return legacyValue;
|
||||
@@ -70,6 +70,23 @@ export function readOpenRigEnv(primary: string, legacy?: string): string | undef
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** An explicit daemon URL with trailing slashes removed, so `http://h:7433/` addresses the
|
||||
* same daemon as `http://h:7433`. A URL with a query or fragment is returned exactly as
|
||||
* written, and so is a value that would strip down to no host (such as `/`), which stays
|
||||
* an explicit (if invalid) target. */
|
||||
export function normalizeOpenRigUrl(raw: string): string {
|
||||
if (/[?#]/.test(raw)) return raw;
|
||||
const stripped = raw.replace(/\/+$/, "");
|
||||
return /:\/\/[^/]/.test(stripped) ? stripped : raw;
|
||||
}
|
||||
|
||||
/** OPENRIG_URL (legacy RIGGED_URL), normalized by normalizeOpenRigUrl. `env` defaults to
|
||||
* process.env; callers with an injected environment pass theirs. */
|
||||
export function readOpenRigUrl(env: NodeJS.ProcessEnv = process.env): string | undefined {
|
||||
const raw = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL", env);
|
||||
return raw === undefined ? undefined : normalizeOpenRigUrl(raw);
|
||||
}
|
||||
|
||||
export function getPreferredOpenRigHome(): string {
|
||||
const openrigHome = getOpenRigHome();
|
||||
const legacyRiggedHome = getLegacyRiggedHome();
|
||||
|
||||
@@ -401,9 +401,7 @@ export async function openTerminalWindow(client: DaemonClient, view: string, req
|
||||
...(session !== undefined ? { session } : {}), ...(expectedPlan !== undefined ? { expectedPlan } : {}) },
|
||||
{ timeoutMs: 45_000, headers: terminalAuthHeaders() });
|
||||
if (!answer.data.ok || !answer.data.workspaceId) return { ...answer.data, window, notes: [...(answer.data.notes ?? []), ...windowNotes] };
|
||||
const selectedSession = session ?? endpoint.session;
|
||||
const selectedArgs = ["-u", "TMUX", "-u", "HERDR_SESSION", "-u", "HERDR_SOCKET_PATH", herdr,
|
||||
...(selectedSession && selectedSession !== "default" ? ["--session", selectedSession] : [])];
|
||||
const selectedArgs = herdrArgs;
|
||||
const selectedStatus = JSON.parse(await deps.exec("/usr/bin/env", [...selectedArgs, "status", "server", "--json"]));
|
||||
const selectedBody = selectedStatus?.result ?? selectedStatus;
|
||||
if (selectedStatus.error || selectedBody?.running !== true || selectedBody.socket !== endpoint.socketPath) {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { chatroomWatchTmuxArgs } from "../src/commands/chatroom.js";
|
||||
|
||||
const sessionArg = (rig: string) => { const args = chatroomWatchTmuxArgs(rig); return args[args.indexOf("-s") + 1]; };
|
||||
|
||||
describe("chatroom watch --tmux arguments", () => {
|
||||
it("hands tmux the watcher as separate arguments, with the rig name after --, and asks for the name it used", () => {
|
||||
expect(chatroomWatchTmuxArgs("my rig")).toEqual(["new-session", "-d", "-P", "-F", "#{session_name}", "-s", "chatroom@my rig",
|
||||
"rig", "chatroom", "watch", "--", "my rig"]);
|
||||
});
|
||||
|
||||
// tmux reads -s as a format: `##` is one `#`, and a run of `#` before `[` is a style it keeps as written.
|
||||
it.each([
|
||||
["my-rig", "chatroom@my-rig"],
|
||||
["a#b", "chatroom@a##b"],
|
||||
["a##b", "chatroom@a####b"],
|
||||
["a#", "chatroom@a##"],
|
||||
["a#{b}", "chatroom@a##{b}"],
|
||||
["a#[fg=red]b", "chatroom@a#[fg=red]b"],
|
||||
["a##[b", "chatroom@a##[b"],
|
||||
])("encodes %j for tmux's session-name format as %j", (rig, session) => {
|
||||
expect(sessionArg(rig)).toBe(session);
|
||||
});
|
||||
|
||||
// tmux ends a command at an argument ending in `;` unless a `\` comes before it.
|
||||
it.each([
|
||||
["a;", "chatroom@a\\;", "a\\;"],
|
||||
[";", "chatroom@\\;", "\\;"],
|
||||
["a\\;", "chatroom@a\\\\;", "a\\\\;"],
|
||||
["a;b", "chatroom@a;b", "a;b"],
|
||||
])("puts a \\ before a final ; in %j", (rig, session, watcherArg) => {
|
||||
const args = chatroomWatchTmuxArgs(rig);
|
||||
expect(sessionArg(rig)).toBe(session);
|
||||
expect(args[args.length - 1]).toBe(watcherArg);
|
||||
});
|
||||
});
|
||||
|
||||
const TMUX_SOCKET = `openrig-chatroom-test-${process.pid}`;
|
||||
const tmuxAvailable = spawnSync("tmux", ["-V"]).status === 0;
|
||||
const names = ["my-rig", "my rig", "it's", 'say "hi"', "$(echo pwned)", "`id`", "a;b", "a$HOME", "-rig",
|
||||
"a#b", "a##b", "a#{b}", "a#[fg=red]b", "a##[b", "a;", ";"];
|
||||
|
||||
describe.runIf(tmuxAvailable)("chatroom watch --tmux against a real tmux server (private socket)", () => {
|
||||
let dir = "";
|
||||
let recorder = "";
|
||||
beforeAll(() => {
|
||||
dir = mkdtempSync(path.join(tmpdir(), "openrig-chatroom-watch-"));
|
||||
// Stands in for `rig`: records the arguments the watcher gets, then stays up so the session lives.
|
||||
recorder = path.join(dir, "record.cjs");
|
||||
writeFileSync(recorder, "require('node:fs').writeFileSync(process.argv[2], JSON.stringify(process.argv.slice(3)));\nsetTimeout(() => {}, 30000);\n");
|
||||
});
|
||||
afterAll(() => {
|
||||
spawnSync("tmux", ["-L", TMUX_SOCKET, "kill-server"]);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// The exact arguments, with only the `rig` program swapped for the recorder; tmux prints the session it made.
|
||||
function start(rig: string, out: string): string {
|
||||
const args = chatroomWatchTmuxArgs(rig);
|
||||
const program = args.indexOf("-s") + 2;
|
||||
return execFileSync("tmux", ["-L", TMUX_SOCKET, "-f", "/dev/null", ...args.slice(0, program), process.execPath, recorder, out,
|
||||
...args.slice(program + 1)], { encoding: "utf8" }).trim();
|
||||
}
|
||||
|
||||
it.each(names.map((rig, i) => [rig, i] as const))("keeps the session chatroom@%s and hands the watcher the name as one argument", async (rig, i) => {
|
||||
const out = path.join(dir, `argv-${i}.json`);
|
||||
expect(start(rig, out)).toBe(`chatroom@${rig}`);
|
||||
await vi.waitFor(() => expect(JSON.parse(readFileSync(out, "utf8"))).toEqual(["chatroom", "watch", "--", rig]), { timeout: 5000 });
|
||||
});
|
||||
|
||||
// Whether tmux changes a `.` depends on its version (3.5a writes `_`; the tmux CI runs keeps the `.`), so the CLI
|
||||
// prints the name tmux reports, which is always the session tmux has.
|
||||
it("reports the name tmux used for a rig name with a ., whichever form its version keeps", async () => {
|
||||
const out = path.join(dir, "argv-dot.json");
|
||||
const reported = start("release-0.7", out);
|
||||
expect(["[email protected]", "chatroom@release-0_7"]).toContain(reported);
|
||||
expect(execFileSync("tmux", ["-L", TMUX_SOCKET, "list-sessions", "-F", "#{session_name}"], { encoding: "utf8" }).split("\n"))
|
||||
.toContain(reported);
|
||||
await vi.waitFor(() => expect(JSON.parse(readFileSync(out, "utf8"))).toEqual(["chatroom", "watch", "--", "release-0.7"]), { timeout: 5000 });
|
||||
});
|
||||
});
|
||||
@@ -232,6 +232,80 @@ describe("doctorLegs — Source-1 learning from the healthz body already in hand
|
||||
} as never);
|
||||
expect(rows.find((r) => r.step === "host-identity-binding")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("first contact where replies do resolve here keeps the claim and adds no route row", async () => {
|
||||
const { doctorLegs } = await import("../src/commands/host.js");
|
||||
const rows = await doctorLegs(anonHost, {
|
||||
...baseDeps,
|
||||
httpGet: async () => ({ status: 200, body: JSON.stringify({ selfHostId: "host-84c37990" }) }),
|
||||
learnHostBinding: (_alias: string, id: string) => ({ outcome: "bound", binding: { hostId: id, firstObservedAt: "x", lastObservedAt: "x" } }),
|
||||
replyRoute: () => anonHost,
|
||||
} as never);
|
||||
expect(rows.find((r) => r.step === "host-identity-binding")!.detail).toContain("now resolve to 'mm2-host'");
|
||||
expect(rows.find((r) => r.step === "reply-route")).toBeUndefined();
|
||||
});
|
||||
|
||||
// An earlier hosts.yaml entry that learned the same identity keeps the replies, even after its
|
||||
// address stops answering. Real learner and real resolver over one sidecar file.
|
||||
it("an earlier entry with the same identity: doctor names it on the working entry and fails it when dead", async () => {
|
||||
const { doctorLegs } = await import("../src/commands/host.js");
|
||||
const p = tmpPath();
|
||||
const stale = { id: "old-box-b", transport: "http", url: "http://10.0.0.9:7433" } as const;
|
||||
const working = { id: "box-b", transport: "http", url: "http://10.0.0.5:7433" } as const;
|
||||
const registry: HostRegistry = { hosts: [stale, working] };
|
||||
recordHostObservation({ alias: stale.id, observedHostId: "host-0b0b0b0b", observedUrl: stale.url, path: p });
|
||||
const deps = {
|
||||
...baseDeps,
|
||||
learnHostBinding: (alias: string, id: string, url: string) => recordHostObservation({ alias, observedHostId: id, observedUrl: url, path: p }),
|
||||
replyRoute: (id: string) => {
|
||||
const r = resolveHost(registry, id, loadHostBindings(p).bindings);
|
||||
return r.ok ? r.host : null;
|
||||
},
|
||||
learnedHostId: (alias: string) => loadHostBindings(p).bindings[alias]?.hostId ?? null,
|
||||
};
|
||||
|
||||
const live = await doctorLegs(working, { ...deps, httpGet: async () => ({ status: 200, body: JSON.stringify({ selfHostId: "host-0b0b0b0b" }) }) } as never);
|
||||
const identity = live.find((r) => r.step === "host-identity-binding")!;
|
||||
expect(identity.status).toBe("pass");
|
||||
expect(identity.detail).not.toContain("now resolve to");
|
||||
const route = live.find((r) => r.step === "reply-route")!;
|
||||
expect(route.status).toBe("unknown");
|
||||
expect(route.detail).toContain("go to 'old-box-b', not 'box-b'");
|
||||
expect(route.fix).toContain("rig host doctor old-box-b");
|
||||
// The entry doctor names is the one rig send's own resolver picks.
|
||||
const sent = resolveCrossHostTarget("pm@some-rig@host-0b0b0b0b", undefined, () => ({ ok: true as const, registry }), undefined, () => loadHostBindings(p));
|
||||
expect(sent).toMatchObject({ ok: true, sugarHost: "old-box-b" });
|
||||
|
||||
const dead = await doctorLegs(stale, { ...deps, httpGet: async () => { throw new Error("read ECONNRESET"); } } as never);
|
||||
expect(dead.find((r) => r.step === "transport-reachability")!.status).toBe("fail");
|
||||
const deadRoute = dead.find((r) => r.step === "reply-route")!;
|
||||
expect(deadRoute.status).toBe("fail");
|
||||
expect(deadRoute.detail).toContain("@host-0b0b0b0b resolve to 'old-box-b'");
|
||||
expect(deadRoute.fix).toContain("delete 'old-box-b'");
|
||||
});
|
||||
|
||||
it("an unreachable entry that replies don't resolve to adds no route row", async () => {
|
||||
const { doctorLegs } = await import("../src/commands/host.js");
|
||||
const rows = await doctorLegs(anonHost, {
|
||||
...baseDeps,
|
||||
httpGet: async () => { throw new Error("connect ECONNREFUSED"); },
|
||||
learnedHostId: () => "host-84c37990",
|
||||
replyRoute: () => ({ id: "other-host", transport: "http", url: "http://y:7433" }),
|
||||
} as never);
|
||||
expect(rows.map((r) => r.step)).toEqual(["transport-reachability"]);
|
||||
});
|
||||
|
||||
it("an unreachable ssh entry whose declared hostId replies resolve to gets the route row too", async () => {
|
||||
const { doctorLegs } = await import("../src/commands/host.js");
|
||||
const sshHost = { id: "vps-1", transport: "ssh", target: "vps-1.tailnet", user: "openrig", hostId: "host-84c37990" } as const;
|
||||
const rows = await doctorLegs(sshHost, {
|
||||
...baseDeps,
|
||||
run: async () => ({ ok: false, failedStep: "ssh-unreachable", sshStderr: "connect refused" }),
|
||||
httpGet: async () => ({ status: 200, body: "" }),
|
||||
replyRoute: (id: string) => (id === "host-84c37990" ? sshHost : null),
|
||||
} as never);
|
||||
expect(rows.find((r) => r.step === "reply-route")).toMatchObject({ status: "fail" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("F2 — concurrency guard: conflict records survive concurrent writers", () => {
|
||||
|
||||
@@ -376,6 +376,26 @@ describe("rig host doctor — stepwise distinct errors + three-valued posture",
|
||||
expect(err.join("\n")).toContain("unknown host id 'nope'");
|
||||
});
|
||||
|
||||
it("doctor's reply route reads the same hosts.yaml and host-bindings.json rig send does", async () => {
|
||||
const { replyRouteFromDisk } = await import("../src/commands/host.js");
|
||||
const { resolveCrossHostTarget } = await import("../src/cross-host-target.js");
|
||||
fs.writeFileSync(path.join(dir, "hosts.yaml"), [
|
||||
"hosts:",
|
||||
" - id: old-box-b",
|
||||
" transport: http",
|
||||
" url: http://10.0.0.9:7433",
|
||||
" - id: box-b",
|
||||
" transport: http",
|
||||
" url: http://10.0.0.5:7433",
|
||||
].join("\n") + "\n");
|
||||
// Both aliases learned one identity; hosts.yaml order, not sidecar order, picks the entry.
|
||||
const learned = { hostId: "host-0b0b0b0b", firstObservedAt: "x", lastObservedAt: "x" };
|
||||
fs.writeFileSync(path.join(dir, "host-bindings.json"), JSON.stringify({ version: 1, bindings: { "box-b": learned, "old-box-b": learned } }));
|
||||
expect(replyRouteFromDisk("host-0b0b0b0b")?.id).toBe("old-box-b");
|
||||
expect(resolveCrossHostTarget("pm@some-rig@host-0b0b0b0b", undefined)).toMatchObject({ ok: true, sugarHost: "old-box-b" });
|
||||
expect(replyRouteFromDisk("host-unknown")).toBeNull();
|
||||
});
|
||||
|
||||
// seed a registry for the doctor CLI test above
|
||||
async function capturePrelude(): Promise<void> {
|
||||
const prog = new Command();
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { DaemonClient } from "../src/client.js";
|
||||
import { getDaemonStatus, stopDaemon, type LifecycleDeps } from "../src/daemon-lifecycle.js";
|
||||
import { normalizeOpenRigUrl } from "../src/openrig-compat.js";
|
||||
import { resolveStartupProofEndpoint } from "../src/commands/startup-proof.js";
|
||||
import { crashCartEvidenceUrl } from "../src/commands/crash-cart.js";
|
||||
|
||||
// OPENRIG_URL=http://host:port/ (trailing slash) addresses the same daemon as
|
||||
// http://host:port; the CLI must not request //healthz or //api/... from it.
|
||||
describe("OPENRIG_URL with a trailing slash", () => {
|
||||
afterEach(() => vi.unstubAllEnvs());
|
||||
|
||||
function probeDeps(ok: (url: string) => boolean) {
|
||||
const urls: string[] = [];
|
||||
const fetch = vi.fn(async (url: string) => {
|
||||
urls.push(url);
|
||||
return { ok: ok(url) };
|
||||
});
|
||||
const exists = vi.fn(() => false);
|
||||
const readFile = vi.fn(() => null);
|
||||
return { urls, exists, readFile, deps: { fetch, exists, readFile } as unknown as LifecycleDeps };
|
||||
}
|
||||
|
||||
it.each([
|
||||
["http://127.0.0.1:7433/", "http://127.0.0.1:7433"],
|
||||
["http://127.0.0.1:7433//", "http://127.0.0.1:7433"],
|
||||
["http://h:7433/p/", "http://h:7433/p"],
|
||||
["https://h/", "https://h"],
|
||||
["http://h:7433/?a=b/", "http://h:7433/?a=b/"],
|
||||
["http://h:7433/p/?x", "http://h:7433/p/?x"],
|
||||
["http://h:7433/p/#x/", "http://h:7433/p/#x/"],
|
||||
["http://h:7433", "http://h:7433"],
|
||||
["/", "/"],
|
||||
["//", "//"],
|
||||
["http://", "http://"],
|
||||
])("normalizeOpenRigUrl(%s) is %s (a query or fragment URL stays as written)", (raw, normalized) => {
|
||||
expect(normalizeOpenRigUrl(raw)).toBe(normalized);
|
||||
});
|
||||
|
||||
it("getDaemonStatus probes <origin>/healthz, not <origin>//healthz", async () => {
|
||||
vi.stubEnv("OPENRIG_URL", "http://127.0.0.1:7555/");
|
||||
const { urls, deps } = probeDeps((url) => url === "http://127.0.0.1:7555/healthz");
|
||||
const status = await getDaemonStatus(deps);
|
||||
expect(urls).toEqual(["http://127.0.0.1:7555/healthz"]);
|
||||
expect(status).toMatchObject({ state: "running", healthy: true, port: 7555, host: "127.0.0.1" });
|
||||
});
|
||||
|
||||
// Commands reach OPENRIG_URL through getDaemonUrl's http://host:port rebuild, so the probe
|
||||
// only changes for a plain http origin; a prefix or https URL is probed exactly as before.
|
||||
it.each([
|
||||
["http://127.0.0.1:7555/p/", "http://127.0.0.1:7555/p//healthz"],
|
||||
["https://127.0.0.1:7555/", "https://127.0.0.1:7555//healthz"],
|
||||
["http://127.0.0.1:7555/?x=1", "http://127.0.0.1:7555/?x=1/healthz"],
|
||||
// No explicit port (or :80, which URL reports as none): status would hand commands 7433,
|
||||
// so the probe must not succeed on port 80 for them.
|
||||
["http://127.0.0.1/", "http://127.0.0.1//healthz"],
|
||||
["http://127.0.0.1:80/", "http://127.0.0.1:80//healthz"],
|
||||
])("getDaemonStatus probes %s as main does (%s)", async (url, probed) => {
|
||||
vi.stubEnv("OPENRIG_URL", url);
|
||||
const { urls, deps } = probeDeps(() => false);
|
||||
await getDaemonStatus(deps);
|
||||
expect(urls).toEqual([probed]);
|
||||
});
|
||||
|
||||
it("a slash-only OPENRIG_URL stays an explicit target and never selects the local daemon", async () => {
|
||||
vi.stubEnv("OPENRIG_URL", "/");
|
||||
const { urls, exists, readFile, deps } = probeDeps(() => true);
|
||||
await getDaemonStatus(deps);
|
||||
expect(urls).toEqual(["//healthz"]);
|
||||
expect(exists).not.toHaveBeenCalled();
|
||||
expect(readFile).not.toHaveBeenCalled();
|
||||
expect(new DaemonClient().baseUrl).toBe("/");
|
||||
});
|
||||
|
||||
it("a default DaemonClient requests <url>/api/..., not <url>//api/...", async () => {
|
||||
vi.stubEnv("OPENRIG_URL", "http://127.0.0.1:7555//");
|
||||
const urls: string[] = [];
|
||||
const fetchImpl = vi.fn(async (url: string | URL | Request) => {
|
||||
urls.push(String(url));
|
||||
return new Response("[]", { status: 200, headers: { "content-type": "application/json" } });
|
||||
}) as unknown as typeof fetch;
|
||||
const client = new DaemonClient(undefined, { fetchImpl });
|
||||
await client.get("/api/ps");
|
||||
expect(urls).toContain("http://127.0.0.1:7555/api/ps");
|
||||
expect(urls.some((url) => url.includes("7555//"))).toBe(false);
|
||||
});
|
||||
|
||||
it("a default DaemonClient keeps a path prefix and scheme", () => {
|
||||
vi.stubEnv("OPENRIG_URL", "https://h:7433/p/");
|
||||
expect(new DaemonClient().baseUrl).toBe("https://h:7433/p");
|
||||
});
|
||||
|
||||
it("the startup-proof endpoint reads OPENRIG_URL through the same normalization", () => {
|
||||
const endpoint = resolveStartupProofEndpoint({
|
||||
env: { OPENRIG_URL: "http://127.0.0.1:7555/", OPENRIG_ACTIVITY_HOOK_TOKEN: "tok" },
|
||||
readFile: () => { throw new Error("no endpoint file"); },
|
||||
});
|
||||
expect(endpoint).toEqual({ baseUrl: "http://127.0.0.1:7555", token: "tok" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["http://127.0.0.1:7555/p/?x=1", "http://127.0.0.1:7555/p/?x=1/api/ps"],
|
||||
["http://127.0.0.1:7555/p/#frag", "http://127.0.0.1:7555/p/#frag/api/ps"],
|
||||
])("a default DaemonClient keeps main's base for %s", async (url, requested) => {
|
||||
vi.stubEnv("OPENRIG_URL", url);
|
||||
const urls: string[] = [];
|
||||
const fetchImpl = vi.fn(async (u: string | URL | Request) => {
|
||||
urls.push(String(u));
|
||||
return new Response("[]", { status: 200, headers: { "content-type": "application/json" } });
|
||||
}) as unknown as typeof fetch;
|
||||
const client = new DaemonClient(undefined, { fetchImpl });
|
||||
expect(client.baseUrl).toBe(url);
|
||||
await client.get("/api/ps");
|
||||
expect(urls).toContain(requested);
|
||||
});
|
||||
|
||||
it("the startup-proof endpoint picks RIGGED_URL like every other reader when OPENRIG_URL is empty", () => {
|
||||
const endpoint = resolveStartupProofEndpoint({
|
||||
env: { OPENRIG_URL: "", RIGGED_URL: "http://127.0.0.1:7556/", OPENRIG_ACTIVITY_HOOK_TOKEN: "tok" },
|
||||
readFile: () => { throw new Error("no endpoint file"); },
|
||||
});
|
||||
expect(endpoint).toEqual({ baseUrl: "http://127.0.0.1:7556", token: "tok" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ OPENRIG_URL: " ", RIGGED_URL: "http://127.0.0.1:7556" }, "http://127.0.0.1:7556"],
|
||||
[{ OPENRIG_URL: " http://127.0.0.1:7555/ " }, "http://127.0.0.1:7555"],
|
||||
])("the startup-proof endpoint trims and skips blank URLs as before (%o)", (urls, baseUrl) => {
|
||||
const endpoint = resolveStartupProofEndpoint({
|
||||
env: { ...urls, OPENRIG_ACTIVITY_HOOK_TOKEN: "tok" },
|
||||
readFile: () => { throw new Error("no endpoint file"); },
|
||||
});
|
||||
expect(endpoint).toEqual({ baseUrl, token: "tok" });
|
||||
});
|
||||
|
||||
// The shared reader leaves a query or fragment URL as written, so these two callers keep main's own trim before
|
||||
// /healthz: rig daemon stop trims every trailing slash, and crash-cart's evidence probe one, as its detector does.
|
||||
it.each([
|
||||
["http://127.0.0.1:7555/?x=1/", "http://127.0.0.1:7555/?x=1/healthz"],
|
||||
["http://127.0.0.1:7555/?x=1///", "http://127.0.0.1:7555/?x=1/healthz"],
|
||||
["http://127.0.0.1:7555/#frag/", "http://127.0.0.1:7555/#frag/healthz"],
|
||||
])("rig daemon stop with OPENRIG_URL=%s checks %s", async (url, check) => {
|
||||
vi.stubEnv("OPENRIG_URL", url);
|
||||
const { urls, deps } = probeDeps(() => true);
|
||||
await expect(stopDaemon(deps)).rejects.toThrow(`Checked ${check};`);
|
||||
expect(urls).toEqual([check]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["http://127.0.0.1:7555/?x=1/", "http://127.0.0.1:7555/?x=1/healthz"],
|
||||
["http://127.0.0.1:7555/?x=1//", "http://127.0.0.1:7555/?x=1//healthz"],
|
||||
["http://127.0.0.1:7555/#frag/", "http://127.0.0.1:7555/#frag/healthz"],
|
||||
])("crash-cart's evidence probe for OPENRIG_URL=%s is %s", (url, probe) => {
|
||||
expect(crashCartEvidenceUrl(normalizeOpenRigUrl(url), undefined)).toBe(probe);
|
||||
});
|
||||
});
|
||||
@@ -103,6 +103,96 @@ describe("a queue create that got no answer is read back once by its ID", () =>
|
||||
const posts = (calls: Call[]) => calls.filter((c) => c.method === "post");
|
||||
const readBacks = (calls: Call[]) => calls.filter((c) => c.method === "get" && c.path === `/api/queue/${ID}`);
|
||||
|
||||
it.each([
|
||||
["an explicit host", "dev@rig-b", ["--host", "vps-b"]],
|
||||
["a host-qualified destination", "dev@rig-b@vps-b", []],
|
||||
])("create --verify reads the remote receipt with %s and its bearer, never the local row", async (_case, destination, extra) => {
|
||||
fs.writeFileSync(path.join(home, "hosts.yaml"), `hosts:\n - id: vps-b\n transport: http\n url: ${REMOTE}\n bearer_env: XH_AUTH\n`);
|
||||
vi.stubEnv("XH_AUTH", AUTH_VALUE);
|
||||
const { calls, error } = await run(destination, [{ status: 201, data: REMOTE_ROW }], {
|
||||
[`${LOCAL}/healthz`]: { status: 200, data: { selfHostId: "host-a" } },
|
||||
[`${LOCAL}/api/queue/${ID}`]: { status: 404, data: { error: "qitem_not_found" } },
|
||||
[`${REMOTE}/api/queue/${ID}`]: { status: 200, data: { ...REMOTE_ROW, lastNudgeResult: "verified" } },
|
||||
}, ["--verify", ...extra]);
|
||||
expect(error).toBeNull();
|
||||
expect(posts(calls)).toEqual([expect.objectContaining({ base: LOCAL, path: "/api/queue/create" })]);
|
||||
expect(readBacks(calls)).toEqual([expect.objectContaining({ base: REMOTE,
|
||||
options: { timeoutMs: 10_000, headers: { Authorization: `Bearer ${AUTH_VALUE}` } } })]);
|
||||
expect(JSON.parse(stdout.join("\n"))).toMatchObject({ qitemId: ID, persisted: true,
|
||||
delivery: { receiptSource: "terminal", outcome: "verified", wakeResult: "verified" } });
|
||||
expect(stdout.join("\n") + stderr.join("\n")).not.toContain(AUTH_VALUE);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["missing", { status: 404, data: { error: "qitem_not_found" } }],
|
||||
["unreachable", new DaemonTimeoutError("remote receipt timed out")],
|
||||
["wrong id", { status: 200, data: { ...REMOTE_ROW, qitemId: "another-id", lastNudgeResult: "verified" } }],
|
||||
["wrong destination", { status: 200, data: { ...REMOTE_ROW, destinationSession: "other@rig-b", lastNudgeResult: "verified" } }],
|
||||
["wrong source", { status: 200, data: { ...REMOTE_ROW, sourceSession: "other@rig-a", lastNudgeResult: "verified" } }],
|
||||
["wrong body", { status: 200, data: { ...REMOTE_ROW, body: "different", lastNudgeResult: "verified" } }],
|
||||
] satisfies Array<[string, Reply]>)("create --verify keeps a %s remote receipt indeterminate without resending", async (_case, receipt) => {
|
||||
fs.writeFileSync(path.join(home, "hosts.yaml"), `hosts:\n - id: vps-b\n transport: http\n url: ${REMOTE}\n`);
|
||||
const { calls, error } = await run("dev@rig-b@vps-b", [{ status: 201, data: REMOTE_ROW }], {
|
||||
[`${LOCAL}/healthz`]: { status: 200, data: { selfHostId: "host-a" } },
|
||||
[`${LOCAL}/api/queue/${ID}`]: { status: 200, data: { ...REMOTE_ROW, lastNudgeResult: "verified" } },
|
||||
[`${REMOTE}/api/queue/${ID}`]: receipt,
|
||||
}, ["--verify"]);
|
||||
expect(error).toBeNull();
|
||||
expect(posts(calls)).toHaveLength(1);
|
||||
expect(readBacks(calls)).toEqual([expect.objectContaining({ base: REMOTE })]);
|
||||
expect(JSON.parse(stdout.join("\n"))).toMatchObject({ persisted: true, delivery: { outcome: "indeterminate" } });
|
||||
});
|
||||
|
||||
it("create --verify does not guess a receipt host missing from this CLI's registry", async () => {
|
||||
const { calls, error } = await run("dev@rig-b@vps-b", [{ status: 201, data: REMOTE_ROW }], {
|
||||
[`${LOCAL}/healthz`]: { status: 200, data: { selfHostId: "host-a" } },
|
||||
[`${LOCAL}/api/queue/${ID}`]: { status: 200, data: { ...REMOTE_ROW, lastNudgeResult: "verified" } },
|
||||
}, ["--verify"]);
|
||||
expect(error).toBeNull();
|
||||
expect(posts(calls)).toHaveLength(1);
|
||||
expect(readBacks(calls)).toHaveLength(0);
|
||||
expect(JSON.parse(stdout.join("\n"))).toMatchObject({ persisted: true, delivery: { outcome: "indeterminate" } });
|
||||
});
|
||||
|
||||
it("does not verify an older stored body returned by a successful colliding create", async () => {
|
||||
fs.writeFileSync(path.join(home, "hosts.yaml"), `hosts:\n - id: vps-b\n transport: http\n url: ${REMOTE}\n`);
|
||||
const oldRow = { ...REMOTE_ROW, body: "older body", lastNudgeResult: "verified" };
|
||||
const { calls, error } = await run("dev@rig-b@vps-b", [{ status: 200, data: { ...oldRow,
|
||||
createWarning: { code: "qitem_body_not_saved", message: "The supplied body was not saved." } } }], {
|
||||
[`${LOCAL}/healthz`]: { status: 200, data: { selfHostId: "host-a" } },
|
||||
[`${REMOTE}/api/queue/${ID}`]: { status: 200, data: oldRow },
|
||||
}, ["--verify"]);
|
||||
expect(error).toBeNull();
|
||||
expect(posts(calls)).toHaveLength(1);
|
||||
expect(readBacks(calls)).toHaveLength(1);
|
||||
expect(JSON.parse(stdout.join("\n"))).toMatchObject({ persisted: true, delivery: { outcome: "indeterminate" } });
|
||||
expect(stderr.join("\n")).toContain("not saved");
|
||||
});
|
||||
|
||||
it("create --verify reads locally when --host names this daemon, without requiring a registry entry", async () => {
|
||||
const { calls, error } = await run("dev@rig-b", [{ status: 201, data: ROW }], {
|
||||
[`${LOCAL}/healthz`]: { status: 200, data: { selfHostId: "host-a" } },
|
||||
[`${LOCAL}/api/queue/${ID}`]: { status: 200, data: { ...ROW, lastNudgeResult: "verified" } },
|
||||
}, ["--host", "host-a", "--verify"]);
|
||||
expect(error).toBeNull();
|
||||
expect(posts(calls)).toHaveLength(1);
|
||||
expect(readBacks(calls)).toEqual([expect.objectContaining({ base: LOCAL })]);
|
||||
expect(JSON.parse(stdout.join("\n"))).toMatchObject({ persisted: true, delivery: { outcome: "verified" } });
|
||||
});
|
||||
|
||||
it("a reconciled remote create still verifies its remote receipt without another write", async () => {
|
||||
fs.writeFileSync(path.join(home, "hosts.yaml"), `hosts:\n - id: vps-b\n transport: http\n url: ${REMOTE}\n`);
|
||||
const { calls, error } = await run("dev@rig-b@vps-b", [FORWARD_UNKNOWN], {
|
||||
[`${LOCAL}/healthz`]: { status: 200, data: { selfHostId: "host-a" } },
|
||||
[`${REMOTE}/api/queue/${ID}`]: { status: 200, data: { ...REMOTE_ROW, lastNudgeResult: "verified" } },
|
||||
}, ["--verify"]);
|
||||
expect(error).toBeNull();
|
||||
expect(posts(calls)).toHaveLength(1);
|
||||
expect(readBacks(calls)).toHaveLength(2);
|
||||
expect(readBacks(calls).every(c => c.base === REMOTE)).toBe(true);
|
||||
expect(JSON.parse(stdout.join("\n"))).toMatchObject({ persisted: true, delivery: { outcome: "verified" } });
|
||||
});
|
||||
|
||||
it("a local create that committed after the CLI stopped waiting reads back as created, and isn't sent again", async () => {
|
||||
const { calls, error } = await run("dev@rig-b", [new DaemonTimeoutError(`Request to ${LOCAL}/api/queue/create timed out after 5000ms`)],
|
||||
{ [`${LOCAL}/api/queue/${ID}`]: { status: 200, data: ROW } });
|
||||
|
||||
@@ -356,6 +356,43 @@ describe("findSlice + resolution variants", () => {
|
||||
expect(slice.missionName).toBe("release-0.3.2");
|
||||
});
|
||||
|
||||
it("finds the owning mission when the missions root ends in a separator", () => {
|
||||
const withSlash = missionsRoot + path.sep;
|
||||
expect(findSlice(withSlash, "07-target", "release-0.3.2").missionName).toBe("release-0.3.2");
|
||||
const abs = path.join(missionsRoot, "release-0.3.2", "slices", "07-target");
|
||||
expect(findSlice(withSlash, abs).missionName).toBe("release-0.3.2");
|
||||
});
|
||||
|
||||
it("finds the owning mission for a relative missions root with a bare name and --mission, or a mission path", () => {
|
||||
const prev = process.cwd();
|
||||
process.chdir(root);
|
||||
try {
|
||||
const hinted = findSlice("missions", "07-target", "release-0.3.2");
|
||||
expect(hinted.missionName).toBe("release-0.3.2");
|
||||
expect(hinted.absPath).toBe(path.join(fs.realpathSync(root), "missions", "release-0.3.2", "slices", "07-target"));
|
||||
expect(findSlice("missions/", "07-target", "release-0.3.2").missionName).toBe("release-0.3.2");
|
||||
expect(findSlice("missions", "release-0.3.2/slices/07-target").missionName).toBe("release-0.3.2");
|
||||
} finally {
|
||||
process.chdir(prev);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["/.", "/sub/.."])("resolves an absolute slice path ending in %s to the slice itself", (suffix) => {
|
||||
const abs = path.join(missionsRoot, "release-0.3.2", "slices", "07-target");
|
||||
fs.mkdirSync(path.join(abs, "sub"));
|
||||
const slice = findSlice(missionsRoot, abs + suffix.replace(/\//g, path.sep));
|
||||
expect(slice.absPath).toBe(abs);
|
||||
expect(slice.name).toBe("07-target");
|
||||
expect(slice.missionName).toBe("release-0.3.2");
|
||||
});
|
||||
|
||||
it("keeps an absolute slice path that leaves the missions root pointing at the requested folder", () => {
|
||||
const outside = path.join(root, "other", "m1", "slices", "x");
|
||||
fs.mkdirSync(outside, { recursive: true });
|
||||
const slice = findSlice(missionsRoot, [missionsRoot, "..", "other", "m1", "slices", "x"].join(path.sep));
|
||||
expect(slice.absPath).toBe(outside);
|
||||
});
|
||||
|
||||
it.each(["workspace", "missions", "declared mission"])(
|
||||
"resolves a hinted bare slice past an unrelated %s directory",
|
||||
(collision) => {
|
||||
|
||||
@@ -1325,6 +1325,19 @@ describe("Send CLI", () => {
|
||||
expect(String(lastSendBody?.text)).toContain("To: dev-impl@my-rig");
|
||||
});
|
||||
|
||||
it("single-seat send with a trailing-slash OPENRIG_URL reaches <url>/api/..., not <url>//api/...", async () => {
|
||||
vi.stubEnv("OPENRIG_URL", `http://127.0.0.1:${port}/`);
|
||||
vi.stubEnv("RIGGED_URL", "");
|
||||
lastSendBody = null;
|
||||
const rc = recordingRealClient();
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd(probeFailNoStateDeps(rc.factory)).parseAsync(["node", "rig", "send", "dev-impl@my-rig", "hello"]);
|
||||
});
|
||||
expect(logs.join("\n")).toContain("Sent to dev-impl@my-rig");
|
||||
expect(rc.seen()).toBe(`http://127.0.0.1:${port}`);
|
||||
expect(String(lastSendBody?.text)).toContain("To: dev-impl@my-rig");
|
||||
});
|
||||
|
||||
it("R2 RED: fan-out --to + OPENRIG_URL + probe-stopped -> actual /broadcast lands with per-recipient results", async () => {
|
||||
vi.stubEnv("OPENRIG_URL", `http://127.0.0.1:${port}`);
|
||||
vi.stubEnv("RIGGED_URL", "");
|
||||
|
||||
@@ -73,24 +73,26 @@ describe("reopening a Herdr view in a desktop window", () => {
|
||||
});
|
||||
afterEach(() => { process.exitCode = originalExit; vi.restoreAllMocks(); });
|
||||
|
||||
it.each([true, false])("following binds default named daemon endpoint before tab selection (matches=%s)", async matches => {
|
||||
const f = fixture([{ workspace_id: "follow-w", tab_id: "follow-t", label: "following" }], "kernel");
|
||||
const namedSocket = "/daemon home/sessions/daemon-named/herdr.sock";
|
||||
it.each([[undefined, true], ["daemon-named", true], [undefined, false], ["daemon-named", false]] as const)("following binds the exact custom endpoint before tab selection (session=%s matches=%s)", async (session, matches) => {
|
||||
const f = fixture([{ workspace_id: "follow-w", tab_id: "follow-t", label: "following" }], "kernel", session);
|
||||
const namedSocket = "/run private/custom.sock";
|
||||
f.get.mockImplementation(async (url: string) => ({ status: 200, data: url.includes("preview")
|
||||
? { planId, composed: f.composed, status: { launch: { socketPath: namedSocket, session: "daemon-named" } } }
|
||||
: { providers: [{ liveness: { alive: true }, status: { launch: { socketPath: namedSocket, session: "daemon-named" } } }] } }) as never);
|
||||
? { ...(session ? { session } : {}), planId, composed: f.composed, status: { launch: { socketPath: namedSocket, session: "daemon-named" } } }
|
||||
: { ...(session ? { session } : {}), providers: [{ liveness: { alive: true }, status: { launch: { socketPath: namedSocket, session: "daemon-named" } } }] } }) as never);
|
||||
f.post.mockResolvedValue({ status: 200, data: { provider: "herdr", ok: true, workspaceId: "follow-w", opened: ["operator"], absent: [], degraded: [], pages: 1,
|
||||
...(session ? { session } : {}),
|
||||
following: { id: "same", enabled: true, state: "following", maxTiles: 8, tiles: [], events: [], overflow: [] } } } as never);
|
||||
const original = f.exec.getMockImplementation()!;
|
||||
f.exec.mockImplementation(async (file, args) => args.includes("status") && args.includes("server")
|
||||
? JSON.stringify({ result: { running: true, socket: matches ? namedSocket : "/wrong/default.sock" } }) : original(file, args));
|
||||
await f.run(["--follow", "--json"]);
|
||||
await f.run(["--follow", "--json", ...(session ? ["--session", session] : [])]);
|
||||
const parsed = JSON.parse(logs[0]!);
|
||||
if (matches) {
|
||||
expect(parsed).toMatchObject({ ok: true, workspaceId: "follow-w" });
|
||||
expect(f.focus).toHaveBeenCalledExactlyOnceWith("follow-t");
|
||||
for (const [, args] of f.exec.mock.calls.filter(([, args]) => args.includes("status") || args.includes("tab"))) {
|
||||
expect(args).toContain("--session"); expect(args).toContain("daemon-named");
|
||||
expect(args).toContain(`HERDR_SOCKET_PATH=${namedSocket}`);
|
||||
expect(args).not.toContain("--session");
|
||||
}
|
||||
} else {
|
||||
expect(parsed.ok).toBe(false); expect(f.focus).not.toHaveBeenCalled(); expect(f.list).not.toHaveBeenCalled();
|
||||
|
||||
@@ -81,6 +81,17 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolBatch": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"${OPENRIG_NODE:-node}\" \"${CLAUDE_PLUGIN_ROOT}/hooks/scripts/activity-relay.cjs\" --from-plugin",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
|
||||
@@ -84,6 +84,11 @@ function buildOpenRigPayload(providerPayload, env = process.env, now = () => new
|
||||
generation,
|
||||
hookEvent,
|
||||
subtype,
|
||||
// Claude's main session omits agent_id; subagents and in-process teammates carry it.
|
||||
// Keep that distinction for permission completion, without forwarding tool inputs/results.
|
||||
...(runtime === "claude-code" && (hookEvent === "Notification" || hookEvent === "PostToolBatch")
|
||||
&& (providerPayload.agent_id == null || firstString(providerPayload.agent_id))
|
||||
? { hookAgentId: firstString(providerPayload.agent_id) } : {}),
|
||||
turnId: firstString(providerPayload.turn_id, providerPayload.turnId),
|
||||
occurredAt: now().toISOString(),
|
||||
relayRoute: argv.includes("--from-plugin") ? "plugin" : "managed",
|
||||
|
||||
@@ -16,7 +16,6 @@ metadata:
|
||||
stage: factory-approved
|
||||
sibling_skills:
|
||||
- openrig-user
|
||||
- openrig-operator
|
||||
- developing-openrig
|
||||
- openrig-upgrade
|
||||
- forming-an-openrig-mental-model
|
||||
|
||||
@@ -16,7 +16,6 @@ metadata:
|
||||
stage: factory-approved
|
||||
sibling_skills:
|
||||
- openrig-user
|
||||
- openrig-operator
|
||||
- forming-an-openrig-mental-model
|
||||
---
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ states.
|
||||
## Don't use this when
|
||||
|
||||
- The operation is single-command and deterministic — use `openrig-user` skill for CLI surface
|
||||
- The work is operator-level configuration of OpenRig itself — use `openrig-operator`
|
||||
- The work is operator-level configuration or troubleshooting of OpenRig itself — run `rig context get help`
|
||||
- The work is rig spec authoring — use `openrig-architect`
|
||||
|
||||
## Failure modes (4)
|
||||
@@ -120,7 +120,7 @@ different claims; don't conflate.
|
||||
## See also
|
||||
|
||||
- `openrig-user` skill — CLI surface for `rig up / down / restore / etc.`
|
||||
- `openrig-operator` skill — operator-level discipline for OpenRig itself
|
||||
- `rig context get help` — the help guide for the installed version, for when OpenRig itself isn't working
|
||||
- `seat-continuity-and-handover` skill — sub-primitive: occupant-creation modes for restore (`resume` / `rebuild` / `fresh` / `failed`)
|
||||
- `session-source-fork` skill — `forked` continuity outcome for fork-based restore
|
||||
- `permission-and-capability-preflight` skill — provider-auth blocker → escalation path
|
||||
|
||||
@@ -47,7 +47,8 @@ machine and have things happen.
|
||||
marks them healthy. A HOST reboot drops tmux entirely; the
|
||||
reconciler then marks the kernel's member sessions detached, and
|
||||
the operator pod owns the agent-restart workflow ("bring my
|
||||
sessions back online") per the openrig-operator skill.
|
||||
sessions back online") per "Incomplete setup and restart":
|
||||
`rig context get reference/getting-started.md#incomplete-setup-and-restart`.
|
||||
Other rigs are NOT auto-instantiated by the daemon at any point —
|
||||
they require explicit operator-initiated `rig up` / `rig restore`
|
||||
via the agent-driven workflow.
|
||||
|
||||
@@ -259,6 +259,7 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
opts: { name: string; resumeToken?: string; forkSource?: import("../domain/runtime-adapter.js").ForkSource; freshInPlace?: boolean; beforeNativeDispatch?: () => boolean },
|
||||
): Promise<HarnessLaunchResult> {
|
||||
binding = { ...binding };
|
||||
const claudeCommand = binding.claudeExecutable ? `exec ${shellQuote(binding.claudeExecutable)}` : "claude";
|
||||
this.autoLaunches.delete(binding.nodeId);
|
||||
opts = { ...opts, ...(opts.forkSource ? { forkSource: { ...opts.forkSource } } : {}) };
|
||||
if (!binding.tmuxSession) {
|
||||
@@ -277,6 +278,7 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
try {
|
||||
if (!this.claudeManagedLaunch) await unresolvedClaudePermissionModes();
|
||||
managed = await this.claudeManagedLaunch!.prepare({ nodeId: binding.nodeId, cwd: binding.cwd,
|
||||
...(binding.claudeExecutable ? { executable: binding.claudeExecutable } : {}),
|
||||
session: binding.tmuxSession, pane: binding.tmuxPane, generation: binding.launchGeneration,
|
||||
...(binding.runtimeSwitchFrom ? { runtimeSwitch: { fromRuntime: binding.runtimeSwitchFrom.runtime, session: binding.runtimeSwitchFrom.session,
|
||||
pane: binding.runtimeSwitchFrom.pane, generation: binding.runtimeSwitchFrom.generation } } : {}) }, binding.permissionMode);
|
||||
@@ -356,7 +358,7 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
// The non-managed command text is parsed by the pane shell, so the
|
||||
// parent id needs the same quoting as --model/--effort above (and as
|
||||
// the codex fork path) — a hostile id must not leave --resume.
|
||||
: `${rendererPrefix}claude ${permissionMode}${modelArg}${effortArg}${advisorArg}${additionArg} --resume ${shellQuote(parentId)} --fork-session --session-id ${forkSessionId} --name ${opts.name}`;
|
||||
: `${rendererPrefix}${claudeCommand} ${permissionMode}${modelArg}${effortArg}${advisorArg}${additionArg} --resume ${shellQuote(parentId)} --fork-session --session-id ${forkSessionId} --name ${opts.name}`;
|
||||
const textResult = managed ? await this.tmux.sendShellCommand(binding.tmuxSession, cmd, managed.assertCurrent)
|
||||
: await sendClassic(cmd);
|
||||
if (!textResult.ok) {
|
||||
@@ -377,8 +379,8 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
const cmd = managed ? managed.command(["--permission-mode", binding.permissionMode!, ...launchArgs, ...(model ? ["--model", model] : []), ...(effort ? ["--effort", effort] : []), ...(advisor ? ["--advisor", advisor] : []), ...additionArgs,
|
||||
...(opts.resumeToken ? ["--resume", opts.resumeToken] : ["--session-id", generatedSessionId!]), "--name", opts.name],
|
||||
opts.resumeToken ? claudeResumeLaunchEnv(opts.resumeToken) : undefined) : opts.resumeToken
|
||||
? `${claudeResumeLaunchPrefix(opts.resumeToken)}${rendererPrefix}claude ${permissionMode}${modelArg}${effortArg}${advisorArg}${additionArg} --resume ${shellQuote(opts.resumeToken)} --name ${opts.name}`
|
||||
: `${rendererPrefix}claude ${permissionMode}${modelArg}${effortArg}${advisorArg}${additionArg} --session-id ${generatedSessionId} --name ${opts.name}`;
|
||||
? `${claudeResumeLaunchPrefix(opts.resumeToken)}${rendererPrefix}${claudeCommand} ${permissionMode}${modelArg}${effortArg}${advisorArg}${additionArg} --resume ${shellQuote(opts.resumeToken)} --name ${opts.name}`
|
||||
: `${rendererPrefix}${claudeCommand} ${permissionMode}${modelArg}${effortArg}${advisorArg}${additionArg} --session-id ${generatedSessionId} --name ${opts.name}`;
|
||||
|
||||
if (opts.beforeNativeDispatch && !opts.beforeNativeDispatch()) return { ok: false, error: "Selected history changed during Claude launch preparation; no native command was sent.", recovery: "attention_required" };
|
||||
const textResult = managed ? await this.tmux.sendShellCommand(binding.tmuxSession, cmd, beforeManagedInput)
|
||||
|
||||
@@ -79,6 +79,7 @@ export class ClaudeResumeAdapter {
|
||||
launchAdditions?: SeatLaunchAdditions,
|
||||
claudePermissions?: ClaudePermissionRules,
|
||||
beforeNativeDispatch?: () => boolean,
|
||||
executable?: string,
|
||||
): Promise<ResumeResult> {
|
||||
if (!this.canResume(resumeType, resumeToken)) {
|
||||
return { ok: false, code: "no_resume", message: "Claude resume not available" };
|
||||
@@ -97,7 +98,8 @@ export class ClaudeResumeAdapter {
|
||||
if (selectedPermissionMode !== undefined) {
|
||||
try {
|
||||
if (!nodeId || !this.options.claudeManagedLaunch) await unresolvedClaudePermissionModes();
|
||||
managed = await this.options.claudeManagedLaunch!.prepare({ nodeId: nodeId!, cwd, session: tmuxSessionName }, selectedPermissionMode);
|
||||
managed = await this.options.claudeManagedLaunch!.prepare({ nodeId: nodeId!, cwd, session: tmuxSessionName,
|
||||
...(executable ? { executable } : {}) }, selectedPermissionMode);
|
||||
} catch (error) { return { ok: false, code: "permission_selection_refused", message: (error as Error).message }; }
|
||||
}
|
||||
// A seat's own subscription token, as on a fresh launch (claude-code-adapter.ts).
|
||||
@@ -112,7 +114,7 @@ export class ClaudeResumeAdapter {
|
||||
});
|
||||
const permissionMode = posture + launchArgs.map(arg => ` ${shellQuote(arg)}`).join("");
|
||||
const cmd = managed ? managed.command(["--permission-mode", selectedPermissionMode!, ...launchArgs, ...(model ? ["--model", model] : []), ...(effort ? ["--effort", effort] : []), ...(advisor ? ["--advisor", advisor] : []), ...additionArgs, "--resume", resumeToken!], claudeResumeLaunchEnv(resumeToken!))
|
||||
: `${claudeResumeLaunchPrefix(resumeToken!)}${claudeClassicRendererEnvPrefix(process.env)}claude ${permissionMode}${modelArg}${effortArg}${advisorArg}${additionArg} --resume ${shellQuote(resumeToken!)}`;
|
||||
: `${claudeResumeLaunchPrefix(resumeToken!)}${claudeClassicRendererEnvPrefix(process.env)}${executable ? `exec ${shellQuote(executable)}` : "claude"} ${permissionMode}${modelArg}${effortArg}${advisorArg}${additionArg} --resume ${shellQuote(resumeToken!)}`;
|
||||
|
||||
const assertSelected = beforeNativeDispatch ? () => { if (!beforeNativeDispatch()) throw new Error("Selected history changed before Claude resume input"); } : undefined;
|
||||
const beforeManagedInput = beforeNativeDispatch ? () => { assertSelected?.(); managed?.assertCurrent?.(); } : managed?.assertCurrent;
|
||||
|
||||
@@ -174,7 +174,9 @@ export const OMP_PROVIDER_EXTRA_ENV_VARS: Record<string, readonly string[]> = {
|
||||
};
|
||||
|
||||
// Baseline process needs. No host credential families or shell customization.
|
||||
export const PI_ENV_BASELINE_VARS = ["PATH", "HOME", "USER", "LOGNAME", "TERM", "LANG", "LC_ALL", "SHELL", "TMPDIR"] as const;
|
||||
// SSH_AUTH_SOCK lets Pi's shell tools run git over SSH (#1159): the path isn't key material, but the socket lets the
|
||||
// agent use the keys loaded in ssh-agent.
|
||||
export const PI_ENV_BASELINE_VARS = ["PATH", "HOME", "USER", "LOGNAME", "SSH_AUTH_SOCK", "TERM", "LANG", "LC_ALL", "SHELL", "TMPDIR"] as const;
|
||||
|
||||
// NodeLauncher supplies identity and instance routing on both fresh and resumed
|
||||
// seats. Pi's shell tools inherit this child env: dropping these values makes
|
||||
|
||||
@@ -899,7 +899,7 @@ export class TmuxAdapter {
|
||||
const identity = this.freshManaged.get(target)?.nodeId ?? target;
|
||||
const checkDraft = guard?.needsInputCheck(identity);
|
||||
const policy = checkDraft || options?.beforePaste ? {
|
||||
beforePaste: async () => { if (checkDraft) await guard!.beforeInput(identity); await options?.beforePaste?.(); },
|
||||
beforePaste: async () => { if (checkDraft) await guard!.beforeInput(identity, false, text); await options?.beforePaste?.(); },
|
||||
afterPaste: async () => { if (checkDraft) await guard!.notePasted(identity, text); },
|
||||
} : undefined;
|
||||
return this.sendTextUnchecked(pane, text, () => { beforeWrite(); beforeInput?.(); }, options?.bracketed, policy);
|
||||
@@ -1194,7 +1194,7 @@ export class TmuxAdapter {
|
||||
beforeWrite();
|
||||
const result = await this.respawnPaneUnchecked(pane, command, opts);
|
||||
const guard = this.deliveryGuard;
|
||||
const target = guard?.maybeTarget(pane);
|
||||
const target = guard?.maybeTarget(paneTarget);
|
||||
if (result.ok && target && guard!.ownsLifecycle(target.nodeId)) guard!.noteFreshLifecyclePane(target.nodeId, pane);
|
||||
return result;
|
||||
}, false, "respawn");
|
||||
|
||||
@@ -134,7 +134,7 @@ export interface ActivityEvidence {
|
||||
livenessSeconds?: number;
|
||||
activity?: ActivityValue;
|
||||
needsInput?: NeedsInput;
|
||||
permissionRequest?: { id: string };
|
||||
permissionRequest?: { id: string; hookAgentId?: string | null };
|
||||
permissionResolved?: boolean;
|
||||
/** Which relay route posted hook evidence, when it said. One event can arrive by more than one route (a Codex seat's
|
||||
* plugin hook and its managed hook), so promotion counts agreements per route. Absent for an untagged post. */
|
||||
|
||||
@@ -8,6 +8,7 @@ export const AGENT_ACTIVITY_FRESHNESS_MS = 5 * 60 * 1000;
|
||||
|
||||
export interface HookActivityInput {
|
||||
turnId?: string | null;
|
||||
hookAgentId?: string | null;
|
||||
|
||||
runtime: string | null;
|
||||
sessionName?: string | null;
|
||||
@@ -120,6 +121,7 @@ export class AgentActivityStore {
|
||||
generation,
|
||||
});
|
||||
activity.turnId = input.turnId ?? null;
|
||||
if (input.hookAgentId !== undefined) activity.hookAgentId = input.hookAgentId;
|
||||
if (input.sourceSequence) activity.sourceSequence = input.sourceSequence;
|
||||
if (input.livenessSeconds) activity.livenessSeconds = input.livenessSeconds;
|
||||
|
||||
|
||||
@@ -462,7 +462,7 @@ export class ClaimService {
|
||||
const input = { target, tmux: this.tmuxAdapter, listProcesses: this.listProcesses };
|
||||
try {
|
||||
if (runtime === "claude-code") return !!await verifyClaudePaneRuntime(input);
|
||||
if (runtime === "codex") return !!await verifyCodexPaneProcess(input);
|
||||
if (runtime === "codex") return !!await verifyCodexPaneProcess({ ...input, requireArgv: true });
|
||||
} catch { /* unproven */ }
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ import { claudeSeatTokenFallbackNotice, claudeSeatTokenFileProblem, claudeSeatTo
|
||||
import { OUTRANKING_TOKEN_ENV, projectRootForLocalSettings, settingsFileConflicts, type CredentialConflict, type CredentialPrecedence } from "./claude-credential-precedence.js";
|
||||
|
||||
export interface ClaudeLaunchTarget {
|
||||
/** A running seat's verified executable, carried through its in-place continuation. */
|
||||
executable?: string;
|
||||
nodeId: string;
|
||||
cwd?: string;
|
||||
session?: string;
|
||||
@@ -221,7 +223,7 @@ export class ClaudeManagedLaunch {
|
||||
return this.selectedConfigDir(nodeId) ? this.configPaths(cwd, nodeId).statePath : undefined;
|
||||
}
|
||||
|
||||
private context(cwd: string, nodeId: string) {
|
||||
private context(cwd: string, nodeId: string, selectedExecutable?: string) {
|
||||
const { PATH, HOME } = this.sessionEnv;
|
||||
const CLAUDE_CONFIG_DIR = this.selectedConfigDir(nodeId);
|
||||
if (!PATH || !HOME || !path.isAbsolute(HOME)) throw new Error("Claude managed launch context is unresolved: managed PATH and absolute HOME are required.");
|
||||
@@ -235,8 +237,9 @@ export class ClaudeManagedLaunch {
|
||||
if (CLAUDE_CONFIG_DIR !== undefined) env.CLAUDE_CONFIG_DIR = configDir;
|
||||
if (claudeClassicRendererEnvPrefix(this.rendererEnv)) env.CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN = "1";
|
||||
let executable: string | undefined;
|
||||
for (const dir of search) {
|
||||
const candidate = path.join(dir, "claude");
|
||||
const candidates = selectedExecutable ? [selectedExecutable] : search.map(dir => path.join(dir, "claude"));
|
||||
for (const candidate of candidates) {
|
||||
if (!path.isAbsolute(candidate)) continue;
|
||||
try { accessSync(candidate, constants.X_OK); if (!statSync(candidate).isFile()) continue; }
|
||||
catch { continue; }
|
||||
executable = realpathSync(candidate); break;
|
||||
@@ -272,7 +275,7 @@ export class ClaudeManagedLaunch {
|
||||
|| (!staged && target.pane != null && target.pane !== before.pane)) {
|
||||
throw new Error("Claude managed launch target disagrees with the current binding; no input or selection changed.");
|
||||
}
|
||||
const context = this.context(cwd, target.nodeId);
|
||||
const context = this.context(cwd, target.nodeId, target.executable);
|
||||
// This is the existing managed session channel, not arbitrary shell variables.
|
||||
// Filter out the explicit capability environment and overwrite launch identity.
|
||||
const inherited = Object.keys(this.sessionEnv).filter(key => this.sessionEnv[key] !== undefined
|
||||
@@ -280,7 +283,7 @@ export class ClaudeManagedLaunch {
|
||||
if (inherited.some(key => !/^[A-Z_][A-Z0-9_]*$/.test(key))) throw new Error("Invalid managed environment key.");
|
||||
const assertCurrent = () => {
|
||||
if (JSON.stringify(this.target(target.nodeId, switching?.fromRuntime)) !== JSON.stringify(before)
|
||||
|| JSON.stringify(this.context(cwd, target.nodeId)) !== JSON.stringify(context)
|
||||
|| JSON.stringify(this.context(cwd, target.nodeId, target.executable)) !== JSON.stringify(context)
|
||||
|| JSON.stringify(Object.keys(this.sessionEnv).filter(key => this.sessionEnv[key] !== undefined
|
||||
&& !["PATH", "HOME", "CLAUDE_CONFIG_DIR", "CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN"].includes(key))) !== JSON.stringify(inherited)) {
|
||||
throw new Error("Claude managed launch context changed during capability discovery/input; retry explicitly.");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { closeSync, fstatSync, openSync, readSync } from "node:fs";
|
||||
import { isAbsolute } from "node:path";
|
||||
import type { SessionRegistry } from "./session-registry.js";
|
||||
import { observeCodexPaneProcess, codexCommandResumeToken, processArgv, type NativeProcessLister } from "./native-process-lineage.js";
|
||||
import { observeCodexPaneProcess, codexProcessArgv, codexProcessResumeToken, type NativeProcessLister } from "./native-process-lineage.js";
|
||||
import { lstartToMinTs } from "./codex-thread-id.js";
|
||||
import { parseSqliteUtcMs } from "./sqlite-time.js";
|
||||
import { shellQuote } from "../adapters/shell-quote.js";
|
||||
@@ -106,13 +106,15 @@ export async function observeCodexRebuildPermissions(input: {
|
||||
return unverified("The predecessor's current occupant and pane binding could not be established.");
|
||||
}
|
||||
try {
|
||||
const probe = { target: binding.tmuxPane, tmux: input.tmux, listProcesses: input.listProcesses };
|
||||
const probe = { target: binding.tmuxPane, tmux: input.tmux, listProcesses: input.listProcesses, requireArgv: true };
|
||||
const first = await observeCodexPaneProcess(probe);
|
||||
const start = lstartToMinTs(first?.process.startedAt);
|
||||
const boot = parseSqliteUtcMs(tenure.bootAt);
|
||||
if (!first || start === undefined || !Number.isFinite(boot)) return unverified("The predecessor's native process identity or start time was not established.");
|
||||
const argv = codexProcessArgv(first.process);
|
||||
if (!argv) return unverified("The predecessor's exact native arguments could not be read.");
|
||||
const thread = await input.readThreadId(first.process.pid, first.process.startedAt!);
|
||||
const argvThread = codexCommandResumeToken(first.process.command);
|
||||
const argvThread = codexProcessResumeToken(first.process);
|
||||
if (!thread || (argvThread !== undefined && argvThread !== thread)) return unverified("The predecessor's process and native conversation could not be joined.");
|
||||
const path = input.rollout(thread);
|
||||
const record = path ? readCodexPermissionRecord(path, thread, input.cwd, Math.max(start * 1000 + 999, boot)) : null;
|
||||
@@ -124,7 +126,6 @@ export async function observeCodexRebuildPermissions(input: {
|
||||
|| current?.tmuxPane !== binding.tmuxPane || current?.tmuxSession !== binding.tmuxSession) {
|
||||
return unverified("The predecessor changed while its native permission policy was read.");
|
||||
}
|
||||
const argv = processArgv(first.process.command);
|
||||
const profileAt = argv.findIndex(arg => arg === "-p" || arg === "--profile");
|
||||
return { state: "observed", ...record, processStartedAt: first.process.startedAt!,
|
||||
profile: profileAt >= 0 ? argv[profileAt + 1] ?? null : null };
|
||||
|
||||
@@ -137,6 +137,8 @@ export interface ComposerInput {
|
||||
state: "empty" | "text" | "unknown";
|
||||
rows: string[];
|
||||
columns: number;
|
||||
/** Conservative visible capacity of the recognized Codex fullscreen composer. */
|
||||
pasteViewport?: { columns: number; rows: number };
|
||||
cursor?: ComposerSnapshot["cursor"];
|
||||
unknownReason?: string;
|
||||
suggestionRows?: string[];
|
||||
@@ -177,6 +179,30 @@ export function composerContainsOwnedText(input: ComposerInput, expected: string
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Conservative uncertainty before a protected Codex paste, never Enter authority.
|
||||
* Codex 0.164 collapses >1000 code points to [Pasted Content N chars], which
|
||||
* the current ownership reader cannot own. Other wrapped text gets a row
|
||||
* upper bound; a line fitting the native width needs only one row.
|
||||
*/
|
||||
export function composerPasteUncertainty(input: ComposerInput, text: string): "controls" | "long" | "viewport" | null {
|
||||
const viewport = input.pasteViewport;
|
||||
if (!viewport) return null;
|
||||
const normalized = text.replace(/\r\n/g, "\n");
|
||||
const lines = normalized.split("\n");
|
||||
if (lines.some(line => /[\x00-\x1f\x7f-\x9f]/.test(line))) return "controls";
|
||||
// rust-v0.164.0 chat_composer/paste_input.rs:184-192 (Unicode scalar count).
|
||||
if ([...normalized].length > 1000) return "long";
|
||||
if (viewport.columns < 1 || viewport.rows < 1) return "viewport";
|
||||
let rows = 0;
|
||||
for (const line of lines) {
|
||||
// Two cells per non-ASCII code point conservatively covers wide glyphs.
|
||||
const cells = [...line].reduce((sum, ch) => sum + (ch >= " " && ch <= "~" ? 1 : 2), 0);
|
||||
rows += cells <= viewport.columns ? 1 : Math.ceil(2 * cells / viewport.columns) + 1;
|
||||
if (rows > viewport.rows) return "viewport";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Recognize this lease's paste immediately after pasting or before its Enter
|
||||
* in the original composer. A later lease cannot acquire ownership from a label.
|
||||
*/
|
||||
|
||||
@@ -205,26 +205,19 @@ export function makeQueuePorts(
|
||||
opts: { loadHumanRegistry?: () => LoadResult; sharedChannels?: () => ReadonlyArray<{ address: string }> } = {},
|
||||
): InboundQueuePort & OutboundQueuePort & { listOpenAsks(filter: AlertFilterOpts): Promise<OpenAskEpisode[]> } {
|
||||
/** Each active row's current owner-notification episode, projected to its person or shared channel. Posted episodes
|
||||
* are skipped unless asked for (the alert sweep never wants them). Null when nobody is registered. */
|
||||
* are skipped unless asked for (the alert sweep never wants them). Null when nobody is registered.
|
||||
*
|
||||
* Eligibility lives in the query now: an active row must carry an owner notification, and — for the alert sweep —
|
||||
* it must still be unposted to Slack. Only eligible rows reach the per-row lookup, so a pathological backlog of
|
||||
* active rows without any notification costs a single read instead of one latest-transition and one receipt lookup
|
||||
* per row. There is no row cap: an eligible item older than any window still comes back in both views. */
|
||||
const currentEpisodes = (includePosted: boolean): Array<{ item: QueueItem; posted: boolean }> | null => {
|
||||
// Nobody registered yet isn't a broken registry: rows to a registered shared channel still post (0.7.3).
|
||||
const people = registeredPeople((opts.loadHumanRegistry ?? (() => loadHumanRegistry()))());
|
||||
if (!people) return null;
|
||||
const channels = (opts.sharedChannels ?? (() => sharedChannelAddresses()))();
|
||||
// Avoid building waiting/recovery views for rows with no unposted owner notification.
|
||||
// Keep list(activeOnly)'s selection, ordering and cap; this remains an O(N) ID scan.
|
||||
const ids = queueRepo.db.prepare(
|
||||
`SELECT qitem_id FROM queue_items
|
||||
WHERE state IN ('pending', 'in-progress', 'blocked')
|
||||
ORDER BY CASE WHEN state IN ('pending', 'in-progress', 'blocked') THEN 0 ELSE 1 END, ts_created DESC
|
||||
LIMIT ?`,
|
||||
).all(1000000) as Array<{ qitem_id: string }>;
|
||||
return ids.flatMap(({ qitem_id: qitemId }) => {
|
||||
const transition = queueRepo.transitionLog.latestOwnerNotificationForQitem(qitemId);
|
||||
if (!transition) return [];
|
||||
const notificationKey = `${qitemId}:${transition.transitionId}`;
|
||||
const posted = queueRepo.transitionLog.hasOwnerNotificationReceipt(qitemId, notificationKey);
|
||||
if (posted && !includePosted) return [];
|
||||
const episodes = queueRepo.transitionLog.activeOwnerNotificationEpisodes(includePosted);
|
||||
return episodes.flatMap(({ qitemId, transition, posted }) => {
|
||||
const row = queueRepo.getById(qitemId);
|
||||
if (!row) return [];
|
||||
const item = project(row, transition, people, channels);
|
||||
|
||||
@@ -103,13 +103,25 @@ interface HeaderScan {
|
||||
unterminatedFenceLine: number | null;
|
||||
}
|
||||
|
||||
/** Scan for real headers, skipping fenced code blocks (``` or ~~~, any info string;
|
||||
/** Scan for real headers, skipping HTML comment blocks and fenced code blocks (``` or ~~~, any info string;
|
||||
* a fence closes only on the same marker at the same or greater length). */
|
||||
function scanHeaders(lines: string[]): HeaderScan {
|
||||
const hits: HeaderHit[] = [];
|
||||
let fence: { marker: string; length: number; line: number } | null = null;
|
||||
let comment = false;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]!;
|
||||
// HTML comment blocks end on the line containing --> (or at EOF). Their
|
||||
// headings and fences are literal content, just as comment markers inside
|
||||
// a fenced code block are literal content. Keep the original span bytes.
|
||||
if (comment) {
|
||||
if (line.includes("-->")) comment = false;
|
||||
continue;
|
||||
}
|
||||
if (!fence && /^ {0,3}<!--/.test(line)) {
|
||||
comment = !line.includes("-->");
|
||||
continue;
|
||||
}
|
||||
const fenceMatch = line.match(/^\s{0,3}(`{3,}|~{3,})/);
|
||||
if (fenceMatch) {
|
||||
const marker = fenceMatch[1]![0]!;
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const MAX_PIDS = 128;
|
||||
const MAX_BYTES = 1024 * 1024;
|
||||
const MAX_ARGS = 4096;
|
||||
const MAX_OUTPUT = 2 * MAX_BYTES;
|
||||
|
||||
// KERN_PROCARGS2 begins with argc and the executable path, followed by argc NUL-delimited
|
||||
// arguments and then the environment. Only the arguments are returned by this helper.
|
||||
const MAC_ARGUMENTS = `ObjC.import("Foundation");
|
||||
ObjC.bindFunction("sysctl", ["int", ["void *", "uint32_t", "void *", "void *", "void *", "size_t"]]);
|
||||
function number(bytes, count) {
|
||||
var value = 0;
|
||||
for (var i = count - 1; i >= 0; i--) value = value * 256 + bytes.charCodeAt(i);
|
||||
return value;
|
||||
}
|
||||
function parse(data, used) {
|
||||
if (used < 7 || used > 1048576) return null;
|
||||
var exact = data.subdataWithRange({location: 0, length: used});
|
||||
var bytes = ObjC.unwrap($.NSString.alloc.initWithDataEncoding(exact, $.NSISOLatin1StringEncoding));
|
||||
if (typeof bytes !== "string" || bytes.length !== used) return null;
|
||||
var argc = number(bytes, 4);
|
||||
if (argc < 1 || argc > 4096) return null;
|
||||
var pathEnd = bytes.indexOf("\\u0000", 4);
|
||||
if (pathEnd <= 4) return null;
|
||||
// XNU aligns ip_startargv after the saved path to the target's pointer size.
|
||||
// KERN_PROCARGS2 strips the 16-byte executable_path= prefix. Supported macOS
|
||||
// processes are 64-bit, so the path and its padding occupy whole 8-byte units.
|
||||
// Skipping every zero would also skip an empty argv[0] and read env as argv.
|
||||
var offset = 4 + Math.ceil((pathEnd - 4 + 1) / 8) * 8;
|
||||
if (offset >= used) return null;
|
||||
for (var padding = pathEnd + 1; padding < offset; padding++) {
|
||||
if (bytes.charCodeAt(padding) !== 0) return null;
|
||||
}
|
||||
var args = [];
|
||||
for (var index = 0; index < argc; index++) {
|
||||
if (offset >= used) return null;
|
||||
var end = bytes.indexOf("\\u0000", offset);
|
||||
if (end < 0 || end >= used) return null;
|
||||
var argument = ObjC.unwrap($.NSString.alloc.initWithDataEncoding(exact.subdataWithRange({location: offset, length: end - offset}), $.NSUTF8StringEncoding));
|
||||
if (typeof argument !== "string") return null;
|
||||
// NSString consumes a UTF-8 BOM at the start of each decoded unit. Restore
|
||||
// it so a BOM-prefixed token cannot become an exact conversation UUID.
|
||||
if (end - offset >= 3 && bytes.charCodeAt(offset) === 239 && bytes.charCodeAt(offset + 1) === 187
|
||||
&& bytes.charCodeAt(offset + 2) === 191) argument = String.fromCharCode(65279) + argument;
|
||||
args.push(argument);
|
||||
offset = end + 1;
|
||||
}
|
||||
return args[0] ? args : null;
|
||||
}
|
||||
function run(inputs) {
|
||||
var data = $.NSMutableData.dataWithLength(1048576);
|
||||
var outputSize = 0;
|
||||
return JSON.stringify(inputs.map(function(input) {
|
||||
var request = JSON.parse(input);
|
||||
if (outputSize > 2097152) return [request.pid, null];
|
||||
if (request.fixture) {
|
||||
var fixture = $.NSData.alloc.initWithBase64EncodedStringOptions(request.fixture, 0);
|
||||
return [request.pid, parse(fixture, Number(fixture.length))];
|
||||
}
|
||||
var mib = $.NSData.alloc.initWithBase64EncodedStringOptions(request.mib, 0);
|
||||
var size = $.NSData.alloc.initWithBase64EncodedStringOptions(request.size, 0).mutableCopy;
|
||||
if ($.sysctl(mib.bytes, 3, data.mutableBytes, size.mutableBytes, null, 0) !== 0) return [request.pid, null];
|
||||
var sizeBytes = ObjC.unwrap($.NSString.alloc.initWithDataEncoding(size, $.NSISOLatin1StringEncoding));
|
||||
var used = typeof sizeBytes === "string" && sizeBytes.length === 8 ? number(sizeBytes, 8) : 0;
|
||||
var args = parse(data, used);
|
||||
if (args) {
|
||||
var answerSize = JSON.stringify(args).length;
|
||||
if (outputSize + answerSize > 2097152) return [request.pid, null];
|
||||
outputSize += answerSize;
|
||||
}
|
||||
return [request.pid, args];
|
||||
}));
|
||||
}`;
|
||||
|
||||
/** Parse a Linux /proc cmdline without ever treating malformed bytes as an argument boundary. */
|
||||
export function parseLinuxProcessArguments(bytes: Buffer): string[] | null {
|
||||
if (bytes.length < 2 || bytes.length > MAX_BYTES || bytes.at(-1) !== 0) return null;
|
||||
const args: string[] = [];
|
||||
// TextDecoder strips an initial BOM by default, even when decoding each argv
|
||||
// unit separately. Keep that byte sequence as U+FEFF so it cannot become a UUID.
|
||||
const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
||||
let start = 0;
|
||||
try {
|
||||
for (let index = 0; index < bytes.length; index++) {
|
||||
if (bytes[index] !== 0) continue;
|
||||
args.push(decoder.decode(bytes.subarray(start, index)));
|
||||
if (args.length > MAX_ARGS) return null;
|
||||
start = index + 1;
|
||||
}
|
||||
} catch { return null; }
|
||||
return args[0] ? args : null;
|
||||
}
|
||||
|
||||
async function boundedLinuxCmdline(pid: number, signal: AbortSignal): Promise<string[] | null> {
|
||||
const chunks: Buffer[] = [];
|
||||
let length = 0;
|
||||
try {
|
||||
for await (const chunk of createReadStream(`/proc/${pid}/cmdline`, { highWaterMark: 64 * 1024, signal })) {
|
||||
length += chunk.length;
|
||||
if (length > MAX_BYTES) return null;
|
||||
chunks.push(chunk);
|
||||
}
|
||||
return parseLinuxProcessArguments(Buffer.concat(chunks, length));
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/** Exercise the same macOS KERN_PROCARGS2 parser with a crafted buffer, without reading any process. */
|
||||
export async function parseMacProcessArgumentsForTest(bytes: Buffer): Promise<string[] | null> {
|
||||
if (process.platform !== "darwin") return null;
|
||||
try {
|
||||
const { stdout } = await execFileAsync("/usr/bin/osascript", ["-l", "JavaScript", "-e", MAC_ARGUMENTS,
|
||||
JSON.stringify({ pid: 0, fixture: bytes.toString("base64") })], { encoding: "utf8", timeout: 2000, maxBuffer: MAX_OUTPUT });
|
||||
const parsed: unknown = JSON.parse(stdout);
|
||||
return Array.isArray(parsed) && Array.isArray(parsed[0]) && Array.isArray(parsed[0][1]) ? parsed[0][1] as string[] : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/** Exact OS argv for bounded native Codex candidates. Unavailable or malformed reads have no witness. */
|
||||
export async function readNativeProcessArguments(requested: number[]): Promise<Map<number, string[]>> {
|
||||
const pids = [...new Set(requested.filter(pid => Number.isInteger(pid) && pid > 0 && pid <= 2147483647))].slice(0, MAX_PIDS);
|
||||
const result = new Map<number, string[]>();
|
||||
if (pids.length === 0) return result;
|
||||
let entries: unknown;
|
||||
try {
|
||||
if (process.platform === "linux") {
|
||||
const found: Array<[number, string[] | null]> = [];
|
||||
// Bounded concurrency and one overall deadline keep a slow /proc from holding observation indefinitely.
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 2000);
|
||||
try {
|
||||
for (let start = 0; start < pids.length; start += 16) {
|
||||
const batch = await Promise.all(pids.slice(start, start + 16).map(async (pid): Promise<[number, string[] | null]> =>
|
||||
[pid, await boundedLinuxCmdline(pid, controller.signal)]));
|
||||
found.push(...batch);
|
||||
if (controller.signal.aborted) break;
|
||||
}
|
||||
} finally { clearTimeout(timer); }
|
||||
entries = found;
|
||||
} else if (process.platform === "darwin") {
|
||||
const size = Buffer.alloc(8);
|
||||
size.writeBigUInt64LE(BigInt(MAX_BYTES));
|
||||
const requests = pids.map(pid => {
|
||||
const mib = Buffer.alloc(12);
|
||||
mib.writeInt32LE(1, 0); // CTL_KERN
|
||||
mib.writeInt32LE(49, 4); // KERN_PROCARGS2
|
||||
mib.writeInt32LE(pid, 8);
|
||||
return JSON.stringify({ pid, mib: mib.toString("base64"), size: size.toString("base64") });
|
||||
});
|
||||
const { stdout } = await execFileAsync("/usr/bin/osascript", ["-l", "JavaScript", "-e", MAC_ARGUMENTS, ...requests], {
|
||||
encoding: "utf8", timeout: 2000, maxBuffer: MAX_OUTPUT,
|
||||
});
|
||||
entries = JSON.parse(stdout);
|
||||
} else return result;
|
||||
if (!Array.isArray(entries)) return result;
|
||||
const requestedPids = new Set(pids);
|
||||
for (const entry of entries) {
|
||||
if (!Array.isArray(entry) || !requestedPids.has(entry[0])) continue;
|
||||
const args: unknown = entry[1];
|
||||
if (Array.isArray(args) && args.length > 0 && args.length <= MAX_ARGS && typeof args[0] === "string" && args[0].length > 0
|
||||
&& args.every(arg => typeof arg === "string" && !arg.includes("\0"))) result.set(entry[0], args);
|
||||
}
|
||||
} catch { /* No OS argv is unknown, never a fallback to flattened ps text. */ }
|
||||
return result;
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
import { isShellForeground } from "./shell-classifier.js";
|
||||
import { runAsyncSite } from "./sync-site-wrap.js";
|
||||
import { readNativeExecutablePaths } from "./native-process-executable.js";
|
||||
import { readNativeProcessArguments } from "./native-process-arguments.js";
|
||||
import { readProcessTable } from "./process-table.js";
|
||||
|
||||
export interface NativeProcessRow {
|
||||
pid: number;
|
||||
ppid: number;
|
||||
command: string;
|
||||
/** Exact OS argv for native Codex. Missing, malformed, or unreadable argv never proves Codex identity. */
|
||||
argv?: string[] | null;
|
||||
pgid?: number;
|
||||
tpgid?: number;
|
||||
executableName?: string;
|
||||
@@ -128,21 +131,18 @@ function mayBeClaudeAfterScript(row: NativeProcessRow): boolean {
|
||||
}
|
||||
|
||||
function commandUsesExpectedToken(command: string, runtime: NativeRuntime, expectedToken: string): boolean {
|
||||
// Codex takes the exact-argv selector in findExactNativeResumeProcess before this Claude-only search.
|
||||
if (runtime !== "claude-code") return false;
|
||||
const argv = tokens(command);
|
||||
const executable = runtime === "claude-code" ? "claude" : "codex";
|
||||
const executableIndex = argv.findIndex((token) => runtime === "claude-code" ? claudeExecutable(token) : executableName(token) === executable);
|
||||
const executableIndex = argv.findIndex((token) => claudeExecutable(token));
|
||||
if (executableIndex < 0) return false;
|
||||
const args = argv.slice(executableIndex + 1);
|
||||
if (runtime === "claude-code") {
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index]!;
|
||||
if ((arg === "--resume" || arg === "--session-id") && args[index + 1] === expectedToken) return true;
|
||||
if (arg === `--resume=${expectedToken}` || arg === `--session-id=${expectedToken}`) return true;
|
||||
}
|
||||
return false;
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index]!;
|
||||
if ((arg === "--resume" || arg === "--session-id") && args[index + 1] === expectedToken) return true;
|
||||
if (arg === `--resume=${expectedToken}` || arg === `--session-id=${expectedToken}`) return true;
|
||||
}
|
||||
|
||||
return codexConversationToken(args, "resume") === expectedToken;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Interactive options can appear before or after `resume`/`fork`. Their values
|
||||
@@ -167,7 +167,7 @@ function codexOptionEnd(args: string[], index: number): number {
|
||||
}
|
||||
|
||||
// undefined is another command; null is the requested command without an exact token.
|
||||
function codexConversationToken(args: string[], subcommand: "resume" | "fork"): string | null | undefined {
|
||||
function codexConversationToken(args: string[], subcommand: "resume" | "fork", exactArgv = false): string | null | undefined {
|
||||
let resumeIndex = -1;
|
||||
for (let index = 0; index < args.length;) {
|
||||
const arg = args[index]!;
|
||||
@@ -186,10 +186,10 @@ function codexConversationToken(args: string[], subcommand: "resume" | "fork"):
|
||||
if (arg === "--") { conversation ??= resumeArgs[index + 1] ?? null; break; }
|
||||
if (arg === "--last") last = true;
|
||||
if (arg.startsWith("-")) { index = codexOptionEnd(resumeArgs, index); continue; }
|
||||
// ps has lost argv boundaries: after a session and the first prompt word,
|
||||
// later option-looking words belong to the prompt. A real --last conflicts
|
||||
// with a session plus a prompt in Codex's interactive grammar.
|
||||
if (conversation !== null) break;
|
||||
// A flattened ps command cannot tell a quoted prompt from several argv entries. Exact
|
||||
// argv can: keep scanning for a real --last after a prompt, while a literal --last
|
||||
// inside one prompt argument never equals the option.
|
||||
if (conversation !== null) { if (!exactArgv) break; index += 1; continue; }
|
||||
conversation = arg;
|
||||
index += 1;
|
||||
}
|
||||
@@ -215,6 +215,25 @@ export function codexCommandResumeToken(command: string): string | null | undefi
|
||||
return codexConversationToken(tokens(command).slice(1), "resume");
|
||||
}
|
||||
|
||||
/** The native process's own resume identity. Flattened ps text is never positive proof. */
|
||||
export function codexProcessResumeToken(row: Pick<NativeProcessRow, "argv">): string | null | undefined {
|
||||
const argv = codexProcessArgv(row);
|
||||
return argv ? codexConversationToken(argv.slice(1), "resume", true) : undefined;
|
||||
}
|
||||
|
||||
function validCodexArgv(row: Pick<NativeProcessRow, "argv">): row is { argv: string[] } {
|
||||
let bytes = 0;
|
||||
return Array.isArray(row.argv) && row.argv.length > 0 && row.argv.length <= 4096
|
||||
&& typeof row.argv[0] === "string" && row.argv[0].length > 0
|
||||
&& row.argv.every(arg => typeof arg === "string" && !arg.includes("\0")
|
||||
&& (bytes += Buffer.byteLength(arg)) <= 1024 * 1024);
|
||||
}
|
||||
|
||||
/** Exact, bounded argv from a process whose argv[0] also names Codex. No ps-text fallback. */
|
||||
export function codexProcessArgv(row: Pick<NativeProcessRow, "argv">): string[] | null {
|
||||
return validCodexArgv(row) && executableName(row.argv[0]!) === "codex" ? row.argv : null;
|
||||
}
|
||||
|
||||
type ClaudeArgvIds = { resume: string | null; "session-id": string | null };
|
||||
|
||||
// OPR.0.7.0.9: a seat's launch additions, read the same way by both selectors. The options OpenRig already emitted
|
||||
@@ -496,6 +515,19 @@ export async function listNativeProcesses(options: { claudeLaunchPaths?: boolean
|
||||
const paths = await readNativeExecutablePaths(candidates.map(row => row.pid));
|
||||
for (const row of candidates) row.executablePath = paths.get(row.pid);
|
||||
}
|
||||
// Linux argv was captured with this same /proc table sample; never read real /proc
|
||||
// again after a caller has selected a fixture or alternate process-table root.
|
||||
// Only foreground rows can be selected below. Skip macOS helper reads for
|
||||
// background Codex processes; every foreground seat still gets fresh argv.
|
||||
const codexCandidates = process.platform === "darwin"
|
||||
? rows.filter(row => row.executableName === "codex" && row.pgid !== undefined && row.pgid > 0 && row.pgid === row.tpgid)
|
||||
: [];
|
||||
if (codexCandidates.length > 0) {
|
||||
// A failed or timed-out helper read loses argv evidence, not the process table.
|
||||
// The foreground Codex still participates in delivery's uniqueness check.
|
||||
const argumentsByPid = await readNativeProcessArguments(codexCandidates.map(row => row.pid)).catch(() => new Map<number, string[]>());
|
||||
for (const row of codexCandidates) row.argv = argumentsByPid.get(row.pid) ?? null;
|
||||
}
|
||||
return rows;
|
||||
} catch { return []; }
|
||||
}
|
||||
@@ -532,24 +564,30 @@ function candidatesMatching(rows: NativeProcessRow[], panePid: number, runtime:
|
||||
const executable = runtime === "claude-code" ? "claude" : "codex";
|
||||
for (const row of rows) {
|
||||
const osExecutable = runtime === "claude-code" ? executableName(row.executableName ?? "") : row.executableName;
|
||||
if ((runtime === "claude-code" ? !claudeRow(row)
|
||||
: osExecutable !== executable || executableName(tokens(row.command)[0] ?? "") !== executable)
|
||||
|| row.pgid !== root.tpgid || row.tpgid !== root.tpgid) continue;
|
||||
if (row.pgid !== root.tpgid || row.tpgid !== root.tpgid) continue;
|
||||
if (runtime === "claude-code" ? !claudeRow(row) : osExecutable !== executable) continue;
|
||||
// OS-named Codex with unreadable argv still competes for the foreground.
|
||||
// A readable argv[0] naming a different executable is positive contrary evidence.
|
||||
if (runtime === "codex" && validCodexArgv(row) && !codexProcessArgv(row)) return [];
|
||||
const chain: NativeProcessRow[] = [];
|
||||
const visited = new Set<number>();
|
||||
let current: NativeProcessRow | undefined = row;
|
||||
let belongsToPane = false;
|
||||
while (current && !visited.has(current.pid) && current.startedAt) {
|
||||
visited.add(current.pid);
|
||||
chain.push(current);
|
||||
if (current.pid === panePid) { matches.push({ process: row, chain }); break; }
|
||||
if (current.pid === panePid) { matches.push({ process: row, chain }); belongsToPane = true; break; }
|
||||
current = byPid.get(current.ppid);
|
||||
}
|
||||
if (runtime === "codex" && !belongsToPane) return [];
|
||||
}
|
||||
return matches.map(({ process, chain }) => ({ panePid, process,
|
||||
fingerprint: JSON.stringify(chain.map(row => [row.pid, row.ppid, row.startedAt, row.pgid, row.tpgid, row.executableName, row.command, row.executablePath])) }));
|
||||
fingerprint: JSON.stringify(chain.map(row => runtime === "codex"
|
||||
? [row.pid, row.ppid, row.startedAt, row.pgid, row.tpgid, row.executableName, row.command, row.executablePath, row.argv]
|
||||
: [row.pid, row.ppid, row.startedAt, row.pgid, row.tpgid, row.executableName, row.command, row.executablePath])) }));
|
||||
}
|
||||
|
||||
function selectNativeProcess(rows: NativeProcessRow[], panePid: number, expectedToken?: string | null, requireResume = false, runtime: NativeRuntime = "codex", selectedExecutable?: string, rotation?: ClaudeResumeRotation | null, scriptLaunch?: boolean): NativeProcessObservation | null {
|
||||
function selectNativeProcess(rows: NativeProcessRow[], panePid: number, expectedToken?: string | null, requireResume = false, runtime: NativeRuntime = "codex", selectedExecutable?: string, rotation?: ClaudeResumeRotation | null, scriptLaunch?: boolean, requireArgv = false): NativeProcessObservation | null {
|
||||
const matches = nativeProcessCandidates(rows, panePid, runtime, selectedExecutable, scriptLaunch);
|
||||
// #1079: a Codex launcher that spawns Codex is one runtime on one chain.
|
||||
const chain = runtime === "codex" ? launcherChain(matches, rows) : matches;
|
||||
@@ -565,8 +603,11 @@ function selectNativeProcess(rows: NativeProcessRow[], panePid: number, expected
|
||||
// links naming different conversations stay refused. Exact resume proof
|
||||
// needs the deepest process's own argv: a launcher's token is never
|
||||
// inherited by a child that names nothing, which stays deliverable but unproved.
|
||||
const identities = chain.map((link) => codexConversationToken(tokens(link.process.command).slice(1), "resume"));
|
||||
const identities = chain.map((link) => codexProcessResumeToken(link.process));
|
||||
if (new Set(identities.filter((value) => typeof value === "string")).size > 1) return null;
|
||||
// Exact resume proof needs readable argv across the whole foreground chain:
|
||||
// an opaque launcher may name another conversation even when its child agrees.
|
||||
if ((requireResume || requireArgv) && chain.some((link) => !codexProcessArgv(link.process))) return null;
|
||||
if (requireResume && (!expectedToken || identities[0] !== expectedToken)) return null;
|
||||
if (expectedToken !== undefined && identities.some((value) => value !== undefined
|
||||
&& (!expectedToken || value !== expectedToken))) return null;
|
||||
@@ -620,10 +661,11 @@ export function explainNativeRejection(rows: NativeProcessRow[], panePid: number
|
||||
if (launched === rotation?.token) return rotationProcess(process, rotation) ? { kind: "passes" } : { kind: "rotation_process_mismatch", conversation: launched };
|
||||
return { kind: "other_conversation", conversation: launched };
|
||||
}
|
||||
const identities = chain.map((link) => codexConversationToken(tokens(link.process.command).slice(1), "resume"));
|
||||
const identities = chain.map((link) => codexProcessResumeToken(link.process));
|
||||
if (new Set(identities.filter((value) => typeof value === "string")).size > 1) return { kind: "conflicting_conversations" };
|
||||
if (requireResume) {
|
||||
if (!expectedToken) return { kind: "no_recorded_conversation" };
|
||||
if (chain.some((link) => !validCodexArgv(link.process))) return { kind: "not_a_resume" };
|
||||
if (identities[0] === undefined) return { kind: "not_a_resume" };
|
||||
if (identities[0] === null) return { kind: "names_no_conversation" };
|
||||
if (identities[0] !== expectedToken) return { kind: "other_conversation", conversation: identities[0] };
|
||||
@@ -642,6 +684,8 @@ async function observeNativePaneProcess(input: {
|
||||
listProcesses?: NativeProcessLister;
|
||||
expectedToken?: string | null;
|
||||
requireResume?: boolean;
|
||||
/** Codex only: require exact argv on every chain link without requiring a resume (fresh launches remain eligible). */
|
||||
requireArgv?: boolean;
|
||||
/** Canonical executable frozen by the managed launch, never re-resolved at observation time. */
|
||||
selectedExecutable?: string;
|
||||
/** Claude only: the frozen executable is a `#!` script, so a process whose OS executable path is named `claude` is
|
||||
@@ -656,7 +700,7 @@ async function observeNativePaneProcess(input: {
|
||||
if (!pid) return null;
|
||||
const rows = await (input.listProcesses ?? (() => listNativeProcesses({ claudeLaunchPaths: input.scriptLaunch === true })))();
|
||||
return selectNativeProcess(rows, pid, input.expectedToken, input.requireResume, runtime, input.selectedExecutable,
|
||||
runtime === "claude-code" ? input.rotation : undefined, input.scriptLaunch);
|
||||
runtime === "claude-code" ? input.rotation : undefined, input.scriptLaunch, input.requireArgv);
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
@@ -678,11 +722,18 @@ export async function observeCodexForkProcess(input: {
|
||||
const rows = await (input.listProcesses ?? listNativeProcesses)();
|
||||
const chain = launcherChain(nativeProcessCandidates(rows, pid, "codex"), rows);
|
||||
if (!chain?.length) return null;
|
||||
const args = chain.map(link => tokens(link.process.command).slice(1));
|
||||
const parents = args.map(argv => codexConversationToken(argv, "fork"));
|
||||
// A fork is positive history proof. Every chain link must have exact argv,
|
||||
// including the selected child and any launcher that could name a conflict.
|
||||
const args: string[][] = [];
|
||||
for (const link of chain) {
|
||||
const argv = codexProcessArgv(link.process);
|
||||
if (!argv) return null;
|
||||
args.push(argv.slice(1));
|
||||
}
|
||||
const parents = args.map(argv => codexConversationToken(argv, "fork", true));
|
||||
if (!parents.includes(input.parentToken)
|
||||
|| parents.some(parent => parent !== undefined && parent !== input.parentToken)
|
||||
|| args.some(argv => codexConversationToken(argv, "resume") !== undefined)) return null;
|
||||
|| args.some(argv => codexConversationToken(argv, "resume", true) !== undefined)) return null;
|
||||
return chain[0]!;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
@@ -588,9 +588,9 @@ export function getNodeInventory(db: Database.Database, rigId: string): NodeInve
|
||||
const identityVerdicts = new SeatIdentityStore(db).getForRig(rigId);
|
||||
// FS-1 W1.3 S1/S2 — the per-node restore-outcome + oriented reads built ONCE
|
||||
// for the rig (was a query per node inside the rows.map). rig-scoped restore
|
||||
// map = the prior WHERE rig_id=? filter; oriented map is node-scoped/global.
|
||||
// map = the prior WHERE rig_id=? filter; oriented history follows the rig's nodes.
|
||||
const restoreOutcomes = buildRestoreOutcomeMap(db, rigId);
|
||||
const orienteds = buildOrientedMap(db);
|
||||
const orienteds = buildOrientedMap(db, rigId);
|
||||
const codexDaemonOptOuts = new AppliedLaunchObservationStore(db).readCurrentDaemonOptOuts();
|
||||
|
||||
const rows = queryInventoryRows(db, rigId);
|
||||
@@ -1284,8 +1284,8 @@ export function attachTerminalActivityAndWork(
|
||||
}
|
||||
const work = countAssignedWorkForEntry(entry, assignedByDest);
|
||||
const arbitratedNeedsInput = arbitratedNeedsInputSignal(arb);
|
||||
const hook = entry.canonicalSessionName
|
||||
? deps.agentActivity?.getLatestForNode({ sessionName: entry.canonicalSessionName, now,
|
||||
const hook = entry.agentActivity == null && entry.canonicalSessionName
|
||||
? deps.agentActivity?.getLatestForNode({ nodeId: entry.nodeId, sessionName: entry.canonicalSessionName, now,
|
||||
order: entry.runtime === "claude-code" ? "source" : "arrival" }) ?? null : null;
|
||||
return {
|
||||
...entry,
|
||||
@@ -1603,11 +1603,12 @@ export async function attachAgentActivity(
|
||||
};
|
||||
};
|
||||
|
||||
const hookActivity = deps.activityStore?.getLatestForNode({
|
||||
const hookActivity = entry.canonicalSessionName ? deps.activityStore?.getLatestForNode({
|
||||
nodeId: entry.nodeId,
|
||||
sessionName: entry.canonicalSessionName,
|
||||
now: sampledAt,
|
||||
order: entry.runtime === "claude-code" ? "source" : "arrival",
|
||||
});
|
||||
}) : null;
|
||||
// Reuse the existing cached capture. Only a complete supported idle frame, bound to this
|
||||
// target and started in a later second than the last write, distinguishes idle redraw from resumed work.
|
||||
// window_activity has second precision: same-second ordering is unknown. Poll/completion time is not write time.
|
||||
|
||||
@@ -218,7 +218,7 @@ export class NodeLauncher {
|
||||
return { ok: false, code: "selection_changed", message: "The selected node or history changed before launch." };
|
||||
}
|
||||
const tmuxResult = opts?.keepPane
|
||||
? await this.takeOverPane(sessionName, opts.keepPane, sessionCwd, openRigEnv)
|
||||
? await this.takeOverPane(sessionName, opts.keepPane, sessionCwd, openRigEnv, node.id)
|
||||
: await this.tmuxAdapter.createSession(sessionName, sessionCwd, openRigEnv);
|
||||
if (!tmuxResult.ok) {
|
||||
return { ok: false, code: tmuxResult.code, message: tmuxResult.message };
|
||||
@@ -364,17 +364,19 @@ export class NodeLauncher {
|
||||
/** A kept pane's takeover, as a handover's cutover does it: its process ends in place (the pane survives it, with its
|
||||
* scrollback), then a shell starts in it with this launch's environment, for the runtime to launch into. Nothing is
|
||||
* touched when the pane isn't in the session. */
|
||||
private async takeOverPane(sessionName: string, pane: string, cwd: string | undefined, env: Record<string, string>):
|
||||
private async takeOverPane(sessionName: string, pane: string, cwd: string | undefined, env: Record<string, string>, nodeId: string):
|
||||
Promise<{ ok: true; warnings?: string[] } | { ok: false; code: string; message: string }> {
|
||||
let panes: Array<{ id: string }> = [];
|
||||
try { panes = await this.tmuxAdapter.listPanes(sessionName); } catch { /* an unreadable session holds no pane to keep */ }
|
||||
if (!panes.some((candidate) => candidate.id === pane)) {
|
||||
return { ok: false, code: "pane_not_found", message: `Pane ${pane} isn't in tmux session "${sessionName}", so nothing was stopped.` };
|
||||
}
|
||||
const ended = await terminatePaneInPlace(this.tmuxAdapter, pane, this.paneExit, "The seat's process");
|
||||
// The guard resolves the selected seat before writing to its immutable pane id.
|
||||
const inputTarget = this.tmuxAdapter.deliveryGuard ? nodeId : pane;
|
||||
const ended = await terminatePaneInPlace(this.tmuxAdapter, pane, this.paneExit, "The seat's process", inputTarget);
|
||||
if (!ended.ok) return { ok: false, code: "pane_process_not_ended", message: ended.message };
|
||||
const shell = (await this.tmuxAdapter.getDefaultShell()) ?? "/bin/sh";
|
||||
const respawned = await this.tmuxAdapter.respawnPane(pane, shell, { ...(cwd ? { cwd } : {}), env });
|
||||
const respawned = await this.tmuxAdapter.respawnPane(inputTarget, shell, { ...(cwd ? { cwd } : {}), env });
|
||||
if (!respawned.ok) {
|
||||
return { ok: false, code: "pane_not_respawned", message: `The seat's process in pane ${pane} ended, but a shell couldn't start there: ${respawned.message}` };
|
||||
}
|
||||
|
||||
@@ -24,15 +24,16 @@ export async function terminatePaneInPlace(
|
||||
paneId: string,
|
||||
timing: PaneCutoverTiming,
|
||||
label = "Retiree",
|
||||
inputTarget = paneId,
|
||||
): Promise<PaneTerminationResult> {
|
||||
const failures: string[] = [];
|
||||
const remain = await tmux.setRemainOnExit(paneId, true);
|
||||
if (!remain.ok) failures.push(`remain-on-exit: ${remain.message}`);
|
||||
const term = await tmux.signalPaneProcess(paneId, "TERM");
|
||||
const term = await tmux.signalPaneProcess(inputTarget, "TERM");
|
||||
if (!term.ok) failures.push(`TERM: ${term.message}`);
|
||||
if (await waitPaneDead(tmux, paneId, timing)) return { ok: true, path: "graceful" };
|
||||
// Graceful window elapsed — force-kill (the pinned degraded path).
|
||||
const kill = await tmux.signalPaneProcess(paneId, "KILL");
|
||||
const kill = await tmux.signalPaneProcess(inputTarget, "KILL");
|
||||
if (!kill.ok) failures.push(`KILL: ${kill.message}`);
|
||||
if (await waitPaneDead(tmux, paneId, timing)) return { ok: true, path: "forced" };
|
||||
const detail = failures.length > 0 ? ` Tmux failures: ${failures.join("; ")}.` : "";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { Worker } from "node:worker_threads";
|
||||
import { parseLinuxProcessArguments } from "./native-process-arguments.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -14,6 +15,8 @@ export interface ProcessTableRow {
|
||||
/** lstart, in local time: the identity half of pid + start time. */
|
||||
startedAt: string;
|
||||
command: string;
|
||||
/** Linux Codex's exact argv from this same /proc cmdline sample; null when unreadable or malformed. */
|
||||
argv?: string[] | null;
|
||||
}
|
||||
|
||||
let inFlight: Promise<ProcessTableRow[]> | null = null;
|
||||
@@ -207,6 +210,8 @@ function procRow(pid: string, stat: ProcFile, cmdlineFile: ProcFile | null, boot
|
||||
executableName: printable(comm.slice(0, 15)),
|
||||
startedAt: lstart(bootTime + Math.floor(Number(fields[19]) / TICKS_PER_SECOND)),
|
||||
command,
|
||||
...(name === "codex" ? { argv: typeof cmdlineFile === "string"
|
||||
? parseLinuxProcessArguments(Buffer.from(cmdlineFile, "latin1")) : null } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -288,7 +288,7 @@ export class PsProjectionService {
|
||||
? this.seatActivity.getSeatStateBySession(session)
|
||||
: null;
|
||||
const hook = this.agentActivity && session
|
||||
? this.agentActivity.getLatestForNode({ sessionName: session, now: nowDate,
|
||||
? this.agentActivity.getLatestForNode({ nodeId: node.nodeId, sessionName: session, now: nowDate,
|
||||
order: node.runtime === "claude-code" ? "source" : "arrival" })
|
||||
: null;
|
||||
// The row's served activity: a live hook, else the cached pane reading. A confirmed-missing session serves
|
||||
|
||||
@@ -922,7 +922,10 @@ export class QueueRepository {
|
||||
* best-effort {@link maybeNudge} when no store is attached (P34 made that
|
||||
* fallback real code rather than a promise in a comment). */
|
||||
private outbox: OutboxHandler | undefined;
|
||||
private readonly promptBlockedSessions = new Set<string>();
|
||||
// A first clear may recover durable refusals observed before startup. Later clears need
|
||||
// a new positive refusal/attention episode, so a returning prompt cannot spin retries.
|
||||
private readonly promptRecoveryState = new Map<string, boolean>();
|
||||
private readonly promptClearRetries = new Map<string, number>();
|
||||
private resolveOccupantGeneration?: (sessionName: string) => string | null;
|
||||
private readonly resolveSessionAddresses: (name: string) => string[];
|
||||
private readonly wakeRepo: QueueWakeRepository;
|
||||
@@ -1638,11 +1641,18 @@ export class QueueRepository {
|
||||
}
|
||||
|
||||
/** One recovery pass on a positive attention-clear transition; the existing sweep remains the backstop. */
|
||||
async retryPromptWakesOnClear(sessionName: string, state: AgentActivity["state"] | undefined): Promise<void> {
|
||||
if (state === "needs_input") { this.promptBlockedSessions.add(sessionName); return; }
|
||||
if ((state !== "running" && state !== "idle") || !this.promptBlockedSessions.delete(sessionName)) return;
|
||||
async retryPromptWakesOnClear(sessionName: string, state: AgentActivity["state"] | undefined, paneNeedsInput = false): Promise<void> {
|
||||
const addresses = this.resolveSessionAddresses(sessionName);
|
||||
if (!addresses.length) return;
|
||||
// A fresh idle hook must not hide the pane's positive attention observation here.
|
||||
// Inventory keeps its own precedence; this only arms recovery, never admits a send.
|
||||
if (paneNeedsInput || state === "needs_input") {
|
||||
for (const address of addresses) this.promptRecoveryState.set(address, true);
|
||||
return;
|
||||
}
|
||||
if (state !== "running" && state !== "idle") return;
|
||||
if (addresses.every(address => this.promptRecoveryState.get(address) === false)) return;
|
||||
for (const address of addresses) this.promptRecoveryState.set(address, false);
|
||||
// Correlation may expand the group or release members for separate sends. Each claim must
|
||||
// independently qualify for this clear-triggered pass; general recovery keeps its broader policy.
|
||||
const eligible = (entry: OutboxEntry): boolean => {
|
||||
@@ -1656,15 +1666,26 @@ export class QueueRepository {
|
||||
AND (last_nudge_result LIKE 'failed:Refused:%interactive prompt%' OR last_nudge_result = 'failed:target_needs_input')`)
|
||||
.all(...addresses) as Array<{ qitem_id: string; destination_session: string; source_session: string }>;
|
||||
const attempted = new Set<string>();
|
||||
for (const row of rows) {
|
||||
const intents = this.outbox ? this.db.prepare(`SELECT outbox_id, delivery_state FROM outbox_entries
|
||||
WHERE audit_pointer = ? AND substr(outbox_id, 1, ?) = ?`).all(row.qitem_id, WAKE_INTENT_PREFIX.length, WAKE_INTENT_PREFIX) as Array<{ outbox_id: string; delivery_state: string }> : [];
|
||||
if (intents.length) {
|
||||
// Claims, known delivery, uncertainty and deliberate holds are not a prompt-refused wake.
|
||||
for (const intent of intents) if (intent.delivery_state === "failed") await this.deliverWakeIntent(intent.outbox_id, attempted, eligible);
|
||||
} else if (this.getById(row.qitem_id)?.state === "pending") {
|
||||
// Older/non-precommitted nudges retain the existing best-effort path.
|
||||
await this.maybeNudge(row.qitem_id, row.destination_session, undefined, row.source_session);
|
||||
// Reserve the whole selection before awaiting: a correlated send can include several rows.
|
||||
// Its refusal stays durable, but must not re-arm this same clear-triggered pass.
|
||||
for (const row of rows) this.promptClearRetries.set(row.qitem_id, (this.promptClearRetries.get(row.qitem_id) ?? 0) + 1);
|
||||
try {
|
||||
for (const row of rows) {
|
||||
const intents = this.outbox ? this.db.prepare(`SELECT outbox_id, delivery_state FROM outbox_entries
|
||||
WHERE audit_pointer = ? AND substr(outbox_id, 1, ?) = ?`).all(row.qitem_id, WAKE_INTENT_PREFIX.length, WAKE_INTENT_PREFIX) as Array<{ outbox_id: string; delivery_state: string }> : [];
|
||||
if (intents.length) {
|
||||
// Claims, known delivery, uncertainty and deliberate holds are not a prompt-refused wake.
|
||||
for (const intent of intents) if (intent.delivery_state === "failed") await this.deliverWakeIntent(intent.outbox_id, attempted, eligible);
|
||||
} else if (this.getById(row.qitem_id)?.state === "pending") {
|
||||
// Older/non-precommitted nudges retain the existing best-effort path.
|
||||
await this.maybeNudge(row.qitem_id, row.destination_session, undefined, row.source_session);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
for (const row of rows) {
|
||||
const remaining = this.promptClearRetries.get(row.qitem_id)! - 1;
|
||||
if (remaining) this.promptClearRetries.set(row.qitem_id, remaining);
|
||||
else this.promptClearRetries.delete(row.qitem_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4601,6 +4622,13 @@ export class QueueRepository {
|
||||
WHERE qitem_id = ?`
|
||||
)
|
||||
.run(ts, result, qitemId);
|
||||
// The transport's definite no-write refusal establishes the episode even when a
|
||||
// fresh hook hid needs_input. Uncertain delivery never arms clear-triggered replay.
|
||||
if (!this.promptClearRetries.has(qitemId) && (result === "failed:target_needs_input"
|
||||
|| (result.startsWith("failed:Refused:") && result.includes("interactive prompt")))) {
|
||||
const row = this.getById(qitemId);
|
||||
if (row?.state === "pending") this.promptRecoveryState.set(row.destinationSession, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -27,6 +27,7 @@ import { resolvePickupThresholdMinutes } from "./queue-pickup.js";
|
||||
|
||||
import { resolveOperatorSeat, resolveRecoveryAgentSeat, resolveSessionNodeId } from "./queue-owner.js";
|
||||
import { nodeSessionConfirmedMissing } from "./node-inventory.js";
|
||||
import { isBoundTerminalSeat } from "./terminal-seat.js";
|
||||
import type Database from "better-sqlite3";
|
||||
import { deriveCrossHostSuccessorId, hasLinkedSuccessor, matchesRecordedHandoffTarget, QueueRepositoryError, type QueueItem, type QueueRepository } from "./queue-repository.js";
|
||||
import { stalledPickupFinding } from "./queue-pickup.js";
|
||||
@@ -865,21 +866,26 @@ export async function runStuckSweep(deps: StuckSweepDeps): Promise<StuckSweepRes
|
||||
// OpenRig rings, whose work is still unclaimed (its ring failed, or it didn't pick the work up by the unclaimed
|
||||
// age), goes the same way; one that claimed and stalled keeps its own finding, which rings it. Only a person can
|
||||
// make a hosted member check its work, so the person goes first: the operator seat when it's a person's address,
|
||||
// else the row's creator, else the operator agent, skipping any that is unset, this seat, confirmed missing or
|
||||
// unroutable itself.
|
||||
// else the row's creator, else the operator agent, skipping any that is unset, this seat, confirmed missing,
|
||||
// unroutable or a terminal seat itself. A bound terminal seat goes the same way: the queue wake types nothing into
|
||||
// it, and nothing there can claim work.
|
||||
const classOf = (seat: string) => deps.queueRepo.classifyDestinationOf(seat).class;
|
||||
for (const c of candidates) {
|
||||
if (c.route !== c.row.destinationSession) continue;
|
||||
const destination = classOf(c.row.destinationSession);
|
||||
const noRoute = destination === "unroutable" && !!c.row.lastNudgeResult?.startsWith("unroutable:");
|
||||
if (!noRoute && !(destination === "hosted" && !c.row.claimedAt)) continue;
|
||||
const terminalSeat = destination === "pane-bound" && isBoundTerminalSeat(deps.db, c.row.destinationSession);
|
||||
if (!noRoute && !terminalSeat && !(destination === "hosted" && !c.row.claimedAt)) continue;
|
||||
const operatorSeat = (deps.resolveOperatorSeat ?? resolveOperatorSeat)();
|
||||
const person = operatorSeat && classOf(operatorSeat) === "gateway-routable" ? operatorSeat : null;
|
||||
const choices = [person, c.row.sourceSession, (deps.resolveRecoveryAgentSeat ?? resolveRecoveryAgentSeat)()];
|
||||
const chosen = choices.findIndex((seat) => !!seat && seat !== c.route && !sessionMissing(seat) && classOf(seat) !== "unroutable");
|
||||
const chosen = choices.findIndex((seat) => !!seat && seat !== c.route && !sessionMissing(seat) && classOf(seat) !== "unroutable"
|
||||
&& !isBoundTerminalSeat(deps.db, seat));
|
||||
c.why += noRoute
|
||||
? `; ${c.route} has no wake route on this daemon (no terminal, not a person). If it's a hosted member, it sees this work only when it next checks, so ask it to; otherwise fix the address`
|
||||
: `; ${c.route} is a hosted member. OpenRig rang its trigger (${c.row.lastNudgeResult ?? "no ring recorded"}), and it hasn't claimed this work. It sees work only when it checks, so ask it to`;
|
||||
: terminalSeat
|
||||
? `; ${c.route} is a terminal seat; nothing there can claim this work, and no wake is typed into it. Route the work to an agent seat`
|
||||
: `; ${c.route} is a hosted member. OpenRig rang its trigger (${c.row.lastNudgeResult ?? "no ring recorded"}), and it hasn't claimed this work. It sees work only when it checks, so ask it to`;
|
||||
if (chosen < 0) {
|
||||
c.why += `. No person, creator or operator agent that can be reached is available, so this finding stays addressed to it`;
|
||||
continue;
|
||||
|
||||
@@ -458,6 +458,62 @@ export class QueueTransitionLog {
|
||||
return this.getOwnerNotificationReceipt(qitemId, notificationKey) !== null;
|
||||
}
|
||||
|
||||
/** The latest owner-notification episode per ACTIVE queue row, in queue order. One query
|
||||
* joins every eligibility rule into the selection — the row is active, it carries an owner
|
||||
* notification, and (when `includePosted` is false, the alert sweep's path) no posted
|
||||
* receipt exists for that notification's key — so rows that cannot matter never reach a
|
||||
* per-row lookup. There is no row cap: an eligible item older than any window still comes
|
||||
* back, and `posted` reports the receipt state for callers that list both.
|
||||
*
|
||||
* The eligible set is restricted to active queue items before any ranking happens, so a
|
||||
* large notification archive never adds scan or ranking work to a sweep with no active item. */
|
||||
activeOwnerNotificationEpisodes(includePosted: boolean): Array<{ qitemId: string; transition: QueueTransition; posted: boolean }> {
|
||||
if (!this.hasOwnerNotificationColumns) return [];
|
||||
// Exact token match with no wildcard risk: pad both ends so a key at the note's
|
||||
// start or end still matches, and instr() compares literally (qitem ids may
|
||||
// contain characters LIKE would treat as wildcards).
|
||||
const receipt = (qitemIdExpr: string, transitionIdExpr: string) =>
|
||||
`EXISTS (
|
||||
SELECT 1 FROM ${this.historySource} r
|
||||
WHERE r.qitem_id = ${qitemIdExpr}
|
||||
AND r.transition_note LIKE 'slack-owner-notification-posted %'
|
||||
AND instr(' ' || r.transition_note || ' ', ' notification_key=' || ${qitemIdExpr} || ':' || ${transitionIdExpr} || ' ') > 0
|
||||
)`;
|
||||
const postedFilter = includePosted ? "" : `AND NOT ${receipt("latest.qitem_id", "latest.transition_id")}`;
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`WITH active_ids AS (
|
||||
SELECT qitem_id FROM queue_items WHERE state IN ('pending', 'in-progress', 'blocked')
|
||||
),
|
||||
active AS (
|
||||
SELECT qitem_id, ts_created FROM queue_items WHERE state IN ('pending', 'in-progress', 'blocked')
|
||||
),
|
||||
history_active AS (
|
||||
SELECT h.* FROM ${this.historySource} h
|
||||
WHERE h.qitem_id IN (SELECT qitem_id FROM active_ids)
|
||||
AND h.owner_notification_level IS NOT NULL
|
||||
),
|
||||
ranked AS (
|
||||
SELECT h.*, ROW_NUMBER() OVER (PARTITION BY h.qitem_id ORDER BY h.transition_id DESC) AS rn
|
||||
FROM history_active h
|
||||
),
|
||||
latest AS (
|
||||
SELECT * FROM ranked WHERE rn = 1
|
||||
)
|
||||
SELECT latest.*, ${receipt("latest.qitem_id", "latest.transition_id")} AS posted
|
||||
FROM active
|
||||
JOIN latest ON latest.qitem_id = active.qitem_id
|
||||
${postedFilter}
|
||||
ORDER BY active.ts_created DESC`,
|
||||
)
|
||||
.all() as Array<QueueTransitionRow & { posted: 0 | 1 }>;
|
||||
return rows.map((row) => ({
|
||||
qitemId: row.qitem_id,
|
||||
transition: this.rowToTransition(row),
|
||||
posted: row.posted === 1,
|
||||
}));
|
||||
}
|
||||
|
||||
getOwnerNotificationReceipt(qitemId: string, notificationKey: string): { messageTs: string | null; threadTs: string | null; digest: string | null } | null {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { resolveEscalationOwner, ESCALATION_HOP_TAG, hasConsumedEscalationHop } from "./escalation-owner.js";
|
||||
import type { ArbitratedSeatState } from "./activity-taxonomy.js";
|
||||
import { seatRuntimeHomeBySession } from "./seat-runtime-home.js";
|
||||
import { OPENRIG_SENDER } from "../lib/pane-envelope.js";
|
||||
import { renderQueueHandoffNudge } from "./queue-nudge-text.js";
|
||||
import { quietHoldCustody } from "./quiet-hold-custody.js";
|
||||
import { latestHookMovedOn, latestHookPermissionPending, latestHookWaitsOnPerson } from "./agent-activity-store.js";
|
||||
import type { AgentActivity } from "./types.js";
|
||||
@@ -640,7 +642,10 @@ export async function runWakeLadderTick(deps: WakeLadderDeps): Promise<WakeLadde
|
||||
deps.attemptWake ??
|
||||
(async (qitemId: string, target: string): Promise<string> => {
|
||||
// A ladder retry is OpenRig's own scheduling on the row, whoever created it.
|
||||
await deps.queueRepo.maybeNudge(qitemId, target, true, undefined, undefined, "openrig");
|
||||
// It is sent as OpenRig (daemon@kernel); the row's original source stays visible in the body.
|
||||
const row = deps.queueRepo.getById(qitemId);
|
||||
const bodyOverride = `${renderQueueHandoffNudge(qitemId, null)}${row?.sourceSession ? ` Row source: ${row.sourceSession}.` : ""}`;
|
||||
await deps.queueRepo.maybeNudge(qitemId, target, true, OPENRIG_SENDER, bodyOverride, "openrig");
|
||||
return deps.queueRepo.getById(qitemId)?.lastNudgeWireResult ?? "indeterminate:no transport available";
|
||||
});
|
||||
|
||||
@@ -842,7 +847,7 @@ export async function runWakeLadderTick(deps: WakeLadderDeps): Promise<WakeLadde
|
||||
if (!due || suspended) continue;
|
||||
const consumedReason = `${reason}; escalates_to one-hop delivery already used; existing fallback retained`;
|
||||
// Keep this recovery visible without sharing the ordinary destination aggregate or forwarding another hop.
|
||||
await ensureEscalationRow(deps, row.destinationSession, resolveOperatorSeat() ?? row.sourceSession,
|
||||
await ensureEscalationRow(deps, row.destinationSession, humanAlertCarrier(deps, row.destinationSession, row.sourceSession),
|
||||
[{ row, reason: consumedReason }], consumedReason, `consumed-hop:${row.qitemId}`, [ESCALATION_HOP_TAG]);
|
||||
if (!view.orchRung) appendMarker(deps.queueRepo, row, `${LADDER_RUNG_PREFIX} orchestrator self-skip reason=${consumedReason}`);
|
||||
if (await operatorRung(deps, row, consumedReason, actions, view)) {
|
||||
@@ -878,10 +883,10 @@ export async function runWakeLadderTick(deps: WakeLadderDeps): Promise<WakeLadde
|
||||
// F4: rung 1 self-skips when it resolves to the destination itself (or nowhere)
|
||||
// — never escalate INTO the dead seat; fall through to the operator rung now.
|
||||
// The operator floor must be a VISIBLE OBJECT, not markers alone: ensure the
|
||||
// per-destination escalation row exists (addressed to the operator seat, else
|
||||
// the obligation's own creator) so the escalations view and the health count
|
||||
// per-destination escalation row has an admissible carrier so the
|
||||
// escalations view and the health count
|
||||
// expose it — it stays open past the batons' exhaustion.
|
||||
const floorDest = resolveOperatorSeat() ?? needsOrchRung[0]!.row.sourceSession;
|
||||
const floorDest = humanAlertCarrier(deps, dest, needsOrchRung[0]!.row.sourceSession);
|
||||
await ensureEscalationRow(deps, dest, floorDest, needsOrchRung, reason);
|
||||
for (const m of needsOrchRung) {
|
||||
appendMarker(
|
||||
@@ -1073,6 +1078,13 @@ function waitsOnPostedPromptAlert(deps: WakeLadderDeps, row: QueueItem, choiceOn
|
||||
return true;
|
||||
}
|
||||
|
||||
// The operator engine owns actual human delivery. Its non-nudged queue carrier
|
||||
// must still be locally admissible; cross-host source provenance is not a local destination.
|
||||
function humanAlertCarrier(deps: WakeLadderDeps, dest: string, source?: string): string {
|
||||
const candidates = [resolveOperatorSeat(), source];
|
||||
return candidates.find((candidate): candidate is string => Boolean(candidate && deps.queueRepo.acceptsDestination(candidate))) ?? dest;
|
||||
}
|
||||
|
||||
/** Positive refusals enter the existing rungs without ever retrying the blocked
|
||||
* seat. One aggregate carries the episode's obligations and notification key;
|
||||
* original work remains owned by its current recipient. */
|
||||
@@ -1282,7 +1294,7 @@ async function advancePromptRefusals(
|
||||
const choiceOnly = Boolean(group.choice && !group.other);
|
||||
const promptTags = choiceOnly ? [PROMPT_ALERT_TAG, PROMPT_CHOICE_TAG, `consumed-prompt-destination:${dest}`,
|
||||
...(group.rowKeys.get(member.row.qitemId) ?? [])] : [];
|
||||
const continuation = await ensureEscalationRow(deps, dest, resolveOperatorSeat() ?? member.row.sourceSession,
|
||||
const continuation = await ensureEscalationRow(deps, dest, humanAlertCarrier(deps, dest, member.row.sourceSession),
|
||||
[{ row: member.row, reason }], reason, `consumed-hop:${member.row.qitemId}`, [ESCALATION_HOP_TAG, ...promptTags]);
|
||||
if (!view.orchRung) appendMarker(deps.queueRepo, member.row, `${LADDER_RUNG_PREFIX} orchestrator self-skip reason=${reason}`);
|
||||
// A choice alert's tags and dispatch marker must live on the same durable row the shared gate reads.
|
||||
@@ -1322,7 +1334,7 @@ async function advancePromptRefusals(
|
||||
const kindTags = choiceOnly ? [PROMPT_CHOICE_TAG] : [];
|
||||
const keys = group.keys.filter(key => group.jobKeys.has(key) || members.some(m => group.rowKeys.get(m.row.qitemId)?.has(key)));
|
||||
const aggregate = await ensureEscalationRow(deps, dest,
|
||||
toOperator ? resolveOperatorSeat() ?? members[0]?.row.sourceSession ?? [...group.jobs.values()][0]! : orch!, members, reason,
|
||||
toOperator ? humanAlertCarrier(deps, dest, members[0]?.row.sourceSession ?? [...group.jobs.values()][0]) : orch!, members, reason,
|
||||
`prompt-destination:${dest}`, [PROMPT_ALERT_TAG, ...kindTags, ...keys], group.jobs);
|
||||
const row = deps.queueRepo.getById(aggregate.qitemId);
|
||||
if (!row) continue;
|
||||
|
||||
@@ -6,8 +6,8 @@ import { assessSnapshotCurrentOccupants, stoppedSnapshotReason } from "./rehydra
|
||||
import { nonInterruptiveNotice, nonInterruptiveSummary } from "../adapters/non-interruptive.js";
|
||||
import { planModelAliasNotice } from "../adapters/yolo-mode.js";
|
||||
import { effortPrecedence, readSeatEffort } from "./seat-effort.js";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { accessSync, constants, existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
||||
import { isAbsolute, join } from "node:path";
|
||||
import { ensureSeatSkillPlugin, hasSeatSkillRecords, rehydrateSeatSkills, seatSkillPaths, seatSkillPathsForNode, seatCatalogKeptNote, seatSkillReplay, writeSeatSkillCatalog, type SeatSkillRehydration, writeSeatSkillRouter, seatRouterKeptNote } from "./seat-skill-storage.js";
|
||||
import type Database from "better-sqlite3";
|
||||
import { NativePermissionStore } from "./native-permission-store.js";
|
||||
@@ -29,7 +29,8 @@ import type { OmpResumeAdapter } from "../adapters/omp-resume.js";
|
||||
import type { TranscriptStore } from "./transcript-store.js";
|
||||
import { assessNativeResumeProbe } from "./native-resume-probe.js";
|
||||
import { resumeProbeEvidence, type NativeResumeProbeEvidence } from "./native-resume-probe-evidence.js";
|
||||
import { observeClaudeDelivery, observeClaudeResumeLaunch, verifyClaudePaneProcess, verifyPaneIdleShell, type ClaudeLaunchedProcess } from "./native-process-lineage.js";
|
||||
import { observeClaudeDelivery, observeClaudeResumeLaunch, processArgv, verifyClaudePaneProcess, verifyClaudePaneRuntime, verifyPaneIdleShell, type ClaudeLaunchedProcess } from "./native-process-lineage.js";
|
||||
import { readNativeExecutablePaths } from "./native-process-executable.js";
|
||||
import { reanchorBuiltinStartupFile, reanchorShippedProjectionEntry } from "./builtin-startup-files.js";
|
||||
import { applySeatSkillsLineRule, seatGuidance, restoreMissingGuidance, missingGuidanceBlocks } from "./restore-guidance.js";
|
||||
import { readFootprintAnswer } from "./footprint-choice.js";
|
||||
@@ -1200,6 +1201,8 @@ export class RestoreOrchestrator {
|
||||
if (opts?.keepPane && (!keptPane || !priorState.binding?.tmuxSession)) {
|
||||
return { nodeId, logicalId: node.logicalId, status: "failed", error: `${node.logicalId} has no bound tmux pane to keep, so nothing was stopped.` };
|
||||
}
|
||||
// Read before takeover ends the process. An unavailable witness keeps normal launch resolution unchanged.
|
||||
const claudeExecutable = keptPane && node.runtime === "claude-code" ? await this.claudeContinuationExecutable(keptPane) : undefined;
|
||||
|
||||
// Clear stale state so NodeLauncher doesn't see already_bound. A kept pane keeps its binding through the takeover: the
|
||||
// delivery guard resolves the pane through it.
|
||||
@@ -1346,11 +1349,26 @@ export class RestoreOrchestrator {
|
||||
warnings?.push(...launchResult.warnings);
|
||||
}
|
||||
|
||||
const restored = await this.postLaunchRestore(entry, rigId, data, launchResult.sessionName, launchResult, opts, warnings, priorState, seatRehydration, resumeSession);
|
||||
const restored = await this.postLaunchRestore(entry, rigId, data, launchResult.sessionName, launchResult,
|
||||
{ ...opts, ...(claudeExecutable ? { claudeExecutable } : {}) }, warnings, priorState, seatRehydration, resumeSession);
|
||||
const checked = this.checkRestoredGuidance(restored, entry.node, rigId, data, warnings);
|
||||
return selectedHistory ? { ...checked, sourceSessionId: selectedHistory.historySessionId, resultSessionId: launchResult.session.id } : checked;
|
||||
}
|
||||
|
||||
private async claudeContinuationExecutable(pane: string): Promise<string | undefined> {
|
||||
const native = await verifyClaudePaneRuntime({ target: pane, tmux: this.tmuxAdapter,
|
||||
...(this.listProcesses ? { listProcesses: this.listProcesses } : {}) });
|
||||
if (!native) return undefined;
|
||||
const argv0 = processArgv(native.process.command)[0];
|
||||
const executable = argv0 && isAbsolute(argv0) ? argv0 : native.process.executablePath
|
||||
?? (await readNativeExecutablePaths([native.process.pid])).get(native.process.pid);
|
||||
try {
|
||||
if (!executable || !isAbsolute(executable)) return undefined;
|
||||
accessSync(executable, constants.X_OK);
|
||||
return statSync(executable).isFile() ? realpathSync(executable) : undefined;
|
||||
} catch { return undefined; }
|
||||
}
|
||||
|
||||
/** A seat that came back running should also have its role, culture and SOP guidance. If its
|
||||
* guidance file still lacks a managed block it should hold, the seat keeps its true status (a
|
||||
* live resumed session is still running) and the gap is disclosed on the node and in the
|
||||
@@ -1447,7 +1465,7 @@ export class RestoreOrchestrator {
|
||||
data: SnapshotData,
|
||||
sessionName: string,
|
||||
launchResult?: { ok: true; sessionName: string; session: import("./types.js").Session; binding: import("./types.js").Binding },
|
||||
opts?: { adapters?: Record<string, import("./runtime-adapter.js").RuntimeAdapter>; fsOps?: { exists(path: string): boolean }; freshLogicalIds?: string[]; selectedHistory?: SelectedRestoreHistory },
|
||||
opts?: { adapters?: Record<string, import("./runtime-adapter.js").RuntimeAdapter>; fsOps?: { exists(path: string): boolean }; freshLogicalIds?: string[]; selectedHistory?: SelectedRestoreHistory; claudeExecutable?: string },
|
||||
warnings?: string[],
|
||||
priorState?: { binding: import("./types.js").Binding | null; sessions: { id: string; status: string }[] },
|
||||
seatRehydration?: SeatSkillRehydration,
|
||||
@@ -1471,6 +1489,17 @@ export class RestoreOrchestrator {
|
||||
}
|
||||
return restoreEffort.effort;
|
||||
};
|
||||
// The model `rig seat set-model` saved after the snapshot is the newer choice, and it says so when it differs.
|
||||
let restoreModel: string | null | undefined;
|
||||
const modelForRestore = (): string | null => {
|
||||
if (restoreModel === undefined) {
|
||||
restoreModel = this.liveModel(rigId, node, selected?.nodeId);
|
||||
if (node.model && restoreModel !== node.model) {
|
||||
warnings?.push(`Restore: ${node.logicalId}: this restore launched the seat's saved model ${restoreModel}, not ${node.model} from the snapshot.`);
|
||||
}
|
||||
}
|
||||
return restoreModel;
|
||||
};
|
||||
// OPR.0.5.7.1 D1 — the active occupant is RESOLVED, never inferred from
|
||||
// row ordering (cite site 2 of 2; "latest = max id" was the incident's
|
||||
// defect: a superseded row with a newer ULID defeated the real occupant).
|
||||
@@ -1597,8 +1626,8 @@ export class RestoreOrchestrator {
|
||||
} catch { /* best-effort: without it a rotation stays unproved, as before */ }
|
||||
}
|
||||
if (selected && !selected.beforeNative(launchedSessionId, launchResult?.binding.id)) return { nodeId: node.id, logicalId: node.logicalId, status: "attention_required", error: "The selected seat, settings or history changed after shell creation; native resume was not dispatched." };
|
||||
const resumeOutcome = await this.attemptResume(node.id, sessionName, resumeType, resumeToken, node.cwd ?? "/", this.liveCodexProfile(rigId, node, selected?.nodeId), node.model, this.resolveRestorePosture(node.id, rigId), effortForRestore(), warnings, node.advisor,
|
||||
selected ? () => selected.beforeNative(launchedSessionId, launchResult?.binding.id) : undefined);
|
||||
const resumeOutcome = await this.attemptResume(node.id, sessionName, resumeType, resumeToken, node.cwd ?? "/", this.liveCodexProfile(rigId, node, selected?.nodeId), modelForRestore(), this.resolveRestorePosture(node.id, rigId), effortForRestore(), warnings, node.advisor,
|
||||
selected ? () => selected.beforeNative(launchedSessionId, launchResult?.binding.id) : undefined, opts?.claudeExecutable);
|
||||
if (resumeOutcome.kind === "resumed") {
|
||||
baseStatus = "resumed";
|
||||
if (launchedSessionId && this.claudeResume.canResume(resumeType, resumeToken)) {
|
||||
@@ -1819,6 +1848,7 @@ export class RestoreOrchestrator {
|
||||
|
||||
const binding = {
|
||||
...launchResult.binding,
|
||||
...(opts?.claudeExecutable ? { claudeExecutable: opts.claudeExecutable } : {}),
|
||||
cwd: node.cwd ?? ".",
|
||||
codexConfigProfile: this.liveCodexProfile(rigId, node, selected?.nodeId) ?? undefined,
|
||||
// OPR.0.4.8.3 Seam B: the pod-aware restore path binds the restored posture too
|
||||
@@ -1830,7 +1860,7 @@ export class RestoreOrchestrator {
|
||||
// the declared provider) passed nothing through ("No API key
|
||||
// found" on every resumed Pi seat). Claude/Codex silently lost
|
||||
// their -m/--model on restore the same way.
|
||||
model: node.model ?? undefined,
|
||||
model: modelForRestore() ?? undefined,
|
||||
effort: effortForRestore() ?? undefined,
|
||||
advisor: node.advisor ?? undefined,
|
||||
};
|
||||
@@ -2141,6 +2171,18 @@ export class RestoreOrchestrator {
|
||||
return "floor";
|
||||
}
|
||||
|
||||
/** The live seat's current model: `rig seat set-model` saves it on the live node after the snapshot, and every
|
||||
* managed resume reads it at call time, so an older snapshot can't bring back the model it replaced. Found by logical
|
||||
* id, as effort's restore does; a seat with no live row, or no model on it, keeps the snapshot's. */
|
||||
private liveModel(rigId: string, node: { logicalId: string; model?: string | null }, selectedNodeId?: string): string | null {
|
||||
const row = this.db.prepare(selectedNodeId
|
||||
? "SELECT model FROM nodes WHERE id = ? AND rig_id = ? AND logical_id = ?"
|
||||
: "SELECT model FROM nodes WHERE rig_id = ? AND logical_id = ?")
|
||||
.get(...(selectedNodeId ? [selectedNodeId, rigId, node.logicalId] : [rigId, node.logicalId])) as
|
||||
{ model: string | null } | undefined;
|
||||
return row?.model ?? node.model ?? null;
|
||||
}
|
||||
|
||||
/** The live seat's current Codex profile: a setter change made after the snapshot is the newer choice, so an older
|
||||
* snapshot can't bring back the profile it replaced. The seat is found by logical id, as effort's restore does; a
|
||||
* seat with no live row keeps the snapshot's value. */
|
||||
@@ -2168,6 +2210,7 @@ export class RestoreOrchestrator {
|
||||
warnings?: string[],
|
||||
advisor?: string | null,
|
||||
beforeNative?: () => boolean,
|
||||
claudeExecutable?: string,
|
||||
): Promise<
|
||||
| { kind: "resumed"; launchedProcess?: ClaudeLaunchedProcess }
|
||||
| { kind: "retry_fresh" }
|
||||
@@ -2207,10 +2250,11 @@ export class RestoreOrchestrator {
|
||||
const tail: unknown[] = claudePermissions !== undefined
|
||||
? [...claudeTail, ...Array(5 - claudeTail.length).fill(undefined), launch.additions, claudePermissions]
|
||||
: launch.additions ? [...claudeTail, ...Array(5 - claudeTail.length).fill(undefined), launch.additions] : claudeTail;
|
||||
const guardedTail = beforeNative ? [...tail, ...Array(Math.max(0, 7 - tail.length)).fill(undefined), beforeNative] : tail;
|
||||
const guardedTail = beforeNative || claudeExecutable ? [...tail, ...Array(Math.max(0, 7 - tail.length)).fill(undefined), beforeNative,
|
||||
...(claudeExecutable ? [claudeExecutable] : [])] : tail;
|
||||
if (beforeNative && !beforeNative()) return { kind: "attention_required", message: "The selected seat, settings or history changed during native preparation; resume was not dispatched." };
|
||||
const result = await this.claudeResume.resume(sessionName, resumeType, resumeToken, cwd, resolvedPosture, model, permissionMode, nodeId,
|
||||
...(guardedTail as [...typeof launchTail, advisor?: string, launchAdditions?: SeatLaunchAdditions, claudePermissions?: import("./claude-permission-rules.js").ClaudePermissionRules, beforeNativeDispatch?: () => boolean]));
|
||||
...(guardedTail as [...typeof launchTail, advisor?: string, launchAdditions?: SeatLaunchAdditions, claudePermissions?: import("./claude-permission-rules.js").ClaudePermissionRules, beforeNativeDispatch?: () => boolean, executable?: string]));
|
||||
if (result.ok) {
|
||||
// OPR.0.7.0.9: the resumed conversation's own start, as on the pod-aware path.
|
||||
const promptNote = launch.additions?.appendSystemPromptFile && nodeId && resumeToken ? systemPromptPendingNoteForNode(this.db, nodeId, resumeToken) : undefined;
|
||||
|
||||
@@ -155,6 +155,7 @@ export class RigExpansionService {
|
||||
: {}),
|
||||
...(member.cwd ? { cwd: member.cwd } : {}),
|
||||
...(member.model ? { model: member.model } : {}),
|
||||
...(member.effort === undefined ? {} : { effort: member.effort }),
|
||||
// OPR.0.4.6.FAC1: role rides the fragment→spec map (a
|
||||
// provided role must never be silently dropped here).
|
||||
...(member.role ? { role: member.role } : {}),
|
||||
|
||||
@@ -9,6 +9,8 @@ import type { ClaudeResumeRotation } from "./native-process-lineage.js";
|
||||
|
||||
export interface NodeBinding extends Binding {
|
||||
cwd: string;
|
||||
/** In-place Claude continuation only: executable observed before the old process exits. */
|
||||
claudeExecutable?: string;
|
||||
model?: string;
|
||||
effort?: string;
|
||||
/** Claude-only, session-scoped Advisor model. */
|
||||
|
||||
@@ -188,7 +188,9 @@ export class SeatActivityService {
|
||||
let updated = false;
|
||||
for (const [id, request] of snapshot) {
|
||||
if (seat.permissions.get(id) !== request) continue;
|
||||
request.seen ||= visible === true;
|
||||
// A screen does not identify which Claude actor owns a dialog. With overlapping actors, only a
|
||||
// scoped batch or turn boundary can settle their unseen notifications.
|
||||
request.seen ||= visible === true && (!claude || seat.permissions.size === 1);
|
||||
request.confirmed = visible !== false || (claude && !request.seen);
|
||||
request.nextCheckMs = this.now().getTime()
|
||||
+ (visible === false && this.now().getTime() - request.sinceMs >= 10_000 ? 5_000 : DEFAULT_POLL_INTERVAL_MS);
|
||||
@@ -196,25 +198,62 @@ export class SeatActivityService {
|
||||
updated = true;
|
||||
}
|
||||
if (!updated) return;
|
||||
seat.permissionObservedAt = this.now().toISOString();
|
||||
const count = [...seat.permissions.values()].filter(request => request.confirmed).length;
|
||||
if (claude && count === 0) {
|
||||
for (const source of seat.sources.values()) {
|
||||
if (source.latest.permissionRequest) source.latest = { ...source.latest, needsInput: undefined };
|
||||
}
|
||||
}
|
||||
const sourceId = `${runtime}:permission-prompt`;
|
||||
this.reportEvidence({
|
||||
seatNodeId: seatNodeId!, sessionName, rung: "needs-input-chrome", sourceId,
|
||||
seq: (seat.sources.get(sourceId)?.latest.seq ?? 0) + 1,
|
||||
observedAt: seat.permissionObservedAt,
|
||||
needsInput: { count, reason: count ? (!claude && visible === null ? "permission_request_unverified" : "permission_prompt") : null },
|
||||
});
|
||||
this.reportPermissionObservation(seatNodeId!, seat, "needs-input-chrome", this.now().toISOString(),
|
||||
!claude && visible === null ? "permission_request_unverified" : "permission_prompt");
|
||||
} finally {
|
||||
seat.permissionPolling = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** A native Claude PostToolBatch resolves only this actor's earlier permission notifications. The route checks
|
||||
* the emitting occupant first. Missing actor attribution and unordered timestamps retain the pane/turn fallback. */
|
||||
completeClaudeToolBatch(sessionName: string, hookAgentId: string | null, observedAt: string): boolean {
|
||||
const seatNodeId = this.sessionToSeat.get(sessionName);
|
||||
const seat = seatNodeId ? this.ladder.get(seatNodeId) : undefined;
|
||||
if (!seat || seat.retired || seat.inventory?.runtime !== "claude-code") return false;
|
||||
const completedAt = Date.parse(observedAt);
|
||||
if (!Number.isFinite(completedAt)) return false;
|
||||
// A notification may arrive after its completion, including when no request was observed yet.
|
||||
seat.claudeToolBatches.set(hookAgentId, Math.max(completedAt, seat.claudeToolBatches.get(hookAgentId) ?? -Infinity));
|
||||
let settled = false;
|
||||
for (const [id, request] of seat.permissions) {
|
||||
if (request.hookAgentId === undefined || request.hookAgentId !== hookAgentId
|
||||
|| !(Date.parse(request.observedAt) < completedAt)) continue;
|
||||
seat.permissions.delete(id);
|
||||
settled = true;
|
||||
}
|
||||
if (!settled) return false;
|
||||
seat.permissionRevision++;
|
||||
this.reportPermissionObservation(seatNodeId!, seat, "lifecycle-hooks", observedAt, "permission_prompt");
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Whether this occupant's actor already completed a strictly later batch. Equal timestamps do not establish order. */
|
||||
hasLaterClaudeToolBatch(sessionName: string, hookAgentId: string | null | undefined, observedAt: string): boolean {
|
||||
if (hookAgentId === undefined) return false;
|
||||
const seatNodeId = this.sessionToSeat.get(sessionName);
|
||||
const seat = seatNodeId ? this.ladder.get(seatNodeId) : undefined;
|
||||
if (!seat || seat.retired || seat.inventory?.runtime !== "claude-code") return false;
|
||||
const completedAt = seat.claudeToolBatches.get(hookAgentId);
|
||||
return completedAt !== undefined && Date.parse(observedAt) < completedAt;
|
||||
}
|
||||
|
||||
private reportPermissionObservation(seatNodeId: string, seat: SeatLadderState, rung: EvidenceRungId,
|
||||
observedAt: string, reason: string): void {
|
||||
const runtime = seat.inventory?.runtime ?? "codex";
|
||||
seat.permissionEvidence = { rung, observedAt };
|
||||
const count = [...seat.permissions.values()].filter(request => request.confirmed).length;
|
||||
if (runtime === "claude-code" && count === 0) {
|
||||
for (const source of seat.sources.values()) {
|
||||
if (source.latest.permissionRequest) source.latest = { ...source.latest, needsInput: undefined };
|
||||
}
|
||||
}
|
||||
const sourceId = `${runtime}:permission-prompt`;
|
||||
this.reportEvidence({ seatNodeId, sessionName: seat.sessionName, rung, sourceId,
|
||||
seq: (seat.sources.get(sourceId)?.latest.seq ?? 0) + 1, observedAt,
|
||||
needsInput: { count, reason: count ? reason : null } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the latest stored observation for a seat, or null when no
|
||||
* observation has been recorded yet (e.g. service hasn't polled this
|
||||
@@ -237,7 +276,8 @@ export class SeatActivityService {
|
||||
seat.retired = true;
|
||||
seat.sources.clear();
|
||||
seat.permissions.clear();
|
||||
seat.permissionObservedAt = null;
|
||||
seat.claudeToolBatches.clear();
|
||||
seat.permissionEvidence = null;
|
||||
seat.pendingIdle = null;
|
||||
seat.contradictionSinceMs = null;
|
||||
seat.promotion.clear();
|
||||
@@ -387,16 +427,21 @@ export class SeatActivityService {
|
||||
const claude = runtime === "claude-code";
|
||||
const wasConfirmed = seat.arbitrated.needsInput.count > 0;
|
||||
if (evidence.permissionRequest) {
|
||||
seat.permissionRevision++;
|
||||
const request = evidence.permissionRequest;
|
||||
if (claude && this.hasLaterClaudeToolBatch(evidence.sessionName, request.hookAgentId, evidence.observedAt)) return;
|
||||
const previousRequest = seat.permissions.get(request.id);
|
||||
// Relay posts can arrive out of order. Keep the newer actor request so an older batch cannot settle it.
|
||||
if (claude && previousRequest && Date.parse(evidence.observedAt) < Date.parse(previousRequest.observedAt)) return;
|
||||
seat.permissionRevision++;
|
||||
if (claude || !seat.permissions.has(request.id)) {
|
||||
seat.permissions.set(request.id, { sinceMs: this.now().getTime(), confirmed: claude, seen: false, nextCheckMs: 0 });
|
||||
seat.permissions.set(request.id, { sinceMs: this.now().getTime(), observedAt: evidence.observedAt,
|
||||
hookAgentId: request.hookAgentId, confirmed: claude, seen: false, nextCheckMs: 0 });
|
||||
}
|
||||
} else if ((evidence.rung === "lifecycle-hooks" && (evidence.activity === "idle-at-prompt" || evidence.permissionResolved
|
||||
|| (claude && evidence.needsInput?.count && evidence.needsInput.reason !== "permission_prompt")))
|
||||
|| (claude && seat.permissions.size === 0 && evidence.needsInput?.count && evidence.needsInput.reason !== "permission_prompt")) {
|
||||
seat.permissions.clear();
|
||||
seat.permissionObservedAt = null;
|
||||
seat.permissionEvidence = null;
|
||||
seat.sources.delete(`${runtime}:permission-prompt`);
|
||||
}
|
||||
seat.sources.set(evidence.sourceId, {
|
||||
@@ -423,7 +468,7 @@ export class SeatActivityService {
|
||||
const seat = seatNodeId ? this.ladder.get(seatNodeId) : undefined;
|
||||
if (!seat || seat.retired) return;
|
||||
seat.permissions.clear();
|
||||
seat.permissionObservedAt = null;
|
||||
seat.permissionEvidence = null;
|
||||
seat.sources.delete(`${seat.inventory?.runtime ?? "codex"}:permission-prompt`);
|
||||
for (const source of seat.sources.values()) {
|
||||
if (source.latest.needsInput?.count) source.latest = { ...source.latest, needsInput: undefined };
|
||||
@@ -440,7 +485,8 @@ export class SeatActivityService {
|
||||
if (!seat) return;
|
||||
seat.sources.clear();
|
||||
seat.permissions.clear();
|
||||
seat.permissionObservedAt = null;
|
||||
seat.claudeToolBatches.clear();
|
||||
seat.permissionEvidence = null;
|
||||
seat.pendingIdle = null;
|
||||
seat.contradictionSinceMs = null;
|
||||
seat.promotion.clear();
|
||||
@@ -523,9 +569,10 @@ export class SeatActivityService {
|
||||
inventory: null,
|
||||
sources: new Map(),
|
||||
permissions: new Map(),
|
||||
claudeToolBatches: new Map(),
|
||||
permissionRevision: 0,
|
||||
permissionPolling: false,
|
||||
permissionObservedAt: null,
|
||||
permissionEvidence: null,
|
||||
trust: new Map(),
|
||||
promotion: new Map(),
|
||||
contradictionSinceMs: null,
|
||||
@@ -760,7 +807,7 @@ export class SeatActivityService {
|
||||
}
|
||||
|
||||
// Codex permission hooks need probe confirmation after grace; Claude hooks latch
|
||||
// attention until a seen prompt disappears. Unavailable capture fails safe.
|
||||
// attention until a seen prompt disappears or the same actor's tool batch completes. Unavailable capture fails safe.
|
||||
// Other prompts retain their existing rung arbitration.
|
||||
const chrome = this.latestByRung(seat, "needs-input-chrome");
|
||||
const hooksEv = this.latestByRung(seat, "lifecycle-hooks");
|
||||
@@ -775,14 +822,14 @@ export class SeatActivityService {
|
||||
: null;
|
||||
const confirmedCount = [...seat.permissions.values()].filter(p => p.confirmed).length;
|
||||
const permissionTracked = (seat.inventory?.runtime === "codex" || seat.inventory?.runtime === "claude-code")
|
||||
&& (seat.permissions.size > 0 || seat.permissionObservedAt !== null);
|
||||
&& (seat.permissions.size > 0 || seat.permissionEvidence !== null);
|
||||
const needsInput: NeedsInputShape = permissionTracked
|
||||
? { count: confirmedCount, reason: confirmedCount ? chrome?.needsInput?.reason ?? "permission_prompt" : null }
|
||||
: decider?.needsInput ?? { count: 0, reason: null };
|
||||
if (permissionTracked && Boolean(confirmedCount) !== Boolean(seat.arbitrated.needsInput.count)) {
|
||||
this.permissionPromptChanged?.(seat.sessionName, Boolean(confirmedCount), seat.inventory!.runtime);
|
||||
}
|
||||
const needsInputEvidence = permissionTracked ? (seat.permissionObservedAt ? { rung: "needs-input-chrome" as const, observedAt: seat.permissionObservedAt } : null) : decider ? { rung: decider.rung, observedAt: decider.observedAt } : null;
|
||||
const needsInputEvidence = permissionTracked ? seat.permissionEvidence : decider ? { rung: decider.rung, observedAt: decider.observedAt } : null;
|
||||
|
||||
// Which rung answers is part of the answer: default zero → observed zero changes what
|
||||
// consumers may conclude, so it advances seq and pushes like a count change.
|
||||
@@ -847,10 +894,12 @@ interface PromotionRun {
|
||||
}
|
||||
|
||||
interface SeatLadderState {
|
||||
permissions: Map<string, { sinceMs: number; confirmed: boolean; seen: boolean; nextCheckMs: number }>;
|
||||
permissions: Map<string, { sinceMs: number; observedAt: string; hookAgentId?: string | null;
|
||||
confirmed: boolean; seen: boolean; nextCheckMs: number }>;
|
||||
claudeToolBatches: Map<string | null, number>;
|
||||
permissionRevision: number;
|
||||
permissionPolling: boolean;
|
||||
permissionObservedAt: string | null;
|
||||
permissionEvidence: ArbitratedSeatState["needsInputEvidence"];
|
||||
sessionName: string;
|
||||
retired: boolean;
|
||||
inventory: AdapterRungInventory | null;
|
||||
|
||||
@@ -80,7 +80,7 @@ async function describeLineageFailure(input: {
|
||||
const rows = pid ? await Promise.resolve((input.listProcesses ?? listNativeProcesses)()).catch((): NativeProcessRow[] => []) : [];
|
||||
if (!pid || !rows.some((row) => row.pid === pid)) return "The pane's process could not be found in the process listing (the listing may have failed), so the runtime could not be verified";
|
||||
const observation = { target: input.target, tmux: input.tmux, listProcesses: () => rows };
|
||||
const passes = input.variant === "codex" ? !!await observeCodexPaneProcess({ ...observation, expectedToken: input.expectedToken, requireResume: input.exact })
|
||||
const passes = input.variant === "codex" ? !!await observeCodexPaneProcess({ ...observation, expectedToken: input.expectedToken, requireResume: input.exact, requireArgv: true })
|
||||
: input.variant === "claude_conversation" ? !!await observeClaudePaneProcess({ ...observation, expectedToken: input.expectedToken, rotation: input.rotation })
|
||||
: input.variant === "claude_runtime" ? !!await observeClaudePaneRuntime(observation)
|
||||
: !!(input.expectedToken && findExactNativeResumeProcess(rows, pid, "claude-code", input.expectedToken, input.rotation));
|
||||
@@ -208,7 +208,7 @@ export async function rebindAndVerifyPaneIdentity(input: {
|
||||
runtimeMatch = "match";
|
||||
const native = await verifyCodexPaneProcess({ target: pane.id, tmux: input.tmux,
|
||||
listProcesses: input.listProcesses, expectedToken: expectedResumeToken,
|
||||
requireResume: input.requireExactResumeLineage === true });
|
||||
requireResume: input.requireExactResumeLineage === true, requireArgv: true });
|
||||
const currentPanes = await input.tmux.listPanes(input.sessionName).catch(() => []);
|
||||
if (native?.panePid === pid && currentPanes.length === 1 && currentPanes[0]?.id === pane.id) lineageMatch = native.process;
|
||||
} else if (claudeProcessProof) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { hasOtherSeatHistory, isStoppedSeatPredecessor, resolveCurrentSessionAdd
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type Database from "better-sqlite3";
|
||||
import { composerContainsOwnedText, ownCollapsedPaste, type ComposerInput, type ComposerOwnership } from "./composer-prompts.js";
|
||||
import { composerPasteUncertainty, composerContainsOwnedText, ownCollapsedPaste, type ComposerInput, type ComposerOwnership } from "./composer-prompts.js";
|
||||
|
||||
/** A binding is captured before waiting. Never rebind an old operation to a new occupant. */
|
||||
export interface GuardTarget {
|
||||
@@ -414,7 +414,7 @@ export class SeatDeliveryGuard {
|
||||
}
|
||||
|
||||
/** Called at the paste/key boundary after file and buffer preparation. */
|
||||
async beforeInput(name: string, submit = false): Promise<void> {
|
||||
async beforeInput(name: string, submit = false, pasteText?: string): Promise<void> {
|
||||
if (!this.needsInputCheck(name)) return;
|
||||
const target = this.target(name);
|
||||
const lease = this.scope.getStore()!.get(target.nodeId)!;
|
||||
@@ -433,6 +433,15 @@ export class SeatDeliveryGuard {
|
||||
} else if (input.state !== "empty") {
|
||||
throw new DeliveryGuardError("draft_input_busy", "The seat has an unsubmitted input. Automatic delivery is held.");
|
||||
} else {
|
||||
const uncertainty = pasteText === undefined ? null : composerPasteUncertainty(input, pasteText);
|
||||
if (uncertainty) {
|
||||
const message = uncertainty === "controls"
|
||||
? "OpenRig couldn't confirm the layout of tabs or control characters. Send the message without them; nothing was pasted."
|
||||
: uncertainty === "long"
|
||||
? "OpenRig couldn't confirm ownership of Codex's collapsed long paste. Send a shorter message, or set this seat's typing guard to off for delivery without draft protection; nothing was pasted."
|
||||
: "OpenRig couldn't confirm the complete message would stay visible in this Codex viewport. Open a larger terminal or shorten the message, then retry this request; nothing was pasted.";
|
||||
throw new DeliveryGuardError("draft_input_unknown", message);
|
||||
}
|
||||
lease.inputFrame = input.frame;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,6 +235,7 @@ export class SeatIdentityReconciler {
|
||||
return Promise.all(nativeSeats.map((seat) => (seat.runtime === "codex" ? observeCodexPaneProcess
|
||||
: seat.resume_token !== null && seat.resume_token !== undefined ? observeClaudePaneProcess : observeClaudePaneRuntime)({
|
||||
target: seat.tmux_pane!, tmux, expectedToken: seat.resume_token, rotation: claudeResumeRotation(seat),
|
||||
...(seat.runtime === "codex" ? { requireArgv: true } : {}),
|
||||
listProcesses: () => snapshot ??= this.listProcesses(),
|
||||
})));
|
||||
};
|
||||
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
type EffortResolution, type NativeEffortObservation,
|
||||
} from "./seat-effort.js";
|
||||
import {
|
||||
claudeCommandSessionToken, codexCommandResumeToken, observeClaudePaneRuntime, observeCodexPaneProcess, processArgv,
|
||||
claudeCommandSessionToken, codexProcessArgv, codexProcessResumeToken, observeClaudePaneRuntime, observeCodexPaneProcess, processArgv,
|
||||
} from "./native-process-lineage.js";
|
||||
import { defaultInPlaceRestartIo } from "./seat-restart-in-place.js";
|
||||
import nodePath from "node:path";
|
||||
@@ -615,19 +615,26 @@ export class SeatLifecycleService {
|
||||
({ emitted, observed } = await this.piEffort(seat.nodeId, session.session_name, saved));
|
||||
} else {
|
||||
const observe = runtime === "claude-code" ? observeClaudePaneRuntime : observeCodexPaneProcess;
|
||||
const process = (await observe({ target, tmux: this.tmuxAdapter, ...(this.listProcesses ? { listProcesses: this.listProcesses } : {}) }))?.process;
|
||||
const process = (await observe({ target, tmux: this.tmuxAdapter, ...(runtime === "codex" ? { requireArgv: true } : {}),
|
||||
...(this.listProcesses ? { listProcesses: this.listProcesses } : {}) }))?.process;
|
||||
if (!process) {
|
||||
emitted = { state: "unknown", detail: `No single ${runtime === "claude-code" ? "Claude" : "Codex"} process could be read in the seat's pane.` };
|
||||
observed = { state: "not_verified", detail: "The running process could not be read, so no turn can be tied to its launch." };
|
||||
} else {
|
||||
const flag = emittedEffortFromArgv(runtime, processArgv(process.command));
|
||||
const value = flag.flag === "present" ? flag.value : null;
|
||||
emitted = { state: "observed", value, pid: process.pid, startedAt: process.startedAt ?? null, matchesSaved: value === saved.effective };
|
||||
// Native effort is attributed only from the transcript of the conversation this process is on.
|
||||
const joined = await this.joinedTranscript(runtime, session, process);
|
||||
const native = "detail" in joined ? { state: "not_verified" as const, detail: joined.detail }
|
||||
: readNativeEffort(runtime, joined.path, process.startedAt ?? null);
|
||||
observed = native.state === "observed" ? { ...native, matchesEmitted: native.value === value } : native;
|
||||
const argv = runtime === "codex" ? codexProcessArgv(process) : processArgv(process.command);
|
||||
if (!argv) {
|
||||
emitted = { state: "unknown", detail: "The running Codex's exact arguments could not be read, so its launch effort is unknown." };
|
||||
observed = { state: "not_verified", detail: "The running Codex's exact arguments could not be read, so no native turn can be tied to its launch." };
|
||||
} else {
|
||||
const flag = emittedEffortFromArgv(runtime, argv);
|
||||
const value = flag.flag === "present" ? flag.value : null;
|
||||
emitted = { state: "observed", value, pid: process.pid, startedAt: process.startedAt ?? null, matchesSaved: value === saved.effective };
|
||||
// Native effort is attributed only from the transcript of the conversation this process is on.
|
||||
const joined = await this.joinedTranscript(runtime, session, process);
|
||||
const native = "detail" in joined ? { state: "not_verified" as const, detail: joined.detail }
|
||||
: readNativeEffort(runtime, joined.path, process.startedAt ?? null);
|
||||
observed = native.state === "observed" ? { ...native, matchesEmitted: native.value === value } : native;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ok: true, seat, runtime, requested, saved, emitted, observed, precedence, summary: effortSummary(saved, emitted, observed, runtime) };
|
||||
@@ -696,7 +703,7 @@ export class SeatLifecycleService {
|
||||
/** The transcript of the conversation the observed process is on, or why that join can't be made.
|
||||
* The recorded conversation (Codex: the session's resume token; Claude: the status sidecar's session)
|
||||
* must be the one the process itself names, by argv or, for a Codex that names none, by its own logs. */
|
||||
private async joinedTranscript(runtime: "claude-code" | "codex", session: LatestSessionRow, process: { pid: number; command: string; startedAt?: string }):
|
||||
private async joinedTranscript(runtime: "claude-code" | "codex", session: LatestSessionRow, process: { pid: number; command: string; argv?: string[] | null; startedAt?: string }):
|
||||
Promise<{ path: string | null } | { detail: string }> {
|
||||
if (!this.transcripts) return { detail: "The native transcript for this session isn't known to OpenRig." };
|
||||
const mismatch = "The process in the pane isn't on the conversation recorded for this seat (or its conversation couldn't be read), so no native turn can be attributed to it.";
|
||||
@@ -704,7 +711,7 @@ export class SeatLifecycleService {
|
||||
if (runtime === "codex") {
|
||||
const recorded = this.sessionResumeToken(session.id);
|
||||
if (!recorded) return { detail: "No Codex conversation is recorded for this session yet." };
|
||||
let own = codexCommandResumeToken(process.command) ?? undefined;
|
||||
let own = codexProcessResumeToken(process) ?? undefined;
|
||||
if (own === undefined && process.startedAt) {
|
||||
const io = { ...defaultInPlaceRestartIo(), ...this.inPlaceRestartIo };
|
||||
const facts = await io.readProcess(process.pid);
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { SessionRegistry } from "./session-registry.js";
|
||||
import type { HarnessLaunchResult, NodeBinding } from "./runtime-adapter.js";
|
||||
import type { PersistedEvent, RigEvent } from "./types.js";
|
||||
import {
|
||||
codexCommandResumeToken, listNativeProcesses, processArgv, verifyCodexPaneProcess,
|
||||
codexProcessArgv, codexProcessResumeToken, listNativeProcesses, processArgv, verifyCodexPaneProcess,
|
||||
type NativeProcessLister, type NativeProcessRow,
|
||||
} from "./native-process-lineage.js";
|
||||
import { CodexThreadIdResolver, resolveCodexDbPaths, type CodexThreadEvidence } from "./codex-thread-id.js";
|
||||
@@ -405,6 +405,8 @@ export class CodexInPlaceRestart {
|
||||
return { ok: false, code: "resume_binding_unverified", message: "No single Codex process in the seat's pane could be tied to the stored conversation (a running Codex that names another conversation is refused too). Nothing was stopped." };
|
||||
}
|
||||
const { process, panePid, rows } = codex;
|
||||
const runningArgv = codexProcessArgv(process);
|
||||
if (!runningArgv) return { ok: false, code: "settings_unverified", message: "The running Codex's exact arguments could not be read, so its launch settings are unverified. Nothing was stopped." };
|
||||
const startedAt = process.startedAt ?? null;
|
||||
const facts = await this.io.readProcess(process.pid);
|
||||
if (!facts) return { ok: false, code: "settings_unverified", message: "The running Codex's working directory and Codex home could not be read (this observer reads /proc on Linux). Nothing was stopped." };
|
||||
@@ -412,7 +414,7 @@ export class CodexInPlaceRestart {
|
||||
if (!codexHome) return { ok: false, code: "settings_unverified", message: "The running Codex's home could not be determined (no CODEX_HOME or HOME). Nothing was stopped." };
|
||||
|
||||
// The conversation the PROCESS is on: argv for a resumed Codex, its own native logs for a fresh one.
|
||||
const argvToken = codexCommandResumeToken(process.command);
|
||||
const argvToken = codexProcessResumeToken(process);
|
||||
let conversationProof: "argv" | "native_logs";
|
||||
if (argvToken === token) conversationProof = "argv";
|
||||
else if (argvToken === undefined && startedAt && (await this.io.readThreadId(process.pid, startedAt, codexHome)) === token) conversationProof = "native_logs";
|
||||
@@ -468,8 +470,7 @@ export class CodexInPlaceRestart {
|
||||
const relaunchBinding: NodeBinding = { ...binding, cwd };
|
||||
// The probed and prepared launch choices come from the running Codex, so the relaunch keeps them and no
|
||||
// Codex process is started to decide them again.
|
||||
// The exact argv when /proc gives it: a --add-dir with a space (a cwd's .git) must not be cut at the space.
|
||||
const runningArgv = facts.argv?.length ? facts.argv : processArgv(process.command);
|
||||
// Keep the launch settings tied to the same OS argv used for the selected process's identity.
|
||||
const running = runningLaunch(runningArgv);
|
||||
const relaunch = await this.deps.codex.buildResumeCommand(relaunchBinding, { name: sessionName, resumeToken: token, running });
|
||||
if (!relaunch.ok) return { ok: false, code: "relaunch_unavailable", message: `The resume command could not be built: ${relaunch.error}. Nothing was stopped.` };
|
||||
@@ -568,7 +569,7 @@ export class CodexInPlaceRestart {
|
||||
const panePid = await this.deps.tmux.getPanePid(pane);
|
||||
if (!panePid) return null;
|
||||
const observation = await verifyCodexPaneProcess({ target: pane, tmux: { getPanePid: async () => panePid },
|
||||
listProcesses: () => rows, ...(token ? { expectedToken: token } : {}) });
|
||||
listProcesses: () => rows, requireArgv: true, ...(token ? { expectedToken: token } : {}) });
|
||||
return observation && observation.panePid === panePid ? { process: observation.process, panePid, rows } : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -467,6 +467,13 @@ export function inspectComposerInput(snapshot: ComposerSnapshot | null, runtime:
|
||||
input.collapsedPaste = visible.join("\n");
|
||||
input.pasteMatchesExpected = true;
|
||||
}
|
||||
if (prompt.glyph !== "❯" && codexFooterSeparator(lines) === end) {
|
||||
// Codex 0.164 fullscreen: composer <= 2/3 of the pane (at least eight
|
||||
// rows, capped by the pane), less padding/status/hint rows. Leave the
|
||||
// one-column right inset out of the native printable width.
|
||||
input.pasteViewport = { columns: width - prefix - 1,
|
||||
rows: Math.min(height, Math.max(8, Math.floor(height * 2 / 3))) - 4 };
|
||||
}
|
||||
candidates.push(input);
|
||||
}
|
||||
return candidates.length === 1 ? candidates[0]! : unknown(candidates.length ? "ambiguous_composer" : "unrecognized_composer", snapshot.cursor);
|
||||
|
||||
@@ -275,11 +275,16 @@ export function deriveOriented(db: Database.Database, nodeId: string): NodeOrien
|
||||
* node-scoped, so the per-node subset of rows + the identical fold yield the
|
||||
* identical verdict. Nodes with no proof events are simply absent (caller
|
||||
* defaults to "n-a", matching the no-challenge branch).
|
||||
* An optional rig scope reads only its current nodes' history, retaining the
|
||||
* same per-node fold without materializing other rigs' proof events.
|
||||
*/
|
||||
export function buildOrientedMap(db: Database.Database): Map<string, NodeOriented> {
|
||||
export function buildOrientedMap(db: Database.Database, rigId?: string): Map<string, NodeOriented> {
|
||||
// Inventory only consumes the current rig's nodes. Scope by node identity,
|
||||
// not events.rig_id: a node's proof history can predate its current rig.
|
||||
const scope = rigId === undefined ? "" : " AND node_id IN (SELECT id FROM nodes WHERE rig_id = ?)";
|
||||
const rows = db.prepare(
|
||||
"SELECT node_id, type, payload, seq FROM events WHERE type IN ('node.startup_challenged','node.startup_proof_skipped','node.startup_proof_verified','node.startup_proof_rejected') ORDER BY node_id, seq DESC"
|
||||
).all() as Array<{ node_id: string; type: string; payload: string; seq: number }>;
|
||||
"SELECT node_id, type, payload, seq FROM events WHERE type IN ('node.startup_challenged','node.startup_proof_skipped','node.startup_proof_verified','node.startup_proof_rejected')" + scope + " ORDER BY node_id, seq DESC"
|
||||
).all(...(rigId === undefined ? [] : [rigId])) as Array<{ node_id: string; type: string; payload: string; seq: number }>;
|
||||
const byNode = new Map<string, OrientedEventRow[]>();
|
||||
for (const r of rows) {
|
||||
let list = byNode.get(r.node_id);
|
||||
|
||||
@@ -9,20 +9,21 @@ import { HerdrFollowView, type ReadFollowInput } from "./herdr-follow-view.js";
|
||||
* request. No request mutates process.env or another request's transport.
|
||||
*/
|
||||
export function createHerdrProviders(env: NodeJS.ProcessEnv = process.env, input?: ReadFollowInput) {
|
||||
env = { ...env };
|
||||
if (env.XDG_CONFIG_HOME !== undefined) env.XDG_CONFIG_HOME = path.resolve(env.XDG_CONFIG_HOME);
|
||||
const defaultSocket = resolveHerdrSocketPath(env);
|
||||
const base = path.dirname(resolveHerdrSocketPath({}));
|
||||
let launch = 0;
|
||||
const viewers = new Map<string, HerdrViewer>();
|
||||
const viewer = (endpoint: NonNullable<ProviderStatus["launch"]>) => {
|
||||
let selected = viewers.get(endpoint.socketPath);
|
||||
if (!selected) { selected = new HerdrViewer(endpoint.socketPath, createHerdrViewerRun(endpoint.session)); viewers.set(endpoint.socketPath, selected); }
|
||||
if (!selected) { selected = new HerdrViewer(endpoint.socketPath, createHerdrViewerRun(endpoint, env)); viewers.set(endpoint.socketPath, selected); }
|
||||
return selected;
|
||||
};
|
||||
const make = (endpoint: NonNullable<ProviderStatus["launch"]>) => {
|
||||
const transportFactory = createHerdrSocketTransport(createHerdrSocketRpc(endpoint.socketPath));
|
||||
return new HerdrAdapter({
|
||||
transportFactory,
|
||||
following: new HerdrFollowView({ socketPath: endpoint.socketPath, run: createHerdrViewerRun(endpoint.session), transport: transportFactory(), input }),
|
||||
following: new HerdrFollowView({ socketPath: endpoint.socketPath, run: createHerdrViewerRun(endpoint, env), transport: transportFactory(), input }),
|
||||
launch: endpoint,
|
||||
viewer: viewer(endpoint),
|
||||
newLaunchToken: () => `l${++launch}`,
|
||||
@@ -34,8 +35,7 @@ export function createHerdrProviders(env: NodeJS.ProcessEnv = process.env, input
|
||||
sessionProvider: (session: string) => {
|
||||
let provider = sessions.get(session);
|
||||
if (!provider) {
|
||||
provider = make({ session, socketPath: session === "default"
|
||||
? path.join(base, "herdr.sock") : path.join(base, "sessions", session, "herdr.sock") });
|
||||
provider = make({ session, socketPath: resolveHerdrSocketPath(env, session) });
|
||||
sessions.set(session, provider);
|
||||
}
|
||||
return provider;
|
||||
|
||||
@@ -52,12 +52,17 @@ export interface HerdrTransport {
|
||||
|
||||
export type HerdrTransportFactory = () => HerdrTransport;
|
||||
|
||||
/** Resolve the herdr control socket path: env override → per-session → default. */
|
||||
export function resolveHerdrSocketPath(env: NodeJS.ProcessEnv = process.env): string {
|
||||
if (env["HERDR_SOCKET_PATH"]) return env["HERDR_SOCKET_PATH"] as string;
|
||||
const base = path.join(homedir(), ".config", "herdr");
|
||||
const session = env["HERDR_SESSION"];
|
||||
return session ? path.join(base, "sessions", session, "herdr.sock") : path.join(base, "herdr.sock");
|
||||
/** A per-request session keeps the daemon's config home. An explicit socket
|
||||
* belongs to its configured session, never to an unrelated requested session. */
|
||||
export function resolveHerdrSocketPath(env: NodeJS.ProcessEnv = process.env, selectedSession?: string): string {
|
||||
if (env["HERDR_SOCKET_PATH"] && (selectedSession === undefined || selectedSession === env["HERDR_SESSION"])) {
|
||||
return env["HERDR_SOCKET_PATH"];
|
||||
}
|
||||
const configHome = env["XDG_CONFIG_HOME"] !== undefined
|
||||
? path.resolve(env["XDG_CONFIG_HOME"]) : path.join(env["HOME"] || homedir(), ".config");
|
||||
const base = path.join(configHome, "herdr");
|
||||
const session = selectedSession ?? env["HERDR_SESSION"];
|
||||
return session && session !== "default" ? path.join(base, "sessions", session, "herdr.sock") : path.join(base, "herdr.sock");
|
||||
}
|
||||
|
||||
/** Extract a version token (e.g. from a ping result's `version`, or `herdr --version`). */
|
||||
|
||||
@@ -26,13 +26,15 @@ const runFile = promisify(execFile);
|
||||
|
||||
/** The supported CLI, never guessed socket RPC method parity. Its reported
|
||||
* endpoint must match the adapter's selected socket before any mutation. */
|
||||
export function createHerdrViewerRun(session?: string): HerdrViewerRun {
|
||||
export function createHerdrViewerRun(endpoint: { socketPath: string }, sourceEnv: NodeJS.ProcessEnv = process.env): HerdrViewerRun {
|
||||
// Herdr's explicit --session wins over the socket override. Select by the
|
||||
// already resolved socket alone, including paths outside the config tree.
|
||||
const env: NodeJS.ProcessEnv = { ...sourceEnv, HERDR_SOCKET_PATH: endpoint.socketPath };
|
||||
delete env.HERDR_SESSION;
|
||||
return async args => {
|
||||
const binary = resolveOnPath("herdr", process.env.PATH);
|
||||
const binary = resolveOnPath("herdr", env.PATH);
|
||||
if (!binary) throw new Error("Herdr CLI is unavailable.");
|
||||
const env = { ...process.env };
|
||||
delete env.HERDR_SESSION; delete env.HERDR_SOCKET_PATH;
|
||||
const { stdout } = await runFile(binary, [...(session && session !== "default" ? ["--session", session] : []), ...args],
|
||||
const { stdout } = await runFile(binary, args,
|
||||
{ env, encoding: "utf8", timeout: 5000, maxBuffer: 2 * 1024 * 1024 });
|
||||
return stdout;
|
||||
};
|
||||
|
||||
@@ -315,7 +315,7 @@ export class TerminalService {
|
||||
if ("code" in resolved) return resolved;
|
||||
const composed = composeView(resolved.id, await this.refineLiveness(resolved.members), { resolveHost: (id) => this.deps.resolveHost(id), panesPerPage: resolved.panesPerPage ?? panesPerPage, localTmux: await this.resolveLocalTmux(), localTmuxServer: daemonTmuxServerArgs() });
|
||||
const saved = view.startsWith("saved:") ? this.deps.viewsStore.get(view.slice(6))
|
||||
: !/^(qitem|mission|slice|pod|rig):/.test(view) ? this.deps.viewsStore.get(view) : null;
|
||||
: !/^(qitem|mission|slice|pod|seat|rig):/.test(view) ? this.deps.viewsStore.get(view) : null;
|
||||
composed.viewDescriptor = { kind: saved && saved.id === resolved.id ? "fixed" : "following",
|
||||
name: resolved.task?.name ?? (saved?.id === resolved.id ? saved.name : resolved.id) };
|
||||
if (resolved.task) {
|
||||
@@ -495,6 +495,23 @@ export class TerminalService {
|
||||
return { id: view, members: deriveViewMembers(rows, { readOnly: false }) };
|
||||
}
|
||||
|
||||
// 2b. one seat within a rig — `seat:<rig-id-or-name>/<logicalId>` (the TUI's agent page opens just that agent).
|
||||
// Interactive, like its rig. A logical id has no `/`, so the last `/` splits it from the rig.
|
||||
if (view.startsWith("seat:")) {
|
||||
const rest = view.slice("seat:".length);
|
||||
const slash = rest.lastIndexOf("/");
|
||||
if (slash <= 0 || slash === rest.length - 1) {
|
||||
return { code: "view_not_found", error: `malformed seat view '${view}' — expected seat:<rig>/<logical-id>` };
|
||||
}
|
||||
const rigArg = rest.slice(0, slash);
|
||||
const logicalId = rest.slice(slash + 1);
|
||||
const rows = ((await this.deps.listRigSeats(rigArg)) ?? []).filter((row) => row.logicalId === logicalId);
|
||||
if (rows.length === 0) {
|
||||
return { code: "view_not_found", error: `unknown seat '${logicalId}' in rig '${rigArg}'` };
|
||||
}
|
||||
return { id: view, members: deriveViewMembers(rows, { readOnly: false }) };
|
||||
}
|
||||
|
||||
// 3. a rig — either the bare name (the common case: `rig terminal open acme`)
|
||||
// or the explicit `rig:<id-or-name>` form the rig-scoped route alias
|
||||
// composes (arch R1: the alias just prefixes `rig:<rigId>` and delegates,
|
||||
|
||||
@@ -574,6 +574,8 @@ export type AgentActivityEvidenceSource =
|
||||
|
||||
export interface AgentActivity {
|
||||
turnId?: string | null;
|
||||
/** Native Claude hook actor: null is the main session; absent means an older or unrecognized relay. */
|
||||
hookAgentId?: string | null;
|
||||
|
||||
state: AgentActivityState;
|
||||
reason: string;
|
||||
@@ -1487,6 +1489,8 @@ export interface ExpansionPodFragment {
|
||||
profile?: string;
|
||||
cwd?: string;
|
||||
model?: string;
|
||||
/** As written: the canonical RigSpec schema trims a text effort and ignores any other value with an advisory. */
|
||||
effort?: unknown;
|
||||
codexConfigProfile?: string;
|
||||
claudePermissions?: unknown;
|
||||
codexPermissions?: unknown;
|
||||
|
||||
@@ -866,7 +866,7 @@ export class WorkflowRuntime {
|
||||
);
|
||||
});
|
||||
if (entryQitemId && entryQitemDestinationSession) {
|
||||
await this.queueRepo.maybeNudge(entryQitemId, entryQitemDestinationSession, entryQitemNudge);
|
||||
await this.queueRepo.maybeNudge(entryQitemId, entryQitemDestinationSession, entryQitemNudge, input.createdBySession);
|
||||
}
|
||||
|
||||
const finalInstance = this.instanceStore.getByIdOrThrow(instanceId!);
|
||||
|
||||
@@ -52,7 +52,11 @@ export function evidenceFromHookActivity(input: {
|
||||
}
|
||||
if (input.runtime === "claude-code" && input.activity.rawEvent === "Notification"
|
||||
&& input.activity.reason === "permission_prompt") {
|
||||
return { ...base, permissionRequest: { id: "claude-permission-prompt" }, needsInput: { count: 1, reason: "permission_prompt" } };
|
||||
const hookAgentId = input.activity.hookAgentId;
|
||||
return { ...base, permissionRequest: {
|
||||
id: hookAgentId ? `claude-permission-prompt:${hookAgentId}` : "claude-permission-prompt",
|
||||
...(hookAgentId !== undefined ? { hookAgentId } : {}),
|
||||
}, needsInput: { count: 1, reason: "permission_prompt" } };
|
||||
}
|
||||
// A runner's exit report clears what its last turn said: the seat is no longer at a prompt or mid-turn.
|
||||
if (input.activity.rawEvent === "Stop" && isRunnerExit(input.activity.rawSubtype, input.runtime)) {
|
||||
@@ -448,6 +452,46 @@ activityRoutes.post("/hooks", async (c) => {
|
||||
return c.json({ ok: true, oriented: "verified", nodeId: result.nodeId, challengeId: result.challengeId });
|
||||
}
|
||||
|
||||
// All tools in this actor's batch have resolved. An individual PostToolUse can belong to a parallel tool,
|
||||
// and a subagent's batch says nothing about the main session's permission. Only the current occupant can
|
||||
// settle a matching, older episode. Do not record an unrelated batch as an unknown hook that hides attention.
|
||||
const claudePermission = body.runtime === "claude-code" && body.hookEvent === "Notification" && body.subtype === "permission_prompt";
|
||||
if (body.hookEvent === "PostToolBatch" || claudePermission) {
|
||||
const oracle = c.get("seatActivityService" as never) as import("../domain/seat-activity-service.js").SeatActivityService | undefined;
|
||||
const registry = c.get("sessionRegistry" as never) as SessionRegistry | undefined;
|
||||
const sessionName = stringOrNull(body.sessionName);
|
||||
const seat = store.resolveSession({ sessionName, nodeId: stringOrNull(body.nodeId) });
|
||||
const hookAgentId = body.hookAgentId;
|
||||
const generation = stringOrNull(body.generation);
|
||||
const occurredAt = stringOrNull(body.occurredAt);
|
||||
let current: ReturnType<SessionRegistry["currentOccupantTenure"]> | undefined;
|
||||
try {
|
||||
current = seat ? registry?.currentOccupantTenure(seat.nodeId) : null;
|
||||
} catch {
|
||||
return c.json({ ok: false, code: "generation_resolver_error", error: "Occupant generation is unavailable." }, 503);
|
||||
}
|
||||
const session = seat && store.db.prepare("SELECT status FROM sessions WHERE id = ?")
|
||||
.get(seat.sessionId) as { status: string } | undefined;
|
||||
const attributable = body.runtime === "claude-code" && seat?.runtime === "claude-code"
|
||||
&& (!sessionName || sessionName === seat.sessionName) && session?.status === "running"
|
||||
&& generation !== null && current?.generationUuid === generation
|
||||
&& (hookAgentId === null || (typeof hookAgentId === "string" && hookAgentId.trim().length > 0))
|
||||
&& occurredAt !== null && Number.isFinite(Date.parse(occurredAt));
|
||||
if (body.hookEvent === "PostToolBatch") {
|
||||
if (attributable && oracle && !oracle.hasRungInventory(seat!.nodeId, seat!.sessionName)) {
|
||||
// The completion may be this occupant's first hook to reach the daemon.
|
||||
oracle.declareRungInventory({ seatNodeId: seat!.nodeId, sessionName: seat!.sessionName }, runtimeRungInventory("claude-code"));
|
||||
}
|
||||
const settled = attributable && oracle
|
||||
? oracle.completeClaudeToolBatch(seat!.sessionName, hookAgentId as string | null, occurredAt!) : false;
|
||||
return c.json({ ok: true, permissionResolved: settled });
|
||||
}
|
||||
// Do not publish an already-completed request as fresh attention to the store's event consumers.
|
||||
if (attributable && oracle?.hasLaterClaudeToolBatch(seat!.sessionName, hookAgentId as string | null, occurredAt!)) {
|
||||
return c.json({ ok: true, permissionResolved: true });
|
||||
}
|
||||
}
|
||||
|
||||
const result = store.recordHookEvent({
|
||||
runtime: stringOrNull(body.runtime),
|
||||
sessionName: stringOrNull(body.sessionName),
|
||||
@@ -460,6 +504,8 @@ activityRoutes.post("/hooks", async (c) => {
|
||||
// (sound per-path absence; never false-fresh).
|
||||
generation: stringOrNull(body.generation),
|
||||
turnId: stringOrNull(body.turnId),
|
||||
...(body.hookAgentId === null || (typeof body.hookAgentId === "string" && body.hookAgentId.trim().length > 0)
|
||||
? { hookAgentId: body.hookAgentId } : {}),
|
||||
sourceSequence: typeof body.sequence === "number" && Number.isSafeInteger(body.sequence) && body.sequence > 0 ? body.sequence : null,
|
||||
livenessSeconds: typeof body.livenessSeconds === "number" && Number.isSafeInteger(body.livenessSeconds)
|
||||
&& body.livenessSeconds > 0 && body.livenessSeconds <= 3600 ? body.livenessSeconds : null,
|
||||
|
||||
@@ -171,6 +171,8 @@ function normalizeExpansionPodFragment(raw: Record<string, unknown>): ExpansionP
|
||||
: "codex_permissions" in m ? { codexPermissions: m["codex_permissions"] } : {}),
|
||||
cwd: typeof m["cwd"] === "string" ? m["cwd"] : undefined,
|
||||
model: typeof m["model"] === "string" ? m["model"] : undefined,
|
||||
// Raw presence: the canonical RigSpec schema trims a text effort and ignores any other value with an advisory.
|
||||
...("effort" in m ? { effort: m["effort"] } : {}),
|
||||
// R2 (4ac243c3): PRESERVE raw presence — a present-invalid value (null, number,
|
||||
// object, …) must reach the canonical RigSpec validator as-is and reject there;
|
||||
// only a truly ABSENT key stays absent. No route-local validation, no coercion.
|
||||
|
||||
@@ -105,7 +105,8 @@ sessionsRoutes.get("/", (c) => {
|
||||
nodesRoutes.get("/", observeInventoryHandler, async (c) => {
|
||||
const rigId = c.req.param("rigId")!;
|
||||
const deps = getDeps(c);
|
||||
const rig = deps.rigRepo.getRig(rigId);
|
||||
// Only existence is needed here; the inventory below builds the response.
|
||||
const rig = deps.rigRepo.db.prepare("SELECT 1 FROM rigs WHERE id = ?").get(rigId);
|
||||
if (!rig) return c.json({ error: `Rig "${rigId}" not found. List rigs with: rig ps` }, 404);
|
||||
|
||||
const refresh = c.req.query("refresh") === "true";
|
||||
|
||||
@@ -578,10 +578,10 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
// hook/pane resolution as status. No new captures, timer or delivery admission path.
|
||||
const retryPromptWakes = (sessionName: string) => {
|
||||
const runtime = sessionRegistry.findResumeContextByName(sessionName)?.runtime;
|
||||
const structural = seatStructuralActivityService.getStructuralActivity(sessionName);
|
||||
const state = hookOrPaneActivity(agentActivityStore.getLatestForNode({ sessionName,
|
||||
order: runtime === "claude-code" ? "source" : "arrival" }),
|
||||
seatStructuralActivityService.getStructuralActivity(sessionName), false, runtime)?.activity.state;
|
||||
void queueRepoInstance.retryPromptWakesOnClear(sessionName, state)
|
||||
order: runtime === "claude-code" ? "source" : "arrival" }), structural, false, runtime)?.activity.state;
|
||||
void queueRepoInstance.retryPromptWakesOnClear(sessionName, state, structural?.state === "attention")
|
||||
.catch(error => console.warn(`Prompt-clear wake recovery: ${error instanceof Error ? error.name : typeof error}`));
|
||||
};
|
||||
seatStructuralActivityService.attachActivityChanged(retryPromptWakes);
|
||||
@@ -655,6 +655,10 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
HOME: daemonHome,
|
||||
USER: process.env.USER,
|
||||
LOGNAME: process.env.LOGNAME,
|
||||
// The SSH agent socket, so git over SSH works in a seat (#1159). The path isn't key material, but the socket lets
|
||||
// the seat's coding agent use the keys loaded in ssh-agent. An empty value is left out like an unset one, so a
|
||||
// socket only the seat's terminal has never takes its place.
|
||||
SSH_AUTH_SOCK: process.env.SSH_AUTH_SOCK || undefined,
|
||||
CODEX_HOME: codexHome,
|
||||
};
|
||||
// OPR.0.4.6.02 S1 — ONE shared tmux option-defaults applier, injected into
|
||||
|
||||
@@ -11,18 +11,22 @@
|
||||
// BOTH must pass — that's what proves the two primitives are computed
|
||||
// independently. Either alone is symmetry-breakable.
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createFullTestDb } from "./helpers/test-app.js";
|
||||
import {
|
||||
attachTerminalActivityAndWork,
|
||||
attachAgentActivity,
|
||||
getNodeInventory,
|
||||
readAssignedWorkBySession,
|
||||
} from "../src/domain/node-inventory.js";
|
||||
import {
|
||||
countAssignedWorkForSession,
|
||||
countPendingWorkForSession,
|
||||
PsProjectionService,
|
||||
} from "../src/domain/ps-projection.js";
|
||||
import { AgentActivityStore } from "../src/domain/agent-activity-store.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
|
||||
function seedRig(db: Database.Database, name: string): string {
|
||||
const id = `rig-${name}`;
|
||||
@@ -80,6 +84,72 @@ describe("attachTerminalActivityAndWork — slice 15 per-node enrichment", () =>
|
||||
beforeEach(() => { db = createFullTestDb(); });
|
||||
afterEach(() => { db.close(); });
|
||||
|
||||
function emitHook(rigId: string, nodeId: string, sessionName: string, state: "needs_input" | "idle") {
|
||||
const eventAt = new Date().toISOString();
|
||||
new EventBus(db).emit({
|
||||
type: "agent.activity", rigId, nodeId, sessionName, runtime: "codex",
|
||||
activity: { state, reason: state === "needs_input" ? "permission_prompt" : "stop",
|
||||
evidenceSource: "runtime_hook", sampledAt: eventAt, eventAt, fallback: false, stale: false },
|
||||
} as never);
|
||||
}
|
||||
|
||||
it.each(["codex", "claude-code"])("keeps %s hooks on their inventory node when another rig reuses the session name", async runtime => {
|
||||
const rig = seedRig(db, "a-owner");
|
||||
const otherRig = seedRig(db, "z-reused");
|
||||
const node = seedNode(db, rig, "dev");
|
||||
const other = seedNode(db, otherRig, "dev");
|
||||
db.prepare("UPDATE nodes SET runtime = ?").run(runtime);
|
||||
seedSession(db, node, "reused-session");
|
||||
seedSession(db, other, "reused-session"); // Later session ID wins the old global name lookup.
|
||||
emitHook(rig, node, "reused-session", "needs_input");
|
||||
emitHook(otherRig, other, "reused-session", "idle");
|
||||
const store = new AgentActivityStore({ db, eventBus: new EventBus(db) });
|
||||
const base = getNodeInventory(db, rig);
|
||||
const [attached] = await attachAgentActivity(base, { tmuxAdapter: {} as never, activityStore: store });
|
||||
expect(attached!.agentActivity?.state).toBe("needs_input");
|
||||
expect(attachTerminalActivityAndWork(base, { db, agentActivity: store })[0]!.needsAttention).toBe(true);
|
||||
const totals = new PsProjectionService({ db, agentActivity: store }).getEntries();
|
||||
expect(totals.find(e => e.rigId === rig)!.attentionCount).toBe(1);
|
||||
expect(totals.find(e => e.rigId === otherRig)!.attentionCount).toBe(0);
|
||||
});
|
||||
|
||||
it.each(["codex", "claude-code"])("does not revive %s hooks from an old session or a node without a session", async runtime => {
|
||||
const rig = seedRig(db, "absence");
|
||||
const current = seedNode(db, rig, "current");
|
||||
const orphan = seedNode(db, rig, "no-session");
|
||||
db.prepare("UPDATE nodes SET runtime = ?").run(runtime);
|
||||
seedSession(db, current, "current-session");
|
||||
emitHook(rig, current, "old-session", "needs_input");
|
||||
emitHook(rig, orphan, "orphan-session", "needs_input");
|
||||
const store = new AgentActivityStore({ db, eventBus: new EventBus(db) });
|
||||
const base = getNodeInventory(db, rig);
|
||||
const attached = await attachAgentActivity(base, { tmuxAdapter: {} as never, activityStore: store });
|
||||
expect(attached.map(e => e.agentActivity?.state)).toEqual(["unknown", "unknown"]);
|
||||
expect(attachTerminalActivityAndWork(base, { db, agentActivity: store }).map(e => e.needsAttention))
|
||||
.toEqual([false, false]);
|
||||
expect(new PsProjectionService({ db, agentActivity: store }).getEntries()[0]!.attentionCount).toBe(0);
|
||||
});
|
||||
|
||||
it("reuses attached unknown activity without another read, but keeps the standalone hook fallback", async () => {
|
||||
const rig = seedRig(db, "reuse");
|
||||
const node = seedNode(db, rig, "dev");
|
||||
seedSession(db, node, "dev@reuse");
|
||||
const store = new AgentActivityStore({ db, eventBus: new EventBus(db) });
|
||||
const read = vi.spyOn(store, "getLatestForNode");
|
||||
const base = getNodeInventory(db, rig);
|
||||
const attached = await attachAgentActivity(base, { tmuxAdapter: {} as never, activityStore: store });
|
||||
expect(attached[0]!.agentActivity?.state).toBe("unknown");
|
||||
expect(read).toHaveBeenCalledTimes(1);
|
||||
emitHook(rig, node, "dev@reuse", "needs_input");
|
||||
expect(attachTerminalActivityAndWork(attached, { db, agentActivity: store })[0]!.needsAttention).toBe(false);
|
||||
expect(read).toHaveBeenCalledTimes(1);
|
||||
for (const agentActivity of [undefined, null]) {
|
||||
const standalone = [{ ...base[0]!, agentActivity }];
|
||||
expect(attachTerminalActivityAndWork(standalone, { db, agentActivity: store })[0]!.needsAttention).toBe(true);
|
||||
}
|
||||
expect(read).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("HG-3 DIRECTION A — node producing output with NOTHING queued: terminalActive=true, hasAssignedWork=false", () => {
|
||||
const rig = seedRig(db, "active-only");
|
||||
const n = seedNode(db, rig, "dev");
|
||||
|
||||
@@ -135,7 +135,8 @@ async function sendAfterHook(mode: Mode, consumer: "send" | "wake" | "handoff" =
|
||||
{ pid: 100, ppid: 1, pgid: 100, tpgid: 101, executableName: "bash", command: "-bash", startedAt },
|
||||
{ pid: 101, ppid: 100, pgid: 101, tpgid: 101, executableName: "sh", command: "/bin/sh /fixture/launch", startedAt },
|
||||
{ pid: 102, ppid: 101, pgid: 101, tpgid: 101, executableName: foreign ? "codex" : "claude",
|
||||
command: foreign ? "codex resume other" : `/opt/claude --session-id ${original}`, startedAt },
|
||||
command: foreign ? "codex resume other" : `/opt/claude --session-id ${original}`,
|
||||
...(foreign ? { argv: ["codex", "resume", "other"] } : {}), startedAt },
|
||||
];
|
||||
if (mode === "shim") rows.push({ ...rows[2]!, pid: 103, ppid: 102,
|
||||
command: `/child/claude --session-id ${original} --settings /fixture/settings.json` });
|
||||
|
||||
@@ -238,7 +238,8 @@ describe("fresh in-place readiness requires current launch evidence", () => {
|
||||
const startedAt = "Mon Oct 19 21:30:00 2026";
|
||||
f.listProcesses.mockReturnValue([
|
||||
{ pid: 100, ppid: 1, pgid: 100, tpgid: 101, executableName: "bash", command: "-bash", startedAt },
|
||||
{ pid: 101, ppid: 100, pgid: 101, tpgid: 101, executableName: "codex", command: "codex resume other-token", startedAt },
|
||||
{ pid: 101, ppid: 100, pgid: 101, tpgid: 101, executableName: "codex", command: "codex resume other-token",
|
||||
argv: ["codex", "resume", "other-token"], startedAt },
|
||||
]);
|
||||
await launch(f.adapter);
|
||||
expect(await f.adapter.checkReady(binding)).toMatchObject({ ready: false, code: "claude_launch_identity_conflict" });
|
||||
|
||||
@@ -13,11 +13,11 @@ const open: Database.Database[] = [];
|
||||
afterEach(() => { for (const db of open.splice(0)) db.close(); });
|
||||
|
||||
// `file` is the exact bytes of `claude` on the daemon's PATH; it must be a script, which runs on every platform.
|
||||
async function prepared(file: string, link?: boolean) {
|
||||
async function prepared(file: string, link?: boolean, recordedExecutable?: boolean) {
|
||||
const root = realpathSync(mkdtempSync(path.join(tmpdir(), "claude-script-launch-")));
|
||||
const cwd = path.join(root, "seat"); const bin = path.join(root, "bin");
|
||||
mkdirSync(cwd); mkdirSync(bin); mkdirSync(path.join(root, "home"));
|
||||
const target = path.join(link ? root : bin, link ? "real-claude" : "claude");
|
||||
const target = path.join(link || recordedExecutable ? root : bin, link || recordedExecutable ? "real-claude" : "claude");
|
||||
writeFileSync(target, file); chmodSync(target, 0o755);
|
||||
if (link) symlinkSync(target, path.join(bin, "claude"));
|
||||
const db = new Database(":memory:"); open.push(db);
|
||||
@@ -27,7 +27,7 @@ async function prepared(file: string, link?: boolean) {
|
||||
db.prepare("INSERT INTO nodes VALUES ('node','claude-code',?)").run(cwd);
|
||||
db.exec("INSERT INTO bindings VALUES ('binding','node','seat','%1'); INSERT INTO occupant_tenures VALUES ('node','generation-1',1)");
|
||||
const managed = new ClaudeManagedLaunch(db, { PATH: `${bin}:/usr/bin:/bin`, HOME: path.join(root, "home") }, {});
|
||||
return { managed: await managed.prepare({ nodeId: "node" }, "auto"), target };
|
||||
return { managed: await managed.prepare({ nodeId: "node", ...(recordedExecutable ? { executable: target } : {}) }, "auto"), target, resolver: managed };
|
||||
}
|
||||
const printsHelp = `printf '%s\\n' '${HELP}'\n`;
|
||||
|
||||
@@ -43,6 +43,15 @@ it("reports it through a symlink, whose realpath is the frozen executable", asyn
|
||||
expect(managed.executableIsScript).toBe(true);
|
||||
});
|
||||
|
||||
it("continues with the recorded executable outside the daemon PATH, freezing the same file", async () => {
|
||||
const { managed, target, resolver } = await prepared(`#!/bin/sh\n${printsHelp}`, false, true);
|
||||
expect(managed.executable).toBe(target);
|
||||
expect(managed.command(["--resume", "same-conversation"])).toContain(target);
|
||||
await expect(resolver.prepare({ nodeId: "node" }, "auto")).rejects.toThrow("unavailable on the intended PATH");
|
||||
writeFileSync(target, `#!/bin/sh\n${printsHelp}\n# changed`);
|
||||
expect(() => managed.assertCurrent()).toThrow("context changed");
|
||||
});
|
||||
|
||||
// Not through prepare(): it runs `--help` on the file first, and a file with no #! at byte 0 can't be exec'd on every
|
||||
// platform (Linux's execvp hands it to /bin/sh, macOS returns ENOEXEC), so such a fixture would pass only on some hosts.
|
||||
// prepare() reports exactly this function's answer for the file it froze.
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { Hono } from "hono";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { activityRoutes } from "../src/routes/activity.js";
|
||||
import { AgentActivityStore } from "../src/domain/agent-activity-store.js";
|
||||
import { SeatActivityService } from "../src/domain/seat-activity-service.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
import { attachAgentActivity, attachTerminalActivityAndWork, getNodeInventory } from "../src/domain/node-inventory.js";
|
||||
import { WAKE_INTENT_PREFIX } from "../src/domain/outbox-handler.js";
|
||||
import { createDaemon } from "../src/startup.js";
|
||||
import { createFullTestDb } from "./helpers/test-app.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { buildOpenRigPayload } = require("../assets/plugins/openrig-core/hooks/scripts/activity-relay.cjs") as {
|
||||
buildOpenRigPayload: (native: Record<string, unknown>, env: Record<string, string>, now: () => Date) => Record<string, unknown>;
|
||||
};
|
||||
const databases: ReturnType<typeof createFullTestDb>[] = [];
|
||||
afterEach(() => { for (const db of databases.splice(0)) db.close(); });
|
||||
|
||||
function fixture(visible: boolean | null = false) {
|
||||
const db = createFullTestDb();
|
||||
databases.push(db);
|
||||
const repo = new RigRepository(db);
|
||||
const rig = repo.createRig("batch-permission");
|
||||
const node = repo.addNode(rig.id, "worker", { runtime: "claude-code" });
|
||||
const registry = new SessionRegistry(db);
|
||||
const sessionName = "worker@batch-permission";
|
||||
const session = registry.registerSession(node.id, sessionName);
|
||||
registry.updateStatus(session.id, "running");
|
||||
registry.updateBinding(node.id, { attachmentType: "tmux", tmuxSession: sessionName });
|
||||
const generation = registry.currentOccupantTenure(node.id)!.generationUuid;
|
||||
let nowMs = Date.parse("2026-10-28T00:00:00.000Z");
|
||||
const now = () => new Date(nowMs);
|
||||
const store = new AgentActivityStore({ db, eventBus: new EventBus(db), now,
|
||||
resolveOccupantGeneration: id => registry.currentOccupantTenure(id)?.generationUuid ?? null,
|
||||
isRegisteredOccupantGeneration: (id, value) => registry.isOccupantGenerationRegistered(id, value),
|
||||
});
|
||||
const reader = vi.fn(async (): Promise<boolean | null> => visible);
|
||||
const changed = vi.fn((name: string, confirmed: boolean, runtime: string) => {
|
||||
const latest = store.getLatestForNode({ sessionName: name });
|
||||
store.recordHookEvent({ sessionName: name, runtime, generation: latest?.generation,
|
||||
hookEvent: confirmed ? "PermissionPromptConfirmed" : "PermissionPromptCleared" });
|
||||
});
|
||||
const service = new SeatActivityService({ tmux: { readPaneLastActivity: async () => null },
|
||||
defaultWindowSeconds: 3, now, permissionPromptReader: reader, permissionPromptChanged: changed });
|
||||
const app = new Hono();
|
||||
app.use("*", async (context, next) => {
|
||||
context.set("agentActivityStore" as never, store as never);
|
||||
context.set("activityHookToken" as never, "batch-test-token" as never);
|
||||
context.set("seatActivityService" as never, service as never);
|
||||
context.set("sessionRegistry" as never, registry as never);
|
||||
await next();
|
||||
});
|
||||
app.route("/activity", activityRoutes);
|
||||
const hook = async (event: string, native: Record<string, unknown> = {}, wire: Record<string, unknown> = {}) => {
|
||||
nowMs += 10;
|
||||
const payload = buildOpenRigPayload({ hook_event_name: event, session_id: "native-session", permission_mode: "auto",
|
||||
...(event === "Notification" ? { notification_type: "permission_prompt" } : {}), ...native }, {
|
||||
OPENRIG_SESSION_NAME: sessionName, OPENRIG_NODE_ID: node.id,
|
||||
OPENRIG_RUNTIME: "claude-code", OPENRIG_OCCUPANT_GENERATION: generation,
|
||||
}, now);
|
||||
const response = await app.request("/activity/hooks", { method: "POST",
|
||||
headers: { "content-type": "application/json", authorization: "Bearer batch-test-token" },
|
||||
body: JSON.stringify({ ...payload, ...wire }),
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
return payload;
|
||||
};
|
||||
return { app, db, node, registry, session, sessionName, generation, store, service, reader, changed, hook, now,
|
||||
advance: (delta: number) => { nowMs += delta; },
|
||||
count: () => service.getSeatState(node.id)!.needsInput.count,
|
||||
latest: () => {
|
||||
// sampledAt is the read's clock; the retained source event and its meaning must stay unchanged.
|
||||
const { sampledAt: _, ...activity } = store.getLatestForNode({ nodeId: node.id, sessionName })!;
|
||||
return activity;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("Claude tool-batch completion resolves its permission notification", () => {
|
||||
it.each([false, null])("clears an unseen permission after the batch finishes, with capture=%s", async visible => {
|
||||
const subject = fixture(visible);
|
||||
await subject.hook("UserPromptSubmit");
|
||||
await subject.hook("Notification");
|
||||
await subject.service.pollSeat(subject.sessionName);
|
||||
expect(subject.count()).toBe(1);
|
||||
await subject.hook("PostToolBatch", { tool_calls: [{ tool_name: "Bash", tool_use_id: "tool-1", tool_response: "finished" }] });
|
||||
expect(subject.count()).toBe(0);
|
||||
expect(subject.service.getSeatState(subject.node.id)).toMatchObject({ activity: "working",
|
||||
needsInputEvidence: { rung: "lifecycle-hooks" } });
|
||||
expect(subject.latest()).toMatchObject({ rawEvent: "PermissionPromptCleared", reason: "permission_prompt_cleared" });
|
||||
const calls = subject.changed.mock.calls.length;
|
||||
await subject.hook("PostToolBatch");
|
||||
expect(subject.changed).toHaveBeenCalledTimes(calls);
|
||||
});
|
||||
|
||||
it.each([undefined, "child-1"])("carries native actor %s without sending tool inputs or results", async agentId => {
|
||||
const subject = fixture();
|
||||
const payload = await subject.hook("Notification", { agent_id: agentId, tool_input: { command: "private" } });
|
||||
expect(payload).toHaveProperty("hookAgentId", agentId ?? null);
|
||||
const recorded = subject.db.prepare("SELECT payload FROM events WHERE type = 'agent.activity' AND json_extract(payload, '$.activity.rawEvent') = 'Notification' ORDER BY seq DESC LIMIT 1")
|
||||
.get() as { payload: string };
|
||||
expect(JSON.parse(recorded.payload).activity).toHaveProperty("hookAgentId", agentId ?? null);
|
||||
const batch = await subject.hook("PostToolBatch", { agent_id: agentId,
|
||||
tool_calls: [{ tool_name: "Bash", tool_use_id: "tool-1", tool_response: "private result" }] });
|
||||
expect(batch).toHaveProperty("hookAgentId", agentId ?? null);
|
||||
expect(batch).not.toHaveProperty("tool_calls");
|
||||
expect(payload).not.toHaveProperty("tool_input");
|
||||
expect(subject.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("does not treat one parallel tool finishing as the whole batch finishing", async () => {
|
||||
const subject = fixture();
|
||||
await subject.hook("Notification");
|
||||
await subject.hook("PostToolUse", { tool_use_id: "other-tool" });
|
||||
expect(subject.count()).toBe(1);
|
||||
await subject.hook("PostToolBatch");
|
||||
expect(subject.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("settles only the completed actor when main and subagent permissions overlap", async () => {
|
||||
const subject = fixture();
|
||||
await subject.hook("Notification");
|
||||
await subject.hook("Notification", { agent_id: "child-1" });
|
||||
expect(subject.count()).toBe(2);
|
||||
await subject.hook("PostToolBatch", { agent_id: "child-1" });
|
||||
expect(subject.count()).toBe(1);
|
||||
await subject.hook("PostToolBatch");
|
||||
expect(subject.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("does not attribute the same visible dialog to two outstanding actors", async () => {
|
||||
const subject = fixture();
|
||||
await subject.hook("Notification");
|
||||
await subject.hook("Notification", { agent_id: "child-1" });
|
||||
subject.reader.mockResolvedValue(true);
|
||||
subject.reader.mockClear();
|
||||
subject.advance(1_000);
|
||||
await subject.service.pollSeat(subject.sessionName);
|
||||
expect(subject.reader).toHaveBeenCalledOnce();
|
||||
subject.reader.mockResolvedValue(false);
|
||||
subject.advance(1_000);
|
||||
await subject.service.pollSeat(subject.sessionName);
|
||||
expect(subject.count()).toBe(2);
|
||||
await subject.hook("PostToolBatch");
|
||||
expect(subject.count()).toBe(1);
|
||||
await subject.hook("PostToolBatch", { agent_id: "child-1" });
|
||||
expect(subject.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("uses the current node identity when a relay only carries nodeId", async () => {
|
||||
const subject = fixture();
|
||||
await subject.hook("Notification");
|
||||
await subject.hook("PostToolBatch", {}, { sessionName: null });
|
||||
expect(subject.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("does not let the completed batch mask later self-reported attention", async () => {
|
||||
const subject = fixture();
|
||||
await subject.hook("Notification");
|
||||
await subject.hook("PostToolBatch");
|
||||
subject.service.reportEvidence({ seatNodeId: subject.node.id, sessionName: subject.sessionName,
|
||||
rung: "self-report", sourceId: "claude:pid-json", seq: 1, observedAt: subject.now().toISOString(),
|
||||
needsInput: { count: 1, reason: "dialog owns input" } });
|
||||
expect(subject.count()).toBe(1);
|
||||
});
|
||||
|
||||
it.each(["child-1", "child-2"])("ignores actor %s finishing while the main request is pending", async agentId => {
|
||||
const subject = fixture();
|
||||
await subject.hook("Notification");
|
||||
const before = subject.latest();
|
||||
await subject.hook("PostToolBatch", { agent_id: agentId });
|
||||
expect(subject.count()).toBe(1);
|
||||
expect(subject.latest()).toEqual(before);
|
||||
});
|
||||
|
||||
it.each([-1, 0])("does not resolve a notification with a batch timestamp offset of %s ms", async offset => {
|
||||
const subject = fixture();
|
||||
await subject.hook("Notification");
|
||||
const before = subject.latest();
|
||||
const occurredAt = new Date(subject.now().getTime() + offset).toISOString();
|
||||
await subject.hook("PostToolBatch", {}, { occurredAt });
|
||||
expect(subject.count()).toBe(1);
|
||||
expect(subject.latest()).toEqual(before);
|
||||
});
|
||||
|
||||
it("does not let a delayed older notification make a newer request eligible for an old batch", async () => {
|
||||
const subject = fixture();
|
||||
const at = (offset: number) => new Date(Date.parse("2026-10-28T00:00:00.000Z") + offset).toISOString();
|
||||
await subject.hook("Notification", {}, { occurredAt: at(30) });
|
||||
await subject.hook("Notification", {}, { occurredAt: at(10) });
|
||||
await subject.hook("PostToolBatch", {}, { occurredAt: at(20) });
|
||||
expect(subject.count()).toBe(1);
|
||||
expect(subject.latest().state).toBe("needs_input");
|
||||
await subject.hook("PostToolBatch", {}, { occurredAt: at(40) });
|
||||
expect(subject.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("does not guess an actor for notifications from an older relay", async () => {
|
||||
const subject = fixture();
|
||||
await subject.hook("Notification", {}, { hookAgentId: undefined });
|
||||
const before = subject.latest();
|
||||
await subject.hook("PostToolBatch");
|
||||
expect(subject.count()).toBe(1);
|
||||
expect(subject.latest()).toEqual(before);
|
||||
});
|
||||
|
||||
it.each([undefined, "child-1"])("does not revive actor %s after its completed notification arrives again", async agentId => {
|
||||
const subject = fixture();
|
||||
await subject.hook("UserPromptSubmit");
|
||||
const notification = await subject.hook("Notification", { agent_id: agentId });
|
||||
const batch = await subject.hook("PostToolBatch", { agent_id: agentId });
|
||||
const before = subject.latest();
|
||||
for (const occurredAt of [notification.occurredAt,
|
||||
new Date(Date.parse(String(batch.occurredAt)) - 1).toISOString()]) {
|
||||
await subject.hook("Notification", { agent_id: agentId }, { occurredAt });
|
||||
expect(subject.count()).toBe(0);
|
||||
expect(subject.latest()).toEqual(before);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([undefined, "child-1"])("remembers actor %s completing before its first notification arrives", async agentId => {
|
||||
const subject = fixture();
|
||||
const batch = await subject.hook("PostToolBatch", { agent_id: agentId });
|
||||
await subject.hook("Notification", { agent_id: agentId },
|
||||
{ occurredAt: new Date(Date.parse(String(batch.occurredAt)) - 1).toISOString() });
|
||||
expect(subject.count()).toBe(0);
|
||||
expect(subject.store.getLatestForNode({ sessionName: subject.sessionName })?.state).not.toBe("needs_input");
|
||||
expect(subject.changed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([0, 1])("keeps a new notification whose timestamp is %s ms after the prior batch", async offset => {
|
||||
const subject = fixture();
|
||||
await subject.hook("Notification");
|
||||
const batch = await subject.hook("PostToolBatch");
|
||||
await subject.hook("Notification", {},
|
||||
{ occurredAt: new Date(Date.parse(String(batch.occurredAt)) + offset).toISOString() });
|
||||
expect(subject.count()).toBe(1);
|
||||
expect(subject.latest().state).toBe("needs_input");
|
||||
});
|
||||
|
||||
it("retains the latest completion per actor even when older batches arrive later", async () => {
|
||||
const subject = fixture();
|
||||
await subject.hook("UserPromptSubmit");
|
||||
const completedAt = subject.now().getTime() + 100;
|
||||
const at = (offset: number) => new Date(completedAt + offset).toISOString();
|
||||
await subject.hook("PostToolBatch", { agent_id: "child-1" }, { occurredAt: at(0) });
|
||||
await subject.hook("PostToolBatch", { agent_id: "child-1" }, { occurredAt: at(-20) });
|
||||
await subject.hook("Notification", { agent_id: "child-1" }, { occurredAt: at(-10) });
|
||||
expect(subject.count()).toBe(0);
|
||||
await subject.hook("Notification", {}, { occurredAt: at(-10) });
|
||||
expect(subject.count()).toBe(1);
|
||||
expect(subject.latest().state).toBe("needs_input");
|
||||
});
|
||||
|
||||
it("keeps an unattributed legacy notification even after the main actor completed", async () => {
|
||||
const subject = fixture();
|
||||
const batch = await subject.hook("PostToolBatch");
|
||||
await subject.hook("Notification", {}, { hookAgentId: undefined,
|
||||
occurredAt: new Date(Date.parse(String(batch.occurredAt)) - 1).toISOString() });
|
||||
expect(subject.count()).toBe(1);
|
||||
expect(subject.latest().state).toBe("needs_input");
|
||||
});
|
||||
|
||||
it.each(["forget", "swap"] as const)("discards the prior occupant's completion on %s", async action => {
|
||||
const subject = fixture();
|
||||
const batch = await subject.hook("PostToolBatch");
|
||||
let generation = subject.generation;
|
||||
if (action === "forget") subject.service.forgetSeat(subject.sessionName);
|
||||
else {
|
||||
generation = subject.registry.mintOccupantTenure(subject.node.id, "initial").generationUuid;
|
||||
subject.service.declareOccupantSwap(subject.node.id, generation);
|
||||
}
|
||||
await subject.hook("Notification", {}, { generation,
|
||||
occurredAt: new Date(Date.parse(String(batch.occurredAt)) - 1).toISOString() });
|
||||
expect(subject.count()).toBe(1);
|
||||
expect(subject.latest().state).toBe("needs_input");
|
||||
});
|
||||
|
||||
it.each(["PostToolBatch", "Notification"])("returns a named 503 for %s when the occupant ledger cannot be read", async hookEvent => {
|
||||
const subject = fixture();
|
||||
await subject.hook("Notification");
|
||||
const before = subject.latest();
|
||||
const resolver = vi.spyOn(subject.registry, "currentOccupantTenure")
|
||||
.mockImplementation(() => { throw new Error("fixture occupant ledger unavailable"); });
|
||||
try {
|
||||
const response = await subject.app.request("/activity/hooks", { method: "POST",
|
||||
headers: { "content-type": "application/json", authorization: "Bearer batch-test-token" },
|
||||
body: JSON.stringify({ runtime: "claude-code", hookEvent, subtype: "permission_prompt",
|
||||
sessionName: subject.sessionName, nodeId: subject.node.id, generation: subject.generation,
|
||||
hookAgentId: null, occurredAt: new Date(subject.now().getTime() + 1).toISOString() }),
|
||||
});
|
||||
expect(response.status).toBe(503);
|
||||
expect(await response.json()).toMatchObject({ ok: false, code: "generation_resolver_error" });
|
||||
} finally {
|
||||
resolver.mockRestore();
|
||||
}
|
||||
expect(subject.count()).toBe(1);
|
||||
expect(subject.latest()).toEqual(before);
|
||||
await subject.hook(hookEvent);
|
||||
expect(subject.count()).toBe(hookEvent === "PostToolBatch" ? 0 : 1);
|
||||
});
|
||||
|
||||
it.each([{ generation: null }, { generation: "retired-generation" }, { hookAgentId: undefined },
|
||||
{ hookAgentId: 42 }, { occurredAt: "invalid" }, { sessionName: "old@batch-permission" }])(
|
||||
"preserves attention when batch attribution is unusable: %j", async wire => {
|
||||
const subject = fixture();
|
||||
await subject.hook("Notification");
|
||||
const before = subject.latest();
|
||||
await subject.hook("PostToolBatch", {}, wire);
|
||||
expect(subject.count()).toBe(1);
|
||||
expect(subject.latest()).toEqual(before);
|
||||
});
|
||||
|
||||
it("does not clear an elicitation that replaced the permission request", async () => {
|
||||
const subject = fixture();
|
||||
await subject.hook("Notification");
|
||||
await subject.hook("Notification", { notification_type: "elicitation_dialog" });
|
||||
const before = subject.latest();
|
||||
await subject.hook("PostToolBatch");
|
||||
expect(subject.count()).toBe(1);
|
||||
expect(subject.latest()).toEqual(before);
|
||||
});
|
||||
|
||||
it("does not revive a completed request from an in-flight pane capture", async () => {
|
||||
const subject = fixture();
|
||||
let finish!: (visible: boolean) => void;
|
||||
subject.reader.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; }));
|
||||
await subject.hook("Notification");
|
||||
expect(finish).toBeTypeOf("function");
|
||||
await subject.hook("PostToolBatch");
|
||||
expect(subject.count()).toBe(0);
|
||||
finish(true);
|
||||
await subject.service.pollSeat(subject.sessionName);
|
||||
expect(subject.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("does not apply a batch to a retired session", async () => {
|
||||
const subject = fixture();
|
||||
await subject.hook("Notification");
|
||||
subject.registry.updateStatus(subject.session.id, "stopped");
|
||||
const before = subject.latest();
|
||||
await subject.hook("PostToolBatch");
|
||||
expect(subject.count()).toBe(1);
|
||||
expect(subject.latest()).toEqual(before);
|
||||
});
|
||||
|
||||
it("clears displayed attention and retries the existing refused handoff once through daemon wiring", async () => {
|
||||
const originalNoKernel = process.env.OPENRIG_NO_KERNEL;
|
||||
process.env.OPENRIG_NO_KERNEL = "1";
|
||||
const fetch = vi.spyOn(globalThis, "fetch").mockImplementation(async input => {
|
||||
expect(String(input)).toBe("https://github.com/mvschwarz/openrig-plugins/releases/latest/download/openrig-core.tar.gz");
|
||||
return new Response(null, { status: 404 });
|
||||
});
|
||||
const captures = JSON.parse(readFileSync(new URL("./fixtures/claude-permission-2.1.300.json", import.meta.url), "utf8"));
|
||||
let daemon: Awaited<ReturnType<typeof createDaemon>> | undefined;
|
||||
try {
|
||||
daemon = await createDaemon({ dbPath: ":memory:",
|
||||
tmuxExec: async command => command.includes("capture-pane") ? captures.approvedBash : "", cmuxExec: async () => "" });
|
||||
const { rigRepo, sessionRegistry, queueRepo, outboxHandler, agentActivityStore, seatActivityService,
|
||||
seatStructuralActivityService, tmuxAdapter, activityHookToken } = daemon.deps;
|
||||
const rig = rigRepo.createRig("batch-wake");
|
||||
const node = rigRepo.addNode(rig.id, "worker", { runtime: "claude-code" });
|
||||
const sessionName = "worker@batch-wake";
|
||||
const session = sessionRegistry.registerSession(node.id, sessionName);
|
||||
sessionRegistry.updateStatus(session.id, "running");
|
||||
sessionRegistry.updateBinding(node.id, { attachmentType: "tmux", tmuxSession: sessionName });
|
||||
const generation = sessionRegistry.currentOccupantTenure(node.id)!.generationUuid;
|
||||
const hook = async (event: string, native: Record<string, unknown> = {}) => {
|
||||
const payload = buildOpenRigPayload({ hook_event_name: event,
|
||||
...(event === "Notification" ? { notification_type: "permission_prompt" } : {}), ...native }, {
|
||||
OPENRIG_SESSION_NAME: sessionName, OPENRIG_NODE_ID: node.id,
|
||||
OPENRIG_RUNTIME: "claude-code", OPENRIG_OCCUPANT_GENERATION: generation,
|
||||
}, () => new Date(event === "PostToolBatch" ? Date.now() : Date.now() - 1_000));
|
||||
const response = await daemon!.app.request("/api/activity/hooks", { method: "POST",
|
||||
headers: { "content-type": "application/json", authorization: `Bearer ${activityHookToken}` },
|
||||
body: JSON.stringify(payload) });
|
||||
expect(response.status).toBe(200);
|
||||
};
|
||||
const inventory = async () => attachTerminalActivityAndWork(await attachAgentActivity(getNodeInventory(daemon!.db, rig.id),
|
||||
{ tmuxAdapter, activityStore: agentActivityStore, structuralActivity: seatStructuralActivityService }),
|
||||
{ db: daemon!.db, seatActivity: seatActivityService, agentActivity: agentActivityStore })[0]!;
|
||||
await seatStructuralActivityService!.pollSeat(sessionName, null, "claude-code");
|
||||
await hook("UserPromptSubmit");
|
||||
await hook("Notification");
|
||||
await seatActivityService!.pollSeat(sessionName);
|
||||
expect(await inventory()).toMatchObject({ needsAttention: true, agentActivity: { state: "needs_input" },
|
||||
activityState: { display: "needs-input", needsInput: { count: 1 } } });
|
||||
|
||||
let refused = true;
|
||||
const send = vi.fn(async () => refused
|
||||
? { ok: false, sent: false, reason: "target_needs_input", error: `Refused: '${sessionName}' is at an interactive prompt (permission_prompt). No text was sent.` }
|
||||
: { ok: true, verified: true });
|
||||
queueRepo!.attachTransport({ send });
|
||||
const source = await queueRepo!.create({ sourceSession: "planner@batch-wake", destinationSession: "builder@batch-wake",
|
||||
body: "build", nudge: false });
|
||||
const { created } = await queueRepo!.handoff({ qitemId: source.qitemId, fromSession: "builder@batch-wake",
|
||||
toSession: sessionName, body: "review the finished work" });
|
||||
const wakeId = WAKE_INTENT_PREFIX + created.qitemId;
|
||||
await vi.waitFor(() => expect(outboxHandler!.getById(wakeId)?.deliveryState).toBe("failed"));
|
||||
const original = outboxHandler!.getById(wakeId)!;
|
||||
refused = false;
|
||||
await hook("PostToolBatch", { agent_id: "other-actor" });
|
||||
expect(send).toHaveBeenCalledOnce();
|
||||
expect((await inventory()).needsAttention).toBe(true);
|
||||
await hook("PostToolBatch");
|
||||
await vi.waitFor(() => expect(outboxHandler!.getById(wakeId)?.deliveryState).toBe("delivered"));
|
||||
expect(send).toHaveBeenCalledTimes(2);
|
||||
expect(send.mock.calls[1][1]).toContain(original.body);
|
||||
expect(agentActivityStore!.getLatestForNode({ sessionName })).toMatchObject({ rawEvent: "PermissionPromptCleared" });
|
||||
expect(await inventory()).toMatchObject({ needsAttention: false, agentActivity: { state: "running" },
|
||||
activityState: { display: "working", needsInput: { count: 0 } } });
|
||||
await hook("PostToolBatch");
|
||||
await seatStructuralActivityService!.pollSeat(sessionName, null, "claude-code");
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
expect(send).toHaveBeenCalledTimes(2);
|
||||
expect(queueRepo!.list({ limit: 100 })).toHaveLength(2);
|
||||
} finally {
|
||||
daemon?.eventLoopMonitor.stop();
|
||||
daemon?.db.close();
|
||||
fetch.mockRestore();
|
||||
if (originalNoKernel === undefined) delete process.env.OPENRIG_NO_KERNEL;
|
||||
else process.env.OPENRIG_NO_KERNEL = originalNoKernel;
|
||||
}
|
||||
}, 10_000);
|
||||
});
|
||||
@@ -33,6 +33,15 @@ function mockTmux(overrides?: {
|
||||
}
|
||||
|
||||
describe("ClaudeResumeAdapter", () => {
|
||||
it("the legacy continuation uses the retained executable and original conversation", async () => {
|
||||
const tmux = mockTmux();
|
||||
const adapter = new ClaudeResumeAdapter(tmux, { maxWaitMs: 1, sleep: async () => {} });
|
||||
await adapter.resume("seat@rig", "claude_id", "original", "/repo", undefined, undefined, undefined, "node",
|
||||
undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, "/native tools/claude");
|
||||
expect(tmux.sendText).toHaveBeenCalledWith("seat@rig", expect.stringContaining("exec '/native tools/claude'"));
|
||||
expect(tmux.sendText).toHaveBeenCalledWith("seat@rig", expect.stringContaining("--resume 'original'"));
|
||||
});
|
||||
|
||||
describe("canResume", () => {
|
||||
it("claude_name + token -> true", () => {
|
||||
const adapter = new ClaudeResumeAdapter(mockTmux());
|
||||
|
||||
@@ -76,6 +76,13 @@ describe("launchHarness — per-agent --model reaches the claude launch (51-07 A
|
||||
return (calls[calls.length - 1]?.[1] as string) ?? "";
|
||||
};
|
||||
|
||||
it("uses the retained executable for a same-conversation continuation", async () => {
|
||||
const tmux = mockTmux();
|
||||
await adapterWith(tmux).launchHarness({ ...makeBinding(), claudeExecutable: "/native tools/claude" }, { name: "seat", resumeToken: "original" });
|
||||
expect(lastCmd(tmux)).toContain("exec '/native tools/claude'");
|
||||
expect(lastCmd(tmux)).toContain("--resume 'original'");
|
||||
});
|
||||
|
||||
it("FRESH launch emits --model when the binding declares one", async () => {
|
||||
const tmux = mockTmux();
|
||||
await adapterWith(tmux).launchHarness(withModel(MODEL), { name: "seat" });
|
||||
@@ -959,7 +966,8 @@ describe("Claude Code runtime adapter", () => {
|
||||
|
||||
describe("a different occupant keeps main's result (timed out, no token): only unknown observations get attention", () => {
|
||||
const timedOut = { ok: false, probe: probeEvidence, error: "Claude resume failed: timed out waiting for Claude to become active" };
|
||||
const codex = { pid: 13, ppid: 11, pgid: 11, tpgid: 11, executableName: "codex", startedAt, command: "codex resume some-codex-session" };
|
||||
const codex = { pid: 13, ppid: 11, pgid: 11, tpgid: 11, executableName: "codex", startedAt,
|
||||
command: "codex resume some-codex-session", argv: ["codex", "resume", "some-codex-session"] };
|
||||
const otherClaude = { ...claude, command: "claude --permission-mode acceptEdits --resume some-other-session --name other" };
|
||||
const booting = [{ ...pane, tpgid: 11 }, launchScript];
|
||||
|
||||
|
||||
@@ -58,7 +58,8 @@ describe("late Codex fork identity", () => {
|
||||
pidOnlyRecord = false;
|
||||
processRows = [
|
||||
{ pid: 900, ppid: 1, pgid: 900, tpgid: 901, executableName: "sh", startedAt: STARTED, command: "sh" },
|
||||
{ pid: 901, ppid: 900, pgid: 901, tpgid: 901, executableName: "codex", startedAt: STARTED, command: `codex fork ${PARENT}` },
|
||||
{ pid: 901, ppid: 900, pgid: 901, tpgid: 901, executableName: "codex", startedAt: STARTED,
|
||||
command: `codex fork ${PARENT}`, argv: ["codex", "fork", PARENT] },
|
||||
];
|
||||
tmux = {
|
||||
hasSession: vi.fn(async () => true),
|
||||
@@ -177,12 +178,12 @@ describe("late Codex fork identity", () => {
|
||||
await orch.startNode(input);
|
||||
threadId = CHILD;
|
||||
const child = processRows[1]!;
|
||||
if (shape === "different parent") child.command = `codex fork ${CHILD}`;
|
||||
if (shape === "resume") child.command = `codex resume ${PARENT}`;
|
||||
if (shape === "fresh") child.command = "codex";
|
||||
if (shape === "different parent") { child.command = `codex fork ${CHILD}`; child.argv = ["codex", "fork", CHILD]; }
|
||||
if (shape === "resume") { child.command = `codex resume ${PARENT}`; child.argv = ["codex", "resume", PARENT]; }
|
||||
if (shape === "fresh") { child.command = "codex"; child.argv = ["codex"]; }
|
||||
if (shape === "background") child.pgid = 800;
|
||||
if (shape === "sibling") processRows.push({ ...child, pid: 902 });
|
||||
if (shape === "helper") { child.command = `helper codex fork ${PARENT}`; child.executableName = "helper"; }
|
||||
if (shape === "helper") { child.command = `helper codex fork ${PARENT}`; child.argv = ["helper", "codex", "fork", PARENT]; child.executableName = "helper"; }
|
||||
if (shape === "missing start") delete child.startedAt;
|
||||
expect(await lifecycle().continueStartup("builder@fork-recovery")).toMatchObject({ ok: false, startupStatus: "attention_required" });
|
||||
expect(tmux.sendText).not.toHaveBeenCalled();
|
||||
@@ -193,9 +194,9 @@ describe("late Codex fork identity", () => {
|
||||
threadId = CHILD;
|
||||
const child = processRows[1]!;
|
||||
if (shape === "reused PID") pidOnlyRecord = true;
|
||||
if (shape === "different parent") child.command = `codex fork ${CHILD}`;
|
||||
if (shape === "different parent") { child.command = `codex fork ${CHILD}`; child.argv = ["codex", "fork", CHILD]; }
|
||||
if (shape === "background") child.pgid = 800;
|
||||
if (shape === "helper") { child.command = `helper codex fork ${PARENT}`; child.executableName = "helper"; }
|
||||
if (shape === "helper") { child.command = `helper codex fork ${PARENT}`; child.argv = ["helper", "codex", "fork", PARENT]; child.executableName = "helper"; }
|
||||
readinessSeconds = 1;
|
||||
const result = await orch.startNode(input);
|
||||
expect(result).toMatchObject({ ok: false, startupStatus: "attention_required" });
|
||||
@@ -209,7 +210,8 @@ describe("late Codex fork identity", () => {
|
||||
await orch.startNode(input);
|
||||
threadId = CHILD;
|
||||
processRows[1]!.command = `codex -c example=fork -s workspace-write fork --add-dir /fixture/queue ${PARENT}`;
|
||||
processRows.push({ ...processRows[1]!, pid: 902, ppid: 901, command: "codex" });
|
||||
processRows[1]!.argv = ["codex", "-c", "example=fork", "-s", "workspace-write", "fork", "--add-dir", "/fixture/queue", PARENT];
|
||||
processRows.push({ ...processRows[1]!, pid: 902, ppid: 901, command: "codex", argv: ["codex"] });
|
||||
expect(await lifecycle().continueStartup("builder@fork-recovery")).toMatchObject({ ok: true, continuityOutcome: "forked" });
|
||||
expect(identityReads.at(-1)).toEqual([902, STARTED]);
|
||||
expect(tmux.sendText).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { codexCommandResumeToken, observeCodexForkProcess, observeCodexPaneProce
|
||||
import { prepareCodexSeatFolder } from "../src/domain/codex-team-workspace.js";
|
||||
import { CodexResumeAdapter } from "../src/adapters/codex-resume.js";
|
||||
import { resolveCodexGitAddDirs } from "../src/domain/codex-git-add-dirs.js";
|
||||
import { shellWords } from "../src/domain/seat-restart-in-place.js";
|
||||
|
||||
// Issue #121: in a linked git worktree `<cwd>/.git` is a FILE ("gitdir: ..."), so passing it to Codex as
|
||||
// `--add-dir` breaks the Linux sandbox for every command. These tests use real temporary Git repositories
|
||||
@@ -154,7 +155,7 @@ describe("Codex resume and fork git add-dirs (a restored seat keeps .git writabl
|
||||
const asPs = (command: string) => command.slice(command.indexOf("codex ")).replace(/'/g, "");
|
||||
const pane = { getPanePid: async () => 4100 };
|
||||
const row = (command: string) => [{ pid: 4100, ppid: 1, pgid: 4100, tpgid: 4100, executableName: "codex",
|
||||
startedAt: "Sat Jan 1 12:00:00 2000", command: asPs(command) }];
|
||||
startedAt: "Sat Jan 1 12:00:00 2000", command: asPs(command), argv: shellWords(command.slice(command.indexOf("codex "))) }];
|
||||
const resume = await launchCommand(repo, { resumeToken: "thread-1" });
|
||||
expect(codexCommandResumeToken(asPs(resume))).toBe("thread-1");
|
||||
expect(await observeCodexPaneProcess({ target: "r01-qa", tmux: pane, listProcesses: () => row(resume),
|
||||
@@ -207,7 +208,8 @@ describe("Codex resume and fork git add-dirs (a restored seat keeps .git writabl
|
||||
// The process line joins argv with spaces and no quoting, so a dir before the id would hide it.
|
||||
const line = built.core.replace(/'/g, "");
|
||||
expect(codexCommandResumeToken(line)).toBe("thread-1");
|
||||
const rows = () => [{ pid: 4100, ppid: 1, pgid: 4100, tpgid: 4100, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000", command: line }];
|
||||
const rows = () => [{ pid: 4100, ppid: 1, pgid: 4100, tpgid: 4100, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000",
|
||||
command: line, argv: shellWords(built.core) }];
|
||||
const pane = { getPanePid: async () => 4100 };
|
||||
// The restart's own proof, then the send path's check behind a shell.
|
||||
expect(await observeCodexPaneProcess({ target: "r01-qa", tmux: pane, listProcesses: rows, expectedToken: "thread-1", requireResume: true })).not.toBeNull();
|
||||
@@ -251,7 +253,7 @@ describe("Codex seat folder add-dir (a seat keeps its LEARNED.md and recap writa
|
||||
const asPs = (command: string) => command.slice(command.indexOf("codex ")).replace(/'/g, "");
|
||||
const pane = { getPanePid: async () => 4100 };
|
||||
const row = (command: string) => [{ pid: 4100, ppid: 1, pgid: 4100, tpgid: 4100, executableName: "codex",
|
||||
startedAt: "Sat Jan 1 12:00:00 2000", command: asPs(command) }];
|
||||
startedAt: "Sat Jan 1 12:00:00 2000", command: asPs(command), argv: shellWords(command.slice(command.indexOf("codex "))) }];
|
||||
const resume = await launchCommand(repo, { resumeToken: "thread-1" });
|
||||
expect(codexCommandResumeToken(asPs(resume))).toBe("thread-1");
|
||||
expect(await observeCodexPaneProcess({ target: "r01-qa", tmux: pane, listProcesses: () => row(resume),
|
||||
|
||||
@@ -3,6 +3,13 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { defaultListProcesses } from "../src/adapters/codex-runtime-adapter.js";
|
||||
import { readNativeProcessArguments } from "../src/domain/native-process-arguments.js";
|
||||
import { findExactNativeResumeProcess, verifyCodexPaneProcess } from "../src/domain/native-process-lineage.js";
|
||||
// The fixture's synthetic PIDs must never inspect host processes.
|
||||
vi.mock("../src/domain/native-process-arguments.js", async importOriginal => ({
|
||||
...await importOriginal<typeof import("../src/domain/native-process-arguments.js")>(),
|
||||
readNativeProcessArguments: vi.fn(async () => new Map()),
|
||||
}));
|
||||
import { restoreProc, serveProcRoot, writeFakeProc } from "./helpers/fake-proc.js";
|
||||
|
||||
let scratch: string | undefined;
|
||||
@@ -11,6 +18,8 @@ afterEach(() => {
|
||||
restoreProc();
|
||||
Object.defineProperty(process, "platform", platform);
|
||||
vi.unstubAllEnvs();
|
||||
vi.mocked(readNativeProcessArguments).mockReset();
|
||||
vi.mocked(readNativeProcessArguments).mockResolvedValue(new Map());
|
||||
if (scratch) fs.rmSync(scratch, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@@ -41,11 +50,19 @@ it("reads foreground identity through the real async executable and parser path"
|
||||
expect(rows).toEqual([
|
||||
{ pid: 101, ppid: 1, pgid: 101, tpgid: 102, executableName: "zsh", startedAt: "Sat Jan 1 12:00:00 2000", command: "-zsh" },
|
||||
{ pid: 102, ppid: 101, pgid: 102, tpgid: 102, executableName: "node", startedAt: "Sat Jan 1 12:00:00 2000", command: "node /opt/bin/codex -C /project" },
|
||||
{ pid: 103, ppid: 102, pgid: 102, tpgid: 102, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000", command: "/opt/vendor/bin/codex -C /project" },
|
||||
{ pid: 103, ppid: 102, pgid: 102, tpgid: 102, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000", command: "/opt/vendor/bin/codex -C /project", argv: null },
|
||||
{ pid: 104, ppid: 1, pgid: 104, tpgid: -1, executableName: "node", startedAt: "Sat Jan 1 12:00:00 2000", command: "node /tmp/other.js --label codex" },
|
||||
{ pid: 106, ppid: 1, pgid: 106, tpgid: -1, executableName: "tmux: server", startedAt: "Sat Jan 1 12:00:00 2000", command: "tmux new-session -d" },
|
||||
{ pid: 107, ppid: 1, pgid: 107, tpgid: -1, executableName: "node (vitest 1)", startedAt: "Sat Jan 1 12:00:00 2000", command: "node (vitest 1)" },
|
||||
]);
|
||||
// A rejected helper (including a policy timeout) must leave the table intact.
|
||||
vi.mocked(readNativeProcessArguments).mockRejectedValueOnce(new Error("argv helper rejected"))
|
||||
.mockRejectedValueOnce(new Error("policy timeout"));
|
||||
const observation = await verifyCodexPaneProcess({ target: "%1", tmux: { getPanePid: async () => 101 },
|
||||
listProcesses: defaultListProcesses, expectedToken: "recorded", requireResume: false });
|
||||
expect(observation?.process.pid).toBe(103);
|
||||
expect(observation?.process.argv).toBeNull();
|
||||
expect(findExactNativeResumeProcess(await defaultListProcesses(), 101, "codex", "recorded")).toBeNull();
|
||||
fs.writeFileSync(path.join(scratch, "ps"), "#!/bin/sh\nexit 1\n", { mode: 0o755 });
|
||||
expect(await defaultListProcesses()).toEqual([]);
|
||||
});
|
||||
@@ -55,7 +72,8 @@ it("reads foreground identity through the real /proc reader and parser path", as
|
||||
const listed = [
|
||||
{ pid: 101, ppid: 1, pgid: 101, tpgid: 102, name: "zsh", startedAt: started, command: "-zsh" },
|
||||
{ pid: 102, ppid: 101, pgid: 102, tpgid: 102, name: "node", startedAt: started, command: "node /opt/bin/codex -C /project" },
|
||||
{ pid: 103, ppid: 102, pgid: 102, tpgid: 102, name: "codex", startedAt: started, command: "/opt/vendor/bin/codex -C /project" },
|
||||
{ pid: 103, ppid: 102, pgid: 102, tpgid: 102, name: "codex", startedAt: started, command: "/opt/vendor/bin/codex -C /project",
|
||||
argv: ["/opt/vendor/bin/codex", "-C", "/project"] },
|
||||
{ pid: 104, ppid: 1, pgid: 104, tpgid: -1, name: "node", startedAt: started, command: "node /tmp/other.js --label codex" },
|
||||
// Linux task names may contain spaces (prctl PR_SET_NAME / process.title).
|
||||
{ pid: 106, ppid: 1, pgid: 106, tpgid: -1, name: "tmux: server", startedAt: started, command: "tmux new-session -d" },
|
||||
@@ -71,12 +89,38 @@ it("reads foreground identity through the real /proc reader and parser path", as
|
||||
scratch = fs.mkdtempSync(path.join(os.tmpdir(), "codex-process-read-"));
|
||||
fs.writeFileSync(path.join(scratch, "ps"), "#!/bin/sh\nexit 1\n", { mode: 0o755 });
|
||||
vi.stubEnv("PATH", scratch);
|
||||
vi.mocked(readNativeProcessArguments).mockClear();
|
||||
const rows = await defaultListProcesses();
|
||||
expect(rows.sort((a, b) => a.pid - b.pid)).toEqual(listed.map(({ name, ...row }) => ({ ...row, executableName: name })));
|
||||
expect(readNativeProcessArguments).not.toHaveBeenCalled();
|
||||
serveProcRoot(path.join(root, "gone"));
|
||||
expect(await defaultListProcesses()).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the foreground Codex deliverable beyond the argv reader's 128-process budget", async () => {
|
||||
Object.defineProperty(process, "platform", { value: "darwin" });
|
||||
scratch = fs.mkdtempSync(path.join(os.tmpdir(), "codex-process-budget-"));
|
||||
const started = "Sat Jan 1 12:00:00 2000";
|
||||
const output = [
|
||||
"PID PPID PGID TPGID UCOMM LSTART COMMAND",
|
||||
`101 1 101 102 zsh ${started} -zsh`,
|
||||
...Array.from({ length: 128 }, (_, index) => `${200 + index} 1 ${200 + index} ${200 + index} codex ${started} codex resume recorded`),
|
||||
`400 101 102 102 codex ${started} codex resume recorded`,
|
||||
`401 1 401 -1 codex ${started} codex resume recorded`,
|
||||
];
|
||||
const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
|
||||
fs.writeFileSync(path.join(scratch, "ps"), `#!/bin/sh\nprintf '%s\\n' ${output.map(quote).join(" ")}\n`, { mode: 0o755 });
|
||||
vi.stubEnv("PATH", scratch);
|
||||
vi.mocked(readNativeProcessArguments).mockImplementation(async requested =>
|
||||
new Map(requested.slice(0, 128).map(pid => [pid, ["codex", "resume", "recorded"]])));
|
||||
const rows = await defaultListProcesses();
|
||||
expect(readNativeProcessArguments).toHaveBeenCalledWith([...Array.from({ length: 128 }, (_, index) => 200 + index), 400]);
|
||||
expect(rows.find(row => row.pid === 400)?.argv).toBeNull();
|
||||
expect((await verifyCodexPaneProcess({ target: "%1", tmux: { getPanePid: async () => 101 },
|
||||
listProcesses: defaultListProcesses, expectedToken: "recorded", requireResume: false }))?.process.pid).toBe(400);
|
||||
expect(findExactNativeResumeProcess(rows, 101, "codex", "recorded")).toBeNull();
|
||||
});
|
||||
|
||||
it("reads this test process with the installed ps columns, without starting a provider", async () => {
|
||||
const rows = await defaultListProcesses();
|
||||
const self = rows.find((r) => r.pid === process.pid);
|
||||
|
||||
@@ -41,7 +41,7 @@ it.each([
|
||||
});
|
||||
|
||||
describe("the live predecessor record", () => {
|
||||
function fixture(options: { wrongThread?: boolean; stale?: boolean; incompleteLatest?: boolean; turnover?: boolean } = {}) {
|
||||
function fixture(options: { wrongThread?: boolean; stale?: boolean; incompleteLatest?: boolean; turnover?: boolean; argv?: string[] | null } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), "codex-rebuild-")); roots.push(root);
|
||||
const path = join(root, "record.jsonl");
|
||||
writeFileSync(path, [
|
||||
@@ -52,13 +52,14 @@ describe("the live predecessor record", () => {
|
||||
const native = provenCodexResume("fixture-thread");
|
||||
let reads = 0;
|
||||
const listProcesses = vi.fn(() => {
|
||||
const rows = native.listProcesses();
|
||||
const rows = native.listProcesses().map(row => row.executableName === "codex" && options.argv !== undefined
|
||||
? { ...row, command: options.argv?.join(" ") ?? row.command, argv: options.argv } : row);
|
||||
if (options.turnover && reads++ > 0) return rows.map(row => ({ ...row, startedAt: "Tue Oct 20 05:02:01 2026" }));
|
||||
return rows;
|
||||
});
|
||||
return { nodeId: "seat", sessionName: "member@team", cwd: "/project", path,
|
||||
tmux: { getPanePid: native.getPanePid }, listProcesses,
|
||||
readThreadId: vi.fn(async () => "fixture-thread"), rollout: () => path,
|
||||
readThreadId: vi.fn(async () => "fixture-thread"), rollout: vi.fn(() => path),
|
||||
sessions: { currentOccupantTenure: () => ({ generationUuid: "generation", bootAt: "2026-10-20T05:00:00Z" }),
|
||||
getBindingForNode: () => ({ tmuxSession: "member@team", tmuxPane: "%1" }),
|
||||
currentOccupantGenerationForSession: () => "generation" } as never };
|
||||
@@ -68,6 +69,25 @@ describe("the live predecessor record", () => {
|
||||
expect(result).toMatchObject({ state: "observed", observedAt: "2026-10-20T05:01:00.000Z",
|
||||
envelope: { approvalPolicy: "on-request", sandboxPolicy: payload.sandbox_policy } });
|
||||
});
|
||||
it("reports a managed profile containing spaces from the selected process's exact argv", async () => {
|
||||
const argv = ["codex", "-p", "openrig desktop work", "resume", "fixture-thread"];
|
||||
expect(await observeCodexRebuildPermissions(fixture({ argv }))).toMatchObject({ state: "observed", profile: "openrig desktop work" });
|
||||
});
|
||||
it("retains the native-log join for a valid fresh Codex process", async () => {
|
||||
expect(await observeCodexRebuildPermissions(fixture({ argv: ["codex", "-p", "managed work"] })))
|
||||
.toMatchObject({ state: "observed", profile: "managed work" });
|
||||
});
|
||||
it.each([null, [], ["codex", "resume", "fixture-thread", "bad\0argument"], ["node", "resume", "fixture-thread"]])(
|
||||
"does not inspect native records for an opaque or malformed predecessor argv: %j", async argv => {
|
||||
const input = fixture({ argv });
|
||||
expect(await observeCodexRebuildPermissions(input)).toMatchObject({ state: "unverified" });
|
||||
expect(input.readThreadId).not.toHaveBeenCalled();
|
||||
expect(input.rollout).not.toHaveBeenCalled();
|
||||
});
|
||||
it("refuses a thread named only inside a spaced option value", async () => {
|
||||
const argv = ["codex", "resume", "--add-dir", "/tmp/fixture-thread", "another-thread"];
|
||||
expect(await observeCodexRebuildPermissions(fixture({ argv }))).toMatchObject({ state: "unverified" });
|
||||
});
|
||||
it.each(["wrongThread", "stale", "incompleteLatest", "turnover"] as const)(
|
||||
"discloses unverified observation on %s, rather than using an older/different policy", async reason => {
|
||||
expect(await observeCodexRebuildPermissions(fixture({ [reason]: true }))).toMatchObject({ state: "unverified" });
|
||||
|
||||
@@ -23,7 +23,8 @@ const errorFrame = `No saved session found with ID ${token}. Run \`codex resume\
|
||||
const started = "Tue Oct 20 05:00:01 2026";
|
||||
const rows = (startedAt = started) => [
|
||||
{ pid: 900, ppid: 1, command: "-zsh", pgid: 900, tpgid: 901, executableName: "zsh", startedAt: "Tue Oct 20 04:59:00 2026" },
|
||||
{ pid: 901, ppid: 900, command: `codex resume ${token}`, pgid: 901, tpgid: 901, executableName: "codex", startedAt },
|
||||
{ pid: 901, ppid: 900, command: `codex resume ${token}`, argv: ["codex", "resume", token],
|
||||
pgid: 901, tpgid: 901, executableName: "codex", startedAt },
|
||||
];
|
||||
|
||||
type Evidence = (pid: number, identity: string) => Promise<CodexResumeEvidence> | CodexResumeEvidence;
|
||||
|
||||
@@ -237,7 +237,7 @@ describe("Codex runtime adapter", () => {
|
||||
});
|
||||
const adapter = new CodexRuntimeAdapter({ tmux, fsOps: mockFs(), listProcesses: () => [
|
||||
{ pid: 900, ppid: 1, command: paneCommand === "bash" ? "/bin/bash" : "node launcher", pgid: 900, tpgid: 901, executableName: paneCommand, startedAt: "Sat Jan 1 12:00:00 2000" },
|
||||
{ pid: 901, ppid: 900, command: "codex", pgid: 901, tpgid: 901, executableName: "codex", startedAt: "Sat Jan 1 12:00:01 2000" },
|
||||
{ pid: 901, ppid: 900, command: "codex", argv: ["codex"], pgid: 901, tpgid: 901, executableName: "codex", startedAt: "Sat Jan 1 12:00:01 2000" },
|
||||
] });
|
||||
expect(await adapter.checkReady(makeBinding()), fixture).toMatchObject({ ready: false, code: "login_required" });
|
||||
expect(tmux.capturePaneContent).toHaveBeenCalledTimes(2);
|
||||
@@ -271,7 +271,7 @@ describe("Codex runtime adapter", () => {
|
||||
fsOps: mockFs(),
|
||||
listProcesses: () => [
|
||||
{ pid: 900, ppid: 1, command: "/bin/zsh", pgid: 900, tpgid: 901, executableName: "zsh", startedAt: "Sat Jan 1 12:00:00 2000" },
|
||||
{ pid: 901, ppid: 900, command: "codex --model anthropic/claude-3.5-sonnet", pgid: 901, tpgid: 901, executableName: "codex", startedAt: "Sat Jan 1 12:00:01 2000" },
|
||||
{ pid: 901, ppid: 900, command: "codex --model anthropic/claude-3.5-sonnet", argv: ["codex", "--model", "anthropic/claude-3.5-sonnet"], pgid: 901, tpgid: 901, executableName: "codex", startedAt: "Sat Jan 1 12:00:01 2000" },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -295,7 +295,7 @@ describe("Codex runtime adapter", () => {
|
||||
fsOps: mockFs(),
|
||||
listProcesses: () => [
|
||||
{ pid: 900, ppid: 1, command: "/bin/zsh", pgid: 900, tpgid: 901, executableName: "zsh", startedAt: "Sat Jan 1 12:00:00 2000" },
|
||||
{ pid: 901, ppid: 900, command: "codex --model anthropic/claude-3.5-sonnet", pgid: 901, tpgid: 901, executableName: "codex", startedAt: "Sat Jan 1 12:00:01 2000" },
|
||||
{ pid: 901, ppid: 900, command: "codex --model anthropic/claude-3.5-sonnet", argv: ["codex", "--model", "anthropic/claude-3.5-sonnet"], pgid: 901, tpgid: 901, executableName: "codex", startedAt: "Sat Jan 1 12:00:01 2000" },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -337,7 +337,7 @@ describe("Codex runtime adapter", () => {
|
||||
fsOps: mockFs(),
|
||||
listProcesses: () => [
|
||||
{ pid: 900, ppid: 1, command: "/bin/bash", pgid: 900, tpgid: 901, executableName: "bash", startedAt: "Sat Jan 1 12:00:00 2000" },
|
||||
{ pid: 901, ppid: 900, command: "codex", pgid: 901, tpgid: 901, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000" },
|
||||
{ pid: 901, ppid: 900, command: "codex", argv: ["codex"], pgid: 901, tpgid: 901, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000" },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -1082,7 +1082,7 @@ describe("Codex runtime adapter", () => {
|
||||
fsOps: mockFs(),
|
||||
listProcesses: () => [
|
||||
{ pid: 900, ppid: 1, command: "-zsh", pgid: 900, tpgid: 901, executableName: "zsh", startedAt: "Sat Jan 1 12:00:00 2000" },
|
||||
{ pid: 901, ppid: 900, command: "codex", pgid: 901, tpgid: 901, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000" },
|
||||
{ pid: 901, ppid: 900, command: "codex", argv: ["codex"], pgid: 901, tpgid: 901, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000" },
|
||||
],
|
||||
readThreadIdByPid: (pid) => pid === 901 ? "019d45bc-117d-78a3-a4ad-6fb186e5a86d" : undefined,
|
||||
sleep: async () => {},
|
||||
@@ -1119,7 +1119,7 @@ describe("Codex runtime adapter", () => {
|
||||
fsOps: mockFs(),
|
||||
listProcesses: () => [
|
||||
{ pid: 900, ppid: 1, command: "-zsh", pgid: 900, tpgid: 901, executableName: "zsh", startedAt: "Sat Jan 1 12:00:00 2000" },
|
||||
{ pid: 901, ppid: 900, command: "codex", pgid: 901, tpgid: 901, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000" },
|
||||
{ pid: 901, ppid: 900, command: "codex", argv: ["codex"], pgid: 901, tpgid: 901, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000" },
|
||||
],
|
||||
readThreadIdByPid: () => "019d45bc-117d-78a3-a4ad-6fb186e5a86d",
|
||||
sleep: async () => {},
|
||||
@@ -1173,7 +1173,7 @@ describe("Codex runtime adapter", () => {
|
||||
fsOps: mockFs(),
|
||||
listProcesses: () => [
|
||||
{ pid: 900, ppid: 1, command: "-zsh", pgid: 900, tpgid: 901, executableName: "zsh", startedAt: "Sat Jan 1 12:00:00 2000" },
|
||||
{ pid: 901, ppid: 900, command: "codex", pgid: 901, tpgid: 901, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000" },
|
||||
{ pid: 901, ppid: 900, command: "codex", argv: ["codex"], pgid: 901, tpgid: 901, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000" },
|
||||
],
|
||||
readThreadIdByPid: (pid) => pid === 901 ? "019d45bc-117d-78a3-a4ad-6fb186e5a86d" : undefined,
|
||||
sleep: async () => {},
|
||||
@@ -1256,7 +1256,7 @@ describe("Codex runtime adapter", () => {
|
||||
it("a skip key sent to an update prompt that stays on screen is reported as sent, with the choice-bearing update screen", async () => {
|
||||
const tmux = screens(() => SKIPPABLE);
|
||||
const adapter = new CodexRuntimeAdapter({ tmux, fsOps: mockFs(), sleep: async () => {},
|
||||
listProcesses: () => [{ pid: 901, ppid: 1, pgid: 901, tpgid: 901, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000", command: "codex" }] });
|
||||
listProcesses: () => [{ pid: 901, ppid: 1, pgid: 901, tpgid: 901, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000", command: "codex", argv: ["codex"] }] });
|
||||
const result = await adapter.verifyResumeInPane("impl", "tok");
|
||||
expect(tmux.sendKeys).toHaveBeenCalledTimes(1);
|
||||
expect(tmux.sendKeys).toHaveBeenCalledWith("impl", ["3"]);
|
||||
@@ -1268,7 +1268,7 @@ describe("Codex runtime adapter", () => {
|
||||
// Round 3: the newest observation wins in the order it was taken, including the helper's current-screen recheck, and
|
||||
// a screen OpenRig acted on afterwards is reported as the screen before that input.
|
||||
const RESUMED = "OpenAI Codex (v0.120.0)\n› Ask Codex to do anything";
|
||||
const CODEX_ROW = [{ pid: 901, ppid: 1, pgid: 901, tpgid: 901, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000", command: "codex" }];
|
||||
const CODEX_ROW = [{ pid: 901, ppid: 1, pgid: 901, tpgid: 901, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000", command: "codex", argv: ["codex"] }];
|
||||
/** How many screen captures a run on an unchanging, unskippable update screen makes. */
|
||||
async function updatingCaptures(): Promise<number> {
|
||||
const counter = screens(() => UPDATING);
|
||||
@@ -1643,7 +1643,7 @@ describe("Codex runtime adapter", () => {
|
||||
.mockResolvedValue("OpenAI Codex (v0.120.0)\n› Ask Codex to do anything"),
|
||||
});
|
||||
const adapter = new CodexRuntimeAdapter({ tmux, fsOps: mockFs(), sleep: async () => {},
|
||||
listProcesses: () => [{ pid: 901, ppid: 1, pgid: 901, tpgid: 901, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000", command: "codex resume sess-456" }],
|
||||
listProcesses: () => [{ pid: 901, ppid: 1, pgid: 901, tpgid: 901, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000", command: "codex resume sess-456", argv: ["codex", "resume", "sess-456"] }],
|
||||
readResumeEvidence: () => ({ threadId: "sess-456" }),
|
||||
});
|
||||
|
||||
|
||||
@@ -22,8 +22,11 @@ const binding = {
|
||||
// DontRemind immediately; :74-85 persists dismissal or returns RunUpdate.
|
||||
// Drive real TmuxAdapter serialization into this model, not a sequence of
|
||||
// ready screenshots that would pass even if the wrong input were sent.
|
||||
type ProcessRow = { pid: number; ppid: number; command: string; pgid?: number; tpgid?: number; executableName?: string; startedAt?: string };
|
||||
type ProcessRow = { pid: number; ppid: number; command: string; argv?: string[]; pgid?: number; tpgid?: number; executableName?: string; startedAt?: string };
|
||||
const native = "/opt/codex/vendor/aarch64-apple-darwin/bin/codex";
|
||||
const ordinaryNative = "/opt/test-provider/lib/node_modules/@openai/codex/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin/bin/codex";
|
||||
const ordinaryArgs = ["-s", "workspace-write", "-C", "/workspace/sample-project", "--add-dir", "/workspace/sample-project/.git",
|
||||
"--add-dir", "/tmp/test-instance/shared-docs/rigs/first-project/state/dev", "-m", "fixture-model"];
|
||||
// Synthetic shell -> Node -> native ancestry, including a background helper.
|
||||
// Paths, PIDs and start times are invented; only the process relationships matter.
|
||||
// The receiver below models the update menu; this is not native execution.
|
||||
@@ -31,19 +34,20 @@ const ordinaryRows: ProcessRow[] = [
|
||||
{ pid: 2001, ppid: 2000, pgid: 2001, tpgid: 2002, executableName: "zsh", startedAt: "Sat Jan 1 12:00:00 2000", command: "-zsh" },
|
||||
{ pid: 2002, ppid: 2001, pgid: 2002, tpgid: 2002, executableName: "bash", startedAt: "Sat Jan 1 12:00:00 2000", command: "/bin/sh /tmp/test-launch/openrig-tmux-send.txt" },
|
||||
{ pid: 2003, ppid: 2002, pgid: 2002, tpgid: 2002, executableName: "node", startedAt: "Sat Jan 1 12:00:00 2000", command: "node /opt/test-provider/bin/codex -s workspace-write -C /workspace/sample-project --add-dir /workspace/sample-project/.git --add-dir /tmp/test-instance/shared-docs/rigs/first-project/state/dev -m fixture-model" },
|
||||
{ pid: 2004, ppid: 2003, pgid: 2002, tpgid: 2002, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000", command: "/opt/test-provider/lib/node_modules/@openai/codex/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin/bin/codex -s workspace-write -C /workspace/sample-project --add-dir /workspace/sample-project/.git --add-dir /tmp/test-instance/shared-docs/rigs/first-project/state/dev -m fixture-model" },
|
||||
{ pid: 2004, ppid: 2003, pgid: 2002, tpgid: 2002, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000",
|
||||
command: `${ordinaryNative} ${ordinaryArgs.join(" ")}`, argv: [ordinaryNative, ...ordinaryArgs] },
|
||||
{ pid: 2005, ppid: 2004, pgid: 2005, tpgid: 2002, executableName: "codex-code-mode-", startedAt: "Sat Jan 1 12:00:00 2000", command: "/opt/test-provider/lib/node_modules/@openai/codex/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin/bin/codex-code-mode-host" },
|
||||
];
|
||||
const processRows = (shape: "wrapper" | "exec-wrapper" | "native"): ProcessRow[] => {
|
||||
if (shape === "native") return [{ pid: 101, ppid: 1, command: native, pgid: 101, tpgid: 101, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000" }];
|
||||
if (shape === "native") return [{ pid: 101, ppid: 1, command: native, argv: [native], pgid: 101, tpgid: 101, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000" }];
|
||||
if (shape === "exec-wrapper") return [
|
||||
{ pid: 101, ppid: 1, command: "node /opt/bin/codex", pgid: 101, tpgid: 101, executableName: "node", startedAt: "Sat Jan 1 12:00:00 2000" },
|
||||
{ pid: 102, ppid: 101, command: native, pgid: 101, tpgid: 101, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000" },
|
||||
{ pid: 102, ppid: 101, command: native, argv: [native], pgid: 101, tpgid: 101, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000" },
|
||||
];
|
||||
return [
|
||||
{ pid: 101, ppid: 1, command: "-zsh", pgid: 101, tpgid: 102, executableName: "zsh", startedAt: "Sat Jan 1 12:00:00 2000" },
|
||||
{ pid: 102, ppid: 101, command: "node /opt/bin/codex", pgid: 102, tpgid: 102, executableName: "node", startedAt: "Sat Jan 1 12:00:00 2000" },
|
||||
{ pid: 103, ppid: 102, command: native, pgid: 102, tpgid: 102, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000" },
|
||||
{ pid: 103, ppid: 102, command: native, argv: [native], pgid: 102, tpgid: 102, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000" },
|
||||
];
|
||||
};
|
||||
function fixture(options: { menu?: string; delay?: number; beforeMenu?: number; failInput?: boolean; screen?: string; command?: string; panePid?: number; processes?: ProcessRow[]; shape?: "wrapper" | "exec-wrapper" | "native" } = {}) {
|
||||
@@ -90,8 +94,10 @@ function fixture(options: { menu?: string; delay?: number; beforeMenu?: number;
|
||||
// #1524 (b): a resume's Codex processes carry `resume <token>`, and Codex's records for them name that token once ready.
|
||||
const resumeArgs = () => / resume '?([^' ]+)'?/.exec(launched)?.[1];
|
||||
const listProcesses = vi.fn(() => (options.processes ?? processRows(options.shape ?? "wrapper")).map(row =>
|
||||
forkArgs() && /codex/.test(row.command) && !/codex-code-mode/.test(row.command) ? { ...row, command: `${row.command} fork ${forkArgs()}` }
|
||||
: resumeArgs() && /codex/.test(row.command) && !/codex-code-mode/.test(row.command) ? { ...row, command: `${row.command} resume ${resumeArgs()}` } : row));
|
||||
forkArgs() && /codex/.test(row.command) && !/codex-code-mode/.test(row.command) ? { ...row, command: `${row.command} fork ${forkArgs()}`,
|
||||
...(row.argv ? { argv: [...row.argv, "fork", forkArgs()!] } : {}) }
|
||||
: resumeArgs() && /codex/.test(row.command) && !/codex-code-mode/.test(row.command) ? { ...row, command: `${row.command} resume ${resumeArgs()}`,
|
||||
...(row.argv ? { argv: [...row.argv, "resume", resumeArgs()!] } : {}) } : row));
|
||||
const adapter = new CodexRuntimeAdapter({
|
||||
tmux, fsOps, sleep: async () => { ticks++; },
|
||||
listProcesses,
|
||||
@@ -227,7 +233,7 @@ describe.each(paths)("Codex update input: $name", ({ opts }) => {
|
||||
|
||||
it.each([
|
||||
{ name: "no process rows", rows: [] },
|
||||
{ name: "native name with unrelated executable argument zero", rows: [{pid:101,ppid:1,command:"/bin/echo codex",pgid:101,tpgid:101,executableName:"codex", startedAt: "Sat Jan 1 12:00:00 2000"}] },
|
||||
{ name: "native name with unrelated executable argument zero", rows: [{pid:101,ppid:1,command:"/bin/echo codex",argv:["/bin/echo","codex"],pgid:101,tpgid:101,executableName:"codex", startedAt: "Sat Jan 1 12:00:00 2000"}] },
|
||||
{ name: "argument named codex", rows: [{pid:101,ppid:1,command:"node other.js --label codex",pgid:101,tpgid:101,executableName:"node", startedAt: "Sat Jan 1 12:00:00 2000"}] },
|
||||
{ name: "native name only in arguments", rows: [{pid:101,ppid:1,command:"/bin/echo codex",pgid:101,tpgid:101,executableName:"echo", startedAt: "Sat Jan 1 12:00:00 2000"}] },
|
||||
{ name: "background Codex", rows: processRows("wrapper").map(r=>({...r,tpgid:500})) },
|
||||
@@ -236,7 +242,7 @@ describe.each(paths)("Codex update input: $name", ({ opts }) => {
|
||||
{ name: "conflicting terminal group", rows: processRows("wrapper").map(r=>r.pid===103?{...r,tpgid:500}:r) },
|
||||
{ name: "another pane", rows: processRows("wrapper").map(r=>r.pid===103?{...r,ppid:999}:r) },
|
||||
{ name: "kernel name contradicts argv", rows: processRows("wrapper").map(r=>r.pid===103?{...r,executableName:"node", startedAt: "Sat Jan 1 12:00:00 2000"}:r) },
|
||||
{ name: "ambiguous native processes", rows: [...processRows("wrapper"),{pid:104,ppid:102,command:native,pgid:102,tpgid:102,executableName:"codex", startedAt: "Sat Jan 1 12:00:00 2000"}] },
|
||||
{ name: "ambiguous native processes", rows: [...processRows("wrapper"),{pid:104,ppid:102,command:native,argv:[native],pgid:102,tpgid:102,executableName:"codex", startedAt: "Sat Jan 1 12:00:00 2000"}] },
|
||||
])("does not choose Skip with $name", async ({rows}) => {
|
||||
for (const command of ["node", "codex", "bash"]) {
|
||||
const f=fixture({command,processes:rows});
|
||||
|
||||
@@ -54,6 +54,19 @@ describe("GET /library/resolve-address — file-level span serving (Atom 4c)", (
|
||||
const resolve = (address: string) =>
|
||||
app.request(`/api/context-packs/library/resolve-address?address=${encodeURIComponent(address)}`);
|
||||
|
||||
it("serves complete sections around commented headings and fences", async () => {
|
||||
const welcome = "## Welcome\nhello\n<!--\n## Archived\n```\n-->\nstill welcome";
|
||||
writeFileSync(join(tmp, "lib", "packs", "world", "walk.md"),
|
||||
`${welcome}\n## Reference\nreference instructions\n`);
|
||||
const first = await resolve("packs/world/walk.md#welcome");
|
||||
expect(first.status).toBe(200);
|
||||
expect(((await first.json()) as { text: string }).text).toBe(welcome);
|
||||
const next = await resolve("packs/world/walk.md#reference");
|
||||
expect(next.status).toBe(200);
|
||||
expect(((await next.json()) as { text: string }).text).toBe("## Reference\nreference instructions\n");
|
||||
expect((await resolve("packs/world/walk.md#archived")).status).toBe(422);
|
||||
});
|
||||
|
||||
it("serves a requested section after an inline triple-backtick span", async () => {
|
||||
writeFileSync(join(tmp, "lib", "packs", "world", "walk.md"),
|
||||
"## Welcome\n```literal ` backticks```\n## Reference\nreference instructions\n");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import type Database from "better-sqlite3";
|
||||
@@ -29,6 +29,7 @@ describe("0.3.0 conveyor starter workflow specs", () => {
|
||||
let db: Database.Database;
|
||||
let eventBus: EventBus;
|
||||
let runtime: WorkflowRuntime;
|
||||
let queueRepo: QueueRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createDb();
|
||||
@@ -44,7 +45,7 @@ describe("0.3.0 conveyor starter workflow specs", () => {
|
||||
]);
|
||||
eventBus = new EventBus(db);
|
||||
db.prepare(`INSERT INTO rigs (id, name) VALUES ('r-conveyor', 'conveyor')`).run();
|
||||
const queueRepo = new QueueRepository(db, eventBus, { validateRig: () => true });
|
||||
queueRepo = new QueueRepository(db, eventBus, { validateRig: () => true });
|
||||
// P34: the W1 seam is fail-closed (MF2) — a nudge-intended terminal
|
||||
// close needs a SAME-DB intent store to make its wake durable.
|
||||
queueRepo.attachOutbox(new OutboxHandler(db));
|
||||
@@ -103,6 +104,18 @@ describe("0.3.0 conveyor starter workflow specs", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("workflow entry nudge is sent as the instantiating session", async () => {
|
||||
const maybeNudge = vi.spyOn(queueRepo, "maybeNudge").mockResolvedValue();
|
||||
const created = await runtime.instantiate({
|
||||
specPath: CONVEYOR_SPEC,
|
||||
rootObjective: "packet sender",
|
||||
createdBySession: "intake-lead@conveyor",
|
||||
});
|
||||
expect(maybeNudge).toHaveBeenCalledTimes(1);
|
||||
expect(maybeNudge.mock.calls[0]?.[0]).toBe(created.entryQitemId);
|
||||
expect(maybeNudge.mock.calls[0]?.[3]).toBe("intake-lead@conveyor");
|
||||
});
|
||||
|
||||
it("conveyor can run multiple active instances on the same rig", async () => {
|
||||
const first = await runtime.instantiate({
|
||||
specPath: CONVEYOR_SPEC,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { TmuxAdapter } from "../src/adapters/tmux.js";
|
||||
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import nativePaste from "./fixtures/claude-paste-footer.json";
|
||||
import { wrapPaneEnvelope } from "../src/lib/pane-envelope.js";
|
||||
import type { ComposerSnapshot } from "../src/domain/composer-prompts.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { OutboxHandler } from "../src/domain/outbox-handler.js";
|
||||
@@ -102,6 +103,127 @@ function fixture(saved?: Buffer, opts: { runtime?: string; idleHook?: boolean }
|
||||
}
|
||||
|
||||
|
||||
// Retained D1 geometry: empty Codex 0.164 at 65x9; after paste the editor
|
||||
// shows only the envelope's reply footer and an upward scroll indicator.
|
||||
function codexViewport(body: string, width: number, height: number): ComposerSnapshot {
|
||||
// rust-v0.164.0 uses this character-count label, not Claude's line-count label.
|
||||
if ([...body].length > 1000) return codexViewport(`[Pasted Content ${[...body].length} chars]`, width, height);
|
||||
if (!body) return { screen: ["• DONE", "", " Worked for 4s • 20:24", "", "",
|
||||
"› \x1b[2mAsk Codex to do anything\x1b[0m", "", " GPT-6.1-Sol low · ~/project · Recover OpenRig identity context", " ? for shortcuts", ""].join("\n"),
|
||||
cursor: { x: 2, y: 5, width, height }, inMode: false };
|
||||
if (height === 9 && body.includes("From:")) return {
|
||||
screen: ["", "", "› --- ↑",
|
||||
" ↩ Reply if needed: rig queue create --destination host-",
|
||||
' fixture-driver@external --summary "<one line>" --body "..."',
|
||||
" --verify", "", " GPT-6.1-Sol low · ~/project · Recover OpenRig identity context", ""].join("\n"),
|
||||
cursor: { x: 10, y: 5, width, height }, inMode: false };
|
||||
const rows = body.split("\n");
|
||||
return { screen: ["Ready", "", `› ${rows[0]}`, ...rows.slice(1).map(row => ` ${row}`), "",
|
||||
" GPT-6.1-Sol low · ~/project", " ? for shortcuts", ""].join("\n"),
|
||||
cursor: { x: 2 + stringWidth(rows.at(-1)!), y: 1 + rows.length, width, height }, inMode: false };
|
||||
}
|
||||
|
||||
const d1Envelope = wrapPaneEnvelope("host-fixture-driver@external", "worker@test",
|
||||
"Write exactly draft-aware to /home/t1s075/project/delivery-draft-aware.txt with one shell tool; reply DONE briefly and wait.");
|
||||
|
||||
async function codexDelivery(mode = "draft-aware", size = { width: 65, height: 9 }) {
|
||||
const f = fixture(undefined, { runtime: "codex" });
|
||||
await f.guard.set("a", { mode }, "human@test", "D1 protected delivery");
|
||||
vi.mocked(f.tmux.captureComposerSnapshot).mockImplementation(async () => codexViewport(f.bodies.a ?? "", size.width, size.height));
|
||||
return { ...f, size, options: { verify: false, ...(mode === "openrig-urgent" ? { urgent: { by: "human@test" } } : {}) } };
|
||||
}
|
||||
|
||||
describe("Codex pre-paste observability through the real delivery path", () => {
|
||||
it.each(["draft-aware", "openrig-urgent"])("retains the full envelope before writing into the native 65x9 frame (%s)", async mode => {
|
||||
const f = await codexDelivery(mode);
|
||||
expect(await f.send("D1", d1Envelope, f.options)).toMatchObject({ ok: true, outcome: "retained", sent: false,
|
||||
inputPhase: "not_reached", reason: "draft_input_unknown" });
|
||||
expect(f.writes).toEqual([]);
|
||||
expect(f.submissions).toEqual([]);
|
||||
expect(f.bodies.a).toBe("");
|
||||
});
|
||||
|
||||
it.each(["draft-aware", "openrig-urgent"])("submits a fitting full envelope once (%s)", async mode => {
|
||||
const f = await codexDelivery(mode, { width: 160, height: 45 });
|
||||
expect(await f.send("fits", d1Envelope, f.options)).toMatchObject({ ok: true, sent: true });
|
||||
expect(f.submissions).toEqual([d1Envelope]);
|
||||
expect(f.writes).toHaveLength(2);
|
||||
expect(f.bodies.a).toBe("");
|
||||
});
|
||||
|
||||
it("delivers nine native-fitting 61-column lines plus their envelope at 65x45", async () => {
|
||||
const f = await codexDelivery("draft-aware", { width: 65, height: 45 });
|
||||
const text = wrapPaneEnvelope("sender@test", "worker@test", Array(9).fill("x".repeat(61)).join("\n"));
|
||||
expect(await f.send("native-fit", text, f.options)).toMatchObject({ ok: true, sent: true });
|
||||
expect(f.submissions).toEqual([text]);
|
||||
});
|
||||
|
||||
it("owns the complete envelope after a native 62-column hard wrap at 65x45", async () => {
|
||||
const f = await codexDelivery("draft-aware", { width: 65, height: 45 });
|
||||
const body = "x".repeat(64);
|
||||
const text = wrapPaneEnvelope("sender@test", "worker@test", body);
|
||||
expect(text.split("\n").filter(row => row.length > 62)).toEqual([body]);
|
||||
// Codex's two-column prompt and one-column right inset leave 62 cells.
|
||||
// This ASCII word wraps without dropping or replacing any authored byte.
|
||||
vi.mocked(f.tmux.captureComposerSnapshot).mockImplementation(async () =>
|
||||
codexViewport((f.bodies.a ?? "").replace(body, `${body.slice(0, 62)}\n${body.slice(62)}`), 65, 45));
|
||||
expect(await f.send("native-wrap", text, f.options)).toMatchObject({ ok: true, sent: true });
|
||||
expect(f.submissions).toEqual([text]);
|
||||
expect(f.bodies.a).toBe("");
|
||||
});
|
||||
|
||||
it("retains a raw-fitting 1200-code-point envelope before Codex collapses it into an unowned label", async () => {
|
||||
const f = await codexDelivery("draft-aware", { width: 160, height: 45 });
|
||||
const empty = wrapPaneEnvelope("sender@test", "worker@test", "");
|
||||
const text = wrapPaneEnvelope("sender@test", "worker@test", "x".repeat(1200 - [...empty].length));
|
||||
expect([...text]).toHaveLength(1200);
|
||||
const result = await f.send("native-long", text, f.options);
|
||||
expect(result).toMatchObject({ outcome: "retained", sent: false, inputPhase: "not_reached" });
|
||||
expect(result.error).toMatch(/shorter message.*guard.*off/);
|
||||
expect(f.writes).toEqual([]);
|
||||
expect(f.bodies.a).toBe("");
|
||||
});
|
||||
|
||||
it.each(["\t", "\x1b"])("gives control-specific recovery at every size (%j)", async control => {
|
||||
const f = await codexDelivery("draft-aware", { width: 160, height: 45 });
|
||||
for (const text of [`a${control}b`, `${"x".repeat(1200)}${control}`]) {
|
||||
const result = await f.send("control", text, f.options);
|
||||
expect(result).toMatchObject({ outcome: "retained", sent: false, inputPhase: "not_reached" });
|
||||
expect(result.error).toMatch(/without them/);
|
||||
expect(result.error).not.toMatch(/larger terminal/);
|
||||
}
|
||||
expect(f.writes).toEqual([]);
|
||||
});
|
||||
|
||||
it("leaves default-off delivery unchanged in the small viewport", async () => {
|
||||
const f = await codexDelivery("off");
|
||||
expect(await f.send("off", d1Envelope, f.options)).toMatchObject({ ok: true, sent: true });
|
||||
expect(f.submissions).toEqual([d1Envelope]);
|
||||
});
|
||||
|
||||
it("rechecks the actual viewport after buffer preparation, before any paste", async () => {
|
||||
const f = await codexDelivery("draft-aware", { width: 160, height: 45 });
|
||||
f.hooks.load = () => { f.size.width = 65; f.size.height = 9; };
|
||||
expect(await f.send("resized", d1Envelope, f.options)).toMatchObject({ outcome: "retained", sent: false, inputPhase: "not_reached" });
|
||||
expect(f.writes).toEqual([]);
|
||||
});
|
||||
|
||||
it("retains a wrapped line which cannot stay visible without writing it", async () => {
|
||||
const f = await codexDelivery();
|
||||
expect(await f.send("wraps", "one-long-word-".repeat(40), f.options)).toMatchObject({ outcome: "retained", sent: false, inputPhase: "not_reached" });
|
||||
expect(f.writes).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps a concurrent real draft and the human-input epoch check", async () => {
|
||||
const f = await codexDelivery("draft-aware", { width: 160, height: 45 });
|
||||
f.hooks.load = () => f.guard.humanInput("a", async () => { f.bodies.a = "my real draft"; });
|
||||
expect(await f.send("human", d1Envelope, f.options)).toMatchObject({ outcome: "retained", sent: false,
|
||||
inputPhase: "not_reached", reason: "draft_input_changed" });
|
||||
expect(f.writes).toEqual([]);
|
||||
expect(f.bodies.a).toBe("my real draft");
|
||||
});
|
||||
});
|
||||
|
||||
describe("current draft-aware delivery through the real transport", () => {
|
||||
it.each(["ordinary request ", "1. Review the change"])("attempts each ordinary authored request, even with a reused caller ID: %j", async text => {
|
||||
const f = fixture(); await f.enable();
|
||||
|
||||
@@ -715,3 +715,62 @@ describe("Effort precedence, latest wins: a choice saved after the snapshot (OPR
|
||||
ctx.db.close();
|
||||
});
|
||||
});
|
||||
|
||||
// #2224: `rig seat set-model` saves the model on the seat's live node, and every managed resume reads it at call time,
|
||||
// so a restore from a snapshot taken before the change launches the saved model, and says so.
|
||||
describe("A model saved with rig seat set-model after the snapshot wins on restore (#2224)", () => {
|
||||
// Claude's resume takes the model sixth, Codex's seventh.
|
||||
const cases = [
|
||||
{ runtime: "claude-code", resumeType: "claude_name", before: "claude-fable-5-1", after: "claude-opus-5-5", modelArg: 5 },
|
||||
{ runtime: "codex", resumeType: "codex_id", before: "gpt-6.1-terra", after: "gpt-6.1-sol", modelArg: 6 },
|
||||
] as const;
|
||||
|
||||
async function restoreAfter(c: (typeof cases)[number], saved: string | null) {
|
||||
const ctx = setup();
|
||||
const rig = ctx.rigRepo.createRig(`model-${c.runtime}`);
|
||||
const node = ctx.rigRepo.addNode(rig.id, "analyst", { runtime: c.runtime, model: c.before, cwd: "/workspace" });
|
||||
const session = ctx.sessionRegistry.registerSession(node.id, `analyst@model-${c.runtime}`);
|
||||
ctx.sessionRegistry.updateStatus(session.id, "running");
|
||||
ctx.sessionRegistry.updateStartupStatus(session.id, "ready", new Date().toISOString());
|
||||
ctx.db.prepare("UPDATE sessions SET resume_type = ?, resume_token = 'tok-model' WHERE id = ?").run(c.resumeType, session.id);
|
||||
const snapshot = ctx.snapshotCapture.captureSnapshot(rig.id, "manual");
|
||||
expect(snapshot.data.nodes[0]!.model).toBe(c.before);
|
||||
if (saved) ctx.rigRepo.setNodeModel(node.id, saved);
|
||||
|
||||
const resume = vi.fn(async () => ({ ok: true, code: "resumed" }));
|
||||
const mockTmux = {
|
||||
createSession: vi.fn(async () => ({ ok: true })), killSession: vi.fn(async () => ({ ok: true })),
|
||||
listSessions: vi.fn(async () => []), hasSession: vi.fn(async () => false),
|
||||
sendText: vi.fn(async () => ({ ok: true })), sendKeys: vi.fn(async () => ({ ok: true })),
|
||||
listWindows: vi.fn(async () => []), listPanes: vi.fn(async () => []),
|
||||
getPaneCommand: vi.fn(async () => (c.runtime === "codex" ? "codex" : "claude")),
|
||||
} as any;
|
||||
const nodeLauncher = new NodeLauncher({ db: ctx.db, rigRepo: ctx.rigRepo, sessionRegistry: ctx.sessionRegistry, eventBus: ctx.eventBus, tmuxAdapter: mockTmux });
|
||||
const restoreOrch = new RestoreOrchestrator({
|
||||
db: ctx.db, rigRepo: ctx.rigRepo, sessionRegistry: ctx.sessionRegistry, eventBus: ctx.eventBus,
|
||||
snapshotRepo: ctx.snapshotRepo, snapshotCapture: ctx.snapshotCapture, checkpointStore: ctx.checkpointStore,
|
||||
nodeLauncher, tmuxAdapter: mockTmux,
|
||||
claudeResume: { canResume: vi.fn(() => c.runtime === "claude-code"), resume: c.runtime === "claude-code" ? resume : vi.fn() } as unknown as ClaudeResumeAdapter,
|
||||
codexResume: { canResume: vi.fn(() => c.runtime === "codex"), resume: c.runtime === "codex" ? resume : vi.fn() } as unknown as CodexResumeAdapter,
|
||||
});
|
||||
const result = await restoreOrch.restore(snapshot.id);
|
||||
ctx.db.close();
|
||||
return { ok: result.ok, launched: (resume.mock.calls[0] as unknown[] | undefined)?.[c.modelArg], text: JSON.stringify(result) };
|
||||
}
|
||||
|
||||
for (const c of cases) {
|
||||
it(`${c.runtime}: restore launches the saved model, not the snapshot's, and says so`, async () => {
|
||||
const restored = await restoreAfter(c, c.after);
|
||||
expect(restored.ok).toBe(true);
|
||||
expect(restored.launched).toBe(c.after);
|
||||
expect(restored.text).toContain(`this restore launched the seat's saved model ${c.after}, not ${c.before} from the snapshot`);
|
||||
});
|
||||
|
||||
it(`${c.runtime}: with no change since the snapshot, restore launches the snapshot's model and says nothing about it`, async () => {
|
||||
const restored = await restoreAfter(c, null);
|
||||
expect(restored.ok).toBe(true);
|
||||
expect(restored.launched).toBe(c.before);
|
||||
expect(restored.text).not.toContain("saved model");
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -7,7 +7,8 @@ export function provenCodexResume(token: string, opts: { panePid?: number; codex
|
||||
const panePid = opts.panePid ?? 900;
|
||||
const codexPid = opts.codexPid ?? 901;
|
||||
const startedAt = opts.startedAt ?? "Tue Oct 20 05:00:01 2026";
|
||||
const codex = { pid: codexPid, ppid: panePid, command: `codex resume ${token}`, pgid: codexPid, tpgid: codexPid, executableName: "codex", startedAt };
|
||||
const codex = { pid: codexPid, ppid: panePid, command: `codex resume ${token}`, argv: ["codex", "resume", token],
|
||||
pgid: codexPid, tpgid: codexPid, executableName: "codex", startedAt };
|
||||
return {
|
||||
getPanePid: vi.fn(async () => panePid),
|
||||
listProcesses: () => panePid === codexPid ? [{ ...codex, ppid: 1 }] : [
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface FakeProcess {
|
||||
startedAt: string;
|
||||
/** The command line, its arguments joined by spaces; empty for a kernel thread. */
|
||||
command: string;
|
||||
/** Exact cmdline arguments when a test needs argv boundaries. */
|
||||
argv?: string[];
|
||||
}
|
||||
|
||||
const platform = Object.getOwnPropertyDescriptor(process, "platform")!;
|
||||
@@ -26,13 +28,13 @@ export function writeFakeProc(processes: FakeProcess[]): string {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "fake-proc-"));
|
||||
roots.push(root);
|
||||
fs.writeFileSync(path.join(root, "stat"), "cpu 1 2 3\nbtime 0\n");
|
||||
for (const { pid, ppid, pgid, tpgid, name, startedAt, command } of processes) {
|
||||
for (const { pid, ppid, pgid, tpgid, name, startedAt, command, argv } of processes) {
|
||||
const ticks = Date.parse(startedAt) / 10;
|
||||
if (!Number.isInteger(ticks)) throw new Error(`not an lstart: ${startedAt}`);
|
||||
fs.mkdirSync(path.join(root, String(pid)));
|
||||
fs.writeFileSync(path.join(root, String(pid), "stat"),
|
||||
`${pid} (${name}) S ${ppid} ${pgid} ${pgid} 34816 ${tpgid} 4194560 1 0 0 0 0 0 0 0 20 0 1 0 ${ticks} 1000 200\n`);
|
||||
fs.writeFileSync(path.join(root, String(pid), "cmdline"), command ? `${command}\0` : "");
|
||||
fs.writeFileSync(path.join(root, String(pid), "cmdline"), argv ? `${argv.join("\0")}\0` : command ? `${command}\0` : "");
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import { tmpdir } from "node:os";
|
||||
import { delimiter, join } from "node:path";
|
||||
import { writeRuntimeStubs } from "../../../../test/stub-runtimes.js";
|
||||
import { shellQuote } from "../../src/adapters/shell-quote.js";
|
||||
|
||||
/**
|
||||
* Inherited environment variables that redirect the CLI/daemon client at a
|
||||
@@ -336,20 +337,14 @@ export function prepareHermeticEnv(opts: PrepareHermeticEnvOptions = {}): Hermet
|
||||
|
||||
// Test-only command seam: every child `tmux` call gets one explicit private
|
||||
// socket. TMUX_TMPDIR remains for compatibility, but is never the selector.
|
||||
// Exec directly so each terminal operation does not start another Node process.
|
||||
const originalPath = baseEnv.PATH ?? process.env.PATH ?? "";
|
||||
writeFileSync(
|
||||
tmuxWrapperPath,
|
||||
`#!/usr/bin/env node
|
||||
const { spawnSync } = require("node:child_process");
|
||||
const socketPath = ${JSON.stringify(tmuxSocketPath)};
|
||||
const originalPath = ${JSON.stringify(originalPath)};
|
||||
const env = { ...process.env, PATH: originalPath };
|
||||
const result = spawnSync("tmux", ["-S", socketPath, ...process.argv.slice(2)], { env, stdio: "inherit" });
|
||||
if (result.error) {
|
||||
console.error(result.error.message);
|
||||
process.exit(127);
|
||||
}
|
||||
process.exit(result.status ?? 1);
|
||||
`#!/bin/sh
|
||||
PATH=${shellQuote(originalPath)}
|
||||
export PATH
|
||||
exec tmux -S ${shellQuote(tmuxSocketPath)} "$@"
|
||||
`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
|
||||
Loaded 100 of 151 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user