mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-12 01:41:39 -10:00
Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42564fb1bf | ||
|
|
f48d5a7296 | ||
|
|
6f525ccbc7 | ||
|
|
8dac2356c0 | ||
|
|
7f8d59ba16 | ||
|
|
aae4395b25 | ||
|
|
25b8f05991 | ||
|
|
a093534871 | ||
|
|
65b466235e | ||
|
|
27d5d6d206 | ||
|
|
aa71304a7d | ||
|
|
492170b1b5 | ||
|
|
bf31a9229d | ||
|
|
9e58308c0c | ||
|
|
8df4672263 | ||
|
|
eb6a218010 | ||
|
|
559025c090 | ||
|
|
37e0715c58 | ||
|
|
80cfb7d01a | ||
|
|
f1a06c4f1b | ||
|
|
a7b8a27eb8 | ||
|
|
be2690179d | ||
|
|
a71ce16dd7 | ||
|
|
0834a16949 | ||
|
|
f1668d6907 | ||
|
|
89999f6ebf | ||
|
|
f3a3b1711b | ||
|
|
7886582f9d | ||
|
|
cf6d633667 | ||
|
|
8dfacd2604 | ||
|
|
4adc8872f2 | ||
|
|
94241e6c45 | ||
|
|
dfd4b6dbf4 | ||
|
|
c3abaaf00d | ||
|
|
8ac23f19e6 | ||
|
|
99c6ff5a08 | ||
|
|
d7d29b9d39 | ||
|
|
98260ad3aa | ||
|
|
4c8813b4b6 | ||
|
|
4aa38abf22 | ||
|
|
03850f2b6f | ||
|
|
04980d9f45 | ||
|
|
ceae26774e | ||
|
|
adb5555f08 | ||
|
|
aa75c28f58 | ||
|
|
86c7af6a76 | ||
|
|
898a342bd2 | ||
|
|
5f72f3934c |
No files matched your search
+28
-59
@@ -13,13 +13,12 @@ deprecations, and behavioral changes. Breaking changes are called out explicitly
|
||||
OpenRig 0.7.12 is a patch for people running their own agent teams, cut from
|
||||
main `16e8b7fb`. When you answer your team, by typing, clicking or qualifying
|
||||
an answer, the answer now goes to an agent that reads it with the question and
|
||||
decides. When no receiving agent is available, your answer waits. Setup's
|
||||
guidance asks your agent to offer to register you as the team's person, and
|
||||
registering works when you ask for it. `rig down` no longer stops a terminal it
|
||||
decides. Answers go to the configured receiving agent or kernel operator when
|
||||
available; otherwise they wait. Setup offers to register you as the team's
|
||||
person. `rig down` no longer stops a terminal it
|
||||
can't show belongs to the seat. There are fixes across delivery, Codex,
|
||||
restore, the queue, Slack and speed, many contributed by people using
|
||||
OpenRig. Two repairs aren't in this release, and some checks weren't run: see
|
||||
Known limits.
|
||||
OpenRig. Two repairs aren't in this release: see Known limits.
|
||||
|
||||
### Before you upgrade
|
||||
|
||||
@@ -27,8 +26,7 @@ Known limits.
|
||||
seat's own `OPENRIG_NODE_ID`, on the server and session it read. A terminal
|
||||
OpenRig didn't create, a mismarked one or a replaced one is left running, and
|
||||
`rig down` exits 2 naming it and how to stop it yourself. A seat joined with
|
||||
`rig attach` as an external CLI is listed as not stopped, since OpenRig never
|
||||
stops it; that alone doesn't make `rig down` exit 2. ([#2962](https://github.com/mvschwarz/openrig/pull/2962))
|
||||
`rig attach --self` is in that group. ([#2962](https://github.com/mvschwarz/openrig/pull/2962))
|
||||
- **openrig-core is 0.1.28.** A Codex session kept running across the upgrade
|
||||
can ask for hook approval until it's relaunched.
|
||||
|
||||
@@ -36,29 +34,15 @@ Known limits.
|
||||
|
||||
- Answers nothing else reads, such as a host pairing request or a mode's
|
||||
scheduled end, use the configured receiving agent or the kernel operator.
|
||||
Answers wait when no receiver is available, and nothing decides them
|
||||
meanwhile. To decide one yourself, use its own command: `rig host approve` or
|
||||
`rig host deny` for a pairing request, `rig seat decide-permission` for a
|
||||
runner's request. Closing an ask isn't permission, and words that were kept
|
||||
aren't a default permit. ([#2923](https://github.com/mvschwarz/openrig/pull/2923))
|
||||
Answers wait when no receiver is available. ([#2923](https://github.com/mvschwarz/openrig/pull/2923))
|
||||
- Pairing answers go to the receiving agent, and you can decide directly in the
|
||||
TUI's pairing settings. A pairing request says who it went to and who reads
|
||||
the answer. ([#2931](https://github.com/mvschwarz/openrig/pull/2931), [#2935](https://github.com/mvschwarz/openrig/pull/2935))
|
||||
- A runner's permission answer goes to the receiving agent, which decides while
|
||||
the runner waits, so a qualified answer such as "only if it's the test file"
|
||||
is read before anything acts. Your answer is kept and an explicit decision
|
||||
works, but the runner's confirmation updates to you aren't written in this
|
||||
release. Don't expect a confirmation in the Feed, a notice of the receiver's
|
||||
state or an immediate wake: the decision reaches the runner at its next read
|
||||
(up to about 21 seconds in testing). To decide one yourself:
|
||||
`rig seat decide-permission <askId> --option <id> --reason "…"`. This is the
|
||||
runner's path only: a pairing request says who reads the answer, as above. ([#2934](https://github.com/mvschwarz/openrig/pull/2934), [#2938](https://github.com/mvschwarz/openrig/pull/2938), [#2950](https://github.com/mvschwarz/openrig/pull/2950), [#2966](https://github.com/mvschwarz/openrig/pull/2966))
|
||||
- Setup's guidance asks your agent to offer to register you as the team's
|
||||
person. In testing the agent didn't reliably make that offer (0 of 5 runs).
|
||||
Registering works when you ask for it: `rig gateway human add`. Nothing is
|
||||
registered without your yes, an existing default person is kept, and no person
|
||||
is chosen automatically. The empty-registry hint is right. A team you start
|
||||
from your own assistant fits your own logins,
|
||||
is read before anything acts. ([#2934](https://github.com/mvschwarz/openrig/pull/2934), [#2938](https://github.com/mvschwarz/openrig/pull/2938), [#2950](https://github.com/mvschwarz/openrig/pull/2950), [#2966](https://github.com/mvschwarz/openrig/pull/2966))
|
||||
- Setup offers to register you as the team's person, and the empty-registry
|
||||
hint is right. A team you start from your own assistant fits your own logins,
|
||||
with a review before anything is committed. ([#2924](https://github.com/mvschwarz/openrig/pull/2924), [#2905](https://github.com/mvschwarz/openrig/pull/2905))
|
||||
|
||||
### Harnesses and connections
|
||||
@@ -96,9 +80,8 @@ Known limits.
|
||||
|
||||
- **Delivery and drafts:** Claude seats with a custom status line read idle and
|
||||
verify sends; a fresh idle hook doesn't type into a current Claude dialog;
|
||||
Claude's context hint and drawn cursor are read correctly; when the input
|
||||
can't be read, a deliberate override (`--dangerously-interact`) pastes with a
|
||||
warning and doesn't press Enter, so it isn't submitted; update notices beside the
|
||||
Claude's context hint and drawn cursor are read correctly; a deliberate
|
||||
override is delivered when the input can't be read; update notices beside the
|
||||
paste hint are recognized. ([#2807](https://github.com/mvschwarz/openrig/pull/2807), [#2813](https://github.com/mvschwarz/openrig/pull/2813), [#2947](https://github.com/mvschwarz/openrig/pull/2947), [#2942](https://github.com/mvschwarz/openrig/pull/2942), [#2933](https://github.com/mvschwarz/openrig/pull/2933), [#2800](https://github.com/mvschwarz/openrig/pull/2800), [#2786](https://github.com/mvschwarz/openrig/pull/2786))
|
||||
- **Codex:** when you answer a Codex choice prompt, a message waiting on it is
|
||||
delivered instead of escalated; a working counter is compared only within one pane process; hook
|
||||
@@ -131,9 +114,8 @@ Known limits.
|
||||
config accepts two keys the daemon already resolves; `rig whoami` says the
|
||||
team is still being built during `rig up`; rigs with `@` in the name find
|
||||
attention; a seat waiting on a person names its question; the installer
|
||||
reports PATH gaps with shell hints; daemon credentials stay out of a tmux
|
||||
server's environment from its next start (a server already running keeps its
|
||||
old environment until it restarts). ([#2914](https://github.com/mvschwarz/openrig/pull/2914), [#2969](https://github.com/mvschwarz/openrig/pull/2969), [#2955](https://github.com/mvschwarz/openrig/pull/2955), [#2949](https://github.com/mvschwarz/openrig/pull/2949), [#2891](https://github.com/mvschwarz/openrig/pull/2891), [#2888](https://github.com/mvschwarz/openrig/pull/2888), [#2883](https://github.com/mvschwarz/openrig/pull/2883), [#2850](https://github.com/mvschwarz/openrig/pull/2850))
|
||||
reports PATH gaps with shell hints; daemon credentials stay out of the tmux
|
||||
server's environment. ([#2914](https://github.com/mvschwarz/openrig/pull/2914), [#2969](https://github.com/mvschwarz/openrig/pull/2969), [#2955](https://github.com/mvschwarz/openrig/pull/2955), [#2949](https://github.com/mvschwarz/openrig/pull/2949), [#2891](https://github.com/mvschwarz/openrig/pull/2891), [#2888](https://github.com/mvschwarz/openrig/pull/2888), [#2883](https://github.com/mvschwarz/openrig/pull/2883), [#2850](https://github.com/mvschwarz/openrig/pull/2850))
|
||||
- **Pi and OMP:** typed edits submit in order on the affected Node releases
|
||||
(22.21.x to 22.22.0, 24.9.0 to 24.11.1, 25.0.0 to 25.1.0) instead of appending an
|
||||
insertion at the end. ([#2847](https://github.com/mvschwarz/openrig/pull/2847))
|
||||
@@ -167,42 +149,29 @@ Known limits.
|
||||
- **`rig down`:** a terminal it leaves running stays running until you stop it.
|
||||
It doesn't keep a stored host or boot identity, doesn't guard against another
|
||||
program running as you, and a daemon restart between losing the tmux server
|
||||
and the stop wasn't exercised. ([#2962](https://github.com/mvschwarz/openrig/pull/2962))
|
||||
and the stop wasn't exercised. For an adopted session, such as a seat joined
|
||||
with `rig attach --self`, `rig seat stop` still suggests `rig down`, which
|
||||
leaves that terminal running too. Release it with `rig unclaim <session>`
|
||||
(that leaves its process running), then end that process. If it runs in tmux,
|
||||
also end the underlying tmux session, including any remaining shell; closing
|
||||
only its viewing window leaves that session running. ([#2962](https://github.com/mvschwarz/openrig/pull/2962))
|
||||
- **Codex working counter:** a capture that doesn't name its pane keeps the old
|
||||
comparison, a restart that keeps both the pane and its process id isn't seen,
|
||||
and a leftover live frame can count once until it expires. ([#2980](https://github.com/mvschwarz/openrig/pull/2980))
|
||||
- **Custom blocker IDs** explain the wait, but their progress gives no event
|
||||
wake; the timer remains. ([#2983](https://github.com/mvschwarz/openrig/pull/2983))
|
||||
- **Delivery evidence** records what was known, not that the agent read the
|
||||
message; where the input ended up can read unknown. Events are best effort, so
|
||||
a missing event isn't proof nothing was sent. ([#2937](https://github.com/mvschwarz/openrig/pull/2937), [#2943](https://github.com/mvschwarz/openrig/pull/2943))
|
||||
- **Runner permission answers** were run end to end with a real receiving
|
||||
agent and the example runner, whose requesting agent was a stand-in; a
|
||||
model-driven runner waiting on a person's answer hasn't been run. ([#2934](https://github.com/mvschwarz/openrig/pull/2934))
|
||||
message; where the input ended up can read unknown. ([#2937](https://github.com/mvschwarz/openrig/pull/2937), [#2943](https://github.com/mvschwarz/openrig/pull/2943))
|
||||
- **Runner permission answers:** when a person answers a runner's permission
|
||||
request, the answer is kept, and an explicit decision works. In 0.7.12 the
|
||||
runner's confirmation updates fail to record, so no receipt or Feed
|
||||
confirmation appears, and the decision can reach the runner on its next read
|
||||
rather than at once (up to about 21 seconds in our test). To settle a request
|
||||
directly, run `rig seat decide-permission <request> --option <id>`. Pairing
|
||||
answers are separate, and their receipts are kept. Tested on one native daemon
|
||||
with the TUI and a runner driven by a stand-in agent. ([#2934](https://github.com/mvschwarz/openrig/pull/2934))
|
||||
- **herdr-app views** are one-shot; following isn't claimed. **Pull relay**
|
||||
needs a connector that implements pull. ([#2744](https://github.com/mvschwarz/openrig/pull/2744), [#2844](https://github.com/mvschwarz/openrig/pull/2844))
|
||||
- **A receiving agent you choose** (`agents.operator_session`) that runs in an
|
||||
explicit or auto permission mode gets no OpenRig allowance for
|
||||
`rig host approve` or `rig host deny`, so Claude's review can refuse them. A
|
||||
kernel receiver in auto mode is included. The default kernel with the default
|
||||
policy passed at its tested scope.
|
||||
- **A receiver that was stopped and then started** may not act on an answer that
|
||||
waited for it. In the tested case where it needed to ask you something back,
|
||||
the question appeared in its own conversation, not in the Feed or Steering.
|
||||
- **Delivery isn't fixed in general:** a footer row OpenRig doesn't recognize can
|
||||
still hold a delivery ([#2925](https://github.com/mvschwarz/openrig/issues/2925)), and a held message's full envelope can stay held;
|
||||
clearing a draft doesn't deliver it ([#2515](https://github.com/mvschwarz/openrig/pull/2515)).
|
||||
- **Not run for this release** (not run, not passed): the two-seat Codex journey
|
||||
with `AGENTS.md` restore; Codex account-home, upgrade and continuity checks;
|
||||
the OpenShell worker's second report; Pi model switches on real providers; ACP
|
||||
in a desktop app; Slack directory paging; some Slack setup cases (channel and
|
||||
thread precedence, a stopped receiver's delayed pickup, an inherited inbound
|
||||
default, a plain yes with no receiver); native checks of [#2786](https://github.com/mvschwarz/openrig/pull/2786), [#2799](https://github.com/mvschwarz/openrig/pull/2799), [#2807](https://github.com/mvschwarz/openrig/pull/2807)
|
||||
and [#2813](https://github.com/mvschwarz/openrig/pull/2813), with [#2947](https://github.com/mvschwarz/openrig/pull/2947) partly; a button-and-words answer, a non-pairing ask
|
||||
with an explicit owner, Steering's Approve page, and these answer and setup
|
||||
checks on a default model (the tested runs used smaller models);
|
||||
`--wait-for-kernel` ([#2888](https://github.com/mvschwarz/openrig/pull/2888)), the installed effect of [#2800](https://github.com/mvschwarz/openrig/pull/2800), and [#2720](https://github.com/mvschwarz/openrig/issues/2720) beyond one
|
||||
platform.
|
||||
- **From 0.7.11:** its known limits below still apply, except Codex hook setup
|
||||
with smol-toml 1.9.x ([#2720](https://github.com/mvschwarz/openrig/issues/2720)), fixed by [#2804](https://github.com/mvschwarz/openrig/pull/2804). 0.7.12 changes openrig-core
|
||||
again, so the Codex hook note there applies to this upgrade too. They weren't
|
||||
|
||||
@@ -26,9 +26,9 @@ use:
|
||||
|
||||
```sh
|
||||
# Claude Code
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.12/skills/rigs --skill rigs --agent claude-code
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.11/skills/rigs --skill rigs --agent claude-code
|
||||
# Codex
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.12/skills/rigs --skill rigs --agent codex
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.11/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.12` 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.11` 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.12/scripts/install.sh | sh -s -- --dry-run
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.12/scripts/install.sh | sh
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.11/scripts/install.sh | sh -s -- --dry-run
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.11/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
If the last step isn't ready, the installer reports `INSTALLED, SETUP NOT COMPLETE` and points to the details from `rig setup` above. Follow that output and any reported Fix, then rerun `rig setup`; the CLI does not need reinstalling. If the only remaining failures are provider sign-ins, sign in to each selected provider as below and continue.
|
||||
|
||||
+2
-2
@@ -6,9 +6,9 @@ This isn't a schedule or a promise: we release often, and an item ships when a g
|
||||
To see what's being worked on, by release and theme, open the [OpenRig roadmap board](https://github.com/users/mvschwarz/projects/1).
|
||||
Issues labelled `good first issue` are also listed on the [contribute page](https://github.com/mvschwarz/openrig/contribute).
|
||||
|
||||
## Released in 0.7.10
|
||||
## Released in 0.7.11
|
||||
|
||||
[0.7.10 is published](https://github.com/mvschwarz/openrig/releases/tag/v0.7.10): it is a patch for people running their own agent teams. A step held after a managed compaction now goes in instead of waiting forever. See its release notes for what shipped, known issues, upgrade instructions and contributor credits.
|
||||
[0.7.11 is published](https://github.com/mvschwarz/openrig/releases/tag/v0.7.11): OpenRig 0.7.11 is a patch for people running their own agent teams. An ordinary send or work notice now goes into a person's typed draft by default instead of being refused (see Behaviour that changed). See its release notes for what shipped, known issues, upgrade instructions and contributor credits.
|
||||
|
||||
## How to help
|
||||
|
||||
|
||||
@@ -140,7 +140,11 @@ that gateway side too. For the pane side, read:
|
||||
|
||||
- `lastNudgeWireResult`: the wake send's result as stored (for example
|
||||
`verified`, `delivered-ack-pending`, `indeterminate:…`, `retained:…`,
|
||||
`failed:…`). A claim never rewrites it; a later wake replaces it.
|
||||
`failed:…`). A claim never rewrites it; a later wake replaces it. Each stored
|
||||
form reads as one delivery fact (`readWakeOutcome`, `connection-contract.ts`).
|
||||
A result no reader recognises reads `unknown`, never delivered: the wake ladder
|
||||
treats it as an unconfirmed wake, which escalates past the window without a
|
||||
re-send.
|
||||
- `lastNudgeResult`: the result as presented. It is the stored result, except
|
||||
that a claimed row whose stored result is `delivered-ack-pending` or
|
||||
`indeterminate:…` reads `claimed` (`presentedNudgeResult`). Unclaiming shows
|
||||
@@ -175,8 +179,14 @@ The standing detector in `queue-stuck-sweep.ts` creates findings through the
|
||||
queue repository, with `evidenceRef: rig queue show <source-qitem-id>` pointing
|
||||
to the underlying durable work row. Unclaimed and undelivered findings go to
|
||||
the destination's orchestrator when one is derivable, otherwise to the
|
||||
destination itself, and that seat may be dead. Overdue claims go to their
|
||||
destination. Repeated detections reuse the existing finding without changing
|
||||
destination itself, and that seat may be dead. A wake that failed (`failed:…`)
|
||||
at a recorded agent seat with no `delegates_to` parent or `escalates_to` route is
|
||||
the exception: its finding tries the row's creator, then the operator agent
|
||||
(`agents.operator_session`), skipping an unset, confirmed-missing, self or
|
||||
unaccepted address, a bound terminal seat, and OpenRig's own machinery
|
||||
(`daemon@kernel` or a `@system` actor), so the finding's own wake isn't sent to the seat that just failed to
|
||||
take one. If neither can be used, it stays with the original seat and says why.
|
||||
Overdue claims go to their destination. Repeated detections reuse the existing finding without changing
|
||||
its route; when the source condition resolves, the sweep closes its own finding.
|
||||
Previously, falling out of a reporting query could itself close a delivery or
|
||||
unclaimed finding. An unconfirmed retry, a fresh human-notification post window,
|
||||
|
||||
@@ -75,20 +75,20 @@ in the rig's `attentionCount` and the TUI's attention totals.
|
||||
After a full restore of a Claude or Codex seat, answering the prompt is enough. When
|
||||
the seat's activity next turns to running or idle, or when the whole restore completes
|
||||
after the seat already reads running or idle, the daemon checks the live seat: its tmux
|
||||
session is alive, and the pane runs the agent resuming the conversation the seat
|
||||
records. When the check passes, the restore outcome becomes `operator_recovered`, so
|
||||
the restore no longer makes the seat need attention. When it fails, the restore outcome
|
||||
stays and the seat still needs attention, which is true when the pane runs another
|
||||
conversation. A check that started before a newer restore of the seat doesn't settle
|
||||
the newer outcome.
|
||||
session is alive, and the agent in its pane was started to resume the conversation the
|
||||
seat records. When the check passes, the restore outcome becomes `operator_recovered`, so
|
||||
the restore no longer makes the seat need attention. When the check fails, the restore
|
||||
outcome stays and the seat still needs attention. The check uses launch evidence; it
|
||||
does not prove which conversation is open now.
|
||||
|
||||
`rig seat clear-attention <seat>` runs the same check on demand. When it fails, the
|
||||
command says which check failed, and `--reason` doesn't bypass it. If
|
||||
the seat records the wrong conversation and you know the right one, set it with
|
||||
`rig seat set-resume-token <seat> --token-stdin --reason <text>`, then run
|
||||
`clear-attention` again. After a restore of only some seats, or a community
|
||||
harness's assisted startup, `--reason "<what you saw>"` can acknowledge the
|
||||
outcome instead.
|
||||
command says which check failed, and `--reason` doesn't bypass it. If the seat records
|
||||
the wrong conversation and the agent in its pane was started with the right one, set it
|
||||
with `rig seat set-resume-token <seat> --token-stdin --reason <text>`, then run
|
||||
`clear-attention` again; a token the running agent wasn't started with fails the same
|
||||
check. After a restore of only some seats, or a community harness's assisted startup,
|
||||
`--reason "<what you saw>"` can acknowledge the outcome instead, once any startup text
|
||||
left in the input box is submitted (not sent again) and the seat has responded.
|
||||
|
||||
## Two Categories of Startup
|
||||
|
||||
@@ -278,7 +278,10 @@ with an old Claude header or input frame above it and Claude already running. A
|
||||
input frame also keeps the check pending. If the current screen or process cannot be read, or the process identity is unconfirmed, the handover keeps
|
||||
waiting within the readiness window and fails if that window expires. Old screen text or the `claude` process label
|
||||
alone cannot complete this check. A healthy agent can therefore remain unconfirmed while those observations are
|
||||
unavailable; inspect its pane before deciding how to recover.
|
||||
unavailable; inspect its pane before deciding how to recover. When the launch was already sent, the failure's
|
||||
guidance names that pane and the result carries `replacementStarted` and `successorPane`: the previous agent was
|
||||
already ended, the new one may be working there without being registered as the seat's occupant, and another
|
||||
handover or `rig seat launch <seat> --fresh --stop --reason <why>` ends it if it goes ahead.
|
||||
|
||||
**Claude submission check.** For each startup message to a Claude seat, OpenRig makes a bounded check that the prompt
|
||||
was submitted. When the capture can't tell, it records the submission as unverified and carries on. When it sees the
|
||||
@@ -402,6 +405,11 @@ Each startup file and action specifies when it applies:
|
||||
- a launch replaces it, as launches always did, and names it (`guidance_block_refreshed_unrecorded`);
|
||||
- an exact resume or a handover leaves it, and names it as possibly stale (`guidance_block_may_be_stale`).
|
||||
|
||||
**A file keeps one copy of each block.**
|
||||
- **Touched markers:** a marker an editor or formatter rewrapped, with spaces or line breaks inside the comment, still reads as its block, and the next write puts it back in OpenRig's form.
|
||||
- **Collected copies:** they're reduced to the first. Beside a copy you edited, OpenRig's own unedited copies and exact repeats are removed, and every different edited copy stays (`guidance_block_copies_removed`).
|
||||
- **A marker that lost its pair:** OpenRig can't tell where that block starts or ends, so it adds no copy beside it and names it (`guidance_block_markers_damaged`). Put back the missing marker or remove the partial block, and OpenRig's next write keeps one block. `rig package install` leaves such a file as it is, names it in its result (`notes`), and installs the rest. A marker without its pair beside a complete block is named too (`guidance_block_marker_unpaired`), and the block is written.
|
||||
|
||||
An exact resume doesn't type anything into the resumed conversation. So there, the restore result names the block (`guidanceNotes`), but the seat isn't told.
|
||||
|
||||
Use this to avoid re-sending context that a fresh conversation doesn't need twice. For example, a one-time project briefing might only apply on `fresh_start`, while identity grounding should apply on both, so a seat that comes back fresh gets it again.
|
||||
|
||||
@@ -13,7 +13,9 @@ provider usage stop updating, and Claude resume-token capture at adoption or
|
||||
handover loses its current session evidence. An earlier sidecar can remain and
|
||||
contain an older session ID; without one, context and capture report
|
||||
`missing_sidecar`, and provider usage reports `no_statusline_cache_yet` unless an
|
||||
earlier cache remains.
|
||||
earlier cache remains. A cache that exists but cannot be read reports
|
||||
`claude_statusline_cache_unreadable`; a readable cache that carried no rate
|
||||
limits reports `claude_statusline_cache_had_no_windows`.
|
||||
|
||||
## Feed both commands the same input
|
||||
|
||||
@@ -84,11 +86,23 @@ so its rate-limit hook is attributed to that seat too. This is appropriate when
|
||||
the helper uses the same login, since the quota belongs to the account. A
|
||||
helper on a different login is attributed the same way, so its rate limit can
|
||||
hold pending handoffs to every seat in that seat's Claude pool for one retry
|
||||
interval, even when the seat's own login still has room. This attribution does
|
||||
not bind the hook to Claude's own native session.
|
||||
interval, even when the seat's own login still has room. A reset time the
|
||||
helper's message states is not used: OpenRig keeps a stated reset only when the
|
||||
hook's Claude `session_id` is the seat's own resume token, so a helper never
|
||||
holds the pool past that one interval.
|
||||
|
||||
The event carries no usage percentage, account identity or reset timestamp.
|
||||
It marks the Claude provider pool as limited and preserves a current
|
||||
The event carries no usage percentage or account identity. When the seat's own
|
||||
Claude states when the limit resets (for example `resets Nov 10, 4pm
|
||||
(America/Sao_Paulo)`, `resets in 2h 30m`, or the older `usage limit
|
||||
reached|<epoch>`), the hook relay reads that time on the seat's machine and
|
||||
forwards only the resulting timestamp (`resetsAt`), never the message text. It
|
||||
reads the reset only after the typed `rate_limit` failure; the text never
|
||||
decides that a seat is limited. A reset it can't read completely (an unknown or
|
||||
truncated time zone, an unsupported unit, a date that doesn't exist such as Feb
|
||||
30) or that is in the past or more than eight days away gives no reset. The
|
||||
daemon applies the same eight-day bound to any sender.
|
||||
|
||||
The event marks the Claude provider pool as limited and preserves a current
|
||||
`agentActivity.reason` of `usage_limit` over a cached idle reading. It does not
|
||||
count as a request for human input; a newer permission prompt still takes
|
||||
precedence in the seat's activity display. Conflicting seat windows retain the
|
||||
@@ -99,13 +113,15 @@ Pending work handled by the wake ladder uses the existing local Claude pool
|
||||
declare different Claude config directories. If reset windows conflict within
|
||||
a proposed shared pool, only the seats reporting exhaustion are held; another
|
||||
seat's pending handoffs can still retry. An exhausted status line window with
|
||||
a known reset supplies the timer. Otherwise a typed failure uses
|
||||
a known reset supplies the timer, and so does a reset the seat's own typed
|
||||
failure stated (`provider-reset`). Otherwise a typed failure uses
|
||||
`queue.wake_retry_interval_seconds` (default 300) from the event time, plus the
|
||||
existing wake jitter, and records `config-fallback`. That is a retry time, not a
|
||||
claim that the provider's quota will reset then. The timer releases the shared
|
||||
blocker once and retains the existing durable wake delivery behavior.
|
||||
|
||||
Authentication, billing and overloaded errors are not classified as usage
|
||||
limits. Claude limit banners are not scraped, and older runtimes or failures
|
||||
that do not emit this hook still depend on status line data. Without either
|
||||
limits. Claude limit banners are not scraped from the terminal, and older
|
||||
runtimes or failures that do not emit this hook still depend on status line
|
||||
data. Without either
|
||||
source, provider usage remains unknown.
|
||||
@@ -201,11 +201,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.12/scripts/install.sh | sh -s -- --dry-run
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.11/scripts/install.sh | sh -s -- --dry-run
|
||||
```
|
||||
|
||||
The same command without `--dry-run`,
|
||||
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.12/scripts/install.sh | sh`, installs the
|
||||
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.11/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
|
||||
@@ -342,9 +342,21 @@ startup reconcile leaves when every kernel terminal is gone, for example after a
|
||||
reboot. It does not detect a reboot as such. It uses the same restore as
|
||||
`rig up kernel --existing`, and `rig status` reports it as `Kernel: booting`
|
||||
until the seats are ready. A kernel stopped with `rig down kernel`, including
|
||||
one whose seats were already detached, stays down on later starts. So do a kernel
|
||||
with only some seats stopped and seats you unclaimed. Start one again with
|
||||
`rig up kernel --existing`.
|
||||
one whose seats were already detached, stays down on later starts; start it again
|
||||
with `rig up kernel --existing`. A seat you stopped or unclaimed also stays down.
|
||||
While the kernel's other seats are running, `rig up kernel --existing` refuses
|
||||
(it restores only a kernel with no live session), so start one stopped seat with
|
||||
`rig launch kernel <logical id>` (`--plan` first). It restores that seat from the
|
||||
kernel snapshot that restore ranks first: the newest usable automatic one (taken
|
||||
before `rig down`, on the periodic schedule, or at a runtime switch), even when a
|
||||
manual one is newer, and otherwise the newest usable one. `--plan` names the
|
||||
snapshot it picked and any newer usable one, and `--snapshot-id <id>` picks one
|
||||
yourself. Under the default restore policy, which the kernel's seats keep, it
|
||||
resumes the seat's saved conversation; a seat it can't resume is reported as
|
||||
waiting for a decision, not as started.
|
||||
`rig seat launch <seat> --fresh --reason "…"` instead starts a new conversation
|
||||
with no continuity source: use it only when you want a fresh occupant, since the
|
||||
seat's earlier conversation isn't carried.
|
||||
When the tmux server is gone, an explicit `rig down` of any rig can start an
|
||||
empty server and probe again. It records the stop and clears eligible bindings
|
||||
only after confirming the sessions are absent. This can leave an empty tmux
|
||||
@@ -866,6 +878,9 @@ What it costs:
|
||||
## Stop your teams
|
||||
|
||||
`rig down <rig-name>` ends that rig's agent sessions and work in progress.
|
||||
A terminal OpenRig didn't start, such as a seat joined with `rig attach --self`,
|
||||
is left running: `rig down` names it with the commands to stop it by hand, and
|
||||
exits 2.
|
||||
Read `rig ps --nodes --rig <rig-name>` first so you can see who will stop;
|
||||
`rig down` also prints the rig's recorded agent sessions and their activity
|
||||
before stopping them.
|
||||
@@ -895,7 +910,7 @@ the lifecycle API without booting a new kernel, then stop the remaining rigs.
|
||||
| --- | --- |
|
||||
| Tool missing or login fails | Use the specific setup/auth hint; recheck that executable in the launch shell. Do not send work to an unready seat. |
|
||||
| "Startup attention" or "Startup details" names a prompt | Answer the login, trust or bypass prompt in that seat's terminal, then run the `rig seat continue <seat>` it shows. |
|
||||
| A restored seat still counts as needing attention after its prompt was answered | Its restore outcome is still `attention_required`, and answering the prompt or running `rig seat continue` doesn't change it. Once the agent is up in its pane, run `rig seat clear-attention <seat>`; see [After a restored seat's prompt](agent-startup-guide.md#after-a-restored-seats-prompt). |
|
||||
| A restored seat still counts as needing attention after its prompt was answered | After a full restore of a Claude or Codex seat, the daemon clears this by itself once the seat reads running or idle and passes its check. If it still counts, check its `restoreOutcome` in `rig ps --nodes --json` (the RESTORE column): when it is `attention_required`, run `rig seat clear-attention <seat>`, which says which check failed; answering the prompt again or running `rig seat continue` doesn't change it. For a partial restore, submit any startup text left in the input box without sending it again, and wait for the agent to respond before clearing attention. See [After a restored seat's prompt](agent-startup-guide.md#after-a-restored-seats-prompt). |
|
||||
| "Readiness timeout after 30s — harness did not become interactive" | The harness took longer than the readiness window. Raise it for the next launch with `rig config set runtime.readiness_timeout_seconds <1-600>`, then relaunch that seat. |
|
||||
| "Claude resume did not finish within Ns; Claude may still be starting, so the launch was kept" | A restored Claude seat was not interactive when the wait ended. Its pane and resume token are kept. Look at the pane; once Claude is up, run `rig seat clear-attention <seat>`. The wait is about `runtime.readiness_timeout_seconds` in total for each seat (default 30 s), counting the first 3.2 s, and the time adds up seat by seat in a restore. Raising the setting for slow fresh launches lengthens it. If `rig up` or `rig launch` times out at 120 s, that is not a rollback: check `rig ps` before running it again. |
|
||||
| Daemon is healthy, kernel is still starting | Read `rig status` and `rig ps --nodes --rig kernel`; kernel readiness is separate. |
|
||||
|
||||
@@ -79,8 +79,9 @@ Other layouts and other herdr-app windows don't show Rig views. Each window (app
|
||||
- Entering a view again (a click in the strip, or herdr-app restarting with it shown) returns the keyboard to the cell
|
||||
you were last in there, else the first live cell.
|
||||
- **Your tabs** gives the keyboard back to your own pane.
|
||||
- OpenRig's own rewrites (another view opening or closing, a cell ending) never move the keyboard, whether you are in
|
||||
a view or in your tabs.
|
||||
- Apart from an open, OpenRig's own rewrites (another view closing, a cell ending) leave the keyboard where it is,
|
||||
whether you are in a view or in your tabs, with one exception: when the cell you were last in ends while its view is
|
||||
shown, the keyboard goes to that view's first live cell, if one is left.
|
||||
- Once the view is shown, an interactive cell for a local seat attaches its tile to the seat with plain tmux, as a
|
||||
writable tile does ([terminal sessions](terminal-sessions.md#replacing-a-seats-session)). Until then it only shows
|
||||
the seat, and anything typed in it is dropped. A cell for a seat on another host is its ssh attachment from the
|
||||
|
||||
@@ -78,14 +78,17 @@ entry. The entry holds secret names, never values:
|
||||
- It's a pointer, not the row's body: the member reads the row under its grant.
|
||||
- A receiver should tolerate a repeated `qitem_id`. A ring accepted just before the daemon stops, before it's recorded,
|
||||
is rung again on recovery, the same at-least-once window a terminal wake has.
|
||||
- It waits up to 10 seconds and doesn't follow redirects.
|
||||
- It waits up to 10 seconds and doesn't follow redirects. A ring with no answer by then may still have reached the
|
||||
trigger, so it reads unknown, not failed, and durable recovery doesn't repeat that recorded wake (other recovery paths
|
||||
can still ring it again). The member sees the work when it next checks its rows.
|
||||
- **What the trigger should do:** start a run that lists the member's rows (`rig_queue_list` with `owned`) and claims its work.
|
||||
|
||||
## What the record says, and doesn't
|
||||
|
||||
These facts stay separate on the row:
|
||||
- **Wake:** with a trigger, the row's wake result is `wake-accepted:webhook (<url_ref>) answered <status>` when the trigger took the
|
||||
ring, or `failed:webhook …`. Accepted means the tool's trigger answered, not that the member read the row.
|
||||
ring, `indeterminate:webhook …` when it didn't answer in time, or `failed:webhook …`. Accepted means the tool's trigger
|
||||
answered, not that the member read the row.
|
||||
- With no trigger, the result is `unroutable`. The member finds work by reading its own inbox and picks a row up by claiming
|
||||
it, so a row gets no finding of its own. Work it leaves unclaimed past the stuck sweep's unclaimed age
|
||||
(`queue.stuck_sweep_unclaimed_age_minutes`, 60 by default) is listed in one standing notice per member. The notice goes to
|
||||
|
||||
@@ -296,3 +296,31 @@ Unknown: not said yet.
|
||||
delivery guarantee.
|
||||
- **Transcript bytes as "usage".** `context-usage-threshold` measures an agent's transcript, not an account's
|
||||
allowance. For an allowance, see "A usage warning" above.
|
||||
|
||||
## Recurring reminders for agents
|
||||
|
||||
An agent-to-agent reminder can repeat until stopped. This is separate from
|
||||
[a person's one-shot watch](#setting-a-watch), which uses `rig queue block --wake-after`.
|
||||
|
||||
Save this as `periodic-reminder.yaml`, replacing the example session names with your own:
|
||||
|
||||
```yaml
|
||||
policy: periodic-reminder
|
||||
target:
|
||||
session: worker@my-team
|
||||
message: "Finish the current action, then check the pending work."
|
||||
```
|
||||
|
||||
Register it with all five required flags:
|
||||
|
||||
```bash
|
||||
rig watchdog register --spec periodic-reminder.yaml --policy periodic-reminder \
|
||||
--target-session worker@my-team --interval-seconds 600 \
|
||||
--registered-by coordinator@my-team
|
||||
```
|
||||
|
||||
At registration, `--policy` selects the policy; the YAML `policy` field is not the
|
||||
selector. YAML `target.session` takes precedence over `--target-session`, so keep them
|
||||
aligned. If YAML omits the target, the CLI target is the fallback. Put a non-empty
|
||||
`message` in YAML: `--message` is a shortcut only for `context-usage-threshold`.
|
||||
The first reminder is due after the interval; this example repeats every 600 seconds.
|
||||
@@ -0,0 +1,114 @@
|
||||
# Network connector: a local seat as a hub participant
|
||||
|
||||
Source of truth: `packages/daemon/src/domain/network/` (`network-bindings.ts`, `network-connector.ts`, `hub-client.ts`),
|
||||
`packages/daemon/src/routes/network.ts`, `packages/cli/src/commands/network.ts`.
|
||||
|
||||
A hub is another OpenRig queue that its participants reach over MCP, such as a daemon served by `rig mcp serve --http`
|
||||
with one grant per participant. The network connector puts one selected local seat on a hub as one of its participants.
|
||||
The connector opens no port: your daemon makes every connection outward, and the hub never connects to your machine. Work the hub
|
||||
sends that participant runs on your seat, under your accounts, and the result goes back to the hub item.
|
||||
|
||||
## Connect
|
||||
|
||||
```bash
|
||||
rig network connect <seat> --hub <url> --as <participant> --token-file <path>
|
||||
```
|
||||
|
||||
- `<participant>` is the participant's address in the hub (`member@rig`), and the token file holds its bearer.
|
||||
- The binding follows the seat's node, so a relaunched or renamed seat keeps it.
|
||||
- The token is stored at `<OpenRig home>/network-secrets/<binding>/credential.json` (a 0700 folder and a 0600 file). It
|
||||
is never returned by the API or shown in status. A token in the hub URL is refused.
|
||||
- A plain-http hub URL needs `--allow-http` (see Limits).
|
||||
- One active binding per local seat, and one per hub participant.
|
||||
- On a daemon with an operator bearer configured, `connect` and `disconnect` send `OPENRIG_AUTH_BEARER_TOKEN`, as the
|
||||
`rig host` writes do.
|
||||
|
||||
## What the connector does
|
||||
|
||||
The daemon runs the connector for every active binding. It normally polls every 30 seconds
|
||||
(`OPENRIG_NETWORK_POLL_SECONDS`, at least 5); a failing hub, backoff or a backlog can delay it. Each binding's pass has
|
||||
a deadline of 60 seconds, so one slow hub doesn't hold the others. With no active binding it makes no network call.
|
||||
|
||||
1. It lists the participant's own items on the hub, claims each pending one, and creates a local queue item for the
|
||||
seat's current address. The local item comes from OpenRig itself, and it says where the work came from first: its
|
||||
summary starts "From <requester> via network hub", and its body opens with the requester, the hub and the hub item,
|
||||
before the requester's own words.
|
||||
2. It reports each stage on the hub item, kept apart:
|
||||
- **Queued:** the hub claim means the connector holds the item. The note says "Not picked up yet."
|
||||
- **Picked up:** once the seat claims the local item.
|
||||
- **The result:** once the local item closes, following any local handoffs to the item that finished. The hub item
|
||||
closes the way its queue closes one: done with the resolution and the closure reason (`no-follow-on`, `canceled`
|
||||
or `denied`), or failed when the local item failed. A close with no resolution sends the closer's own note on that
|
||||
close, or "Closed without a stated result." No other note on the item is sent.
|
||||
|
||||
To answer, close the local item with your result:
|
||||
|
||||
```bash
|
||||
rig queue update <local item> --state done --closure-reason no-follow-on --resolution "<your answer>"
|
||||
```
|
||||
|
||||
## Retries, restarts and unknown outcomes
|
||||
|
||||
- Each hub item maps to one local item, under an id derived from the seat's node, the hub, the participant and the hub
|
||||
item. A lost answer, a retry, a daemon restart, or disconnecting and connecting the same seat to the same hub and
|
||||
participant again lands on that same item, so it isn't queued twice. A new binding reports what the earlier one
|
||||
took.
|
||||
- If the local record of an item is gone, or it was handed on without one successor here, the hub item says its outcome
|
||||
is unknown, and it is never run again.
|
||||
- If someone else closed the hub item first (its requester canceled it, say), the local result stays here, the connector
|
||||
doesn't count it as delivered, and `rig network status` (and its `--json`) shows it under the binding as closed
|
||||
elsewhere. The connector checks the hub item just before closing it. It does not write over a close it has already
|
||||
seen, and reads the item back after a close attempt. A hub item someone there handed on counts as closed elsewhere
|
||||
too.
|
||||
- The derived id makes the queue item unique, not the work's outside effects. Anything your agent did outside OpenRig
|
||||
before a crash is neither undone nor deduplicated.
|
||||
|
||||
## Status
|
||||
|
||||
```bash
|
||||
rig network status [--json]
|
||||
```
|
||||
|
||||
Each binding shows its seat, participant, hub, state, and its last contact or last error. `--json` adds the
|
||||
connector's view: each binding's next pass, backoff and last pass.
|
||||
|
||||
## When things go wrong
|
||||
|
||||
- **The seat has no session:** its hub items stay pending and unclaimed, and the last error says why. They're taken
|
||||
once the seat has a session again. A stopped seat still has its session, so it still takes items; they wait for it.
|
||||
- **The hub can't be reached, or a pass runs past its deadline:** the binding stays active, the error is recorded, and
|
||||
retries back off, doubling up to 5 minutes. A failure on this machine is recorded as local, not as the hub's.
|
||||
- **The hub refuses the credential** (HTTP 401): the binding is marked revoked and its credential removed. No new work
|
||||
comes in; local items and results stay.
|
||||
|
||||
## Disconnect
|
||||
|
||||
```bash
|
||||
rig network disconnect <binding-or-seat> [--reason "<why>"]
|
||||
```
|
||||
|
||||
The binding ends here, so no new work comes in through it, and its credential is removed. Local items and results
|
||||
stay. The hub still lists the participant until it's removed there too.
|
||||
|
||||
If the credential can't be removed, the binding still ends, and the error names the
|
||||
`rig network disconnect <binding id>` to run again. The daemon also removes credentials whose binding isn't active
|
||||
each time it starts.
|
||||
|
||||
## Limits
|
||||
|
||||
- The connector polls, so new work normally waits up to one interval before it reaches the seat, and longer when the
|
||||
hub fails or a backlog builds.
|
||||
- If someone else closes a hub item between the connector's check and its close, the hub may keep either outcome, or
|
||||
combine the later state with the earlier resolution. The connector counts its result as delivered only when the
|
||||
readback matches its state, closure reason and resolution; otherwise a closed item is reported as closed elsewhere.
|
||||
An atomic close-if-open needs the hub to offer a conditional close.
|
||||
- After a disconnect or a revocation, work already taken keeps running here, but its pickup and result no longer reach
|
||||
the hub item, which stays at its last reported stage.
|
||||
- **Plain http needs your choice.** `rig network connect` refuses an `http:` hub URL until you add `--allow-http`, because
|
||||
the participant's token and its work then cross the network unencrypted, where they can be read. Use the hub's
|
||||
`https:` URL where it has one. The choice is recorded on that binding, so the connector doesn't ask again, and
|
||||
`rig network status` (and its `--json`) marks the binding unencrypted. OpenRig doesn't judge the address: a private or
|
||||
tailnet address doesn't prove the connection is protected.
|
||||
- **Redirects:** the connector follows a hub's redirect only within its own origin, or to its `https:` form on the same
|
||||
host. Any other redirect stops the connection with its target named, and the token isn't sent there. Connect the
|
||||
binding to the hub's own URL.
|
||||
@@ -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.12`. If OpenRig is already installed, use the tag
|
||||
For a new install, use the latest release's tag, for example `v0.7.11`. 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.
|
||||
|
||||
@@ -1088,6 +1088,20 @@ and returns `edge_not_found` (HTTP 404, exit 1), so a repeated removal reports n
|
||||
Rig names resolve only among unarchived rigs, and an ambiguous name requires an
|
||||
explicit ID. These commands target the local daemon, as `rig add` does.
|
||||
|
||||
Managed Claude compaction also uses the delegation parent for two diagnostic notices: repeated
|
||||
unsent preparation attempts and repeated restore-delivery failures. If the seat has no recorded
|
||||
parent session (including when it is outside the recorded topology), these notices go to the
|
||||
configured registered human operator, or the default registered person when no operator is set.
|
||||
If no registered person can be selected, OpenRig creates no notice rather than inventing a
|
||||
destination: `rig compact <seat> --state` reports a restore-delivery notice failure, while a
|
||||
preparation notice is tried again on later polls. A parent is the source of a `delegates_to` edge
|
||||
to the seat; declare one when an agent should receive these notices. With multiple parents, the
|
||||
parent session with the latest creation time is selected; `escalates_to` is not used for these
|
||||
notices. The restore-delivery notice to a person omits local paths and
|
||||
points to `rig compact <seat> --state` on the host; the preparation notice includes its state-file
|
||||
evidence reference and attempt details. Treat these notices and any chat relay accordingly. Existing
|
||||
notices retain their destination when the topology changes.
|
||||
|
||||
A `delegates_to` edge also decides routing. A seat's stuck-row findings and recovery go to its most
|
||||
recently started parent. A person's top-level Slack message in the rig's mapped channel goes to the rig's
|
||||
representative, its one delegation root, unless that channel names an `inbound` seat; with no single root it
|
||||
|
||||
@@ -109,7 +109,10 @@ To move a team that runs on an older server:
|
||||
shares your default server, which runs the new version only after every session
|
||||
on it, yours included, has ended.
|
||||
3. Stop each rig deliberately with `rig down <rig>`. Its seats' sessions end, and a
|
||||
turn in progress stops. Queue rows, rigs and seats stay as they are.
|
||||
turn in progress stops. Queue rows, rigs and seats stay as they are. A session
|
||||
OpenRig didn't start, such as a seat joined with `rig attach --self`, is left
|
||||
running and `rig down` exits 2; stop it with the commands it prints, or it stays on
|
||||
the old server.
|
||||
4. Restart the daemon with `OPENRIG_TMUX_CONTROL=1` in its environment.
|
||||
`rig terminal status --json` should show `migrationPending: false`. If it's still
|
||||
`true`, `blockingSessions` names the sessions still on the old server.
|
||||
|
||||
+178
-723
@@ -1,327 +1,94 @@
|
||||
# What changed in OpenRig 0.7.12
|
||||
# What changed in OpenRig 0.7.1
|
||||
|
||||
This note is for an agent on an install that was just upgraded to 0.7.12.
|
||||
OpenRig 0.7.12 routes a person's typed, clicked or qualified answers with the
|
||||
question to the configured receiving agent or the kernel operator to decide.
|
||||
Answers wait when no receiver is available. Setup's guidance asks the agent to
|
||||
offer to register the person, which it didn't reliably do in testing. `rig down` stops
|
||||
only terminals it can show belong to the seat. It adds five database
|
||||
migrations. If you upgraded from 0.7.10 or earlier, also read "What changed in
|
||||
OpenRig 0.7.11" below, and so on back. For the full capability map, read
|
||||
`rig context get onboarding-width/public-what-you-can-do.md`. After the upgraded
|
||||
daemon starts, `rig context get reference/whats-new.md` describes this version.
|
||||
Without a running daemon, read the packaged `daemon/docs/reference/whats-new.md`.
|
||||
This note is for an agent on an install that was just upgraded to 0.7.1. It lists only what behaves differently from
|
||||
0.7.0 and what to do about it. For everything OpenRig can do, read the capability map:
|
||||
`rig context get onboarding-width/public-what-you-can-do.md`. Each release replaces this file, so
|
||||
`rig context get reference/whats-new.md` describes the installed version once the upgraded daemon is running. Without
|
||||
a running daemon, read `$OPENRIG_HOME/reference/whats-new.md`. The daemon refreshes that copy when it starts, so right
|
||||
after an upgrade it can still hold the previous version's note.
|
||||
|
||||
## After the upgrade
|
||||
|
||||
- **Back up the database, then restart the daemon.** Five migrations run on the
|
||||
daemon's first start: 155, 156 and 157 (queue erasure and its batches), 158
|
||||
(queue inbox check-ins) and 159 (an index for handed-off rows). If
|
||||
`$OPENRIG_DB` isn't backed up yet, copy that file (`rig config get db.path`
|
||||
shows it) with the daemon stopped, then `rig daemon start`. Going back to
|
||||
0.7.11 after these migrations isn't established.
|
||||
- **New settings:** `retention.closed_content_days` (`0`, keeps closed work's
|
||||
content), `retention.closed_metadata_days` (`0`) and `retention.erasure_ledger`
|
||||
(empty, no ledger). Erasing closed work is opt-in. `rig config` shows each with
|
||||
its source.
|
||||
- **The openrig-core plugin moves from 0.1.27 to 0.1.28.** Its activity relay
|
||||
and refocus hooks changed (with a new `refocus-state.cjs`), as did the
|
||||
agent-operated-software, claude-compaction-restore, openrig-user,
|
||||
retiring-and-inheriting-a-seat, seat-continuity-and-handover and
|
||||
session-compaction-and-restore skills. A Codex session kept running across the
|
||||
upgrade can ask for hook approval until it's relaunched.
|
||||
- **`rig down` now leaves some terminals running.** It stops only a tmux session
|
||||
carrying the seat's own `OPENRIG_NODE_ID`, on the server and session it read.
|
||||
For a terminal OpenRig didn't create, or a mismarked or replaced one, it exits
|
||||
2 and names the terminal and how to stop it. A seat joined with `rig attach` as
|
||||
an external CLI is listed as not stopped, which alone doesn't make it exit 2.
|
||||
Tell the person, and stop those by hand if they're theirs to stop.
|
||||
([#2962](https://github.com/mvschwarz/openrig/pull/2962))
|
||||
- **Restart the daemon** (`rig daemon stop`, then `rig daemon start`). Eight migrations run on the first start (124, 125,
|
||||
127, 128, 129, 130, 131 and 135). Nine settings are added: `policies.claude_compaction.preparation_root` (unset),
|
||||
`skills.router_line.enabled` (off), `decisions.enabled` (false), `decisions.consumers`, `decisions.template_dir`,
|
||||
`retention.stream_days` (30), `retention.stream_max_items` (20000), `provider.selected` (empty) and
|
||||
`daemon.db_conflict` (`warn`). None is removed.
|
||||
- **Stream retention is on.** Stream items emitted after the upgrade are deleted after 30 days, or beyond the newest
|
||||
20,000, unless a record names them (a classification, duplicate link, attempt or disposition). History from before the
|
||||
upgrade is kept. Raise `retention.stream_days` or `retention.stream_max_items` to keep more.
|
||||
- **The bundled openrig-core plugin goes from 0.1.17 to 0.1.18.** Startup replaces the installed plugin's changed files:
|
||||
its Claude hook definitions, three hook scripts and the pre-compaction hook (so hooks start on a seat whose PATH has
|
||||
no Node), and the `openrig-user`, `messaging-the-human` and `forming-an-openrig-mental-model` skills. As in
|
||||
0.7.0, it first copies each file it replaces under `$OPENRIG_HOME/backups/managed-refresh/<id>/`, edited or not, and
|
||||
logs `[plugin-vendor] preserved previous contents of <file> at <copy> before managed refresh`. A copy doesn't mean
|
||||
anyone edited the file; if someone did, reapply the change from the copy.
|
||||
- **Claude seats need Claude Code 2.1.280 or newer to see their team skills,** which now arrive as a per-seat plugin
|
||||
through `CLAUDE_CODE_PLUGIN_DIRS` (below). OpenRig doesn't check the version. A running seat gets the new layout at its
|
||||
next launch, restore or handover.
|
||||
- **If a worktree's `.claude/settings.local.json` says `"defaultMode": "acceptEdits"` and the person didn't choose it,**
|
||||
an earlier OpenRig wrote it when it added or freshly launched a managed Claude seat. 0.7.1 no longer writes it but
|
||||
doesn't repair it; ask the person which mode they want and set it once.
|
||||
|
||||
## Answers from a person reach an agent
|
||||
## New things you can do
|
||||
|
||||
- Answers nothing else reads, such as a host pairing request or a mode's
|
||||
scheduled end, use the configured receiving agent or the kernel operator.
|
||||
Answers wait when no receiver is available, and nothing decides them
|
||||
meanwhile; a direct decision uses the request's own command (`rig host approve`
|
||||
or `rig host deny`, `rig seat decide-permission`). An ordinary close isn't
|
||||
permission, and kept words aren't a default permit. ([#2923](https://github.com/mvschwarz/openrig/pull/2923))
|
||||
- Pairing answers go to the receiving agent, and a person can decide directly in
|
||||
the TUI's pairing settings. ([#2931](https://github.com/mvschwarz/openrig/pull/2931), [#2935](https://github.com/mvschwarz/openrig/pull/2935))
|
||||
- A runner's permission answer goes to the receiving agent, which decides while
|
||||
the runner waits, so a qualified answer ("only if it's the test file") is
|
||||
read before anything acts. The answer is kept and an explicit decision works,
|
||||
but the runner's confirmation updates to the person aren't written in this
|
||||
release: no Feed confirmation, receiver-state notice or immediate wake. The
|
||||
decision reaches the runner at its next read (up to about 21 seconds in
|
||||
testing). Decide one directly with `rig seat decide-permission`. ([#2934](https://github.com/mvschwarz/openrig/pull/2934))
|
||||
- **What to do:** design for an agent to read the person's answer with its
|
||||
question and decide. If no receiver is available, say the answer is waiting;
|
||||
don't describe it as read or decided. Pairing settings in the TUI also offer
|
||||
the direct decision described above.
|
||||
- **A chosen receiver** (`agents.operator_session`) in an explicit or auto
|
||||
permission mode gets no OpenRig allowance for `rig host approve` or `deny`, so
|
||||
Claude's review can refuse them; a kernel receiver in auto mode is included.
|
||||
The default kernel with the default policy passed at its tested scope.
|
||||
- **A receiver that was stopped and then started** may not act on an answer
|
||||
that waited for it. When it needed to ask back in testing, the question
|
||||
appeared in its own conversation, not in the Feed or Steering.
|
||||
- **Choose how much automatic input reaches a seat:** `rig seat set-typing-guard <seat> --mode <mode> --reason "…"`, where
|
||||
`<mode>` adds `openrig-urgent` (OpenRig's own notices and anything urgent) and `urgent-only` between `off` and `hold`.
|
||||
Mark a message urgent with `rig send --urgent`, or create the row at priority `urgent` or `critical`. Held input stays
|
||||
readable (`rig seat held-messages`) and is never replayed.
|
||||
- **Choose how far OpenRig and peers may act on a seat:** `rig seat set-terminal-policy <seat> --preset
|
||||
no-peer-interventions|help-within-instructions|full-control --reason "…"`, with `--helper <addresses…>`,
|
||||
`--rig-default` (seats added later), and exceptions such as `--peer-compaction deny` or `--openrig-model allow`.
|
||||
Managed compaction and saved model or effort changes are checked; a denial is `terminal_policy_refused`. Other
|
||||
interventions are guidance only.
|
||||
- **Read a seat's planned permissions:** `rig seat status <seat>` shows what its next fresh managed launch will pass and
|
||||
where each part comes from, and its terminal policy. `rig setup --router-line yes|no` decides whether OpenRig adds its
|
||||
one line to shared guidance files.
|
||||
- **Let a person choose whether every ask reaches Slack:** `rig gateway human set <id> slack-subscription
|
||||
every-ask|urgent-only|unset`. Every ask is on the TUI's Feed either way. Under `urgent-only`, only an ask
|
||||
created or handed off with `--alert` posts. An ask marked `--alert` stands out in Slack (an interrupt with a mention)
|
||||
under either setting, so use it only for something only they can do, or an approval whose wait costs now.
|
||||
- **Give one Claude member its own setup** in the rig spec: `agent_file`, `append_system_prompt_file`, `plugin_dirs` and
|
||||
`advisor` (an Advisor model). They're copied into that seat's storage, never the project.
|
||||
- **See a seat's own skills:** `rig skill loadout --seat <session|self>`.
|
||||
- **Reach the team from an assistant without a shell:** the MCP server has 34 tools, including `rig_whoami`,
|
||||
`rig_restore_status`, `rig_team`, `rig_telemetry_events`, following a handoff, reading a finished item's artifact and
|
||||
deciding inside a person's decision room. `rig mcp serve --http` serves them over HTTP; `rig mcp token add <name>
|
||||
--seat <session>` gives each caller its own seat.
|
||||
- **Hand a row's task to an outside agent:** `rig acp run <row>` for an ACP agent (Hermes, OpenClaw).
|
||||
`docs/reference/a2a-agents.md` covers A2A agents.
|
||||
- **Run a community harness that names, reports or reopens its conversations:** preset fields `launch_result`, `resume`,
|
||||
`names_conversation` and `startup_assisted`, with examples for Cursor, Hermes and OpenClaw under
|
||||
`docs/reference/community-harness-examples/`.
|
||||
- **Change one edge on a running rig:** `rig edge add <rig> <from> <to> --kind <kind>` and `rig edge remove <rig>
|
||||
<edge-id>` (`--plan`, `--json`). A `delegates_to` change says when it moves who work routes to.
|
||||
- **Run one spec as several rigs:** `rig up <spec> --name <rig>`.
|
||||
- **Retune a watchdog in place:** `rig watchdog update <jobId> [--interval-seconds n] [--message text] [--spec path]`
|
||||
keeps its id and attachments. The context watch takes `--threshold-tokens` or `--threshold-percent`.
|
||||
- **Put restore maps where seats may write:** `rig config set policies.claude_compaction.preparation_root <abs path>`.
|
||||
- **Pick a Herdr session:** `rig terminal open <view> --provider herdr --session <name>`.
|
||||
- **Close a row with its artifact:** `rig queue update <id> --state done --evidence-ref <path>`.
|
||||
- **File and read subscription move reports:** `rig provider moves report` and `rig provider moves`. `--host <id>` and
|
||||
`--codex-home <dir>` on `rig auth status|list|switch` and `rig seat restart` act on another machine's Codex home.
|
||||
- **Route stream observations with typed decisions (opt-in, off by default):** once `decisions.enabled` is true,
|
||||
`decisions.consumers` includes `stream` and the intake seat has `OPENROUTER_API_KEY`, `rig project wake --decide`
|
||||
gives each eligible stream observation one bounded typed decision during that seat's wake: create a row for a supplied
|
||||
owner, join supplied work, or record a duplicate or no action. Anything uncertain, refused or failed goes to the
|
||||
intake seat as its own disposition, and the model isn't asked again. `rig project decisions status` shows the state
|
||||
without making a call. Without `--decide`, or with decisions off, nothing changes; turning decisions off no longer
|
||||
strands an accepted create or join whose effect hadn't landed.
|
||||
- **Read the stream as a subscriber:** `rig stream subscribe <seat>` (`--tags`, `--type`; re-subscribing keeps the
|
||||
place), then `rig stream read` returns the next bounded page for the calling seat (at most 20 items and 64 KiB, with
|
||||
`more`, `unread` and any `gap`). `rig stream subscriptions` lists readers; `rig stream unsubscribe <seat>` stops one.
|
||||
`rig stream show <id>` says what became of an observation.
|
||||
- **Keep the kernel to the harnesses you chose:** `rig setup --providers <list>` is now saved as `provider.selected`, and
|
||||
the kernel starts seats only for those.
|
||||
- **Stop a second daemon on a live database:** `rig config set daemon.db_conflict stop` refuses a start whose database a
|
||||
matching live daemon already holds (the default `warn` only warns). `rig daemon start --allow-db-conflict` allows one
|
||||
deliberate start.
|
||||
|
||||
## Other changes to act on
|
||||
## Behaviour that changed
|
||||
|
||||
- **Setup's guidance** asks the agent to offer to register the person; in
|
||||
testing it didn't reliably make the offer (0 of 5 runs). Registering works
|
||||
when the person asks for it (`rig gateway human add`): nothing is registered
|
||||
without their yes, an existing default is kept, and no person is chosen
|
||||
automatically. A team started from the person's own assistant fits their
|
||||
logins, with a review before commit. ([#2924](https://github.com/mvschwarz/openrig/pull/2924), [#2905](https://github.com/mvschwarz/openrig/pull/2905))
|
||||
- **Harnesses:** `protocol: pi-rpc`, launch wrappers in presets, and Pi
|
||||
cross-runtime switches. ([#2870](https://github.com/mvschwarz/openrig/pull/2870), [#2759](https://github.com/mvschwarz/openrig/pull/2759), [#2669](https://github.com/mvschwarz/openrig/pull/2669))
|
||||
- **A relay connector** that can't accept incoming connections can pull
|
||||
(`OPENRIG_RELAY_MODE=pull` in the relay secrets file); push stays the default.
|
||||
([#2844](https://github.com/mvschwarz/openrig/pull/2844))
|
||||
- **herdr-app:** `rig terminal open <view> --provider herdr-app` opens a view in
|
||||
the herdr-app window. Following isn't claimed. ([#2744](https://github.com/mvschwarz/openrig/pull/2744))
|
||||
- **Queue erasure** for closed-work retention and account deletion, off by
|
||||
default. ([#2837](https://github.com/mvschwarz/openrig/pull/2837), [#2843](https://github.com/mvschwarz/openrig/pull/2843))
|
||||
- **Delivery evidence:** each send records one `transport.delivery_evidence`
|
||||
event. Read it as what was known about a delivery, not proof the agent read
|
||||
it. ([#2937](https://github.com/mvschwarz/openrig/pull/2937), [#2943](https://github.com/mvschwarz/openrig/pull/2943))
|
||||
- **Seat self-knowledge:** managed guidance points to skills, help and project
|
||||
context; `rig seat status` shows when guidance was written relative to the session's recorded moments, and what the
|
||||
refocus hook recorded. ([#2918](https://github.com/mvschwarz/openrig/pull/2918), [#2920](https://github.com/mvschwarz/openrig/pull/2920), [#2916](https://github.com/mvschwarz/openrig/pull/2916))
|
||||
- **Codex "Hooks need review" with smol-toml 1.9** is fixed; stop working around
|
||||
it by hand. ([#2804](https://github.com/mvschwarz/openrig/pull/2804))
|
||||
- **A start reservation left after a reboot** is archived by `rig daemon start`;
|
||||
don't clear it by hand. ([#2915](https://github.com/mvschwarz/openrig/pull/2915))
|
||||
|
||||
## Limits to keep in view
|
||||
|
||||
- **Not in this release:** daemon pauses of up to about a second while reading
|
||||
proof on large mission trees ([#2970](https://github.com/mvschwarz/openrig/pull/2970)), and a delivery stall to a Claude seat
|
||||
showing Claude's auto-update failure footer ([#2948](https://github.com/mvschwarz/openrig/pull/2948)). Don't tell a person
|
||||
either is fixed.
|
||||
- **`rig down`** keeps no stored host or boot identity, doesn't guard against
|
||||
another program running as the person, and a daemon restart between losing the
|
||||
tmux server and the stop wasn't exercised. ([#2962](https://github.com/mvschwarz/openrig/pull/2962))
|
||||
- **Codex working counter:** a capture that doesn't name its pane keeps the old
|
||||
comparison, and a restart that keeps the pane and its process id isn't seen.
|
||||
([#2980](https://github.com/mvschwarz/openrig/pull/2980))
|
||||
- **Custom blocker IDs** explain the wait but give no event wake; the timer
|
||||
remains. ([#2983](https://github.com/mvschwarz/openrig/pull/2983))
|
||||
- **Runner permission answers** were run end to end with a real receiving agent
|
||||
and the example runner, whose requesting agent was a stand-in; a model-driven
|
||||
runner waiting on a person's answer hasn't been run. ([#2934](https://github.com/mvschwarz/openrig/pull/2934))
|
||||
- **An already-running tmux server** keeps its old environment, daemon
|
||||
credentials included, until it restarts. ([#2850](https://github.com/mvschwarz/openrig/pull/2850))
|
||||
- **Delivery:** events are best effort, so a missing event isn't proof nothing
|
||||
was sent. A footer row OpenRig doesn't recognize can still hold a delivery ([#2925](https://github.com/mvschwarz/openrig/issues/2925)),
|
||||
and a held message's full envelope can stay held; clearing a draft doesn't
|
||||
deliver it ([#2515](https://github.com/mvschwarz/openrig/pull/2515)). With unreadable input, `--dangerously-interact` pastes
|
||||
with a warning and doesn't press Enter ([#2800](https://github.com/mvschwarz/openrig/pull/2800)).
|
||||
- **Not run for this release** (not run, not passed): the two-seat Codex journey
|
||||
with `AGENTS.md` restore; Codex account-home, upgrade and continuity checks;
|
||||
the OpenShell worker's second report; Pi model switches on real providers; ACP
|
||||
in a desktop app; Slack directory paging; some Slack setup cases; native
|
||||
checks of [#2786](https://github.com/mvschwarz/openrig/pull/2786), [#2799](https://github.com/mvschwarz/openrig/pull/2799), [#2807](https://github.com/mvschwarz/openrig/pull/2807) and [#2813](https://github.com/mvschwarz/openrig/pull/2813) ([#2947](https://github.com/mvschwarz/openrig/pull/2947) partly); a button-and-words
|
||||
answer, a non-pairing ask with an explicit owner, Steering's Approve page, and
|
||||
the answer and setup checks on a default model; `--wait-for-kernel` ([#2888](https://github.com/mvschwarz/openrig/pull/2888)),
|
||||
[#2800](https://github.com/mvschwarz/openrig/pull/2800)'s installed effect, and [#2720](https://github.com/mvschwarz/openrig/issues/2720) beyond one platform. Don't tell a person
|
||||
these work.
|
||||
- **0.7.11's limits below** still apply, except Codex hook setup with smol-toml
|
||||
1.9.x ([#2720](https://github.com/mvschwarz/openrig/issues/2720)), fixed by [#2804](https://github.com/mvschwarz/openrig/pull/2804).
|
||||
|
||||
## What changed in OpenRig 0.7.11
|
||||
|
||||
This note is for an agent on an install that was just upgraded to 0.7.11.
|
||||
OpenRig 0.7.11 sends ordinary messages and work notices into a person's typed
|
||||
draft by default instead of refusing them, lets a person reach the team from
|
||||
their own coding agent (the `openrig` plugin) or an ACP app (`rig acp serve`),
|
||||
holds a prepared managed compaction until the seat is idle, and clears a
|
||||
restore that stopped at a prompt. On Linux, a daemon run as a systemd user
|
||||
service keeps its seats when its unit restarts. It adds five database
|
||||
migrations. On OpenShell 0.1.6 the worker's maintained cleanup now works, but
|
||||
the worker still isn't end to end. If you upgraded
|
||||
from 0.7.9 or earlier, also read "What changed in OpenRig 0.7.10" below, and
|
||||
from 0.7.8 or earlier, "What changed in OpenRig 0.7.9" too. For the full
|
||||
capability map, read `rig context get onboarding-width/public-what-you-can-do.md`.
|
||||
After the upgraded daemon starts, `rig context get reference/whats-new.md`
|
||||
describes this version. Without a running daemon, read the packaged
|
||||
`daemon/docs/reference/whats-new.md`; `$OPENRIG_HOME/reference/whats-new.md` can
|
||||
still be the previous copy until startup.
|
||||
|
||||
### After the upgrade
|
||||
|
||||
- **Back up the database, then restart the daemon.** Five migrations run on
|
||||
the daemon's first start: 150 (node launch wrapper), 151 (Pi account
|
||||
switches), 152 (workflow instance spec), 153 (events retention marks) and 154
|
||||
(workflow instance root objective). If `$OPENRIG_DB` isn't backed up yet,
|
||||
copy that file (`rig config get db.path` shows it) with the daemon stopped,
|
||||
then `rig daemon start`. Going back to 0.7.10 after these migrations isn't
|
||||
established.
|
||||
- **New settings:** `transport.draft_delivery` (`deliver`), `acp.to` (empty),
|
||||
`daemon.seat_poll_interval_ms` (1000) and `retention.events_days` (`0`, keeps
|
||||
every event). `rig config` shows each with its source.
|
||||
- **The openrig-core plugin moves from 0.1.26 to 0.1.27.**
|
||||
Its activity relay and refocus hooks changed, as did the refocusing,
|
||||
openrig-user and openrig-skills skills. Managed refresh preserves replaced
|
||||
files under `$OPENRIG_HOME/backups/managed-refresh/<id>/`; reapply local edits
|
||||
you still need from the preserved copy.
|
||||
- **Linux, with the daemon as a systemd user service:** the seats move into
|
||||
their own scope only when their tmux server next starts fresh; until then,
|
||||
stopping or restarting the daemon's unit still takes them down.
|
||||
- **Tell the person sends now join their drafts** (below). To keep the refusal,
|
||||
run `rig config set transport.draft_delivery refuse`; it applies from the
|
||||
next send, with no restart.
|
||||
- **Pi seats:** a Pi seat that declares an absolute `PI_CODING_AGENT_DIR` in its
|
||||
`env:` now uses it as its account home and needs a readable `auth.json`
|
||||
there, or the launch stops. Prepare one, or remove the declaration to keep
|
||||
the managed seat login. Ask the person before changing their spec.
|
||||
- **herdr-core terminals:** new starts no longer inherit `HC_SCOPE`,
|
||||
`HC_TERMINAL_ID` or `HC_TERMINAL_SOCKET`; a daemon or tmux server already
|
||||
running with them keeps them until it's restarted.
|
||||
- **Claude seats that load openrig-core themselves** lose OpenRig's unchanged
|
||||
copies of its skills at their next launch, so each skill loads once; the
|
||||
launch result warns with the copies removed or kept.
|
||||
|
||||
### Sends and drafts
|
||||
|
||||
- **Under `deliver` (the default),** an ordinary `rig send` or work notice to a
|
||||
seat whose prompt holds a person's typed draft pastes the message at the
|
||||
cursor and submits the combined input as one prompt. The result says whether
|
||||
submission was confirmed and, when OpenRig saw the draft, that it joined it.
|
||||
An unset, unreadable or invalid value follows `deliver`.
|
||||
- **Still refused or held under either value:** menus, permission prompts, a
|
||||
seat's typing guard (`draft-aware`, `hold`), managed compaction's sends and
|
||||
`--wait-for-idle`.
|
||||
- **Under `refuse`,** a send is refused, and a work notice retried once the
|
||||
prompt is clear, where OpenRig detects the draft. Claude Code and Codex sends
|
||||
now also read the input box before pasting, so a draft only the box shows
|
||||
refuses too (`target_needs_input` / `prompt_draft`).
|
||||
- **The draft-aware typing guard** reads a Claude composer with a statusLine or
|
||||
the background task panel, and Claude Code 2.1.319's bare
|
||||
`✔ Update installed · Restart to update` row in a narrow pane, so an empty box
|
||||
gets the wake instead of `draft_input_unknown`.
|
||||
- **Held messages coalesce:** a sender's newest held message at a seat retires
|
||||
its earlier ones. Read them with `rig seat held-messages <seat>` (each with
|
||||
`--id <id>`); an older backlog clears at that sender's next held message or
|
||||
with `rig seat retire-held-message`.
|
||||
- **Stopped seats aren't woken at each daemon start:** their rows stay owed, and
|
||||
the start-up log says `waiting=N (not replayed: …)`. That isn't delivery.
|
||||
|
||||
### Compaction, restore and handover
|
||||
|
||||
- **A prepared managed compaction is held, not re-requested.** When the map is
|
||||
complete but `/compact` couldn't go in, the attempt stays `waiting`, marked
|
||||
`prepared`, and sends `/compact` at the next idle moment with an empty box.
|
||||
`rig compact <seat> --state` shows it; a manual `rig compact` reads
|
||||
`already_in_progress`; `--cancel` disarms it. It ends on a context dip, a
|
||||
conversation change, a stale map (`prepared_map_stale`: 15 points of growth or
|
||||
2 hours) or a map that stops reading ready.
|
||||
- **The final `/compact` waits out the preparation's own fresh activity** (up
|
||||
to about 23 s, against 9 s), so an idle seat that ran out of context compacts
|
||||
instead of being refused; a busy seat can wait up to about 14 s longer.
|
||||
- **A queue wake refused at a prompt** no longer goes in right after
|
||||
"Compacted" while the compaction still owes its turn boundary and restore
|
||||
request; it goes in at the next idle moment after that step clears.
|
||||
- **A full restore stopped at a prompt clears itself** once the seat runs the
|
||||
conversation it was restoring. If a seat still reads `attention_required`, run
|
||||
`rig seat clear-attention <seat>`, with `--reason` after a partial restore or
|
||||
a community harness's assisted startup. `proof_superseded` means the restore
|
||||
changed mid-check.
|
||||
- **A Codex helper inside a Claude seat** no longer takes the seat's resume
|
||||
token (`transcript_not_this_session`).
|
||||
- **A Claude rebuild handover may report its priming packet unconfirmed.**
|
||||
Submit the existing packet once, only if it's still staged. Never resend it
|
||||
blindly or rerun the handover.
|
||||
- **`on_restore: follow_source`** on a spec's `resources.skills[]` entry
|
||||
refreshes that skill from its source on restore; `keep_saved` is the default.
|
||||
|
||||
### Working with the team from outside
|
||||
|
||||
- **The `openrig` plugin** lets a person use their own Claude Code or Codex
|
||||
with their local team: hand it work, follow it, answer the team's questions
|
||||
in their own words, and read the result. Install it from the package
|
||||
(`claude plugin marketplace add "$(npm root -g)/@openrig/cli/daemon/assets/plugins/openrig"`,
|
||||
then `claude plugin install openrig@openrig-app`) or from this repository on
|
||||
GitHub. Everyone using the app on a machine acts as the seat it names.
|
||||
- **VS Code, Copilot CLI and Cursor** start its server with no seat: add your
|
||||
own `rig mcp serve --front-door` server with `OPENRIG_SESSION_NAME` set and
|
||||
turn the plugin's off. A seatless app can create tasks, send and post as
|
||||
`unknown-mcp-<8 hex>`, but can't claim, update, hand off or answer.
|
||||
- **ACP apps:** `rig acp serve --to <member@rig>` (or the `acp.to` setting)
|
||||
makes the team an agent in an app that launches a custom ACP agent, such as
|
||||
Zed or T3 Code. Nothing runs until an app starts it, and it uses the daemon
|
||||
`rig` uses. A question left unanswered in the app stays open in the TUI and
|
||||
Slack. The `@openrig/acp` launcher is packed beside the CLI at the same
|
||||
version. See `docs/reference/acp-agents.md`.
|
||||
- **Assistants through a grant service** are experimental, unsupported and off
|
||||
by default: they need your own internet-reachable `rig mcp serve --http
|
||||
--grant-service <url>` and an outside grant service. A registered person can
|
||||
answer the team's questions from one, and `--task-destination <member@rig>`
|
||||
names where its tasks go (`docs/reference/hosted-members.md`).
|
||||
- **The `relay` gateway connector** is off unless you add a relay binding. It
|
||||
hands questions and updates to a bridge the operator runs on loopback;
|
||||
"Accepted by bridge" isn't delivery (`docs/reference/gateway-relay.md`).
|
||||
|
||||
### OpenShell workers
|
||||
|
||||
On OpenShell 0.1.6 the maintained cleanup (`delete-owned`) now works for a
|
||||
custom image, and recreate and an exact resume passed in our native run. A
|
||||
restored guest still receives its startup text again, so strict recall after a
|
||||
restore isn't met, and the worker isn't end to end. The CA fix holds on 0.1.6.
|
||||
"Never grant the guest access to the daemon's main API
|
||||
port (normally 7433)."
|
||||
|
||||
### Other changes to act on
|
||||
|
||||
- Denying or dropping a host pairing request in Steering now gives the requesting machine no access; in 0.7.9 and 0.7.10, that action could return the daemon access token. ([#2884](https://github.com/mvschwarz/openrig/pull/2884))
|
||||
- **Pairing answers now reach the target's kernel operator.** On a 0.7.11 target with a receiving kernel operator, typed words and Approve or Deny choices in the TUI, Steering or Slack are replies for that agent to read. Receiving a reply neither grants nor denies access. The operator acts on clear intent with `rig host approve <code>` or `rig host deny <code>` and asks back when the answer is unclear. The commands also work from a plain terminal on the target; they use its operator bearer (`OPENRIG_AUTH_BEARER_TOKEN`, or `--bearer <token>`). ([#2900](https://github.com/mvschwarz/openrig/pull/2900))
|
||||
- **An Approve click on an operator-owned request waits for the operator to act.** A stopped operator keeps ownership; the answer waits in its queue. A stopped or unknown running state is named with the terminal route, without claiming the answer was read. If there is no operator, it cannot receive answers, or OpenRig cannot determine one, the request names that case and uses the agent-free path: Approve and Deny decide directly, and replies lead to another ask rather than denial. File-only replies and the literal `[file reply]` have no words stored on the agent-free ask; they still do not deny it.
|
||||
- **TUI answers use the daemon's operator bearer when configured.** Ordinary terminal calls keep their separate terminal credential. A missing or wrong action credential leaves the answer unrecorded and shows what to set; this does not weaken the daemon's authentication.
|
||||
- **Receipt, clarification and outcome are separate steps.** Receipts, agent-free re-asks and outcomes inferred from clicks or closes need the requesting machine to keep polling. An explicit `rig host approve` or `rig host deny` attempts its outcome update directly. An expiry timer closes the ask even if polling stopped; an unanswered expired ask gets no further update. Requests and decisions live in daemon memory, so a restart drops an open request. Approval still must be collected before expiry. This target-side change does not change older targets.
|
||||
|
||||
- **Rows on another host:** `rig queue show`, `list`, `transitions`, `update`
|
||||
and `block` take `--host <id>`; `claim` doesn't, since only the destination
|
||||
seat on its own host claims. `--host` never follows `rig host select`.
|
||||
- **A seat's unclaimed backlog:** a seat with no escalation route or delegation
|
||||
parent gets one `unclaimed-backlog` notice. To route its findings to a
|
||||
watching seat:
|
||||
`rig edge add <rig> <root seat's logical id> <watching seat> --kind escalates_to`.
|
||||
- **Claims read `working`** for up to 60 minutes while a Claude owner has a
|
||||
live background shell, and 20 while a managed restore is owed.
|
||||
- **A cross-host handoff that overrides your claim** wakes you with the
|
||||
successor's id: stop and coordinate with that seat.
|
||||
- **Code results:** set an `openrig.code-result/v1` manifest as `evidenceRef`;
|
||||
`rig queue artifact <qitemId>` and `rig_artifact_read` return the commit pair.
|
||||
- **Workflows:** runs keep their own spec; `rig workflow unregister
|
||||
<name>[@<version>]` (or `--prune-missing`) removes registered versions;
|
||||
`rig workflow trace <id> --full` returns the whole trail; `show`, `trace` and
|
||||
`status` print `objective:`.
|
||||
- **`rig stream emit` timeouts:** repeat with the same `--id`, or check with
|
||||
`rig stream list` before emitting again.
|
||||
- **Launch and setup:** `launch_wrapper` works for local Claude Code, Pi and
|
||||
Codex seats; `rig provider switch --seat <pi seat> --to-account <name>
|
||||
--account-home <dir> --reason "<why>"` switches a Pi seat's account (`--dry-run`
|
||||
first), and a Pi seat with a declared account home starts again; `rig config set` says when
|
||||
an environment variable masks the saved value.
|
||||
- **Linux seats:** when the daemon runs as a systemd user service, the seats'
|
||||
tmux server starts in its own user scope (`openrig-tmux-<id>.scope`), so the
|
||||
daemon's unit can stop or restart without ending them. Otherwise the server
|
||||
starts as before, with one log line giving the reason.
|
||||
- **Writable terminal tiles** attach as in 0.7.10: the screen in use sizes the
|
||||
seat and text reflows on resize. Read-only tiles keep showing without attaching
|
||||
or resizing the seat. Web fixed geometry is opt-in through
|
||||
`terminal.fixed_geometry` (off by default). As in 0.7.10, Enter or Ctrl-C while
|
||||
the first attach is in flight can close that viewer; the agent keeps running,
|
||||
so reopen the view. Kept writable tiles from 0.7.8, 0.7.9 or pre-reflow 0.7.11
|
||||
need a manual refresh: close only the owned viewer and reopen with
|
||||
`rig terminal open`; for `--follow`, use `--no-follow` first, then open a fresh
|
||||
view. The agent and conversation are preserved; no automatic migration runs.
|
||||
([#2868](https://github.com/mvschwarz/openrig/pull/2868), [#2757](https://github.com/mvschwarz/openrig/pull/2757), [#2821](https://github.com/mvschwarz/openrig/pull/2821))
|
||||
- **A Claude seat launched with `--settings` JSON written with spaces** can be
|
||||
verified again.
|
||||
- **`view.changed` is no longer stored,** and `/api/events` no longer sends it.
|
||||
- **Old history-only events can be pruned, if you choose to.** `retention.events_days` (default `0`, which keeps every
|
||||
event) turns on a pass in the retention sweep (at daemon start and daily) that deletes `view.changed`,
|
||||
`snapshot.created`, `watchdog.evaluation_fired` and the `queue.created`, `queue.updated`, `queue.claimed`,
|
||||
@@ -340,417 +107,105 @@ port (normally 7433)."
|
||||
- **With pruning on, `rig telemetry events` reports the pruned ranges.** A page that spans pruned events inside the
|
||||
retained window has a `retained_sequence_gap` for each missing seq range and `coverage.status` `partial` (it used to be
|
||||
`retained_window`); events pruned from the start of the log show up as `before_retained_floor`.
|
||||
- **Large fleets:** `daemon.seat_poll_interval_ms` (up to 2000) lowers CPU at
|
||||
the cost of activity freshness; keep 1000 if tmux is slow.
|
||||
- **Terminals:** Herdr follow views work on macOS (protocol 22) and open a
|
||||
snapshot (`follow_unsupported`) where live following isn't supported.
|
||||
|
||||
### Limits to keep in view
|
||||
- **Team skills live in each seat's own storage** (`$OPENRIG_HOME/state/seats/<rig id>/<logical seat>/<runtime>/`), not
|
||||
in the project's `.claude/skills` or `.agents/skills`. Claude seats get them as a generated plugin (`openrig:<id>`),
|
||||
Codex seats as their own catalog. If a link sits in that path, the seat starts without them and the result names
|
||||
what wasn't delivered.
|
||||
- **Urgent wakes are labelled:** an urgent or critical row's wake starts with `Urgent (marked by …):` at every seat. A
|
||||
seat on `hold` gets one held notice per wake instead of re-nudges, and lifecycle work is refused at the middle levels as
|
||||
at `hold`.
|
||||
- **A daemon restart picks up a managed compaction where it stopped.** An owed restore step resumes, a `/compact` that
|
||||
may have been sent is never sent again, and `rig compact --state` shows anything stranded or owed.
|
||||
- **A Claude seat whose startup turn is refused** (an expired login, say) holds the rest of its startup context as owed.
|
||||
A startup input sent before the refusal is noticed can still be submitted (see Known issues in the
|
||||
[0.7.1 release notes](https://github.com/mvschwarz/openrig/releases/tag/v0.7.1)).
|
||||
- **`rig send` refuses with `target_needs_input`** when a Claude seat's hook has just reported it running and its screen
|
||||
shows an MCP enablement menu, a question or a permission prompt.
|
||||
- **`rig down` asks before stopping the caller's own rig.** Without a terminal it exits 1, stops nothing and prints the
|
||||
`--yes` command.
|
||||
- **`rig up --plan` doesn't start a stopped daemon:** it exits 1 with `daemon_not_running` and names `rig daemon start`
|
||||
and `--no-kernel`.
|
||||
- **`rig daemon start`** waits up to 20 seconds for a slow daemon, and starts with a warning when only a secondary
|
||||
listener, such as Tailscale's, can't be verified.
|
||||
- **A seat joined with `rig attach` as an outside app** (Claude Desktop) stays attached through `rig down` and restore
|
||||
(`left_attached`). To replace it, `rig unclaim <name>`, then launch.
|
||||
- **New seats get the daemon's `OPENRIG_DB` and `TMUX_TMPDIR`.**
|
||||
- **A rebuilt Codex seat keeps its observed permissions,** and a read-only Codex profile launches without OpenRig's
|
||||
writable roots.
|
||||
- **A seat's own role** goes only to that seat, never into a shared `CLAUDE.md` or `AGENTS.md`, and `rig down` returns
|
||||
guidance files byte for byte.
|
||||
- **Workflow actions** (`project`, `route`, `resume`, `abort`) record the calling agent; a request with neither identity
|
||||
nor actor gets the shared `actor_required` error.
|
||||
- **`rig provider moves` is back** for subscription move reports. It switches nothing.
|
||||
- **`--dangerously-interact` into a seat's input box stops first** when the box already holds typed or pasted text: it
|
||||
shows that text (apart from suggestions) and types nothing. Clear the box and retry, or pass `--type-without-checking`,
|
||||
which clears nothing. Keys for a recognized native menu are sent as before.
|
||||
- **A terminal seat gets exactly the text sent to it,** without the From/To lines (the result says `envelopeOmitted`).
|
||||
A queue wake to a terminal seat types nothing and fails `failed:terminal-seat`; the row stays queued.
|
||||
- **A daemon restart that can't see the seats' tmux server** no longer detaches seats whose recorded process still
|
||||
runs, so it doesn't relaunch them beside themselves.
|
||||
- **Provider credentials and the activity-hook token stay off tmux, shell and `env` command lines** at launch and
|
||||
handover. A launch whose staging fails stops instead of falling back.
|
||||
- **An answer retried from Slack or Steering** after an interrupted close reuses the first reply and finishes the
|
||||
request once.
|
||||
- **One bad row no longer stops the stuck sweep** from flagging the others; **a malformed unrelated diagnosis row** no
|
||||
longer stops health diagnoses from listing.
|
||||
|
||||
- **OpenShell:** a restored guest gets its startup text again, strict recall
|
||||
isn't met, and the worker isn't end to end (above).
|
||||
- **Drafts:** detection isn't complete. Under `refuse`, an unreadable input box
|
||||
lets the send go ahead; under `deliver`, a result may not mention a draft.
|
||||
- **A post-compaction step can still hold** when the box reads as typed, under
|
||||
draft-aware delivery or the typing guard, or after a lost `/compact`. Run
|
||||
`rig compact <seat> --cancel`, then send a restore request naming the newest
|
||||
restore map, marker and packet. A refused queue wake now waits for that step,
|
||||
but recovery at daemon start and a new handoff's wake don't check yet.
|
||||
- **`keep_saved` skills:** a skill whose source changes between `rig down` and
|
||||
the restore comes back missing, named in the restore's warnings; an edited seat
|
||||
copy is kept.
|
||||
- **Terminal tiles** each run one tmux command a second while showing (8 tiles
|
||||
used about 6–8% of one core on an Apple Silicon Mac mini).
|
||||
- **A message held as `draft_input_unknown`** isn't retried, and whether
|
||||
managed compaction gets past the update banner wasn't retested.
|
||||
- **Claude provider switch:** the "probe pane returned to a shell" failure in
|
||||
[#2341](https://github.com/mvschwarz/openrig/issues/2341) isn't shown fixed;
|
||||
the launcher-script rebuild timeout and `--no-chrome` are fixed.
|
||||
- **ACP apps** were checked in T3 Code (Beta) 0.1.7 and with a scripted client;
|
||||
the unanswered-question notice hasn't run in a live app. A cancel stops
|
||||
waiting; the team's work carries on.
|
||||
- **The `openrig` plugin** hasn't run in Claude Desktop, the Codex app or
|
||||
Copilot CLI. **Grant-service assistants** still aren't woken.
|
||||
- **Claude `launch_wrapper`** hasn't run with zsh, on macOS arm64 or with a real
|
||||
installed Claude; an alias or shell function named `claude` fails closed.
|
||||
- **Going back to 0.7.10** isn't established; an older permission hook also
|
||||
gives named-policy Claude sessions no decision until they're relaunched.
|
||||
- **The other 0.7.10 limits below still apply:** `rig exec` cancels,
|
||||
permission requests in seat status, Codex hooks (including
|
||||
`rig seat restart --keep-conversation` refusing a Codex seat launched with
|
||||
developer instructions), smol-toml 1.9.x, Slack, Claude Code 2.1.319's auto
|
||||
mode, Codex snapshot restores and `AGENTS.md`, unretried reminders, SSH host
|
||||
status, advisories on a native resume, seats handed over before 0.7.9, `rig`
|
||||
in the TUI's shell after a project-local install, the "Startup prompt still
|
||||
staged" message, and a partial install from two npm installs at once.
|
||||
0.7.10's bare-banner and launcher-script limits are fixed, and its OpenShell
|
||||
limit is replaced by the one above.
|
||||
## Output that changed shape
|
||||
|
||||
---
|
||||
- **`rig ps --nodes`** separates every cell with one space (the default row is 5 columns wider), adds an `ATTN` column
|
||||
(`yes`, `no` or `?`) before `REASON`, and marks a `running` seat whose tmux couldn't be reached as unconfirmed.
|
||||
- **`rig queue show --json`** no longer has `lastNudgeInputBox`, and send results no longer describe the input box.
|
||||
- **`rig queue show` and `rig queue whoami`** print their human notices on stderr, so stdout is the JSON.
|
||||
- **A claimed row whose owner waits on a person** reads pickup `needs-input`, with the reason.
|
||||
- **`rig down --json`** adds `leftRunning`; `rig reconcile-session` says whether resume metadata was recorded.
|
||||
- **`rig compact-plan`** labels its thresholds as its own and says whether automatic compaction is on.
|
||||
- **A send held for a draft** in the seat's input box reports `inputPhase: "not_reached"`: it was never typed.
|
||||
- **A stream body over 16 KiB** is stored truncated, ending `[truncated by OpenRig: kept N of M bytes]`, and reads mark it
|
||||
`bodyTruncated`. `rig stream list --after <sortKey>` from a pruned item returns 410 `stream_cursor_expired`, naming
|
||||
`--since` to resume, instead of an empty list.
|
||||
|
||||
## What changed in OpenRig 0.7.10
|
||||
## What to stop doing
|
||||
|
||||
The changes from 0.7.9 to 0.7.10, published on 2026-11-08, for an install
|
||||
upgrading from 0.7.9 or earlier. 0.7.10 added `rig exec` for finite runs on
|
||||
Claude Code or Codex, sent a held post-compaction step after two idle polls that
|
||||
can't read the input box, left every tmux mode before a delivery, and showed
|
||||
agent spec advisories at launch. Its OpenShell line below is corrected.
|
||||
- **Don't read `lastNudgeInputBox`.** It's gone.
|
||||
- **Don't parse `rig ps --nodes` by column position.** Use `--json`, with `--fields` to pick fields.
|
||||
- **Don't look for a team seat's skills in the project folders.** Use `rig skill loadout --seat`.
|
||||
- **Don't stop and re-register a watchdog to change it.** Use `rig watchdog update`.
|
||||
- **Don't take a rig down to fix one edge.** Use `rig edge add` or `remove`.
|
||||
- **Don't print `OPENRIG_ACTIVITY_*` values** while looking for identity. Use `rig whoami`.
|
||||
- **Don't use `--dangerously-interact` to type over a half-written input box.** It stops and shows the text; choose
|
||||
explicitly, or use `rig send --urgent` where the seat's interruption level allows it.
|
||||
|
||||
### After the upgrade
|
||||
## Known gaps in 0.7.1
|
||||
|
||||
- **Restart the daemon** to load the new code: `rig daemon stop`, then
|
||||
`rig daemon start`. This release adds no migrations.
|
||||
- **The bundled openrig-core plugin goes from 0.1.25 to 0.1.26.** Its Claude
|
||||
hooks add a `PermissionRequest` hook that reports permission requests to seat
|
||||
status, and its refocus hook and the refocusing skill's trace script changed.
|
||||
Managed refresh preserves replaced files under
|
||||
`$OPENRIG_HOME/backups/managed-refresh/<id>/`; reapply local edits you still
|
||||
need from the preserved copy.
|
||||
- **Check agent specs before relaunching seats:** run `rig agent validate <path>`
|
||||
and read any `agent spec advisory` line. A profile's `uses.<category>` written
|
||||
as a single value now selects that resource; if the resource pool doesn't hold
|
||||
it, a member using that profile fails to resolve with "not found in resource
|
||||
pool". Ask the person before you change their spec.
|
||||
- **Codex hooks:** a Codex seat kept running across the upgrade can show "Hook
|
||||
failed — plugin contents changed; hook approval required" until relaunched; in
|
||||
0.7.9's upgrade test, one seat restarted with OpenRig's managed restart showed
|
||||
no review afterwards.
|
||||
- **OpenShell:** rebuild a guest image with this release's `guest.py` before
|
||||
using it; the CA repair is in that file, which the image carries.
|
||||
- **Codex tool commands use a non-login shell:** managed Codex seats now pass
|
||||
Codex `-c allow_login_shell=false`, so plain `rig` works however OpenRig was
|
||||
installed, with a Codex that supports `allow_login_shell` (checked in Codex's
|
||||
source at 0.162.1 and 0.166); not yet run with a live Codex seat. Setup that lives only in login or rc files, and shell functions and
|
||||
aliases, may be missing from tool commands, and a model request for
|
||||
`login: true` gets Codex's refusal, "login shell is disabled by config". To
|
||||
defer to Codex's own setting, run `rig config set launch.codex_login_shell
|
||||
true`; a login file that sets `PATH` outright can then hide `rig`. It applies
|
||||
at each seat's next launch, and a restart in place keeps the running choice.
|
||||
([#2613](https://github.com/mvschwarz/openrig/pull/2613))
|
||||
|
||||
### Finite runs: `rig exec`
|
||||
|
||||
`docs/reference/acp-agents.md` is the rule. Read it before handing a row to
|
||||
Claude Code or Codex.
|
||||
- **Install the adapters first:** `@agentclientprotocol/claude-agent-acp`
|
||||
0.101.0 and `@agentclientprotocol/codex-acp` 2.5.0, as that page shows.
|
||||
`rig exec harnesses` reports the adapter and native versions it finds.
|
||||
- **Run:** `rig exec run <row> --harness claude` (or `codex`). `--plan` shows
|
||||
what would run and starts nothing; `--wait` waits for the outcome. A connector
|
||||
owns the run, so leaving `--wait` or closing the terminal doesn't stop it.
|
||||
Running it again returns the row's run instead of sending the task twice;
|
||||
`--again` starts another turn once that run has ended.
|
||||
- **Read the outcome** with `rig exec status <runId|row>`. `interrupted` means
|
||||
the connector died without recording an outcome, so the outcome is unknown. A
|
||||
`completed` turn is the agent's report, not finished work: check the work
|
||||
before you close the row. `--wait` exits 0 for `completed`, 2 for any other
|
||||
outcome and 3 when you stop waiting.
|
||||
- **Cancel** with `rig exec cancel <runId>`, not `rig acp cancel`. An
|
||||
acknowledgement isn't a settled shutdown, and it says nothing about further
|
||||
spend.
|
||||
- **Usage and cost** the agent reported are on the row's outcome note, for
|
||||
`rig acp run` too, each with its source; what it didn't report reads unknown.
|
||||
|
||||
### Compaction and delivery
|
||||
|
||||
- **A held post-compaction step goes in.** After two idle polls in a row that
|
||||
can't read a seat's input box, the next poll sends the step (the turn
|
||||
boundary, the restore request or the audit). `rig compact <seat> --state` and
|
||||
`rig seat status` say it went in over a box OpenRig couldn't read; they never
|
||||
call that box empty. A draft OpenRig can't recognise in that box may receive
|
||||
the restore text.
|
||||
- **Still held:** a box that reads as typed, with or without a suggestion; a
|
||||
seat whose draft-aware delivery or typing guard holds OpenRig's input; and the
|
||||
step after a `/compact` whose result was lost. To take over a held restore by
|
||||
hand, run `rig compact <seat> --cancel`, then send the seat a restore request
|
||||
naming its newest restore map, its restore marker and its packet. A step
|
||||
already being pasted can still land.
|
||||
- **tmux modes:** a send or wake now leaves every tmux mode in a seat's pane,
|
||||
including a chooser, customize or clock mode a person has open. If a send is
|
||||
still refused for a tmux mode, the refusal names why leaving failed.
|
||||
- **A scrolled pane with a stale record:** leaving the mode now goes through the
|
||||
seat's address, so a stale binding naming the same reused tmux pane, from
|
||||
another rig for example, no longer makes the send fail with nothing sent.
|
||||
- **Herdr views kept across an upgrade keep working,** on Linux Herdr 0.9.5 /
|
||||
protocol 22. A viewer or follow view kept from 0.7.5 through 0.7.9 now counts
|
||||
as the same attachment when only OpenRig's tile script changed, and moves to
|
||||
the current command, so the viewer stays confirmed and the follow view goes on
|
||||
adding and closing tiles. A kept viewer that's already running keeps its
|
||||
earlier sizing for as long as it runs, its own re-attaches included; writable
|
||||
viewers opened after the upgrade size their pane. A different session, tmux,
|
||||
server or read-only setting still holds the view, as before. Checked against
|
||||
the scripts those releases used, not yet with a live Herdr. It follows
|
||||
@natefaerber's approach in
|
||||
[#2467](https://github.com/mvschwarz/openrig/pull/2467). ([#2634](https://github.com/mvschwarz/openrig/pull/2634))
|
||||
|
||||
### OpenShell workers
|
||||
|
||||
0.7.10 fixes, in the source, the two problems that stopped the OpenShell worker
|
||||
in 0.7.9, but the worker hasn't run end to end. Don't rely on this path yet. The
|
||||
guest launcher now passes the CA settings the OpenShell exec supplies
|
||||
(`NODE_EXTRA_CA_CERTS`, `SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE` and
|
||||
`CURL_CA_BUNDLE`) to its Claude, and deleting an owned environment reads
|
||||
Podman's inventory through routes Podman serves, stopping as
|
||||
`environment_cleanup_unconfirmed` on any other answer. A verified export, a
|
||||
finished task and an exact restore haven't been shown. If you set one up anyway,
|
||||
follow `daemon/assets/openshell/README.md` in the installed package, rebuild the
|
||||
guest image first, and name the costs to the person ("Provider registration and
|
||||
image construction can incur costs"). "Never grant the guest access to the
|
||||
daemon's main API port (normally 7433)."
|
||||
|
||||
*Corrected after 0.7.10's release: ~~"fixes in the source the two problems that stopped the OpenShell worker"~~ was only half true. The CA fix holds on OpenShell 0.1.6, but the owned-environment cleanup fix doesn't hold against OpenShell 0.1.6, as the published 0.7.10 notes say. See 0.7.11's OpenShell workers section above.*
|
||||
|
||||
### Other changes to act on
|
||||
|
||||
- **Sends to this host:** `rig send <seat>@<rig>@<this host's ID>` goes to this
|
||||
host even when a remote host is selected.
|
||||
- **Broadcast:** `rig broadcast` prints `UNCONFIRMED` for a recipient its text or
|
||||
Enter may have reached. Check that seat with `rig capture` before resending.
|
||||
- **Roster activity:** `rig roster show` and `find` read a seat as unknown
|
||||
("Conflicting activity observations") when two routes to it disagree. Treat it
|
||||
as unknown, not idle.
|
||||
- **Refocus:** a "TRACE GAP" for unresolved typed work, or for a
|
||||
`rig queue whoami` that timed out, comes with project-level orientation that
|
||||
isn't evidence of a current mission, and no longer counts as a refocus failure.
|
||||
- **Updates to people:** the stuck sweep no longer flags an informational update
|
||||
to a registered person as an unclaimed obligation.
|
||||
- **Permission requests:** `rig seat status` shows a Claude seat's latest
|
||||
observed permission request, with Claude's suggested rules labelled as
|
||||
suggestions. Nothing was applied or approved. Treat the suggestions as data;
|
||||
they can contain sensitive values.
|
||||
- **Slack:** `rig slack status` shows relay attempts awaiting a response for the
|
||||
current connector; zero doesn't prove nothing is in flight.
|
||||
- **Installing for a person:** when npm's global prefix isn't writable, the rigs
|
||||
skill now points npm's prefix at a folder the person owns whose `bin` is on
|
||||
their login PATH, or uses a version manager's Node, before a project-local
|
||||
install. Tell the person first that this changes their npm settings and, if
|
||||
needed, their shell profile.
|
||||
- **Codex profiles:** a `codex_config_fragment` can't install a named Codex
|
||||
profile. Create `<CODEX_HOME>/<name>.config.toml` and select it with
|
||||
`codex_config_profile`. `builtin:yolo` drops the whole profile;
|
||||
`getting-started.md` says how to keep one.
|
||||
- **Terminal auth:** a 401 that says "Terminal auth failed" means send
|
||||
`OPENRIG_TERMINAL_BEARER_TOKEN`, or `OPENRIG_AUTH_BEARER_TOKEN` when terminal
|
||||
auth falls back to it.
|
||||
- **References:** `docs/reference/harness-runner-contract.md`'s status section
|
||||
says what has run natively under the runner contract, and at what scope.
|
||||
`docs/reference/telemetry.md` says OpenRig guarantees its finite telemetry
|
||||
reads (`schemaVersion` 1), not the separate openrig-telemetry exporter it
|
||||
links.
|
||||
- **Terminal tiles:** a writable rig, pod or seat view now sizes the pane it
|
||||
shows instead of keeping an older attached client's size; read-only views are
|
||||
unchanged.
|
||||
- **Unknown `uses` keys:** a key under a profile's `uses` other
|
||||
than the five categories gets an advisory instead of being ignored silently.
|
||||
|
||||
### Limits to keep in view
|
||||
|
||||
- **OpenShell** hasn't run end to end on 0.7.10 (above). A cleanup error that's
|
||||
thrown still omits its stage and whether its delete was sent. A
|
||||
same-generation reconciled launch opens no viewer, so run
|
||||
`rig environment connect` yourself; non-text startup actions aren't supported
|
||||
on environment seats.
|
||||
- **`rig exec`:** no real adapter has run under it yet; one run per row,
|
||||
detached ownership and reconciling were checked against a stand-in agent.
|
||||
Concurrent runs, long or background tasks, recovery across a reboot, MCP
|
||||
servers and Windows aren't covered. A Ctrl-C just before the connector is
|
||||
launched isn't caught by `--wait`.
|
||||
- **Draft-aware delivery to Claude:** a narrow Claude pane showing
|
||||
`✔ Update installed · Restart to update` on its own line can still hold
|
||||
deliveries at an empty prompt, queue wakes included; 0.7.10 doesn't recognize
|
||||
that bare banner. A held message isn't replayed automatically by that path.
|
||||
- **Seats handed over before 0.7.9** keep their adopted origin until a later
|
||||
launched handover or managed launch records a new session. Until then
|
||||
`rig seat stop`, `clean` and in-place restart refuse them, and now say why.
|
||||
Don't use `rig unclaim` to get around it: that ends OpenRig's management of
|
||||
the seat.
|
||||
- **Permission requests in seat status** show only the latest one, held in
|
||||
memory for five minutes and lost on a daemon restart.
|
||||
- **Agent spec advisories** don't show for a native resume that continues a
|
||||
saved conversation.
|
||||
- **The other 0.7.9 limits below still apply:** open updates for a person, Slack,
|
||||
SSH host status, shared asks, permit records, existing seats, Codex hooks, and
|
||||
grant-service assistants not being woken. 0.7.9's limits on a restore request
|
||||
held after compaction, `rig broadcast`'s `FAILED`, what has run under the
|
||||
runner contract, and Codex seats' `rig: command not found` are fixed or
|
||||
changed. The 0.7.8 limits below that 0.7.9
|
||||
didn't fix or change, and the
|
||||
[0.7.7](https://github.com/mvschwarz/openrig/releases/tag/v0.7.7) and
|
||||
[0.7.6](https://github.com/mvschwarz/openrig/releases/tag/v0.7.6) limits, still
|
||||
apply.
|
||||
|
||||
---
|
||||
|
||||
## What changed in OpenRig 0.7.9
|
||||
|
||||
The changes from 0.7.8 to 0.7.9, published on 2026-11-06, for an install
|
||||
upgrading from 0.7.8 or earlier.
|
||||
|
||||
### After the upgrade
|
||||
|
||||
- **Restart the daemon** to load the new code: `rig daemon stop`, then
|
||||
`rig daemon start`.
|
||||
- **Migrations 146 to 149** record a Pi seat's guidance file, what OpenRig last
|
||||
wrote in each managed guidance block, an index for reading a rig's outbox,
|
||||
and the stream intake position.
|
||||
- **Tell the person that stream intake is on.** Each `rig stream emit` now opens
|
||||
an intake row for the kernel queue worker (`queue.worker`), or adds a note to
|
||||
the one still unclaimed, and the row wakes the queue worker like any queue row.
|
||||
Earlier stream history isn't replayed. If they want 0.7.8's behaviour, with no
|
||||
intake rows, run `rig config set stream.intake off`.
|
||||
- **The bundled openrig-core plugin goes from 0.1.24 to 0.1.25.** Its
|
||||
seat-continuity-and-handover skill changed. Managed refresh preserves replaced
|
||||
files under `$OPENRIG_HOME/backups/managed-refresh/<id>/`; reapply local edits
|
||||
you still need from the preserved copy.
|
||||
- **Where seat guidance goes now:**
|
||||
- a fresh Pi seat gets OpenRig's guidance in its own agent folder's
|
||||
`AGENTS.md`, unless that file or folder is a link;
|
||||
- a fresh Codex seat on Codex 0.164.0 or later gets it as launch developer
|
||||
instructions, not in the repository `AGENTS.md`, when no config layer
|
||||
OpenRig reads sets `developer_instructions`;
|
||||
- a Codex resume, fork, restore or handover successor, an older or unknown
|
||||
Codex, and a config layer that sets `developer_instructions` or can't be read
|
||||
still use the repository `AGENTS.md`;
|
||||
- if only the person's shell startup sets `CODEX_HOME`, OpenRig reads
|
||||
`~/.codex`, so a `developer_instructions` set in their home is replaced for
|
||||
that session. Declare the same absolute path as `CODEX_HOME` in the member's
|
||||
or rig's `env:` to have it checked. `docs/reference/agent-startup-guide.md`
|
||||
("Codex guidance at launch") is the rule;
|
||||
- a managed block a person edited by hand is kept and named
|
||||
(`guidance_block_edited`), not replaced. Read the warning before you change
|
||||
the block.
|
||||
- **Slack:** on the first start after upgrading, the connector edits the asks
|
||||
that ended earlier to show they were handled, at Slack's edit rate: about an
|
||||
hour for 450 asks. `rig slack status` shows pending edits.
|
||||
- **OpenShell:** rebuild a guest image built for 0.7.8 with this release's
|
||||
helper; an older helper rejects the new startup step, and the error names the
|
||||
rebuild.
|
||||
|
||||
### Runner seats
|
||||
|
||||
Section 7 of `docs/reference/harness-runner-contract.md` is the rule for
|
||||
permissions, and section 4 for activity. Read them before writing or changing a
|
||||
runner.
|
||||
- **Typed words:** a reply of just yes or no counts as the agent's allow or
|
||||
reject. Any other words choose nothing: OpenRig's default permits the request
|
||||
when the agent offered an allow option, and the read result carries the words
|
||||
whole as `said`. A runner may queue them for the agent as an ordinary prompt
|
||||
after the current turn, never as the answer; one that drops them still keeps
|
||||
the contract. The ask now tells the person this.
|
||||
- **Permit records:** each permit OpenRig decides by default (no one to ask, or
|
||||
an ask closed without a choice, including a row passed to an agent seat) is
|
||||
recorded once, as a stream item from the seat, naming the tool call, why no
|
||||
person answered and the option given. Read them with
|
||||
`rig stream list --tag runner-permission-permit` or `--source <seat>`. A record
|
||||
has no destination and never changes the permit. Stream intake reads past it, so
|
||||
it opens no intake row and wakes no one.
|
||||
- **The working beat:** while a prompt is outstanding and the agent is alive, a
|
||||
runner may post `active` / `liveness` so a long call keeps reading working.
|
||||
After delivering an answer or permit, it posts `active` /
|
||||
`permission_resolved`. A runner that sends no beats behaves as before. The Pi
|
||||
runner and the dsh example post it.
|
||||
- **Check a runner** with
|
||||
`node daemon/docs/reference/runner-conformance/check-runner.mjs --runner '<command with {agent}>' --name <harness>`.
|
||||
It now covers the beat, and no longer falsely fails a correct runner on a busy
|
||||
machine.
|
||||
- **The dsh example** reads `DSH_SEAT_BIN` and `DSH_SEAT_HOME` from the member's
|
||||
`env:`, and section 8 names ACP's `session/resume`.
|
||||
|
||||
### OpenShell workers
|
||||
|
||||
0.7.9 includes a repair for the OpenShell worker that wasn't usable in 0.7.8,
|
||||
but the worker still doesn't work in 0.7.9. Don't rely on this path in 0.7.9.
|
||||
Startup reaches the guest through a hash-checked first-input file, and nothing
|
||||
is typed through the host viewer. Readiness checks the guest's own execution,
|
||||
and a failed owned delete reports the stage that failed. Release testing on
|
||||
0.7.9 then found two more problems: the guest's Claude couldn't reach its model
|
||||
(`SELF_SIGNED_CERT_IN_CHAIN`), because the guest launcher left out the CA
|
||||
settings the OpenShell exec supplies, and deleting an owned environment never
|
||||
sent the delete, because its inventory step misread a "Not Found" reply. 0.7.10's
|
||||
fixes for both are described above. "Never grant the guest access to the
|
||||
daemon's main API port (normally 7433)."
|
||||
|
||||
### Assistants connected through a grant service (experimental)
|
||||
|
||||
Experimental, unsupported, off by default, and at the person's own risk. With
|
||||
`rig mcp serve --http --grant-service <url>` and a grant service the person
|
||||
supplies and runs, a connected assistant can claim, update and park the rows
|
||||
addressed to its own seat, and see its team with `rig_team`, within the scope its
|
||||
existing tools have. Nothing wakes it: an item stays queued until its receiver
|
||||
claims it. Name the outside service, and the exposure of serving MCP over HTTP,
|
||||
to the person before you set this up. `docs/reference/hosted-members.md` is the
|
||||
rule.
|
||||
|
||||
### Other changes to act on
|
||||
|
||||
- **Held messages:** a seat that starts, or takes over a seat, with held
|
||||
messages is told how many there are. Read them with
|
||||
`rig seat held-messages <seat>`.
|
||||
- **Unconfirmed sends:** `rig send --verify` now reports unconfirmed and exits 1
|
||||
when it can't see what its Enter did, and `rig walk` reports unconfirmed
|
||||
instead of lost. Inspect the recipient before any resend.
|
||||
- **Deadlines:** to accept a re-estimate without unclaiming the work, run
|
||||
`rig queue update <id> --closure-required-at <ISO>`.
|
||||
- **Shared workspaces:** any registered person can settle another's ask as
|
||||
themselves, and the first committed answer wins; a later answer gets 409
|
||||
`qitem_already_terminal` and records nothing. A person registered with no
|
||||
connector binding is asked on Steering and in the Feed only.
|
||||
- **Handover:** a fresh or rebuild successor's first turn lists its startup
|
||||
files, role first. A successor that started without a required startup file is
|
||||
marked in the seat list.
|
||||
- **Ambiguous rig names:** when an active rig name matches more than one rig,
|
||||
`rig env down`, `status` and `logs` list the matching rig IDs and stop, and
|
||||
`rig doctor --spec` reports conformance as unknown. Use the rig ID.
|
||||
- **Seat activity:** window samples older than their bound no longer decide a
|
||||
seat's activity, so a seat whose pane can't be read reads unknown rather than
|
||||
its last verdict. Treat unknown as unknown, not idle.
|
||||
- **Launch options:** `specs.builtins_visible: false` hides built-in specs
|
||||
(`rig specs ls --include-builtins` still lists them); `plugin_channels` gives
|
||||
one Claude member its own `--channels`, untested with a real channel plugin.
|
||||
- **Files:** `rig down` removes OpenRig's own additions from a working folder
|
||||
once no live seat uses them. `rig export` with no `-o` writes
|
||||
`rig-export.yaml` beside an existing `rig.yaml`. `rig workspace validate` exits
|
||||
1 for malformed optional frontmatter.
|
||||
- **Kernels:** a new default kernel starts in `<workspace.root>/kernel`.
|
||||
Existing kernels stay where they are. `docs/reference/agent-startup-guide.md`
|
||||
explains the optional move; a fresh launch there starts a new native
|
||||
conversation, so ask the person before moving.
|
||||
- **PATH:** if preflight warns that a Codex seat's login shell can't find `rig`,
|
||||
follow its fix. `getting-started.md` explains the `~/.profile` line and how to
|
||||
check it with `bash -lc 'command -v node rig'`.
|
||||
|
||||
### Limits to keep in view
|
||||
|
||||
- **OpenShell:** the worker doesn't work in 0.7.9 (above). A same-generation
|
||||
reconciled launch opens no viewer, so run `rig environment connect` yourself;
|
||||
non-text startup actions aren't supported on environment seats.
|
||||
- **Open updates for a person** read a 1,001-row window, so a page can come back
|
||||
short, marked `truncated`.
|
||||
- **Slack:** a private channel needs the documented route, which isn't tested,
|
||||
and direct messages aren't supported. The channel and people reads haven't
|
||||
been run against a live Slack workspace. Redaction of the bot token from a
|
||||
transport error can still leave a malformed token fragment shorter than six
|
||||
characters, so review an error before sharing it.
|
||||
- **SSH host status** reads `unknown` behind a ProxyJump or ProxyCommand; that
|
||||
isn't proof the connection is broken. `rig host ls` reads the ssh config with
|
||||
`ssh -G`, which runs any `Match exec` command in it.
|
||||
- **Shared asks:** a click's option written before its answer row can stand
|
||||
beside or inside the closer's answer without the first clicker's authorship.
|
||||
- **Permit records** last as long as stream retention keeps them. A permit a
|
||||
runner makes on its own, when it can't reach OpenRig, is in its pane only. For
|
||||
an ask opened before 0.7.9, a record may omit the option's kind.
|
||||
- **Existing seats** pick up Codex launch instructions and the single
|
||||
openrig-core skill load at their next fresh launch. A `CLAUDE.md` or
|
||||
`AGENTS.md` that already grew a line per launch isn't trimmed.
|
||||
- **Codex hooks after an upgrade:** Codex sessions kept running across the
|
||||
upgrade show "Hook failed — plugin contents changed; hook approval required"
|
||||
until relaunched.
|
||||
- **0.7.8's limits** on the dsh `env:` names, conformance false failures, typed
|
||||
words, the README's operator address, a symlinked rebuild, the rebuild receipt,
|
||||
a re-parked click and unrecorded default permits are fixed or changed. The
|
||||
other 0.7.8 limits below, and the
|
||||
[0.7.7](https://github.com/mvschwarz/openrig/releases/tag/v0.7.7) and
|
||||
[0.7.6](https://github.com/mvschwarz/openrig/releases/tag/v0.7.6) limits, still
|
||||
apply.
|
||||
|
||||
---
|
||||
- **A new Claude seat's startup text can stay in its input box unsent.** If a fresh Claude seat sits idle with text at
|
||||
its prompt, press Enter in its pane, or ask the person to.
|
||||
- **Claude seats on a macOS native install can read as needing attention after a restore.** Check the pane before
|
||||
acting on it.
|
||||
- **A Slack channel-mapping change needs a daemon restart.** Don't use `rig slack disable` then `enable` for it: enabling
|
||||
marks any alerts waiting to post to Slack as already seen.
|
||||
- **Ubuntu 22.04's tmux 3.2a:** the query known to crash its server is gone, but a narrower race remains. Avoid 3.2a
|
||||
(`tmux -V`).
|
||||
- **`rig daemon start` can take 20 to 40 seconds** before its listener warning, longer than the TUI's 30-second "restore
|
||||
everything" wait. On a slow host, start the daemon yourself first.
|
||||
- **A launch that warns "Scoped tmux environment unavailable; retaining the previous environment"** didn't give that seat
|
||||
the clean environment. Check `env` in the seat before relying on OpenRig's variables.
|
||||
- **Going back to 0.7.0 with a Slack subscription set:** 0.7.0 can't read a person file that holds one, and its people
|
||||
registry stops loading. Save the choice first (`rig gateway human show <id> --json > <id>.json`), then run
|
||||
`rig gateway human set <id> slack-subscription unset` (`urgent-only` becomes every-ask, as on 0.7.0); set it again after
|
||||
returning to 0.7.1. A person file still holding a `subscriptions:` entry fails on 0.7.0 too. Don't restore an older
|
||||
gateway folder or database instead: that can lose newer people, bindings and delivery records.
|
||||
- **A Herdr tile over SSH** doesn't come back by itself after a Herdr restart. Reopen it with `rig terminal open <view>
|
||||
--provider herdr`.
|
||||
- **Delegated decisions:** the ask is created before the decision, so without quiet hours it can post to the person
|
||||
first. Pair a decision room with quiet hours, or decide before asking.
|
||||
- **`restore-check` still treats a missing startup file as rig-wide.** Trust `rig up --existing`'s per-seat result.
|
||||
- **Managed Claude launch requires `/usr/bin/env` support for `-0` and `-u`** (option support checked on GNU coreutils
|
||||
and macOS 15; BusyBox and older macOS unverified). Without the required option support, launch stops instead of
|
||||
falling back to credential-bearing command-line arguments.
|
||||
- **In draft-aware mode, an agent's multi-line message to a Claude seat can remain unsubmitted when the paste changes
|
||||
Claude's footer hint.** OpenRig withholds Enter, leaving the text in the composer and later sends waiting. Observed on
|
||||
Claude 2.1.309 with both 0.7.0 and the 0.7.1 pre-cut build; #14 remains open.
|
||||
- **A Claude seat signed in with a long-lived token (`claude setup-token`) gets no usage reading.** The reader accepts
|
||||
only claude.ai subscription logins and reports `not_subscription_login`.
|
||||
Generated
+6
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openrig",
|
||||
"version": "0.7.12",
|
||||
"version": "0.7.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openrig",
|
||||
"version": "0.7.12",
|
||||
"version": "0.7.1",
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
"packages/daemon",
|
||||
@@ -7298,7 +7298,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@openrig/cli",
|
||||
"version": "0.7.12",
|
||||
"version": "0.7.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -7336,11 +7336,12 @@
|
||||
},
|
||||
"packages/daemon": {
|
||||
"name": "@openrig/daemon",
|
||||
"version": "0.7.12",
|
||||
"version": "0.7.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.0",
|
||||
"@hono/node-ws": "^1.3.1",
|
||||
"@modelcontextprotocol/sdk": "^1.32.2",
|
||||
"better-sqlite3": "^13.0.3",
|
||||
"hono": "^4.7.0",
|
||||
"smol-toml": "^1.8.0",
|
||||
@@ -7379,7 +7380,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@openrig/ui",
|
||||
"version": "0.7.12",
|
||||
"version": "0.7.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openrig",
|
||||
"version": "0.7.12",
|
||||
"version": "0.7.1",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openrig/acp",
|
||||
"version": "0.7.12",
|
||||
"version": "0.7.1",
|
||||
"description": "OpenRig as an agent in ACP apps: starts rig acp serve from the @openrig/cli it pins.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -15,7 +15,7 @@
|
||||
"node": ">=22"
|
||||
},
|
||||
"dependencies": {
|
||||
"@openrig/cli": "0.7.12"
|
||||
"@openrig/cli": "0.7.1"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"author": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "openrig",
|
||||
"name": "OpenRig",
|
||||
"version": "0.7.12",
|
||||
"version": "0.7.1",
|
||||
"description": "Send work to your OpenRig team of agents and follow it through to the result",
|
||||
"repository": "https://github.com/mvschwarz/openrig",
|
||||
"website": "https://openrig.dev",
|
||||
@@ -12,7 +12,7 @@
|
||||
"license_url": "https://github.com/mvschwarz/openrig/blob/main/LICENSE",
|
||||
"distribution": {
|
||||
"npx": {
|
||||
"package": "@openrig/[email protected]2"
|
||||
"package": "@openrig/[email protected]"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openrig/cli",
|
||||
"version": "0.7.12",
|
||||
"version": "0.7.1",
|
||||
"type": "module",
|
||||
"description": "Build your own network of agents from Claude Code, Codex and Pi: persistent teams with roles, shared context and owned work.",
|
||||
"keywords": [
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
OPENRIG_DIR,
|
||||
STATE_FILE,
|
||||
resolveBindIntent,
|
||||
statusGuardMessage,
|
||||
} from "../daemon-lifecycle.js";
|
||||
|
||||
interface ProcessAliveDeps {
|
||||
@@ -37,14 +38,16 @@ export function createIsProcessAlive(deps: ProcessAliveDeps): (pid: number) => b
|
||||
|
||||
export type SignalOutcome = "sent" | "missing" | "not-permitted";
|
||||
|
||||
/** kill(pid, 0) as an outcome. POSIX: only ESRCH means no such process; EPERM means the process
|
||||
* EXISTS but this shell may not signal it (another user's process, or a sandbox such as Codex's). */
|
||||
/** kill(pid, 0) as an outcome. POSIX: kill(pid, 0) fails only with ESRCH (no such process) or EPERM
|
||||
* (the process EXISTS but this shell may not signal it: another user's process, or a sandbox such as
|
||||
* Codex's). Only ESRCH is "missing"; any other error is not evidence of death, so it reads as
|
||||
* "not-permitted" (uninspectable), never as a dead process. */
|
||||
export function signalProbe(pid: number, kill: (pid: number, signal: 0) => unknown = process.kill): SignalOutcome {
|
||||
try {
|
||||
kill(pid, 0);
|
||||
return "sent";
|
||||
} catch (error) {
|
||||
return (error as NodeJS.ErrnoException).code === "EPERM" ? "not-permitted" : "missing";
|
||||
return (error as NodeJS.ErrnoException).code === "ESRCH" ? "missing" : "not-permitted";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,6 +162,11 @@ export function realDeps(): LifecycleDeps {
|
||||
isProcessAlive,
|
||||
// RULING 1ae863d2 — sibling-home scan (home-resolution honesty).
|
||||
listDir: (p) => { try { return fs.readdirSync(p); } catch { return []; } },
|
||||
// #2860: where the OS has pid namespaces (Linux), a missing pid is only proof from the daemon's own.
|
||||
pidNamespace: () => {
|
||||
if (process.platform !== "linux") return "host";
|
||||
try { return fs.readlinkSync("/proc/self/ns/pid"); } catch { return undefined; }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -369,6 +377,12 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
|
||||
console.log(` resolved: ${status.siblingHint.resolvedHome}`);
|
||||
console.log(` sibling: ${status.siblingHint.siblingHome}`);
|
||||
console.log(" Fix: point OPENRIG_HOME at the right home (or check your shell env) — this CLI is likely resolving the wrong home.");
|
||||
} else if (status.pidUnseen) {
|
||||
// #2860: the same words as the command guard.
|
||||
const unseen = statusGuardMessage(status);
|
||||
console.log(unseen.fact);
|
||||
console.log(` ${unseen.consequence}`);
|
||||
console.log(` ${unseen.action}`);
|
||||
} else if (!status.startPending) {
|
||||
// A pending start's probe was refused, not inconclusive; its line above says what is known.
|
||||
console.log("Daemon state UNVERIFIED — the probe timed out or was inconclusive (this is NOT evidence the daemon is down).");
|
||||
|
||||
@@ -24,9 +24,8 @@ import { existsSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { hostname as osHostname, userInfo } from "node:os";
|
||||
import { connect } from "node:net";
|
||||
import { execFile } from "node:child_process";
|
||||
import { getOpenRigHome } from "../openrig-compat.js";
|
||||
import { addHostEntry, updateHostEntry, removeHostEntry, defaultHostRegistryPath, loadHostRegistry, validateHostRegistry, hostDisplayTarget, resolveHost, resolveRemoteBearer, bearerAuthHeaders, type HostEntry, type HttpHostEntry, type SshHostEntry } from "../host-registry.js";
|
||||
import { addHostEntry, updateHostEntry, removeHostEntry, defaultHostRegistryPath, loadHostRegistry, validateHostRegistry, hostDisplayTarget, resolveHost, resolveRemoteBearer, bearerAuthHeaders, type HostEntry, type HttpHostEntry } from "../host-registry.js";
|
||||
import { runCrossHostCommand, type CrossHostResult } from "../cross-host-executor.js";
|
||||
import { recordHostObservation, loadHostBindings, describeBindingConflict, defaultHostBindingsPath, type HostObservationOutcome } from "../host-bindings.js";
|
||||
import { DaemonClient, operatorAuthHeaders } from "../client.js";
|
||||
@@ -168,32 +167,13 @@ function defaultDoctorDeps(): DoctorDeps {
|
||||
};
|
||||
}
|
||||
|
||||
/** Where ssh would connect for this host, from `ssh -G`, which reads ssh config without connecting.
|
||||
* Null when ssh can't resolve the target or a ProxyJump/ProxyCommand sits in between, since no
|
||||
* direct dial stands for that transport. */
|
||||
export function resolveSshEndpoint(host: SshHostEntry, timeoutMs = 1500): Promise<{ hostname: string; port: number } | null> {
|
||||
const args = [...(host.user ? ["-l", host.user] : []), "-G", host.target];
|
||||
return new Promise((resolve) => {
|
||||
execFile("ssh", args, { timeout: timeoutMs }, (error, stdout) => {
|
||||
if (error) return resolve(null);
|
||||
const conf = new Map(stdout.split("\n").map((line) => {
|
||||
const i = line.indexOf(" ");
|
||||
return [line.slice(0, i).toLowerCase(), line.slice(i + 1).trim()] as const;
|
||||
}));
|
||||
const proxied = ["proxyjump", "proxycommand"].some((k) => (conf.get(k) ?? "none") !== "none");
|
||||
const hostname = conf.get("hostname");
|
||||
const port = Number(conf.get("port"));
|
||||
resolve(hostname && Number.isInteger(port) && !proxied ? { hostname, port } : null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** FR-1 doctor legs — each failing step maps to a DISTINCT actionable error:
|
||||
* "SSH works but daemon is down" ≠ "daemon works but registry is wrong" ≠
|
||||
* "remote rig binary missing/old". */
|
||||
/** OPR.0.4.6.MH1 FR-3 — the coarse ls status word: a BOUNDED tcp dial
|
||||
* against the host's transport endpoint (ssh: the host and port ssh itself
|
||||
* resolves, see resolveSshEndpoint; http: the URL's host:port). Coarse by
|
||||
* against an HTTP host's URL host:port; passive SSH entries report
|
||||
* `unknown` without running `ssh` or opening a raw TCP socket (explicit SSH
|
||||
* verification lives in `rig host doctor`). Coarse by
|
||||
* design (the kubectl STATUS / docker context ls precedent): reachable |
|
||||
* unreachable | unknown — never a hang (hard timeout), never a guess (dial
|
||||
* failure = unreachable, probe error/unparseable endpoint = unknown). */
|
||||
@@ -203,17 +183,12 @@ export async function probeHostStatus(
|
||||
timeoutMs = 1500,
|
||||
): Promise<"reachable" | "unreachable" | "unknown"> {
|
||||
try {
|
||||
let target: string;
|
||||
let port: number;
|
||||
if (host.transport === "ssh") {
|
||||
const endpoint = await resolveSshEndpoint(host, timeoutMs);
|
||||
if (!endpoint) return "unknown";
|
||||
({ hostname: target, port } = endpoint);
|
||||
} else {
|
||||
const u = new URL(host.url);
|
||||
target = u.hostname.replace(/^\[|\]$/g, "");
|
||||
port = u.port ? Number(u.port) : u.protocol === "https:" ? 443 : 80;
|
||||
return "unknown";
|
||||
}
|
||||
const u = new URL(host.url);
|
||||
const target = u.hostname.replace(/^\[|\]$/g, "");
|
||||
const port = u.port ? Number(u.port) : u.protocol === "https:" ? 443 : 80;
|
||||
const r = await tcpProbe(target, port, timeoutMs);
|
||||
return r === "open" ? "reachable" : r === "closed" ? "unreachable" : "unknown";
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Command } from "commander";
|
||||
import { lstatSync, readFileSync } from "node:fs";
|
||||
import { DaemonClient, operatorAuthHeaders } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl, daemonStatusGuard, type LifecycleDeps } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
|
||||
export interface NetworkDeps {
|
||||
lifecycleDeps: LifecycleDeps;
|
||||
clientFactory: (url: string) => Pick<DaemonClient, "get" | "post">;
|
||||
readTokenFile?: (path: string) => string;
|
||||
}
|
||||
|
||||
interface Binding {
|
||||
id: string; localSession: string; hubUrl: string; hubSeat: string; state: string; stateReason: string | null;
|
||||
lastContactAt: string | null; lastError: string | null; unencrypted?: boolean;
|
||||
/** Hub items someone else closed before this binding's result reached them. */
|
||||
closedElsewhere?: Array<{ hubItem: string; localItem: string }>;
|
||||
}
|
||||
|
||||
/** The participant's token, read from the file the account gave the person. A file others can read is used, with a note. */
|
||||
function defaultReadTokenFile(path: string): string {
|
||||
const stat = lstatSync(path);
|
||||
if (!stat.isFile()) throw new Error(`${path} isn't a regular file.`);
|
||||
if ((stat.mode & 0o077) !== 0) console.error(`Note: ${path} can be read by other users of this machine; OpenRig keeps its own private copy.`);
|
||||
return readFileSync(path, "utf8").trim();
|
||||
}
|
||||
|
||||
function line(b: Binding): string {
|
||||
const contact = b.lastContactAt ? `last contact ${b.lastContactAt}` : "no contact yet";
|
||||
const state = b.state === "active" ? contact : `${b.state}${b.stateReason ? ` (${b.stateReason})` : ""}`;
|
||||
const plain = b.unencrypted ? " UNENCRYPTED (http, allowed for this binding)" : "";
|
||||
const conflicts = (b.closedElsewhere ?? []).map((c) => `\n hub item ${c.hubItem} was closed elsewhere before its result arrived; the result stays on ${c.localItem}`).join("");
|
||||
return `${b.id} ${b.localSession} -> ${b.hubSeat} at ${b.hubUrl}${plain} ${state}${b.lastError && b.state === "active" ? ` last error: ${b.lastError}` : ""}${conflicts}`;
|
||||
}
|
||||
|
||||
/** `rig network`: connect a selected local seat to a participant of a remote hub (an account's network rig, or
|
||||
* another OpenRig hub), see where each connection stands, and disconnect one. */
|
||||
export function networkCommand(depsOverride?: NetworkDeps): Command {
|
||||
const cmd = new Command("network")
|
||||
.description("Connect selected local seats to a remote hub, see their status, and disconnect them");
|
||||
const getDeps = (): NetworkDeps => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
|
||||
|
||||
const client = async () => {
|
||||
const deps = getDeps();
|
||||
const daemon = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (!daemonStatusGuard(daemon)) return null;
|
||||
return deps.clientFactory(getDaemonUrl(daemon));
|
||||
};
|
||||
const finish = (status: number, data: unknown, json: boolean | undefined, ok: (data: Record<string, unknown>) => string) => {
|
||||
if (status >= 400) {
|
||||
const error = data as { error?: string; message?: string };
|
||||
console.error(json ? JSON.stringify(data) : `${error.message ?? "The daemon refused this"} (${error.error ?? status})`);
|
||||
process.exitCode = status >= 500 ? 2 : 1;
|
||||
return;
|
||||
}
|
||||
console.log(json ? JSON.stringify(data, null, 2) : ok(data as Record<string, unknown>));
|
||||
};
|
||||
|
||||
cmd.command("connect")
|
||||
.description("Connect a local seat to a hub participant, using that participant's token")
|
||||
.argument("<seat>", "The local seat to connect (its address, member@rig)")
|
||||
.requiredOption("--hub <url>", "The hub's MCP URL")
|
||||
.requiredOption("--as <participant>", "The participant's address in the hub (member@rig)")
|
||||
.requiredOption("--token-file <path>", "A file holding the participant's token")
|
||||
.option("--allow-http", "Accept a plain-http hub for this binding: its token and work cross the network unencrypted")
|
||||
.option("--json", "Print JSON")
|
||||
.action(async (seat: string, opts: { hub: string; as: string; tokenFile: string; allowHttp?: boolean; json?: boolean }) => {
|
||||
let token: string;
|
||||
try { token = (getDeps().readTokenFile ?? defaultReadTokenFile)(opts.tokenFile); } catch (error) {
|
||||
console.error(`Can't read the token file: ${(error as Error).message}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const daemon = await client();
|
||||
if (!daemon) return;
|
||||
const res = await daemon.post<Record<string, unknown>>("/api/network/bindings", { session: seat, hubUrl: opts.hub, hubSeat: opts.as, token, allowHttp: opts.allowHttp === true }, { headers: operatorAuthHeaders() });
|
||||
finish(res.status, res.data, opts.json, (b) => `Connected ${line(b as unknown as Binding)}\nWork for ${opts.as} reaches ${seat} once the connector runs; rig network status shows its last contact.`);
|
||||
});
|
||||
|
||||
cmd.command("status")
|
||||
.description("List the connections and where each stands")
|
||||
.option("--json", "Print JSON")
|
||||
.action(async (opts: { json?: boolean }) => {
|
||||
const daemon = await client();
|
||||
if (!daemon) return;
|
||||
const res = await daemon.get<{ bindings: Binding[] }>("/api/network/bindings");
|
||||
finish(res.status, res.data, opts.json, (d) => {
|
||||
const bindings = (d.bindings ?? []) as Binding[];
|
||||
return bindings.length ? bindings.map(line).join("\n") : "No seats are connected to a hub.";
|
||||
});
|
||||
});
|
||||
|
||||
cmd.command("disconnect")
|
||||
.description("Disconnect a seat: no new work comes in through it; its local work and results stay")
|
||||
.argument("<binding-or-seat>", "The binding id, or the connected seat's address")
|
||||
.option("--reason <text>", "Why, for the record")
|
||||
.option("--json", "Print JSON")
|
||||
.action(async (ref: string, opts: { reason?: string; json?: boolean }) => {
|
||||
const daemon = await client();
|
||||
if (!daemon) return;
|
||||
const res = await daemon.post<Record<string, unknown>>(`/api/network/bindings/${encodeURIComponent(ref)}/disconnect`, { reason: opts.reason }, { headers: operatorAuthHeaders() });
|
||||
finish(res.status, res.data, opts.json, (b) => `Disconnected ${line(b as unknown as Binding)}\nRemove the participant on the hub too, so the account stops listing it.`);
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -137,6 +137,7 @@ export function packageCommand(depsOverride?: StatusDeps): Command {
|
||||
conflicts?: Array<{ existingPath: string; reason: string }>;
|
||||
verification?: { passed: boolean };
|
||||
policyRejected?: Array<{ entry: { exportType: string; exportName: string }; reason: string }>;
|
||||
notes?: string[];
|
||||
error?: string;
|
||||
errors?: string[];
|
||||
code?: string;
|
||||
@@ -204,6 +205,11 @@ export function packageCommand(depsOverride?: StatusDeps): Command {
|
||||
console.log(` ${r.entry.exportType.padEnd(12)} ${r.entry.exportName.padEnd(20)} ${r.reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (res.data.notes && res.data.notes.length > 0) {
|
||||
console.log("Left as it is:");
|
||||
for (const note of res.data.notes) console.log(` ${note}`);
|
||||
}
|
||||
});
|
||||
|
||||
// rig package rollback <installId>
|
||||
|
||||
@@ -1395,6 +1395,13 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
.option("--summary <text>", "Summary persisted onto the item (human-seat parks, or standalone summary update without --state)")
|
||||
.option("--evidence-ref <path>", "Pointer to the durable artifact, persisted onto the item on a human-seat park or a done close (--state done). For a code result, a JSON file of kind openrig.code-result/v1 (repo, base and revision as full commit ids, paths) lets a reader diff it exactly")
|
||||
.option("--note <text>", "Transition note for the audit log")
|
||||
.addHelpText("after", `
|
||||
After reading an existing remote successor, record its exact closure target, including the host:
|
||||
rig queue update <source-id> --note "custody-verified: receiver@team@remote-host. Registered-host read confirmed the successor"
|
||||
The note can accompany the closing update or be appended later; a separate note is not required.
|
||||
A trailing period, comma, semicolon, colon or closing parenthesis is accepted; a longer or different target is not.
|
||||
For a new cross-host transfer, prefer rig queue handoff --host <registered-host>.
|
||||
`)
|
||||
.option("--source <name@external>", "Outside a managed seat: the external routine that appends the --note, recorded as an external actor (claimed:v1), never a seat; note only, on this host's daemon (a --host that resolves to another host is refused). A managed seat stays the actor. Like any note, it resets the item's stall and notification timing (and its recovery and operator episodes) and advances a claimed item's last activity, but never resets how long an unclaimed item has waited")
|
||||
.option("--host <id>", QUEUE_HOST_ROW_OPTION_HELP)
|
||||
.option("--full", "Print the whole response row(s) as today, even when output.compact is on (the receipt's waiting keeps only blocker and nextBackstop)")
|
||||
|
||||
@@ -1493,9 +1493,10 @@ Examples:
|
||||
.addHelpText("after", `
|
||||
Siblings are untouched. A dead seat is refused (use rig seat clean). An adopted
|
||||
session is refused: to end only it, run rig unclaim <session> (it releases the
|
||||
session and leaves its process running), then end that process in its own
|
||||
terminal. rig down <rig> ends every seat in its rig, so it is the route only
|
||||
for a one-seat rig.
|
||||
session and leaves its process running), then end that process and the
|
||||
underlying tmux session, including any remaining shell. Closing only its
|
||||
viewing window leaves the tmux session running. rig down <rig> leaves a
|
||||
session OpenRig didn't start running.
|
||||
A seat handed over (fresh, fork or rebuild) before OpenRig 0.7.9 still reads
|
||||
adopted and is refused too, until its next launched handover or managed launch;
|
||||
the refusal says so. Stopping ends that agent's session and any work in progress:
|
||||
|
||||
@@ -85,7 +85,7 @@ export function watchdogCommand(depsOverride?: WatchdogDeps): Command {
|
||||
"Context transcripts measured 113K–153K tokens/MB. The margin is the protection " +
|
||||
"because prompt-bound consumers act only at turn boundaries",
|
||||
)
|
||||
.option("--spec <path>", "Path to YAML spec file (optional for context-usage-threshold)")
|
||||
.option("--spec <path>", "YAML spec file (required except for context-usage-threshold)")
|
||||
.requiredOption("--policy <policy>", "Policy name (one of: periodic-reminder, artifact-pool-ready, edge-artifact-required, workflow-keepalive, idle-gate-qitem, context-usage-threshold)")
|
||||
.requiredOption("--target-session <session>", "Canonical <member>@<rig> target")
|
||||
.requiredOption("--interval-seconds <n>", "Evaluation interval (positive integer)")
|
||||
@@ -100,7 +100,27 @@ export function watchdogCommand(depsOverride?: WatchdogDeps): Command {
|
||||
.option("--active-wake-interval-seconds <n>", "(pool-ready-specific) wake-up cadence when actionable artifacts exist")
|
||||
.option("--scan-interval-seconds <n>", "(pool-ready-specific) artifact pool scan cadence")
|
||||
.option("--json", "JSON output for agents")
|
||||
.addHelpText("after", "\nTranscript density varies by session shape; do not tune a context threshold near the context wall.\n\nThe returned job id can be attached to a deliberate park with: rig queue block <qitemId> --on <blocker> --continuation <what-resumes> --wake-watchdog <jobId>")
|
||||
.addHelpText("after", `
|
||||
Recurring reminder for an agent (not a person's one-shot queue watch):
|
||||
Save periodic-reminder.yaml:
|
||||
policy: periodic-reminder
|
||||
target:
|
||||
session: worker@my-team
|
||||
message: "Finish the current action, then check the pending work."
|
||||
|
||||
rig watchdog register --spec periodic-reminder.yaml --policy periodic-reminder \\
|
||||
--target-session worker@my-team --interval-seconds 600 \\
|
||||
--registered-by coordinator@my-team
|
||||
|
||||
All five flags above are required for periodic-reminder. At registration,
|
||||
--policy selects the policy; a YAML policy field is not the selector.
|
||||
YAML target.session takes precedence over --target-session; keep them aligned.
|
||||
Put the reminder message in YAML. --message is only a context-usage-threshold shortcut.
|
||||
For a person's one-shot watch, use rig queue block --wake-after instead.
|
||||
|
||||
Transcript density varies by session shape; do not tune a context threshold near the context wall.
|
||||
|
||||
The returned job id can be attached to a deliberate park with: rig queue block <qitemId> --on <blocker> --continuation <what-resumes> --wake-watchdog <jobId>`)
|
||||
.action(async (opts: {
|
||||
spec?: string;
|
||||
policy: string;
|
||||
|
||||
@@ -101,7 +101,12 @@ export async function runCrossHostCommand(
|
||||
? `OPENRIG_SESSION_NAME=${shellQuote(opts.originTriple)} ${quotedArgv}`
|
||||
: quotedArgv;
|
||||
const remoteCommandLine = `sh -lc ${shellQuote(innerCommand)}`;
|
||||
const fullArgs = [...sshOpts, sshHost.target, remoteCommandLine];
|
||||
// SEP-1: `--` ends ssh's own option parsing, so a target that begins with `-` reaches ssh as the
|
||||
// destination instead of being read as one more option. That misreading is the harm: `-V`, for
|
||||
// instance, is ssh's own version flag, parsed and exited 0 before any connection, which this
|
||||
// executor would count as a success with no remote command run. With the separator, ssh rejects
|
||||
// the name itself.
|
||||
const fullArgs = [...sshOpts, "--", sshHost.target, remoteCommandLine];
|
||||
|
||||
const spawn = opts.spawn ?? (nodeSpawn as unknown as SpawnFn);
|
||||
const child = spawn("ssh", fullArgs);
|
||||
|
||||
@@ -22,8 +22,15 @@ export interface DaemonState {
|
||||
host?: string;
|
||||
db: string;
|
||||
startedAt: string;
|
||||
/** The pid namespace (Linux `/proc/self/ns/pid`) of the shell that started the daemon. A shell
|
||||
* in another namespace cannot tell a missing pid from an invisible one, nor map the number.
|
||||
* Absent on older records. */
|
||||
pidNamespace?: string;
|
||||
}
|
||||
|
||||
/** Linux' initial pid namespace: where an older record, which names no namespace, is read. */
|
||||
const INITIAL_PID_NAMESPACE = "pid:[4026531836]";
|
||||
|
||||
/**
|
||||
* OPR.0.4.3.21 — event-loop wedge evidence read from the enriched `/healthz`
|
||||
* body. Present only when the daemon actually answered healthz with a monitor
|
||||
@@ -64,6 +71,10 @@ export interface DaemonStatus {
|
||||
eventLoop?: DaemonEventLoopEvidence;
|
||||
/** #275 — the OS code behind a failed health probe (e.g. EPERM when a sandbox blocked it). */
|
||||
probeErrorCode?: string;
|
||||
/** The recorded pid looks missing and the connection was refused, but this shell is not in the
|
||||
* daemon's pid namespace and no clean-shutdown receipt matches: nothing here shows the daemon is
|
||||
* down (#2860). */
|
||||
pidUnseen?: true;
|
||||
/** T2 — a refused listener under a live start reservation (daemon-start.lock): a launch is in
|
||||
* progress and not listening yet. `pid` is the live launcher (else child) PID; `since` its start. */
|
||||
startPending?: { pid: number; since?: string };
|
||||
@@ -152,6 +163,15 @@ export function statusGuardMessage(status: DaemonStatus): DaemonNotRunningError
|
||||
...causeCode,
|
||||
};
|
||||
}
|
||||
// #2860: a refused probe and a missing pid, seen from outside the daemon's pid namespace.
|
||||
if (status.pidUnseen) {
|
||||
return {
|
||||
fact: "Couldn't connect to the daemon from this shell; it may still be running.",
|
||||
consequence: "This shell can't see the daemon's process (a sandbox or another namespace) and no clean shutdown was recorded, so a refused connection here doesn't show it is down.",
|
||||
action: "Don't start another daemon from here. Check from a normal terminal with 'rig daemon status'.",
|
||||
...causeCode,
|
||||
};
|
||||
}
|
||||
// unverified, or running-but-unhealthy: we do NOT know it is down.
|
||||
return {
|
||||
fact: "Daemon did not respond — it may be busy or stopped (state not confirmed).",
|
||||
@@ -248,6 +268,9 @@ export interface LifecycleDeps {
|
||||
/** #275 — three-state liveness for status reads (production: realDeps). Optional so existing
|
||||
* mocks keep the boolean rule; absent = derived from isProcessAlive. */
|
||||
processLiveness?: (pid: number) => ProcessLiveness;
|
||||
/** This shell's pid namespace ("host" where the OS has none, undefined when unreadable).
|
||||
* Production: realDeps. Absent = no namespace evidence, a missing pid counts as dead. */
|
||||
pidNamespace?: () => string | undefined;
|
||||
// OPR.0.4.2.1 — optional injectable delay for the status-probe bounded settle/retry.
|
||||
// Defaults to a real setTimeout in production; tests pass a no-op to stay fast. Optional so
|
||||
// existing deps / mocks / callers are untouched.
|
||||
@@ -889,7 +912,8 @@ async function startOwnedDaemon(opts: StartOptions, deps: LifecycleDeps, lock: D
|
||||
}
|
||||
assertChild();
|
||||
if (!deps.isProcessAlive(pid!)) throw new Error(`Daemon child ${pid} liveness could not be confirmed; state not published`);
|
||||
const state: DaemonState = { pid: pid!, port, host: probeHost, db, startedAt: new Date().toISOString() };
|
||||
const namespace = deps.pidNamespace?.();
|
||||
const state: DaemonState = { pid: pid!, port, host: probeHost, db, startedAt: new Date().toISOString(), ...(namespace && namespace !== "host" ? { pidNamespace: namespace } : {}) };
|
||||
try {
|
||||
deps.writeFile(STATE_FILE, JSON.stringify(state, null, 2));
|
||||
assertChild();
|
||||
@@ -950,6 +974,17 @@ function isCleanShutdown(receipt: DaemonShutdownReceipt | undefined): boolean {
|
||||
return receipt?.outcome === "clean" && receipt.phase === "complete" && receipt.failures.length === 0;
|
||||
}
|
||||
|
||||
/** Whether the recorded pid number means the same process in this shell. A recorded namespace must
|
||||
* be this shell's own: another namespace (even the host's) numbers processes differently, so its
|
||||
* missing number says nothing. Only an older record, which names none, is read as the host's. */
|
||||
function sharesPidNamespace(state: DaemonState, deps: LifecycleDeps): boolean {
|
||||
if (!deps.pidNamespace) return true;
|
||||
const own = deps.pidNamespace();
|
||||
if (own === undefined) return false;
|
||||
if (state.pidNamespace) return own === state.pidNamespace;
|
||||
return own === "host" || own === INITIAL_PID_NAMESPACE;
|
||||
}
|
||||
|
||||
function removeMatchingState(deps: LifecycleDeps, stateFile: string, state: DaemonState): void {
|
||||
// A concurrent start/rebind must not have its state removed by an old stop/status.
|
||||
const current = readState(deps);
|
||||
@@ -1153,7 +1188,23 @@ export async function getDaemonStatus(
|
||||
return { state: "running", port: Number(url.port) || DEFAULT_PORT, host: url.hostname || DEFAULT_HOST, healthy: ev.healthy, reason: ev.reason, eventLoop: ev.eventLoop, ...(ev.databaseWarnings ? { databaseWarnings: ev.databaseWarnings } : {}) };
|
||||
} catch (err) {
|
||||
// 1ae863d2: refusal = positive down; anything else (timeout/wedged/blocked) = unverified.
|
||||
return isRefusedError(err) ? { state: "stopped" } : { state: "unverified", ...endpoint, ...probeErrorCodeOf(err) };
|
||||
if (!isRefusedError(err)) return { state: "unverified", ...endpoint, ...probeErrorCodeOf(err) };
|
||||
// #2860: a loopback URL that is this home's recorded daemon is judged like the record: a refusal
|
||||
// alone proves nothing from a shell that can't see the pid. No record for it keeps the plain rule.
|
||||
const recorded = "explicitRemote" in endpoint ? null : readState(deps);
|
||||
if (!recorded || recorded.port !== ("port" in endpoint ? endpoint.port : undefined)) return { state: "stopped" };
|
||||
const recordedHost = recorded.host ?? DEFAULT_HOST;
|
||||
const recordedLiveness: ProcessLiveness = deps.processLiveness
|
||||
? deps.processLiveness(recorded.pid)
|
||||
: deps.isProcessAlive(recorded.pid) ? "alive" : "dead";
|
||||
const { receipt } = readShutdownReceipt(deps, resolveLifecycleFile(deps, "daemon.json"));
|
||||
if ((receiptMatchesState(receipt, recorded) && isCleanShutdown(receipt))
|
||||
|| (recordedLiveness === "dead" && sharesPidNamespace(recorded, deps))) return { state: "stopped" };
|
||||
const startPending = liveStartReservation(deps);
|
||||
return {
|
||||
state: "unverified", pid: recorded.pid, port: recorded.port, host: recordedHost, ...(startPending ? { startPending } : {}),
|
||||
...(recordedLiveness !== "alive" && !startPending ? { pidUnseen: true as const } : {}), ...probeErrorCodeOf(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1187,17 +1238,21 @@ export async function getDaemonStatus(
|
||||
}
|
||||
}
|
||||
|
||||
const liveness: ProcessLiveness = deps.processLiveness
|
||||
let liveness: ProcessLiveness = deps.processLiveness
|
||||
? deps.processLiveness(state.pid)
|
||||
: deps.isProcessAlive(state.pid) ? "alive" : "dead";
|
||||
// #2860: down needs positive evidence: the daemon's clean-shutdown receipt, or a missing pid seen
|
||||
// from the daemon's own pid namespace. Elsewhere a missing pid may just be invisible.
|
||||
const stateFile = resolveLifecycleFile(deps, "daemon.json");
|
||||
const { receipt } = readShutdownReceipt(deps, stateFile);
|
||||
const cleanShutdown = receiptMatchesState(receipt, state) && isCleanShutdown(receipt);
|
||||
if (liveness === "dead") {
|
||||
// A status read must not erase the identity needed to judge a failed stop.
|
||||
const stateFile = resolveLifecycleFile(deps, "daemon.json");
|
||||
const { receipt } = readShutdownReceipt(deps, stateFile);
|
||||
if (options.cleanupStaleState !== false && receiptMatchesState(receipt, state) && isCleanShutdown(receipt)) {
|
||||
if (cleanShutdown && options.cleanupStaleState !== false) {
|
||||
// A status read must not erase the identity needed to judge a failed stop.
|
||||
removeMatchingState(deps, stateFile, state);
|
||||
}
|
||||
return { state: "stale" };
|
||||
if (cleanShutdown || sharesPidNamespace(state, deps)) return { state: "stale" };
|
||||
liveness = "unknown";
|
||||
}
|
||||
|
||||
const host = state.host ?? DEFAULT_HOST;
|
||||
@@ -1213,7 +1268,9 @@ export async function getDaemonStatus(
|
||||
// T2: unless a live start reservation says a launch is in progress.
|
||||
if (isRefusedError(err)) {
|
||||
const startPending = liveStartReservation(deps);
|
||||
return startPending ? { state: "unverified", pid: state.pid, port: state.port, host, startPending, ...probeErrorCodeOf(err) } : { state: "stopped" };
|
||||
if (startPending) return { state: "unverified", pid: state.pid, port: state.port, host, startPending, ...probeErrorCodeOf(err) };
|
||||
// #2860: a refusal from a shell that can't see the pid proves nothing without a clean shutdown.
|
||||
return cleanShutdown ? { state: "stopped" } : { state: "unverified", pid: state.pid, port: state.port, host, pidUnseen: true, ...probeErrorCodeOf(err) };
|
||||
}
|
||||
return { state: "unverified", pid: state.pid, port: state.port, host, ...probeErrorCodeOf(err) };
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ export function getOfflineHelpPath(): string | undefined {
|
||||
* reporting a starved loop) gets the request, under its normal timeout. */
|
||||
export function daemonMayAnswer(status: DaemonStatus): boolean {
|
||||
if (status.state === "stopped" || status.state === "stale") return false;
|
||||
if (status.probeErrorCode === "ECONNREFUSED" || status.startPending) return false;
|
||||
if (status.probeErrorCode === "ECONNREFUSED" || status.startPending || status.pidUnseen) return false;
|
||||
if (status.siblingHint || isBlockedConnectionCode(status.probeErrorCode)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ import { compactPlanCommand, type CompactPlanDeps } from "./commands/compact-pla
|
||||
import { compactCommand, type CompactDeps } from "./commands/compact.js";
|
||||
import { heartbeatCommand, type HeartbeatDeps } from "./commands/heartbeat.js";
|
||||
import { seatCommand, handoverCommand, type SeatDeps } from "./commands/seat.js";
|
||||
import { networkCommand, type NetworkDeps } from "./commands/network.js";
|
||||
import { rigModeCommand, type RigModeDeps } from "./commands/rig-mode.js";
|
||||
import { policyCommand } from "./commands/policy.js";
|
||||
import { startupProofCommand, type StartupProofDeps } from "./commands/startup-proof.js";
|
||||
@@ -168,6 +169,7 @@ export interface ProgramDeps {
|
||||
compactDeps?: CompactDeps;
|
||||
heartbeatDeps?: HeartbeatDeps;
|
||||
seatDeps?: SeatDeps;
|
||||
networkDeps?: NetworkDeps;
|
||||
rigModeDeps?: RigModeDeps;
|
||||
startupProofDeps?: StartupProofDeps;
|
||||
healthDeps?: HealthDeps;
|
||||
@@ -269,6 +271,7 @@ export function createProgram(depsOverride?: ProgramDeps): Command {
|
||||
program.addCommand(heartbeatCommand(depsOverride?.heartbeatDeps));
|
||||
program.addCommand(seatCommand(depsOverride?.seatDeps));
|
||||
program.addCommand(handoverCommand(depsOverride?.seatDeps));
|
||||
program.addCommand(networkCommand(depsOverride?.networkDeps));
|
||||
program.addCommand(startupProofCommand(depsOverride?.startupProofDeps));
|
||||
// release-0.3.2 slice 12 — rig scope CLI primitive.
|
||||
program.addCommand(scopeCommand());
|
||||
|
||||
@@ -102,6 +102,7 @@ export const COMMAND_LOADERS: ReadonlyMap<string, Load> = new Map<string, Load>(
|
||||
["heartbeat", async () => (await import("./commands/heartbeat.js")).heartbeatCommand()],
|
||||
["seat", async () => (await import("./commands/seat.js")).seatCommand()],
|
||||
["handover", async () => (await import("./commands/seat.js")).handoverCommand()],
|
||||
["network", async () => (await import("./commands/network.js")).networkCommand()],
|
||||
["startup-proof", async () => (await import("./commands/startup-proof.js")).startupProofCommand()],
|
||||
["scope", async () => (await import("./commands/scope.js")).scopeCommand()],
|
||||
["proof", async () => (await import("./commands/proof.js")).proofCommand()],
|
||||
|
||||
@@ -156,8 +156,10 @@ describe("runCrossHostCommand", () => {
|
||||
expect(capture.command).toBe("ssh");
|
||||
// D13 supersession: the remote command now runs under `sh -lc` (login-shell PATH
|
||||
// resolution) — the quoted argv rides inside one further quoting layer.
|
||||
// SEP-1: `--` ends ssh's own option parsing before the target.
|
||||
expect(capture.args).toEqual([
|
||||
"-o", "ConnectTimeout=10",
|
||||
"--",
|
||||
"vm-test.local",
|
||||
"sh -lc ''\\''rig'\\'' '\\''send'\\'' '\\''dev-impl@rig'\\'' '\\''hello world'\\'' '\\''--verify'\\'''",
|
||||
]);
|
||||
@@ -202,6 +204,29 @@ describe("runCrossHostCommand", () => {
|
||||
expect(capture.args?.[1]).toBe("ops");
|
||||
});
|
||||
|
||||
it("SEP-1 — `--` separates the options from the target, whatever shape the target has", async () => {
|
||||
const capture: { command?: string; args?: readonly string[] } = {};
|
||||
const spawn = mockSpawnFor({ exitCode: 0, capture });
|
||||
await runCrossHostCommand({ id: "vm-test", transport: "ssh", target: "-oProxyCommand=touch /tmp/nothing" }, ["rig", "ps"], { spawn });
|
||||
const args = capture.args ?? [];
|
||||
const separator = args.indexOf("--");
|
||||
expect(separator).toBeGreaterThan(0);
|
||||
// The whole target reaches ssh as one argument after the separator, so an option-shaped
|
||||
// destination is a host ssh rejects rather than an option it obeys.
|
||||
expect(args[separator + 1]).toBe("-oProxyCommand=touch /tmp/nothing");
|
||||
// Everything we pass as an option stays before it.
|
||||
expect(separator).toBeGreaterThan(args.indexOf("-o"));
|
||||
expect(separator).toBeLessThan(args.length - 1);
|
||||
});
|
||||
|
||||
it("SEP-1 — the separator sits after -l <user> too", async () => {
|
||||
const capture: { command?: string; args?: readonly string[] } = {};
|
||||
const spawn = mockSpawnFor({ exitCode: 0, capture });
|
||||
await runCrossHostCommand(HOST_WITH_USER, ["rig", "ps"], { spawn });
|
||||
const args = capture.args ?? [];
|
||||
expect(args.slice(0, args.indexOf("--"))).toEqual(["-l", "ops", "-o", "ConnectTimeout=10"]);
|
||||
});
|
||||
|
||||
it("happy path returns stdout/stderr verbatim", async () => {
|
||||
const spawn = mockSpawnFor({
|
||||
exitCode: 0,
|
||||
|
||||
@@ -289,6 +289,12 @@ describe("sandboxed liveness: EPERM on kill(pid, 0) is not a dead daemon", () =>
|
||||
expect(signalProbe(1, throwing("ESRCH"))).toBe("missing");
|
||||
});
|
||||
|
||||
it("an error other than ESRCH is never evidence that the process is gone", () => {
|
||||
const throwing = (code: string | undefined) => () => { throw Object.assign(new Error(String(code)), { code }); };
|
||||
expect(signalProbe(1, throwing("EINVAL"))).toBe("not-permitted");
|
||||
expect(signalProbe(1, throwing(undefined))).toBe("not-permitted");
|
||||
});
|
||||
|
||||
it("unknown liveness plus a blocked probe is unverified with EPERM, never stale, and the guard says blocked", async () => {
|
||||
const status = await getDaemonStatus(withState({
|
||||
isProcessAlive: () => false,
|
||||
@@ -304,14 +310,16 @@ describe("sandboxed liveness: EPERM on kill(pid, 0) is not a dead daemon", () =>
|
||||
expect(guard.fact).not.toMatch(/not running/i);
|
||||
});
|
||||
|
||||
it("unknown liveness plus a refused probe is still a stopped daemon with down advice", async () => {
|
||||
// #2860: a refusal from a shell that can't inspect the pid is "stopped" only with the daemon's clean-shutdown receipt.
|
||||
it("unknown liveness plus a refused probe and no clean shutdown is unverified, not stopped", async () => {
|
||||
const status = await getDaemonStatus(withState({
|
||||
isProcessAlive: () => false,
|
||||
processLiveness: sandboxLiveness,
|
||||
fetch: probeFailing("ECONNREFUSED"),
|
||||
}));
|
||||
expect(status.state).toBe("stopped");
|
||||
expect(statusGuardMessage(status).fact).toMatch(/not running/i);
|
||||
expect(status.state).toBe("unverified");
|
||||
expect(status.pidUnseen).toBe(true);
|
||||
expect(statusGuardMessage(status).fact).toMatch(/couldn't connect.*from this shell/i);
|
||||
});
|
||||
|
||||
it("unknown liveness plus a healthy probe is running", async () => {
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
// #2860 — a sandbox that cuts loopback often also hides the daemon's pid. A refused probe plus a
|
||||
// missing pid then looked exactly like a stopped daemon, and the guard told the agent to run `rig up`.
|
||||
//
|
||||
// Contract pinned here: "stopped" needs positive evidence, either the daemon's own clean-shutdown
|
||||
// receipt or a missing pid seen from the daemon's pid namespace. Otherwise status says the CLI
|
||||
// couldn't connect from this shell and the daemon may still be running, and never advises starting
|
||||
// another daemon.
|
||||
import { afterEach, describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
daemonStatusGuard,
|
||||
getDaemonStatus,
|
||||
statusGuardMessage,
|
||||
STATE_FILE,
|
||||
type DaemonState,
|
||||
type LifecycleDeps,
|
||||
} from "../src/daemon-lifecycle.js";
|
||||
import { daemonCommand } from "../src/commands/daemon.js";
|
||||
import { daemonMayAnswer, shouldAutoStartDaemon } from "../src/daemon-reachability.js";
|
||||
|
||||
const INITIAL_NAMESPACE = "pid:[4026531836]";
|
||||
const DAEMON_NAMESPACE = "pid:[4026532001]";
|
||||
const SANDBOX_NAMESPACE = "pid:[4026532777]";
|
||||
const START = "2026-01-01T00:00:00Z";
|
||||
|
||||
const REFUSED: LifecycleDeps["fetch"] = async () => {
|
||||
const err = new Error("fetch failed") as Error & { cause?: { code: string } };
|
||||
err.cause = { code: "ECONNREFUSED" };
|
||||
throw err;
|
||||
};
|
||||
|
||||
function cleanReceipt(pid: number): string {
|
||||
const now = new Date().toISOString();
|
||||
return JSON.stringify({ schema: "openrig.daemon-shutdown/v1", pid, startedAt: START, completedAt: now, outcome: "clean", phase: "complete", failures: [] });
|
||||
}
|
||||
|
||||
function deps(state: DaemonState, over: Partial<LifecycleDeps> = {}, receipt?: string): LifecycleDeps {
|
||||
return {
|
||||
spawn: vi.fn() as unknown as LifecycleDeps["spawn"],
|
||||
fetch: vi.fn(REFUSED),
|
||||
kill: vi.fn(() => true),
|
||||
readFile: vi.fn((p: string) => (p === STATE_FILE ? JSON.stringify(state) : p.endsWith("daemon-shutdown.json") ? receipt ?? null : null)),
|
||||
writeFile: vi.fn(),
|
||||
removeFile: vi.fn(),
|
||||
exists: vi.fn((p: string) => p === STATE_FILE || (receipt !== undefined && p.endsWith("daemon-shutdown.json"))),
|
||||
mkdirp: vi.fn(),
|
||||
openForAppend: vi.fn(() => 0),
|
||||
isProcessAlive: vi.fn(() => false),
|
||||
processLiveness: vi.fn(() => "dead" as const),
|
||||
pidNamespace: () => SANDBOX_NAMESPACE,
|
||||
sleep: async () => {},
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const recorded = (extra: Partial<DaemonState> = {}): DaemonState =>
|
||||
({ pid: 4242, port: 7433, db: "x.db", startedAt: START, pidNamespace: DAEMON_NAMESPACE, ...extra });
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe("#2860: a refused probe with an invisible pid is not a stopped daemon", () => {
|
||||
it("reads 'couldn't connect from this shell' when the pid is missing, the probe is refused and there is no clean shutdown", async () => {
|
||||
const status = await getDaemonStatus(deps(recorded()));
|
||||
expect(status.state).toBe("unverified");
|
||||
expect(status.pidUnseen).toBe(true);
|
||||
const message = statusGuardMessage(status);
|
||||
expect(message.fact).toMatch(/couldn't connect to the daemon from this shell.*may still be running/i);
|
||||
expect(`${message.fact} ${message.consequence} ${message.action}`).not.toMatch(/rig up|rig daemon start/i);
|
||||
expect(message.action).toMatch(/don't start another daemon/i);
|
||||
expect(shouldAutoStartDaemon(status)).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks commands through the guard with the same message", async () => {
|
||||
const status = await getDaemonStatus(deps(recorded()));
|
||||
const lines: string[] = [];
|
||||
vi.spyOn(console, "error").mockImplementation((line: string) => { lines.push(line); });
|
||||
const savedExit = process.exitCode;
|
||||
try {
|
||||
expect(daemonStatusGuard(status)).toBe(false);
|
||||
} finally { process.exitCode = savedExit; }
|
||||
const message = statusGuardMessage(status);
|
||||
expect(lines).toEqual([`Error: ${message.fact}`, ` ${message.consequence}`, ` ${message.action}`]);
|
||||
});
|
||||
|
||||
it("is the same on an older record that names no namespace", async () => {
|
||||
const status = await getDaemonStatus(deps({ pid: 4242, port: 7433, db: "x.db", startedAt: START }));
|
||||
expect(status.pidUnseen).toBe(true);
|
||||
});
|
||||
|
||||
it("is the same when this shell cannot read its own namespace", async () => {
|
||||
const status = await getDaemonStatus(deps(recorded(), { pidNamespace: () => undefined }));
|
||||
expect(status.pidUnseen).toBe(true);
|
||||
});
|
||||
|
||||
it("is the same when the pid exists but cannot be inspected (unknown liveness) and the probe is refused", async () => {
|
||||
const status = await getDaemonStatus(deps(recorded(), { processLiveness: vi.fn(() => "unknown" as const) }));
|
||||
expect(status.state).toBe("unverified");
|
||||
expect(status.pidUnseen).toBe(true);
|
||||
});
|
||||
|
||||
it("still answers 'running' when the probe gets through, whatever the pid looks like", async () => {
|
||||
const status = await getDaemonStatus(deps(recorded(), { fetch: vi.fn(async () => ({ ok: true })) }));
|
||||
expect(status.state).toBe("running");
|
||||
});
|
||||
|
||||
it("does not remove state it cannot prove stale", async () => {
|
||||
const d = deps(recorded());
|
||||
await getDaemonStatus(d);
|
||||
expect(d.removeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("#2860: the real stopped cases keep reading as down", () => {
|
||||
it("a clean shutdown for the recorded pid reads stopped from any shell", async () => {
|
||||
const d = deps(recorded(), {}, cleanReceipt(4242));
|
||||
const status = await getDaemonStatus(d);
|
||||
expect(status.state).toBe("stale");
|
||||
expect(statusGuardMessage(status).fact).toBe("Daemon not running.");
|
||||
expect(shouldAutoStartDaemon(status)).toBe(true);
|
||||
expect(d.removeFile).toHaveBeenCalledWith(STATE_FILE);
|
||||
});
|
||||
|
||||
it("a clean shutdown plus a refused probe reads stopped even when the pid state is unknown", async () => {
|
||||
const status = await getDaemonStatus(deps(recorded(), { processLiveness: vi.fn(() => "unknown" as const) }, cleanReceipt(4242)));
|
||||
expect(status.state).toBe("stopped");
|
||||
});
|
||||
|
||||
it("a receipt for another pid is no evidence", async () => {
|
||||
const status = await getDaemonStatus(deps(recorded(), {}, cleanReceipt(1111)));
|
||||
expect(status.pidUnseen).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["the daemon's own namespace", recorded(), () => DAEMON_NAMESPACE],
|
||||
["the initial namespace it was started in", recorded({ pidNamespace: INITIAL_NAMESPACE }), () => INITIAL_NAMESPACE],
|
||||
["an older record, from the initial namespace", recorded({ pidNamespace: undefined }), () => INITIAL_NAMESPACE],
|
||||
["an older record, on a platform without pid namespaces", recorded({ pidNamespace: undefined }), () => "host"],
|
||||
])("a dead pid in a normal shell (%s) still reads down so 'rig up' can start it", async (_label, state, namespace) => {
|
||||
const status = await getDaemonStatus(deps(state, { pidNamespace: namespace }));
|
||||
expect(status.state).toBe("stale");
|
||||
expect(statusGuardMessage(status).fact).toBe("Daemon not running.");
|
||||
expect(shouldAutoStartDaemon(status)).toBe(true);
|
||||
});
|
||||
|
||||
it("a number recorded in a nested namespace says nothing from the initial one, whatever it finds there", async () => {
|
||||
const fetch = vi.fn(REFUSED);
|
||||
const status = await getDaemonStatus(deps(recorded(), { pidNamespace: () => INITIAL_NAMESPACE, fetch }));
|
||||
expect(status.state).toBe("unverified");
|
||||
expect(status.pidUnseen).toBe(true);
|
||||
expect(fetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("...and a healthy endpoint reads running, not stale", async () => {
|
||||
const status = await getDaemonStatus(deps(recorded(), { pidNamespace: () => INITIAL_NAMESPACE, fetch: vi.fn(async () => ({ ok: true })) }));
|
||||
expect(status.state).toBe("running");
|
||||
});
|
||||
|
||||
it("deps without namespace evidence keep the old rule", async () => {
|
||||
const d = deps(recorded());
|
||||
delete d.pidNamespace;
|
||||
expect((await getDaemonStatus(d)).state).toBe("stale");
|
||||
});
|
||||
});
|
||||
|
||||
describe("#2860: a selected loopback OPENRIG_URL is judged like the record", () => {
|
||||
const withUrl = async (url: string, d: LifecycleDeps) => {
|
||||
process.env.OPENRIG_URL = url;
|
||||
try { return await getDaemonStatus(d); } finally { delete process.env.OPENRIG_URL; }
|
||||
};
|
||||
const URL_OF_DAEMON = "http://127.0.0.1:7433";
|
||||
|
||||
it("a refusal with an invisible pid and no clean shutdown is not stopped, and 'rig up' is not advised", async () => {
|
||||
const status = await withUrl(URL_OF_DAEMON, deps(recorded()));
|
||||
expect(status.state).toBe("unverified");
|
||||
expect(status.pidUnseen).toBe(true);
|
||||
expect(shouldAutoStartDaemon(status)).toBe(false);
|
||||
expect(daemonMayAnswer(status)).toBe(false);
|
||||
});
|
||||
|
||||
it("a missing pid in the daemon's own namespace reads stopped", async () => {
|
||||
expect((await withUrl(URL_OF_DAEMON, deps(recorded(), { pidNamespace: () => DAEMON_NAMESPACE }))).state).toBe("stopped");
|
||||
});
|
||||
|
||||
it("a matching clean shutdown reads stopped from any shell", async () => {
|
||||
expect((await withUrl(URL_OF_DAEMON, deps(recorded(), {}, cleanReceipt(4242)))).state).toBe("stopped");
|
||||
});
|
||||
|
||||
it("with no record for that endpoint a refusal keeps reading stopped", async () => {
|
||||
expect((await withUrl("http://127.0.0.1:9999", deps(recorded()))).state).toBe("stopped");
|
||||
expect((await withUrl(URL_OF_DAEMON, deps(recorded(), { exists: vi.fn(() => false), readFile: vi.fn(() => null) }))).state).toBe("stopped");
|
||||
});
|
||||
|
||||
it("a remote URL keeps reading stopped on a refusal", async () => {
|
||||
expect((await withUrl("http://daemon.example:7433", deps(recorded()))).state).toBe("stopped");
|
||||
});
|
||||
});
|
||||
|
||||
describe("#2860: `rig daemon status` says what the guard says", () => {
|
||||
it("prints the guard's three lines", async () => {
|
||||
const d = deps(recorded());
|
||||
const status = await getDaemonStatus(d);
|
||||
const message = statusGuardMessage(status);
|
||||
const lines: string[] = [];
|
||||
vi.spyOn(console, "log").mockImplementation((line: string) => { lines.push(line); });
|
||||
await daemonCommand(d).parseAsync(["status"], { from: "user" });
|
||||
expect(lines).toEqual([message.fact, ` ${message.consequence}`, ` ${message.action}`]);
|
||||
});
|
||||
});
|
||||
@@ -78,7 +78,8 @@ describe("rig edge", () => {
|
||||
expect(result.out).toHaveLength(1);
|
||||
const error = JSON.parse(result.out[0]!).error;
|
||||
expect(error).toMatchObject({ fact: expect.any(String), consequence: expect.any(String), action: expect.any(String) });
|
||||
if (causeCode === "ECONNREFUSED") expect(error.fact).toBe("Daemon not running.");
|
||||
// #2860: a refused connection alone isn't proof the daemon is stopped, so rig edge says so only on positive evidence.
|
||||
if (causeCode === "ECONNREFUSED") expect(error.fact).toBe("Daemon did not respond — it may be busy or stopped (state not confirmed).");
|
||||
else expect(error).toMatchObject({ causeCode: "EPERM", fact: expect.stringContaining("blocked") });
|
||||
expect(client.get).not.toHaveBeenCalled();
|
||||
expect(client.post).not.toHaveBeenCalled();
|
||||
|
||||
@@ -1,51 +1,144 @@
|
||||
// `rig host ls` status for an ssh host dials where ssh itself would connect (`ssh -G`), so an
|
||||
// ssh-config alias or a non-22 port isn't reported unreachable. A stand-in `ssh` on PATH plays
|
||||
// `ssh -G`; no real ssh runs.
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, writeFileSync, chmodSync, rmSync } from "node:fs";
|
||||
// `rig host list` / `rig host ls` status is tested at process and probe boundaries:
|
||||
// passive SSH rows must not invoke ssh -G or trigger a TCP probe.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { probeHostStatus } from "../src/commands/host.js";
|
||||
import { Command } from "commander";
|
||||
import { hostCommand, type DoctorDeps } from "../src/commands/host.js";
|
||||
|
||||
const { execFileMock, connectMock } = vi.hoisted(() => ({
|
||||
execFileMock: vi.fn(),
|
||||
connectMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:child_process")>();
|
||||
return { ...actual, execFile: execFileMock };
|
||||
});
|
||||
vi.mock("node:net", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:net")>();
|
||||
return { ...actual, connect: connectMock };
|
||||
});
|
||||
|
||||
interface ProbeCall {
|
||||
target: string;
|
||||
port: number;
|
||||
timeoutMs: number;
|
||||
}
|
||||
interface ExecFileCall {
|
||||
command: string;
|
||||
args: string[];
|
||||
timeout: number | undefined;
|
||||
}
|
||||
|
||||
let home: string;
|
||||
let savedHome: string | undefined;
|
||||
let execFileCalls: ExecFileCall[];
|
||||
let log: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
function writeRegistry(hosts: unknown[]): void {
|
||||
writeFileSync(join(home, "hosts.yaml"), JSON.stringify({ hosts }));
|
||||
}
|
||||
|
||||
function deps(tcpProbe: DoctorDeps["tcpProbe"]): DoctorDeps {
|
||||
return {
|
||||
run: async () => { throw new Error("unexpected remote SSH command"); },
|
||||
httpGet: async () => { throw new Error("unexpected HTTP request"); },
|
||||
tcpProbe,
|
||||
};
|
||||
}
|
||||
|
||||
async function listHosts(hosts: unknown[], tcpProbe: DoctorDeps["tcpProbe"], verb: "list" | "ls" = "ls") {
|
||||
writeRegistry(hosts);
|
||||
const program = new Command().exitOverride();
|
||||
program.addCommand(hostCommand(deps(tcpProbe)));
|
||||
await program.parseAsync(["node", "rig", "host", verb, "--json"]);
|
||||
const output = log.mock.calls.map((call) => String(call[0])).find((line) => line.trim().startsWith("["));
|
||||
if (output === undefined) throw new Error("host list did not print its JSON array");
|
||||
return JSON.parse(output) as Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
let dir: string;
|
||||
let savedPath: string | undefined;
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "host-status-ssh-"));
|
||||
writeFileSync(join(dir, "ssh"), `#!/bin/sh
|
||||
case "$*" in
|
||||
*-G*devbox*) printf 'user ops\\nhostname 10.1.2.3\\nport 2222\\n' ;;
|
||||
*-G*jumpy*) printf 'hostname jumpy\\nport 22\\nproxyjump bastion\\n' ;;
|
||||
*) exit 255 ;;
|
||||
esac
|
||||
`);
|
||||
chmodSync(join(dir, "ssh"), 0o755);
|
||||
savedPath = process.env["PATH"];
|
||||
process.env["PATH"] = `${dir}:${savedPath}`;
|
||||
});
|
||||
afterEach(() => {
|
||||
process.env["PATH"] = savedPath;
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
home = mkdtempSync(join(tmpdir(), "host-list-ssh-inert-"));
|
||||
savedHome = process.env["OPENRIG_HOME"];
|
||||
process.env["OPENRIG_HOME"] = home;
|
||||
execFileCalls = [];
|
||||
execFileMock.mockReset();
|
||||
execFileMock.mockImplementation((
|
||||
command: string,
|
||||
args: string[],
|
||||
options: { timeout?: number },
|
||||
callback: (error: Error | null, stdout: string) => void,
|
||||
) => {
|
||||
execFileCalls.push({ command, args: [...args], timeout: options.timeout });
|
||||
queueMicrotask(() => callback(null, ""));
|
||||
});
|
||||
connectMock.mockReset();
|
||||
connectMock.mockImplementation(() => { throw new Error("unexpected direct TCP socket"); });
|
||||
log = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
describe("rig host ls status for an ssh host", () => {
|
||||
it("dials the host and port ssh resolves for an ssh-config alias", async () => {
|
||||
const dials: string[] = [];
|
||||
const status = await probeHostStatus({ id: "devbox", transport: "ssh", target: "devbox" }, async (target, port) => {
|
||||
dials.push(`${target}:${port}`);
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
if (savedHome === undefined) delete process.env["OPENRIG_HOME"];
|
||||
else process.env["OPENRIG_HOME"] = savedHome;
|
||||
});
|
||||
|
||||
describe("rig host list/ls passive status", () => {
|
||||
it.each(["list", "ls"] as const)("reports an SSH host unknown without launching a process or probing TCP through host %s", async (verb) => {
|
||||
const probes: ProbeCall[] = [];
|
||||
const tcpProbe: DoctorDeps["tcpProbe"] = async (target, port, timeoutMs) => {
|
||||
probes.push({ target, port, timeoutMs });
|
||||
return "open";
|
||||
};
|
||||
|
||||
const rows = await listHosts([{ id: "devbox", transport: "ssh", target: "devbox", user: "ops" }], tcpProbe, verb);
|
||||
|
||||
expect({ status: rows[0]?.status, execFileCalls, probes, directSockets: connectMock.mock.calls }).toEqual({
|
||||
status: "unknown",
|
||||
execFileCalls: [],
|
||||
probes: [],
|
||||
directSockets: [],
|
||||
});
|
||||
expect(status).toBe("reachable");
|
||||
expect(dials).toEqual(["10.1.2.3:2222"]);
|
||||
});
|
||||
|
||||
it.each([["a ProxyJump host", "jumpy"], ["a target ssh can't resolve", "broken"]])(
|
||||
"reads unknown for %s, since a direct dial can't stand for the transport", async (_label, target) => {
|
||||
const dials: string[] = [];
|
||||
const status = await probeHostStatus({ id: target, transport: "ssh", target }, async (t, port) => {
|
||||
dials.push(`${t}:${port}`);
|
||||
return "closed";
|
||||
});
|
||||
expect(status).toBe("unknown");
|
||||
expect(dials).toEqual([]);
|
||||
it.each([
|
||||
{ label: "HTTP default port", url: "http://example.test", target: "example.test", port: 80 },
|
||||
{ label: "HTTP explicit port", url: "http://example.test:7433", target: "example.test", port: 7433 },
|
||||
{ label: "HTTPS default port", url: "https://example.test", target: "example.test", port: 443 },
|
||||
{ label: "bracketed IPv6 HTTPS", url: "https://[2001:db8::7]:9443", target: "2001:db8::7", port: 9443 },
|
||||
])("preserves CLI $label status probing through its injected seam", async ({ url, target, port }) => {
|
||||
const probes: ProbeCall[] = [];
|
||||
const tcpProbe: DoctorDeps["tcpProbe"] = async (host, servicePort, timeoutMs) => {
|
||||
probes.push({ target: host, port: servicePort, timeoutMs });
|
||||
return "open";
|
||||
};
|
||||
|
||||
const rows = await listHosts([{ id: "web", transport: "http", url }], tcpProbe);
|
||||
|
||||
expect({ status: rows[0]?.status, probes, execFileCalls, directSockets: connectMock.mock.calls }).toEqual({
|
||||
status: "reachable",
|
||||
probes: [{ target, port, timeoutMs: 1500 }],
|
||||
execFileCalls: [],
|
||||
directSockets: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("maps an HTTP closed-probe control to unreachable", async () => {
|
||||
const probes: ProbeCall[] = [];
|
||||
const tcpProbe: DoctorDeps["tcpProbe"] = async (target, port, timeoutMs) => {
|
||||
probes.push({ target, port, timeoutMs });
|
||||
return "closed";
|
||||
};
|
||||
|
||||
const rows = await listHosts([{ id: "web", transport: "http", url: "http://example.test:7433" }], tcpProbe);
|
||||
|
||||
expect({ status: rows[0]?.status, probes, directSockets: connectMock.mock.calls }).toEqual({
|
||||
status: "unreachable",
|
||||
probes: [{ target: "example.test", port: 7433, timeoutMs: 1500 }],
|
||||
directSockets: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,11 +14,13 @@ import { configCommand } from "../src/commands/config.js";
|
||||
describe("rig host add/list", () => {
|
||||
let dir: string;
|
||||
let savedHome: string | undefined;
|
||||
let tcpProbeCalls: Array<{ target: string; port: number; timeoutMs: number }> = [];
|
||||
|
||||
beforeEach(() => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "hostcmd-"));
|
||||
savedHome = process.env.OPENRIG_HOME;
|
||||
process.env.OPENRIG_HOME = dir;
|
||||
tcpProbeCalls = [];
|
||||
});
|
||||
afterEach(() => {
|
||||
if (savedHome === undefined) delete process.env.OPENRIG_HOME;
|
||||
@@ -43,7 +45,15 @@ describe("rig host add/list", () => {
|
||||
function run(argv: string[]) {
|
||||
const prog = new Command();
|
||||
prog.exitOverride();
|
||||
prog.addCommand(hostCommand());
|
||||
prog.addCommand(hostCommand({
|
||||
run: async () => { throw new Error("unexpected cross-host command in add/list test"); },
|
||||
httpGet: async () => { throw new Error("unexpected HTTP GET in add/list test"); },
|
||||
httpPost: async () => { throw new Error("unexpected HTTP POST in add/list test"); },
|
||||
tcpProbe: async (target, port, timeoutMs) => {
|
||||
tcpProbeCalls.push({ target, port, timeoutMs });
|
||||
return "unknown";
|
||||
},
|
||||
}));
|
||||
return prog.parseAsync(["node", "rig", "host", ...argv]);
|
||||
}
|
||||
|
||||
@@ -97,11 +107,17 @@ describe("rig host add/list", () => {
|
||||
const added = await capture(() => run(["add", "--id", "f1", "--transport", "http", "--url", "http://100.64.0.9:7433", "--bearer-env", "FACTORY_TOKEN", "--json"]));
|
||||
expect(JSON.parse(added.out.join("\n"))).toMatchObject({ ok: true, entry: { id: "f1" } });
|
||||
expect(added.err).toEqual([]);
|
||||
const beforeTextList = tcpProbeCalls.length;
|
||||
const { out } = await capture(() => run(["list"]));
|
||||
expect(tcpProbeCalls.length - beforeTextList).toBe(1);
|
||||
const text = out.join("\n");
|
||||
expect(text).toContain("env:FACTORY_TOKEN");
|
||||
expect(text).toContain("unknown");
|
||||
expect(text).not.toContain("super-secret-bearer-value");
|
||||
const beforeJsonList = tcpProbeCalls.length;
|
||||
const { out: jsonOut } = await capture(() => run(["list", "--json"]));
|
||||
expect(tcpProbeCalls.length - beforeJsonList).toBe(1);
|
||||
expect(JSON.parse(jsonOut.join(""))).toMatchObject([{ id: "f1", status: "unknown" }]);
|
||||
expect(jsonOut.join("")).toContain("FACTORY_TOKEN");
|
||||
expect(jsonOut.join("")).not.toContain("super-secret-bearer-value");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
// `rig network connect | status | disconnect` against the daemon's real network routes.
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { Command } from "commander";
|
||||
import { Hono } from "hono";
|
||||
import Database from "better-sqlite3";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { networkCommand, type NetworkDeps } from "../src/commands/network.js";
|
||||
import { STATE_FILE, type LifecycleDeps } from "../src/daemon-lifecycle.js";
|
||||
import { ALL_MIGRATIONS } from "../../daemon/src/db/all-migrations.js";
|
||||
import { migrate } from "../../daemon/src/db/migrate.js";
|
||||
import { RigRepository } from "../../daemon/src/domain/rig-repository.js";
|
||||
import { networkRoutes } from "../../daemon/src/routes/network.js";
|
||||
|
||||
let db: Database.Database;
|
||||
let home: string;
|
||||
let app: Hono;
|
||||
let rigs: RigRepository;
|
||||
const routesWith = (bearerToken: string | null) => {
|
||||
const next = new Hono();
|
||||
next.use("*", async (c, n) => { c.set("rigRepo" as never, rigs as never); await n(); });
|
||||
next.route("/api/network", networkRoutes({ home, bearerToken }));
|
||||
return next;
|
||||
};
|
||||
beforeEach(() => {
|
||||
db = new Database(":memory:");
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
rigs = new RigRepository(db);
|
||||
const node = rigs.addNode(rigs.createRig("lab").id, "impl", { runtime: "claude-code" });
|
||||
db.prepare("INSERT INTO sessions (id, node_id, session_name, status) VALUES ('s1', ?, 'dev-impl@lab', 'running')").run(node.id);
|
||||
home = mkdtempSync(join(tmpdir(), "openrig-network-cli-"));
|
||||
app = routesWith(null);
|
||||
});
|
||||
afterEach(() => { db.close(); rmSync(home, { recursive: true, force: true }); vi.restoreAllMocks(); vi.unstubAllEnvs(); process.exitCode = undefined; });
|
||||
|
||||
function cli(tokenFile: Record<string, string> = {}) {
|
||||
const lifecycleDeps: LifecycleDeps = {
|
||||
spawn: vi.fn(), fetch: vi.fn(async () => ({ ok: true })), kill: vi.fn(),
|
||||
readFile: (p) => p === STATE_FILE ? JSON.stringify({ pid: 123, port: 19731, db: "/fixture/test.sqlite", startedAt: new Date().toISOString() }) : null,
|
||||
writeFile: vi.fn(), removeFile: vi.fn(), exists: (p) => p === STATE_FILE, mkdirp: vi.fn(), openForAppend: () => 3, isProcessAlive: () => true,
|
||||
} as LifecycleDeps;
|
||||
const call = async (method: "GET" | "POST", url: string, body?: unknown, headers: Record<string, string> = {}) => {
|
||||
const res = await app.request(url, { method, headers: { "Content-Type": "application/json", ...headers }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
|
||||
return { status: res.status, data: await res.json() };
|
||||
};
|
||||
const deps: NetworkDeps = {
|
||||
lifecycleDeps,
|
||||
clientFactory: () => ({ get: (url: string) => call("GET", url), post: (url: string, body: unknown, options?: { headers?: Record<string, string> }) => call("POST", url, body, options?.headers) }) as never,
|
||||
readTokenFile: (path) => { if (!(path in tokenFile)) throw new Error("no such file"); return tokenFile[path]!; },
|
||||
};
|
||||
return new Command().exitOverride().addCommand(networkCommand(deps));
|
||||
}
|
||||
|
||||
it("connects a seat with its token file, shows it in status without the token, and disconnects it", async () => {
|
||||
const out = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
await cli({ "/tok": "tok-abc" }).parseAsync(["node", "rig", "network", "connect", "dev-impl@lab", "--hub", "https://hub.example/mcp", "--as", "alex@team", "--token-file", "/tok"]);
|
||||
expect(String(out.mock.calls.at(-1)?.[0])).toMatch(/^Connected netb-[0-9a-f]{20} dev-impl@lab -> alex@team at https:\/\/hub\.example\/mcp no contact yet/);
|
||||
|
||||
await cli().parseAsync(["node", "rig", "network", "status", "--json"]);
|
||||
const status = String(out.mock.calls.at(-1)?.[0]);
|
||||
expect(JSON.parse(status).bindings).toEqual([expect.objectContaining({ localSession: "dev-impl@lab", hubSeat: "alex@team", state: "active" })]);
|
||||
expect(status).not.toContain("tok-abc");
|
||||
|
||||
await cli().parseAsync(["node", "rig", "network", "disconnect", "dev-impl@lab", "--reason", "done"]);
|
||||
expect(String(out.mock.calls.at(-1)?.[0])).toMatch(/^Disconnected netb-[0-9a-f]{20} dev-impl@lab -> alex@team .*disconnected \(done\)/);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it("names the daemon's refusal and exits 1, and an unreadable token file sends nothing", async () => {
|
||||
const err = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
await cli({ "/tok": "tok-abc" }).parseAsync(["node", "rig", "network", "connect", "nobody@lab", "--hub", "https://hub.example/mcp", "--as", "alex@team", "--token-file", "/tok"]);
|
||||
expect(String(err.mock.calls.at(-1)?.[0])).toBe("No local seat is named 'nobody@lab'. (seat_not_found)");
|
||||
expect(process.exitCode).toBe(1);
|
||||
process.exitCode = undefined;
|
||||
await cli().parseAsync(["node", "rig", "network", "connect", "dev-impl@lab", "--hub", "https://hub.example/mcp", "--as", "alex@team", "--token-file", "/missing"]);
|
||||
expect(String(err.mock.calls.at(-1)?.[0])).toBe("Can't read the token file: no such file");
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect((await app.request("/api/network/bindings").then((r) => r.json()) as { bindings: unknown[] }).bindings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("a plain-http hub needs --allow-http: refused with the remedy, then connected and shown unencrypted in status and JSON", async () => {
|
||||
const out = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const err = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const args = ["node", "rig", "network", "connect", "dev-impl@lab", "--hub", "http://192.0.2.7:7433/mcp", "--as", "alex@team", "--token-file", "/tok"];
|
||||
await cli({ "/tok": "tok-abc" }).parseAsync(args);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(String(err.mock.calls.at(-1)?.[0])).toMatch(/plain http, .*unencrypted.*--allow-http to accept that for this binding\. \(http_not_allowed\)$/);
|
||||
process.exitCode = undefined;
|
||||
|
||||
await cli({ "/tok": "tok-abc" }).parseAsync([...args, "--allow-http"]);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
expect(String(out.mock.calls.at(-1)?.[0])).toMatch(/^Connected netb-[0-9a-f]{20} dev-impl@lab -> alex@team at http:\/\/192\.0\.2\.7:7433\/mcp UNENCRYPTED \(http, allowed for this binding\)/);
|
||||
await cli().parseAsync(["node", "rig", "network", "status"]);
|
||||
expect(String(out.mock.calls.at(-1)?.[0])).toContain("UNENCRYPTED (http, allowed for this binding)");
|
||||
await cli().parseAsync(["node", "rig", "network", "status", "--json"]);
|
||||
const json = String(out.mock.calls.at(-1)?.[0]);
|
||||
expect(JSON.parse(json).bindings).toEqual([expect.objectContaining({ hubUrl: "http://192.0.2.7:7433/mcp", allowHttp: true, unencrypted: true })]);
|
||||
expect(json).not.toContain("tok-abc");
|
||||
});
|
||||
|
||||
it("status shows a hub item closed elsewhere under its binding, in text and JSON", async () => {
|
||||
const out = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
await cli({ "/tok": "tok-abc" }).parseAsync(["node", "rig", "network", "connect", "dev-impl@lab", "--hub", "https://hub.example/mcp", "--as", "alex@team", "--token-file", "/tok"]);
|
||||
const bindingId = String(out.mock.calls.at(-1)?.[0]).match(/netb-[0-9a-f]{20}/)![0];
|
||||
db.prepare(`INSERT INTO network_receipts (local_qitem_id, binding_id, hub_qitem_id, created_at, settled_at, outcome, last_error)
|
||||
VALUES ('qitem-net-0001', ?, 'hub-9', '2026-11-11T19:00:00Z', '2026-11-11T19:01:00Z', 'closed_on_hub', 'closed elsewhere (done, canceled)')`).run(bindingId);
|
||||
await cli().parseAsync(["node", "rig", "network", "status"]);
|
||||
expect(String(out.mock.calls.at(-1)?.[0])).toContain("\n hub item hub-9 was closed elsewhere before its result arrived; the result stays on qitem-net-0001");
|
||||
await cli().parseAsync(["node", "rig", "network", "status", "--json"]);
|
||||
expect(JSON.parse(String(out.mock.calls.at(-1)?.[0])).bindings[0].closedElsewhere).toEqual([{ hubItem: "hub-9", localItem: "qitem-net-0001", note: "closed elsewhere (done, canceled)" }]);
|
||||
});
|
||||
|
||||
it("sends the operator bearer on its writes, so a daemon with one configured takes them", async () => {
|
||||
app = routesWith("op-secret");
|
||||
const out = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const err = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const connectArgs = ["node", "rig", "network", "connect", "dev-impl@lab", "--hub", "https://hub.example/mcp", "--as", "alex@team", "--token-file", "/tok"];
|
||||
vi.stubEnv("OPENRIG_AUTH_BEARER_TOKEN", "wrong");
|
||||
await cli({ "/tok": "tok-abc" }).parseAsync(connectArgs);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(String(err.mock.calls.at(-1)?.[0])).toContain("bearer token does not match");
|
||||
process.exitCode = undefined;
|
||||
vi.stubEnv("OPENRIG_AUTH_BEARER_TOKEN", "op-secret");
|
||||
await cli({ "/tok": "tok-abc" }).parseAsync(connectArgs);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
expect(String(out.mock.calls.at(-1)?.[0])).toMatch(/^Connected netb-/);
|
||||
await cli().parseAsync(["node", "rig", "network", "disconnect", "dev-impl@lab"]);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
expect(String(out.mock.calls.at(-1)?.[0])).toMatch(/^Disconnected netb-/);
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
// The outbound connector against a real hub: the daemon's routes behind `rig mcp serve --http` with a grant per
|
||||
// participant, reached by the connector's own MCP client. A second, local queue takes the work, and what happens there
|
||||
// comes back to the hub item: queued, picked up, the result, and a revoked grant ending the binding.
|
||||
import { afterEach, beforeEach, expect, it } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { serve, type ServerType } from "@hono/node-server";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createTestApp } from "../../daemon/test/helpers/test-app.js";
|
||||
import { createDb } from "../../daemon/src/db/connection.js";
|
||||
import { migrate } from "../../daemon/src/db/migrate.js";
|
||||
import { ALL_MIGRATIONS } from "../../daemon/src/db/all-migrations.js";
|
||||
import { EventBus } from "../../daemon/src/domain/event-bus.js";
|
||||
import { QueueRepository } from "../../daemon/src/domain/queue-repository.js";
|
||||
import { OutboxHandler } from "../../daemon/src/domain/outbox-handler.js";
|
||||
import { RigRepository } from "../../daemon/src/domain/rig-repository.js";
|
||||
import { NetworkBindingStore } from "../../daemon/src/domain/network/network-bindings.js";
|
||||
import { networkClosedElsewhere, runNetworkBindingPass, type NetworkConnectorDeps } from "../../daemon/src/domain/network/network-connector.js";
|
||||
import { addGrant, revokeGrant, startMcpHttpServer } from "../src/mcp-http.js";
|
||||
|
||||
let hubDb: Database.Database;
|
||||
let localDb: Database.Database;
|
||||
let hubHome: string;
|
||||
let localHome: string;
|
||||
let hubRepo: QueueRepository;
|
||||
let daemon: ServerType;
|
||||
let hubUrl: string;
|
||||
let tokenFile: string;
|
||||
const closers: Array<() => Promise<void>> = [];
|
||||
const saved = { home: process.env.OPENRIG_HOME, session: process.env.OPENRIG_SESSION_NAME };
|
||||
|
||||
const queue = (db: Database.Database) => {
|
||||
const repo = new QueueRepository(db, new EventBus(db), { validateRig: () => true, loadHumanRegistry: () => ({ ok: true as const, entities: [] }) });
|
||||
repo.attachOutbox(new OutboxHandler(db));
|
||||
return repo;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
hubDb = createDb(":memory:");
|
||||
migrate(hubDb, ALL_MIGRATIONS);
|
||||
hubHome = fs.mkdtempSync(path.join(os.tmpdir(), "network-hub-"));
|
||||
localHome = fs.mkdtempSync(path.join(os.tmpdir(), "network-local-"));
|
||||
process.env.OPENRIG_HOME = hubHome;
|
||||
process.env.OPENRIG_SESSION_NAME = "env-seat@elsewhere";
|
||||
tokenFile = path.join(hubHome, "mcp-http-tokens.json");
|
||||
hubRepo = queue(hubDb);
|
||||
const inert = async () => ({ ok: false, error: "inert fixture" });
|
||||
const { app } = createTestApp(hubDb, { appDeps: {
|
||||
sessionTransport: { resolveSessions: async () => ({ ok: false, code: "not_found", error: "inert fixture" }), send: inert, capture: inert, broadcast: inert } as never,
|
||||
queueRepo: hubRepo,
|
||||
} });
|
||||
daemon = await new Promise<ServerType>((resolve) => { const s = serve({ fetch: app.fetch, port: 0, hostname: "127.0.0.1" }, () => resolve(s)); });
|
||||
const served = await startMcpHttpServer({ daemonUrl: `http://127.0.0.1:${(daemon.address() as AddressInfo).port}`, host: "127.0.0.1", port: 0, tokenFile });
|
||||
closers.push(() => served.close());
|
||||
hubUrl = served.url;
|
||||
|
||||
localDb = createDb(":memory:");
|
||||
migrate(localDb, ALL_MIGRATIONS);
|
||||
const rigs = new RigRepository(localDb);
|
||||
const node = rigs.addNode(rigs.createRig("lab").id, "impl", { runtime: "claude-code" });
|
||||
localDb.prepare("INSERT INTO sessions (id, node_id, session_name, status) VALUES ('s1', ?, 'dev-impl@lab', 'running')").run(node.id);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const close of closers.splice(0).reverse()) await close();
|
||||
await new Promise<void>((resolve) => daemon.close(() => resolve()));
|
||||
hubDb.close();
|
||||
localDb.close();
|
||||
fs.rmSync(hubHome, { recursive: true, force: true });
|
||||
fs.rmSync(localHome, { recursive: true, force: true });
|
||||
if (saved.home === undefined) delete process.env.OPENRIG_HOME; else process.env.OPENRIG_HOME = saved.home;
|
||||
if (saved.session === undefined) delete process.env.OPENRIG_SESSION_NAME; else process.env.OPENRIG_SESSION_NAME = saved.session;
|
||||
});
|
||||
|
||||
it("takes the participant's item over the wire, carries pickup and the result back, and a revoked grant ends the binding", async () => {
|
||||
const token = await addGrant(tokenFile, { name: "alex", seat: "alex@team", rigs: ["team"], roots: [], workspace: false, owner: false });
|
||||
// Longer than a list preview, with its end marked, so a shortened body would show.
|
||||
const body = `Summarize the release notes. ${"Context. ".repeat(400)}END-OF-BODY`;
|
||||
const task = await hubRepo.create({ sourceSession: "lead@team", destinationSession: "alex@team", summary: "Summarize the notes", body, nudge: false } as never);
|
||||
|
||||
const localRepo = queue(localDb);
|
||||
const bindings = new NetworkBindingStore(localDb, localHome);
|
||||
// The test hub listens on loopback http, so the binding records the person's --allow-http choice.
|
||||
const binding = bindings.create({ session: "dev-impl@lab", hubUrl, hubSeat: "alex@team", token, allowHttp: true });
|
||||
const deps: NetworkConnectorDeps = { db: localDb, queueRepo: localRepo, bindings, home: localHome };
|
||||
const pass = () => runNetworkBindingPass(deps, bindings.get(binding.id)!);
|
||||
const notes = () => hubRepo.listTransitions(task.qitemId).map((t) => t.transitionNote).filter(Boolean);
|
||||
|
||||
const first = await pass();
|
||||
expect(first.error).toBeUndefined();
|
||||
expect(first).toMatchObject({ taken: 1 });
|
||||
const [local] = localRepo.list({ limit: 50 });
|
||||
expect(local).toMatchObject({ sourceSession: "daemon@kernel", destinationSession: "dev-impl@lab", summary: "From lead@team via network hub: Summarize the notes", state: "pending" });
|
||||
expect(local!.body!.split("\n")[0]).toBe(`From lead@team through the network hub ${hubUrl}, as alex@team (hub item ${task.qitemId}).`);
|
||||
expect(local!.body).toContain(body);
|
||||
expect(hubRepo.getById(task.qitemId)!.state).toBe("in-progress");
|
||||
expect(notes().at(-1)).toBe(`Queued for the connected local seat as ${local!.qitemId}. Not picked up yet.`);
|
||||
expect(hubRepo.listTransitions(task.qitemId).find((t) => t.state === "in-progress")?.actorSession).toMatch(/^alex@team/);
|
||||
|
||||
expect(await pass()).toMatchObject({ taken: 0, pickupsSent: 0 });
|
||||
expect(localRepo.list({ limit: 50 })).toHaveLength(1);
|
||||
|
||||
localRepo.claim({ qitemId: local!.qitemId, destinationSession: "dev-impl@lab" });
|
||||
expect(await pass()).toMatchObject({ pickupsSent: 1 });
|
||||
expect(notes().at(-1)).toMatch(/^Picked up by the local seat at /);
|
||||
expect(hubRepo.getById(task.qitemId)!.state).toBe("in-progress");
|
||||
|
||||
await localRepo.update({ qitemId: local!.qitemId, actorSession: "dev-impl@lab", state: "done", closureReason: "no-follow-on", resolution: "Three changes; none breaking." });
|
||||
expect(await pass()).toMatchObject({ resultsSent: 1 });
|
||||
expect(hubRepo.getById(task.qitemId)).toMatchObject({ state: "done", resolution: "Three changes; none breaking." });
|
||||
expect(bindings.get(binding.id)!.lastError).toBeNull();
|
||||
|
||||
// A failure and a cancel close the hub item the way its queue takes them: failed, and done with closure reason canceled.
|
||||
const failing = await hubRepo.create({ sourceSession: "lead@team", destinationSession: "alex@team", summary: "Build it", body: "Build the release.", nudge: false } as never);
|
||||
const dropped = await hubRepo.create({ sourceSession: "lead@team", destinationSession: "alex@team", summary: "Skip it", body: "Maybe not.", nudge: false } as never);
|
||||
expect(await pass()).toMatchObject({ taken: 2 });
|
||||
const byHub = (id: string) => localRepo.list({ limit: 50 }).find((i) => (i.tags ?? []).includes(`network-hub-item:${id}`))!;
|
||||
await localRepo.update({ qitemId: byHub(failing.qitemId).qitemId, actorSession: "dev-impl@lab", state: "failed", transitionNote: "The build broke." });
|
||||
await localRepo.update({ qitemId: byHub(dropped.qitemId).qitemId, actorSession: "dev-impl@lab", state: "done", closureReason: "canceled", resolution: "Not needed." });
|
||||
expect(await pass()).toMatchObject({ resultsSent: 2 });
|
||||
expect(hubRepo.getById(failing.qitemId)).toMatchObject({ state: "failed", resolution: "The build broke." });
|
||||
expect(hubRepo.getById(dropped.qitemId)).toMatchObject({ state: "done", closureReason: "canceled", resolution: "Not needed." });
|
||||
|
||||
// Two items the requester fails on the hub while they're queued here. Our failed and done results then aren't ours to
|
||||
// deliver: the hub items keep their failure, and the receipts say closed elsewhere.
|
||||
const theirs = [
|
||||
await hubRepo.create({ sourceSession: "lead@team", destinationSession: "alex@team", summary: "One", body: "First.", nudge: false } as never),
|
||||
await hubRepo.create({ sourceSession: "lead@team", destinationSession: "alex@team", summary: "Two", body: "Second.", nudge: false } as never),
|
||||
];
|
||||
expect(await pass()).toMatchObject({ taken: 2 });
|
||||
for (const item of theirs) await hubRepo.update({ qitemId: item.qitemId, actorSession: "lead@team", state: "failed", resolution: "Dropped by the requester." } as never);
|
||||
await localRepo.update({ qitemId: byHub(theirs[0]!.qitemId).qitemId, actorSession: "dev-impl@lab", state: "failed", transitionNote: "Could not finish." });
|
||||
await localRepo.update({ qitemId: byHub(theirs[1]!.qitemId).qitemId, actorSession: "dev-impl@lab", state: "done", closureReason: "no-follow-on", resolution: "Finished it." });
|
||||
expect(await pass()).toMatchObject({ resultsSent: 0 });
|
||||
for (const item of theirs) expect(hubRepo.getById(item.qitemId)).toMatchObject({ state: "failed", resolution: "Dropped by the requester." });
|
||||
expect(localDb.prepare("SELECT DISTINCT outcome FROM network_receipts WHERE hub_qitem_id IN (?, ?)").all(theirs[0]!.qitemId, theirs[1]!.qitemId)).toEqual([{ outcome: "closed_on_hub" }]);
|
||||
|
||||
// An operator on the hub hands the original on to another seat while it's queued here. It's closed to the connector:
|
||||
// nothing is written to it, the original and its successor are unchanged, status shows it, and it isn't retried.
|
||||
const handed = await hubRepo.create({ sourceSession: "lead@team", destinationSession: "alex@team", summary: "Three", body: "Third.", nudge: false } as never);
|
||||
expect(await pass()).toMatchObject({ taken: 1 });
|
||||
const { created: successor } = await hubRepo.handoff({ qitemId: handed.qitemId, fromSession: "lead@team", toSession: "other@team", nudge: false });
|
||||
const before = hubRepo.listTransitions(handed.qitemId).length;
|
||||
await localRepo.update({ qitemId: byHub(handed.qitemId).qitemId, actorSession: "dev-impl@lab", state: "done", closureReason: "no-follow-on", resolution: "Done here." });
|
||||
expect(await pass()).toMatchObject({ resultsSent: 0 });
|
||||
expect(hubRepo.getById(handed.qitemId)!.state).toBe("handed-off");
|
||||
expect(hubRepo.listTransitions(handed.qitemId)).toHaveLength(before);
|
||||
expect(hubRepo.getById(successor.qitemId)).toMatchObject({ state: "pending", destinationSession: "other@team" });
|
||||
expect(networkClosedElsewhere(localDb, binding.id)).toContainEqual(expect.objectContaining({ hubItem: handed.qitemId, localItem: byHub(handed.qitemId).qitemId,
|
||||
note: expect.stringContaining("closed elsewhere (handed-off") }));
|
||||
await pass();
|
||||
expect(hubRepo.listTransitions(handed.qitemId)).toHaveLength(before);
|
||||
|
||||
await revokeGrant(tokenFile, "alex");
|
||||
expect(await pass()).toMatchObject({ error: "revoked" });
|
||||
expect(bindings.get(binding.id)!.state).toBe("revoked");
|
||||
});
|
||||
@@ -207,6 +207,20 @@ function createMockDaemon() {
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (sourceRef.includes("skipped")) {
|
||||
res.writeHead(201, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({
|
||||
installId: "inst-skip-1",
|
||||
packageId: "pkg-1",
|
||||
packageName: "guidance-pkg",
|
||||
applied: [],
|
||||
deferred: [],
|
||||
conflicts: [],
|
||||
verification: { passed: true },
|
||||
notes: ["guidance_block_markers_damaged: /repo/CLAUDE.md: guidance-pkg has a BEGIN or END marker without its pair"],
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (sourceRef.includes("merge")) {
|
||||
res.writeHead(201, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({
|
||||
@@ -383,6 +397,14 @@ describe("rig package", () => {
|
||||
expect(output).toContain("inst-merge-1");
|
||||
});
|
||||
|
||||
it("install with a guidance file left as it is: names the file and the reason, exit 0", async () => {
|
||||
const { logs, exitCode } = await captureLogs(() => makeProgram().parseAsync(["node", "rig", "package", "install", "/skipped/path", "--target", "/repo", "--allow-merge"]));
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain("Left as it is:");
|
||||
expect(output).toContain(" guidance_block_markers_damaged: /repo/CLAUDE.md: guidance-pkg has a BEGIN or END marker without its pair");
|
||||
expect(exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
// Test 8: install conflicts (409) → exitCode 1
|
||||
it("install conflicts: prints conflicts, exitCode 1", async () => {
|
||||
const { logs, exitCode } = await captureLogs(() => makeProgram().parseAsync(["node", "rig", "package", "install", "/conflict/path", "--target", "/repo"]));
|
||||
|
||||
@@ -76,7 +76,7 @@ describe("SystemPreflight", () => {
|
||||
it("Node version below 22 → ready: false with guidance", async () => {
|
||||
Object.defineProperty(process, "version", { value: "v18.19.0", writable: true });
|
||||
const pf = createPreflight();
|
||||
const result = await pf.run();
|
||||
const result = await pf.run({ port: 0 });
|
||||
expect(result.ready).toBe(false);
|
||||
const nodeCheck = result.checks.find((c) => c.name === "node_version");
|
||||
expect(nodeCheck!.ok).toBe(false);
|
||||
@@ -104,7 +104,7 @@ describe("SystemPreflight", () => {
|
||||
// Test 3
|
||||
it("tmux missing → ready: false with guidance", async () => {
|
||||
const pf = createPreflight({ tmuxMode: "missing" });
|
||||
const result = await pf.run();
|
||||
const result = await pf.run({ port: 0 });
|
||||
expect(result.ready).toBe(false);
|
||||
const tmuxCheck = result.checks.find((c) => c.name === "tmux");
|
||||
expect(tmuxCheck!.ok).toBe(false);
|
||||
@@ -114,7 +114,7 @@ describe("SystemPreflight", () => {
|
||||
|
||||
it("tmux installed but default control socket unhealthy → ready: false with guidance", async () => {
|
||||
const pf = createPreflight({ tmuxMode: "unhealthy" });
|
||||
const result = await pf.run();
|
||||
const result = await pf.run({ port: 0 });
|
||||
|
||||
expect(result.ready).toBe(false);
|
||||
const tmuxCheck = result.checks.find((c) => c.name === "tmux");
|
||||
@@ -135,7 +135,7 @@ describe("SystemPreflight", () => {
|
||||
getDaemonStatus: makeStatus(),
|
||||
riggedHome: join(readonlyDir, "nested"),
|
||||
});
|
||||
const result = await pf.run();
|
||||
const result = await pf.run({ port: 0 });
|
||||
expect(result.ready).toBe(false);
|
||||
const homeCheck = result.checks.find((c) => c.name === "writable_home");
|
||||
expect(homeCheck!.ok).toBe(false);
|
||||
@@ -187,9 +187,18 @@ describe("SystemPreflight", () => {
|
||||
const pf = createPreflight({
|
||||
daemonStatus: { state: "running", port: 7433, host: "127.0.0.1", healthy: true },
|
||||
});
|
||||
const result = await pf.run({ port: 7433, host: "127.0.0.1" });
|
||||
const portCheck = result.checks.find((c) => c.name === "port_available");
|
||||
expect(portCheck!.ok).toBe(true);
|
||||
// A regressed skip would fail at construction before it could reach any socket.
|
||||
const constructor = vi.spyOn(net, "Socket").mockImplementation(() => {
|
||||
throw new Error("same-endpoint preflight must not construct a probe socket");
|
||||
});
|
||||
try {
|
||||
const result = await pf.run({ port: 7433, host: "127.0.0.1" });
|
||||
const portCheck = result.checks.find((c) => c.name === "port_available");
|
||||
expect(portCheck).toMatchObject({ ok: true });
|
||||
expect(constructor).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
constructor.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
// Test 7
|
||||
@@ -198,18 +207,33 @@ describe("SystemPreflight", () => {
|
||||
const pf = createPreflight({
|
||||
daemonStatus: { state: "running", port: 7433, host: "127.0.0.1", healthy: true },
|
||||
});
|
||||
// Use a port that's actually available, but the skip rule should not apply
|
||||
const result = await pf.run({ port: 7433, host: "10.0.0.5" });
|
||||
const portCheck = result.checks.find((c) => c.name === "port_available");
|
||||
// Should have attempted the check (not skipped)
|
||||
expect(portCheck).toBeDefined();
|
||||
// A deterministic refusal proves the requested endpoint without dialing it.
|
||||
const socket = Object.assign(new EventEmitter(), {
|
||||
setTimeout: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
connect: vi.fn((_port: number, _host: string) => {
|
||||
queueMicrotask(() => socket.emit("error", Object.assign(new Error("connection refused"), { code: "ECONNREFUSED" })));
|
||||
}),
|
||||
});
|
||||
const constructor = vi.spyOn(net, "Socket").mockImplementation(() => socket as unknown as net.Socket);
|
||||
try {
|
||||
const result = await pf.run({ port: 7433, host: "10.0.0.5" });
|
||||
const portCheck = result.checks.find((c) => c.name === "port_available");
|
||||
expect(portCheck).toMatchObject({ ok: true });
|
||||
expect(constructor).toHaveBeenCalledTimes(1);
|
||||
expect(socket.connect).toHaveBeenCalledTimes(1);
|
||||
expect(socket.connect).toHaveBeenCalledWith(7433, "10.0.0.5");
|
||||
expect(socket.destroy).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
constructor.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
// Test 8
|
||||
it("multiple failures reported together (additive)", async () => {
|
||||
Object.defineProperty(process, "version", { value: "v18.19.0", writable: true });
|
||||
const pf = createPreflight({ tmuxMode: "missing" });
|
||||
const result = await pf.run();
|
||||
const result = await pf.run({ port: 0 });
|
||||
expect(result.ready).toBe(false);
|
||||
const failedChecks = result.checks.filter((c) => !c.ok);
|
||||
expect(failedChecks.length).toBeGreaterThanOrEqual(2);
|
||||
@@ -343,9 +367,12 @@ describe("Preflight CLI", () => {
|
||||
it("rig preflight with failure prints three-part error and exits 1", async () => {
|
||||
const homeDir = join(tmpDir, ".openrig");
|
||||
mkdirSync(homeDir, { recursive: true });
|
||||
const configPath = join(tmpDir, "config.json");
|
||||
const { writeFileSync } = await import("node:fs");
|
||||
writeFileSync(configPath, JSON.stringify({ daemon: { port: 0 } }));
|
||||
const cmd = preflightCommand({
|
||||
exec: makeExec("missing"),
|
||||
configPath: join(tmpDir, "config.json"),
|
||||
configPath,
|
||||
riggedHome: homeDir,
|
||||
getDaemonStatus: makeStatus(),
|
||||
});
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "openrig-core",
|
||||
"version": "0.1.28",
|
||||
"version": "0.1.27",
|
||||
"description": "OpenRig canonical skills and hooks for cross-runtime agent topology coordination, continuity, and operating discipline.",
|
||||
"author": {
|
||||
"name": "Mike Schwarz",
|
||||
"url": "https://github.com/mvschwarz"
|
||||
},
|
||||
"homepage": "https://github.com/mvschwarz/openrig-plugins",
|
||||
"repository": "github:mvschwarz/openrig-plugins",
|
||||
"repository": "https://github.com/mvschwarz/openrig-plugins",
|
||||
"license": "Apache-2.0",
|
||||
"keywords": ["openrig", "agent", "topology", "rig", "continuity", "coordination", "compaction", "handoff"],
|
||||
"skills": "./skills",
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "openrig-core",
|
||||
"version": "0.1.28",
|
||||
"version": "0.1.27",
|
||||
"description": "OpenRig canonical skills and hooks for cross-runtime agent topology coordination, continuity, and operating discipline.",
|
||||
"author": {
|
||||
"name": "Mike Schwarz",
|
||||
"url": "https://github.com/mvschwarz"
|
||||
},
|
||||
"homepage": "https://github.com/mvschwarz/openrig-plugins",
|
||||
"repository": "github:mvschwarz/openrig-plugins",
|
||||
"repository": "https://github.com/mvschwarz/openrig-plugins",
|
||||
"license": "Apache-2.0",
|
||||
"keywords": ["openrig", "agent", "topology", "rig", "continuity", "coordination", "compaction", "handoff"],
|
||||
"skills": "./skills",
|
||||
|
||||
@@ -50,6 +50,12 @@ This plugin ships dual-manifest packaging — both `.claude-plugin/` and `.codex
|
||||
no-ops. The plugin supplies refocus and skills. Disabling an inline activity hook is not bypassed by the plugin.
|
||||
- **Seat scope:** the commands require both `OPENRIG_SEAT_PROJECT_DIR` and the executable `OPENRIG_NODE`. Personal and
|
||||
unselected sessions are no-ops. A nested Codex inheriting those seat variables still acts as that seat.
|
||||
- **Refocus output:** the Codex prompt handler keeps its unchanged declaration and default 2,500-token budget
|
||||
(10,000 UTF-8 bytes), preserving its native trust identity. Larger packets
|
||||
send a prefix and an explicit shortening notice for the current attempt. After a claimed, successful render/write
|
||||
and state save, the refocus record lists that attempt's omitted tail byte count and full packet's `.context.txt`
|
||||
path, or says the full text could not be saved. A trace/content failure or unavailable final state guard/write can
|
||||
leave older metadata or no record. The metadata describes the last successfully recorded attempt, not model receipt.
|
||||
|
||||
Generated Claude settings commands for the activity relay and context collector use the daemon's absolute Node
|
||||
executable when its name is recognized (`node` or `node.exe`); other names keep the legacy bare `node` command and
|
||||
|
||||
@@ -144,10 +144,134 @@ function buildOpenRigPayload(providerPayload, env = process.env, now = () => new
|
||||
...promptBoundaryFields(hookEvent, providerPayload),
|
||||
occurredAt: now().toISOString(),
|
||||
relayRoute: argv.includes("--from-plugin") ? "plugin" : "managed",
|
||||
...limitResetField(runtime, hookEvent, subtype, providerPayload, now),
|
||||
...conversationFields(providerPayload, env),
|
||||
};
|
||||
}
|
||||
|
||||
// #99: a Claude usage limit says when it resets only in its rendered text ("You've hit your weekly limit ·
|
||||
// resets Nov 10, 4pm (America/Sao_Paulo)"). The typed error still decides THAT the seat is limited; the text
|
||||
// is read here, on the seat's machine and in its time zone, only for WHEN, and only the instant leaves the
|
||||
// relay: never the text itself.
|
||||
function limitResetField(runtime, hookEvent, subtype, providerPayload, now) {
|
||||
if (runtime !== "claude-code" || hookEvent !== "StopFailure" || subtype !== "rate_limit") return {};
|
||||
for (const value of [providerPayload.last_assistant_message, providerPayload.error_details]) {
|
||||
if (typeof value !== "string") continue;
|
||||
const resetsAt = claudeLimitResetAt(value.slice(0, 2000), now());
|
||||
if (resetsAt) return { resetsAt };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
const RESET_MAX_MS = 8 * 24 * 3600 * 1000;
|
||||
const MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
|
||||
const RESET_EPOCH_RE = /usage limit reached\|(\d{10})\b/i;
|
||||
// Each unit must be a whole word ("2 months" is not "2 m"), and a "resets in" fragment is read only this way.
|
||||
const RESET_IN_LEAD_RE = /\bresets?\s+in\b/i;
|
||||
const RESET_IN_RE = /\bresets?\s+in\s+(?:(\d+)\s*(?:d|days?)\b\s*)?(?:(\d+)\s*(?:h|hrs?|hours?)\b\s*)?(?:(\d+)\s*(?:m|mins?|minutes?)\b)?/i;
|
||||
// A zone, when the text opens one, must be closed: "4pm (Not/AZone" is truncated, not local time.
|
||||
const RESET_AT_RE = /\bresets?\s+(?:at\s+|on\s+)?(?:([a-z]{3})[a-z]*\.?\s+(\d{1,2}),?\s+(?:at\s+)?)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b(?:\s*(\()([^)\n]{0,64})(\))?)?/i;
|
||||
|
||||
/** The reset instant a Claude usage-limit message states, as an ISO string, or null when it states none
|
||||
* this can read. A wall-clock time without a zone is the seat's local time, which is what Claude renders. */
|
||||
function claudeLimitResetAt(text, nowDate) {
|
||||
const nowMs = nowDate.getTime();
|
||||
if (!Number.isFinite(nowMs) || typeof text !== "string") return null;
|
||||
let resetMs = null;
|
||||
const epoch = RESET_EPOCH_RE.exec(text);
|
||||
if (epoch) {
|
||||
resetMs = Number(epoch[1]) * 1000;
|
||||
} else if (RESET_IN_LEAD_RE.test(text)) {
|
||||
const relative = RESET_IN_RE.exec(text);
|
||||
// The whole fragment must be read: nothing numeric may follow what was, across a separator either
|
||||
// ("in 2h 30x", "in 2h, 30x" and "in 2h and 30x" are not two hours).
|
||||
if (!relative || !(relative[1] || relative[2] || relative[3])
|
||||
|| /^(?:\s|[,;&+]|and\b)*\d/i.test(text.slice(relative.index + relative[0].length))) return null;
|
||||
resetMs = nowMs + ((Number(relative[1] || 0) * 24 + Number(relative[2] || 0)) * 60 + Number(relative[3] || 0)) * 60_000;
|
||||
} else {
|
||||
const wall = RESET_AT_RE.exec(text);
|
||||
if (wall) resetMs = wallClockReset(wall, nowMs);
|
||||
}
|
||||
if (resetMs === null || !Number.isFinite(resetMs) || resetMs <= nowMs || resetMs > nowMs + RESET_MAX_MS) return null;
|
||||
return new Date(resetMs).toISOString();
|
||||
}
|
||||
|
||||
function wallClockReset(match, nowMs) {
|
||||
const [, monthName, day, hourText, minuteText, meridiem, zoneOpen, zoneText, zoneClose] = match;
|
||||
if (zoneOpen && (!zoneClose || !zoneText.trim())) return null; // a truncated or empty zone: no reset
|
||||
const hour12 = Number(hourText);
|
||||
const minute = Number(minuteText || 0);
|
||||
if (hour12 < 1 || hour12 > 12 || minute > 59) return null;
|
||||
const hour = (hour12 % 12) + (meridiem.toLowerCase() === "pm" ? 12 : 0);
|
||||
const zone = zoneText ? zoneText.trim() : null;
|
||||
let toUtc;
|
||||
try {
|
||||
toUtc = zone ? zonedClock(zone) : localClock();
|
||||
} catch {
|
||||
return null; // a zone this runtime doesn't know: no reset rather than a guessed one
|
||||
}
|
||||
const today = toUtc.partsAt(nowMs);
|
||||
if (monthName) {
|
||||
const month = MONTHS.indexOf(monthName.toLowerCase());
|
||||
if (month < 0) return null;
|
||||
for (const year of [today.year, today.year + 1]) {
|
||||
if (!isCalendarDay(year, month, Number(day))) continue; // "Feb 30" names no day; never roll it into March
|
||||
const at = toUtc.instant(year, month, Number(day), hour, minute);
|
||||
if (Number.isNaN(at)) return null;
|
||||
if (at > nowMs) return at;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const at = toUtc.instant(today.year, today.month, today.day, hour, minute);
|
||||
if (Number.isNaN(at)) return null; // ambiguous today: never advance a day on a guess
|
||||
return at > nowMs ? at : toUtc.instant(today.year, today.month, today.day + 1, hour, minute);
|
||||
}
|
||||
|
||||
function isCalendarDay(year, month, day) {
|
||||
return Number.isInteger(day) && day >= 1 && day <= new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
|
||||
}
|
||||
|
||||
function localClock() {
|
||||
return wallClock(
|
||||
(ms) => { const d = new Date(ms); return { year: d.getFullYear(), month: d.getMonth(), day: d.getDate(), hour: d.getHours(), minute: d.getMinutes() }; },
|
||||
(ms) => -new Date(ms).getTimezoneOffset() * 60_000,
|
||||
);
|
||||
}
|
||||
|
||||
function zonedClock(timeZone) {
|
||||
const format = new Intl.DateTimeFormat("en-US", { timeZone, hourCycle: "h23", year: "numeric", month: "numeric",
|
||||
day: "numeric", hour: "numeric", minute: "numeric", second: "numeric" });
|
||||
const partsAt = (ms) => {
|
||||
const parts = Object.fromEntries(format.formatToParts(new Date(ms)).map((part) => [part.type, Number(part.value)]));
|
||||
return { year: parts.year, month: parts.month - 1, day: parts.day, hour: parts.hour, minute: parts.minute, second: parts.second };
|
||||
};
|
||||
const offsetAt = (ms) => {
|
||||
const p = partsAt(ms);
|
||||
return Date.UTC(p.year, p.month, p.day, p.hour, p.minute, p.second) - Math.floor(ms / 1000) * 1000;
|
||||
};
|
||||
return wallClock(partsAt, offsetAt);
|
||||
}
|
||||
|
||||
/** A clock whose instant() is the one moment a wall time names, or NaN when it names none (skipped by a DST jump) or
|
||||
* two (repeated by a DST fall-back): the message doesn't say which, so no reset rather than a guess a day off. */
|
||||
function wallClock(partsAt, offsetAt) {
|
||||
return {
|
||||
partsAt,
|
||||
instant: (year, month, day, hour, minute) => {
|
||||
const asUtc = Date.UTC(year, month, day, hour, minute);
|
||||
const want = new Date(asUtc); // normalizes a day past the month's end (tomorrow)
|
||||
const matches = new Set();
|
||||
for (const probe of [asUtc - 14 * 3600_000, asUtc, asUtc + 14 * 3600_000]) {
|
||||
const candidate = asUtc - offsetAt(probe);
|
||||
const p = partsAt(candidate);
|
||||
if (p.year === want.getUTCFullYear() && p.month === want.getUTCMonth() && p.day === want.getUTCDate()
|
||||
&& p.hour === want.getUTCHours() && p.minute === want.getUTCMinutes()) matches.add(candidate);
|
||||
}
|
||||
return matches.size === 1 ? [...matches][0] : NaN;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// OPR.0.4.3.28 B1+B3 — resolve the ingest base URL + token without depending on
|
||||
// the operator seeding OPENRIG_URL/OPENRIG_ACTIVITY_HOOK_TOKEN into the shell:
|
||||
// 1. env OPENRIG_URL / token (unchanged fast path).
|
||||
@@ -390,6 +514,7 @@ if (require.main === module) {
|
||||
|
||||
module.exports = {
|
||||
buildOpenRigPayload,
|
||||
claudeLimitResetAt,
|
||||
claimFirstLaunchHook,
|
||||
buildSessionIdentityPayload,
|
||||
parseJson,
|
||||
|
||||
@@ -630,10 +630,30 @@ function runsForThisSeatsProject(env = process.env) {
|
||||
"",
|
||||
...body,
|
||||
]).join("\n");
|
||||
let additionalContext = payload;
|
||||
let contextOutput;
|
||||
if (runtime() === "codex") {
|
||||
// Keep the handler declaration unchanged; Codex's default is 2,500 tokens, ceil(UTF-8 bytes / 4).
|
||||
const limitTokens = 2_500;
|
||||
const bytes = Buffer.from(payload);
|
||||
contextOutput = { status: "full", limitTokens, originalBytes: bytes.length, outputBytes: bytes.length, omittedBytes: 0 };
|
||||
if (bytes.length > limitTokens * 4) {
|
||||
let fullPath = `${stateFile}.${crypto.randomUUID()}.context.txt`;
|
||||
try { fs.writeFileSync(fullPath, payload, { flag: "wx", mode: 0o600 }); }
|
||||
catch { fullPath = null; }
|
||||
const location = fullPath ? `Full text saved to: ${fullPath}` : "Full text could not be saved.";
|
||||
// Reserve the notice before taking the prefix; streaming decode drops an incomplete final UTF-8 character.
|
||||
const prefix = new TextDecoder().decode(bytes.subarray(0, Math.max(0, limitTokens * 4 - Buffer.byteLength(location) - 256)), { stream: true });
|
||||
const prefixBytes = Buffer.byteLength(prefix);
|
||||
const omittedBytes = bytes.length - prefixBytes;
|
||||
additionalContext = `${prefix}\n\n[REFOCUS SHORTENED: omitted ${omittedBytes} trailing UTF-8 bytes from the assembled trace, role and standing instructions. ${location}]`;
|
||||
contextOutput = { status: "shortened", limitTokens, originalBytes: bytes.length, outputBytes: Buffer.byteLength(additionalContext), prefixBytes, omittedBytes, fullPath };
|
||||
}
|
||||
}
|
||||
const output = JSON.stringify({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: "UserPromptSubmit",
|
||||
additionalContext: payload,
|
||||
additionalContext,
|
||||
},
|
||||
});
|
||||
process.stdout.write(output, (error) => {
|
||||
@@ -661,6 +681,9 @@ function runsForThisSeatsProject(env = process.env) {
|
||||
if (size > 0 && latest.lastReset?.at === state.lastReset?.at) latest.lastBytes = size;
|
||||
latest.firedAt = new Date().toISOString();
|
||||
latest.firedOn = event;
|
||||
// Writer completion, not a model-context receipt. A shortened output names its omitted tail and full-text file.
|
||||
if (contextOutput) latest.contextOutput = contextOutput;
|
||||
else delete latest.contextOutput;
|
||||
if (samePending) {
|
||||
delete latest.pendingOn;
|
||||
delete latest.pendingAt;
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
@@ -7,7 +7,7 @@
|
||||
"url": "https://github.com/mvschwarz"
|
||||
},
|
||||
"homepage": "https://github.com/mvschwarz/openrig",
|
||||
"repository": "github:mvschwarz/openrig",
|
||||
"repository": "https://github.com/mvschwarz/openrig",
|
||||
"license": "Apache-2.0",
|
||||
"keywords": ["openrig", "agents", "team", "delegation", "mcp"],
|
||||
"skills": "./skills",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"url": "https://github.com/mvschwarz"
|
||||
},
|
||||
"homepage": "https://github.com/mvschwarz/openrig",
|
||||
"repository": "github:mvschwarz/openrig",
|
||||
"repository": "https://github.com/mvschwarz/openrig",
|
||||
"license": "Apache-2.0",
|
||||
"keywords": [
|
||||
"openrig",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"url": "https://github.com/mvschwarz"
|
||||
},
|
||||
"homepage": "https://github.com/mvschwarz/openrig",
|
||||
"repository": "github:mvschwarz/openrig",
|
||||
"repository": "https://github.com/mvschwarz/openrig",
|
||||
"license": "Apache-2.0",
|
||||
"keywords": ["openrig", "agents", "team", "delegation", "mcp"]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openrig/daemon",
|
||||
"version": "0.7.12",
|
||||
"version": "0.7.1",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
@@ -107,6 +107,7 @@
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.0",
|
||||
"@hono/node-ws": "^1.3.1",
|
||||
"@modelcontextprotocol/sdk": "^1.32.2",
|
||||
"better-sqlite3": "^13.0.3",
|
||||
"hono": "^4.7.0",
|
||||
"smol-toml": "^1.8.0",
|
||||
|
||||
@@ -94,6 +94,11 @@ pools and missing edge artifacts.
|
||||
|
||||
## Active surface
|
||||
|
||||
For a worked `periodic-reminder` YAML spec, full registration command and required
|
||||
flags, read `rig context get reference/keeping-a-watch.md#recurring-reminders-for-agents`.
|
||||
This is an agent's recurring reminder. A person's watch uses the one-shot queue
|
||||
pattern in `rig context get reference/keeping-a-watch.md#setting-a-watch`.
|
||||
|
||||
Daemon `rig watchdog` is the active host coordination surface for new
|
||||
work. History records only loud evaluations (`sent` / `terminal`);
|
||||
quiet skip reasons are NOT recorded — POC parity so agents are not
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
import fs from "node:fs";
|
||||
import nodePath from "node:path";
|
||||
import readline from "node:readline";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { PassThrough, Writable } from "node:stream";
|
||||
import stringWidth from "string-width";
|
||||
import { StringDecoder } from "node:string_decoder";
|
||||
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
||||
import { pathToFileURL } from "node:url";
|
||||
@@ -71,14 +72,306 @@ export function preparePiAccount(agentDir: string, accountHome: string | undefin
|
||||
// Use Node's line editor in raw mode, with only paste framing handled here.
|
||||
export const MAX_PI_INPUT_BYTES = 1024 * 1024;
|
||||
|
||||
// ── The compose region's physical layout ──────────────────────────────────────────
|
||||
// One layout model for painting, erasing and the caret: cells fill terminal rows
|
||||
// greedily, a wide character that no longer fits wraps whole to the next row, and
|
||||
// an explicit newline starts one. Every row the compositor paints or erases, and
|
||||
// the caret it places, derives from here — measured with the daemon's string-width.
|
||||
|
||||
/** Where a compose region sits on screen, and where its caret lands. */
|
||||
export interface ComposeLayout { rows: number; caretRow: number; caretColumn: number; }
|
||||
|
||||
const SEGMENTER = (() => {
|
||||
try {
|
||||
return new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
/** Grapheme clusters: what a terminal lays out. A ZWJ family emoji is one cluster
|
||||
* taking two cells where its code points would sum to six; a combining mark adds none. */
|
||||
export function* graphemesOf(text: string): Generator<string> {
|
||||
if (SEGMENTER) {
|
||||
for (const part of SEGMENTER.segment(text)) yield part.segment;
|
||||
} else {
|
||||
for (const character of text) yield character;
|
||||
}
|
||||
}
|
||||
|
||||
const TAB_WIDTH = 8;
|
||||
|
||||
export function composeLayout(text: string, width: number, caret = text.length): ComposeLayout {
|
||||
if (!text) return { rows: 0, caretRow: 0, caretColumn: 0 };
|
||||
if (width <= 0) return { rows: text.split("\n").length, caretRow: 0, caretColumn: 0 };
|
||||
let row = 0;
|
||||
let column = 0;
|
||||
let caretRow = 0;
|
||||
let caretColumn = 0;
|
||||
let index = 0;
|
||||
const place = () => { caretRow = row; caretColumn = column; };
|
||||
for (const segment of graphemesOf(text)) {
|
||||
if (index === caret) place();
|
||||
if (segment === "\n") {
|
||||
row += 1;
|
||||
column = 0;
|
||||
} else if (segment === "\t") {
|
||||
// A tab advances to the next ordinary tab stop, clamped to the last column.
|
||||
column = Math.min((Math.floor(column / TAB_WIDTH) + 1) * TAB_WIDTH, width - 1);
|
||||
} else {
|
||||
const cells = Math.max(0, stringWidth(segment));
|
||||
if (column + cells > width) {
|
||||
row += 1;
|
||||
column = 0;
|
||||
}
|
||||
column += cells;
|
||||
}
|
||||
index += segment.length;
|
||||
}
|
||||
if (index === caret) place();
|
||||
return { rows: row + 1, caretRow, caretColumn };
|
||||
}
|
||||
|
||||
// ── Pane live region (compose + status) ────────────────────────────────────────
|
||||
// A seat pane is a plain terminal: one bottom live region holds the operator's draft
|
||||
// and the runner's status line, and is erased and repainted around every transcript
|
||||
// write. Nothing else may write at the cursor, or a draft freezes into scrollback and
|
||||
// streamed text fuses with it mid-word. When the terminal's width is unknown the
|
||||
// compositor places nothing — the pane falls back to plain output, and the draft,
|
||||
// though still editable, is not drawn.
|
||||
|
||||
/** The live region a pane host repaints around transcript writes. */
|
||||
export interface PaneCompositor {
|
||||
/** Bind the line editor the compose line renders from, and where its cursor sits. */
|
||||
attach(read: () => string, redraw: () => void, caretUp?: () => number, readCursor?: () => number): void;
|
||||
/** Unbind the editor; any held streamed tail is flushed and the live region is dropped. */
|
||||
detach(): void;
|
||||
/** A keystroke changed the draft: repaint the live region from readline's state. A
|
||||
* draft that just went away flushes a held streamed tail first, as plain output would. */
|
||||
refresh(): void;
|
||||
/** Streamed model text: verbatim while no live region exists; once a draft or a
|
||||
* status line is on the bottom, complete lines go above the region and an
|
||||
* unfinished line is held, so no live line is split or frozen into scrollback. */
|
||||
transcript(text: string): void;
|
||||
/** One complete line of transcript. */
|
||||
line(text: string): void;
|
||||
/** A keypress is about to echo: close an open transcript line first, or the echo
|
||||
* and the next streamed deltas would land on top of it. */
|
||||
beforeInput(): void;
|
||||
/** Pin the latest diagnostic on the status line. The transcript copy is the caller's;
|
||||
* this pin is never the only copy. */
|
||||
diagnostic(text: string): void;
|
||||
/** Drop a pinned status line. */
|
||||
clearStatus(): void;
|
||||
/** Before the submit commits: drop the draft line so no partial copy scrolls, and
|
||||
* finish any held streamed line; the durable copy is the commit line below. */
|
||||
beforeSubmit(): void;
|
||||
/** Repaint the live region in place. */
|
||||
repaint(): void;
|
||||
}
|
||||
|
||||
export function createPaneCompositor(out: NodeJS.WriteStream): PaneCompositor {
|
||||
let status: string | null = null;
|
||||
/** The status text actually on screen: clipped when it was painted. A resize must
|
||||
* size the region from this, not from the longer text it was pinned with. */
|
||||
let paintedStatus: string | null = null;
|
||||
let readInput: (() => string) | null = null;
|
||||
let redrawLine: (() => void) | null = null;
|
||||
let held = "";
|
||||
let openLine = false;
|
||||
let screenRows = 0;
|
||||
let readCaretUp: (() => number) | null = null;
|
||||
let readCaretIndex: (() => number) | null = null;
|
||||
const widthKnown = () => typeof out.columns === "number" && out.columns > 0;
|
||||
const composeText = () => (readInput && redrawLine ? readInput() : "");
|
||||
const composeRows = () => {
|
||||
const text = composeText();
|
||||
return text && widthKnown() ? composeLayout(text, out.columns).rows : 0;
|
||||
};
|
||||
const clip = (text: string) => {
|
||||
const flat = text.replace(/[\r\n\t]+/g, " ").trimEnd();
|
||||
if (!widthKnown() || stringWidth(flat) < out.columns) return flat;
|
||||
let used = 0;
|
||||
let clipped = "";
|
||||
for (const segment of graphemesOf(flat)) {
|
||||
const cells = Math.max(0, stringWidth(segment));
|
||||
if (used + cells > out.columns - 1) break;
|
||||
clipped += segment;
|
||||
used += cells;
|
||||
}
|
||||
return `${clipped}\u2026`;
|
||||
};
|
||||
const eraseLines = (lines: number) => {
|
||||
let sequence = "";
|
||||
for (let index = 0; index < lines; index += 1) sequence += index === 0 ? "\r\u001b[2K" : "\u001b[1A\r\u001b[2K";
|
||||
if (sequence) out.write(sequence);
|
||||
};
|
||||
/** Rows this compositor has on screen, recorded when it painted them. Readline never writes
|
||||
* to the terminal, so nothing else can change these rows behind the compositor's back —
|
||||
* except a reflow, which the resize handler owns. */
|
||||
const paint = () => {
|
||||
paintedStatus = status !== null ? clip(status) : null;
|
||||
if (paintedStatus !== null) {
|
||||
out.write(paintedStatus);
|
||||
out.write("\n");
|
||||
}
|
||||
const rows = composeRows();
|
||||
if (rows) redrawLine?.();
|
||||
screenRows = (status !== null ? 1 : 0) + (rows || (status !== null ? 1 : 0));
|
||||
// Only a painted region closes an open transcript line. With nothing painted the
|
||||
// cursor still sits on the line a flush left open, and the next keystroke belongs
|
||||
// to that line's context, not to a fresh one.
|
||||
if (screenRows > 0) openLine = false;
|
||||
};
|
||||
const eraseScreen = () => {
|
||||
// Nothing painted: there is no region to erase, and stepping down from a stale
|
||||
// caret count would move the cursor off the transcript line it is still on.
|
||||
if (screenRows === 0) return;
|
||||
const down = readCaretUp ? readCaretUp() : 0;
|
||||
if (down > 0) out.write(`\u001b[${down}B`);
|
||||
eraseLines(screenRows);
|
||||
screenRows = 0;
|
||||
};
|
||||
const flushHeld = () => {
|
||||
if (!held) return;
|
||||
out.write(held.endsWith("\n") ? held : `${held}\n`);
|
||||
held = "";
|
||||
openLine = false;
|
||||
};
|
||||
const writeVerbatim = (text: string) => {
|
||||
out.write(text);
|
||||
openLine = !text.endsWith("\n");
|
||||
};
|
||||
const writeAbove = (text: string) => {
|
||||
eraseScreen();
|
||||
out.write(text.endsWith("\n") ? text : `${text}\n`);
|
||||
paint();
|
||||
};
|
||||
// A tile attaching or detaching reflows the pane, and the terminal keeps the cursor on
|
||||
// its character. So the old paint now occupies exactly what the new width says it does:
|
||||
// the pinned status wraps to ceil(cells/columns) rows, the draft to its layout rows.
|
||||
// Move down to the region's last row — past the lines below the caret — erase exactly
|
||||
// that many rows, and repaint at the new width. No max(): under-erasing leaves a stale
|
||||
// copy of a wrapped status, over-erasing drops a transcript line off the screen.
|
||||
const onResize = () => {
|
||||
if (!widthKnown()) {
|
||||
screenRows = 0;
|
||||
return;
|
||||
}
|
||||
const draft = composeText();
|
||||
const layout = draft ? composeLayout(draft, out.columns, readCaretIndex ? readCaretIndex() : draft.length) : null;
|
||||
const draftRows = layout ? layout.rows : 0;
|
||||
const statusLines = paintedStatus !== null ? Math.max(1, Math.ceil(stringWidth(paintedStatus) / out.columns)) : 0;
|
||||
const down = layout ? layout.rows - 1 - layout.caretRow : 0;
|
||||
if (down > 0) out.write(`\u001b[${down}B`);
|
||||
// A pinned status without a draft also owns the blank row its trailing newline
|
||||
// left the cursor on — the same row paint() counts into screenRows.
|
||||
eraseLines(statusLines + (draftRows || (status !== null ? 1 : 0)));
|
||||
screenRows = 0;
|
||||
paint();
|
||||
};
|
||||
out.on("resize", onResize);
|
||||
const pane: PaneCompositor = {
|
||||
attach(read, redraw, caretUp, readCursor) {
|
||||
readInput = read;
|
||||
redrawLine = redraw;
|
||||
readCaretUp = caretUp ?? null;
|
||||
readCaretIndex = readCursor ?? null;
|
||||
},
|
||||
detach() {
|
||||
eraseScreen();
|
||||
flushHeld();
|
||||
readInput = null;
|
||||
redrawLine = null;
|
||||
readCaretUp = null;
|
||||
readCaretIndex = null;
|
||||
openLine = false;
|
||||
screenRows = 0;
|
||||
},
|
||||
refresh() {
|
||||
eraseScreen();
|
||||
// With no region left, an unfinished streamed line goes out verbatim so the
|
||||
// next delta continues it on the same row instead of splitting mid-word.
|
||||
if (held && status === null && !composeText()) {
|
||||
writeVerbatim(held);
|
||||
held = "";
|
||||
}
|
||||
paint();
|
||||
},
|
||||
transcript(text) {
|
||||
if (!text) return;
|
||||
if (status === null && composeRows() === 0 && screenRows === 0) {
|
||||
if (held) {
|
||||
writeVerbatim(held);
|
||||
held = "";
|
||||
}
|
||||
writeVerbatim(text);
|
||||
return;
|
||||
}
|
||||
held += text;
|
||||
let newline = held.indexOf("\n");
|
||||
while (newline >= 0) {
|
||||
const line = held.slice(0, newline + 1);
|
||||
held = held.slice(newline + 1);
|
||||
writeAbove(line);
|
||||
newline = held.indexOf("\n");
|
||||
}
|
||||
},
|
||||
line(text) {
|
||||
pane.transcript(`${text}\n`);
|
||||
},
|
||||
beforeInput() {
|
||||
if (!openLine) return;
|
||||
out.write("\n");
|
||||
openLine = false;
|
||||
},
|
||||
diagnostic(text) {
|
||||
// Without a known width there is no region to pin into. Callers already wrote
|
||||
// the transcript copy; printing here would duplicate it.
|
||||
if (!widthKnown()) return;
|
||||
if (openLine) {
|
||||
out.write("\n");
|
||||
openLine = false;
|
||||
}
|
||||
eraseScreen();
|
||||
status = clip(text);
|
||||
paint();
|
||||
},
|
||||
clearStatus() {
|
||||
if (status === null) return;
|
||||
eraseScreen();
|
||||
status = null;
|
||||
paint();
|
||||
},
|
||||
beforeSubmit() {
|
||||
eraseScreen();
|
||||
openLine = false;
|
||||
flushHeld();
|
||||
},
|
||||
repaint() {
|
||||
paint();
|
||||
},
|
||||
};
|
||||
return pane;
|
||||
}
|
||||
|
||||
export function createRunnerInput(
|
||||
input: NodeJS.ReadStream,
|
||||
output: NodeJS.WriteStream,
|
||||
onSubmit: (block: string) => void,
|
||||
pane?: PaneCompositor,
|
||||
): readline.Interface {
|
||||
const reject = () => output.write(
|
||||
"[pi-runner] input rejected: maximum 1048576 UTF-8 bytes; send a smaller message. Ctrl-C clears unfinished input.\n",
|
||||
);
|
||||
const say = (text: string) => {
|
||||
if (pane) {
|
||||
pane.line(text);
|
||||
pane.diagnostic(text);
|
||||
} else {
|
||||
output.write(`${text}\n`);
|
||||
}
|
||||
};
|
||||
const reject = () => {
|
||||
say("[pi-runner] input rejected: maximum 1048576 UTF-8 bytes; send a smaller message. Ctrl-C clears unfinished input.");
|
||||
};
|
||||
const submit = (block: string) => {
|
||||
if (Buffer.byteLength(block) > MAX_PI_INPUT_BYTES) reject();
|
||||
else if (block.trim()) onSubmit(block);
|
||||
@@ -94,14 +387,37 @@ export function createRunnerInput(
|
||||
|| (major === 25 && (minor === 0 || (minor === 1 && patch === 0)));
|
||||
const keys = new PassThrough();
|
||||
const editorInput = batchText ? new PassThrough() : keys;
|
||||
// Readline edits against a discard sink: it must never draw on the terminal, because
|
||||
// _refreshLine does moveCursor(0, -prevRows) + clearScreenDown(), which erases whatever the
|
||||
// compositor put above the draft. The compositor owns every terminal byte instead.
|
||||
const sink = new Writable({ write(_chunk, _encoding, callback) { callback(); } });
|
||||
// Readline wraps its edit against output.columns; carry the terminal's geometry onto the sink.
|
||||
Object.assign(sink, { columns: output.columns, rows: output.rows });
|
||||
let caretUp = 0;
|
||||
const renderCompose = () => {
|
||||
const text = `${editor.line ?? ""}`;
|
||||
if (!text) return;
|
||||
const width = typeof output.columns === "number" && output.columns > 0 ? output.columns : 0;
|
||||
output.write(text);
|
||||
if (!width) return;
|
||||
const cursor = Math.max(0, Math.min(Number(editor.cursor) || 0, text.length));
|
||||
const layout = composeLayout(text, width, cursor);
|
||||
caretUp = layout.rows - 1 - layout.caretRow;
|
||||
if (caretUp > 0) output.write(`\u001b[${caretUp}A`);
|
||||
output.write("\r");
|
||||
if (layout.caretColumn > 0) output.write(`\u001b[${layout.caretColumn}C`);
|
||||
};
|
||||
pane?.attach(() => `${editor.line ?? ""}`, renderCompose, () => caretUp, () => Math.max(0, Math.min(Number(editor.cursor) || 0, `${editor.line ?? ""}`.length)));
|
||||
|
||||
const editor = readline.createInterface({
|
||||
input: editorInput, output, terminal: true, prompt: "", historySize: 0, crlfDelay: Infinity,
|
||||
input: editorInput, output: sink, terminal: true, prompt: "", historySize: 0, crlfDelay: Infinity,
|
||||
});
|
||||
// Node forces dumb-terminal mode without editing if TERM=dumb. When terminal: true
|
||||
// was requested, remove the dumb-mode override so VT100 line editing is preserved.
|
||||
if (Object.prototype.hasOwnProperty.call(editor, "_ttyWrite")) {
|
||||
delete (editor as any)._ttyWrite;
|
||||
}
|
||||
|
||||
const decoder = new StringDecoder("utf8");
|
||||
let pending = "";
|
||||
let paste: string | null = null;
|
||||
@@ -111,7 +427,7 @@ export function createRunnerInput(
|
||||
// Node documents changing rl.line together with rl.cursor. The installed
|
||||
// typings mark them readonly, so assign this pair through one explicit seam.
|
||||
Object.assign(editor, { line, cursor });
|
||||
editor.prompt(true);
|
||||
pane?.refresh();
|
||||
};
|
||||
const clear = () => setLine("", 0);
|
||||
// Node 22.21–22.22.0 (also some 24/25 releases) appends batched text after
|
||||
@@ -137,6 +453,7 @@ export function createRunnerInput(
|
||||
}
|
||||
const consume = (text: string) => {
|
||||
if (paste === null) {
|
||||
pane?.beforeInput();
|
||||
keys.write(text);
|
||||
if (batchText) flushTyped();
|
||||
} else if (!discarded) {
|
||||
@@ -170,7 +487,7 @@ export function createRunnerInput(
|
||||
paste = null;
|
||||
discarded = false;
|
||||
clear();
|
||||
output.write("\n[pi-runner] input cleared\n");
|
||||
say("[pi-runner] input cleared");
|
||||
onSubmit("/abort");
|
||||
} else if (paste === null) {
|
||||
paste = "";
|
||||
@@ -189,6 +506,7 @@ export function createRunnerInput(
|
||||
paste = null;
|
||||
}
|
||||
}
|
||||
pane?.refresh();
|
||||
};
|
||||
const wasRaw = input.isRaw;
|
||||
const end = () => editor.close();
|
||||
@@ -196,7 +514,10 @@ export function createRunnerInput(
|
||||
output.write("\u001b[?2004h");
|
||||
input.on("data", receive);
|
||||
input.once("end", end);
|
||||
editor.on("line", submit);
|
||||
editor.on("line", (line) => {
|
||||
pane?.beforeSubmit();
|
||||
submit(line);
|
||||
});
|
||||
editor.once("close", () => {
|
||||
input.removeListener("data", receive);
|
||||
input.removeListener("end", end);
|
||||
@@ -204,6 +525,7 @@ export function createRunnerInput(
|
||||
input.pause();
|
||||
keys.removeListener("keypress", keypress);
|
||||
keys.destroy();
|
||||
pane?.detach();
|
||||
if (editorInput !== keys) editorInput.destroy();
|
||||
output.write("\u001b[?2004l");
|
||||
});
|
||||
@@ -359,6 +681,11 @@ export interface RunnerIo {
|
||||
mirrorLine(line: string): void;
|
||||
/** Append raw text to the current pane line (streamed deltas). */
|
||||
mirrorAppend(text: string): void;
|
||||
/** Pin a copy of a diagnostic on the pane's status line, when the pane provides one.
|
||||
* The transcript copy is written by the caller; this pin is never the only copy. */
|
||||
diagnostic?(text: string): void;
|
||||
/** Drop a pinned status line at the start of a new agent turn. */
|
||||
clearStatus?(): void;
|
||||
/** POST to the daemon activity endpoint. May resolve to the parsed JSON
|
||||
* response (null on transport failure) so identity delivery can retry. */
|
||||
postActivity(payload: Record<string, unknown>): void | Promise<Record<string, unknown> | null>;
|
||||
@@ -592,7 +919,7 @@ export class RunnerCore {
|
||||
|
||||
/** A frame that can't be reassembled is lost, and said so. If it was a get_entries reply, the cursor stays where it was. */
|
||||
private reportDroppedFrame(reason: string | null | undefined): void {
|
||||
if (reason) this.io.mirrorLine(`[omp-runner] ERROR rpc frame: ${reason}; the chunked frame was dropped`);
|
||||
if (reason) this.diag(`[omp-runner] ERROR rpc frame: ${reason}; the chunked frame was dropped`);
|
||||
}
|
||||
|
||||
private get runtime(): string { return this.opts.runtime ?? "pi"; }
|
||||
@@ -601,7 +928,7 @@ export class RunnerCore {
|
||||
handleUserBlock(block: string): void {
|
||||
if (block === "/abort") {
|
||||
this.io.sendRpc({ type: "abort" });
|
||||
this.io.mirrorLine("[pi-runner] abort sent");
|
||||
this.diag("[pi-runner] abort sent");
|
||||
return;
|
||||
}
|
||||
if (block.startsWith("/followup ")) {
|
||||
@@ -646,7 +973,7 @@ export class RunnerCore {
|
||||
} else {
|
||||
// Advertised but refused: v1 as before, so a reply over 1 MiB still fails, visibly.
|
||||
const detail = typeof record.error === "string" ? record.error : "refused";
|
||||
this.io.mirrorLine(`[omp-runner] RPC protocol v2 not negotiated (${detail}); replies over 1 MiB will fail`);
|
||||
this.diag(`[omp-runner] RPC protocol v2 not negotiated (${detail}); replies over 1 MiB will fail`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -656,7 +983,7 @@ export class RunnerCore {
|
||||
if (record.id === GET_STATE_ID) {
|
||||
this.ready = false;
|
||||
this.writeSidecar({});
|
||||
this.io.mirrorLine(this.runtime === "omp" ? `[omp-runner] ERROR rpc get_state: ${message}` : `${PI_RUNNER_ERROR_MARKER} rpc: ${message}`);
|
||||
this.diag(this.runtime === "omp" ? `[omp-runner] ERROR rpc get_state: ${message}` : `${PI_RUNNER_ERROR_MARKER} rpc: ${message}`);
|
||||
// OMP cannot provide a resume token without get_state; stop it so the
|
||||
// launch fails now instead of waiting out readiness.
|
||||
if (this.runtime === "omp") this.io.stopChild?.();
|
||||
@@ -665,11 +992,11 @@ export class RunnerCore {
|
||||
if (record.id === CATCH_UP_ID && this.runtime === "pi") {
|
||||
// A refused catch-up (a saved cursor this session's history doesn't hold, such as an older runner's UI-event
|
||||
// id) is history sync, not the seat's resume: say so, and take a confirmed cursor from the history's tail.
|
||||
this.io.mirrorLine(`[pi-runner] History catch-up didn't run (${message}). That's history sync, separate from the seat's resume; reading the history cursor again.`);
|
||||
this.diag(`[pi-runner] History catch-up didn't run (${message}). That's history sync, separate from the seat's resume; reading the history cursor again.`);
|
||||
this.io.sendRpc({ type: "get_entries", id: CURSOR_REFRESH_ID });
|
||||
return;
|
||||
}
|
||||
this.io.mirrorLine(`[${this.runtime}-runner] ERROR rpc: ${message}`);
|
||||
this.diag(`[${this.runtime}-runner] ERROR rpc: ${message}`);
|
||||
// Only a rejected input means the seat cannot do the requested work.
|
||||
// A mistimed /abort (or any control command) leaves the seat idle.
|
||||
if (this.runtime === "omp" && typeof record.command === "string" && Object.hasOwn(INPUT_COMMANDS, record.command)) {
|
||||
@@ -687,13 +1014,13 @@ export class RunnerCore {
|
||||
this.ready = this.runtime === "pi" || !!this.sessionFile;
|
||||
if (!this.ready) {
|
||||
if (this.runtime === "omp") {
|
||||
this.io.mirrorLine("[omp-runner] ERROR rpc get_state returned no session file; cannot provide a resume token");
|
||||
this.diag("[omp-runner] ERROR rpc get_state returned no session file; cannot provide a resume token");
|
||||
this.io.stopChild?.();
|
||||
return;
|
||||
}
|
||||
// For custom protocol: pi-rpc community harnesses, reporting no session file
|
||||
// reads as needing attention rather than stopping the child process.
|
||||
this.io.mirrorLine(`[${this.runtime}-runner] ERROR rpc get_state returned no session file; cannot provide a resume token`);
|
||||
this.diag(`[${this.runtime}-runner] ERROR rpc get_state returned no session file; cannot provide a resume token`);
|
||||
this.raiseAttention("runtime_error");
|
||||
return;
|
||||
}
|
||||
@@ -856,6 +1183,13 @@ export class RunnerCore {
|
||||
this.attentionIsLatest = false;
|
||||
}
|
||||
|
||||
/** A diagnostic stays in the transcript — the readiness probe and the operator both read
|
||||
* it there; a pane with a status line pins the latest one as a convenience copy. */
|
||||
private diag(text: string): void {
|
||||
this.io.mirrorLine(text);
|
||||
this.io.diagnostic?.(text);
|
||||
}
|
||||
|
||||
private handleEvent(event: Record<string, unknown>): void {
|
||||
if (event.type === "extension_ui_request") {
|
||||
const method = event.method;
|
||||
@@ -863,7 +1197,7 @@ export class RunnerCore {
|
||||
const detail = [event.title, event.message].filter((text): text is string => typeof text === "string")
|
||||
.map(stripVTControlCharacters).join(" — ").replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, " ").slice(0, 400);
|
||||
if (this.runtime !== "omp" && !dialog) {
|
||||
if (method === "notify") this.io.mirrorLine(`[${this.runtime}-runner] ${detail}`);
|
||||
if (method === "notify") this.diag(`[${this.runtime}-runner] ${detail}`);
|
||||
return; // Pi notifications and other fire-and-forget UI requests expect no answer.
|
||||
}
|
||||
// RPC does not mount an interactive UI. Reply at once; otherwise the
|
||||
@@ -872,7 +1206,7 @@ export class RunnerCore {
|
||||
this.io.sendRpc({ type: "extension_ui_response", id: event.id, cancelled: true });
|
||||
}
|
||||
if (dialog) {
|
||||
this.io.mirrorLine(this.runtime !== "omp"
|
||||
this.diag(this.runtime !== "omp"
|
||||
? `[${this.runtime}-runner] Extension ${method}: ${detail}; cancelled because the managed RPC pane cannot answer it. Operator action required: review the cancelled request and re-send an instruction the extension can complete without a dialog.`
|
||||
: "[omp-runner] Approval/input requested; cancelled because the managed RPC pane cannot answer it. Operator action required: review the denied action and re-send a safe instruction, or change the seat permission policy explicitly.");
|
||||
this.raiseAttention("permission_prompt");
|
||||
@@ -892,7 +1226,10 @@ export class RunnerCore {
|
||||
this.writeSidecar({});
|
||||
}
|
||||
|
||||
if (event.type === "agent_start") this.clearAttention();
|
||||
if (event.type === "agent_start") {
|
||||
this.clearAttention();
|
||||
this.io.clearStatus?.();
|
||||
}
|
||||
if (this.runtime === "omp") {
|
||||
if (event.type === "agent_start" || event.type === "turn_start"
|
||||
|| (event.type === "message_start" && message?.role === "assistant")
|
||||
@@ -936,7 +1273,7 @@ export class RunnerCore {
|
||||
// the repeat lacks it, then reset at the next assistant message/agent
|
||||
// turn, not at agent_end.
|
||||
if (event.type === "message_end" || !this.assistantErrorShown) {
|
||||
this.io.mirrorLine(mapped.errorNotice);
|
||||
this.diag(mapped.errorNotice);
|
||||
}
|
||||
this.assistantErrorShown = true;
|
||||
// Only typed, explicitly transient HTTP failures may release the send
|
||||
@@ -1445,6 +1782,14 @@ export async function main(argv = process.argv.slice(2)): Promise<void> {
|
||||
// The child may close its pipe before the first RPC write completes.
|
||||
child.stdin.on("error", () => { /* spawn/exit handlers own the diagnosis */ });
|
||||
|
||||
const pane = createPaneCompositor(process.stdout);
|
||||
// Diagnostics stay in the transcript — the pane's readiness probe greps it, and people
|
||||
// and agents read the history there; the status line only pins the latest copy.
|
||||
const paneDiag = (text: string) => {
|
||||
pane.line(text);
|
||||
pane.diagnostic(text);
|
||||
};
|
||||
|
||||
const chargeLookup = new OpenRouterChargeLookup({
|
||||
fetchGeneration: openRouterGenerationFetcher(() => childEnv["OPENROUTER_API_KEY"]),
|
||||
postCharges: async (piSessionId, charges) => {
|
||||
@@ -1463,7 +1808,7 @@ export async function main(argv = process.argv.slice(2)): Promise<void> {
|
||||
}, () => false as const).finally(() => clearTimeout(timeout));
|
||||
},
|
||||
schedule: (fn, ms) => { setTimeout(fn, ms).unref(); },
|
||||
notice: (line) => { process.stdout.write(`${line}\n`); },
|
||||
notice: (line) => { paneDiag(line); },
|
||||
});
|
||||
|
||||
// One activity post at a time: the daemon reads its newest record by arrival, so a beat still in flight when a turn
|
||||
@@ -1486,8 +1831,10 @@ export async function main(argv = process.argv.slice(2)): Promise<void> {
|
||||
sendRpc: (cmd) => {
|
||||
try { child.stdin.write(`${JSON.stringify(cmd)}\n`); } catch { /* exit handler reports */ }
|
||||
},
|
||||
mirrorLine: (line) => process.stdout.write(`${line}\n`),
|
||||
mirrorAppend: (text) => process.stdout.write(text),
|
||||
mirrorLine: (line) => pane.line(line),
|
||||
mirrorAppend: (text) => pane.transcript(text),
|
||||
diagnostic: (text) => pane.diagnostic(text),
|
||||
clearStatus: () => pane.clearStatus(),
|
||||
postActivity: (payload) => {
|
||||
if (!endpoint || typeof fetch !== "function") return;
|
||||
const result = postInOrder(payload).catch(() => null);
|
||||
@@ -1568,7 +1915,7 @@ export async function main(argv = process.argv.slice(2)): Promise<void> {
|
||||
let reportedPiCapability = false;
|
||||
readline.createInterface({ input: child.stderr }).on("line", (line) => {
|
||||
if (!line.trim()) return;
|
||||
process.stdout.write(`[${runtime}:err] ${line}\n`);
|
||||
paneDiag(`[${runtime}:err] ${line}`);
|
||||
stderrTail.push(line.length > OMP_STDERR_TAIL_CHARS ? `${line.slice(0, OMP_STDERR_TAIL_CHARS)}...` : line);
|
||||
if (stderrTail.length > OMP_STDERR_TAIL_LINES) stderrTail.shift();
|
||||
if (runtime === "pi" && !core.isReady() && !reportedPiCapability) {
|
||||
@@ -1582,15 +1929,15 @@ export async function main(argv = process.argv.slice(2)): Promise<void> {
|
||||
});
|
||||
if (detail) {
|
||||
reportedPiCapability = true;
|
||||
process.stdout.write(`[pi-runner] ${detail}\n`);
|
||||
paneDiag(`[pi-runner] ${detail}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
const input = createRunnerInput(process.stdin, process.stdout, (block) => core.handleUserBlock(block));
|
||||
const input = createRunnerInput(process.stdin, process.stdout, (block) => core.handleUserBlock(block), pane);
|
||||
|
||||
if (runtime !== "omp") {
|
||||
child.on("error", (err) => {
|
||||
console.error(`${PI_RUNNER_ERROR_MARKER} failed to spawn ${args.launchWrapper?.length ? `launch_wrapper (${spawnFile})` : (args.command ?? "pi")}: ${err.message}`);
|
||||
paneDiag(`${PI_RUNNER_ERROR_MARKER} failed to spawn ${args.launchWrapper?.length ? `launch_wrapper (${spawnFile})` : (args.command ?? "pi")}: ${err.message}`);
|
||||
const code = (err as NodeJS.ErrnoException).code === "ENOENT" ? 127 : 1;
|
||||
core.handlePiExit(code);
|
||||
input.close();
|
||||
@@ -1617,7 +1964,7 @@ export async function main(argv = process.argv.slice(2)): Promise<void> {
|
||||
if (!core.isReady()) {
|
||||
const how = signal ? `signal ${signal}` : `exit code ${code ?? "unknown"}`;
|
||||
const output = stderrTail.length > 0 ? ` Its last output: ${stderrTail.join(" | ")}` : " It printed nothing on stderr.";
|
||||
console.error(transportUp
|
||||
paneDiag(transportUp
|
||||
? "[omp-runner] ERROR OMP did not establish a resumable RPC session. Authenticate this isolated seat using HOME=<seat-root> PI_CODING_AGENT_DIR=<seat-root>/agent omp and /login, or provide its declared model provider key in the OpenRig daemon environment. Default OMP credentials are not shared."
|
||||
: `[omp-runner] ERROR OMP exited during startup, before its RPC transport started (${command}, ${how}).${output}`);
|
||||
}
|
||||
@@ -1626,7 +1973,7 @@ export async function main(argv = process.argv.slice(2)): Promise<void> {
|
||||
process.exitCode = code ?? 1;
|
||||
};
|
||||
child.on("error", (err) => {
|
||||
console.error(`[omp-runner] ERROR failed to spawn omp: ${err.message}`);
|
||||
paneDiag(`[omp-runner] ERROR failed to spawn omp: ${err.message}`);
|
||||
recordExit(null);
|
||||
});
|
||||
// "close" fires once OMP's stdio is drained, so its stderr is in the tail
|
||||
|
||||
@@ -74,8 +74,8 @@ export function daemonTmuxControlStatus(): { requested: boolean } & TmuxControlS
|
||||
return { requested: true, ...(daemonControl.status?.() ?? { state: "off" }) };
|
||||
}
|
||||
|
||||
function shellWord(value: string): string {
|
||||
return /^[A-Za-z0-9_@%+=:,./-]+$/.test(value) ? value : "'" + value.replace(/'/g, "'\"'\"'") + "'";
|
||||
function shellWord(value: string, force = false): string {
|
||||
return !force && /^[A-Za-z0-9_@%+=:,./-]+$/.test(value) ? value : "'" + value.replace(/'/g, "'\"'\"'") + "'";
|
||||
}
|
||||
|
||||
/** `tmux`, with the daemon's server flags when it isn't on the default server. */
|
||||
@@ -84,7 +84,39 @@ export function tmuxOnDaemonServer(): string {
|
||||
return `tmux${server ? ` ${server}` : ""}`;
|
||||
}
|
||||
|
||||
/** `tmux attach -t <session>`, with the daemon's server flags when it isn't on the default server. */
|
||||
/** A session name as tmux must receive it.
|
||||
*
|
||||
* Callers hand this a raw session name, not an argument tmux has already escaped, so the name is
|
||||
* encoded exactly once here, for the tmux rules that apply at this layer.
|
||||
*
|
||||
* `cmd_parse_from_arguments` drops a final `;` as a command separator and consumes a backslash
|
||||
* sitting immediately in front of it. One more backslash goes before that separator, whether or
|
||||
* not one is already there: a session named `report;` goes as `report\;` and one named `report\;`
|
||||
* goes as `report\\;`, so the two stay different all the way to tmux.
|
||||
*
|
||||
* `cmd_find_parse_target` reads several spellings as target markers rather than as part of a name:
|
||||
* a leading `=` is the exact-match prefix, a leading `@` a window target, a leading `%` a pane
|
||||
* target, and the words `~` and `{marked}` mean the marked target. One more `=` in front turns
|
||||
* each of those back into a session name, because the marker is consumed and the rest is looked up
|
||||
* as the name. A name starting with `$` is the one that cannot be rescued this way:
|
||||
* `cmd_find_get_session` reads it as a session id before the exact-match flag is consulted. */
|
||||
function tmuxSessionWord(session: string): string {
|
||||
const markedTarget = session === "~" || session === "{marked}";
|
||||
let word = /^[=@%]/.test(session) || markedTarget ? `=${session}` : session;
|
||||
if (word.endsWith(";")) word = `${word.slice(0, -1)}\\;`;
|
||||
return word;
|
||||
}
|
||||
|
||||
/** `tmux attach -t <session>`, with the daemon's server flags when it isn't on the default server.
|
||||
*
|
||||
* The session is encoded for tmux first and then quoted like any other shell word: callers type
|
||||
* this line into a fresh terminal (the CMUX panes) or hand it to a person to run, and a session
|
||||
* name is not guaranteed to be shell-safe — an adopted session's name comes from outside
|
||||
* OpenRig. `shellWord` leaves the ordinary `name@rig` form byte-identical. `=` is the one
|
||||
* character it cannot leave bare: zsh expands a word beginning with `=` to the path of that
|
||||
* command, and with MAGIC_EQUAL_SUBST it can expand one inside a `name=value` word, so any
|
||||
* session word carrying `=` is forced into quotes. A shell passes those quotes through. */
|
||||
export function tmuxAttachCommand(session: string): string {
|
||||
return `${tmuxOnDaemonServer()} attach -t ${session}`;
|
||||
const word = tmuxSessionWord(session);
|
||||
return `${tmuxOnDaemonServer()} attach -t ${shellWord(word, word.includes("="))}`;
|
||||
}
|
||||
@@ -7,6 +7,9 @@ import { queueErasureLinkIndexesSchema } from "./migrations/156_queue_erasure_li
|
||||
import { queueErasureBatchesSchema } from "./migrations/157_queue_erasure_batches.js";
|
||||
import { queueInboxCheckinsSchema } from "./migrations/158_queue_inbox_checkins.js";
|
||||
import { queueItemsHandedOffFromIndexSchema } from "./migrations/159_queue_items_handed_off_from_index.js";
|
||||
import { networkBindingsSchema } from "./migrations/160_network_bindings.js";
|
||||
import { networkReceiptsSchema } from "./migrations/161_network_receipts.js";
|
||||
import { sessionAddressReadIndexesSchema } from "./migrations/162_session_address_read_indexes.js";
|
||||
import { activityGrantsSchema } from "./migrations/143_activity_grants.js";
|
||||
import { environmentBindingsSchema } from "./migrations/144_environment_bindings.js";
|
||||
import { nodePermissionsToSchema } from "./migrations/145_node_permissions_to.js";
|
||||
@@ -321,4 +324,7 @@ export const ALL_MIGRATIONS: Migration[] = [
|
||||
queueErasureBatchesSchema,
|
||||
queueInboxCheckinsSchema,
|
||||
queueItemsHandedOffFromIndexSchema,
|
||||
networkBindingsSchema,
|
||||
networkReceiptsSchema,
|
||||
sessionAddressReadIndexesSchema,
|
||||
];
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Migration } from "../migrate.js";
|
||||
|
||||
/** A selected local seat joined to one participant of a remote hub (an account's network rig, reached over MCP). The
|
||||
* participant's credential lives in a private file under the OpenRig home, never in this table. One active binding per
|
||||
* local seat and per hub participant; a disconnected or revoked binding stays as history. `allow_http` records the
|
||||
* person's choice to send the participant's token to a plain-http hub. */
|
||||
export const networkBindingsSchema: Migration = {
|
||||
name: "160_network_bindings.sql",
|
||||
sql: `
|
||||
CREATE TABLE IF NOT EXISTS network_bindings (
|
||||
id TEXT PRIMARY KEY,
|
||||
node_id TEXT NOT NULL,
|
||||
local_session TEXT NOT NULL,
|
||||
hub_url TEXT NOT NULL,
|
||||
hub_seat TEXT NOT NULL,
|
||||
allow_http INTEGER NOT NULL DEFAULT 0 CHECK (allow_http IN (0, 1)),
|
||||
state TEXT NOT NULL CHECK (state IN ('active', 'revoked', 'disconnected')),
|
||||
state_reason TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
last_contact_at TEXT,
|
||||
last_error TEXT
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_network_bindings_active_node ON network_bindings(node_id) WHERE state = 'active';
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_network_bindings_active_participant ON network_bindings(hub_url, hub_seat) WHERE state = 'active';
|
||||
`,
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Migration } from "../migrate.js";
|
||||
|
||||
/** One hub item taken in, and the local item that carries it: what was reported back, and when. It's a receipt, not a
|
||||
* task: the work itself is the local queue item, found by its id, which is derived from the seat's node, the hub, the
|
||||
* participant and the hub item. `binding_id` is the binding that reports it now, and a new binding for the same seat,
|
||||
* hub and participant takes it over. `settled_at` is when nothing more is reported (the result was sent, the outcome is
|
||||
* unknown, or the hub item was closed elsewhere: `outcome` says which); `checked_at` gives every open receipt a turn. */
|
||||
export const networkReceiptsSchema: Migration = {
|
||||
name: "161_network_receipts.sql",
|
||||
sql: `
|
||||
CREATE TABLE IF NOT EXISTS network_receipts (
|
||||
local_qitem_id TEXT PRIMARY KEY,
|
||||
binding_id TEXT NOT NULL,
|
||||
hub_qitem_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
queued_sent_at TEXT,
|
||||
pickup_sent_at TEXT,
|
||||
settled_at TEXT,
|
||||
checked_at TEXT,
|
||||
outcome TEXT,
|
||||
last_error TEXT
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_network_receipts_hub ON network_receipts(binding_id, hub_qitem_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_network_receipts_open ON network_receipts(binding_id) WHERE settled_at IS NULL;
|
||||
`,
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Migration } from "../migrate.js";
|
||||
|
||||
/** Address reads seek a current binding; inventory recency is by id, not wall clock.
|
||||
* Keep the older created_at index for readers that explicitly use that order. */
|
||||
export const sessionAddressReadIndexesSchema: Migration = {
|
||||
name: "162_session_address_read_indexes.sql",
|
||||
sql: `
|
||||
CREATE INDEX IF NOT EXISTS idx_bindings_tmux_session ON bindings(tmux_session)
|
||||
WHERE tmux_session IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_node_latest_id ON sessions(node_id, id DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_name_latest_id ON sessions(session_name, id DESC);
|
||||
`,
|
||||
};
|
||||
@@ -26,6 +26,11 @@ export interface HookActivityInput {
|
||||
sourceSequence?: number | null;
|
||||
/** How often the emitter promises to report while it stays idle (OpenRig's pi runner). */
|
||||
livenessSeconds?: number | null;
|
||||
/** #99: the reset instant a Claude usage limit stated; kept only on that typed failure. */
|
||||
resetsAt?: string | null;
|
||||
/** The provider's own session id the hook reports (Claude's session_id). A stated reset is kept only when this is
|
||||
* the seat's own Claude session (its resume token), so a helper on another login can't park the seat's pool. */
|
||||
sessionId?: string | null;
|
||||
}
|
||||
|
||||
export type RecordHookActivityResult =
|
||||
@@ -113,6 +118,16 @@ export class AgentActivityStore {
|
||||
} catch { return unavailablePermissionStop(); }
|
||||
}
|
||||
|
||||
/** #99: whether a hook's Claude session id is the seat's own Claude session (its current resume token). A helper
|
||||
* Claude started from the seat's shell posts as the seat but runs its own session, possibly on another login. */
|
||||
private isSeatOwnClaudeSession(nodeId: string, hookSessionId: string | null | undefined): boolean {
|
||||
const token = typeof hookSessionId === "string" ? hookSessionId.trim() : "";
|
||||
if (!token) return false;
|
||||
const row = this.db.prepare("SELECT resume_type, resume_token FROM sessions WHERE node_id = ? ORDER BY id DESC LIMIT 1")
|
||||
.get(nodeId) as { resume_type: string | null; resume_token: string | null } | undefined;
|
||||
return row?.resume_type === "claude_id" && row.resume_token?.trim() === token;
|
||||
}
|
||||
|
||||
recordHookEvent(input: HookActivityInput): RecordHookActivityResult {
|
||||
if (!input.sessionName && !input.nodeId) {
|
||||
return {
|
||||
@@ -159,6 +174,10 @@ export class AgentActivityStore {
|
||||
if (input.hookAgentId !== undefined) activity.hookAgentId = input.hookAgentId;
|
||||
if (input.sourceSequence) activity.sourceSequence = input.sourceSequence;
|
||||
if (input.livenessSeconds) activity.livenessSeconds = input.livenessSeconds;
|
||||
if (input.resetsAt && isClaudeRateLimitHook(activity.runtime, input.hookEvent, input.subtype)
|
||||
&& this.isSeatOwnClaudeSession(session.node_id, input.sessionId)) {
|
||||
activity.resetsAt = input.resetsAt;
|
||||
}
|
||||
|
||||
const event = this.eventBus.emit({
|
||||
type: "agent.activity",
|
||||
|
||||
@@ -643,6 +643,9 @@ export class BootstrapOrchestrator {
|
||||
if (!outcome.ok) {
|
||||
errors.push(`Package install failed for '${ref}' (${runtime}): ${outcome.message}`);
|
||||
packageInstallFailed = true;
|
||||
} else {
|
||||
// A file the install left as it is, and why: the rest installed, and the rig still imports.
|
||||
for (const note of outcome.notes ?? []) warnings.push(`Package '${ref}' (${runtime}): ${note}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// The shared words for what happened to a piece of work and what a member can do. Today each part of OpenRig names
|
||||
// these for itself (the transport, the outbox, the queue's wake record, the hosted ring, the watchdog), and the names
|
||||
// drift: a result one reader doesn't recognise reads as fine to it and as a failure to another. This module is where
|
||||
// the words live, so a consumer asks it instead of matching raw strings.
|
||||
//
|
||||
// It translates what is already stored; it never renames it. Activity stays in activity-taxonomy.ts, which this module
|
||||
// doesn't redefine. An unrecognised word always reads `unknown`, never success and never failure.
|
||||
|
||||
/** The two contracts' identifiers, in the daemon's existing `<name>.v<N>` form (`operation-correlation.v1`). N changes
|
||||
* only when a word's meaning changes incompatibly; new jobs and capabilities are advertised, not versioned. */
|
||||
export const TEAM_OPERATIONS_CONTRACT = "team-operations.v1";
|
||||
export const MEMBER_CONNECTION_CONTRACT = "member-connection.v1";
|
||||
|
||||
/** Whether a member or a daemon supports something. A client reads an absent answer as `unknown`, never as
|
||||
* `not-supported`. */
|
||||
export type Support = "supported" | "not-supported" | "unknown";
|
||||
|
||||
/** Who established a fact: OpenRig observing it directly, OpenRig's own adapter reporting what it read from the
|
||||
* program, or an outside adapter. A member's own claim never makes a fact observed. */
|
||||
export type Reporter = "openrig-observed" | "openrig-adapter" | "outside-adapter";
|
||||
|
||||
/** What a delivery route says happened to one attempt. None of them means the member read the work or did it: pickup
|
||||
* is a claim on the row, and nothing else. */
|
||||
export type DeliveryFact =
|
||||
/** The route took it: text typed and submitted, a trigger answered 2xx. */
|
||||
| "accepted"
|
||||
/** Queued inside the program; no start seen yet. */
|
||||
| "queued-not-started"
|
||||
/** A turn start correlated to this attempt. */
|
||||
| "turn-started"
|
||||
/** Proven that nothing reached the member: no input was written, or the route refused before sending. */
|
||||
| "not-delivered"
|
||||
/** The route reported a failure after the attempt may already have reached the member (an error answer, a dropped
|
||||
* connection): failed, with its effect unknown. */
|
||||
| "failed-effect-unknown"
|
||||
/** Held on purpose by a guard or a setting; nothing was written. */
|
||||
| "held"
|
||||
| "unknown";
|
||||
|
||||
export interface ReportedFact<F extends string> {
|
||||
fact: F;
|
||||
reporter: Reporter;
|
||||
}
|
||||
|
||||
/** The route a wake took. `none` means no route can carry it (a pull-only member, an address nothing holds). */
|
||||
export type WakeRoute = "terminal" | "gateway" | "hosted-trigger" | "none";
|
||||
|
||||
/** One wake attempt's outcome, read from the queue's recorded nudge result. */
|
||||
export interface WakeOutcome extends ReportedFact<DeliveryFact> {
|
||||
/** Null when the recorded text names no route this reader knows. */
|
||||
route: WakeRoute | null;
|
||||
/** A terminal wake whose submission was seen rendered, not only sent. */
|
||||
renderVerified?: true;
|
||||
/** Which guard held it: the seat's hold or interruption level (retrying is held again), or the draft-aware check
|
||||
* (a draft or unreadable input, which can clear on its own). */
|
||||
heldBy?: "typing-guard" | "draft-aware";
|
||||
/** The recorded text, unchanged. */
|
||||
recorded: string;
|
||||
}
|
||||
|
||||
/** Read a queue row's recorded nudge result (`last_nudge_result`) as a delivery fact. Every form a producer writes
|
||||
* today is listed; anything else reads `unknown` with no route, so a consumer can tell "the wake's outcome can't be
|
||||
* read" from every known outcome. Null when no attempt was recorded. */
|
||||
export function readWakeOutcome(recorded: string | null | undefined): WakeOutcome | null {
|
||||
if (recorded === null || recorded === undefined) return null;
|
||||
const at = (fact: DeliveryFact, route: WakeRoute | null, extra: Partial<WakeOutcome> = {}): WakeOutcome =>
|
||||
({ fact, reporter: "openrig-observed", route, recorded, ...extra });
|
||||
if (recorded === "verified") return at("accepted", "terminal", { renderVerified: true });
|
||||
// Sent and submitted without render verification: the route took it, nothing more.
|
||||
if (recorded === "delivered-ack-pending") return at("accepted", "terminal");
|
||||
if (recorded === "retained:typing_guard") return at("held", "terminal", { heldBy: "typing-guard" });
|
||||
if (recorded.startsWith("retained:")) return at("held", "terminal", { heldBy: "draft-aware" });
|
||||
// A hosted member's trigger answered: the ring was taken, not the work.
|
||||
if (recorded.startsWith("wake-accepted:webhook")) return at("accepted", "hosted-trigger");
|
||||
if (recorded.startsWith("indeterminate:webhook")) return at("unknown", "hosted-trigger");
|
||||
// A ring that never left: no URL, no Authorization value, an invalid URL.
|
||||
if (/^failed:webhook \([^)]*\) (isn't set:|not rung:)/.test(recorded)) return at("not-delivered", "hosted-trigger");
|
||||
// Any other webhook failure came after the POST was sent. The trigger's own error answer is its report; a network
|
||||
// error is what OpenRig saw. Either way the member may have the ring.
|
||||
if (/^failed:webhook \([^)]*\) answered /.test(recorded)) {
|
||||
return at("failed-effect-unknown", "hosted-trigger", { reporter: "outside-adapter" });
|
||||
}
|
||||
if (recorded.startsWith("failed:webhook")) return at("failed-effect-unknown", "hosted-trigger");
|
||||
// The gateway's own ledger records whether the person was posted to; this attempt's own outcome is unknown.
|
||||
if (recorded.startsWith("gateway-owned:")) return at("unknown", "gateway");
|
||||
if (recorded.startsWith("unroutable:")) return at("not-delivered", "none");
|
||||
// `indeterminate:` covers input that may have been written (a paste, an unconfirmed Enter) and timeouts.
|
||||
if (recorded.startsWith("indeterminate:")) return at("unknown", "terminal");
|
||||
// `failed:` is written only when no input could have reached the seat.
|
||||
if (recorded.startsWith("failed:")) return at("not-delivered", "terminal");
|
||||
return at("unknown", null);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { physicalSessionName } from "./session-registry.js";
|
||||
import { resolveNodeFile } from "./scope/node-file.js";
|
||||
import { belongsToProject } from "./workspace/project-catalog.js";
|
||||
import { inspectGraph } from "./workflow-reconciliation.js";
|
||||
import { createProofPolicyRead, readMissionReadiness, readProjectReadiness } from "./proof/judgments.js";
|
||||
import { createProofPolicyRead, readMissionReadiness, readProjectReadinessAsync } from "./proof/judgments.js";
|
||||
import { lifecycleObligations, requiredLifecycleSteps } from "./lifecycle-obligations.js";
|
||||
import { QueueWakeRepository } from "./queue-wake-repository.js";
|
||||
import { isTypedGateBlocker } from "./queue-repository.js";
|
||||
@@ -1361,7 +1361,7 @@ export async function buildExecutionView(deps: ExecutionViewDeps, opts?: { missi
|
||||
...(opts?.project ? { project: opts.project, membership: "exact project:<id> queue tags and lifecycle identity; unscoped rows excluded" } : {}),
|
||||
view: "execution",
|
||||
readiness,
|
||||
project_readiness: missionsRoot ? readProjectReadiness(missionsRoot, readPolicy) : null,
|
||||
project_readiness: missionsRoot ? await readProjectReadinessAsync(missionsRoot, readPolicy) : null,
|
||||
// Authored guidance is carried verbatim in meaning, never parsed into edges or acceptance.
|
||||
planning_guidance: arrangement?.state === "valid" ? arrangement.guidance : [],
|
||||
mission,
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { InstallPlanEntry } from "./install-planner.js";
|
||||
import type { PolicyResult } from "./install-policy.js";
|
||||
import type { RefinedInstallPlan } from "./conflict-detector.js";
|
||||
import { InstallRepository, type JournalEntry } from "./install-repository.js";
|
||||
import { damagedManagedBlockNote, findManagedBlocks, managedBlockBodyMatches, mergeManagedBlockText } from "./managed-blocks.js";
|
||||
|
||||
export interface EngineFsOps {
|
||||
readFile: (filePath: string) => string;
|
||||
@@ -19,6 +20,8 @@ export interface InstallResult {
|
||||
applied: JournalEntry[];
|
||||
deferred: InstallPlanEntry[];
|
||||
conflicts: InstallPlanEntry[];
|
||||
/** Entries left as they were, each with the reason: a guidance file whose block has a marker without its pair. */
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
export interface RollbackResult {
|
||||
@@ -52,10 +55,19 @@ export class InstallEngine {
|
||||
): InstallResult {
|
||||
const install = this.installRepo.createInstall(packageId, targetRoot, "project_shared", bootstrapId);
|
||||
const applied: JournalEntry[] = [];
|
||||
const notes: string[] = [];
|
||||
const backupRoot = path.join(targetRoot, ".rigged-backups", install.id);
|
||||
|
||||
try {
|
||||
for (const entry of policyResult.approved) {
|
||||
// A guidance block with a marker that lost its pair can't be replaced, and another copy beside it is the
|
||||
// duplicate to avoid: that file is left as it is and named, and the rest of the install goes on.
|
||||
const unreadable = entry.exportType === "guidance" ? this.unreadableGuidanceBlock(entry, plan.packageName) : null;
|
||||
if (unreadable) {
|
||||
notes.push(unreadable);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ensure target directory
|
||||
this.fs.mkdirp(path.dirname(entry.targetPath));
|
||||
|
||||
@@ -121,6 +133,7 @@ export class InstallEngine {
|
||||
applied,
|
||||
deferred: plan.deferred,
|
||||
conflicts: plan.conflicts,
|
||||
notes,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -170,6 +183,14 @@ export class InstallEngine {
|
||||
return { restored, deleted };
|
||||
}
|
||||
|
||||
/** The note for a guidance entry whose target holds a marker of the package's block without its pair, and no complete
|
||||
* copy, or null. */
|
||||
private unreadableGuidanceBlock(entry: InstallPlanEntry, packageName: string): string | null {
|
||||
if (!entry.sourcePath || !this.fs.exists(entry.targetPath)) return null;
|
||||
const found = findManagedBlocks(this.fs.readFile(entry.targetPath), packageName);
|
||||
return found.blocks.length === 0 && found.unpaired > 0 ? damagedManagedBlockNote(entry.targetPath, packageName) : null;
|
||||
}
|
||||
|
||||
private applyGuidanceMerge(entry: InstallPlanEntry, packageName: string): void {
|
||||
if (!entry.sourcePath) return;
|
||||
|
||||
@@ -178,34 +199,17 @@ export class InstallEngine {
|
||||
|
||||
if (this.fs.exists(entry.targetPath)) {
|
||||
const existing = this.fs.readFile(entry.targetPath);
|
||||
const startMarker = BLOCK_START(packageName);
|
||||
const endMarker = BLOCK_END(packageName);
|
||||
const legacyStart = `<!-- BEGIN RIGGED MANAGED BLOCK: ${packageName} -->`;
|
||||
const legacyEnd = `<!-- END RIGGED MANAGED BLOCK: ${packageName} -->`;
|
||||
// Prefer the OpenRig form; fall back to legacy markers from
|
||||
// prior installs so re-applying a guidance package over a
|
||||
// pre-rename file replaces the old block instead of appending a
|
||||
// duplicate.
|
||||
let startIdx = existing.indexOf(startMarker);
|
||||
let endIdx = existing.indexOf(endMarker, startIdx + startMarker.length);
|
||||
let matchedEndLength = endMarker.length;
|
||||
if (startIdx === -1 || endIdx === -1) {
|
||||
const legacyStartIdx = existing.indexOf(legacyStart);
|
||||
const legacyEndIdx = existing.indexOf(legacyEnd, legacyStartIdx + legacyStart.length);
|
||||
if (legacyStartIdx !== -1 && legacyEndIdx !== -1) {
|
||||
startIdx = legacyStartIdx;
|
||||
endIdx = legacyEndIdx;
|
||||
matchedEndLength = legacyEnd.length;
|
||||
}
|
||||
}
|
||||
|
||||
if (startIdx !== -1 && endIdx !== -1) {
|
||||
// Update existing block (rewrites legacy markers to OpenRig form).
|
||||
const updated = existing.slice(0, startIdx) + block + existing.slice(endIdx + matchedEndLength);
|
||||
this.fs.writeFile(entry.targetPath, updated);
|
||||
} else {
|
||||
const found = findManagedBlocks(existing, packageName);
|
||||
if (found.blocks.length === 0) {
|
||||
// Insert new block at end
|
||||
this.fs.writeFile(entry.targetPath, existing + "\n\n" + block + "\n");
|
||||
} else {
|
||||
// Update the first copy in place, in the OpenRig form (legacy markers too). A later copy goes when it repeats the
|
||||
// first copy's text or the package's; a copy with other text, which someone wrote, stays.
|
||||
const first = found.blocks[0]!.body;
|
||||
const updated = mergeManagedBlockText(existing, packageName, block, [],
|
||||
(body) => !managedBlockBodyMatches(body, first) && !managedBlockBodyMatches(body, content));
|
||||
if (updated !== null) this.fs.writeFile(entry.targetPath, updated);
|
||||
}
|
||||
} else {
|
||||
// New file
|
||||
|
||||
@@ -45,7 +45,9 @@ export class InstallVerifier {
|
||||
this.fs = fs;
|
||||
}
|
||||
|
||||
verify(installId: string): VerificationResult {
|
||||
/** `skipped`: entries the install deliberately left as they were (its result's notes), so an install whose every entry was
|
||||
* skipped verifies as that, not as an empty journal. */
|
||||
verify(installId: string, opts: { skipped?: number } = {}): VerificationResult {
|
||||
const install = this.installRepo.getInstall(installId);
|
||||
if (!install) {
|
||||
return {
|
||||
@@ -76,6 +78,16 @@ export class InstallVerifier {
|
||||
const journal = this.installRepo.getJournalEntries(installId);
|
||||
const applyEntries = journal.filter((e) => e.action !== "rollback");
|
||||
|
||||
// Every entry left as it was on purpose, each named in the install's notes: nothing to check, and nothing missing.
|
||||
if (applyEntries.length === 0 && (opts.skipped ?? 0) > 0) {
|
||||
return {
|
||||
passed: true,
|
||||
installId,
|
||||
entries: [],
|
||||
statusCheck: { ...statusCheck, name: "entries_skipped", passed: true, expected: "applied entries, or skips named in notes", actual: `0 applied, ${opts.skipped} skipped` },
|
||||
};
|
||||
}
|
||||
|
||||
// Empty journal for applied install is a verification failure
|
||||
if (applyEntries.length === 0) {
|
||||
return {
|
||||
|
||||
@@ -2,7 +2,8 @@ import { createHash } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import nodePath from "node:path";
|
||||
import type Database from "better-sqlite3";
|
||||
import { frameManagedBlockBody, managedBlockStanding, mergeManagedBlock, type ManagedBlockMergeFsOps, type ManagedBlockStanding } from "./managed-blocks.js";
|
||||
import { damagedManagedBlockNote, frameManagedBlockBody, managedBlockBodyMatches, managedBlockStanding, mergeManagedBlock, pruneManagedBlockCopies,
|
||||
withCanonicalMarkers, type ManagedBlockMergeFsOps, type ManagedBlockStanding } from "./managed-blocks.js";
|
||||
|
||||
/** #2456: OpenRig's record of what it last wrote in each managed guidance block, so a later start, exact resume, handover
|
||||
* or runtime switch can tell a block it wrote (refreshed when its source changes) from a block a person edited (kept). */
|
||||
@@ -92,6 +93,8 @@ export function decideManagedBlock(content: string, blockId: string, source: str
|
||||
const standing = managedBlockStanding(content, blockId, source, recorded, managedBlockBodyHash);
|
||||
const named = opts.sourcePath ?? "its source";
|
||||
if (standing === "absent") return { standing, write: !opts.onlyIfPresent };
|
||||
// A marker without its pair: writing would add a second copy beside the block that can't be read, at every start.
|
||||
if (standing === "damaged") return { standing, write: false, note: damagedManagedBlockNote(targetPath, blockId) };
|
||||
if (unreadable && standing === "unrecorded") {
|
||||
return { standing: "unknown", write: false,
|
||||
note: `guidance_block_record_unreadable: ${targetPath}: ${blockId} wasn't refreshed from ${named} because OpenRig couldn't read its write record` };
|
||||
@@ -103,10 +106,36 @@ export function decideManagedBlock(content: string, blockId: string, source: str
|
||||
return { standing, write: true };
|
||||
}
|
||||
|
||||
/** A block that isn't rewritten (current, edited or unrecorded) can still carry extra copies, or markers an editor
|
||||
* rewrapped: take out the copies that repeat a kept one, and OpenRig's own (its source's text, or what it last wrote)
|
||||
* beside an edited one, so the file keeps one copy of each distinct text, and put the markers in OpenRig's form. Nothing
|
||||
* a person wrote is removed. Best effort: a file that can't be read or written is named in `notes` and left as it is,
|
||||
* and the block's own decision stands. Returns whether the file changed. */
|
||||
export function pruneKeptManagedBlock(files: ManagedBlockMergeFsOps, targetPath: string, blockId: string, source: string,
|
||||
records: ManagedBlockRecords | undefined, notes?: string[]): boolean {
|
||||
let recorded: string | undefined;
|
||||
try { recorded = records?.get(targetPath, blockId); } catch { /* unreadable: only the source's text counts as OpenRig's */ }
|
||||
try {
|
||||
const existing = files.exists(targetPath) ? files.readFile(targetPath) : "";
|
||||
const pruned = pruneManagedBlockCopies(existing, blockId,
|
||||
(body) => managedBlockBodyMatches(body, source) || (recorded !== undefined && managedBlockBodyHash(body) === recorded));
|
||||
const tidy = withCanonicalMarkers(pruned ?? existing, blockId);
|
||||
if (tidy === existing) return false;
|
||||
files.writeFile(targetPath, tidy);
|
||||
if (pruned !== null) {
|
||||
notes?.push(`guidance_block_copies_removed: ${targetPath}: ${blockId} had repeated copies; the repeats were removed and every edited copy was kept`);
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
notes?.push(`guidance_block_copies_not_removed: ${targetPath}: ${blockId}'s repeated copies or markers couldn't be tidied (${(err as Error).message}); the file was left as it is`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** #2456: the one write of a managed guidance block that every adapter's guidance merge goes through. Without `records` it's
|
||||
* the plain merge, as before. With them, `decideManagedBlock` decides; a kept block is named in `warnings` and nothing is
|
||||
* written. A written block is recorded at once, so a later failure in the same start can't lose its record. Returns
|
||||
* whether the block was written. */
|
||||
* the plain merge, as before. With them, `decideManagedBlock` decides; a kept block is named in `warnings`, and only its
|
||||
* repeated copies are taken out (`pruneKeptManagedBlock`). A written block is recorded at once, so a later failure in the
|
||||
* same start can't lose its record. Returns whether the block was written. */
|
||||
export function writeManagedGuidanceBlock(files: ManagedBlockMergeFsOps, targetPath: string, blockId: string, content: string, opts: {
|
||||
records?: ManagedBlockRecords; unrecorded?: "keep" | "refresh"; onlyIfPresent?: boolean; sourcePath?: string;
|
||||
warnings?: string[]; replaceBlockIds?: string[];
|
||||
@@ -115,15 +144,19 @@ export function writeManagedGuidanceBlock(files: ManagedBlockMergeFsOps, targetP
|
||||
// Without records, the plain merge as before; a refresh-only call (`onlyIfPresent`) then writes nothing.
|
||||
if (!opts.records) {
|
||||
if (opts.onlyIfPresent) return false;
|
||||
merge();
|
||||
return true;
|
||||
return merge();
|
||||
}
|
||||
const existing = files.exists(targetPath) ? files.readFile(targetPath) : "";
|
||||
const decision = decideManagedBlock(existing, blockId, content, opts.records, targetPath,
|
||||
{ unrecorded: opts.unrecorded ?? "refresh", onlyIfPresent: opts.onlyIfPresent, sourcePath: opts.sourcePath });
|
||||
if (decision.note) opts.warnings?.push(decision.note);
|
||||
if (!decision.write) return false;
|
||||
merge();
|
||||
if (!decision.write) {
|
||||
if (decision.standing === "edited" || decision.standing === "unrecorded") {
|
||||
pruneKeptManagedBlock(files, targetPath, blockId, content, opts.records, opts.warnings);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (!merge()) return false;
|
||||
recordManagedBlockWrite(opts.records, targetPath, blockId, content, opts.warnings);
|
||||
return true;
|
||||
}
|
||||
@@ -41,15 +41,95 @@ export function managedBlockText(blockId: string, content: string): string {
|
||||
/** What `mergeManagedBlock` puts between a file's existing text and a block it adds at the end. */
|
||||
export const MANAGED_BLOCK_APPEND_SEPARATOR = "\n\n";
|
||||
|
||||
/** A BEGIN or END marker for `blockId`, in the current or the legacy form, as an editor or formatter may leave it: any
|
||||
* run of whitespace, line breaks included, between its words. */
|
||||
function markerSource(kind: "BEGIN" | "END", blockId: string): string {
|
||||
return `<!--\\s*${kind}\\s+(?:OpenRig|RIGGED)\\s+MANAGED\\s+BLOCK:\\s*${escapeRegex(blockId)}\\s*-->`;
|
||||
}
|
||||
|
||||
/** One `blockId` block in a file: where it and its body start and end, and the text between its markers. */
|
||||
export interface FoundManagedBlock { start: number; end: number; bodyStart: number; bodyEnd: number; body: string }
|
||||
|
||||
/** Every `blockId` block in `content`, in order: each BEGIN paired with the next marker when that marker is an END. A
|
||||
* marker left without a pair (an END with no BEGIN before it, or a BEGIN followed by another BEGIN or by nothing) is
|
||||
* counted in `unpaired`: where its block ends, or starts, can't be read. */
|
||||
export function findManagedBlocks(content: string, blockId: string): { blocks: FoundManagedBlock[]; unpaired: number } {
|
||||
const markers = [...content.matchAll(new RegExp(`(${markerSource("BEGIN", blockId)})|${markerSource("END", blockId)}`, "g"))]
|
||||
.map((m) => ({ begin: m[1] !== undefined, index: m.index!, length: m[0].length }));
|
||||
const blocks: FoundManagedBlock[] = [];
|
||||
let unpaired = 0;
|
||||
for (let i = 0; i < markers.length; i++) {
|
||||
const marker = markers[i]!;
|
||||
const next = markers[i + 1];
|
||||
if (marker.begin && next && !next.begin) {
|
||||
const bodyStart = marker.index + marker.length;
|
||||
blocks.push({ start: marker.index, end: next.index + next.length, bodyStart, bodyEnd: next.index, body: content.slice(bodyStart, next.index) });
|
||||
i++;
|
||||
} else {
|
||||
unpaired++;
|
||||
}
|
||||
}
|
||||
return { blocks, unpaired };
|
||||
}
|
||||
|
||||
/** The note for a block OpenRig didn't write because a marker of it has lost its pair. */
|
||||
export function damagedManagedBlockNote(targetPath: string, blockId: string): string {
|
||||
return `guidance_block_markers_damaged: ${targetPath}: ${blockId} has a BEGIN or END marker without its pair, so OpenRig `
|
||||
+ "didn't add another copy; restore the missing marker or remove the partial block, and OpenRig's next write keeps one block";
|
||||
}
|
||||
|
||||
/**
|
||||
* `existing` with `blockId`'s block written as `block`, or null when it can't be written without a second copy: the file
|
||||
* has a marker of that block without its pair, and no complete copy. The first copy is replaced in place and a later copy
|
||||
* is removed with the separator before it, so a file that collected copies keeps one; with `keepLater`, a later copy it
|
||||
* names stays, unless it repeats one already kept. Blocks of `replaceBlockIds` (older ids) are removed. A file with none
|
||||
* of these gets the block at its end.
|
||||
*/
|
||||
export function mergeManagedBlockText(existing: string, blockId: string, block: string, replaceBlockIds: readonly string[] = [],
|
||||
keepLater?: (body: string) => boolean): string | null {
|
||||
const own = findManagedBlocks(existing, blockId);
|
||||
if (own.blocks.length === 0 && own.unpaired > 0) return null;
|
||||
const keptLater: string[] = [];
|
||||
const edits: Array<{ start: number; end: number; text: string }> = [];
|
||||
own.blocks.forEach((found, i) => {
|
||||
if (i === 0) edits.push({ start: found.start, end: found.end, text: block });
|
||||
else if (keepLater?.(found.body) && !keptLater.some((body) => managedBlockBodyMatches(body, found.body))) keptLater.push(found.body);
|
||||
else edits.push({ start: withSeparator(existing, found.start), end: found.end, text: "" });
|
||||
});
|
||||
for (const id of new Set(replaceBlockIds)) {
|
||||
if (id === blockId) continue;
|
||||
for (const found of findManagedBlocks(existing, id).blocks) edits.push({ start: found.start, end: found.end, text: "" });
|
||||
}
|
||||
if (edits.length === 0) return `${existing}${MANAGED_BLOCK_APPEND_SEPARATOR}${block}`;
|
||||
// A block of one id inside another's is left to the outer edit.
|
||||
const ordered: typeof edits = [];
|
||||
for (const edit of edits.sort((a, b) => a.start - b.start)) if (edit.start >= (ordered.at(-1)?.end ?? 0)) ordered.push(edit);
|
||||
let updated = existing;
|
||||
// The block goes in by slicing, never a replacement string, so `$&` and the like in authored content stay as written.
|
||||
for (const edit of ordered.reverse()) updated = `${updated.slice(0, edit.start)}${edit.text}${updated.slice(edit.end)}`;
|
||||
if (own.blocks.length === 0) updated = `${updated.trim()}\n\n${block}`.trim();
|
||||
// End with one newline only when the file doesn't already: re-merging on every launch must not add a line each time
|
||||
// (#2372), and blank lines a person left after the last block stay.
|
||||
return updated.endsWith("\n") ? updated : `${updated}\n`;
|
||||
}
|
||||
|
||||
/** Where a later copy's removal starts: before the newlines that separate it from the text above, so removing the copy
|
||||
* takes the separator the writer put there. */
|
||||
function withSeparator(content: string, start: number): number {
|
||||
let from = start;
|
||||
while (from > 0 && content[from - 1] === "\n") from--;
|
||||
return from;
|
||||
}
|
||||
|
||||
/** Write `blockId`'s block into a guidance file (see `mergeManagedBlockText`). Returns false, writing nothing and naming
|
||||
* the block in `warnings`, when a marker of it has lost its pair. */
|
||||
export function mergeManagedBlock(
|
||||
fs: ManagedBlockMergeFsOps,
|
||||
targetPath: string,
|
||||
blockId: string,
|
||||
content: string,
|
||||
options?: MergeManagedBlockOptions,
|
||||
): void {
|
||||
const begin = MANAGED_BLOCK_START(blockId);
|
||||
const end = MANAGED_BLOCK_END(blockId);
|
||||
): boolean {
|
||||
const block = managedBlockText(blockId, content);
|
||||
|
||||
if (!fs.exists(targetPath)) {
|
||||
@@ -57,49 +137,39 @@ export function mergeManagedBlock(
|
||||
fs.writeFile(targetPath, block);
|
||||
const warnings = excludeNewGeneratedFiles(nodePath.dirname(targetPath), [targetPath]);
|
||||
options?.warnings?.push(...warnings);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
const existing = fs.readFile(targetPath);
|
||||
const allReplaceIds = Array.from(new Set([blockId, ...(options?.replaceBlockIds ?? [])]));
|
||||
const replaceableIds = allReplaceIds.filter((id) => {
|
||||
const candidateBegin = MANAGED_BLOCK_START(id);
|
||||
const candidateEnd = MANAGED_BLOCK_END(id);
|
||||
const legacyBegin = LEGACY_BLOCK_START(id);
|
||||
const legacyEnd = LEGACY_BLOCK_END(id);
|
||||
return (
|
||||
(existing.includes(candidateBegin) && existing.includes(candidateEnd)) ||
|
||||
(existing.includes(legacyBegin) && existing.includes(legacyEnd))
|
||||
);
|
||||
});
|
||||
|
||||
if (replaceableIds.length > 0) {
|
||||
let updated = existing;
|
||||
for (const id of replaceableIds) {
|
||||
const candidateBegin = MANAGED_BLOCK_START(id);
|
||||
const candidateEnd = MANAGED_BLOCK_END(id);
|
||||
const regex = new RegExp(`${escapeRegex(candidateBegin)}[\\s\\S]*?${escapeRegex(candidateEnd)}`, "g");
|
||||
// A replacer function inserts the block literally; a replacement string
|
||||
// would expand `$$`, `$&`, `` $` `` and `$'` in the authored content.
|
||||
const replacement = id === blockId ? block : "";
|
||||
updated = updated.replace(regex, () => replacement);
|
||||
// Legacy marker variant from prior installs — replace with the
|
||||
// OpenRig form (or strip when not the active block id).
|
||||
const legacyBegin = LEGACY_BLOCK_START(id);
|
||||
const legacyEnd = LEGACY_BLOCK_END(id);
|
||||
const legacyRegex = new RegExp(`${escapeRegex(legacyBegin)}[\\s\\S]*?${escapeRegex(legacyEnd)}`, "g");
|
||||
updated = updated.replace(legacyRegex, () => replacement);
|
||||
}
|
||||
if (!updated.includes(begin) || !updated.includes(end)) {
|
||||
updated = `${updated.trim()}\n\n${block}`.trim();
|
||||
}
|
||||
// End with one newline only when the file doesn't already: re-merging on every launch must not add a line each time
|
||||
// (#2372), and blank lines a person left after the last block stay.
|
||||
fs.writeFile(targetPath, updated.endsWith("\n") ? updated : `${updated}\n`);
|
||||
return;
|
||||
const merged = mergeManagedBlockText(existing, blockId, block, options?.replaceBlockIds ?? []);
|
||||
if (merged === null) {
|
||||
options?.warnings?.push(damagedManagedBlockNote(targetPath, blockId));
|
||||
return false;
|
||||
}
|
||||
fs.writeFile(targetPath, merged);
|
||||
if (findManagedBlocks(existing, blockId).unpaired > 0) options?.warnings?.push(unpairedMarkerNote(targetPath, blockId));
|
||||
return true;
|
||||
}
|
||||
|
||||
fs.writeFile(targetPath, `${existing}${MANAGED_BLOCK_APPEND_SEPARATOR}${block}`);
|
||||
/** The note for a marker without its pair left beside a block that was written. */
|
||||
export function unpairedMarkerNote(targetPath: string, blockId: string): string {
|
||||
return `guidance_block_marker_unpaired: ${targetPath}: ${blockId} was written, and a BEGIN or END marker of it without its `
|
||||
+ "pair was left as it is; remove it or restore its pair";
|
||||
}
|
||||
|
||||
/** `content` with each `blockId` block whose markers an editor rewrapped written back in OpenRig's own form, its body
|
||||
* exactly as it is. Markers already in the current or legacy form stay as they are. */
|
||||
export function withCanonicalMarkers(content: string, blockId: string): string {
|
||||
let updated = content;
|
||||
for (const found of findManagedBlocks(content, blockId).blocks.reverse()) {
|
||||
const begin = content.slice(found.start, found.bodyStart);
|
||||
const end = content.slice(found.bodyEnd, found.end);
|
||||
const exact = (begin === MANAGED_BLOCK_START(blockId) && end === MANAGED_BLOCK_END(blockId))
|
||||
|| (begin === LEGACY_BLOCK_START(blockId) && end === LEGACY_BLOCK_END(blockId));
|
||||
if (exact) continue;
|
||||
updated = `${updated.slice(0, found.start)}${MANAGED_BLOCK_START(blockId)}${found.body}${MANAGED_BLOCK_END(blockId)}${updated.slice(found.end)}`;
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Each `blockId` block in `content` (current markers): the whole block and the text between its markers. */
|
||||
@@ -136,19 +206,47 @@ export function managedBlockOpenings(content: string, blockId: string): number {
|
||||
* - `current`: it holds the source's text;
|
||||
* - `stale`: it holds what OpenRig last wrote, and the source has changed since, so it's OpenRig's to refresh;
|
||||
* - `edited`: it holds something else, which someone wrote by hand;
|
||||
* - `unrecorded`: it differs from the source and OpenRig has no record of writing it, so which of the two is unknown.
|
||||
* - `unrecorded`: it differs from the source and OpenRig has no record of writing it, so which of the two is unknown;
|
||||
* - `damaged`: it holds a marker of the block without its pair, and no complete copy, so the block can't be read.
|
||||
* A block written more than once counts as edited when any copy is. */
|
||||
export type ManagedBlockStanding = "absent" | "current" | "stale" | "edited" | "unrecorded";
|
||||
export type ManagedBlockStanding = "absent" | "current" | "stale" | "edited" | "unrecorded" | "damaged";
|
||||
|
||||
export function managedBlockStanding(content: string, blockId: string, source: string, recordedHash: string | undefined,
|
||||
hash: (body: string) => string): ManagedBlockStanding {
|
||||
const bodies = managedBlockBodies(content, blockId);
|
||||
if (bodies.length === 0) return "absent";
|
||||
const found = findManagedBlocks(content, blockId);
|
||||
const bodies = found.blocks.map((block) => block.body);
|
||||
if (bodies.length === 0) return found.unpaired > 0 ? "damaged" : "absent";
|
||||
if (bodies.every((body) => managedBlockBodyMatches(body, source))) return "current";
|
||||
if (recordedHash === undefined) return "unrecorded";
|
||||
return bodies.every((body) => hash(body) === recordedHash) ? "stale" : "edited";
|
||||
}
|
||||
|
||||
/** `content` with the extra copies of a `blockId` block taken out where no one's text is lost: a later copy whose body
|
||||
* repeats an earlier copy's, and, beside a copy that's kept, a copy that `isOpenRigs` says holds only what OpenRig
|
||||
* wrote. Every other copy stays where it is. Null when there's nothing to take out. */
|
||||
export function pruneManagedBlockCopies(content: string, blockId: string, isOpenRigs: (body: string) => boolean): string | null {
|
||||
const { blocks } = findManagedBlocks(content, blockId);
|
||||
if (blocks.length < 2) return null;
|
||||
const kept: FoundManagedBlock[] = [];
|
||||
const removed: FoundManagedBlock[] = [];
|
||||
for (const block of blocks) {
|
||||
if (kept.some((other) => managedBlockBodyMatches(other.body, block.body))) removed.push(block);
|
||||
else kept.push(block);
|
||||
}
|
||||
const people = kept.filter((block) => !isOpenRigs(block.body));
|
||||
if (people.length > 0) for (const block of kept.filter((b) => isOpenRigs(b.body))) removed.push(block);
|
||||
if (removed.length === 0) return null;
|
||||
let updated = content;
|
||||
for (const block of removed.sort((a, b) => b.start - a.start)) {
|
||||
// A copy goes with the newlines before it; one that starts the file goes with the newlines after it instead.
|
||||
const from = withSeparator(content, block.start);
|
||||
let to = block.end;
|
||||
if (from === 0) while (updated[to] === "\n") to++;
|
||||
updated = `${updated.slice(0, from)}${updated.slice(to)}`;
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Whether `content` holds a `blockId` block whose body is anything but `written`: someone edited it. */
|
||||
export function hasEditedManagedBlock(content: string, blockId: string, written: string): boolean {
|
||||
return [...content.matchAll(managedBlockPattern(blockId))].some((m) => !managedBlockBodyMatches(m[1]!, written));
|
||||
|
||||
@@ -967,7 +967,7 @@ function verbToClosure(input: MissionControlActionInput): ClosureMapping {
|
||||
* person's own receipt says why). Undefined for a request the answer closed (no agent wired here). */
|
||||
function runnerAnswerNotice(ask: QueueItem): string | undefined {
|
||||
if (ask.state === "handed-off" && ask.handedOffTo) {
|
||||
return `Your answer on ${ask.qitemId} went to ${ask.handedOffTo}, the receiving agent, which decides. The runner waits until then; nothing has run.`;
|
||||
return `Your answer on ${ask.qitemId} went to ${ask.handedOffTo}, the receiving agent, which decides. The runner waits until then; nothing has run. A note to you says the agent's recorded state.`;
|
||||
}
|
||||
if (ask.state === "pending" || ask.state === "in-progress" || ask.state === "blocked") {
|
||||
return `Your answer is kept on ${ask.qitemId}, and the request is still waiting: the tool hasn't run, and nothing was permitted or refused. A note to you says why it wasn't passed to a receiving agent. To decide directly: rig seat decide-permission ${ask.qitemId} --option <id>.`;
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// The connector's view of a remote hub: the queue tools an external grant offers its participant (list its own items,
|
||||
// claim, show, update), reached over MCP's Streamable HTTP with that participant's bearer. Nothing here decides what to
|
||||
// do with an item; it only says what the hub answered, and sorts a failure into what the caller must do about it.
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { StreamableHTTPClientTransport, StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
|
||||
export interface HubItem {
|
||||
qitemId: string;
|
||||
sourceSession: string;
|
||||
destinationSession: string;
|
||||
state: string;
|
||||
body?: string | null;
|
||||
summary?: string | null;
|
||||
resolution?: string | null;
|
||||
closureReason?: string | null;
|
||||
}
|
||||
|
||||
/** `revoked`: the hub refused the credential (401), so the binding ends. `unavailable`: the hub or its grant authority
|
||||
* couldn't answer, or the connection failed, so try later. `refused`: the hub answered this one call with an error. */
|
||||
export type HubErrorKind = "revoked" | "unavailable" | "refused";
|
||||
|
||||
export class HubError extends Error {
|
||||
constructor(readonly kind: HubErrorKind, message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export interface HubSession {
|
||||
listOwned(state: "pending" | "in-progress", limit: number): Promise<HubItem[]>;
|
||||
claim(qitemId: string): Promise<void>;
|
||||
show(qitemId: string): Promise<HubItem>;
|
||||
note(qitemId: string, note: string): Promise<void>;
|
||||
/** Close the way the queue closes an item: done with a closure reason (no-follow-on, canceled or denied), or failed. */
|
||||
close(qitemId: string, input: { state: "done" | "failed"; closureReason: string | null; resolution: string }): Promise<void>;
|
||||
end(): Promise<void>;
|
||||
}
|
||||
|
||||
/** `signal` closes the connection when it aborts, which ends any call still waiting on the hub. */
|
||||
export type HubConnect = (hubUrl: string, token: string, signal?: AbortSignal) => Promise<HubSession>;
|
||||
|
||||
/** A failure, sorted. The hub's own MCP endpoint answers 401 for an unknown or revoked credential and 503 when its grant
|
||||
* authority can't answer (`packages/cli/src/mcp-http.ts`). */
|
||||
export function hubFailure(error: unknown): HubError {
|
||||
if (error instanceof HubError) return error;
|
||||
if (error instanceof StreamableHTTPError && error.code === 401) {
|
||||
return new HubError("revoked", "The hub refused this participant's credential: it's unknown there or was revoked.");
|
||||
}
|
||||
if (error instanceof StreamableHTTPError && typeof error.code === "number" && error.code >= 300 && error.code < 400) {
|
||||
return new HubError("unavailable", `The hub redirected the connection somewhere else, and the participant's token isn't sent there (${error.message.slice(0, 300)}). Connect the binding to the hub's own URL.`);
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return new HubError("unavailable", `The hub couldn't be reached: ${message.slice(0, 300)}`);
|
||||
}
|
||||
|
||||
function parsed(result: { isError?: boolean; content?: unknown }): unknown {
|
||||
const content = Array.isArray(result.content) ? result.content as Array<{ type?: string; text?: string }> : [];
|
||||
const text = content.filter((part) => part.type === "text").map((part) => part.text ?? "").join("\n");
|
||||
if (result.isError) throw new HubError("refused", text.slice(0, 500) || "The hub refused this call.");
|
||||
try { return JSON.parse(text); } catch { throw new HubError("refused", "The hub answered with something that isn't JSON."); }
|
||||
}
|
||||
|
||||
const item = (value: unknown): HubItem => {
|
||||
const row = (value ?? {}) as Record<string, unknown>;
|
||||
if (typeof row.qitemId !== "string") throw new HubError("refused", "The hub answered without an item id.");
|
||||
return {
|
||||
qitemId: row.qitemId,
|
||||
sourceSession: String(row.sourceSession ?? ""),
|
||||
destinationSession: String(row.destinationSession ?? ""),
|
||||
state: String(row.state ?? ""),
|
||||
body: typeof row.body === "string" ? row.body : null,
|
||||
summary: typeof row.summary === "string" ? row.summary : null,
|
||||
resolution: typeof row.resolution === "string" ? row.resolution : null,
|
||||
closureReason: typeof row.closureReason === "string" ? row.closureReason : null,
|
||||
};
|
||||
};
|
||||
|
||||
/** Connect to a hub as one participant. The bearer goes only in this transport's Authorization header. The SDK's
|
||||
* same-origin policy, set explicitly, follows a redirect only within the hub's origin, or to its https form on the same
|
||||
* host with default ports, so the bearer follows only those; any other redirect fails with its target named. */
|
||||
export const connectMcpHub: HubConnect = async (hubUrl, token, signal) => {
|
||||
const transport = new StreamableHTTPClientTransport(new URL(hubUrl), {
|
||||
requestInit: { headers: { Authorization: `Bearer ${token}` } },
|
||||
redirectPolicy: "same-origin",
|
||||
});
|
||||
const client = new Client({ name: "openrig-network-connector", version: "1" });
|
||||
const close = () => { void client.close().catch(() => {}); };
|
||||
if (signal?.aborted) throw new HubError("unavailable", "The pass ended before it reached the hub.");
|
||||
signal?.addEventListener("abort", close, { once: true });
|
||||
try {
|
||||
await client.connect(transport);
|
||||
} catch (error) {
|
||||
signal?.removeEventListener("abort", close);
|
||||
await client.close().catch(() => {});
|
||||
throw hubFailure(error);
|
||||
}
|
||||
const call = async (name: string, args: Record<string, unknown>): Promise<unknown> => {
|
||||
try {
|
||||
return parsed(await client.callTool({ name, arguments: args }) as { isError?: boolean; content?: unknown });
|
||||
} catch (error) {
|
||||
throw hubFailure(error);
|
||||
}
|
||||
};
|
||||
return {
|
||||
listOwned: async (state, limit) => {
|
||||
const rows = await call("rig_queue_list", { owned: true, state, limit });
|
||||
if (!Array.isArray(rows)) throw new HubError("refused", "The hub's list wasn't a list.");
|
||||
return rows.map(item);
|
||||
},
|
||||
claim: async (qitemId) => { await call("rig_queue_claim", { qitemId }); },
|
||||
show: async (qitemId) => {
|
||||
const shown = await call("rig_queue_show", { qitemId }) as Record<string, unknown>;
|
||||
return item(shown?.item ?? shown);
|
||||
},
|
||||
note: async (qitemId, note) => { await call("rig_queue_update", { qitemId, note }); },
|
||||
close: async (qitemId, input) => {
|
||||
await call("rig_queue_update", { qitemId, state: input.state, ...(input.closureReason ? { closureReason: input.closureReason } : {}), resolution: input.resolution });
|
||||
},
|
||||
end: async () => { signal?.removeEventListener("abort", close); await client.close().catch(() => {}); },
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,238 @@
|
||||
// A selected local seat joined to one participant of a remote hub: the account's network rig, which this daemon reaches
|
||||
// as an MCP client with that participant's own credential (an external grant). The binding names the local seat by its
|
||||
// durable node id, so a renamed or relaunched seat keeps it; the hub address represents the seat and never renames it.
|
||||
// The credential is a private file under the OpenRig home; this table only says where the binding stands.
|
||||
import type Database from "better-sqlite3";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { closeSync, existsSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { resolveSessionNodeId } from "../queue-owner.js";
|
||||
|
||||
export type NetworkBindingState = "active" | "revoked" | "disconnected";
|
||||
|
||||
export interface NetworkBinding {
|
||||
id: string;
|
||||
nodeId: string;
|
||||
/** The seat's address when it was bound, for display; the node id is what the binding follows. */
|
||||
localSession: string;
|
||||
hubUrl: string;
|
||||
/** The participant's address in the hub (`member@rig`). */
|
||||
hubSeat: string;
|
||||
/** The person chose, for this binding, to send the participant's token to a plain-http hub. */
|
||||
allowHttp: boolean;
|
||||
/** The hub URL is plain http, so the token and the work cross the network unencrypted. */
|
||||
unencrypted: boolean;
|
||||
state: NetworkBindingState;
|
||||
stateReason: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastContactAt: string | null;
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
export class NetworkBindingError extends Error {
|
||||
constructor(readonly code: string, message: string, readonly status: 400 | 404 | 409 | 500 = 400) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
interface Row {
|
||||
id: string; node_id: string; local_session: string; hub_url: string; hub_seat: string; allow_http: number; state: NetworkBindingState;
|
||||
state_reason: string | null; created_at: string; updated_at: string; last_contact_at: string | null; last_error: string | null;
|
||||
}
|
||||
|
||||
const project = (row: Row): NetworkBinding => ({
|
||||
id: row.id, nodeId: row.node_id, localSession: row.local_session, hubUrl: row.hub_url, hubSeat: row.hub_seat,
|
||||
allowHttp: row.allow_http === 1, unencrypted: row.hub_url.startsWith("http:"), state: row.state, stateReason: row.state_reason, createdAt: row.created_at, updatedAt: row.updated_at,
|
||||
lastContactAt: row.last_contact_at, lastError: row.last_error,
|
||||
});
|
||||
|
||||
const MAX_CREDENTIAL_BYTES = 16384;
|
||||
|
||||
/** Where a binding's credential lives: `<home>/network-secrets/<binding id>/credential.json`, private to this user. */
|
||||
export function networkCredentialFile(home: string, bindingId: string): string {
|
||||
if (!/^netb-[0-9a-f]{20}$/.test(bindingId)) throw new NetworkBindingError("invalid_binding_id", `'${bindingId}' isn't a network binding id.`);
|
||||
return join(home, "network-secrets", bindingId, "credential.json");
|
||||
}
|
||||
|
||||
/** The participant's bearer, read from its private file. A file anyone else can read, a link or an oversized file is
|
||||
* refused, with the path named and the value never shown. */
|
||||
export function readNetworkCredential(home: string, bindingId: string): string {
|
||||
const file = networkCredentialFile(home, bindingId);
|
||||
let stat;
|
||||
try { stat = lstatSync(file); } catch {
|
||||
throw new NetworkBindingError("credential_missing", `The credential for ${bindingId} isn't at ${file}. Connect the seat again with its token file.`, 404);
|
||||
}
|
||||
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_CREDENTIAL_BYTES || (stat.mode & 0o077) !== 0) {
|
||||
throw new NetworkBindingError("credential_unsafe", `${file} must be a regular file readable only by this user.`);
|
||||
}
|
||||
// A parse error can quote the file's text, which holds the token: say only which file.
|
||||
let value: { version?: unknown; token?: unknown };
|
||||
try { value = JSON.parse(readFileSync(file, "utf8")) as typeof value; } catch {
|
||||
throw new NetworkBindingError("credential_invalid", `${file} isn't a network credential.`);
|
||||
}
|
||||
if (value === null || typeof value !== "object" || value.version !== 1 || typeof value.token !== "string" || !value.token) {
|
||||
throw new NetworkBindingError("credential_invalid", `${file} isn't a network credential.`);
|
||||
}
|
||||
return value.token;
|
||||
}
|
||||
|
||||
function validHubUrl(raw: unknown, allowHttp: boolean): string {
|
||||
if (typeof raw !== "string" || !raw.trim()) throw new NetworkBindingError("invalid_hub_url", "A hub URL is required.");
|
||||
let url: URL;
|
||||
try { url = new URL(raw.trim()); } catch { throw new NetworkBindingError("invalid_hub_url", `'${raw}' isn't a URL.`); }
|
||||
if (url.protocol !== "https:" && url.protocol !== "http:") throw new NetworkBindingError("invalid_hub_url", "The hub URL must use https (or http).");
|
||||
// A credential in the URL would be stored in plain text in this table and shown in status.
|
||||
if (url.username || url.password) throw new NetworkBindingError("invalid_hub_url", "Put the participant's token in its token file, not in the hub URL.");
|
||||
// Plain http is the person's choice per binding. The scheme is kept as given, and the address isn't judged: a private
|
||||
// or tailnet address doesn't prove the connection is protected.
|
||||
if (url.protocol === "http:" && !allowHttp) {
|
||||
throw new NetworkBindingError("http_not_allowed", `${url.toString()} is plain http, so the participant's token and its work would cross the network unencrypted, where they can be read. Use the hub's https URL, or connect again with --allow-http to accept that for this binding.`);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function validHubSeat(raw: unknown): string {
|
||||
if (typeof raw !== "string" || !/^[^@\s]+@[^@\s]+$/.test(raw.trim())) {
|
||||
throw new NetworkBindingError("invalid_hub_seat", "The hub participant is its address in the hub, member@rig.");
|
||||
}
|
||||
return raw.trim();
|
||||
}
|
||||
|
||||
function validToken(raw: unknown): string {
|
||||
const token = typeof raw === "string" ? raw.trim() : "";
|
||||
if (!token || /\s/.test(token) || Buffer.byteLength(token) > 8192) {
|
||||
throw new NetworkBindingError("invalid_token", "The participant's token must be one non-empty line with no spaces.");
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
export class NetworkBindingStore {
|
||||
constructor(
|
||||
private readonly db: Database.Database,
|
||||
private readonly home: string,
|
||||
private readonly now: () => Date = () => new Date(),
|
||||
private readonly newId: () => string = () => `netb-${randomBytes(10).toString("hex")}`,
|
||||
) {}
|
||||
|
||||
list(): NetworkBinding[] {
|
||||
return (this.db.prepare("SELECT * FROM network_bindings ORDER BY created_at, id").all() as Row[]).map(project);
|
||||
}
|
||||
|
||||
active(): NetworkBinding[] {
|
||||
return (this.db.prepare("SELECT * FROM network_bindings WHERE state = 'active' ORDER BY created_at, id").all() as Row[]).map(project);
|
||||
}
|
||||
|
||||
get(id: string): NetworkBinding | null {
|
||||
const row = this.db.prepare("SELECT * FROM network_bindings WHERE id = ?").get(id) as Row | undefined;
|
||||
return row ? project(row) : null;
|
||||
}
|
||||
|
||||
/** A binding by its id, or the active binding of a local seat named by its address. */
|
||||
find(ref: string): NetworkBinding | null {
|
||||
const byId = this.get(ref);
|
||||
if (byId) return byId;
|
||||
const nodeId = resolveSessionNodeId(this.db, ref);
|
||||
const row = nodeId ? this.db.prepare("SELECT * FROM network_bindings WHERE node_id = ? AND state = 'active'").get(nodeId) as Row | undefined : undefined;
|
||||
return row ? project(row) : null;
|
||||
}
|
||||
|
||||
/** Bind a local seat to a hub participant with that participant's token. The credential is written first, exclusively
|
||||
* and private, then the row. If either step fails, the credential this call created is removed, so a refused bind
|
||||
* leaves none behind; a file that was already at that path is never touched. */
|
||||
create(input: { session: unknown; hubUrl: unknown; hubSeat: unknown; token: unknown; allowHttp?: unknown }): NetworkBinding {
|
||||
const session = typeof input.session === "string" ? input.session.trim() : "";
|
||||
if (!session) throw new NetworkBindingError("invalid_seat", "Name the local seat to connect.");
|
||||
const nodeId = resolveSessionNodeId(this.db, session);
|
||||
if (!nodeId) throw new NetworkBindingError("seat_not_found", `No local seat is named '${session}'.`, 404);
|
||||
const allowHttp = input.allowHttp === true;
|
||||
const hubUrl = validHubUrl(input.hubUrl, allowHttp);
|
||||
const hubSeat = validHubSeat(input.hubSeat);
|
||||
const token = validToken(input.token);
|
||||
const bound = this.db.prepare("SELECT id FROM network_bindings WHERE node_id = ? AND state = 'active'").get(nodeId) as { id: string } | undefined;
|
||||
if (bound) throw new NetworkBindingError("seat_already_connected", `${session} is already connected (${bound.id}). Disconnect it first.`, 409);
|
||||
const taken = this.db.prepare("SELECT id FROM network_bindings WHERE hub_url = ? AND hub_seat = ? AND state = 'active'").get(hubUrl, hubSeat) as { id: string } | undefined;
|
||||
if (taken) throw new NetworkBindingError("participant_already_connected", `${hubSeat} at ${hubUrl} is already connected (${taken.id}).`, 409);
|
||||
|
||||
const id = this.newId();
|
||||
const file = networkCredentialFile(this.home, id);
|
||||
let created = false;
|
||||
let madeFolder: string | undefined;
|
||||
try {
|
||||
madeFolder = mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
||||
const fd = openSync(file, "wx", 0o600);
|
||||
created = true;
|
||||
try { writeFileSync(fd, `${JSON.stringify({ version: 1, token })}\n`); } finally { closeSync(fd); }
|
||||
const at = this.now().toISOString();
|
||||
this.db.prepare(`INSERT INTO network_bindings (id, node_id, local_session, hub_url, hub_seat, allow_http, state, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?)`).run(id, nodeId, session, hubUrl, hubSeat, allowHttp ? 1 : 0, at, at);
|
||||
} catch (error) {
|
||||
// Only what this call made: the file it opened, and this binding's folder if it made it (`mkdirSync` returns the
|
||||
// first folder it made, so any value means this one is new). A folder that was already there keeps its other files.
|
||||
if (created) rmSync(file, { force: true });
|
||||
if (madeFolder !== undefined) rmSync(dirname(file), { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
return this.get(id)!;
|
||||
}
|
||||
|
||||
/** The person ends this binding here. No new work comes in through it; local rows, results and other bindings stay. The
|
||||
* credential is removed, and the hub still lists the participant until it's removed there too. */
|
||||
disconnect(ref: string, reason?: string): NetworkBinding {
|
||||
return this.end(ref, "disconnected", reason?.trim() || "disconnected here");
|
||||
}
|
||||
|
||||
/** The hub refused the credential: it was revoked there. Same effect as a local disconnect. */
|
||||
markRevoked(id: string, reason: string): NetworkBinding {
|
||||
return this.end(id, "revoked", reason);
|
||||
}
|
||||
|
||||
/** The connector reached the hub as this binding. */
|
||||
recordContact(id: string, at: Date = this.now()): void {
|
||||
this.db.prepare("UPDATE network_bindings SET last_contact_at = ?, last_error = NULL, updated_at = ? WHERE id = ?")
|
||||
.run(at.toISOString(), at.toISOString(), id);
|
||||
}
|
||||
|
||||
/** The connector couldn't reach the hub as this binding, or the hub answered with an error; the binding stays active.
|
||||
* Control characters are replaced, since the text can come from the hub and `rig network status` prints it. */
|
||||
recordError(id: string, message: string): void {
|
||||
this.db.prepare("UPDATE network_bindings SET last_error = ?, updated_at = ? WHERE id = ?")
|
||||
.run(message.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").slice(0, 500), this.now().toISOString(), id);
|
||||
}
|
||||
|
||||
/** The binding ends first, so no new work comes in even if the credential can't be removed; removal is tried on every
|
||||
* call, including again on an already ended binding, until its credential is gone. */
|
||||
private end(ref: string, state: "disconnected" | "revoked", reason: string): NetworkBinding {
|
||||
const binding = this.find(ref);
|
||||
if (!binding) throw new NetworkBindingError("binding_not_found", `No network binding or connected seat is named '${ref}'.`, 404);
|
||||
if (binding.state === "active") {
|
||||
this.db.prepare("UPDATE network_bindings SET state = ?, state_reason = ?, updated_at = ? WHERE id = ? AND state = 'active'")
|
||||
.run(state, reason, this.now().toISOString(), binding.id);
|
||||
}
|
||||
try {
|
||||
this.removeCredential(binding.id);
|
||||
} catch (error) {
|
||||
throw new NetworkBindingError("credential_cleanup_failed",
|
||||
`${binding.id} is ${this.get(binding.id)!.state}, but its credential at ${networkCredentialFile(this.home, binding.id)} couldn't be removed (${(error as Error).message}). Run \`rig network disconnect ${binding.id}\` again to retry.`, 500);
|
||||
}
|
||||
return this.get(binding.id)!;
|
||||
}
|
||||
|
||||
/** Removes the credential folders whose binding isn't active: one a stop left between the credential write and its
|
||||
* row, or one whose removal failed when its binding ended. Other names under the folder are left alone. */
|
||||
sweepCredentials(): { removed: string[]; failed: string[] } {
|
||||
const swept = { removed: [] as string[], failed: [] as string[] };
|
||||
let names: string[];
|
||||
try { names = readdirSync(join(this.home, "network-secrets")); } catch { return swept; }
|
||||
for (const name of names.sort()) {
|
||||
if (!/^netb-[0-9a-f]{20}$/.test(name) || this.get(name)?.state === "active") continue;
|
||||
try { this.removeCredential(name); swept.removed.push(name); } catch { swept.failed.push(name); }
|
||||
}
|
||||
return swept;
|
||||
}
|
||||
|
||||
private removeCredential(id: string): void {
|
||||
const file = networkCredentialFile(this.home, id);
|
||||
if (existsSync(dirname(file))) rmSync(dirname(file), { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
// The outbound connector: for each active network binding, reach the hub as that participant, take the items addressed
|
||||
// to it into the bound local seat's queue, and carry what happens locally back to the hub item.
|
||||
//
|
||||
// Honest stages, kept apart on the hub item:
|
||||
// - a hub claim is the connector taking custody of the item, not the agent picking it up;
|
||||
// - "picked up" is reported only once the local item is claimed;
|
||||
// - the result is the local item's own closing outcome.
|
||||
//
|
||||
// Each hub item maps to one local item, under an id derived from what it is: the seat's node, the hub, the participant
|
||||
// and the hub item. The queue's same-id absorption makes every retry, restart or new binding for the same seat, hub and
|
||||
// participant land on that one item, so a lost response never starts a second one. An item whose local record is gone
|
||||
// reads unknown and is never run again.
|
||||
import type Database from "better-sqlite3";
|
||||
import { createHash } from "node:crypto";
|
||||
import { QueueRepositoryError, type QueueItem, type QueueRepository } from "../queue-repository.js";
|
||||
import { NetworkBindingError, readNetworkCredential, type NetworkBinding, type NetworkBindingStore } from "./network-bindings.js";
|
||||
import { connectMcpHub, HubError, type HubConnect, type HubItem, type HubSession } from "./hub-client.js";
|
||||
|
||||
/** OpenRig's own sender: the connector creates the local item for the hub's requester, who is named first in it. */
|
||||
const LOCAL_SOURCE = "daemon@kernel";
|
||||
const PAGE = 25;
|
||||
const MAX_PAGES = 8;
|
||||
/** Receipts looked at per pass, the least recently looked at first, so a long backlog still gives every item a turn. */
|
||||
const REPORT_LIMIT = 50;
|
||||
/** How long one binding's pass may take before its connection is closed, so one slow hub can't hold the others. */
|
||||
const PASS_TIMEOUT_MS = 60_000;
|
||||
|
||||
/** The local item for a hub item, by what it is. A retry, a restart or a new binding for the same seat, hub and
|
||||
* participant lands on the same item. */
|
||||
export function networkLocalItemId(binding: Pick<NetworkBinding, "nodeId" | "hubUrl" | "hubSeat">, hubQitemId: string): string {
|
||||
return `qitem-net-${createHash("sha256").update([binding.nodeId, binding.hubUrl, binding.hubSeat, hubQitemId].join("\n")).digest("hex").slice(0, 20)}`;
|
||||
}
|
||||
|
||||
export interface NetworkConnectorDeps {
|
||||
db: Database.Database;
|
||||
queueRepo: QueueRepository;
|
||||
bindings: NetworkBindingStore;
|
||||
home: string;
|
||||
connect?: HubConnect;
|
||||
now?: () => Date;
|
||||
log?: (line: string) => void;
|
||||
passTimeoutMs?: number;
|
||||
reportLimit?: number;
|
||||
}
|
||||
|
||||
export interface NetworkPassResult {
|
||||
bindingId: string;
|
||||
taken: number;
|
||||
pickupsSent: number;
|
||||
resultsSent: number;
|
||||
unknown: number;
|
||||
/** Why the pass stopped early, if it did. `revoked` ended the binding; `no_session` left the hub's items pending;
|
||||
* `local` is a failure here, not the hub's. */
|
||||
error?: HubError["kind"] | "credential" | "no_session" | "http_not_allowed" | "local";
|
||||
}
|
||||
|
||||
/** The seat's current address: the node's latest session, as the queue's other node lookups read it. */
|
||||
function currentSession(db: Database.Database, nodeId: string): string | null {
|
||||
const row = db.prepare("SELECT session_name FROM sessions WHERE node_id = ? ORDER BY created_at DESC, id DESC LIMIT 1")
|
||||
.get(nodeId) as { session_name: string } | undefined;
|
||||
return row?.session_name ?? null;
|
||||
}
|
||||
|
||||
interface Receipt {
|
||||
local_qitem_id: string; binding_id: string; hub_qitem_id: string; queued_sent_at: string | null; pickup_sent_at: string | null;
|
||||
settled_at: string | null; outcome: string | null;
|
||||
}
|
||||
|
||||
/** The states a local item ends in. A handed-off local item ends at its successor, which is followed. */
|
||||
const FINAL = new Set(["done", "canceled", "denied", "failed"]);
|
||||
/** The states in which the hub item is closed to this connector. A hub item handed on by someone there is closed here:
|
||||
* its successor isn't followed, and it's never written to again. */
|
||||
const HUB_CLOSED = new Set([...FINAL, "handed-off"]);
|
||||
|
||||
/** One pass for one binding, bounded by its deadline and the connector's stop. Never throws: a failure is recorded on
|
||||
* the binding and returned. */
|
||||
export async function runNetworkBindingPass(deps: NetworkConnectorDeps, binding: NetworkBinding,
|
||||
opts: { signal?: AbortSignal } = {}): Promise<NetworkPassResult> {
|
||||
const now = deps.now ?? (() => new Date());
|
||||
const result: NetworkPassResult = { bindingId: binding.id, taken: 0, pickupsSent: 0, resultsSent: 0, unknown: 0 };
|
||||
// The token goes to a plain-http hub only on the person's recorded choice for this binding.
|
||||
if (binding.unencrypted && !binding.allowHttp) {
|
||||
deps.bindings.recordError(binding.id, `${binding.hubUrl} is plain http and this binding has no --allow-http choice, so its token isn't sent. Connect again with the hub's https URL, or with --allow-http.`);
|
||||
return { ...result, error: "http_not_allowed" };
|
||||
}
|
||||
let token: string;
|
||||
try {
|
||||
token = readNetworkCredential(deps.home, binding.id);
|
||||
} catch (error) {
|
||||
deps.bindings.recordError(binding.id, error instanceof NetworkBindingError ? error.message : String(error));
|
||||
return { ...result, error: "credential" };
|
||||
}
|
||||
|
||||
const timeoutMs = deps.passTimeoutMs ?? PASS_TIMEOUT_MS;
|
||||
const deadline = new AbortController();
|
||||
const timer = setTimeout(() => deadline.abort(), timeoutMs);
|
||||
timer.unref?.();
|
||||
const onStop = () => deadline.abort();
|
||||
if (opts.signal?.aborted) deadline.abort();
|
||||
opts.signal?.addEventListener("abort", onStop, { once: true });
|
||||
// Checked before every take, and again just before a local item is made: an ended binding, the pass's deadline or
|
||||
// the connector's stop starts no local work, even when a hub answer arrives late.
|
||||
const live = () => !deadline.signal.aborted && deps.bindings.get(binding.id)?.state === "active";
|
||||
|
||||
let hub: HubSession | null = null;
|
||||
try {
|
||||
hub = await (deps.connect ?? connectMcpHub)(binding.hubUrl, token, deadline.signal);
|
||||
// Work goes to the seat at its current address. With no session there's nobody to give it to, so nothing is taken.
|
||||
const destination = currentSession(deps.db, binding.nodeId);
|
||||
if (destination) {
|
||||
// What this participant already holds (claimed before a restart, or through an earlier binding) comes first.
|
||||
for (const held of await hub.listOwned("in-progress", PAGE * MAX_PAGES)) {
|
||||
if (!live()) break;
|
||||
if (held.destinationSession === binding.hubSeat && await ensureLocal(deps, binding, hub, held, destination, now, live)) result.taken++;
|
||||
}
|
||||
// New work, a page at a time: a claim moves an item out of pending, so each page is new items.
|
||||
for (let page = 0; page < MAX_PAGES && live(); page++) {
|
||||
const pending = await hub.listOwned("pending", PAGE);
|
||||
for (const item of pending) {
|
||||
if (!live()) break;
|
||||
if (item.destinationSession !== binding.hubSeat) continue;
|
||||
try {
|
||||
await hub.claim(item.qitemId);
|
||||
} catch (error) {
|
||||
// Another claimant, or the item closed meanwhile: it isn't this binding's to take.
|
||||
if (error instanceof HubError && error.kind === "refused") continue;
|
||||
throw error;
|
||||
}
|
||||
if (await ensureLocal(deps, binding, hub, item, destination, now, live)) result.taken++;
|
||||
}
|
||||
if (pending.length < PAGE) break;
|
||||
}
|
||||
}
|
||||
// Report back what happened locally, for the items this binding holds. A revoked binding never gets here.
|
||||
if (live()) await reportBack(deps, binding, hub, now, result);
|
||||
deps.bindings.recordContact(binding.id, now());
|
||||
if (!destination) {
|
||||
deps.bindings.recordError(binding.id, `${binding.localSession} has no session here, so its hub items stay pending until it has one.`);
|
||||
return { ...result, error: "no_session" };
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (deadline.signal.aborted) {
|
||||
deps.bindings.recordError(binding.id, opts.signal?.aborted
|
||||
? "The connector stopped during this pass and closed its connection to the hub."
|
||||
: `This pass took longer than ${timeoutMs >= 1000 ? `${Math.round(timeoutMs / 1000)} s` : `${timeoutMs} ms`}, so its connection to the hub was closed. The next pass tries again.`);
|
||||
return { ...result, error: "unavailable" };
|
||||
}
|
||||
// Only the hub session's own failures are the hub's (it reports them as HubError); anything else happened here.
|
||||
if (!(error instanceof HubError)) {
|
||||
deps.bindings.recordError(binding.id, `A local error stopped this pass: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return { ...result, error: "local" };
|
||||
}
|
||||
if (error.kind === "revoked") {
|
||||
try {
|
||||
deps.bindings.markRevoked(binding.id, error.message);
|
||||
} catch (cleanup) {
|
||||
// The binding is revoked either way; only its credential's removal failed, and the error says how to retry.
|
||||
if (!(cleanup instanceof NetworkBindingError) || cleanup.code !== "credential_cleanup_failed") throw cleanup;
|
||||
deps.bindings.recordError(binding.id, cleanup.message);
|
||||
}
|
||||
deps.log?.(`network ${binding.id}: revoked by the hub; no new work comes in through it`);
|
||||
} else {
|
||||
deps.bindings.recordError(binding.id, error.message);
|
||||
}
|
||||
return { ...result, error: error.kind };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
opts.signal?.removeEventListener("abort", onStop);
|
||||
await hub?.end();
|
||||
}
|
||||
}
|
||||
|
||||
/** The local item for a hub item this participant holds: created once, or found again. Returns true when it was new. */
|
||||
async function ensureLocal(deps: NetworkConnectorDeps, binding: NetworkBinding, hub: HubSession, item: HubItem, destination: string,
|
||||
now: () => Date, live: () => boolean): Promise<boolean> {
|
||||
const localId = networkLocalItemId(binding, item.qitemId);
|
||||
const taken = deps.db.prepare("SELECT binding_id FROM network_receipts WHERE local_qitem_id = ?").get(localId) as { binding_id: string } | undefined;
|
||||
if (taken) {
|
||||
// Taken already, perhaps through an earlier binding for this seat, hub and participant: this binding reports it now,
|
||||
// with what was already sent and its outcome, unknown included, kept.
|
||||
if (taken.binding_id !== binding.id) deps.db.prepare("UPDATE network_receipts SET binding_id = ? WHERE local_qitem_id = ?").run(binding.id, localId);
|
||||
return false;
|
||||
}
|
||||
const full = item.body == null ? await hub.show(item.qitemId) : item;
|
||||
if (!live()) return false;
|
||||
let outcome: string | null = null;
|
||||
try {
|
||||
await deps.queueRepo.create({
|
||||
qitemId: localId,
|
||||
sourceSession: LOCAL_SOURCE,
|
||||
destinationSession: destination,
|
||||
// Where it came from is read first: the requester and the hub, before the requester's own words.
|
||||
summary: full.summary ? `From ${item.sourceSession} via network hub: ${full.summary}` : `From ${item.sourceSession} via network hub`,
|
||||
body: [
|
||||
`From ${item.sourceSession} through the network hub ${binding.hubUrl}, as ${binding.hubSeat} (hub item ${item.qitemId}).`,
|
||||
"",
|
||||
full.body ?? "",
|
||||
"",
|
||||
"---",
|
||||
`Answer by closing this item with your result: rig queue update ${localId} --state done --closure-reason no-follow-on --resolution "<your answer>".`,
|
||||
"OpenRig returns the resolution to the hub item.",
|
||||
].join("\n"),
|
||||
tags: [`network-binding:${binding.id}`, `network-hub-item:${item.qitemId}`],
|
||||
});
|
||||
} catch (error) {
|
||||
const code = error instanceof QueueRepositoryError ? error.code : null;
|
||||
// Its id was used before and its record erased: whether it ran is unknown, and it's never run again. The hub hears
|
||||
// that from reportBack, which retries the note until it lands.
|
||||
if (code === "qitem_erased") outcome = "unknown";
|
||||
// The same id with another destination: the seat's address changed between attempts; the item already exists.
|
||||
else if (code !== "qitem_id_reuse" || !deps.queueRepo.getById(localId)) throw error;
|
||||
}
|
||||
const inserted = deps.db.prepare(`INSERT INTO network_receipts (local_qitem_id, binding_id, hub_qitem_id, created_at, outcome)
|
||||
VALUES (?, ?, ?, ?, ?) ON CONFLICT DO NOTHING`).run(localId, binding.id, item.qitemId, now().toISOString(), outcome).changes > 0;
|
||||
// Every stage note, queued included, goes out from reportBack, which marks it sent only once it lands.
|
||||
return inserted && outcome === null;
|
||||
}
|
||||
|
||||
/** The item a local item ends at, following local handoffs: a successor names the item it came from. Null when a
|
||||
* handed-off item has no one successor here (handed to another host, or the record is gone): its outcome is unknown. */
|
||||
function finalItem(repo: QueueRepository, start: QueueItem): QueueItem | null {
|
||||
let current = start;
|
||||
for (let hop = 0; hop < 20 && current.state === "handed-off"; hop++) {
|
||||
const next = repo.list({ handedOffFrom: current.qitemId, limit: 2 });
|
||||
if (next.length !== 1) return null;
|
||||
current = next[0]!;
|
||||
}
|
||||
return current.state === "handed-off" ? null : current;
|
||||
}
|
||||
|
||||
/** What the hub hears as the result: the resolution, else the closer's own note on the close itself. The close is read
|
||||
* with `closingTransition`, since later notes on a closed item repeat its state. Other notes on the item can be
|
||||
* OpenRig's or another seat's, and name local seats, so they're never sent. */
|
||||
function closingWords(repo: QueueRepository, item: QueueItem): string {
|
||||
if (item.resolution) return item.resolution;
|
||||
const close = repo.closingTransition(item.qitemId, item.state);
|
||||
return close?.transitionNote && close.actorSession === item.destinationSession ? close.transitionNote : "Closed without a stated result.";
|
||||
}
|
||||
|
||||
/** The hub items a binding's result never reached because someone else closed them first, for status. */
|
||||
export function networkClosedElsewhere(db: Database.Database, bindingId: string): Array<{ hubItem: string; localItem: string; note: string | null }> {
|
||||
return (db.prepare("SELECT hub_qitem_id, local_qitem_id, last_error FROM network_receipts WHERE binding_id = ? AND outcome = 'closed_on_hub' ORDER BY settled_at, local_qitem_id")
|
||||
.all(bindingId) as Array<{ hub_qitem_id: string; local_qitem_id: string; last_error: string | null }>)
|
||||
.map((row) => ({ hubItem: row.hub_qitem_id, localItem: row.local_qitem_id, note: row.last_error }));
|
||||
}
|
||||
|
||||
async function reportBack(deps: NetworkConnectorDeps, binding: NetworkBinding, hub: HubSession, now: () => Date, result: NetworkPassResult): Promise<void> {
|
||||
const open = deps.db.prepare(`SELECT * FROM network_receipts WHERE binding_id = ? AND settled_at IS NULL
|
||||
ORDER BY COALESCE(checked_at, ''), created_at LIMIT ?`).all(binding.id, deps.reportLimit ?? REPORT_LIMIT) as Receipt[];
|
||||
const mark = (column: "queued_sent_at" | "pickup_sent_at" | "settled_at", receipt: Receipt, outcome?: string, lastError: string | null = null) =>
|
||||
deps.db.prepare(`UPDATE network_receipts SET ${column} = ?, outcome = COALESCE(?, outcome), last_error = ? WHERE local_qitem_id = ?`)
|
||||
.run(now().toISOString(), outcome ?? null, lastError, receipt.local_qitem_id);
|
||||
for (const receipt of open) {
|
||||
deps.db.prepare("UPDATE network_receipts SET checked_at = ? WHERE local_qitem_id = ?").run(now().toISOString(), receipt.local_qitem_id);
|
||||
try {
|
||||
const local = deps.queueRepo.getById(receipt.local_qitem_id);
|
||||
const unknown = async (why: string) => {
|
||||
await hub.note(receipt.hub_qitem_id, why);
|
||||
mark("settled_at", receipt, "unknown");
|
||||
result.unknown++;
|
||||
};
|
||||
if (!local || receipt.outcome === "unknown") {
|
||||
await unknown(`This item's local record (${receipt.local_qitem_id}) is gone, so whether it ran is unknown. It won't be run again.`);
|
||||
continue;
|
||||
}
|
||||
if (!receipt.queued_sent_at) {
|
||||
await hub.note(receipt.hub_qitem_id, `Queued for the connected local seat as ${local.qitemId}. Not picked up yet.`);
|
||||
mark("queued_sent_at", receipt);
|
||||
}
|
||||
if (!receipt.pickup_sent_at && local.claimedAt) {
|
||||
await hub.note(receipt.hub_qitem_id, `Picked up by the local seat at ${local.claimedAt}.`);
|
||||
mark("pickup_sent_at", receipt);
|
||||
result.pickupsSent++;
|
||||
}
|
||||
const end = local.state === "handed-off" ? finalItem(deps.queueRepo, local) : local;
|
||||
if (!end) {
|
||||
await unknown(`This item was handed on from ${local.qitemId} without one successor here, so its outcome is unknown. It won't be run again.`);
|
||||
continue;
|
||||
}
|
||||
if (!FINAL.has(end.state)) continue;
|
||||
// The hub item closes the way the queue closes one: done with the local closure reason, or failed. A refusal, a
|
||||
// cancel or a failure never reads as success.
|
||||
const reason = end.state === "canceled" || end.closureReason === "canceled" ? "canceled"
|
||||
: end.state === "denied" || end.closureReason === "denied" ? "denied" : "no-follow-on";
|
||||
const state = end.state === "failed" ? "failed" as const : "done" as const;
|
||||
const closureReason = state === "failed" ? null : reason;
|
||||
const words = closingWords(deps.queueRepo, end);
|
||||
// A final check before the close: a hub item already closed, by our own close whose answer was lost or by someone
|
||||
// else, gets no write, so another actor's failure or cancel never becomes our done.
|
||||
let shown = await hub.show(receipt.hub_qitem_id);
|
||||
if (!HUB_CLOSED.has(shown.state)) {
|
||||
try {
|
||||
await hub.close(receipt.hub_qitem_id, { state, closureReason, resolution: words });
|
||||
} catch (error) {
|
||||
// Refused: closed by someone else between the check and the close. The readback says how.
|
||||
if (!(error instanceof HubError) || error.kind !== "refused") throw error;
|
||||
}
|
||||
shown = await hub.show(receipt.hub_qitem_id);
|
||||
if (!HUB_CLOSED.has(shown.state)) {
|
||||
deps.db.prepare("UPDATE network_receipts SET last_error = ? WHERE local_qitem_id = ?").run("The close didn't take: the hub item still reads open. Trying again.", receipt.local_qitem_id);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// It counts as our result only when the hub item shows our state, closure reason and resolution. Otherwise the
|
||||
// local result stays here, and status shows the hub item as closed elsewhere.
|
||||
if (shown.state !== state || shown.resolution !== words || (shown.closureReason ?? null) !== closureReason) {
|
||||
mark("settled_at", receipt, "closed_on_hub", `The hub item was closed elsewhere (${shown.state}${shown.closureReason ? `, ${shown.closureReason}` : ""}) before this result reached it. The result stays on ${local.qitemId}.`);
|
||||
deps.log?.(`network ${binding.id}: hub item ${receipt.hub_qitem_id} was closed elsewhere; its local result stays on ${local.qitemId}`);
|
||||
continue;
|
||||
}
|
||||
mark("settled_at", receipt, end.state);
|
||||
result.resultsSent++;
|
||||
} catch (error) {
|
||||
if (!(error instanceof HubError) || error.kind !== "refused") throw error;
|
||||
deps.db.prepare("UPDATE network_receipts SET last_error = ? WHERE local_qitem_id = ?").run(error.message.slice(0, 500), receipt.local_qitem_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface NetworkConnectorSnapshot {
|
||||
running: boolean;
|
||||
intervalMs: number;
|
||||
bindings: Array<{ id: string; nextPassAt: string | null; backoffMs: number; lastPass: NetworkPassResult | null }>;
|
||||
}
|
||||
|
||||
/** The connector the daemon runs: one pass per active binding when it's due, one round at a time, off the event loop's
|
||||
* hot path (the hub calls are async network I/O). Each pass is bounded by its deadline, and stop closes a pass in
|
||||
* flight. A binding that fails backs off, doubling to a cap, with jitter. With no active binding it makes no network
|
||||
* call at all. */
|
||||
export class NetworkConnectorService {
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
private inFlight: Promise<void> | null = null;
|
||||
private stopped = false;
|
||||
private stopping: AbortController | null = null;
|
||||
private readonly due = new Map<string, { at: number; backoffMs: number; last: NetworkPassResult | null }>();
|
||||
readonly intervalMs: number;
|
||||
private readonly maxBackoffMs: number;
|
||||
|
||||
constructor(private readonly deps: NetworkConnectorDeps, opts: { intervalMs?: number; maxBackoffMs?: number; random?: () => number } = {}) {
|
||||
this.intervalMs = opts.intervalMs ?? 30_000;
|
||||
this.maxBackoffMs = Math.max(this.intervalMs, opts.maxBackoffMs ?? 300_000);
|
||||
this.random = opts.random ?? Math.random;
|
||||
}
|
||||
private readonly random: () => number;
|
||||
|
||||
start(): void {
|
||||
this.stopped = false;
|
||||
this.stopping = new AbortController();
|
||||
// A credential whose binding isn't active has nothing left to do here: one a stop left before its row, or one
|
||||
// whose removal failed when its binding ended.
|
||||
const swept = this.deps.bindings.sweepCredentials();
|
||||
if (swept.removed.length) this.deps.log?.(`network connector: removed the credentials of ended bindings ${swept.removed.join(", ")}`);
|
||||
if (swept.failed.length) this.deps.log?.(`network connector: couldn't remove the credentials of ended bindings ${swept.failed.join(", ")}`);
|
||||
this.schedule(0);
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.stopped = true;
|
||||
this.stopping?.abort();
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
await this.inFlight;
|
||||
}
|
||||
|
||||
/** One round: a pass for each active binding that's due. Exposed for tests and status. */
|
||||
async round(): Promise<void> {
|
||||
if (this.inFlight) return this.inFlight;
|
||||
this.inFlight = (async () => {
|
||||
const at = (this.deps.now ?? (() => new Date()))().getTime();
|
||||
const active = this.deps.bindings.active();
|
||||
for (const id of [...this.due.keys()]) if (!active.some((b) => b.id === id)) this.due.delete(id);
|
||||
for (const binding of active) {
|
||||
if (this.stopped) break;
|
||||
const due = this.due.get(binding.id);
|
||||
if (due && due.at > at) continue;
|
||||
const pass = await runNetworkBindingPass(this.deps, binding, { signal: this.stopping?.signal });
|
||||
const failing = pass.error === "unavailable" || pass.error === "refused" || pass.error === "credential" || pass.error === "local";
|
||||
const backoffMs = failing ? Math.min(this.maxBackoffMs, (due?.backoffMs ?? this.intervalMs) * 2) : this.intervalMs;
|
||||
const jitter = 1 + (this.random() * 0.4 - 0.2);
|
||||
this.due.set(binding.id, { at: at + Math.round(backoffMs * jitter), backoffMs, last: pass });
|
||||
}
|
||||
})();
|
||||
try { await this.inFlight; } finally { this.inFlight = null; }
|
||||
}
|
||||
|
||||
snapshot(): NetworkConnectorSnapshot {
|
||||
return {
|
||||
running: !this.stopped,
|
||||
intervalMs: this.intervalMs,
|
||||
bindings: [...this.due.entries()].map(([id, d]) => ({ id, nextPassAt: new Date(d.at).toISOString(), backoffMs: d.backoffMs, lastPass: d.last })),
|
||||
};
|
||||
}
|
||||
|
||||
private schedule(ms: number): void {
|
||||
if (this.stopped) return;
|
||||
this.timer = setTimeout(() => {
|
||||
void this.round().catch((error) => this.deps.log?.(`network connector round failed: ${(error as Error).message}`))
|
||||
.finally(() => this.schedule(Math.min(this.intervalMs, 5_000)));
|
||||
}, ms);
|
||||
this.timer.unref?.();
|
||||
}
|
||||
}
|
||||
|
||||
export { HubError };
|
||||
@@ -1508,7 +1508,7 @@ export function hookOrPaneActivity(
|
||||
// beats hook-arrival age) and makes ACTIVITY real on the DEFAULT path for hook-less / Codex /
|
||||
// just-finished-a-turn seats: the exact fleet-blindness this fix targets. Structural discrimination
|
||||
// is STRUCTURAL (spinner shapes / esc-to-interrupt / idle prompt), never a verb allowlist.
|
||||
if (structural && structural.state !== "unknown") {
|
||||
if (structural && (structural.state !== "unknown" || structural.reason === "unrecognized_client_version")) {
|
||||
return {
|
||||
source: "pane",
|
||||
activity: {
|
||||
@@ -1617,8 +1617,9 @@ export async function attachAgentActivity(
|
||||
*
|
||||
* needs_input > running > MOTION > idle / unknown
|
||||
*
|
||||
* A current, target-bound complete idle frame is the narrow exception to motion below.
|
||||
* A generic footer or old idle capture is not that evidence; D2 remains unchanged.
|
||||
* A current, target-bound complete idle frame is the narrow idle exception to motion below.
|
||||
* An explicitly unsupported client version also retains unknown rather than inferring work from redraws.
|
||||
* A generic footer or old idle capture is not completed-idle evidence; D2 remains unchanged.
|
||||
* Live motion otherwise UPGRADES a verdict of `idle` or `unknown`; it never touches `needs_input` or an
|
||||
* already-`running` verdict, and it never MANUFACTURES `idle`. That asymmetry is the whole
|
||||
* safety argument, and it is what each half of the defect needs:
|
||||
@@ -1644,7 +1645,7 @@ export async function attachAgentActivity(
|
||||
* signal improving. Silence therefore leaves the prior verdict exactly as it was.
|
||||
*/
|
||||
const withMotion = (activity: AgentActivity, currentIdleFrame = false): AgentActivity => {
|
||||
if (!motionRunning || (currentIdleFrame && activity.state === "idle")) return activity;
|
||||
if (!motionRunning || activity.reason === "unrecognized_client_version" || (currentIdleFrame && activity.state === "idle")) return activity;
|
||||
if (activity.state !== "idle" && activity.state !== "unknown") return activity;
|
||||
return {
|
||||
state: "running",
|
||||
@@ -1682,7 +1683,8 @@ export async function attachAgentActivity(
|
||||
|
||||
// An idle hook alone still yields to motion (D2); when a current complete idle frame exists, report
|
||||
// that frame's provenance instead.
|
||||
const resolved = hookOrPaneActivity(hookActivity, structural, motionRunning && currentIdleFrame, entry.runtime);
|
||||
const resolved = hookOrPaneActivity(hookActivity, structural,
|
||||
motionRunning && (currentIdleFrame || structural?.reason === "unrecognized_client_version"), entry.runtime);
|
||||
if (resolved?.source === "hook") return { ...entry, agentActivity: withMotion(resolved.activity) };
|
||||
if (resolved?.source === "limit") return { ...entry, agentActivity: resolved.activity };
|
||||
if (resolved?.source === "pane") return { ...entry, agentActivity: withMotion(resolved.activity, currentIdleFrame) };
|
||||
|
||||
@@ -8,7 +8,7 @@ import { detectConflicts } from "./conflict-detector.js";
|
||||
import { applyPolicy } from "./install-policy.js";
|
||||
|
||||
export type PackageInstallOutcome =
|
||||
| { ok: true; installId: string; packageId: string; applied: number; deferred: number }
|
||||
| { ok: true; installId: string; packageId: string; applied: number; deferred: number; notes?: string[] }
|
||||
| { ok: false; code: "conflict_blocked"; message: string }
|
||||
| { ok: false; code: "policy_rejected"; message: string }
|
||||
| { ok: false; code: "manifest_hash_mismatch"; message: string }
|
||||
@@ -101,7 +101,7 @@ export class PackageInstallService {
|
||||
}
|
||||
|
||||
// Verify
|
||||
const verification = this.installVerifier.verify(result.installId);
|
||||
const verification = this.installVerifier.verify(result.installId, { skipped: result.notes?.length ?? 0 });
|
||||
if (!verification.passed) {
|
||||
return { ok: false, code: "verification_failed", message: "Post-apply verification failed" };
|
||||
}
|
||||
@@ -112,6 +112,7 @@ export class PackageInstallService {
|
||||
packageId: pkg.id,
|
||||
applied: result.applied.length,
|
||||
deferred: result.deferred.length,
|
||||
...(result.notes?.length ? { notes: result.notes } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -21,12 +21,17 @@
|
||||
// per founder authorization 2026-05-10 (LICENSE only); 404 is the
|
||||
// expected normal-state response until a separate publish authorization.
|
||||
|
||||
import fs from "node:fs";
|
||||
import nodePath from "node:path";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
|
||||
export interface PluginVendorFs {
|
||||
readFile(path: string): string;
|
||||
writeFile(path: string, content: string): void;
|
||||
/** A file's raw bytes. With `writeBytes`, a plugin's files are copied and compared byte for byte, so a binary file
|
||||
* (an icon) arrives intact. Optional: without them files are read and written as text. */
|
||||
readBytes?(path: string): Uint8Array;
|
||||
writeBytes?(path: string, content: Uint8Array): void;
|
||||
/** Copy raw bytes, failing if the recovery path already exists. */
|
||||
copyFileExclusive(source: string, target: string): void;
|
||||
exists(path: string): boolean;
|
||||
@@ -160,7 +165,7 @@ export class PluginVendorService {
|
||||
const destPath = nodePath.join(targetDir, relPath);
|
||||
if (
|
||||
this.fs.exists(destPath) &&
|
||||
hashContent(this.fs.readFile(destPath)) === hashContent(this.fs.readFile(srcPath))
|
||||
hashContent(this.read(destPath)) === hashContent(this.read(srcPath))
|
||||
) {
|
||||
this.preserveMode(srcPath, destPath);
|
||||
}
|
||||
@@ -170,7 +175,7 @@ export class PluginVendorService {
|
||||
}
|
||||
}
|
||||
|
||||
const updates: Array<[string, string]> = this.fs.listFiles(sourceDir).map(rel => [rel, this.fs.readFile(nodePath.join(sourceDir, rel))]);
|
||||
const updates: Array<[string, FileContent]> = this.fs.listFiles(sourceDir).map(rel => [rel, this.read(nodePath.join(sourceDir, rel))]);
|
||||
if (!this.preserveBeforeRefresh(targetDir, updates)) return;
|
||||
this.fs.mkdirp(targetDir);
|
||||
|
||||
@@ -179,23 +184,34 @@ export class PluginVendorService {
|
||||
const destPath = nodePath.join(targetDir, relPath);
|
||||
// Hash-skip: only write if content differs (idempotent re-runs). Reconcile mode even on
|
||||
// skip — a byte-identical copy staged earlier may still carry the wrong (default) mode.
|
||||
if (this.fs.exists(destPath) && hashContent(this.fs.readFile(destPath)) === hashContent(content)) {
|
||||
if (this.fs.exists(destPath) && hashContent(this.read(destPath)) === hashContent(content)) {
|
||||
this.preserveMode(srcPath, destPath);
|
||||
continue;
|
||||
}
|
||||
this.fs.mkdirp(nodePath.dirname(destPath));
|
||||
this.fs.writeFile(destPath, content);
|
||||
this.write(destPath, content);
|
||||
this.preserveMode(srcPath, destPath);
|
||||
}
|
||||
}
|
||||
|
||||
/** A plugin file as the fs reads it exactly: bytes when it can, else text. */
|
||||
private read(path: string): FileContent {
|
||||
return this.fs.readBytes ? this.fs.readBytes(path) : this.fs.readFile(path);
|
||||
}
|
||||
|
||||
private write(path: string, content: FileContent): void {
|
||||
if (typeof content === "string") this.fs.writeFile(path, content);
|
||||
else if (this.fs.writeBytes) this.fs.writeBytes(path, content);
|
||||
else this.fs.writeFile(path, Buffer.from(content).toString("utf-8"));
|
||||
}
|
||||
|
||||
/** Preserve every replaced file before touching a target, including its version authority. */
|
||||
private preserveBeforeRefresh(targetDir: string, updates: Array<[string, string]>): boolean {
|
||||
private preserveBeforeRefresh(targetDir: string, updates: Array<[string, FileContent]>): boolean {
|
||||
const backupDir = nodePath.join(nodePath.dirname(this.userPluginsDir), "backups", "managed-refresh", randomUUID());
|
||||
try {
|
||||
for (const [rel, content] of new Map(updates)) {
|
||||
const dest = nodePath.join(targetDir, rel);
|
||||
if (!this.fs.exists(dest) || hashContent(this.fs.readFile(dest)) === hashContent(content)) continue;
|
||||
if (!this.fs.exists(dest) || hashContent(this.read(dest)) === hashContent(content)) continue;
|
||||
const backup = nodePath.join(backupDir, rel);
|
||||
this.fs.mkdirp(nodePath.dirname(backup));
|
||||
this.fs.copyFileExclusive(dest, backup);
|
||||
@@ -276,7 +292,7 @@ export class PluginVendorService {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const updates: Array<[string, string]> = files.map(rel => [rel, this.fs.readFile(nodePath.join(sourceDir, rel))]);
|
||||
const updates: Array<[string, FileContent]> = files.map(rel => [rel, this.read(nodePath.join(sourceDir, rel))]);
|
||||
if (!this.preserveBeforeRefresh(targetDir, [...updates, [GLOBAL_VENDOR_VERSION, `${sourceVersion}\n`]])) continue;
|
||||
this.fs.mkdirp(targetDir);
|
||||
for (const [relPath, content] of updates) {
|
||||
@@ -284,13 +300,13 @@ export class PluginVendorService {
|
||||
const destPath = nodePath.join(targetDir, relPath);
|
||||
if (
|
||||
this.fs.exists(destPath) &&
|
||||
hashContent(this.fs.readFile(destPath)) === hashContent(content)
|
||||
hashContent(this.read(destPath)) === hashContent(content)
|
||||
) {
|
||||
this.preserveMode(srcPath, destPath);
|
||||
continue;
|
||||
}
|
||||
this.fs.mkdirp(nodePath.dirname(destPath));
|
||||
this.fs.writeFile(destPath, content);
|
||||
this.write(destPath, content);
|
||||
this.preserveMode(srcPath, destPath);
|
||||
}
|
||||
this.fs.writeFile(versionMarker, `${sourceVersion}\n`);
|
||||
@@ -339,6 +355,37 @@ export class PluginVendorService {
|
||||
}
|
||||
}
|
||||
|
||||
function hashContent(content: string): string {
|
||||
/** A plugin file's content: its bytes, or its text from an fs that only reads text. */
|
||||
type FileContent = string | Uint8Array;
|
||||
|
||||
/** The same digest for a text and for its UTF-8 bytes, so a text compare and a byte compare agree. */
|
||||
function hashContent(content: FileContent): string {
|
||||
return createHash("sha256").update(content).digest("hex");
|
||||
}
|
||||
|
||||
/** The daemon's own filesystem for vendoring: files are copied and compared as bytes. */
|
||||
export function nodePluginVendorFs(): PluginVendorFs {
|
||||
return {
|
||||
readFile: (p) => fs.readFileSync(p, "utf-8"),
|
||||
writeFile: (p, c) => fs.writeFileSync(p, c, "utf-8"),
|
||||
readBytes: (p) => fs.readFileSync(p),
|
||||
writeBytes: (p, c) => fs.writeFileSync(p, c),
|
||||
copyFileExclusive: (src, dest) => fs.copyFileSync(src, dest, fs.constants.COPYFILE_EXCL),
|
||||
exists: (p) => fs.existsSync(p),
|
||||
mkdirp: (p) => { fs.mkdirSync(p, { recursive: true }); },
|
||||
listFiles: (dir) => {
|
||||
const r: string[] = [];
|
||||
/** Collect relative file paths recursively. */
|
||||
const w = (d: string, pre: string) => {
|
||||
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
||||
if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name));
|
||||
else r.push(pre ? nodePath.join(pre, e.name) : e.name);
|
||||
}
|
||||
};
|
||||
w(dir, "");
|
||||
return r;
|
||||
},
|
||||
statMode: (p) => fs.statSync(p).mode,
|
||||
chmod: (p, m) => fs.chmodSync(p, m),
|
||||
};
|
||||
}
|
||||
@@ -389,6 +389,22 @@ function declaredNodeId(dir: string): string | null {
|
||||
* from them); it defaults to the ordinary filesystem reader and changes nothing about the evaluation. */
|
||||
export function readProjectReadiness(missionsRoot: string, readPolicy: ProofPolicyRead = defaultPolicyRead, root?: string, io: ScopeFsDeps = proofFs, observe?: (file: string) => void) {
|
||||
const missions = fs.readdirSync(missionsRoot).filter(n => fs.statSync(path.join(missionsRoot, n)).isDirectory()).map(name => ({ name, ...readMissionReadiness(contained(missionsRoot, path.join(missionsRoot, name)), readPolicy, root, io, observe) }));
|
||||
return projectReadiness(missions);
|
||||
}
|
||||
|
||||
/** The same semantic read, yielding between missions for daemon requests and source-watch refreshes.
|
||||
* A single mission (including its evidence) is still synchronous; this is not a hard time bound or atomic snapshot. */
|
||||
export async function readProjectReadinessAsync(missionsRoot: string, readPolicy: ProofPolicyRead = defaultPolicyRead, root?: string, io: ScopeFsDeps = proofFs, observe?: (file: string) => void) {
|
||||
const names = fs.readdirSync(missionsRoot).filter(n => fs.statSync(path.join(missionsRoot, n)).isDirectory());
|
||||
const missions: Array<MissionReadiness & { name: string }> = [];
|
||||
for (const name of names) {
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
missions.push({ name, ...readMissionReadiness(contained(missionsRoot, path.join(missionsRoot, name)), readPolicy, root, io, observe) });
|
||||
}
|
||||
return projectReadiness(missions);
|
||||
}
|
||||
|
||||
function projectReadiness(missions: Array<MissionReadiness & { name: string }>) {
|
||||
const active = missions.filter(m => ["active", "release-candidate"].includes(m.historicalStatus ?? ""));
|
||||
return { revision: hash(active), state: active.length && active.every(m => m.state === "ready") ? "ready" : active.some(m => m.state === "unknown") ? "unknown" : "not-ready", missions, basis: "Active mission readiness; distinct outcome judgment and publication remain separate" };
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import * as path from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import type { EventBus } from "../event-bus.js";
|
||||
import type { ScopeFsDeps } from "../scope/scope-view-projection.js";
|
||||
import { createProofPolicyRead, proofFs, readProjectReadiness, resolveProjectRoot } from "./judgments.js";
|
||||
import { createProofPolicyRead, proofFs, readProjectReadinessAsync, resolveProjectRoot } from "./judgments.js";
|
||||
|
||||
export interface ProofSourceWatch {
|
||||
close(): void;
|
||||
@@ -76,16 +76,15 @@ function shallowWatches(missionsRoot: string, realRoot: string, top: string, pro
|
||||
* including absent nearer ones, and accepted evidence elsewhere in the project) is watched at its parent directory,
|
||||
* not recursively, so sibling checkouts and build trees are never subscribed to.
|
||||
*/
|
||||
export function watchProofSources(missionsRoot: string, invalidate: () => void, bus: EventBus, scopeChanged?: () => void): ProofSourceWatch {
|
||||
export async function watchProofSources(missionsRoot: string, invalidate: () => void, bus: EventBus, scopeChanged?: () => void): Promise<ProofSourceWatch> {
|
||||
missionsRoot = path.resolve(missionsRoot);
|
||||
let projectRoot = path.dirname(missionsRoot);
|
||||
try { projectRoot = resolveProjectRoot(missionsRoot) ?? projectRoot; } catch { /* Legacy roots keep the missions root's parent. */ }
|
||||
const top = path.dirname(path.resolve(projectRoot));
|
||||
// The authority boundary in real terms: what the readers' containment check accepts.
|
||||
const project = canonical(projectRoot) ?? path.resolve(projectRoot);
|
||||
// ponytail: bounded local workspaces use a full semantic read after a file burst.
|
||||
// Keep this workload measured; subtree indexing is warranted only when that bound is exceeded.
|
||||
const basis = () => {
|
||||
// Keep the full semantic read, but let queue/send requests run between missions.
|
||||
const basis = async () => {
|
||||
const read = new Set<string>();
|
||||
const note = (p: string) => { read.add(p); return p; };
|
||||
const io: ScopeFsDeps = {
|
||||
@@ -95,7 +94,8 @@ export function watchProofSources(missionsRoot: string, invalidate: () => void,
|
||||
listDir: (p) => proofFs.listDir(note(p)),
|
||||
isDirectory: (p) => proofFs.isDirectory(note(p)),
|
||||
};
|
||||
const hash = createHash("sha256").update(JSON.stringify(readProjectReadiness(missionsRoot, createProofPolicyRead(), undefined, io, note).missions.map(m => [m.name, m.revision]))).digest("hex");
|
||||
const result = await readProjectReadinessAsync(missionsRoot, createProofPolicyRead(), undefined, io, note);
|
||||
const hash = createHash("sha256").update(JSON.stringify(result.missions.map(m => [m.name, m.revision]))).digest("hex");
|
||||
// A file read through a link inside the project depends on its real location too: observe both.
|
||||
for (const file of [...read]) {
|
||||
const real = canonical(file);
|
||||
@@ -107,6 +107,7 @@ export function watchProofSources(missionsRoot: string, invalidate: () => void,
|
||||
let closed = false, failed = false, covered = false, readable = false;
|
||||
let revision = "unavailable", timer: NodeJS.Timeout | undefined;
|
||||
let scopeDirty = false;
|
||||
let reading = true, dirty = false;
|
||||
const notifyScope = () => { try { scopeChanged?.(); } catch { /* A later event or explicit refresh retries the view read. */ } };
|
||||
let wanted: Shallow = new Map();
|
||||
const shallow = new Map<string, fs.FSWatcher>();
|
||||
@@ -163,25 +164,32 @@ export function watchProofSources(missionsRoot: string, invalidate: () => void,
|
||||
/** Read the sources and keep the subscriptions in step with what was read, even when the result is unchanged.
|
||||
* Coverage is certified only by a read whose every dependency was already watched before it; if the dependencies
|
||||
* keep moving past a small budget, the sources are reported unobserved rather than read forever. */
|
||||
const evaluate = (): string => {
|
||||
let read = basis();
|
||||
const evaluate = async (): Promise<{ hash: string; covered: boolean }> => {
|
||||
let read = await basis();
|
||||
for (let i = 0; ; i++) {
|
||||
if (closed) return { hash: revision, covered: false };
|
||||
// A root whose real location is unknown or outside the project has no anchors there: never certified.
|
||||
if (!resubscribe(read.watches)) { covered = recursiveReal !== null && within(recursiveReal, project); return read.hash; }
|
||||
if (i === 2) { covered = false; return read.hash; }
|
||||
read = basis();
|
||||
// Coverage belongs to this candidate, just like the hash; neither escapes a discarded read.
|
||||
if (!resubscribe(read.watches)) return { hash: read.hash, covered: recursiveReal !== null && within(recursiveReal, project) };
|
||||
if (i === 2) return { hash: read.hash, covered: false };
|
||||
read = await basis();
|
||||
}
|
||||
};
|
||||
const observed = () => !closed && !failed && covered && readable;
|
||||
const schedule = () => {
|
||||
if (timer || closed) return;
|
||||
timer = setTimeout(() => {
|
||||
dirty = true;
|
||||
if (timer || reading || closed) return;
|
||||
timer = setTimeout(async () => {
|
||||
timer = undefined;
|
||||
if (closed) return;
|
||||
reading = true; dirty = false;
|
||||
try {
|
||||
if (scopeDirty) { scopeDirty = false; notifyScope(); }
|
||||
const before = observed();
|
||||
const next = evaluate();
|
||||
const candidate = await evaluate();
|
||||
if (closed || dirty) return;
|
||||
const next = candidate.hash;
|
||||
covered = candidate.covered && !failed;
|
||||
readable = true;
|
||||
// Deduplicate on what clients see: the revision and whether the sources are observed. Losing or regaining
|
||||
// coverage is news even when the proof result itself did not change.
|
||||
@@ -190,12 +198,16 @@ export function watchProofSources(missionsRoot: string, invalidate: () => void,
|
||||
invalidate();
|
||||
notify(observed() ? revision : "unavailable");
|
||||
} catch (e) {
|
||||
if (closed) return;
|
||||
// Never report the sources as watched after a failed read or a watch that could not be attached.
|
||||
readable = false;
|
||||
if (e instanceof WatchFailure) covered = false;
|
||||
revision = "unavailable";
|
||||
invalidate();
|
||||
notify("unavailable");
|
||||
} finally {
|
||||
reading = false;
|
||||
if (dirty && !closed) schedule();
|
||||
}
|
||||
}, 40);
|
||||
timer.unref();
|
||||
@@ -203,7 +215,10 @@ export function watchProofSources(missionsRoot: string, invalidate: () => void,
|
||||
try {
|
||||
recursiveReal = canonical(missionsRoot);
|
||||
recursive = watchDir(missionsRoot, true);
|
||||
try { revision = evaluate(); readable = true; } catch (e) {
|
||||
try {
|
||||
const candidate = await evaluate();
|
||||
if (!dirty) { revision = candidate.hash; covered = candidate.covered && !failed; readable = true; }
|
||||
} catch (e) {
|
||||
if (e instanceof WatchFailure) throw e;
|
||||
// Direct reads name unavailable inputs; watching may recover them.
|
||||
covered = true;
|
||||
@@ -214,6 +229,9 @@ export function watchProofSources(missionsRoot: string, invalidate: () => void,
|
||||
for (const watcher of shallow.values()) watcher.close();
|
||||
invalidate(); notify("unavailable"); notifyScope();
|
||||
return { close() {}, observation: () => ({ state: "unavailable", revision }) };
|
||||
} finally {
|
||||
reading = false;
|
||||
if (dirty && !closed) schedule();
|
||||
}
|
||||
return {
|
||||
observation: () => ({ state: observed() ? "watching" : "unavailable", revision }),
|
||||
|
||||
@@ -61,15 +61,16 @@ export interface ClaudeUsageReaderDeps {
|
||||
|
||||
/**
|
||||
* Read each Claude seat's provider_usage cache → provider_statusline rows via
|
||||
* claudeStatuslineSignals. Explicit unknown rows for absent cache (no_statusline_cache_yet) and
|
||||
* cache-present-but-no/-malformed windows (empty_reading).
|
||||
* Never throws — a malformed cache is treated as present-with-no-windows, not a crash.
|
||||
* claudeStatuslineSignals. Explicit unknown rows for absent cache (no_statusline_cache_yet),
|
||||
* an unreadable cache (unreadable_cache), and a valid cache with no windows (empty_reading).
|
||||
* Never throws — a malformed cache becomes an unknown row, not a crash.
|
||||
*/
|
||||
export function collectClaudeStatuslineSignals(deps: ClaudeUsageReaderDeps): ProviderSignal[] {
|
||||
const now = deps.now();
|
||||
const out: ProviderSignal[] = [];
|
||||
for (const seat of deps.listClaudeSeats()) {
|
||||
let cachePresent = false;
|
||||
let cacheUnreadable = false;
|
||||
let reading: ClaudeStatuslineReading | undefined;
|
||||
let staleAfter: string | undefined;
|
||||
let capturedAsOf: string | undefined;
|
||||
@@ -84,7 +85,8 @@ export function collectClaudeStatuslineSignals(deps: ClaudeUsageReaderDeps): Pro
|
||||
staleAfter = parsed.staleAfter;
|
||||
capturedAsOf = typeof parsed.asOf === "string" ? parsed.asOf : undefined;
|
||||
} catch {
|
||||
// Malformed cache → present-with-no-windows (empty_reading unknown), never a throw.
|
||||
// Malformed cache → present-but-unreadable (its own unknown), never a throw.
|
||||
cacheUnreadable = true;
|
||||
reading = undefined;
|
||||
}
|
||||
}
|
||||
@@ -92,6 +94,7 @@ export function collectClaudeStatuslineSignals(deps: ClaudeUsageReaderDeps): Pro
|
||||
...claudeStatuslineSignals({
|
||||
seatSession: seat.seatSession,
|
||||
cachePresent,
|
||||
cacheUnreadable,
|
||||
reading,
|
||||
// Signal asOf = when the reading was captured (the cache's asOf) when present+valid; else now.
|
||||
asOf: cachePresent && capturedAsOf ? capturedAsOf : now,
|
||||
@@ -109,6 +112,7 @@ export function collectClaudeSignalsFromProviderUsageDirectory(
|
||||
legacyDirectory?: string,
|
||||
): ProviderSignal[] {
|
||||
const seats = new Map<string, ClaudeSeatRef>();
|
||||
const unreadableFiles = new Set<string>();
|
||||
for (const candidateDirectory of [directory, legacyDirectory]) {
|
||||
if (!candidateDirectory) continue;
|
||||
try {
|
||||
@@ -119,10 +123,16 @@ export function collectClaudeSignalsFromProviderUsageDirectory(
|
||||
if (typeof parsed.seatSession === "string") {
|
||||
seats.set(parsed.seatSession, { seatSession: parsed.seatSession });
|
||||
}
|
||||
} catch { /* malformed cache cannot create a usable cache signal */ }
|
||||
} catch {
|
||||
// A cache file that exists but cannot be read or parsed still names its seat in the
|
||||
// filename (telemetrySidecarFilename sanitizes the same seat name). Keep it in the
|
||||
// seat list so the reader reports an unreadable cache instead of the file vanishing.
|
||||
unreadableFiles.add(file.slice(0, -".json".length));
|
||||
}
|
||||
}
|
||||
} catch { /* absent cache directory */ }
|
||||
}
|
||||
for (const seatSession of unreadableFiles) seats.set(seatSession, { seatSession });
|
||||
|
||||
const readFrom = (candidateDirectory: string, seatSession: string): string | null => {
|
||||
try {
|
||||
|
||||
@@ -19,7 +19,8 @@ export interface CodexLimitBanner {
|
||||
evidence: string;
|
||||
}
|
||||
|
||||
const BANNER_LINE_RE = /^\s*■ You've hit your usage limit\b/;
|
||||
// Codex prints a typographic apostrophe (U+2019); older builds use the straight one.
|
||||
const BANNER_LINE_RE = /^\s*■ You['’]ve hit your usage limit\b/u;
|
||||
const RESET_RE = /try again at ([^.!\n]+)/i;
|
||||
|
||||
// Fenced code blocks: banner-shaped text an agent printed is quoted content,
|
||||
|
||||
@@ -131,6 +131,8 @@ export interface ClaudeSignalInput {
|
||||
seatSession: string;
|
||||
/** false = the statusline cache has not been written yet (before the first API response). */
|
||||
cachePresent: boolean;
|
||||
/** True when a cache file exists but cannot be read or parsed. Distinct from an empty reading. */
|
||||
cacheUnreadable?: boolean;
|
||||
/** Present only when the cache exists and carried a rate_limits object. */
|
||||
reading?: ClaudeStatuslineReading;
|
||||
asOf: string;
|
||||
@@ -140,6 +142,7 @@ export interface ClaudeSignalInput {
|
||||
export const CLAUDE_UNKNOWN_REASON = {
|
||||
no_statusline_cache_yet: "claude_no_statusline_cache_yet",
|
||||
empty_reading: "claude_statusline_cache_had_no_windows",
|
||||
unreadable_cache: "claude_statusline_cache_unreadable",
|
||||
} as const;
|
||||
|
||||
function claudeUnknownSignal(
|
||||
@@ -165,17 +168,22 @@ function claudeUnknownSignal(
|
||||
/**
|
||||
* Normalize a Claude statusline `rate_limits` reading into `signals[]` rows.
|
||||
* - seat before first response (no cache) → explicit unknown row.
|
||||
* - a cache that exists but cannot be read/parsed → its own unknown reason (broken, not empty).
|
||||
* - seat with subscription windows → one `provider_statusline` row per window (five_hour, and
|
||||
* seven_day normalized to "weekly"); a genuine 0 survives as 0.
|
||||
* - subscription with cache but no windows → explicit unknown row (never fabricated zeros).
|
||||
*/
|
||||
export function claudeStatuslineSignals(input: ClaudeSignalInput): ProviderSignal[] {
|
||||
const { seatSession, cachePresent, reading, asOf, staleAfter } = input;
|
||||
const { seatSession, cachePresent, cacheUnreadable, reading, asOf, staleAfter } = input;
|
||||
|
||||
if (!cachePresent) {
|
||||
return [claudeUnknownSignal(seatSession, asOf, CLAUDE_UNKNOWN_REASON.no_statusline_cache_yet, staleAfter)];
|
||||
}
|
||||
|
||||
if (cacheUnreadable) {
|
||||
return [claudeUnknownSignal(seatSession, asOf, CLAUDE_UNKNOWN_REASON.unreadable_cache, staleAfter)];
|
||||
}
|
||||
|
||||
const windows: Array<["five_hour" | "weekly", ClaudeStatuslineWindow | undefined]> = [
|
||||
["five_hour", reading?.five_hour],
|
||||
["weekly", reading?.seven_day],
|
||||
@@ -219,6 +227,8 @@ export type ReactiveEventInput = {
|
||||
// Required: a reactive event without a freshness bound could never pass BR-2, and a momentary
|
||||
// event must always carry when it expires. Staleness itself stays the shared predicate's job.
|
||||
staleAfter: string;
|
||||
/** #99: when the provider's own failure says the limit resets. Absent, the pool falls back to the bounded retry. */
|
||||
resetsAt?: string;
|
||||
} & ({ provider: ProviderKind; accountRef: string; seatSession?: never }
|
||||
| { provider: "claude"; seatSession: string; accountRef?: never });
|
||||
|
||||
@@ -236,6 +246,7 @@ export function reactiveEventSignal(input: ReactiveEventInput): ProviderSignal {
|
||||
// At-limit exhaustion is an actionable switch trigger; stream/stop errors are advisory.
|
||||
automationUse: input.kind === "at_limit" ? "allow_switch_decision" : "advisory_only",
|
||||
// No usedPercent — a reactive event is exhaustion evidence, not a remaining meter.
|
||||
...(input.resetsAt !== undefined ? { resetsAt: input.resetsAt } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -319,8 +330,8 @@ export function deriveUsageLimitPools(input: {
|
||||
const statedResets = pool.signals
|
||||
.map((signal) => Date.parse(signal.resetsAt ?? ""))
|
||||
.filter((value) => Number.isFinite(value) && value > nowMs);
|
||||
// A typed Claude failure has no reset timestamp. Use the existing bounded retry interval,
|
||||
// as for Codex events; a Claude meter missing its reset still cannot invent one.
|
||||
// A typed Claude failure carries a reset only when Claude's own message stated one (#99). Without it, use
|
||||
// the existing bounded retry interval, as for Codex events; a Claude meter missing its reset still cannot invent one.
|
||||
const fallbackResets = pool.signals
|
||||
.filter((signal) => pool.provider === "codex" || (signal.sourceClass === "provider_event" && signal.authority === "reactive_error"))
|
||||
.map((signal) => Date.parse(signal.asOf) + input.fallbackSeconds * 1000)
|
||||
|
||||
@@ -4,6 +4,9 @@ import type { CodexAuthMetadata } from "./codex-auth-reader.js";
|
||||
import { reactiveEventSignal, type ReactiveEventKind } from "./provider-signals.js";
|
||||
import type { ProviderSignal } from "./provider-types.js";
|
||||
|
||||
/** #99: the farthest a stated Claude reset may sit after its failure: the relay's own cap (activity-relay.cjs). */
|
||||
const CLAUDE_STATED_RESET_MAX_MS = 8 * 24 * 3600 * 1000;
|
||||
|
||||
interface ReactiveSeat {
|
||||
seatSession: string;
|
||||
runtime: string;
|
||||
@@ -98,9 +101,13 @@ export function collectReactiveEventSignals(deps: ReactiveTapDeps): ReactiveTapR
|
||||
|| isVerificationReason(event.reason) || event.stale) continue;
|
||||
const eventAtMs = Date.parse(event.eventAt ?? "");
|
||||
if (!Number.isFinite(eventAtMs) || eventAtMs > nowMs || nowMs >= eventAtMs + deps.freshnessMs) continue;
|
||||
// #99: the reset Claude's message stated, when the relay could read one, after the failure and within the relay's
|
||||
// eight-day cap. The cap is enforced here too, so no other sender can park a pool longer than the relay could.
|
||||
const resetMs = Date.parse(event.resetsAt ?? "");
|
||||
signals.push(reactiveEventSignal({
|
||||
provider: "claude", seatSession: seat.seatSession, kind: "at_limit",
|
||||
asOf: event.eventAt!, staleAfter: new Date(eventAtMs + deps.freshnessMs).toISOString(),
|
||||
...(Number.isFinite(resetMs) && resetMs > eventAtMs && resetMs <= eventAtMs + CLAUDE_STATED_RESET_MAX_MS ? { resetsAt: new Date(resetMs).toISOString() } : {}),
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -4272,7 +4272,9 @@ export class QueueRepository {
|
||||
// The person learns who decides and in what recorded state, so a delay is shown: one update per request.
|
||||
const toldId = `qitem-runner-permission-passed-${item.qitemId.replace(/^qitem-runner-permission-/, "").replace(/^qitem-/, "")}`;
|
||||
setImmediate(() => {
|
||||
// The request the person answered is what they'd judge (convention C3: a note to a person names its evidence).
|
||||
void this.create({ qitemId: toldId, sourceSession: root.sourceSession, destinationSession: person, humanIntent: "update",
|
||||
evidenceRef: `rig queue show ${root.qitemId}`,
|
||||
summary: `Your answer went to ${decider} to decide; ${root.sourceSession} is waiting`,
|
||||
body: [`Your answer to ${root.sourceSession}'s permission request ${root.qitemId} went to ${decider}, which decides it while ${root.sourceSession} waits. Nothing runs until it decides.`,
|
||||
...(chosen?.describe ? [`Receiving agent: ${chosen.describe}`] : []),
|
||||
@@ -4323,6 +4325,7 @@ export class QueueRepository {
|
||||
// After the answer's own write settles: one receipt per request (its id is the request's), told to who answered.
|
||||
setImmediate(() => {
|
||||
void this.create({ qitemId: receiptId, sourceSession: requester, destinationSession: person, humanIntent: "update",
|
||||
evidenceRef: `rig queue show ${item.qitemId}`,
|
||||
summary: `Your answer to ${requester}'s permission request is kept; the tool hasn't run`,
|
||||
body: [`Your answer to ${requester}'s permission request ${item.qitemId} is kept, and the request is still waiting: ${unreachable ? `it couldn't be passed to ${unreachable}, the receiving agent, just now (answer again to retry)` : `no receiving agent on this host can read it before the tool runs (none is set, or the only one is ${requester} itself)`}. The tool hasn't run, and nothing was permitted or refused.`,
|
||||
`To decide now: rig seat decide-permission ${item.qitemId} --option <id>${options.length ? ` (options: ${options.map((o) => `${o.id}, ${o.label}`).join("; ")})` : ""}. Or set up a receiving agent that isn't ${requester}: rig config set agents.operator_session <seat>@<rig>.`,
|
||||
|
||||
@@ -34,6 +34,7 @@ import { createLinkedSuccessorLookup, deriveCrossHostSuccessorId, hasLinkedSucce
|
||||
import { stalledPickupFinding } from "./queue-pickup.js";
|
||||
import { SettingsStore } from "./user-settings/settings-store.js";
|
||||
import { loadHostRegistry } from "./hosts/hosts-registry-reader.js";
|
||||
import { readWakeOutcome } from "./connection-contract.js";
|
||||
|
||||
export const STUCK_SWEEP_INTERVAL_KEY = "queue.stuck_sweep_interval_seconds";
|
||||
export const DEFAULT_STUCK_SWEEP_INTERVAL_SECONDS = 300;
|
||||
@@ -58,6 +59,12 @@ const NO_WAKE_ROUTE_RULES = [
|
||||
"the operator agent (agents.operator_session)",
|
||||
] as const;
|
||||
|
||||
/** How a failed wake's finding at a seat with no declared route names the rule that chose its recipient, in the order tried. */
|
||||
const FAILED_WAKE_ROUTE_RULES = [
|
||||
"the row's creator",
|
||||
"the operator agent (agents.operator_session)",
|
||||
] as const;
|
||||
|
||||
// S01 ladder marker vocabulary (the seam contract). S01 writes these transition-note
|
||||
// prefixes; the sweep derives "live ladder" from the latest marker. An attempt/rung is
|
||||
// LIVE, an exhausted marker hands the row back, and a later attempt starts a live cycle
|
||||
@@ -284,7 +291,9 @@ function custodyVerifiedTargets(db: Database.Database, qitemId: string, targets:
|
||||
for (const { transition_note: note } of notes) {
|
||||
if (!note) continue;
|
||||
for (const token of note.slice(CUSTODY_VERIFIED_PREFIX.length).trim().split(/\s+/)) {
|
||||
const named = [token, ...token.split(",").map((t) => t.trim())].filter(Boolean);
|
||||
const named = [token, ...token.split(",").map((t) => t.trim())]
|
||||
.filter(Boolean)
|
||||
.map((t) => targets.includes(t) ? t : t.replace(/[.,;:)]$/, ""));
|
||||
if (!named.some((t) => targets.includes(t))) continue;
|
||||
for (const t of named) verified.add(t);
|
||||
break;
|
||||
@@ -320,6 +329,12 @@ function defaultIsRegisteredHost(log: (line: string) => void): (hostId: string)
|
||||
* Use the closing transition, including archived history; later same-state notes
|
||||
* and delivery receipts do not transfer responsibility. A newer cancellation
|
||||
* without closure metadata still supersedes an older closing actor. */
|
||||
/** OpenRig's own machinery (the daemon, or a `@system` actor such as this sweep): it creates and closes rows, but it isn't
|
||||
* a seat that can act on a finding. */
|
||||
function isMachinerySession(session: string): boolean {
|
||||
return session === "daemon@kernel" || session.endsWith("@system");
|
||||
}
|
||||
|
||||
function custodyFindingRoute(db: Database.Database, queueRepo: QueueRepository, row: QueueItem): string {
|
||||
// Unknown identity cannot establish a new owner. The caller retains this row's
|
||||
// established destination if the registry needed for classification is unavailable.
|
||||
@@ -338,7 +353,7 @@ function custodyFindingRoute(db: Database.Database, queueRepo: QueueRepository,
|
||||
ORDER BY transition_id DESC LIMIT 1`,
|
||||
).get(row.qitemId, row.qitemId) as { actor_session: string } | undefined;
|
||||
for (const session of [closure?.actor_session, row.sourceSession]) {
|
||||
if (!session || session === "daemon@kernel" || session.endsWith("@system")) continue;
|
||||
if (!session || isMachinerySession(session)) continue;
|
||||
if (!queueRepo.isHumanDestination(session) && queueRepo.acceptsDestination(session)) {
|
||||
return session;
|
||||
}
|
||||
@@ -458,8 +473,9 @@ function deliveryResolutionEvidence(
|
||||
if (row.claimedAt) return `source claimed at ${row.claimedAt}`;
|
||||
if (row.state === "blocked") return "source parked";
|
||||
if (kind === "undelivered-wake") {
|
||||
if (row.lastNudgeResult === "verified") return "source wake verified";
|
||||
if (row.lastNudgeResult?.startsWith("wake-accepted:")) return "source wake accepted by its hosted trigger";
|
||||
const wake = readWakeOutcome(row.lastNudgeResult);
|
||||
if (wake?.fact === "accepted" && wake.renderVerified) return "source wake verified";
|
||||
if (wake?.fact === "accepted" && wake.route === "hosted-trigger") return "source wake accepted by its hosted trigger";
|
||||
}
|
||||
}
|
||||
if (deps.queueRepo.humanNotificationPostedThisEpisode(qitemId)) return "current human ask posted";
|
||||
@@ -684,7 +700,10 @@ export async function runStuckSweep(deps: StuckSweepDeps): Promise<StuckSweepRes
|
||||
// Every yield and every awaited write lets other code run: indexes read before it are dropped, and `othersRan`
|
||||
// tells a list read before it that its rows need a fresh read.
|
||||
let othersRan = 0;
|
||||
const othersMayHaveRun = (): void => { othersRan++; recoveries.invalidate(); successors?.invalidate(); };
|
||||
/** Each seat's agent-seat reading (agentSeatNode, below), dropped with the indexes: another writer can declare a
|
||||
* seat's delegation parent while the pass yields or awaits a write. */
|
||||
const agentSeats = new Map<string, { logicalId: string; rig: string } | null>();
|
||||
const othersMayHaveRun = (): void => { othersRan++; recoveries.invalidate(); successors?.invalidate(); agentSeats.clear(); };
|
||||
// A successor index whose build took at least half of a stretch that yielded by elapsed time is not rebuilt for
|
||||
// the rest of the pass: the yield drops it, the next stretch would pay the build again and end the same way after a
|
||||
// few rows, so the rows would pay about one build each instead of one per STUCK_SWEEP_YIELD_EVERY_ROWS. Those rows
|
||||
@@ -1274,8 +1293,6 @@ export async function runStuckSweep(deps: StuckSweepDeps): Promise<StuckSweepRes
|
||||
c.why += `. This finding goes to ${recipient.seat}, ${NO_WAKE_ROUTE_RULES[recipient.chosen]}`;
|
||||
c.route = recipient.seat;
|
||||
};
|
||||
/** The two re-routes, in order, for one candidate. */
|
||||
const routeCandidate = (c: Candidate): void => { routeAroundMissing(c); routeAroundNoWake(c); };
|
||||
for (const c of candidates) {
|
||||
if (dueToYield()) await yieldLoop();
|
||||
routeAroundMissing(c);
|
||||
@@ -1299,10 +1316,9 @@ export async function runStuckSweep(deps: StuckSweepDeps): Promise<StuckSweepRes
|
||||
// (below). A finding per row stays for a person's address or a terminal seat, for a declared route that failed (its
|
||||
// finding names why, and a declared delegation parent with no session is one), and for a row that already has a
|
||||
// recovery.
|
||||
const agentSeats = new Map<string, { logicalId: string; rig: string } | null>();
|
||||
/** An agent seat's node: one whose runtime isn't a terminal, with no declared delegation parent, at an address that
|
||||
* isn't a person's. An address the human registry can't classify counts as a person's. null for anything else. Read
|
||||
* once per seat per pass. */
|
||||
* once per seat until other code may have run (agentSeats). */
|
||||
const agentSeatNode = (session: string): { logicalId: string; rig: string } | null => {
|
||||
if (agentSeats.has(session)) return agentSeats.get(session)!;
|
||||
const nodeId = resolveSessionNodeId(deps.db, session);
|
||||
@@ -1319,6 +1335,30 @@ export async function runStuckSweep(deps: StuckSweepDeps): Promise<StuckSweepRes
|
||||
agentSeats.set(session, agent);
|
||||
return agent;
|
||||
};
|
||||
// A wake that failed at a live agent seat with no delegation parent or escalates_to made its finding that same seat's,
|
||||
// so the finding's own wake went to the seat that had just failed to take one, onto whatever was in its input box.
|
||||
// The row's creator can act on it (resend, reroute or ask), as the waiting and parked-owner findings already route;
|
||||
// for a seat that sent itself the work, a row OpenRig's own machinery created, or a creator that is a bound terminal
|
||||
// seat (nothing there can claim it, as for the no-wake rule), the operator agent can. A declared
|
||||
// route that failed keeps its fallback, and a finding the two re-routes above moved keeps its recipient. A wake that
|
||||
// found no route never reached the seat, so a seat launched since keeps its own finding, whose wake is the one it missed.
|
||||
const routeAroundFailedWake = (c: Candidate): void => {
|
||||
if (c.kind !== "undelivered-wake" || !startsWithCI(c.row.lastNudgeResult, "failed:") || c.noWake
|
||||
|| c.route !== c.row.destinationSession
|
||||
|| routeNotes.has(c.row.qitemId) || agentSeatNode(c.route) === null) return;
|
||||
const choices = [c.row.sourceSession, (deps.resolveRecoveryAgentSeat ?? resolveRecoveryAgentSeat)()];
|
||||
const chosen = choices.findIndex((seat) => !!seat && seat !== c.route && !isMachinerySession(seat)
|
||||
&& !sessionMissing(seat) && deps.queueRepo.acceptsDestination(seat) && !isBoundTerminalSeat(deps.db, seat));
|
||||
if (chosen < 0) {
|
||||
c.why += `; ${c.route} declares no delegation parent or escalates_to, and its creator and the operator agent (agents.operator_session) are each unset, missing, this seat, OpenRig's own machinery, a terminal seat or not an address this queue takes, so this finding stays addressed to it`;
|
||||
return;
|
||||
}
|
||||
c.why += `; ${c.route} declares no delegation parent or escalates_to, so this finding goes to ${choices[chosen]}, ${FAILED_WAKE_ROUTE_RULES[chosen]}`;
|
||||
c.route = choices[chosen]!;
|
||||
};
|
||||
/** The three re-routes, in order, for one candidate. The third reads no earlier pass: a failed wake's finding is
|
||||
* routed only here, on the row as it is right before the finding is created. */
|
||||
const routeCandidate = (c: Candidate): void => { routeAroundMissing(c); routeAroundNoWake(c); routeAroundFailedWake(c); };
|
||||
const selfRoutedUnclaimed = (c: Candidate): boolean => c.kind === "unclaimed-obligation"
|
||||
&& c.route === c.row.destinationSession && !routeNotes.has(c.row.qitemId) && agentSeatNode(c.route) !== null;
|
||||
// A hosted member that polls for its work (no wake trigger) can't hear a finding, so each unclaimed row went to a
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { resolveEscalationOwner, ESCALATION_HOP_TAG, hasConsumedEscalationHop } from "./escalation-owner.js";
|
||||
import { tmuxAttachCommand } from "../adapters/tmux-server.js";
|
||||
import { shellQuote } from "../adapters/shell-quote.js";
|
||||
import type { HookReviewObservation } from "./kernel-hook-review.js";
|
||||
import type { ArbitratedSeatState } from "./activity-taxonomy.js";
|
||||
import { seatRuntimeHomeBySession } from "./seat-runtime-home.js";
|
||||
@@ -13,6 +12,7 @@ import type { OperatorDeliveryEngine } from "./gateway/operator-delivery-engine.
|
||||
import { openPromptRefusals, CLOSE_PREFIX, PARKED_OWNER_POLICY_NAME, REFUSED_PREFIX } from "./policies/parked-owner-consumer.js";
|
||||
import { findQueueRecovery, recoveryTag } from "./queue-recovery.js";
|
||||
import { lastMeaningfulTransition, type WaitingView } from "./queue-waiting.js";
|
||||
import { readWakeOutcome } from "./connection-contract.js";
|
||||
// S01 (OPR.0.5.5.1) — WAKE OR ESCALATE ON BATONS. A handoff whose wake fails must never
|
||||
// silently park: today one failed nudge is recorded and nothing follows (the measured
|
||||
// dominant 0.5.3 failure class — a perfect surviving packet, a recipient never woken).
|
||||
@@ -384,28 +384,44 @@ function hasPickupEvidence(db: Database.Database, row: Pick<QueueItem, "qitemId"
|
||||
type WakeMode = "failed" | "unconfirmed" | "held";
|
||||
|
||||
function classifyWakeResult(lastNudgeResult: string | null): WakeMode | null {
|
||||
if (!lastNudgeResult) return null;
|
||||
if (lastNudgeResult.startsWith("failed:")) return "failed";
|
||||
// #344 follow-up — a typing-guard refusal (retained:) wrote NO pane input, so an
|
||||
// unclaimed baton keeps a retry path instead of stranding behind a terminal
|
||||
// recovery. Admission is scoped to unclaimed batons by unclaimedWakeMode(): the
|
||||
// claimed parked-owner arm's retry readback still selects `failed:%` only, so
|
||||
// admitting retained there would make recovery OWN a row that nothing retries.
|
||||
// Retry behaviour, as it actually runs: the first retry re-probes the guard and
|
||||
// records a fresh attempt; later retries reuse the same delivery id, whose
|
||||
// retained readback returns `retained` again even after the guard has cleared —
|
||||
// so the row re-probes once and then escalates rather than looping probes.
|
||||
// A draft-aware hold can clear on its own (the draft is sent or cleared), so it keeps that retry path. A hold or a
|
||||
// level that holds the wake is the person's choice: retrying is held again, so it reads as held.
|
||||
if (lastNudgeResult === "retained:typing_guard") return "held";
|
||||
if (lastNudgeResult.startsWith("retained:")) return "failed";
|
||||
if (
|
||||
lastNudgeResult === "delivered-ack-pending" ||
|
||||
lastNudgeResult.startsWith("indeterminate:") ||
|
||||
lastNudgeResult.startsWith("gateway-owned:")
|
||||
)
|
||||
return "unconfirmed";
|
||||
return null; // verified (or unknown vocabulary) — never enters the ladder
|
||||
// A claimed row's unconfirmed result is presented as `claimed` (presentedNudgeResult): the claim is pickup, which
|
||||
// outranks any wake outcome.
|
||||
if (lastNudgeResult === "claimed") return null;
|
||||
const outcome = readWakeOutcome(lastNudgeResult);
|
||||
if (!outcome) return null;
|
||||
switch (outcome.fact) {
|
||||
// Nothing reached the seat, so a retry can't double-deliver. An address no route can carry (`none`) has nothing
|
||||
// to retry; its own notice covers it.
|
||||
case "not-delivered":
|
||||
return outcome.route === "none" ? null : "failed";
|
||||
// A webhook failure after the POST: its effect is unknown, but it keeps the failed path and retry policy it has
|
||||
// always had.
|
||||
case "failed-effect-unknown":
|
||||
return "failed";
|
||||
// #344 follow-up — a typing-guard refusal (retained:) wrote NO pane input, so an
|
||||
// unclaimed baton keeps a retry path instead of stranding behind a terminal
|
||||
// recovery. Admission is scoped to unclaimed batons by unclaimedWakeMode(): the
|
||||
// claimed parked-owner arm's retry readback still selects `failed:%` only, so
|
||||
// admitting retained there would make recovery OWN a row that nothing retries.
|
||||
// Retry behaviour, as it actually runs: the first retry re-probes the guard and
|
||||
// records a fresh attempt; later retries reuse the same delivery id, whose
|
||||
// retained readback returns `retained` again even after the guard has cleared —
|
||||
// so the row re-probes once and then escalates rather than looping probes.
|
||||
// A draft-aware hold can clear on its own (the draft is sent or cleared), so it keeps that retry path. A hold or a
|
||||
// level that holds the wake is the person's choice: retrying is held again, so it reads as held.
|
||||
case "held":
|
||||
return outcome.heldBy === "typing-guard" ? "held" : "failed";
|
||||
// A rendered terminal wake, or a hosted trigger that took the ring (the stuck sweep owns a hosted member's
|
||||
// unclaimed work), never enters. An unverified terminal send rides the confirmation path.
|
||||
case "accepted":
|
||||
return outcome.renderVerified || outcome.route === "hosted-trigger" ? null : "unconfirmed";
|
||||
// Unknown, including a recorded result no reader recognises: it never retries (input may have arrived), and it
|
||||
// escalates past the window when nothing picked the work up, instead of reading as delivered.
|
||||
case "queued-not-started":
|
||||
case "turn-started":
|
||||
case "unknown":
|
||||
return "unconfirmed";
|
||||
}
|
||||
}
|
||||
|
||||
/** A retained (typing-guard) result is retryable ONLY for an unclaimed baton. The
|
||||
@@ -417,7 +433,7 @@ function classifyWakeResult(lastNudgeResult: string | null): WakeMode | null {
|
||||
function unclaimedWakeMode(row: Pick<QueueItem, "state" | "claimedAt" | "handedOffFrom" | "lastNudgeResult">): WakeMode | null {
|
||||
const mode = classifyWakeResult(row.lastNudgeResult);
|
||||
if (mode === null) return null;
|
||||
const retained = row.lastNudgeResult?.startsWith("retained:") ?? false;
|
||||
const retained = readWakeOutcome(row.lastNudgeResult)?.fact === "held";
|
||||
if (retained && !(row.state === "pending" && !row.claimedAt && row.handedOffFrom)) return null;
|
||||
return mode;
|
||||
}
|
||||
@@ -864,7 +880,9 @@ export async function runWakeLadderTick(deps: WakeLadderDeps): Promise<WakeLadde
|
||||
? `wake failed ${view.attempts} times over ${minutesSince(view.firstMarkerTs ?? row.tsCreated, now)} min`
|
||||
: mode === "held"
|
||||
? `held by ${row.destinationSession}'s typing guard for ${minutesSince(row.tsCreated, now)} min: nothing was typed, and it isn't retried`
|
||||
: `unconfirmed delivery with no pickup evidence over ${minutesSince(row.tsCreated, now)} min`;
|
||||
: readWakeOutcome(row.lastNudgeResult)?.route === null
|
||||
? `wake outcome unknown (unrecognised result "${row.lastNudgeResult!.slice(0, 80)}") with no pickup evidence over ${minutesSince(row.tsCreated, now)} min`
|
||||
: `unconfirmed delivery with no pickup evidence over ${minutesSince(row.tsCreated, now)} min`;
|
||||
if (hasConsumedEscalationHop(deps.db, row.qitemId)) {
|
||||
if (!due || suspended) continue;
|
||||
const consumedReason = `${reason}; escalates_to one-hop delivery already used; existing fallback retained`;
|
||||
@@ -1447,7 +1465,7 @@ async function advancePromptRefusals(
|
||||
const route = !deps.resolveOrchestrator
|
||||
? resolveEscalationOwner(deps.db, dest, target => deps.queueRepo.acceptsDestination(target)) : null;
|
||||
const reason = (group.hookReview
|
||||
? `Seat ${dest} is stopped at Codex's "Hooks need review" (hook_trust_gate). Reach that pane on this host with ${tmuxAttachCommand(shellQuote(dest))}. Review the hooks there through Codex's hook review; approve them there only if you choose to. OpenRig does not approve or bypass this review. No input was sent.`
|
||||
? `Seat ${dest} is stopped at Codex's "Hooks need review" (hook_trust_gate). Reach that pane on this host with ${tmuxAttachCommand(dest)}. Review the hooks there through Codex's hook review; approve them there only if you choose to. OpenRig does not approve or bypass this review. No input was sent.`
|
||||
: "interactive prompt blocks outstanding work; no input was sent")
|
||||
+ (route && route.outcome !== "none" && route.outcome !== "parent" ? `; ${route.reason}` : "");
|
||||
// Every refusal at a choice prompt: the alert follows queue.wake_human_rung and holds for other choice alerts. Any other
|
||||
|
||||
@@ -92,7 +92,7 @@ export class Reconciler {
|
||||
// A session this server doesn't show isn't gone while its seat's recorded process still runs: the daemon is
|
||||
// looking at a different tmux server. Detaching it would have the kernel relaunch duplicates and supersede the
|
||||
// live seats. Without that evidence (after a reboot nothing recorded is running), it detaches as before.
|
||||
const elsewhere = await this.recordedProcessStillRunning(session, processes);
|
||||
const elsewhere = await recordedProcessStillRunning(this.db, session, processes);
|
||||
if (elsewhere) {
|
||||
unseen.push({ sessionId: session.id, sessionName: session.sessionName, ...elsewhere });
|
||||
continue;
|
||||
@@ -123,31 +123,32 @@ export class Reconciler {
|
||||
|
||||
return { checked, detached, detachedSessionIds, unseen, errors };
|
||||
}
|
||||
}
|
||||
|
||||
/** The seat's last observed process, as a conservative hint that it still runs, not proof of identity: the last pid
|
||||
* evidence recorded for this very session (kept with its own time, so a later poll that couldn't see the seat doesn't
|
||||
* erase it), and a process with that pid whose start isn't observably later than that observation. Start times are
|
||||
* whole seconds, so a pid reused within the observation's second still passes. An unreadable table or record is no
|
||||
* evidence. */
|
||||
private async recordedProcessStillRunning(
|
||||
session: { nodeId: string; sessionName: string },
|
||||
processes: () => Promise<ProcessTableRow[] | null>,
|
||||
): Promise<{ pid: number; parent: { pid: number; command: string } | null } | null> {
|
||||
const evidence = new SeatIdentityStore(this.db).getProcessEvidence(session.nodeId);
|
||||
if (!evidence || evidence.sessionName !== session.sessionName) return null;
|
||||
const observedAt = Date.parse(evidence.observedAt) / 1000;
|
||||
const pids = [evidence.panePid, evidence.pid]
|
||||
.filter((pid): pid is number => typeof pid === "number" && pid > 0);
|
||||
if (!pids.length || Number.isNaN(observedAt)) return null;
|
||||
const rows = await processes();
|
||||
if (!rows) return null;
|
||||
for (const pid of pids) {
|
||||
const row = rows.find((r) => r.pid === pid);
|
||||
const started = row ? lstartToMinTs(row.startedAt) : undefined;
|
||||
if (!row || started === undefined || started > observedAt) continue;
|
||||
const parent = rows.find((r) => r.pid === row.ppid);
|
||||
return { pid, parent: parent ? { pid: parent.pid, command: parent.command } : null };
|
||||
}
|
||||
return null;
|
||||
/** The seat's last observed process, as a conservative hint that it still runs, not proof of identity: the last pid
|
||||
* evidence recorded for this very session (kept with its own time, so a later poll that couldn't see the seat doesn't
|
||||
* erase it), and a process with that pid whose start isn't observably later than that observation. Start times are
|
||||
* whole seconds, so a pid reused within the observation's second still passes. An unreadable table or record is no
|
||||
* evidence. */
|
||||
export async function recordedProcessStillRunning(
|
||||
db: Database.Database,
|
||||
session: { nodeId: string; sessionName: string },
|
||||
processes: () => Promise<ProcessTableRow[] | null>,
|
||||
): Promise<{ pid: number; parent: { pid: number; command: string } | null } | null> {
|
||||
const evidence = new SeatIdentityStore(db).getProcessEvidence(session.nodeId);
|
||||
if (!evidence || evidence.sessionName !== session.sessionName) return null;
|
||||
const observedAt = Date.parse(evidence.observedAt) / 1000;
|
||||
const pids = [evidence.panePid, evidence.pid]
|
||||
.filter((pid): pid is number => typeof pid === "number" && pid > 0);
|
||||
if (!pids.length || Number.isNaN(observedAt)) return null;
|
||||
const rows = await processes();
|
||||
if (!rows) return null;
|
||||
for (const pid of pids) {
|
||||
const row = rows.find((r) => r.pid === pid);
|
||||
const started = row ? lstartToMinTs(row.startedAt) : undefined;
|
||||
if (!row || started === undefined || started > observedAt) continue;
|
||||
const parent = rows.find((r) => r.pid === row.ppid);
|
||||
return { pid, parent: parent ? { pid: parent.pid, command: parent.command } : null };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -17,25 +17,21 @@ import nodePath from "node:path";
|
||||
import { lstatSync } from "node:fs";
|
||||
import type Database from "better-sqlite3";
|
||||
import {
|
||||
MANAGED_BLOCK_START,
|
||||
MANAGED_BLOCK_END,
|
||||
DEFAULT_CLAUDE_MANAGED_BLOCK_FILE,
|
||||
findManagedBlocks,
|
||||
mergeManagedBlock,
|
||||
stripManagedBlocks,
|
||||
type ManagedBlockMergeFsOps,
|
||||
isPerSeatRoleGuidance,
|
||||
isPerSeatRoleStartupFile,
|
||||
} from "./managed-blocks.js";
|
||||
import { decideManagedBlock, recordManagedBlockWrite, type ManagedBlockRecords } from "./managed-block-records.js";
|
||||
import { decideManagedBlock, pruneKeptManagedBlock, recordManagedBlockWrite, type ManagedBlockRecords } from "./managed-block-records.js";
|
||||
import { resolveConcreteHint } from "./runtime-adapter.js";
|
||||
import { reanchorBuiltinStartupFile, reanchorShippedProjectionEntry, withContextEntryFloor } from "./builtin-startup-files.js";
|
||||
import type { NodeStartupSnapshot } from "./types.js";
|
||||
import { SEAT_SKILLS_BOOTSTRAP_BLOCK_ID } from "./seat-skill-storage.js";
|
||||
import { guidanceFileIsLink } from "./footprint-choice.js";
|
||||
|
||||
const LEGACY_BLOCK_START = (id: string) => `<!-- BEGIN RIGGED MANAGED BLOCK: ${id} -->`;
|
||||
const LEGACY_BLOCK_END = (id: string) => `<!-- END RIGGED MANAGED BLOCK: ${id} -->`;
|
||||
|
||||
|
||||
export interface GuidanceItem {
|
||||
blockId: string;
|
||||
@@ -97,15 +93,10 @@ export function recordedGuidanceFile(db: Pick<Database.Database, "prepare">, nod
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/** Whether guidance text holds this id's block as it is written: a BEGIN marker followed later by its END, in the
|
||||
* current or legacy form. For reporting an installed file; restore's own `blockState` is unchanged. */
|
||||
/** Whether guidance text holds this id's block as it is written: a BEGIN marker followed by its END, in the current or
|
||||
* legacy form, read as the writer reads it (`findManagedBlocks`). For reporting an installed file. */
|
||||
export function hasCompleteGuidanceBlock(content: string, blockId: string): boolean {
|
||||
const ordered = (start: string, end: string): boolean => {
|
||||
const begin = content.indexOf(start);
|
||||
return begin >= 0 && content.indexOf(end, begin + start.length) >= 0;
|
||||
};
|
||||
return ordered(MANAGED_BLOCK_START(blockId), MANAGED_BLOCK_END(blockId))
|
||||
|| ordered(LEGACY_BLOCK_START(blockId), LEGACY_BLOCK_END(blockId));
|
||||
return findManagedBlocks(content, blockId).blocks.length > 0;
|
||||
}
|
||||
|
||||
/** The guidance file a seat's managed blocks live in, and the blocks it should hold on restore. */
|
||||
@@ -199,14 +190,13 @@ export function applySeatSkillsLineRule(guidance: SeatGuidance, answeredYes: boo
|
||||
|
||||
type BlockState = "complete" | "absent" | "incomplete";
|
||||
|
||||
/** A block counts only with both its markers (current or legacy form). A lone marker is incomplete:
|
||||
* writing next to it is unsafe, because cleanup strips from a BEGIN to the next END. */
|
||||
/** A block counts only with both its markers (current or legacy form), read as the writer reads them. A lone marker is
|
||||
* incomplete: writing next to it is unsafe, because cleanup strips from a BEGIN to the next END, and it would add a
|
||||
* second copy beside one that can't be read. */
|
||||
function blockState(content: string, blockId: string): BlockState {
|
||||
const current = [content.includes(MANAGED_BLOCK_START(blockId)), content.includes(MANAGED_BLOCK_END(blockId))];
|
||||
const legacy = [content.includes(LEGACY_BLOCK_START(blockId)), content.includes(LEGACY_BLOCK_END(blockId))];
|
||||
if ((current[0] && current[1]) || (legacy[0] && legacy[1])) return "complete";
|
||||
if (current[0] || current[1] || legacy[0] || legacy[1]) return "incomplete";
|
||||
return "absent";
|
||||
const found = findManagedBlocks(content, blockId);
|
||||
if (found.blocks.length > 0) return "complete";
|
||||
return found.unpaired > 0 ? "incomplete" : "absent";
|
||||
}
|
||||
|
||||
function blockStates(guidance: SeatGuidance, fs: Pick<ManagedBlockMergeFsOps, "exists" | "readFile">): Map<string, BlockState> {
|
||||
@@ -228,11 +218,13 @@ export interface GuidanceRepair {
|
||||
refreshed: string[];
|
||||
/** #2456: blocks left as they are although they differ from their source, each named with the reason
|
||||
* (`guidance_block_edited`, `guidance_block_may_be_stale` or `guidance_block_record_unreadable`). The seat runs them as
|
||||
* they are. A block written whose record wasn't saved is named here too (`guidance_block_record_not_saved`). */
|
||||
* they are. A block written whose record wasn't saved is named here too (`guidance_block_record_not_saved`), and so are
|
||||
* a kept block's removed repeats (`guidance_block_copies_removed`). */
|
||||
kept: string[];
|
||||
}
|
||||
|
||||
const KEPT_GUIDANCE_PREFIXES = ["guidance_block_edited: ", "guidance_block_may_be_stale: ", "guidance_block_record_unreadable: "];
|
||||
const KEPT_GUIDANCE_PREFIXES = ["guidance_block_edited: ", "guidance_block_may_be_stale: ", "guidance_block_record_unreadable: ",
|
||||
"guidance_block_markers_damaged: "];
|
||||
|
||||
/** #2456: the one line a seat is told when a managed guidance block was left as it is (edited by hand, or possibly stale),
|
||||
* naming each one, or undefined when none was. */
|
||||
@@ -261,7 +253,8 @@ export function appendIsCleanupSafe(content: string, blocks: Array<{ blockId: st
|
||||
|
||||
/** Put back the missing managed guidance blocks. With `records` (#2456), a block already there that OpenRig wrote is
|
||||
* refreshed from its changed source, and one edited since, or with no record, is kept and named (`decideManagedBlock`).
|
||||
* Without them a block already there is left as it is, as before. Any other text in the file is left exactly as it is,
|
||||
* Without them a block already there keeps its text. Either way a block left in place loses its exact repeats, and
|
||||
* OpenRig's own copies beside an edited one, and rewrapped markers go back to OpenRig's form (`pruneKeptManagedBlock`). Any other text in the file is left exactly as it is,
|
||||
* and nothing is written unless cleanup would later remove exactly what was written. Never throws: a failure becomes a
|
||||
* reported gap. */
|
||||
export function restoreMissingGuidance(guidance: SeatGuidance, fs: ManagedBlockMergeFsOps, records?: ManagedBlockRecords): GuidanceRepair {
|
||||
@@ -279,17 +272,27 @@ export function restoreMissingGuidance(guidance: SeatGuidance, fs: ManagedBlockM
|
||||
for (const item of guidance.items) {
|
||||
const state = states.get(item.blockId);
|
||||
if (state === "complete") {
|
||||
if (!records || item.unresolved) continue;
|
||||
if (item.unresolved) continue;
|
||||
let source: string;
|
||||
try {
|
||||
if (!fs.exists(item.sourcePath)) continue;
|
||||
source = fs.readFile(item.sourcePath);
|
||||
} catch { continue; }
|
||||
// Without records the block's text stays as it is; only its exact repeats and rewrapped markers are tidied.
|
||||
if (!records) {
|
||||
pruneKeptManagedBlock(fs, guidance.targetPath, item.blockId, source, undefined, repair.kept);
|
||||
continue;
|
||||
}
|
||||
// #2456: the same decision every adapter's guidance write makes; an exact resume and a handover keep an unrecorded block.
|
||||
const decision = decideManagedBlock(existing ?? "", item.blockId, source, records, guidance.targetPath, { unrecorded: "keep", sourcePath: item.sourcePath });
|
||||
if (decision.note) repair.kept.push(decision.note);
|
||||
else if (decision.standing === "current") recordManagedBlockWrite(records, guidance.targetPath, item.blockId, source, repair.kept);
|
||||
else if (decision.write) toWrite.push({ blockId: item.blockId, content: source, refresh: true });
|
||||
if (decision.write && decision.standing !== "current") {
|
||||
toWrite.push({ blockId: item.blockId, content: source, refresh: true });
|
||||
continue;
|
||||
}
|
||||
if (decision.standing === "current") recordManagedBlockWrite(records, guidance.targetPath, item.blockId, source, repair.kept);
|
||||
// A block left as it is (current, edited or unrecorded) keeps one copy of each distinct text, in OpenRig's markers.
|
||||
pruneKeptManagedBlock(fs, guidance.targetPath, item.blockId, source, records, repair.kept);
|
||||
continue;
|
||||
}
|
||||
if (state === "incomplete") {
|
||||
@@ -313,6 +316,9 @@ export function restoreMissingGuidance(guidance: SeatGuidance, fs: ManagedBlockM
|
||||
if (toWrite.length === 0) return repair;
|
||||
// A file that doesn't exist yet holds no text to lose. An existing one is written only if the
|
||||
// next rig down would remove exactly what is added.
|
||||
// Read again: removing a kept block's repeats above may have changed the file.
|
||||
try { existing = fs.exists(guidance.targetPath) ? fs.readFile(guidance.targetPath) : null; }
|
||||
catch (err) { repair.gaps.push(`could not read ${guidance.targetPath}: ${(err as Error).message}`); return repair; }
|
||||
if (existing !== null && !appendIsCleanupSafe(existing, toWrite)) {
|
||||
for (const block of toWrite) {
|
||||
repair.gaps.push(`${block.blockId}: not written, because ${guidance.targetPath} has a managed-block marker that the next rig down would strip together with other text, so adding blocks there could cost that text`);
|
||||
@@ -321,7 +327,7 @@ export function restoreMissingGuidance(guidance: SeatGuidance, fs: ManagedBlockM
|
||||
}
|
||||
for (const block of toWrite) {
|
||||
try {
|
||||
mergeManagedBlock(fs, guidance.targetPath, block.blockId, block.content);
|
||||
if (!mergeManagedBlock(fs, guidance.targetPath, block.blockId, block.content, { warnings: repair.gaps })) continue;
|
||||
if (records) recordManagedBlockWrite(records, guidance.targetPath, block.blockId, block.content, repair.kept);
|
||||
(block.refresh ? repair.refreshed : repair.restored).push(block.blockId);
|
||||
} catch (err) {
|
||||
|
||||
@@ -331,8 +331,15 @@ export class RunnerPermissions {
|
||||
const changed = new Promise<void>((resolve) => { release = resolve; });
|
||||
let liveId = askId; // after a route or handoff, the colleague's row is where the answer lands
|
||||
const unsubscribe = this.deps.eventBus.subscribe((event) => {
|
||||
const changedId = (event as { type?: string; qitemId?: string }).type === "queue.updated" ? (event as { qitemId?: string }).qitemId : undefined;
|
||||
if (changedId === askId || changedId === liveId) release();
|
||||
const { type, qitemId } = event as { type?: string; qitemId?: string };
|
||||
// A handoff while this read waits (the person's answer going to the receiving agent) moves the request to a new
|
||||
// row: follow it, so the decision recorded there wakes this read rather than its next one.
|
||||
if (type === "queue.handed_off" && (qitemId === askId || qitemId === liveId)) {
|
||||
const asked = this.deps.queueRepo.getById(askId);
|
||||
liveId = (asked ? this.liveEnd(asked)?.qitemId : undefined) ?? liveId;
|
||||
return;
|
||||
}
|
||||
if (type === "queue.updated" && (qitemId === askId || qitemId === liveId)) release();
|
||||
});
|
||||
try {
|
||||
const now = this.withWords(askId, this.read(seat, askId));
|
||||
|
||||
@@ -12,6 +12,7 @@ import { SessionAddressConflict, type SessionRegistry } from "./session-registry
|
||||
import type { DiscoveryRepository } from "./discovery-repository.js";
|
||||
import type { EventBus } from "./event-bus.js";
|
||||
import type { TmuxAdapter } from "../adapters/tmux.js";
|
||||
import { shellQuote } from "../adapters/shell-quote.js";
|
||||
import type { TmuxOptionDefaultsApplier } from "./tmux-option-defaults.js";
|
||||
import { SeatStatusService, type SeatStatus, type SeatStatusResult } from "./seat-status-service.js";
|
||||
import { SeatHandoverPlanner, parseHandoverSource, SEAT_HANDOVER_SOURCE_CAPABILITIES, type SeatHandoverPlan, type SeatHandoverSource } from "./seat-handover-planner.js";
|
||||
@@ -118,7 +119,9 @@ export type SeatHandoverResult =
|
||||
| { ok: false; code: "missing_reason" | "invalid_source" | "successor_creation_not_implemented" | "source_not_supported" | "resume_token_unavailable" | "fork_source_not_found"; message: string; guidance: string }
|
||||
| { ok: false; code: "address_conflict" | "current_occupant_required" | "discovered_not_active" | "successor_tmux_absent" | "successor_already_managed" | "successor_is_current" | "runtime_mismatch"; message: string; guidance: string }
|
||||
| { ok: false; code: "discovered_not_found"; message: string; guidance: string }
|
||||
| { ok: false; code: "tmux_probe_failed" | "handover_commit_failed" | "successor_create_failed" | "context_delivery_failed"; message: string; guidance: string }
|
||||
| { ok: false; code: "tmux_probe_failed" | "handover_commit_failed" | "context_delivery_failed"; message: string; guidance: string }
|
||||
// #2985: once the successor was launched in the respawned pane, the failure says so and names the pane.
|
||||
| { ok: false; code: "successor_create_failed"; message: string; guidance: string; replacementStarted?: true; successorPane?: string }
|
||||
| Extract<SeatStatusResult, { ok: false }>;
|
||||
|
||||
type ClaudeRestorePacketObservation =
|
||||
@@ -588,6 +591,24 @@ export class SeatHandoverService {
|
||||
},
|
||||
});
|
||||
if (!launch.ok) {
|
||||
// #2985: the harness was launched into the respawned pane and only readiness failed. The previous occupant is
|
||||
// already ended and the successor may come up (or already be working) there, but the handover never commits,
|
||||
// so its generation is never registered. Retrying the handover would end it, so the guidance says what may be
|
||||
// in the pane instead of "retry". It claims only what holds for every runtime (a Codex successor's hooks are
|
||||
// not held back by generation_uncommitted, which is Claude-only).
|
||||
if (launch.step === "start_agent" && launch.replacementStarted && launch.successorLaunched) {
|
||||
const pane = launch.tmuxPane ?? "unknown";
|
||||
const session = latestSession.session_name;
|
||||
const quoted = shellQuote(session);
|
||||
return {
|
||||
ok: false,
|
||||
code: "successor_create_failed",
|
||||
message: `Handover failed at step "${launch.step}": ${launch.message}`,
|
||||
replacementStarted: true,
|
||||
...(launch.tmuxPane ? { successorPane: launch.tmuxPane } : {}),
|
||||
guidance: `The handover was not committed: the seat's registry binding is unchanged and OpenRig has not registered the successor as the seat's occupant. The previous occupant was already ended, and a successor was launched in pane "${pane}" of ${session}; it may still be starting there or already working. Look at the pane before acting: rig capture ${quoted}. Another handover, or rig seat launch ${quoted} --fresh --stop --reason "<why>", ends that successor if it goes ahead. Shell-looking output alone does not confirm that the successor has stopped; check the launch and process state before another handover.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
code: "successor_create_failed",
|
||||
@@ -833,12 +854,12 @@ export class SeatHandoverService {
|
||||
}
|
||||
// #981: a composer-launched successor respawns into the seat's own pane, whose discovery row the
|
||||
// previous handover claimed for this same node, and a rescan keeps that claim (#237). That claim is
|
||||
// this seat's own, so commit re-claims it. A row claimed by any other node, or a discovered-source
|
||||
// successor, still needs an active, unclaimed record.
|
||||
// this seat's own, so commit re-claims it. #2861: a deleted owner or a terminal owner in an archived
|
||||
// earlier rig can also leave that pane claimed. Other owners and discovered-source successors still refuse.
|
||||
const ownPaneClaim = input.reportedSource.mode !== "discovered"
|
||||
&& discovered.status === "claimed"
|
||||
&& discovered.claimedNodeId === input.node.id
|
||||
&& discovered.tmuxSession === input.latestSession.session_name;
|
||||
&& discovered.tmuxSession === input.latestSession.session_name
|
||||
&& (discovered.claimedNodeId === input.node.id || this.isStalePaneClaimOwner(discovered.claimedNodeId));
|
||||
if (discovered.status !== "active" && !ownPaneClaim) {
|
||||
return fail({
|
||||
ok: false,
|
||||
@@ -1256,6 +1277,15 @@ export class SeatHandoverService {
|
||||
return findOtherSessionOwner(this.db, tmuxSession, targetNodeId, { ignoreArchived });
|
||||
}
|
||||
|
||||
private isStalePaneClaimOwner(nodeId: string | null): boolean {
|
||||
if (nodeId === null) return true; // ON DELETE SET NULL retains the claim after its owner is removed.
|
||||
return this.db.prepare(`
|
||||
SELECT 1 FROM nodes n JOIN rigs r ON r.id = n.rig_id
|
||||
WHERE n.id = ? AND r.archived_at IS NOT NULL
|
||||
AND (SELECT status FROM sessions WHERE node_id = n.id ORDER BY id DESC LIMIT 1) IN ('superseded', 'detached', 'exited')
|
||||
`).pluck().get(nodeId) === 1;
|
||||
}
|
||||
|
||||
private commit(input: {
|
||||
seatRef: string;
|
||||
status: SeatStatus;
|
||||
@@ -1277,6 +1307,15 @@ export class SeatHandoverService {
|
||||
}): SeatHandoverResult {
|
||||
const handoverAt = this.now().toISOString();
|
||||
const tx = this.db.transaction(() => {
|
||||
// The presence probe awaited after validation. Reclaim only the same still-stale foreign claim,
|
||||
// inside the transaction that assigns it, so a resumed owner or a new claimant cannot be overwritten.
|
||||
if (input.discovered.status === "claimed" && input.discovered.claimedNodeId !== input.node.id) {
|
||||
const current = this.discoveryRepo.getDiscoveredSession(input.discovered.id);
|
||||
if (current?.status !== "claimed" || current.claimedNodeId !== input.discovered.claimedNodeId
|
||||
|| !this.isStalePaneClaimOwner(current.claimedNodeId)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const rows = this.db.prepare(
|
||||
"SELECT id FROM sessions WHERE node_id = ? AND status NOT IN ('superseded', 'detached', 'exited') ORDER BY id"
|
||||
).all(input.node.id) as Array<{ id: string }>;
|
||||
@@ -1392,7 +1431,16 @@ export class SeatHandoverService {
|
||||
|
||||
let committed: { newSessionId: string; previousSessionIdsSuperseded: string[]; event: PersistedEvent; provenanceRecordWritten: boolean };
|
||||
try {
|
||||
committed = tx();
|
||||
const outcome = tx();
|
||||
if (outcome === null) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "discovered_not_active",
|
||||
message: `Discovery record "${input.discovered.id}" no longer has a stale owner claim.`,
|
||||
guidance: "Inspect the current discovery owner and successor state before retrying.",
|
||||
};
|
||||
}
|
||||
committed = outcome;
|
||||
// S19 ruling 01530 — the SOLE narrow call: after the commit lands, the activity
|
||||
// oracle sees the swap as its own event, keyed by the durable node id, identified
|
||||
// by the successor tenure (never the retiree's). In-memory, post-commit, optional.
|
||||
|
||||
@@ -141,6 +141,22 @@ export class SeatIdentityReconciler {
|
||||
`).all() as RunningSeatRow[];
|
||||
}
|
||||
|
||||
/** Nodes whose latest session `rig unclaim` released (detached, origin claimed). Unclaim leaves the process running,
|
||||
* so the node's last recorded process stays on record: a fresh launch reads it before reopening a tmux server, and
|
||||
* must not start a second occupant beside a process that still runs. An older schema without `origin` keeps none. */
|
||||
private releasedSeats(): string[] {
|
||||
try {
|
||||
return (this.db.prepare(`
|
||||
SELECT n.id as node_id FROM nodes n
|
||||
JOIN sessions s ON s.node_id = n.id
|
||||
AND s.id = (SELECT s2.id FROM sessions s2 WHERE s2.node_id = n.id ORDER BY s2.id DESC LIMIT 1)
|
||||
WHERE s.status = 'detached' AND s.origin = 'claimed'
|
||||
`).all() as Array<{ node_id: string }>).map((r) => r.node_id);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Reconcile every running tmux-bound seat once and persist the verdicts. */
|
||||
async reconcileAll(): Promise<void> {
|
||||
// Skip ticks while actual reads are pending; never release on a deadline
|
||||
@@ -156,8 +172,8 @@ export class SeatIdentityReconciler {
|
||||
|
||||
private async reconcileSweep(generation: number): Promise<void> {
|
||||
const seats = this.runningSeats();
|
||||
// Prune verdicts for nodes no longer running (keep the table bounded).
|
||||
this.store.pruneExcept(seats.map((s) => s.node_id));
|
||||
// Prune verdicts for nodes no longer running (keep the table bounded), except a released seat's.
|
||||
this.store.pruneExcept([...seats.map((s) => s.node_id), ...this.releasedSeats()]);
|
||||
if (seats.length === 0) return;
|
||||
|
||||
const observedAt = this.now().toISOString();
|
||||
|
||||
@@ -55,6 +55,8 @@ import { sessionCommunityHarnessSelect } from "./session-registry.js";
|
||||
import { seatLaunchAdditionsReport } from "./seat-launch-additions.js";
|
||||
import { seatSkillPaths } from "./seat-skill-storage.js";
|
||||
import { resolveSeatKind, seatProcessOwner } from "./seat-kinds.js";
|
||||
import { recordedProcessStillRunning } from "./reconciler.js";
|
||||
import { readProcessTable, type ProcessTableRow } from "./process-table.js";
|
||||
|
||||
/**
|
||||
* S5 (OPR.0.5.4.7) — the seat-lifecycle verb surface: set-model, single-seat stop,
|
||||
@@ -72,32 +74,30 @@ import { resolveSeatKind, seatProcessOwner } from "./seat-kinds.js";
|
||||
const SEAT_LOOKUP_GUIDANCE = "List seats with: rig ps --nodes";
|
||||
|
||||
/**
|
||||
* The supported routes for a session registered as adopted (origin=claimed). `rig unclaim` releases
|
||||
* it and leaves its process running, so ending that process in its own terminal ends only this seat.
|
||||
* `rig down` ends it with the rest of its rig, so it is the route only when the rig has one seat;
|
||||
* otherwise it is named for what it ends. Only the guidance names them; the refusal stands.
|
||||
* `seats` is the rig's node count when known. A seat handed over before 0.7.9 gets
|
||||
* earlierHandoverRefusal instead. A seat joined with `rig attach` as an external CLI has no tmux
|
||||
* session that `rig down` stops.
|
||||
* The supported route for a session registered as adopted (origin=claimed): `rig unclaim` releases
|
||||
* it and leaves its process running, so ending that process and its tmux session ends only this seat. Closing only
|
||||
* a viewing window detaches from the tmux session and leaves it running.
|
||||
* `rig down` stops only a session carrying the seat's OpenRig marker, which OpenRig sets on the sessions
|
||||
* it starts and an adopted, bound or self-attached session lacks, so it isn't a route here and the
|
||||
* guidance says why. Only the guidance names the route; the refusal stands. A seat handed over before
|
||||
* 0.7.9 gets earlierHandoverRefusal instead. A seat joined with `rig attach` as an external CLI has no
|
||||
* tmux session that `rig down` stops.
|
||||
*/
|
||||
export function claimedSessionRoutes(rigName: string, sessionName: string, seats: number | null, externalCli = false): string {
|
||||
export function claimedSessionRoutes(rigName: string, sessionName: string, externalCli = false): string {
|
||||
const session = shellQuote(sessionName);
|
||||
const release = `To release it from OpenRig and leave its process running, run rig unclaim ${session}.`;
|
||||
if (externalCli) return `This seat was joined with rig attach as an external CLI, so rig down leaves it running. ${release} End the process in its own terminal.`;
|
||||
if (seats === 1) return `To end the session, run ${rigDownRoute(rigName)}. ${release}`;
|
||||
return `To end only this session, run rig unclaim ${session} (it releases the session from OpenRig and leaves its process running), `
|
||||
+ `then end that process in its own terminal. ${rigDownScope(rigName, seats)}`;
|
||||
return `To end this session, run rig unclaim ${session} (it releases the session from OpenRig and leaves its process running), `
|
||||
+ `then end that process and the underlying tmux session, including any remaining shell, so no session keeps the seat's name. `
|
||||
+ `${VIEWER_LEAVES_IT} ${rigDownLeavesIt(rigName)}`;
|
||||
}
|
||||
|
||||
/** `rig down <rig>` as the route for a one-seat rig, and how the rig comes back. */
|
||||
function rigDownRoute(rigName: string): string {
|
||||
const rig = shellQuote(rigName);
|
||||
return `rig down ${rig} (this rig has one seat, so it stops only this session; rig up ${rig} --existing brings the rig back)`;
|
||||
}
|
||||
/** Closing a viewer detaches; the tmux session, its shell and the seat's name stay. */
|
||||
const VIEWER_LEAVES_IT = "Closing only its viewing window leaves the tmux session running.";
|
||||
|
||||
/** What `rig down <rig>` ends in a rig with more than one seat, or an unknown count. */
|
||||
function rigDownScope(rigName: string, seats: number | null): string {
|
||||
return `rig down ${shellQuote(rigName)} ends ${seats !== null && seats > 1 ? `all ${seats} seats` : "every seat"} in this rig.`;
|
||||
/** Why `rig down <rig>` isn't the route for an adopted session. */
|
||||
function rigDownLeavesIt(rigName: string): string {
|
||||
return `rig down ${shellQuote(rigName)} leaves a session OpenRig didn't start running.`;
|
||||
}
|
||||
|
||||
/** Terminal session statuses — rows the clean verb must NOT touch (they already
|
||||
@@ -112,6 +112,8 @@ export interface SeatLifecycleDeps {
|
||||
eventBus: EventBus;
|
||||
tmuxAdapter: TmuxAdapter;
|
||||
listProcesses?: NativeProcessLister;
|
||||
/** The OS process table, read before reopening a released seat's tmux server; injectable for tests. */
|
||||
readProcesses?: () => Promise<ProcessTableRow[]>;
|
||||
nodeLauncher?: NodeLauncher;
|
||||
startupOrchestrator?: StartupOrchestrator;
|
||||
runtimeAdapters?: Record<string, RuntimeAdapter>;
|
||||
@@ -340,6 +342,7 @@ export class SeatLifecycleService {
|
||||
private readonly eventBus: EventBus;
|
||||
private readonly tmuxAdapter: TmuxAdapter;
|
||||
private readonly listProcesses?: NativeProcessLister;
|
||||
private readonly readProcesses: () => Promise<ProcessTableRow[]>;
|
||||
private readonly nodeLauncher: NodeLauncher | null;
|
||||
private readonly startupOrchestrator: StartupOrchestrator | null;
|
||||
private readonly runtimeAdapters: Record<string, RuntimeAdapter>;
|
||||
@@ -362,6 +365,7 @@ export class SeatLifecycleService {
|
||||
this.eventBus = deps.eventBus;
|
||||
this.tmuxAdapter = deps.tmuxAdapter;
|
||||
this.listProcesses = deps.listProcesses;
|
||||
this.readProcesses = deps.readProcesses ?? readProcessTable;
|
||||
this.nodeLauncher = deps.nodeLauncher ?? null;
|
||||
this.startupOrchestrator = deps.startupOrchestrator ?? null;
|
||||
this.runtimeAdapters = deps.runtimeAdapters ?? {};
|
||||
@@ -1388,6 +1392,8 @@ export class SeatLifecycleService {
|
||||
ok: false,
|
||||
code: "unmanaged_session_collision",
|
||||
message: `Canonical tmux session "${canonicalSessionName}" exists but is not owned by this seat's current managed rows; refusing to overwrite it.`,
|
||||
guidance: "If you released this session with rig unclaim, end that underlying tmux session, including any remaining shell, "
|
||||
+ "then launch again. Closing only its viewing window doesn't end the session.",
|
||||
};
|
||||
}
|
||||
if (!stop) {
|
||||
@@ -1427,13 +1433,14 @@ export class SeatLifecycleService {
|
||||
const rows = this.nonTerminalSessions(nodeId);
|
||||
const claimed = rows.find(row => row.origin === "claimed");
|
||||
if (claimed) {
|
||||
const seats = this.rigRepo.getRig(seat.rigId)?.nodes.length ?? null;
|
||||
const externalCli = seatProcessOwner(resolveSeatKind(this.sessionRegistry.getBindingForNode(nodeId)?.attachmentType)) === "external";
|
||||
return {
|
||||
ok: false, code: "claimed_session",
|
||||
message: `Seat "${seatRef}" has an adopted/operator-owned occupant; fresh launch refuses even with --stop.`,
|
||||
guidance: `Release it with rig unclaim ${shellQuote(claimed.session_name)} (its process keeps running), end that process yourself, then launch fresh.`
|
||||
+ (externalCli ? "" : seats === 1 ? ` Or end it with ${rigDownRoute(seat.rigName)}.` : ` ${rigDownScope(seat.rigName, seats)}`),
|
||||
guidance: `Release it with rig unclaim ${shellQuote(claimed.session_name)} (its process keeps running), `
|
||||
+ (externalCli
|
||||
? "end that process and close its terminal, then launch fresh."
|
||||
: `end that process and the underlying tmux session, including any remaining shell, then launch fresh. ${VIEWER_LEAVES_IT} ${rigDownLeavesIt(seat.rigName)}`),
|
||||
};
|
||||
}
|
||||
const binding = this.sessionRegistry.getBindingForNode(nodeId);
|
||||
@@ -1574,17 +1581,34 @@ export class SeatLifecycleService {
|
||||
// probe sees unavailable transport instead of positive absence. Reopen an
|
||||
// empty server only when the persisted state proves this seat's managed
|
||||
// occupant was deliberately stopped and no other managed row remains live.
|
||||
// `rig unclaim` is as deliberate for an adopted session: it marks the row
|
||||
// detached and clears the binding, and the person ends the process and its
|
||||
// tmux session, which can be the server's last session too.
|
||||
// The probe below still decides absence; a recreated session or failed
|
||||
// transport remains a refusal.
|
||||
if ("code" in canonicalProbe) {
|
||||
const latest = this.latestSession(node.id);
|
||||
const hasCurrentBinding = this.sessionRegistry.getBindingForNode(node.id) !== null;
|
||||
const released = latest?.status === "detached" && latest.origin === "claimed";
|
||||
const stoppedManagedOccupant = latest !== null
|
||||
&& latest.session_name === canonicalSessionName
|
||||
&& latest.status === "exited"
|
||||
&& latest.origin !== "claimed"
|
||||
&& ((latest.status === "exited" && latest.origin !== "claimed") || released)
|
||||
&& !hasCurrentBinding
|
||||
&& this.nonTerminalSessions(node.id).length === 0;
|
||||
// Unclaim doesn't end the process. While the released session's recorded process still runs, its server may
|
||||
// only have lost its socket: a server started there would read the session absent and launch a second occupant
|
||||
// beside it. So keep the refusal, as the reconciler keeps such a session (the same evidence).
|
||||
const stillRuns = stoppedManagedOccupant && released
|
||||
? await recordedProcessStillRunning(this.db, { nodeId: node.id, sessionName: canonicalSessionName },
|
||||
() => this.readProcesses().catch(() => null))
|
||||
: null;
|
||||
if (stillRuns) {
|
||||
return {
|
||||
...canonicalProbe,
|
||||
guidance: `This seat's released session last ran as process ${stillRuns.pid}, which is still running, so it may be `
|
||||
+ "on a tmux server this daemon can't reach. End that process, then launch again.",
|
||||
};
|
||||
}
|
||||
if (stoppedManagedOccupant) {
|
||||
try {
|
||||
const restored = await this.tmuxAdapter.startServer();
|
||||
@@ -2292,7 +2316,7 @@ export class SeatLifecycleService {
|
||||
|
||||
/** The claimed-session routes for this seat's rig (see claimedSessionRoutes). */
|
||||
private claimedRoutes(seat: SeatDescriptor, sessionName: string): string {
|
||||
return claimedSessionRoutes(seat.rigName, sessionName, this.rigRepo.getRig(seat.rigId)?.nodes.length ?? null,
|
||||
return claimedSessionRoutes(seat.rigName, sessionName,
|
||||
seatProcessOwner(resolveSeatKind(this.sessionRegistry.getBindingForNode(seat.nodeId)?.attachmentType)) === "external");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type Database from "better-sqlite3";
|
||||
import type { PaneCapture, TmuxAdapter } from "../adapters/tmux.js";
|
||||
import { communityHarnessPaneActivity, classifyPaneActivity, codexWorkingElapsed, isCodexLiveWorkingFrame, readSeatBeforePaste, type PaneActivityClassification } from "./session-transport.js";
|
||||
import { communityHarnessPaneActivity, classifyPaneActivity, codexWorkingElapsed, isCodexLiveWorkingFrame, namesAnotherClientVersion, readSeatBeforePaste, type PaneActivityClassification } from "./session-transport.js";
|
||||
import { communityHarnessOf } from "./built-in-runtimes.js";
|
||||
import { detectCodexLimitBanner, type CodexLimitBanner } from "./provider/codex-limit-banner.js";
|
||||
import { IDLE_FULL_CAPTURE_MS, windowSnapshot } from "./transcript-rotation.js";
|
||||
@@ -129,6 +129,9 @@ export class SeatStructuralActivityService {
|
||||
return null;
|
||||
}
|
||||
const c = communityHarnessPaneActivity(classifyPaneActivity(content), communityHarness, content);
|
||||
// Redraws from an unrecorded Codex layout cannot establish work or completed idle.
|
||||
// Keep positive work and attention visible; native hooks are resolved separately by the activity fold.
|
||||
const unrecognizedVersion = runtime === "codex" && c.state !== "attention" && c.state !== "agent_active" && namesAnotherClientVersion(content, "codex");
|
||||
const at = observedAt ?? this.now();
|
||||
const latest = this.latestBySession.get(sessionName);
|
||||
// A counter or proof read from another pane or process says nothing about this one, so compare only within one.
|
||||
@@ -146,8 +149,8 @@ export class SeatStructuralActivityService {
|
||||
...(advanced ? { previousCaptureStartedAt: previous!.codexWorkingClock!.captureStartedAt } : {}) }
|
||||
: priorProof;
|
||||
const obs: StructuralObservation = {
|
||||
state: c.state,
|
||||
reason: c.reason,
|
||||
state: unrecognizedVersion ? "unknown" : c.state,
|
||||
reason: unrecognizedVersion ? "unrecognized_client_version" : c.reason,
|
||||
evidence: c.evidence,
|
||||
observedAt: at.toISOString(),
|
||||
...(captureStartedAt ? { captureStartedAt: captureStartedAt.toISOString() } : {}),
|
||||
@@ -167,7 +170,7 @@ export class SeatStructuralActivityService {
|
||||
// Null/failed, blank and unrecognized captures cannot establish recovery.
|
||||
if (runtime === "codex" && this.bannerEmitter) {
|
||||
const banner = detectCodexLimitBanner(content);
|
||||
if (banner || (content.trim().length > 0 && c.state !== "unknown")) {
|
||||
if (banner || (content.trim().length > 0 && obs.state !== "unknown")) {
|
||||
this.bannerEmitter(sessionName, banner, at);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,14 +124,25 @@ export interface CurrentSessionAddress {
|
||||
* Physical names win over derived names. Archived/historical bindings cannot
|
||||
* advertise a new alias, and two current matches never pick an arbitrary seat. */
|
||||
export function resolveCurrentSessionAddress(db: Database.Database, address: string): CurrentSessionAddress | null {
|
||||
const rows = db.prepare(`SELECT r.*, n.id AS nodeId, n.logical_id AS logicalId,
|
||||
const current = `SELECT r.name, r.archived_at, n.id AS nodeId, n.logical_id AS logicalId,
|
||||
s.id AS sessionId, s.session_name AS sessionName, s.status,
|
||||
replace(n.logical_id, '.', '-') || '@' || r.name AS senderAddress
|
||||
FROM nodes n JOIN rigs r ON r.id=n.rig_id
|
||||
JOIN bindings b ON b.node_id=n.id AND b.tmux_session IS NOT NULL
|
||||
JOIN sessions s ON s.node_id=n.id AND s.session_name=b.tmux_session
|
||||
AND s.id=(SELECT id FROM sessions WHERE node_id=n.id ORDER BY id DESC LIMIT 1)
|
||||
WHERE s.session_name=? OR replace(n.logical_id, '.', '-') || '@' || r.name=?`)
|
||||
CROSS JOIN sessions s ON s.node_id=n.id AND s.session_name=b.tmux_session
|
||||
AND s.id=(SELECT id FROM sessions WHERE node_id=n.id ORDER BY id DESC LIMIT 1)`;
|
||||
// Inventory passes physical names for every seat. The OR with a derived alias
|
||||
// made each lookup walk all nodes (or all historical sessions). Seek the exact
|
||||
// name first; only an absent active exact match needs the alias walk. Keep this
|
||||
// in one statement so both alternatives observe the same database snapshot.
|
||||
const rows = db.prepare(`WITH exact AS MATERIALIZED (
|
||||
${current} WHERE b.tmux_session=? AND r.archived_at IS NULL
|
||||
AND COALESCE(s.status, '') NOT IN ('superseded', 'detached', 'exited')
|
||||
) SELECT * FROM exact
|
||||
UNION ALL
|
||||
${current}
|
||||
WHERE replace(n.logical_id, '.', '-') || '@' || r.name=?
|
||||
AND NOT EXISTS (SELECT 1 FROM exact)`)
|
||||
.all(address, address) as Array<CurrentSessionAddress & { name: string; archived_at?: string | null; status: string }>;
|
||||
const active = rows.filter(row => row.archived_at == null && !['superseded', 'detached', 'exited'].includes(row.status));
|
||||
const exact = active.filter(row => row.sessionName === address);
|
||||
@@ -783,7 +794,7 @@ export class SessionRegistry {
|
||||
`SELECT s.* FROM sessions s
|
||||
JOIN nodes n ON s.node_id = n.id
|
||||
WHERE n.rig_id = ?
|
||||
ORDER BY s.created_at`
|
||||
ORDER BY s.created_at, s.rowid`
|
||||
)
|
||||
.all(rigId) as SessionRow[];
|
||||
|
||||
|
||||
@@ -280,7 +280,9 @@ function findCurrentClaudeQuestion(paneContent: string): string | null {
|
||||
// Claude can leave these noninteractive warnings BELOW the input box and mode bar.
|
||||
// Recognize the complete input block, never a warning or historical prompt alone.
|
||||
const CLAUDE_STATUS_WARNINGS = [
|
||||
/^✘ Auto-update failed: no write permission to npm prefix · Run claude doctor$/,
|
||||
/^✘ Auto-update failed(?:: [^·\r\n]+)? · Run claude doctor$/,
|
||||
/^✘ Auto-update failed · Try claude doctor or (?:npm i -g @anthropic-ai\/claude-code|cd ~\/\.claude\/local && npm update @anthropic-ai\/claude-code)$/,
|
||||
/^✘ Auto-update failed · Try claude doctor or npm i -g @anthropic-ai\/claude-c…$/,
|
||||
/^tmux focus-events off · add 'set -g focus-events on' to ~\/\.tmux\.conf and re…$/,
|
||||
/^You've used (?:\d|[1-9]\d)% of your weekly limit · resets \d{1,2}(?::\d{2})?(?:am|pm) \(UTC\)$/,
|
||||
// #1641: Claude 2.1.293's update banner, whatever installer it names.
|
||||
@@ -1834,8 +1836,9 @@ const SEAT_IDLE_SHAPES: {
|
||||
},
|
||||
},
|
||||
codex: {
|
||||
// The hint row under the status line of an empty box (product-path-idle.J, idle).
|
||||
hints: [/^\? for shortcuts$/, /^\? for shortcuts\s+⚠ \d+ warnings? · f2 to view$/],
|
||||
// Empty-box hints: 0.160 product-path-idle.J/idle, and the captured 0.167 completed frame.
|
||||
hints: [/^\? for shortcuts$/, /^\? for shortcuts\s+⚠ \d+ warnings? · f2 to view$/,
|
||||
/^← for agents · \? for shortcuts\s+⚠ \d+ warnings? · f2 to view$/],
|
||||
// The last non-blank row above the empty box: the completion row Codex adds when a turn completes
|
||||
// (after-enter-15s.J; its grammar is CODEX_COMPLETION_ROW above), or the greeting under the session header
|
||||
// before any turn (idle, product-path-idle.J, cleared-without-enter.J). A running or streaming turn draws rows
|
||||
@@ -1908,7 +1911,7 @@ const VISIBLE_CLIENT_VERSION: Record<"claude-code" | "codex", RegExp[]> = {
|
||||
};
|
||||
|
||||
/** Whether a frame visibly names a client version other than the recorded one (see RECORDED_CLIENT_VERSION). */
|
||||
function namesAnotherClientVersion(pane: string, runtime: "claude-code" | "codex"): boolean {
|
||||
export function namesAnotherClientVersion(pane: string, runtime: "claude-code" | "codex"): boolean {
|
||||
return VISIBLE_CLIENT_VERSION[runtime].some((pattern) =>
|
||||
[...pane.matchAll(pattern)].some((match) => match[1] !== RECORDED_CLIENT_VERSION[runtime]));
|
||||
}
|
||||
|
||||
@@ -811,19 +811,22 @@ export class SliceIndexer {
|
||||
// --- internals ---
|
||||
|
||||
private readSliceLocations(): SliceLocation[] {
|
||||
return this.walkSliceLocations().filter((location) => !this.missionId || location.missionId === this.missionId);
|
||||
return this.walkSliceLocations(false, [], undefined, this.missionId || undefined);
|
||||
}
|
||||
|
||||
/** Every slice location under the roots, whatever mission this indexer is scoped to.
|
||||
* Directory names are deduplicated (first wins) unless `physical` asks for every folder. */
|
||||
/** Every slice location under the roots. A requested mission is filtered before
|
||||
* directory-name deduplication; otherwise the first matching name wins unless
|
||||
* `physical` asks for every folder. */
|
||||
private walkSliceLocations(
|
||||
physical = false,
|
||||
additionalRoots: readonly string[] = [],
|
||||
onReadError?: () => void,
|
||||
missionId?: string,
|
||||
): SliceLocation[] {
|
||||
const locations: SliceLocation[] = [];
|
||||
const seen = new Set<string>();
|
||||
const addLocation = (location: SliceLocation) => {
|
||||
if (missionId !== undefined && location.missionId !== missionId) return;
|
||||
if (!physical) {
|
||||
if (seen.has(location.name)) return;
|
||||
seen.add(location.name);
|
||||
@@ -1085,7 +1088,7 @@ export class SliceIndexer {
|
||||
const status = this.mapStatus(frontmatter["status"] as string | undefined);
|
||||
const railItem = this.extractRailItem(frontmatter, missionId);
|
||||
const qitemIds = this.matchQitems(name, served);
|
||||
const proofPacket = this.findProofPacket(name);
|
||||
const proofPacket = this.findProofPacket(name, this.missionId ? missionId : null);
|
||||
const lastActivityAt = this.computeLastActivity(slicePath, qitemIds, proofPacket);
|
||||
|
||||
return {
|
||||
@@ -1115,7 +1118,7 @@ export class SliceIndexer {
|
||||
const status = this.mapStatus(frontmatter["status"] as string | undefined);
|
||||
const railItem = this.extractRailItem(frontmatter, missionId);
|
||||
const qitemIds = this.matchQitems(name, served);
|
||||
const proofPacket = this.findProofPacket(name);
|
||||
const proofPacket = this.findProofPacket(name, this.missionId ? missionId : null);
|
||||
const lastActivityAt = this.computeLastActivity(slicePath, qitemIds, proofPacket);
|
||||
const commitRefs = this.extractCommitRefs(frontmatter);
|
||||
const files = this.listSliceFiles(slicePath);
|
||||
@@ -1502,7 +1505,34 @@ export class SliceIndexer {
|
||||
return this.membershipIndex;
|
||||
}
|
||||
|
||||
private findProofPacket(sliceName: string): SliceProofPacket | null {
|
||||
private discoverMissionIds(): Set<string> {
|
||||
const missionIds = new Set<string>();
|
||||
for (const root of this.sliceRoots()) {
|
||||
let entries: fs.Dirent[];
|
||||
try { entries = fs.readdirSync(root, { withFileTypes: true }); }
|
||||
catch { continue; }
|
||||
for (const entry of entries) {
|
||||
if ((!entry.isDirectory() && !entry.isSymbolicLink()) || entry.name.startsWith(".")) continue;
|
||||
// Project readers treat every non-dot directory or symlink entry as a mission;
|
||||
// ownership needs only its name and never follows a sibling link.
|
||||
missionIds.add(entry.name);
|
||||
}
|
||||
}
|
||||
return missionIds;
|
||||
}
|
||||
|
||||
private proofDirectoryMissionOwner(dirName: string, missionIds: ReadonlySet<string>): string | null {
|
||||
let owner: string | null = null;
|
||||
for (const candidate of missionIds) {
|
||||
if (!dirName.startsWith(candidate)) continue;
|
||||
const separator = dirName[candidate.length];
|
||||
if (separator !== "-" && separator !== "." && separator !== "_") continue;
|
||||
if (!owner || candidate.length > owner.length) owner = candidate;
|
||||
}
|
||||
return owner;
|
||||
}
|
||||
|
||||
private findProofPacket(sliceName: string, missionId: string | null): SliceProofPacket | null {
|
||||
if (!this.dogfoodEvidenceRoot) return null;
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
@@ -1511,19 +1541,26 @@ export class SliceIndexer {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Match strategy: token-based. Tokenize slice name on `-`, drop
|
||||
// trailing `vN` version tokens (real proof dirs typically don't
|
||||
// include the version suffix), then a dir matches if every remaining
|
||||
// token appears as a hyphen-bounded substring of the dir name. This
|
||||
// handles real-world dir-naming where phase indicators land at the
|
||||
// front (e.g. `pl005-phase-a-mission-control-queue-observability-...`)
|
||||
// even though the slice folder uses suffix form
|
||||
// (`mission-control-queue-observability-phase-a`). Latest mtime wins.
|
||||
// Qualified proof directories use the observed `<mission>-<slice>` form:
|
||||
// the literal mission ID must lead the name at a separator boundary. If
|
||||
// known mission IDs overlap, the longest prefix owns the directory. This
|
||||
// is a fixture-supported rule, not a historically documented contract.
|
||||
// Unqualified reads retain their legacy slice-only token match. Once a
|
||||
// qualified directory is attributed, slice tokens match against the
|
||||
// remainder after that mission prefix and separator (`vN` handling unchanged);
|
||||
// latest mtime remains the tie-break among same-mission matches.
|
||||
let knownMissionIds: Set<string> | null = null;
|
||||
if (missionId) {
|
||||
knownMissionIds = this.discoverMissionIds();
|
||||
knownMissionIds.add(missionId);
|
||||
}
|
||||
const sliceTokens = sliceName.split("-").filter((t) => t.length > 0 && !/^v\d+$/.test(t));
|
||||
const matches: { dirent: fs.Dirent; mtime: number }[] = [];
|
||||
for (const dirent of entries) {
|
||||
if (!dirent.isDirectory()) continue;
|
||||
const dirTokenSet = new Set(dirent.name.split(/[-._]/).filter((t) => t.length > 0));
|
||||
if (knownMissionIds && this.proofDirectoryMissionOwner(dirent.name, knownMissionIds) !== missionId) continue;
|
||||
const tokenSource = knownMissionIds && missionId ? dirent.name.slice(missionId.length + 1) : dirent.name;
|
||||
const dirTokenSet = new Set(tokenSource.split(/[-._]/).filter((t) => t.length > 0));
|
||||
const allTokensPresent = sliceTokens.every((t) => dirTokenSet.has(t));
|
||||
if (!allTokensPresent) continue;
|
||||
try {
|
||||
|
||||
@@ -24,7 +24,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import type Database from "better-sqlite3";
|
||||
import type { WorkflowSpec, WorkflowExitKind, WorkflowAgentHarness, WorkflowGateSpec } from "./workflow-types.js";
|
||||
import { RETAINED_STATUS, type WorkflowSpecCache } from "./workflow-spec-cache.js";
|
||||
import { getFailedSpecRead, RETAINED_STATUS, type WorkflowSpecCache } from "./workflow-spec-cache.js";
|
||||
import type { EventBus } from "./event-bus.js";
|
||||
|
||||
export interface SpecLibraryWorkflowEntry {
|
||||
@@ -377,12 +377,13 @@ function isUnderDir(childPath: string, parentDir: string): boolean {
|
||||
// disappear since the last scan get removed via removeBySourcePath
|
||||
// (OQ-4 decision; deletion + audit log).
|
||||
//
|
||||
// OQ-3 timestamp check: a file is skipped when both its mtime/ctime are <=
|
||||
// the cache row's cached_at. A file can be restored or copied with an older
|
||||
// mtime, so ctime keeps the stat-only fast path honest. Files in the same
|
||||
// timestamp second still use the content hash, and other newer files are
|
||||
// re-parsed via the cache (which itself hashes the content and returns the
|
||||
// prior row when the hash matches).
|
||||
// OQ-3 timestamp check: a file is skipped when its mtime/ctime precede the
|
||||
// persisted cache observation. This stat-only hit assumes filesystem timestamps
|
||||
// and the daemon clock are comparable and that edits advance mtime or ctime. A
|
||||
// restored/copied file can have an older mtime, so ctime is included. Files in
|
||||
// the same timestamp second still use the content hash; other newer files are
|
||||
// re-parsed via the cache (which itself hashes the content and returns the prior
|
||||
// row when the hash matches).
|
||||
|
||||
export interface ScanWorkflowSpecFolderOpts {
|
||||
/** SQLite handle (used for direct lookups). */
|
||||
@@ -403,6 +404,8 @@ export interface ScanWorkflowSpecFolderOpts {
|
||||
eventBus?: EventBus;
|
||||
/** A user file with the same bytes and identity as an existing built-in row. */
|
||||
onBuiltinAlias?: (name: string, version: string, sourcePath: string, sourceHash: string) => void;
|
||||
/** A valid source whose hash was just read or whose persisted stat observation is safely aged. */
|
||||
onVerifiedSource?: (sourcePath: string, sourceHash: string) => void;
|
||||
}
|
||||
|
||||
export interface ScanWorkflowSpecFolderResult {
|
||||
@@ -468,14 +471,15 @@ export function scanWorkflowSpecFolder(
|
||||
// A path can hold several cached versions plus a diagnostic row (#503); the most
|
||||
// recently written row is the one that reflects the file's last scan.
|
||||
const cachedAt = opts.db
|
||||
.prepare(`SELECT cached_at, source_hash FROM workflow_specs WHERE source_path = ? AND status != '${RETAINED_STATUS}' ORDER BY cached_at DESC, rowid DESC LIMIT 1`)
|
||||
.get(filePath) as { cached_at: string; source_hash: string } | undefined;
|
||||
.prepare(`SELECT cached_at, source_hash, status FROM workflow_specs WHERE source_path = ? AND status != '${RETAINED_STATUS}' ORDER BY cached_at DESC, rowid DESC LIMIT 1`)
|
||||
.get(filePath) as { cached_at: string; source_hash: string; status: string } | undefined;
|
||||
if (cachedAt) {
|
||||
const cachedAtMs = Date.parse(cachedAt.cached_at);
|
||||
const cachedAtSec = Math.floor(cachedAtMs / 1000);
|
||||
const changeSec = Math.floor(changeMs / 1000);
|
||||
if (Number.isFinite(cachedAtMs) && Number.isFinite(changeMs)) {
|
||||
if (cachedAtSec > changeSec) {
|
||||
if (cachedAt.status === "valid") opts.onVerifiedSource?.(filePath, cachedAt.source_hash);
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -489,6 +493,7 @@ export function scanWorkflowSpecFolder(
|
||||
unchanged = createHash("sha256").update(readFileSync(filePath, "utf-8")).digest("hex") === cachedAt.source_hash;
|
||||
} catch { /* The existing readThrough path records the diagnostic. */ }
|
||||
if (unchanged) {
|
||||
if (cachedAt.status === "valid") opts.onVerifiedSource?.(filePath, cachedAt.source_hash);
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -505,25 +510,29 @@ export function scanWorkflowSpecFolder(
|
||||
&& isUnderDir(cached.sourcePath, opts.builtinDir)) {
|
||||
opts.onBuiltinAlias(cached.name, cached.version, filePath, cached.sourceHash);
|
||||
}
|
||||
if (cached.sourcePath === filePath) opts.onVerifiedSource?.(filePath, cached.sourceHash);
|
||||
result.valid += 1;
|
||||
} catch (err) {
|
||||
// Diagnostic: hash the raw content best-effort so we can detect
|
||||
// edits that fix the error (mtime alone is fine; this is for
|
||||
// bookkeeping). Use the empty string when content is unreadable.
|
||||
// Prefer the digest and timestamp captured by the failed read itself. A second path read here can observe a
|
||||
// repair that raced parse handling and falsely describe those replacement bytes as the bytes that failed.
|
||||
// Errors without captured text (missing/unreadable files) retain the previous best-effort path read.
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
let sourceHash = "";
|
||||
try {
|
||||
sourceHash = createHash("sha256").update(readFileSync(filePath, "utf-8")).digest("hex");
|
||||
} catch {
|
||||
// unreadable / disappeared between stat and read; treat as
|
||||
// empty hash so the next scan re-evaluates
|
||||
sourceHash = "";
|
||||
const failedRead = getFailedSpecRead(err);
|
||||
let sourceHash = failedRead?.sourceHash ?? "";
|
||||
if (!failedRead) {
|
||||
try {
|
||||
sourceHash = createHash("sha256").update(readFileSync(filePath, "utf-8")).digest("hex");
|
||||
} catch {
|
||||
// unreadable / disappeared between stat and read; treat as empty so the next scan re-evaluates
|
||||
sourceHash = "";
|
||||
}
|
||||
}
|
||||
try {
|
||||
opts.cache.writeDiagnostic({
|
||||
sourcePath: filePath,
|
||||
sourceHash,
|
||||
errorMessage: message,
|
||||
...(failedRead ? { cachedAt: failedRead.cachedAt } : {}),
|
||||
});
|
||||
} catch (writeErr) {
|
||||
// One file's diagnostic must not end the scan of the rest of the folder (#511).
|
||||
|
||||
@@ -116,6 +116,11 @@ export type SuccessorLaunchResult =
|
||||
message: string;
|
||||
/** True once the predecessor process has been replaced in its pane. */
|
||||
replacementStarted: boolean;
|
||||
/** The pane the successor was being started in, once it is resolved (start_agent failures). */
|
||||
tmuxPane?: string;
|
||||
/** True when the harness launch was sent into the pane and only readiness failed (#2985): the successor may
|
||||
* still come up there, and it is not adopted. */
|
||||
successorLaunched?: boolean;
|
||||
};
|
||||
|
||||
export class SuccessorSessionLauncher {
|
||||
@@ -293,11 +298,14 @@ export class SuccessorSessionLauncher {
|
||||
// 3. Route the successor through REAL runtime startup (launchHarness send-keys + readiness) so it
|
||||
// becomes a LIVE, READY agent in the reused pane BEFORE it can be committed. UNWIND INVARIANT:
|
||||
// on ANY launch/readiness failure we do NOT killSession the preserved seat — that would destroy
|
||||
// the retiree's recoverable state. We return the structured failure and leave the re-wakeable
|
||||
// shell in the pane; commit never runs, so the binding is not repointed.
|
||||
// the retiree's recoverable state. We return the structured failure; commit never runs, so the binding is
|
||||
// not repointed. When the harness launch was already sent and only readiness failed, the pane is NOT left as
|
||||
// a bare shell: the successor may still come up there, unadopted (#2985). The failure says so, so the caller
|
||||
// can tell the operator that a retry would end it.
|
||||
const started = await this.startAgent(input.node, departingSession, pane.id, cwd, input.forkSource, input.occupantGeneration);
|
||||
if (!started.ok) {
|
||||
return { ok: false, code: started.code, step: "start_agent", message: started.message, replacementStarted: true };
|
||||
return { ok: false, code: started.code, step: "start_agent", message: started.message, replacementStarted: true,
|
||||
tmuxPane: pane.id, ...(started.launched ? { successorLaunched: true } : {}) };
|
||||
}
|
||||
|
||||
const discovered = this.discoveryRepo.upsertDiscoveredSession({
|
||||
@@ -386,7 +394,7 @@ export class SuccessorSessionLauncher {
|
||||
forkSource?: ForkSource,
|
||||
launchGeneration?: string | null,
|
||||
staged?: StagedLaunch,
|
||||
): Promise<{ ok: true; resumeToken?: string; resumeType?: string; appliedLaunch?: AppliedLaunchObservation; notes?: string[] } | { ok: false; code: string; message: string }> {
|
||||
): Promise<{ ok: true; resumeToken?: string; resumeType?: string; appliedLaunch?: AppliedLaunchObservation; notes?: string[] } | { ok: false; code: string; message: string; launched?: true }> {
|
||||
const adapter = node.runtime ? this.runtimeAdapters[node.runtime] : undefined;
|
||||
if (!adapter) {
|
||||
return {
|
||||
@@ -472,9 +480,9 @@ export class SuccessorSessionLauncher {
|
||||
// createSession already succeeded (adapter/socket/permission errors rethrow).
|
||||
// A thrown exception must NOT reject createSuccessor before its kill/unwind
|
||||
// runs (that would LEAK the unmanaged successor + surface an unstructured 500).
|
||||
// Catch it here and return a STRUCTURED ok:false so the caller kills the
|
||||
// just-created session (killBestEffort) and fails loudly at step=start_agent —
|
||||
// exactly like a returned readiness failure.
|
||||
// Catch it here and return a STRUCTURED ok:false so the caller fails loudly at
|
||||
// step=start_agent, exactly like a returned readiness failure. Nothing kills the
|
||||
// pane (unwind invariant above): the launched successor may still be running there.
|
||||
let readiness: ReadinessResult;
|
||||
try {
|
||||
readiness = await this.waitForReady(adapter, binding);
|
||||
@@ -483,6 +491,7 @@ export class SuccessorSessionLauncher {
|
||||
ok: false,
|
||||
code: "successor_readiness_failed",
|
||||
message: `Successor readiness probe threw: ${err instanceof Error ? err.message : String(err)}`,
|
||||
launched: true,
|
||||
};
|
||||
}
|
||||
if (!readiness.ready) {
|
||||
@@ -490,6 +499,7 @@ export class SuccessorSessionLauncher {
|
||||
ok: false,
|
||||
code: isAttentionRequiredReadinessCode(readiness.code) ? "successor_attention_required" : "successor_not_ready",
|
||||
message: `Successor did not become a ready agent: ${readiness.reason ?? "readiness timeout"}`,
|
||||
launched: true,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -651,6 +651,9 @@ export interface AgentActivity {
|
||||
sourceSequence?: number | null;
|
||||
/** How often the emitter promises to report while idle; an idle report older than two of these no longer holds. */
|
||||
livenessSeconds?: number | null;
|
||||
/** #99: when a Claude usage limit (StopFailure/rate_limit) says it resets, read by the relay from Claude's own
|
||||
* message. Only the instant is kept, never the message. */
|
||||
resetsAt?: string | null;
|
||||
rawEvent?: string | null;
|
||||
rawSubtype?: string | null;
|
||||
runtime?: string | null;
|
||||
|
||||
@@ -76,6 +76,20 @@ export class WorkflowSpecError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
interface FailedSpecRead {
|
||||
sourceHash: string;
|
||||
cachedAt: string;
|
||||
}
|
||||
|
||||
// Keep capture evidence out of the original exception and out of process-global path state. The scanner can retrieve it
|
||||
// while handling this exact parse error, then persist it in the diagnostic row; WeakMap lifetime follows the exception.
|
||||
const failedSpecReads = new WeakMap<object, FailedSpecRead>();
|
||||
|
||||
export function getFailedSpecRead(error: unknown): FailedSpecRead | null {
|
||||
if ((typeof error !== "object" || error === null) && typeof error !== "function") return null;
|
||||
return failedSpecReads.get(error as object) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* OPR.0.4.6.WF1 FR-7 — THE closed keysets, EXPORTED as named
|
||||
* constants: parseWorkflowSpec is the ONLY seam that sees raw keys
|
||||
@@ -643,7 +657,10 @@ export class WorkflowSpecCache {
|
||||
* not previously cached).
|
||||
*/
|
||||
readThrough(sourcePath: string): WorkflowSpecRow {
|
||||
const { spec, sourceHash } = readSpecFile(sourcePath);
|
||||
// Stamp before opening the file. If an edit races the read, a later stat must not be hidden by a timestamp
|
||||
// assigned after that edit and after parse-error handling.
|
||||
const capturedAt = this.now().toISOString();
|
||||
const { spec, sourceHash } = readSpecFile(sourcePath, capturedAt);
|
||||
const named = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM workflow_specs WHERE name = ? AND version = ?`,
|
||||
@@ -671,11 +688,18 @@ export class WorkflowSpecCache {
|
||||
.run(sourcePath, existing?.spec_id ?? "").changes
|
||||
: 0;
|
||||
if (existing && existing.status !== "error" && existing.status !== RETAINED_STATUS && !moved && existing.source_hash === sourceHash) {
|
||||
// A repair back to the cached bytes records this scan, so the next scan skips the file.
|
||||
if (cleared > 0) {
|
||||
const repairedAt = this.now().toISOString();
|
||||
this.db.prepare(`UPDATE workflow_specs SET cached_at = ? WHERE spec_id = ?`).run(repairedAt, existing.spec_id);
|
||||
existing.cached_at = repairedAt;
|
||||
// A read verifies the timestamp only for its own source path, even when another copy has matching bytes. Advance only forward:
|
||||
// this pre-read stamp is safe for stat-only skips, while a backwards clock must not replace a newer observation.
|
||||
const existingCachedAtMs = Date.parse(existing.cached_at);
|
||||
const capturedAtMs = Date.parse(capturedAt);
|
||||
const shouldAdvanceObservation = existing.source_path === sourcePath && Number.isFinite(capturedAtMs) && (
|
||||
Number.isFinite(existingCachedAtMs)
|
||||
? existingCachedAtMs < capturedAtMs
|
||||
: cleared > 0
|
||||
);
|
||||
if (shouldAdvanceObservation) {
|
||||
this.db.prepare(`UPDATE workflow_specs SET cached_at = ? WHERE spec_id = ?`).run(capturedAt, existing.spec_id);
|
||||
existing.cached_at = capturedAt;
|
||||
}
|
||||
// readThrough is file-authoritative: return the freshly parsed
|
||||
// file spec so validation sees non-column metadata such as
|
||||
@@ -691,7 +715,7 @@ export class WorkflowSpecCache {
|
||||
}
|
||||
return rowToWorkflowSpec(existing, spec);
|
||||
}
|
||||
const cachedAt = this.now().toISOString();
|
||||
const cachedAt = capturedAt;
|
||||
const purpose = spec.objective ?? null;
|
||||
const targetRig = spec.target?.rig ?? null;
|
||||
const rolesJson = JSON.stringify(spec.roles);
|
||||
@@ -940,8 +964,10 @@ export class WorkflowSpecCache {
|
||||
sourcePath: string;
|
||||
sourceHash: string;
|
||||
errorMessage: string;
|
||||
/** Capture time from the read that failed, when available. */
|
||||
cachedAt?: string;
|
||||
}): void {
|
||||
const cachedAt = this.now().toISOString();
|
||||
const cachedAt = opts.cachedAt ?? this.now().toISOString();
|
||||
const existing = this.db
|
||||
.prepare(`SELECT spec_id FROM workflow_specs WHERE source_path = ? AND status = 'error'`)
|
||||
.get(opts.sourcePath) as { spec_id: string } | undefined;
|
||||
@@ -1116,7 +1142,7 @@ function warnLegacyRehydrationOnce(name: string, version: string): void {
|
||||
);
|
||||
}
|
||||
|
||||
function readSpecFile(sourcePath: string): { spec: WorkflowSpec; sourceHash: string } {
|
||||
function readSpecFile(sourcePath: string, cachedAt?: string): { spec: WorkflowSpec; sourceHash: string } {
|
||||
if (!existsSync(sourcePath)) {
|
||||
throw new WorkflowSpecError(
|
||||
"spec_file_missing",
|
||||
@@ -1126,7 +1152,14 @@ function readSpecFile(sourcePath: string): { spec: WorkflowSpec; sourceHash: str
|
||||
}
|
||||
const raw = readFileSync(sourcePath, "utf-8");
|
||||
const sourceHash = createHash("sha256").update(raw).digest("hex");
|
||||
return { spec: parseWorkflowSpec(raw, sourcePath), sourceHash };
|
||||
try {
|
||||
return { spec: parseWorkflowSpec(raw, sourcePath), sourceHash };
|
||||
} catch (error) {
|
||||
if (cachedAt && ((typeof error === "object" && error !== null) || typeof error === "function")) {
|
||||
failedSpecReads.set(error as object, { sourceHash, cachedAt });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function rowToWorkflowSpec(row: SpecRow, parsedSpec?: WorkflowSpec): WorkflowSpecRow {
|
||||
|
||||
@@ -384,6 +384,7 @@ export async function startServer(port?: number) {
|
||||
// tree post-bind so the HTTP surface is ready before the first
|
||||
// tick (matches contextMonitor pattern).
|
||||
deps.watchdogScheduler?.start();
|
||||
deps.networkConnector?.start();
|
||||
deps.sessionTransport?.guardedDelivery?.recover();
|
||||
// S10 — start the gateway subsystem's NETWORK services (replay, outbound poll,
|
||||
// Socket Mode inbound) post-bind, contextMonitor pattern: the composed wire is
|
||||
@@ -452,6 +453,7 @@ export async function startServer(port?: number) {
|
||||
["proof-source-watch", () => deps.proofSourceWatch?.close()],
|
||||
["health-diagnosis", () => deps.healthDiagnosis?.stop()],
|
||||
["watchdog", () => deps.watchdogScheduler?.stop()],
|
||||
["network-connector", () => deps.networkConnector?.stop()],
|
||||
["stream-intake", () => deps.streamIntakeCoordinator?.stop()],
|
||||
["draft-delivery", () => deps.sessionTransport?.guardedDelivery?.stop()],
|
||||
["seat-activity", () => deps.seatActivityService?.stop()],
|
||||
|
||||
@@ -116,6 +116,16 @@ export function hookRelayRoute(value: unknown): EvidenceRoute | undefined {
|
||||
const hookEvidenceSeq = new Map<string, number>();
|
||||
// What each seat's runner has said about its turns, for deciding whether a working beat counts (the runner contract §4).
|
||||
const workingLiveness = new WorkingLivenessGate();
|
||||
/** #99: the reset instant the relay read from a Claude usage limit, as a canonical ISO timestamp, or null. The store
|
||||
* keeps it only on the typed StopFailure/rate_limit row; the provider lane decides whether it is still ahead. */
|
||||
function limitResetsAt(body: Record<string, unknown>): string | null {
|
||||
if (typeof body.resetsAt !== "string" || body.resetsAt.length > 40) return null;
|
||||
const ms = Date.parse(body.resetsAt);
|
||||
// The relay's eight-day horizon, enforced at ingress too: no sender can park a pool for longer than the relay could.
|
||||
return Number.isFinite(ms) && ms <= Date.now() + LIMIT_RESET_MAX_MS ? new Date(ms).toISOString() : null;
|
||||
}
|
||||
const LIMIT_RESET_MAX_MS = 8 * 24 * 3600 * 1000;
|
||||
|
||||
function sourceSequence(body: Record<string, unknown>): number | null {
|
||||
return typeof body.sequence === "number" && Number.isSafeInteger(body.sequence) && body.sequence > 0 ? body.sequence : null;
|
||||
}
|
||||
@@ -727,6 +737,8 @@ function recordHookActivity(c: Context, store: AgentActivityStore, body: Record<
|
||||
livenessSeconds: beat ? workingLivenessSeconds(body.livenessSeconds)
|
||||
: typeof body.livenessSeconds === "number" && Number.isSafeInteger(body.livenessSeconds)
|
||||
&& body.livenessSeconds > 0 && body.livenessSeconds <= 3600 ? body.livenessSeconds : null,
|
||||
resetsAt: limitResetsAt(body),
|
||||
sessionId: stringOrNull(body.sessionId),
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
|
||||
@@ -50,11 +50,10 @@ import { mkdirSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { randomUUID, randomInt } from "node:crypto";
|
||||
import { connect } from "node:net";
|
||||
import { execFile } from "node:child_process";
|
||||
import { authBearerTokenMiddleware } from "../middleware/auth-bearer-token.js";
|
||||
import { getOpenRigHome } from "../openrig-compat.js";
|
||||
import { addHostEntry, updateHostEntry, removeHostEntry } from "../domain/hosts/hosts-registry-writer.js";
|
||||
import { defaultHostRegistryPath, loadHostRegistry, validateHostRegistry, type HostEntry, type SshHostEntry } from "../domain/hosts/hosts-registry-reader.js";
|
||||
import { defaultHostRegistryPath, loadHostRegistry, validateHostRegistry, type HostEntry } from "../domain/hosts/hosts-registry-reader.js";
|
||||
import type { SettingsStore } from "../domain/user-settings/settings-store.js";
|
||||
import { UNATTRIBUTED_ACTOR, type QueueItem, type QueueRepository } from "../domain/queue-repository.js";
|
||||
import { KERNEL_OPERATOR_SESSION, resolveDefaultReceiver } from "../domain/default-receiver.js";
|
||||
@@ -332,39 +331,16 @@ export function hostsRoutes(opts?: { bearerToken?: string | null; humanRegistry?
|
||||
// POINTERS by construction) + `selected` + a bounded coarse `status`.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// Where ssh would connect for this host, from `ssh -G` (reads ssh config, no connection); null when
|
||||
// ssh can't resolve it or a ProxyJump/ProxyCommand sits in between. Twin of the CLI's resolveSshEndpoint.
|
||||
function resolveSshEndpoint(host: SshHostEntry, timeoutMs: number): Promise<{ hostname: string; port: number } | null> {
|
||||
const args = [...(host.user ? ["-l", host.user] : []), "-G", host.target];
|
||||
return new Promise((resolve) => {
|
||||
execFile("ssh", args, { timeout: timeoutMs }, (error, stdout) => {
|
||||
if (error) return resolve(null);
|
||||
const conf = new Map(stdout.split("\n").map((line) => {
|
||||
const i = line.indexOf(" ");
|
||||
return [line.slice(0, i).toLowerCase(), line.slice(i + 1).trim()] as const;
|
||||
}));
|
||||
const proxied = ["proxyjump", "proxycommand"].some((k) => (conf.get(k) ?? "none") !== "none");
|
||||
const hostname = conf.get("hostname");
|
||||
const port = Number(conf.get("port"));
|
||||
resolve(hostname && Number.isInteger(port) && !proxied ? { hostname, port } : null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function probeHost(host: HostEntry, timeoutMs = 1500): Promise<"reachable" | "unreachable" | "unknown"> {
|
||||
try {
|
||||
let target: string;
|
||||
let port: number;
|
||||
if (host.transport === "ssh") {
|
||||
const endpoint = await resolveSshEndpoint(host, timeoutMs);
|
||||
if (!endpoint) return "unknown";
|
||||
({ hostname: target, port } = endpoint);
|
||||
} else {
|
||||
const u = new URL(host.url);
|
||||
// WHATWG keeps the brackets on an IPv6 hostname; net.connect wants the bare address.
|
||||
target = u.hostname.replace(/^\[(.*)\]$/, "$1");
|
||||
port = u.port ? Number(u.port) : u.protocol === "https:" ? 443 : 80;
|
||||
// Passive SSH rows have no authority to read SSH config or probe sshd.
|
||||
return "unknown";
|
||||
}
|
||||
const u = new URL(host.url);
|
||||
// WHATWG keeps the brackets on an IPv6 hostname; net.connect wants the bare address.
|
||||
const target = u.hostname.replace(/^\[(.*)\]$/, "$1");
|
||||
const port = u.port ? Number(u.port) : u.protocol === "https:" ? 443 : 80;
|
||||
return new Promise((resolve) => {
|
||||
const sock = connect({ host: target, port, timeout: timeoutMs });
|
||||
sock.on("connect", () => { sock.destroy(); resolve("reachable"); });
|
||||
|
||||
Loaded 100 of 195 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user