mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-12 18:48:31 -10:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fc45a0bf2 | ||
|
|
126efafeeb | ||
|
|
568ccef975 | ||
|
|
ec2ad9e1bd | ||
|
|
7ee288cdaf | ||
|
|
6d2b7468ce | ||
|
|
761edea6a9 | ||
|
|
77bc2161ce | ||
|
|
18337c55ec | ||
|
|
653d77e80d | ||
|
|
59845be8f7 | ||
|
|
55cc990d28 | ||
|
|
9bbb94a0d2 | ||
|
|
53165ddf45 |
No files matched your search
+184
-3
@@ -8,6 +8,187 @@ deprecations, and behavioral changes. Breaking changes are called out explicitly
|
||||
|
||||
---
|
||||
|
||||
## [0.7.14]
|
||||
|
||||
OpenRig 0.7.14 is a patch for steadier circulation on a busy team. A burst of node
|
||||
reads now takes turns, so a health check, a queue read or a send waits for one
|
||||
step of it instead of the whole burst, and bounded queue listings over a long
|
||||
retained history use indexes instead of scanning it. An assistant connected to
|
||||
your team can reach a seat by the name its team shows, connected assistants
|
||||
appear in `rig ps` and the TUI, and setup guidance tells the agent to fit a
|
||||
first team to your logins. There are fixes across delivery, seats and the TUI,
|
||||
many contributed by people using OpenRig. See Known limits for what this
|
||||
release doesn't fix.
|
||||
|
||||
### Before you upgrade
|
||||
|
||||
- **Stop the daemon, back up the database, install 0.7.14, then start the
|
||||
daemon.** Your agents keep running through a daemon restart; don't use
|
||||
`rig down`, which ends them.
|
||||
|
||||
```sh
|
||||
rig daemon stop
|
||||
cp "$(rig config get db.path)" "$(rig config get db.path).before-0.7.14"
|
||||
npm install -g @openrig/cli@0.7.14 # or your existing installation method
|
||||
rig daemon start
|
||||
```
|
||||
|
||||
- **Three migrations run before the daemon listens.** Migration 163 adds the
|
||||
`network_installations` table and four columns to `network_bindings`.
|
||||
Migrations 164 and 166 build indexes. The author's measurement of 166 took
|
||||
3.47 s on 28,976 rows, not a real installation's size, so a large database can
|
||||
take longer. The write overhead, and going back to 0.7.13 after these three
|
||||
migrations, aren't established; keep the backup. ([#3007](https://github.com/mvschwarz/openrig/pull/3007), [#3008](https://github.com/mvschwarz/openrig/pull/3008), [#3090](https://github.com/mvschwarz/openrig/pull/3090))
|
||||
- **If the start is reported pending:** the CLI waits 20 s, then may print
|
||||
"Daemon startup is pending after the readiness deadline". Readiness remains
|
||||
unconfirmed. It leaves the child running and the startup reservation
|
||||
retained, without recording the daemon. Inspect the address and log it
|
||||
prints. The child may later answer, and status can then report it running,
|
||||
but that does not finish this start's recording or release its reservation.
|
||||
Don't start it again until the steps below are done. The message's restart
|
||||
steps stop that child and clear the reservation: stop the named child PID and
|
||||
wait for it to exit, inspect `daemon-start.lock`, and only when both recorded
|
||||
launcher and child PIDs are absent, archive the lock and run
|
||||
`rig daemon start`. On a large database the new start can be reported pending
|
||||
again; reaching a recorded start this way hasn't been tested.
|
||||
`rig daemon stop` cannot stop that unpublished child.
|
||||
- **The `openrig` plugin carries the CLI's version, 0.7.14; openrig-core stays
|
||||
0.1.29.**
|
||||
|
||||
### Steadier under load
|
||||
|
||||
- Node reads (`rig ps --nodes`, the TUI, several agents at once) take turns,
|
||||
one step at a time, so other reads and sends are no longer held behind a
|
||||
whole burst. ([#3101](https://github.com/mvschwarz/openrig/pull/3101))
|
||||
- Bounded queue listings over retained history use indexes for their ordering
|
||||
and for live and archived owner notifications. ([#3090](https://github.com/mvschwarz/openrig/pull/3090))
|
||||
- Codex seats re-checked in one readiness poll share process listings where
|
||||
each seat's observation order and the 2 s freshness allow; each keeps its own
|
||||
later observation and its own fallback. ([#3061](https://github.com/mvschwarz/openrig/pull/3061))
|
||||
- New tmux servers raise their soft open-file limit to 8,192 or the hard limit
|
||||
where they can, keeping an already higher limit. This makes descriptor
|
||||
exhaustion less likely as panes and viewers add up; servers already running
|
||||
keep their limits. ([#3070](https://github.com/mvschwarz/openrig/pull/3070))
|
||||
- A delegation parent gets one standing notice per child seat for unclaimed
|
||||
rows, not one per row, and held work gets its notice back when its typing
|
||||
hold clears. ([#3049](https://github.com/mvschwarz/openrig/pull/3049), [#3082](https://github.com/mvschwarz/openrig/pull/3082))
|
||||
|
||||
### Your team and the people it works for
|
||||
|
||||
- An assistant connected over MCP reaches a seat by its plain name, stored
|
||||
under the seat's actual session; an unknown name, or one several seats match,
|
||||
says so. ([#3088](https://github.com/mvschwarz/openrig/pull/3088))
|
||||
- Connected assistants appear, with their check-in, in `rig ps`, `rig_team` and
|
||||
the TUI's tables, and their person sees plain tool titles. ([#3057](https://github.com/mvschwarz/openrig/pull/3057), [#3058](https://github.com/mvschwarz/openrig/pull/3058), [#3092](https://github.com/mvschwarz/openrig/pull/3092))
|
||||
- `rig doctor` and setup say a login is needed when a seat you start runs on
|
||||
that harness. The operator is taught to check each seat's runtime against
|
||||
your logins before launch, name any missing sign-in and let you choose.
|
||||
([#3077](https://github.com/mvschwarz/openrig/pull/3077))
|
||||
- When the readable person registry holds nobody, `rig daemon start` and
|
||||
`rig gateway participants` tell the setup agent to offer registration. The
|
||||
kernel operator is taught to include that offer in its greeting and register
|
||||
only on your yes. ([#3041](https://github.com/mvschwarz/openrig/pull/3041), [#3071](https://github.com/mvschwarz/openrig/pull/3071))
|
||||
- A team your agent hands work to knows how to ask you a question, and your
|
||||
answer wakes it. A click on an ask with several questions records that
|
||||
choice; the ask stays open until the last one. ([#3059](https://github.com/mvschwarz/openrig/pull/3059), [#3045](https://github.com/mvschwarz/openrig/pull/3045))
|
||||
- A pairing request's description of itself is quoted wherever it's shown. ([#3067](https://github.com/mvschwarz/openrig/pull/3067))
|
||||
- `rig queue list` takes exact `--tag`, `--mission` and `--slice` filters, and
|
||||
several tags must all match. ([#3066](https://github.com/mvschwarz/openrig/pull/3066))
|
||||
- A skill-only edition of the `openrig` plugin, for Claude's plugin directory,
|
||||
follows handed-on work and keeps its reply route. ([#3056](https://github.com/mvschwarz/openrig/pull/3056), [#3075](https://github.com/mvschwarz/openrig/pull/3075))
|
||||
|
||||
### Fixes
|
||||
|
||||
- An ordinary send is refused as needing input while an open Claude Code or
|
||||
Codex `/model` picker is positively identified; nothing is typed. Close the
|
||||
picker before trying to deliver the message. ([#3083](https://github.com/mvschwarz/openrig/pull/3083))
|
||||
- On a resume of a Codex seat's own conversation in a rig with pods, including
|
||||
`rig up --existing`, OpenRig avoids adding missing guidance blocks to the
|
||||
repository's tracked `AGENTS.md` when Codex is 0.164.0 or later, no guidance
|
||||
file or developer instructions are configured, and the configuration and
|
||||
block sources can be read. ([#3039](https://github.com/mvschwarz/openrig/pull/3039))
|
||||
- With `recovery.auto_drive_provider_prompts` on, Claude Code's gated-grants
|
||||
trust check is never answered for you, and the plain trust prompt is answered
|
||||
"Yes". ([#3073](https://github.com/mvschwarz/openrig/pull/3073))
|
||||
- Codex seats with linked `CODEX_HOME` folders or config files get hook trust
|
||||
under the keys Codex reads. ([#3079](https://github.com/mvschwarz/openrig/pull/3079))
|
||||
- `rig terminal open --provider herdr` reuses a live Herdr workspace, and Rex
|
||||
tiles show the seat's agent and its activity. ([#3068](https://github.com/mvschwarz/openrig/pull/3068), [#2992](https://github.com/mvschwarz/openrig/pull/2992))
|
||||
- `rig broadcast` prints a held recipient as retained, not sent. ([#3054](https://github.com/mvschwarz/openrig/pull/3054))
|
||||
- Account erasure also removes OpenRig's own wakes and notices for that
|
||||
account's seats. ([#3089](https://github.com/mvschwarz/openrig/pull/3089))
|
||||
- A snapshot without sessions returns its `invalid_snapshot_data` blocker
|
||||
instead of throwing a TypeError; the restore still stops there. A retried
|
||||
no-launch import recovers the original rig by its operation ID. ([#3069](https://github.com/mvschwarz/openrig/pull/3069), [#3042](https://github.com/mvschwarz/openrig/pull/3042))
|
||||
- A rebuilt briefing limits optional superseded recap paths to the newest
|
||||
three by default when one folder covers that history; the current recap and
|
||||
all artifact records remain. Other layouts keep the full list, and
|
||||
`output.rebuild_recaps` set to 0 lists all paths. ([#3055](https://github.com/mvschwarz/openrig/pull/3055))
|
||||
- `rig compact --state` names the input check that stopped a managed
|
||||
compaction, and the TUI's graph shows a seat the daemon reads as unknown as
|
||||
unknown. ([#3053](https://github.com/mvschwarz/openrig/pull/3053), [#3046](https://github.com/mvschwarz/openrig/pull/3046))
|
||||
- Setting a context-pressure threshold explains when an environment override
|
||||
inverts the pair, and `rig workflow validate` warns that harness pins aren't
|
||||
checked against seats. ([#2567](https://github.com/mvschwarz/openrig/pull/2567), [#3102](https://github.com/mvschwarz/openrig/pull/3102))
|
||||
- The config browser's entries say each setting's type, choices, range, format
|
||||
and write route. ([#3050](https://github.com/mvschwarz/openrig/pull/3050))
|
||||
|
||||
### Experimental
|
||||
|
||||
- `rig network` is experimental, unsupported and at your own risk. Its connector
|
||||
isn't inert: the daemon starts it, sweeps leftover credential folders and
|
||||
schedules local rounds even with no binding. ([#3007](https://github.com/mvschwarz/openrig/pull/3007), [#3084](https://github.com/mvschwarz/openrig/pull/3084))
|
||||
- A grant service can hold back only new work under its fair-use limit, and an
|
||||
app can read what needs a person, and the answers to it, by link. ([#3048](https://github.com/mvschwarz/openrig/pull/3048), [#3008](https://github.com/mvschwarz/openrig/pull/3008))
|
||||
|
||||
### Behaviour that changed
|
||||
|
||||
- **Node reads:** a nodes response is no longer one snapshot across its steps,
|
||||
and in a burst each nodes response comes later: about 43 ms against 21 ms
|
||||
median in a 24-request burst. One very large rig is still one step. ([#3101](https://github.com/mvschwarz/openrig/pull/3101))
|
||||
- **An assistant naming a seat now hears three errors:** `destination_ambiguous`
|
||||
(several seats match; the candidates are listed), `destination_not_found` (no
|
||||
seat it can reach has that name) and `destination_unchecked` (the team
|
||||
couldn't be read; try again). ([#3088](https://github.com/mvschwarz/openrig/pull/3088))
|
||||
|
||||
### Known limits
|
||||
|
||||
- **Queue listings:** the new indexes aren't a cure for every stall. Listings of
|
||||
active work showed no gain, one case was slower, and writes weren't measured. ([#3090](https://github.com/mvschwarz/openrig/pull/3090))
|
||||
- **Draft-aware delivery ([#3076](https://github.com/mvschwarz/openrig/issues/3076)):** an automatic OpenRig notice can still be left beside
|
||||
a draft started during delivery. In the reproduced paste-window cases the person's draft
|
||||
stayed intact and no Enter was sent, but OpenRig's text stayed in the input.
|
||||
Those cases don't cover a later window. On a conditional send, one that names
|
||||
the conversation it expects, an isolated test with a scripted terminal showed
|
||||
characters typed while OpenRig ran its last checks before Enter submitted
|
||||
together with OpenRig's text, and the send reported success. The same test
|
||||
reproduces it on 0.7.13. It hasn't been tested with a real client or model.
|
||||
- **The TUI's tables** can drop a connected assistant's check-in line when its
|
||||
rig's seat inventory can't be read; `rig roster` still prints it. ([#3105](https://github.com/mvschwarz/openrig/pull/3105) isn't in this release)
|
||||
- **Repository guidance on restore:** rigs without pods, old or unknown Codex
|
||||
versions, configured guidance and unreadable configuration keep the existing
|
||||
file path. Fresh, fork and handover launches are outside this resume fix.
|
||||
Existing managed blocks may still refresh, and the seat-skills line still
|
||||
belongs to the file writer; check `git status`. A block whose source can't be
|
||||
read is reported missing.
|
||||
- **From 0.7.13:** [its known limits](https://github.com/mvschwarz/openrig/releases/tag/v0.7.13)
|
||||
still apply, except where this release fixes them. The runtime fixes cover
|
||||
adding missing guidance on the eligible Codex resumes described above
|
||||
([#3039](https://github.com/mvschwarz/openrig/pull/3039)), recognized model pickers ([#3083](https://github.com/mvschwarz/openrig/pull/3083)) and the unquoted
|
||||
pairing description ([#3067](https://github.com/mvschwarz/openrig/pull/3067)).
|
||||
This release also changes the operator-first registration ([#3041](https://github.com/mvschwarz/openrig/pull/3041),
|
||||
[#3071](https://github.com/mvschwarz/openrig/pull/3071)) and pre-launch login guidance ([#3077](https://github.com/mvschwarz/openrig/pull/3077)); their observed
|
||||
outcomes aren't established yet. They weren't re-checked for this release
|
||||
unless named here.
|
||||
|
||||
### Credits
|
||||
|
||||
Thanks to @Kayhusk, @billbenetti, @cavalcanticampos, @Aummadour, @Tankard,
|
||||
@w4rc0n and @Totopo27 for merged contributions. Thanks also for the reports
|
||||
behind these fixes: @Kayhusk ([#3035](https://github.com/mvschwarz/openrig/issues/3035) and [#3078](https://github.com/mvschwarz/openrig/issues/3078)), @Tankard ([#2583](https://github.com/mvschwarz/openrig/issues/2583)),
|
||||
@dajiaohuang ([#2529](https://github.com/mvschwarz/openrig/issues/2529)), @billbenetti ([#2728](https://github.com/mvschwarz/openrig/issues/2728)), @emilio-gagliardi ([#3087](https://github.com/mvschwarz/openrig/issues/3087)), @korallis
|
||||
([#161](https://github.com/mvschwarz/openrig/issues/161)) and @w4rc0n ([#3017](https://github.com/mvschwarz/openrig/issues/3017)).
|
||||
|
||||
## [0.7.13]
|
||||
|
||||
OpenRig 0.7.13 is a patch for people running their own agent teams. When you answer a runner's permission request, you now get a
|
||||
@@ -24,9 +205,9 @@ committed change. Two repairs aren't in this release: see Known limits.
|
||||
|
||||
### Before you upgrade
|
||||
|
||||
- **Back up the database, then restart the daemon:** `rig daemon stop`, copy the
|
||||
database file (`rig config get db.path` prints its path), then
|
||||
`rig daemon start`. Your agents keep running through a daemon restart; don't
|
||||
- **Back up the database, install 0.7.13, then restart the daemon:** `rig daemon stop`,
|
||||
copy the database file (`rig config get db.path` prints its path), install
|
||||
OpenRig 0.7.13 using your existing installation method, then `rig daemon start`. Your agents keep running through a daemon restart; don't
|
||||
use `rig down`, which ends them. Three migrations run on the daemon's first start: 160 and 161 (for the experimental hub connector below)
|
||||
and 162 (indexes for session reads). Going back to 0.7.12 after them isn't
|
||||
established. ([#2993](https://github.com/mvschwarz/openrig/pull/2993), [#2997](https://github.com/mvschwarz/openrig/pull/2997))
|
||||
|
||||
@@ -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.14/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.14/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.14` 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.14/scripts/install.sh | sh -s -- --dry-run
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.14/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.
|
||||
|
||||
@@ -182,8 +182,9 @@ With `needs-me.read` supported:
|
||||
about the open rows blocked on it (at most 100; empty when none are recorded).
|
||||
- **A snapshot watermark:** the response's `watermark` is the newest event `seq` read before the snapshot. Follow
|
||||
`GET /api/events` from it with `Last-Event-ID: <watermark>`: every event after the snapshot is replayed, some possibly
|
||||
already reflected in it. Recording one answer of several emits no event yet, so re-read the snapshot (or the row)
|
||||
rather than relying on events alone for partial answers.
|
||||
already reflected in it. A click that records or changes an answer emits `queue.answer_recorded` even while the set
|
||||
is partial. An identical repeated choice, or a later click after a pending ask's answer set is complete, records
|
||||
nothing new and emits none. Events are best effort; re-read the snapshot or row after a stream gap.
|
||||
- **The answers returned to a request:** `GET /api/queue/<qitemId>` adds `answers`, oldest first, each
|
||||
`{ qitemId, by, at, text, episode, recordedIdentity }`. They are found by the answer row's link to the request, not
|
||||
by its text, so don't parse the `In reply to:` line in an answer's body. `text` is the person's answer without that
|
||||
|
||||
@@ -185,10 +185,10 @@ agent doing the setup. Both use this release's tag:
|
||||
|
||||
```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.14/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.14/skills/rigs" --skill rigs --agent codex
|
||||
```
|
||||
|
||||
If OpenRig is already installed, use the release tag matching your installed `rig --version` instead: replace the tag
|
||||
@@ -203,11 +203,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.14/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.14/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
|
||||
|
||||
@@ -177,6 +177,13 @@ and nothing else is offered:
|
||||
`rig_queue_create` that leaves out `destination` goes to that receiver. The operator sets it; a caller can't. It must be
|
||||
exactly one existing local node within that request's grant, or nothing is written. An explicit `destination` works as
|
||||
before.
|
||||
- **A seat by its name:** a `destination` with no `@` names a seat as its team shows it: its session's own name, then its
|
||||
pod-member form, then that name's slug, in the connection's one rig or the configured receiver's. Only seats the grant
|
||||
can address count, and the first of these that any of them matches decides, so another seat's slug never makes an exact
|
||||
name ambiguous and a seat the grant can't address never shadows one it can. The item is stored under that
|
||||
seat's actual session. A slug two seats match returns `destination_ambiguous` with the candidates, and a name that no
|
||||
seat the grant can reach has returns `destination_not_found`. A name the grant can't address as written (its own seat,
|
||||
or another assistant's without `team`/`list`) stays `out_of_scope`, whether or not that seat exists.
|
||||
- **Two assistants together:** a grant with `team`/`list` sees its rigs' seats with `rig_team`, and may address and hear from
|
||||
another connected assistant's seat. A grant without it never reaches a seat in the service's caller form
|
||||
(`cus-<id>-<connection>@<rig>`), so an existing connection keeps the reach it had.
|
||||
|
||||
@@ -60,9 +60,13 @@ waits for any other change to this enrollment to finish, within the same 30 seco
|
||||
waiting says so and doesn't send the code. The account makes a new installation, with a new key, for the new code. Here,
|
||||
that key takes the old one's place and the bindings made through the old installation end, in one step:
|
||||
|
||||
- If that step fails after the account accepted the code, nothing here changes, and the error says so. The old
|
||||
enrollment keeps working only while the account keeps the old installation, and the account now has a new
|
||||
installation whose key nothing here holds: remove it on the account page, then enroll again with a new code.
|
||||
- If that step fails after the account accepted the code, the enrollment's record here doesn't change, and the error
|
||||
says so. The key is put back too, but moving key files happens outside that record's rollback. If putting it back also
|
||||
fails (on a full or read-only disk, for example), the old key is left as `key.json.prev` beside `key.json` in the
|
||||
installation's folder under `network-secrets/` in the OpenRig home: move it back to `key.json` before you go on. A
|
||||
`key.json.next` there is a staged new key, and the next replace removes it. The old enrollment keeps working only
|
||||
while the account keeps the old installation, and the account now has a new installation whose key nothing here
|
||||
holds: remove it on the account page, then enroll again with a new code.
|
||||
- Once the step is done, `enroll` names the bindings it ended. The old installation may still list their names, and the
|
||||
new key can't drop them, so remove them on the account page. `rig network status` marks each of them as possibly still
|
||||
listed under the old installation, as it does a binding that ended earlier without the account confirming the drop:
|
||||
|
||||
@@ -12,9 +12,9 @@ In your project folder, add OpenRig's `rigs` skill to the agent you 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.14/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.14/skills/rigs --skill rigs --agent codex
|
||||
```
|
||||
|
||||
These use this release's tag, for a new install. If OpenRig is already installed, replace the tag after `tree/` with
|
||||
|
||||
+826
-178
File diff suppressed because it is too large.
Load diff
Generated
+5
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openrig",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.14",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openrig",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.14",
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
"packages/daemon",
|
||||
@@ -7298,7 +7298,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@openrig/cli",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.14",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -7336,7 +7336,7 @@
|
||||
},
|
||||
"packages/daemon": {
|
||||
"name": "@openrig/daemon",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.14",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.0",
|
||||
@@ -7380,7 +7380,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@openrig/ui",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.14",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openrig",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.14",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openrig/acp",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.14",
|
||||
"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.1"
|
||||
"@openrig/cli": "0.7.14"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"author": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "openrig",
|
||||
"name": "OpenRig",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.14",
|
||||
"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]"
|
||||
"package": "@openrig/[email protected]4"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openrig/cli",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.14",
|
||||
"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": [
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Command } from "commander";
|
||||
import { ConfigStore, VALID_KEYS, type ResolvedSetting, type ValidKey } from "../config-store.js";
|
||||
import { ConfigStore, ENV_MAP, VALID_KEYS, type ResolvedSetting, type ValidKey } from "../config-store.js";
|
||||
import { initWorkspaceCommand } from "./config-init-workspace.js";
|
||||
|
||||
function formatRow(key: string, value: unknown): string {
|
||||
@@ -10,6 +10,40 @@ function summarizeSettings(store: ConfigStore): Record<ValidKey, ResolvedSetting
|
||||
return store.resolveAllWithSource();
|
||||
}
|
||||
|
||||
interface ContextPressureFallbackInfo {
|
||||
effectiveValue: number;
|
||||
explanation: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns fallback explanation and effective default value if setting/resetting a context-pressure
|
||||
* key caused an inverted threshold pair to be rejected and fall back to 95/99 defaults in this shell.
|
||||
* Returns null if no pair rejection occurred.
|
||||
*/
|
||||
function getContextPressureFallbackInfo(store: ConfigStore, key: string): ContextPressureFallbackInfo | null {
|
||||
if (key !== "health.context_pressure.warning_percent" && key !== "health.context_pressure.critical_percent") {
|
||||
return null;
|
||||
}
|
||||
const state = store.resolveContextPressurePolicyState();
|
||||
if (!state.rejectedPair) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const warnKey = "health.context_pressure.warning_percent" as const;
|
||||
const critKey = "health.context_pressure.critical_percent" as const;
|
||||
const envSources: string[] = [];
|
||||
if (state.warning.source === "env") {
|
||||
envSources.push(`${ENV_MAP[warnKey].primary}=${state.warning.value}`);
|
||||
}
|
||||
if (state.critical.source === "env") {
|
||||
envSources.push(`${ENV_MAP[critKey].primary}=${state.critical.value}`);
|
||||
}
|
||||
const envDetails = envSources.length > 0 ? ` from ${envSources.join(", ")}` : "";
|
||||
const explanation = `; inverted pair ${state.rejectedPair.warning}/${state.rejectedPair.critical}${envDetails} falls back to 95/99 defaults in this shell`;
|
||||
const effectiveValue = key === warnKey ? 95 : 99;
|
||||
return { effectiveValue, explanation };
|
||||
}
|
||||
|
||||
// These settings remain boot-only; capture interval/line settings reload live.
|
||||
const BOOT_ONLY_KEYS = ["daemon.port", "daemon.host", "daemon.db_conflict", "daemon.seat_poll_interval_ms", "db.path", "terminal.tmux_server"];
|
||||
const BOOT_ONLY_PREFIXES = ["transcripts.enabled", "transcripts.path"];
|
||||
@@ -220,11 +254,16 @@ Precedence: CLI flag > environment variable > config file > default`)
|
||||
try {
|
||||
store.set(key, value);
|
||||
const resolved = store.resolveWithSource(key);
|
||||
console.log(resolved.source === "env"
|
||||
? `${key} = ${resolved.value} (source: env; file override saved but masked)`
|
||||
: resolved.source === "default"
|
||||
? `${key} file override saved; effective value ${resolved.value} (source: default)`
|
||||
: `${key} = ${resolved.value}`);
|
||||
const fallbackInfo = getContextPressureFallbackInfo(store, key);
|
||||
if (fallbackInfo) {
|
||||
console.log(`${key} file override saved; effective value ${fallbackInfo.effectiveValue} (source: default${fallbackInfo.explanation})`);
|
||||
} else {
|
||||
console.log(resolved.source === "env"
|
||||
? `${key} = ${resolved.value} (source: env; file override saved but masked)`
|
||||
: resolved.source === "default"
|
||||
? `${key} file override saved; effective value ${resolved.value} (source: default)`
|
||||
: `${key} = ${resolved.value}`);
|
||||
}
|
||||
// SWEEP-c: boot-only key + running daemon = stale-until-restart; say so loudly.
|
||||
if (isBootOnlyKey(key) && (await probeDaemonRunning())) {
|
||||
console.error(resolved.source === "env"
|
||||
@@ -246,9 +285,14 @@ Precedence: CLI flag > environment variable > config file > default`)
|
||||
store.reset(key);
|
||||
if (key) {
|
||||
const resolved = store.resolveWithSource(key);
|
||||
console.log(resolved.source === "env"
|
||||
? `${key} file override cleared; effective value ${resolved.value} (source: env; default: ${resolved.defaultValue}).`
|
||||
: `${key} reset to default (${resolved.value}).`);
|
||||
const fallbackInfo = getContextPressureFallbackInfo(store, key);
|
||||
if (fallbackInfo) {
|
||||
console.log(`${key} file override cleared; effective value ${fallbackInfo.effectiveValue} (source: default${fallbackInfo.explanation}).`);
|
||||
} else {
|
||||
console.log(resolved.source === "env"
|
||||
? `${key} file override cleared; effective value ${resolved.value} (source: env; default: ${resolved.defaultValue}).`
|
||||
: `${key} reset to default (${resolved.value}).`);
|
||||
}
|
||||
} else {
|
||||
let envKeys: string[] = [];
|
||||
try {
|
||||
|
||||
@@ -1319,6 +1319,12 @@ export interface ResolvedSetting {
|
||||
defaultValue: string | number | boolean;
|
||||
}
|
||||
|
||||
export interface ContextPressurePolicyState {
|
||||
warning: ResolvedSetting;
|
||||
critical: ResolvedSetting;
|
||||
rejectedPair: { warning: number; critical: number } | null;
|
||||
}
|
||||
|
||||
export class ConfigStore {
|
||||
readonly configPath: string;
|
||||
|
||||
@@ -1585,6 +1591,34 @@ export class ConfigStore {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Inspect resolved context-pressure policy and whether an inverted pair was rejected. */
|
||||
resolveContextPressurePolicyState(
|
||||
fileConfig?: Record<string, unknown>,
|
||||
workspaceRoot?: string,
|
||||
): ContextPressurePolicyState {
|
||||
const fc = fileConfig ?? this.readConfigFile();
|
||||
const wr = workspaceRoot ?? (this.resolveOne("workspace.root", fc, DEFAULT_WORKSPACE_ROOT).value as string);
|
||||
return this.resolveContextPressureStateRaw(fc, wr);
|
||||
}
|
||||
|
||||
private resolveContextPressureStateRaw(
|
||||
fileConfig: Record<string, unknown>,
|
||||
workspaceRoot: string,
|
||||
): ContextPressurePolicyState {
|
||||
const warningKey = "health.context_pressure.warning_percent" as const;
|
||||
const criticalKey = "health.context_pressure.critical_percent" as const;
|
||||
const warning = this.resolveOneUnpaired(warningKey, fileConfig, workspaceRoot);
|
||||
const critical = this.resolveOneUnpaired(criticalKey, fileConfig, workspaceRoot);
|
||||
if ((warning.value as number) < (critical.value as number)) {
|
||||
return { warning, critical, rejectedPair: null };
|
||||
}
|
||||
return {
|
||||
warning,
|
||||
critical,
|
||||
rejectedPair: { warning: warning.value as number, critical: critical.value as number },
|
||||
};
|
||||
}
|
||||
|
||||
private resolveOne(key: ValidKey, fileConfig: Record<string, unknown>, workspaceRoot: string): ResolvedSetting {
|
||||
if (key === "health.context_pressure.warning_percent" || key === "health.context_pressure.critical_percent") {
|
||||
return this.resolveContextPressurePair(fileConfig, workspaceRoot)[key];
|
||||
@@ -1598,13 +1632,12 @@ export class ConfigStore {
|
||||
): Record<"health.context_pressure.warning_percent" | "health.context_pressure.critical_percent", ResolvedSetting> {
|
||||
const warningKey = "health.context_pressure.warning_percent" as const;
|
||||
const criticalKey = "health.context_pressure.critical_percent" as const;
|
||||
const warning = this.resolveOneUnpaired(warningKey, fileConfig, workspaceRoot);
|
||||
const critical = this.resolveOneUnpaired(criticalKey, fileConfig, workspaceRoot);
|
||||
if ((warning.value as number) < (critical.value as number)) {
|
||||
return { [warningKey]: warning, [criticalKey]: critical };
|
||||
const state = this.resolveContextPressureStateRaw(fileConfig, workspaceRoot);
|
||||
if (!state.rejectedPair) {
|
||||
return { [warningKey]: state.warning, [criticalKey]: state.critical };
|
||||
}
|
||||
process.stderr.write(
|
||||
`[openrig-config] context-pressure policy rejected: warning (${warning.value}) must be less than critical (${critical.value}); falling back to 95/99 defaults\n`,
|
||||
`[openrig-config] context-pressure policy rejected: warning (${state.warning.value}) must be less than critical (${state.critical.value}); falling back to 95/99 defaults\n`,
|
||||
);
|
||||
return {
|
||||
[warningKey]: { value: 95, source: "default", defaultValue: 95 },
|
||||
|
||||
+103
-40
@@ -503,6 +503,28 @@ const READ_ONLY: ToolAnnotations = { readOnlyHint: true };
|
||||
const ADDITIVE: ToolAnnotations = { readOnlyHint: false, destructiveHint: false };
|
||||
const DESTRUCTIVE: ToolAnnotations = { readOnlyHint: false, destructiveHint: true };
|
||||
|
||||
/** The title and plain first sentence a connected assistant's client shows a person for each tool. Each replaces only the
|
||||
* opening it names; every rule after it stays as the model reads it. */
|
||||
export const PLAIN_WORDING: Readonly<Record<string, { title: string; replaces: string; with: string }>> = {
|
||||
rig_queue_list: { title: "List my OpenRig requests", replaces: "List queue items.",
|
||||
with: "Lists the requests addressed to this assistant, or that it sent." },
|
||||
rig_queue_show: { title: "Show one request", replaces: "Read one queue item and its transitions.",
|
||||
with: "Shows one request, who picked it up, and its answer." },
|
||||
rig_queue_create: { title: "Send a request", replaces: "Hand a task to another seat as a durable queue item.",
|
||||
with: "Sends a request to another assistant on your team." },
|
||||
rig_queue_claim: { title: "Pick up a request", replaces: "Claim a queue item assigned to this seat before working on it.",
|
||||
with: "Marks a request addressed to this assistant as picked up, so others see it's being worked on. Do it before working on the request." },
|
||||
rig_queue_update: { title: "Answer a request", replaces: "Update a queue item: add a note, block it, close it, or reopen a closed one.",
|
||||
with: "Adds this assistant's answer to a request, or a note while it works. It can also block, close or reopen one." },
|
||||
rig_artifact_read: { title: "Read a shared file",
|
||||
replaces: "Read a finished item's artifact (its evidenceRef), or a named file, through the daemon's shared roots (OPENRIG_FILES_ALLOWLIST), as text:",
|
||||
with: "Reads a file attached to a request. It returns text:" },
|
||||
rig_team: { title: "Show my team", replaces: "Show a rig's team.",
|
||||
with: "Shows the assistants on your team, each one's address for sending it a request, and which one leads." },
|
||||
rig_whoami: { title: "Show this connection", replaces: "Show which OpenRig seat this server acts as: ",
|
||||
with: "Shows which assistant this connection is and which team it belongs to. It answers with " },
|
||||
};
|
||||
|
||||
/** Who an authenticated HTTP caller must be to be offered a tool: work tools (the work loop, orientation, and
|
||||
* communication with seats) reach a team within the caller's scope; workspace tools read the work tree and context when
|
||||
* the caller's grant includes them; owner tools administer the instance (launch, stop, snapshot and restore, discover and
|
||||
@@ -662,25 +684,41 @@ function instructionsFor(access: McpAccess | undefined, frontDoor = false): stri
|
||||
return tools.has("rig_team") ? `Discover the team this grant connects you to by calling rig_team without rigId. A sole granted rig is selected automatically; with several, it returns the granted names to choose from.\n${instructions}` : instructions;
|
||||
}
|
||||
|
||||
/** A configured default must identify exactly one existing local node. No topology relationship, name convention
|
||||
* for a lead, caller identity or task history supplies a missing receiver. Resolve again on each omitted destination. */
|
||||
async function taskDestinationExists(client: DaemonClient, destination: string): Promise<boolean> {
|
||||
const rigName = destination.split("@")[1]!;
|
||||
/** The seats of `rigName` that `name` names, each as its actual session: its node's canonical session, or the
|
||||
* pod-member form while the node has no session yet. A name matches, in this order, a seat's session's own name before
|
||||
* `@`, its pod-member form (logical id `pod.member` written `pod-member`), or that own name's slug, so a seat attached
|
||||
* under its own name is found by the name its team shows. Only seats the caller may address (`addressable`) count, and
|
||||
* the first of these that any of them matches decides: another seat's slug never makes an exact name ambiguous, and a
|
||||
* seat the caller can't address never decides or shadows one it can. No topology relationship, name convention for a
|
||||
* lead, caller identity or task history supplies a seat. Throws when the rig isn't one active rig here or its node list
|
||||
* can't be read. */
|
||||
async function namedSeats(client: DaemonClient, rigName: string, name: string, addressable: (session: string) => boolean): Promise<string[]> {
|
||||
const rig = await resolveRigHandle(client, rigName, { strict: true });
|
||||
if (rig.kind !== "resolved") return false;
|
||||
if (rig.kind !== "resolved") throw new Error(`rig ${rigName} isn't one active rig here`);
|
||||
const nodes = await client.get(`/api/rigs/${encodeURIComponent(rig.id)}/nodes`);
|
||||
if (nodes.status !== 200 || !Array.isArray(nodes.data)) return false;
|
||||
let matches = 0;
|
||||
if (nodes.status !== 200 || !Array.isArray(nodes.data)) throw new Error(`the node list for ${rigName} answered HTTP ${nodes.status}`);
|
||||
const wanted = seatSlug(name);
|
||||
const [byOwn, byPodMember, bySlug] = [new Set<string>(), new Set<string>(), new Set<string>()];
|
||||
for (const entry of nodes.data as unknown[]) {
|
||||
const { logicalId, rigName: name } = (entry ?? {}) as Record<string, unknown>;
|
||||
if (typeof logicalId !== "string" || name !== rigName) return false;
|
||||
const { logicalId, rigName: inRig, canonicalSessionName } = (entry ?? {}) as Record<string, unknown>;
|
||||
if (typeof logicalId !== "string" || inRig !== rigName) throw new Error(`the node list for ${rigName} has a malformed entry`);
|
||||
const dot = logicalId.indexOf(".");
|
||||
if (dot <= 0 || dot === logicalId.length - 1) return false;
|
||||
if (`${logicalId.slice(0, dot)}-${logicalId.slice(dot + 1)}@${name}` === destination) matches++;
|
||||
if (dot <= 0 || dot === logicalId.length - 1) throw new Error(`the node list for ${rigName} has a malformed entry`);
|
||||
const podMember = `${logicalId.slice(0, dot)}-${logicalId.slice(dot + 1)}`;
|
||||
const session = typeof canonicalSessionName === "string" && canonicalSessionName.endsWith(`@${rigName}`)
|
||||
&& canonicalSessionName.length > rigName.length + 1 ? canonicalSessionName : `${podMember}@${rigName}`;
|
||||
const own = session.slice(0, -(rigName.length + 1));
|
||||
if (!addressable(session)) continue;
|
||||
if (name === own) byOwn.add(session);
|
||||
else if (name === podMember) byPodMember.add(session);
|
||||
else if (wanted !== "" && wanted === seatSlug(own)) bySlug.add(session);
|
||||
}
|
||||
return matches === 1;
|
||||
return [...(byOwn.size > 0 ? byOwn : byPodMember.size > 0 ? byPodMember : bySlug)];
|
||||
}
|
||||
|
||||
/** A seat name as a slug: lowercase, with each run of other characters a single hyphen. */
|
||||
const seatSlug = (name: string): string => name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
||||
|
||||
/**
|
||||
* Maps a DaemonResponse to MCP tool result.
|
||||
* Three error levels: HTTP status, in-body errors, and structural failure fields.
|
||||
@@ -804,6 +842,15 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
|
||||
return kind === "work" || (kind === "workspace" && access.workspace);
|
||||
};
|
||||
const scope = access && !access.owner ? scopeFor(access, seatOf) : null;
|
||||
/** A tool's title and plain opening for a connected assistant (an external grant), whose client shows a person these;
|
||||
* rig_whoami's are true for every caller, so it has them everywhere. Another caller keeps the tool's own text, which
|
||||
* covers what only it can do (listing its rig's work, reaching a person, reading a named shared file). The same title
|
||||
* goes in both of MCP's title fields, and the hints are unchanged. */
|
||||
const worded = (tool: string, description: string, annotations: ToolAnnotations): { title?: string; description: string; annotations: ToolAnnotations } => {
|
||||
const plain = PLAIN_WORDING[tool];
|
||||
if (!plain || !(access?.external || tool === "rig_whoami") || !description.startsWith(plain.replaces)) return { description, annotations };
|
||||
return { title: plain.title, description: plain.with + description.slice(plain.replaces.length), annotations: { ...annotations, title: plain.title } };
|
||||
};
|
||||
const caller = access ? { authenticated: true, principal: access.principal } : { authenticated: false, identity: "declared" };
|
||||
/** For an authenticated non-owner: the error for an item it can't reach (or can't read), else null. `owner` asks
|
||||
* whether it may change the item, which needs the item's owner in scope. */
|
||||
@@ -1351,10 +1398,10 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
|
||||
if (offered("rig_queue_create")) server.registerTool(
|
||||
"rig_queue_create",
|
||||
{
|
||||
description: "Hand a task to another seat as a durable queue item. Returns its qitemId: read the answer later with rig_queue_show. If the call times out, check rig_queue_show with the same qitemId before retrying; never retry with a new id. To ask for a revision of earlier work, name it in revises: the new item opens with 'Revision of <id>' and records it in its chainOfRecord.",
|
||||
...worded("rig_queue_create", "Hand a task to another seat as a durable queue item. Returns its qitemId: read the answer later with rig_queue_show. If the call times out, check rig_queue_show with the same qitemId before retrying; never retry with a new id. To ask for a revision of earlier work, name it in revises: the new item opens with 'Revision of <id>' and records it in its chainOfRecord.", ADDITIVE),
|
||||
inputSchema: z.object({
|
||||
destination: access?.external
|
||||
? z.string().optional().describe("Destination seat: member@rig, or its name alone (e.g. workshop-lead) within this connection's team; omit to use the connected team's configured receiver")
|
||||
? z.string().optional().describe("Destination seat: member@rig, or its name alone as the team shows it (e.g. workshop-lead) within this connection's team; omit to use the connected team's configured receiver")
|
||||
: z.string().describe("Destination seat (e.g. dev-impl@my-rig)"),
|
||||
body: z.string().describe("The task: what to do and what to return"),
|
||||
summary: z.string().optional().describe("One-line summary"),
|
||||
@@ -1365,7 +1412,6 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
|
||||
revises: z.string().optional().describe("The qitemId of the work this asks to revise"),
|
||||
alert: z.boolean().optional().describe("For an ask to a person that's urgent: something only they can do, or an approval whose wait costs now (as rig queue create --alert). It stands out in Slack. Without it the ask still posts to Slack unless the person's Slack subscription is urgent-only, in which case it waits on Steering and the TUI"),
|
||||
}),
|
||||
annotations: ADDITIVE,
|
||||
},
|
||||
async ({ destination, body, summary, tags, priority, tier, qitemId, revises, alert }) => {
|
||||
// The server's one receiver counts for this connection only when its grant reaches it. Then a refused destination
|
||||
@@ -1381,22 +1427,46 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
|
||||
// Check scope before looking up the configured receiver: configuration never expands a grant or reveals
|
||||
// another rig's destination. An explicit destination continues through the existing path below.
|
||||
if (!scope?.destination(destination)) return outOfScope("Configured task destination");
|
||||
// The receiver is exactly one seat this connection may address, and the item goes to that seat's actual session.
|
||||
const at = destination.lastIndexOf("@");
|
||||
let seats: string[];
|
||||
try {
|
||||
if (!await taskDestinationExists(client, destination)) return unavailable();
|
||||
seats = await namedSeats(client, destination.slice(at + 1), destination.slice(0, at), (seat) => scope?.destination(seat) === true);
|
||||
} catch { return unavailable(); }
|
||||
if (seats.length !== 1) return unavailable();
|
||||
destination = seats[0]!;
|
||||
} else if (destination !== undefined && access?.external && !destination.includes("@")) {
|
||||
// A person names their lead as their team shows it ("workshop-lead"), and the assistant passes that on. A name
|
||||
// alone is the receiver this connection reaches when it is that seat's name, else a seat of this connection's one
|
||||
// rig; never another rig's. It must be in scope and exist, or nothing is written.
|
||||
// A person names a seat as their team shows it ("workshop-lead"), and the assistant passes that on. A name alone
|
||||
// looks in the rig of the receiver this connection reaches when it is that receiver's name, else in this
|
||||
// connection's one rig; never another rig's. It reaches one seat the connection may address, stored under that
|
||||
// seat's actual session, or nothing is written.
|
||||
const named = destination;
|
||||
const resolved = receiver?.split("@")[0] === named ? receiver
|
||||
: access.rigs.length === 1 ? `${named}@${access.rigs[0]}` : undefined;
|
||||
let exists = false;
|
||||
if (resolved && scope?.destination(resolved)) {
|
||||
try { exists = await taskDestinationExists(client, resolved); } catch { /* not resolved */ }
|
||||
const receiverAt = receiver?.lastIndexOf("@") ?? -1;
|
||||
const rigName = receiver && receiver.slice(0, receiverAt) === named ? receiver.slice(receiverAt + 1)
|
||||
: access.rigs.length === 1 ? access.rigs[0] : undefined;
|
||||
if (!rigName) return outOfScope(`Destination ${named}`, omitHint);
|
||||
// A name this connection may not address as written (another caller's seat without team/list, or its own) is
|
||||
// refused before the team is read, so the answer doesn't say whether such a seat exists.
|
||||
if (named !== "" && !scope?.destination(`${named}@${rigName}`)) return outOfScope(`Destination ${named}`, omitHint);
|
||||
let reachable: string[];
|
||||
try {
|
||||
reachable = named === "" ? [] : await namedSeats(client, rigName, named, (seat) => scope?.destination(seat) === true);
|
||||
} catch {
|
||||
const message = "This connection's team couldn't be read just now, so nothing was written. Try again.";
|
||||
return errorResult(message, "destination_unchecked", message);
|
||||
}
|
||||
if (!exists) return outOfScope(`Destination ${named}`, omitHint);
|
||||
destination = resolved!;
|
||||
if (reachable.length > 1) {
|
||||
const message = `'${named}' names ${reachable.length} seats here. Nothing was written; pass one of these: ${reachable.join(", ")}.`;
|
||||
return errorResult(JSON.stringify({ ok: false, code: "destination_ambiguous", message, candidates: reachable }),
|
||||
"destination_ambiguous", message, { candidates: reachable });
|
||||
}
|
||||
// A seat it may not address reads as no seat at all, so a hidden seat's name says nothing about it.
|
||||
if (reachable.length === 0) {
|
||||
const where = offered("rig_team") ? " rig_team lists the seats you can reach and their names." : " Check the name the person gave.";
|
||||
const message = `No seat here is named '${named}', so nothing was written.${where}${omitHint ? ` ${omitHint}` : ""}`;
|
||||
return errorResult(message, "destination_not_found", message);
|
||||
}
|
||||
destination = reachable[0]!;
|
||||
}
|
||||
if (scope && !scope.destination(destination)) return outOfScope(`Destination ${destination}`, access?.external ? omitHint : undefined);
|
||||
// A revision carries its original's lineage as a handoff does: the original's chain, then the original.
|
||||
@@ -1463,11 +1533,10 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
|
||||
if (offered("rig_queue_show")) server.registerTool(
|
||||
"rig_queue_show",
|
||||
{
|
||||
description: "Read one queue item and its transitions. For a task you handed off, this is where its answer is: the state, the closure reason and target, the notes the assignee recorded, and evidenceRef when it names an artifact (read it with rig_artifact_read). When the item was handed on, successor names the next item (on another host: its id and host); follow it with rig_queue_show. null means none was found. A revision names the work it revises in chainOfRecord.",
|
||||
...worded("rig_queue_show", "Read one queue item and its transitions. For a task you handed off, this is where its answer is: the state, the closure reason and target, the notes the assignee recorded, and evidenceRef when it names an artifact (read it with rig_artifact_read). When the item was handed on, successor names the next item (on another host: its id and host); follow it with rig_queue_show. null means none was found. A revision names the work it revises in chainOfRecord.", READ_ONLY),
|
||||
inputSchema: z.object({
|
||||
qitemId: z.string().describe("The qitemId returned by rig_queue_create (or any queue item)"),
|
||||
}),
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async ({ qitemId }) => {
|
||||
try {
|
||||
@@ -1497,7 +1566,7 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
|
||||
if (offered("rig_queue_list")) server.registerTool(
|
||||
"rig_queue_list",
|
||||
{
|
||||
description: "List queue items. owned: assigned to this seat. mine: this seat is the source or the destination. Without a scope, this seat's rig. Without state, open items only (all adds closed ones); a state filter returns exactly those states, closed ones included. since finds work updated after a time; handedOffFrom finds where a handed-off item went. tags are exact, case-sensitive AND filters; mission and slice add their corresponding exact tags.",
|
||||
...worded("rig_queue_list", "List queue items. owned: assigned to this seat. mine: this seat is the source or the destination. Without a scope, this seat's rig. Without state, open items only (all adds closed ones); a state filter returns exactly those states, closed ones included. since finds work updated after a time; handedOffFrom finds where a handed-off item went. tags are exact, case-sensitive AND filters; mission and slice add their corresponding exact tags.", READ_ONLY),
|
||||
inputSchema: z.object({
|
||||
owned: z.boolean().optional().describe("Only items assigned to this seat: its whole inbox (overrides destination)"),
|
||||
mine: z.boolean().optional().describe("Items this seat sent or owns"),
|
||||
@@ -1512,7 +1581,6 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
|
||||
handedOffFrom: z.string().optional().describe("The items handed on from this qitemId, in any state and any rig"),
|
||||
limit: z.number().int().positive().optional().describe("Result limit (default 100)"),
|
||||
}),
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async ({ owned, mine: mineAsked, destination, source, state, tags: requestedTags, mission, slice, all, since, handedOffFrom, limit }) => {
|
||||
try {
|
||||
@@ -1576,9 +1644,8 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
|
||||
if (offered("rig_queue_claim")) server.registerTool(
|
||||
"rig_queue_claim",
|
||||
{
|
||||
description: "Claim a queue item assigned to this seat before working on it.",
|
||||
...worded("rig_queue_claim", "Claim a queue item assigned to this seat before working on it.", DESTRUCTIVE),
|
||||
inputSchema: z.object({ qitemId: z.string().describe("The queue item to claim") }),
|
||||
annotations: DESTRUCTIVE,
|
||||
},
|
||||
async ({ qitemId }) => {
|
||||
try {
|
||||
@@ -1598,7 +1665,7 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
|
||||
if (offered("rig_queue_update")) server.registerTool(
|
||||
"rig_queue_update",
|
||||
{
|
||||
description: "Update a queue item: add a note, block it, close it, or reopen a closed one. To answer a task, close it with state done, a closure reason and the answer in resolution, which the item keeps; note records why this transition happened.",
|
||||
...worded("rig_queue_update", "Update a queue item: add a note, block it, close it, or reopen a closed one. To answer a task, close it with state done, a closure reason and the answer in resolution, which the item keeps; note records why this transition happened.", DESTRUCTIVE),
|
||||
inputSchema: z.object({
|
||||
qitemId: z.string().describe("The queue item to update"),
|
||||
state: z.string().optional().describe("New state (e.g. in-progress, blocked, done)"),
|
||||
@@ -1612,7 +1679,6 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
|
||||
blockedOn: z.string().optional().describe("What it waits on, when blocking: another qitemId (this item returns to pending when that one closes), or the address of the person this item came from, not a shared channel (they're asked with this item's summary, and their answer returns it to you in progress)"),
|
||||
wakeAfterSeconds: z.number().int().positive().optional().describe("Wake this seat after this many seconds, when blocking. Delivered only to a seat with a terminal: an attached app can't receive it today, so the item just waits until it is checked."),
|
||||
}),
|
||||
annotations: DESTRUCTIVE,
|
||||
},
|
||||
async ({ qitemId, state, closureReason, closureTarget, note, resolution, reopen, summary, evidenceRef, blockedOn, wakeAfterSeconds }) => {
|
||||
try {
|
||||
@@ -1699,9 +1765,8 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
|
||||
if (offered("rig_whoami")) server.registerTool(
|
||||
"rig_whoami",
|
||||
{
|
||||
description: "Show which OpenRig seat this server acts as: its rig, pod, peers and edges, and caller: authenticated (a validated HTTP credential, with its principal) or declared (OPENRIG_SESSION_NAME where the server was configured, which nothing verifies).",
|
||||
...worded("rig_whoami", "Show which OpenRig seat this server acts as: its rig, pod, peers and edges, and caller: authenticated (a validated HTTP credential, with its principal) or declared (OPENRIG_SESSION_NAME where the server was configured, which nothing verifies).", READ_ONLY),
|
||||
inputSchema: z.object({}),
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async () => {
|
||||
try {
|
||||
@@ -1756,11 +1821,10 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
|
||||
if (offered("rig_team")) server.registerTool(
|
||||
"rig_team",
|
||||
{
|
||||
description: "Show a rig's team. For an external grant, omit rigId to use its sole rig or see its granted names to choose from. The answer includes its lead, the rig's one delegates_to root (the seat that delegates to others and that nothing delegates to: the seat to delegate to), and each seat's pod, role, runtime, delegation (reportsTo, delegatesTo) and activity as the daemon reports it. Activity the daemon doesn't know reads unknown, never idle. hostedMembers are the rig's members that live outside a terminal (roster entries marked hosted): their authored capabilities and limits, with no pane and activity unknown.",
|
||||
...worded("rig_team", "Show a rig's team. For an external grant, omit rigId to use its sole rig or see its granted names to choose from. The answer includes its lead, the rig's one delegates_to root (the seat that delegates to others and that nothing delegates to: the seat to delegate to), and each seat's pod, role, runtime, delegation (reportsTo, delegatesTo) and activity as the daemon reports it. Activity the daemon doesn't know reads unknown, never idle. hostedMembers are the rig's members that live outside a terminal (roster entries marked hosted): their authored capabilities and limits, with no pane and activity unknown.", READ_ONLY),
|
||||
inputSchema: z.object({
|
||||
rigId: z.string().optional().describe("Rig name or identifier; optional for an external grant to discover its team"),
|
||||
}),
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async ({ rigId }) => {
|
||||
try {
|
||||
@@ -1933,14 +1997,13 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
|
||||
if (offered("rig_artifact_read")) server.registerTool(
|
||||
"rig_artifact_read",
|
||||
{
|
||||
description: `Read a finished item's artifact (its evidenceRef), or a named file, through the daemon's shared roots (OPENRIG_FILES_ALLOWLIST), as text: at most ${CONTEXT_TEXT_LIMIT} characters from offset; nextOffset reads on. An artifact outside every shared root, a link, or an item with no evidenceRef comes back as available: false with the reason and the shared roots' names; nothing outside those roots is read. A code-result manifest (kind openrig.code-result/v1) also comes back parsed as codeResult: its repo, the exact base and revision commits, and paths; a branch or fetch ref in it is a hint, never the revision.`,
|
||||
...worded("rig_artifact_read", `Read a finished item's artifact (its evidenceRef), or a named file, through the daemon's shared roots (OPENRIG_FILES_ALLOWLIST), as text: at most ${CONTEXT_TEXT_LIMIT} characters from offset; nextOffset reads on. An artifact outside every shared root, a link, or an item with no evidenceRef comes back as available: false with the reason and the shared roots' names; nothing outside those roots is read. A code-result manifest (kind openrig.code-result/v1) also comes back parsed as codeResult: its repo, the exact base and revision commits, and paths; a branch or fetch ref in it is a hint, never the revision.`, READ_ONLY),
|
||||
inputSchema: z.object({
|
||||
qitemId: z.string().optional().describe("Read this item's evidenceRef"),
|
||||
root: z.string().optional().describe("A shared root's name: with path, the file to read; with qitemId, the root a relative evidenceRef is under"),
|
||||
path: z.string().optional().describe("With root: the file's path inside that root"),
|
||||
offset: z.number().int().min(0).optional().describe("The character to start from (default 0)"),
|
||||
}),
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async ({ qitemId, root, path, offset }) => {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { configCommand } from "../src/commands/config.js";
|
||||
import { ConfigStore } from "../src/config-store.js";
|
||||
|
||||
describe("issue #2529: context-pressure inverted environment pair reporting", () => {
|
||||
let root: string;
|
||||
let configPath: string;
|
||||
let savedEnv: Record<string, string | undefined>;
|
||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||
let errSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), "opr-issue-2529-"));
|
||||
configPath = path.join(root, "config.json");
|
||||
savedEnv = {
|
||||
OPENRIG_HEALTH_CONTEXT_PRESSURE_WARNING_PERCENT: process.env["OPENRIG_HEALTH_CONTEXT_PRESSURE_WARNING_PERCENT"],
|
||||
OPENRIG_HEALTH_CONTEXT_PRESSURE_CRITICAL_PERCENT: process.env["OPENRIG_HEALTH_CONTEXT_PRESSURE_CRITICAL_PERCENT"],
|
||||
};
|
||||
delete process.env["OPENRIG_HEALTH_CONTEXT_PRESSURE_WARNING_PERCENT"];
|
||||
delete process.env["OPENRIG_HEALTH_CONTEXT_PRESSURE_CRITICAL_PERCENT"];
|
||||
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
process.exitCode = undefined;
|
||||
for (const [k, v] of Object.entries(savedEnv)) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("persists valid file value and reports inverted environment pair with env sources", async () => {
|
||||
// Scenario 2 from issue #2529:
|
||||
// warning is 98 in env. Setting critical to 97 in file.
|
||||
// The write succeeds, persists critical 97 to file, but reports the inverted pair
|
||||
// and that it falls back to 95/99 defaults in this shell.
|
||||
process.env["OPENRIG_HEALTH_CONTEXT_PRESSURE_WARNING_PERCENT"] = "98";
|
||||
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
||||
|
||||
const cmd = configCommand(configPath);
|
||||
await cmd.parseAsync(["node", "rig", "set", "health.context_pressure.critical_percent", "97"]);
|
||||
|
||||
const fileContent = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
||||
expect(fileContent.health?.contextPressure?.criticalPercent).toBe(97);
|
||||
|
||||
const output = logSpy.mock.calls.map((c) => c.join(" ")).join("\n");
|
||||
expect(output).toContain(
|
||||
"health.context_pressure.critical_percent file override saved; effective value 99 (source: default; inverted pair 98/97 from OPENRIG_HEALTH_CONTEXT_PRESSURE_WARNING_PERCENT=98 falls back to 95/99 defaults in this shell)",
|
||||
);
|
||||
stderr.mockRestore();
|
||||
});
|
||||
|
||||
it("persists and prints cleanly without warning when there is no environment conflict", async () => {
|
||||
const cmd = configCommand(configPath);
|
||||
await cmd.parseAsync(["node", "rig", "set", "health.context_pressure.critical_percent", "100"]);
|
||||
|
||||
const fileContent = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
||||
expect(fileContent.health?.contextPressure?.criticalPercent).toBe(100);
|
||||
|
||||
const output = logSpy.mock.calls.map((c) => c.join(" ")).join("\n");
|
||||
expect(output).toBe("health.context_pressure.critical_percent = 100");
|
||||
});
|
||||
|
||||
it("whole-file reset still works even if config file is malformed JSON", async () => {
|
||||
fs.writeFileSync(configPath, "{ not valid json ... ");
|
||||
const cmd = configCommand(configPath);
|
||||
await cmd.parseAsync(["node", "rig", "reset"]);
|
||||
|
||||
expect(fs.existsSync(configPath)).toBe(false);
|
||||
const output = logSpy.mock.calls.map((c) => c.join(" ")).join("\n");
|
||||
expect(output).toBe("Config reset to defaults.");
|
||||
});
|
||||
|
||||
it("reports inverted pair on reset <key> when active environment override causes fallback", async () => {
|
||||
// File has warning 90, critical 98
|
||||
const store = new ConfigStore(configPath);
|
||||
store.set("health.context_pressure.warning_percent", "90");
|
||||
store.set("health.context_pressure.critical_percent", "98");
|
||||
|
||||
// Environment sets warning=99
|
||||
process.env["OPENRIG_HEALTH_CONTEXT_PRESSURE_WARNING_PERCENT"] = "99";
|
||||
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
||||
|
||||
const cmd = configCommand(configPath);
|
||||
// Resetting critical removes file 98, so file has warning 90 / default critical 99.
|
||||
// But with env warning=99, env warning (99) >= default critical (99), falling back to 95/99.
|
||||
await cmd.parseAsync(["node", "rig", "reset", "health.context_pressure.critical_percent"]);
|
||||
|
||||
const fileContent = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
||||
expect(fileContent.health?.contextPressure?.criticalPercent).toBeUndefined();
|
||||
|
||||
const output = logSpy.mock.calls.map((c) => c.join(" ")).join("\n");
|
||||
expect(output).toContain(
|
||||
"health.context_pressure.critical_percent file override cleared; effective value 99 (source: default; inverted pair 99/99 from OPENRIG_HEALTH_CONTEXT_PRESSURE_WARNING_PERCENT=99 falls back to 95/99 defaults in this shell).",
|
||||
);
|
||||
stderr.mockRestore();
|
||||
});
|
||||
|
||||
it("resets cleanly to default without inversion warning when no environment conflict exists", async () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
store.set("health.context_pressure.critical_percent", "100");
|
||||
|
||||
const cmd = configCommand(configPath);
|
||||
await cmd.parseAsync(["node", "rig", "reset", "health.context_pressure.critical_percent"]);
|
||||
|
||||
const fileContent = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
||||
expect(fileContent.health?.contextPressure?.criticalPercent).toBeUndefined();
|
||||
|
||||
const output = logSpy.mock.calls.map((c) => c.join(" ")).join("\n");
|
||||
expect(output).toBe("health.context_pressure.critical_percent reset to default (99).");
|
||||
expect(output).not.toContain("inverted");
|
||||
});
|
||||
|
||||
it("handles rejected partial environment override without false inversion warning", async () => {
|
||||
// Warning env is partial/malformed '98junk', setting critical to 97 on an empty file.
|
||||
// The resolver discards '98junk' (falls back to default 95), so pair is 95/97 (valid).
|
||||
// The write saves 97, effective is 97 from file, and no inversion/fallback is reported.
|
||||
process.env["OPENRIG_HEALTH_CONTEXT_PRESSURE_WARNING_PERCENT"] = "98junk";
|
||||
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
||||
|
||||
const cmd = configCommand(configPath);
|
||||
await cmd.parseAsync(["node", "rig", "set", "health.context_pressure.critical_percent", "97"]);
|
||||
|
||||
const fileContent = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
||||
expect(fileContent.health?.contextPressure?.criticalPercent).toBe(97);
|
||||
|
||||
const output = logSpy.mock.calls.map((c) => c.join(" ")).join("\n");
|
||||
expect(output).toBe("health.context_pressure.critical_percent = 97");
|
||||
expect(output).not.toContain("inverted");
|
||||
stderr.mockRestore();
|
||||
});
|
||||
|
||||
it("handles rejected out-of-range environment override without false inversion warning", async () => {
|
||||
// Warning env is out-of-range '101', setting critical to 97 on an empty file.
|
||||
// The resolver discards '101' (falls back to default 95), so pair is 95/97 (valid).
|
||||
// The write saves 97, effective is 97 from file, and no inversion/fallback is reported.
|
||||
process.env["OPENRIG_HEALTH_CONTEXT_PRESSURE_WARNING_PERCENT"] = "101";
|
||||
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
||||
|
||||
const cmd = configCommand(configPath);
|
||||
await cmd.parseAsync(["node", "rig", "set", "health.context_pressure.critical_percent", "97"]);
|
||||
|
||||
const fileContent = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
||||
expect(fileContent.health?.contextPressure?.criticalPercent).toBe(97);
|
||||
|
||||
const output = logSpy.mock.calls.map((c) => c.join(" ")).join("\n");
|
||||
expect(output).toBe("health.context_pressure.critical_percent = 97");
|
||||
expect(output).not.toContain("inverted");
|
||||
stderr.mockRestore();
|
||||
});
|
||||
|
||||
it("correctly explains fallback when an invalid file value is discarded and causes inversion", async () => {
|
||||
// File has invalid warningPercent: 0, critical env is 90.
|
||||
// Writing critical 100 to file saves critical 100.
|
||||
// The resolver discards file warningPercent 0, uses default 95.
|
||||
// With critical env 90, warning 95 >= critical 90 causes inversion and fallback to 95/99.
|
||||
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({ health: { contextPressure: { warningPercent: 0 } } }, null, 2) + "\n",
|
||||
);
|
||||
process.env["OPENRIG_HEALTH_CONTEXT_PRESSURE_CRITICAL_PERCENT"] = "90";
|
||||
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
||||
|
||||
const cmd = configCommand(configPath);
|
||||
await cmd.parseAsync(["node", "rig", "set", "health.context_pressure.critical_percent", "100"]);
|
||||
|
||||
const fileContent = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
||||
expect(fileContent.health?.contextPressure?.criticalPercent).toBe(100);
|
||||
|
||||
const output = logSpy.mock.calls.map((c) => c.join(" ")).join("\n");
|
||||
expect(output).toContain(
|
||||
"health.context_pressure.critical_percent file override saved; effective value 99 (source: default; inverted pair 95/90 from OPENRIG_HEALTH_CONTEXT_PRESSURE_CRITICAL_PERCENT=90 falls back to 95/99 defaults in this shell)",
|
||||
);
|
||||
stderr.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -119,7 +119,7 @@ describe("rig config get --with-source", () => {
|
||||
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
||||
try {
|
||||
expect(await run(["set", "health.context_pressure.critical_percent", "97"]))
|
||||
.toContain("file override saved; effective value 99 (source: default)");
|
||||
.toContain("file override saved; effective value 99 (source: default");
|
||||
expect(JSON.parse(fs.readFileSync(configPath, "utf8")).health.contextPressure.criticalPercent).toBe(97);
|
||||
expect(stderr).toHaveBeenCalled();
|
||||
} finally { stderr.mockRestore(); }
|
||||
|
||||
@@ -156,14 +156,14 @@ describe("rig mcp serve --http --grant-service on the daemon's routes", () => {
|
||||
const args = { qitemId: "qitem-first-task", body: "Create a useful file and return it" };
|
||||
const first = await ask("rig_queue_create", args);
|
||||
expect(first.isError).toBe(false);
|
||||
expect(repo.getById(args.qitemId)).toMatchObject({ sourceSession: CALLER, destinationSession: "pod-lead@team-a", body: args.body });
|
||||
expect(repo.getById(args.qitemId)).toMatchObject({ sourceSession: CALLER, destinationSession: "lead@team-a", body: args.body });
|
||||
const retry = await ask("rig_queue_create", args);
|
||||
expect(retry.isError).toBe(false);
|
||||
expect(retry.structured.qitemId).toBe(args.qitemId);
|
||||
expect(rows()).toBe(1);
|
||||
const revision = await ask("rig_queue_create", { body: "Improve the file", revises: args.qitemId });
|
||||
expect(revision.isError).toBe(false);
|
||||
expect(repo.getById(revision.structured.qitemId)).toMatchObject({ sourceSession: CALLER, destinationSession: "pod-lead@team-a",
|
||||
expect(repo.getById(revision.structured.qitemId)).toMatchObject({ sourceSession: CALLER, destinationSession: "lead@team-a",
|
||||
chainOfRecord: [args.qitemId], body: `Revision of ${args.qitemId}.\n\nImprove the file` });
|
||||
// A supplied destination still takes the original route, even with a different configured receiver.
|
||||
expect((await ask("rig_queue_create", { destination: "other@team-a", body: "Explicit task" })).isError).toBe(false);
|
||||
@@ -185,7 +185,7 @@ describe("rig mcp serve --http --grant-service on the daemon's routes", () => {
|
||||
expect(tools.find((tool) => tool.name === "rig_queue_create")?.inputSchema.required).not.toContain("destination");
|
||||
const result = await client.callTool({ name: "rig_queue_create", arguments: { qitemId: "qitem-modern-first", body: "Create the file" } });
|
||||
expect(result.isError).not.toBe(true);
|
||||
expect(repo.getById("qitem-modern-first")).toMatchObject({ sourceSession: CALLER, destinationSession: "pod-lead@team-a" });
|
||||
expect(repo.getById("qitem-modern-first")).toMatchObject({ sourceSession: CALLER, destinationSession: "lead@team-a" });
|
||||
});
|
||||
|
||||
it("does not infer a receiver from grant metadata, caller identity or an empty queue", async () => {
|
||||
@@ -219,16 +219,129 @@ describe("rig mcp serve --http --grant-service on the daemon's routes", () => {
|
||||
const ask = await connect(url);
|
||||
const sent = await ask("rig_queue_create", { qitemId: "qitem-bare-name", destination: "pod-lead", body: "Do this directly" });
|
||||
expect(sent.isError).toBe(false);
|
||||
expect(repo.getById("qitem-bare-name")).toMatchObject({ sourceSession: CALLER, destinationSession: "pod-lead@team-a" });
|
||||
// A name that is no seat there, or the caller's own, writes nothing.
|
||||
for (const name of ["my-lead", "cus-vvmaexample01-caller", ""]) {
|
||||
expect((await ask("rig_queue_create", { destination: name, body: "Nobody" })).structured.code).toBe("out_of_scope");
|
||||
expect(repo.getById("qitem-bare-name")).toMatchObject({ sourceSession: CALLER, destinationSession: "lead@team-a" });
|
||||
// A name that is no seat there says so; the caller's own seat stays a scope refusal. Neither writes anything.
|
||||
for (const name of ["my-lead", ""]) {
|
||||
expect((await ask("rig_queue_create", { destination: name, body: "Nobody" })).structured.code).toBe("destination_not_found");
|
||||
}
|
||||
expect((await ask("rig_queue_create", { destination: "cus-vvmaexample01-caller", body: "Myself" })).structured.code).toBe("out_of_scope");
|
||||
expect(rows()).toBe(1);
|
||||
const described = (await ask.catalog()).tools.find((tool) => tool.name === "rig_queue_create")?.inputSchema.properties?.destination as { description?: string };
|
||||
expect(described.description).toContain("name alone");
|
||||
});
|
||||
|
||||
describe("a name alone reaches the seat's actual session", () => {
|
||||
/** A seat whose session is its own name, as a self-attached seat's is, rather than its pod-member form. */
|
||||
function namedSeat(rigName: string, member: string, session: string) {
|
||||
const rig = setup.rigRepo.listRigs?.().find((r: { name: string }) => r.name === rigName) ?? setup.rigRepo.createRig(rigName);
|
||||
const node = setup.rigRepo.addNode(rig.id, `pod.${member}`, { role: "worker" });
|
||||
setup.sessionRegistry.registerSession(node.id, session);
|
||||
}
|
||||
|
||||
it("finds a seat by its session's own name, that name's slug, or its pod-member form, and stores its actual session", async () => {
|
||||
namedSeat("team-a", "caller-7f3a", "Workshop.Caller@team-a");
|
||||
const { url } = await listen();
|
||||
const ask = await connect(url);
|
||||
for (const [name, qitemId] of [["Workshop.Caller", "qitem-own-name"], ["workshop-caller", "qitem-slug"], ["pod-caller-7f3a", "qitem-pod-member"]]) {
|
||||
const sent = await ask("rig_queue_create", { qitemId, destination: name, body: `Reach me as ${name}` });
|
||||
expect(sent.isError).toBe(false);
|
||||
expect(repo.getById(qitemId)).toMatchObject({ sourceSession: CALLER, destinationSession: "Workshop.Caller@team-a" });
|
||||
}
|
||||
expect(rows()).toBe(3);
|
||||
});
|
||||
|
||||
it("names the candidates when a name matches no seat exactly and two by slug, and writes nothing", async () => {
|
||||
namedSeat("team-a", "reviewer", "reviewer@team-a");
|
||||
namedSeat("team-a", "reviewer-two", "Reviewer@team-a");
|
||||
const { url } = await listen();
|
||||
const ask = await connect(url);
|
||||
const refused = await ask("rig_queue_create", { destination: "REVIEWER", body: "Which one?" });
|
||||
expect(refused.structured).toMatchObject({ code: "destination_ambiguous" });
|
||||
expect([...refused.structured.candidates].sort()).toEqual(["Reviewer@team-a", "reviewer@team-a"]);
|
||||
expect(rows()).toBe(0);
|
||||
});
|
||||
|
||||
it("says no such seat, naming rig_team when the grant offers it, apart from a scope refusal and an unreadable team", async () => {
|
||||
namedSeat("team-a", "lead", "lead@team-a");
|
||||
answer = () => [200, active({ objects: { queue: ["create", "show", "list"], team: ["list"] } })];
|
||||
const { url } = await listen();
|
||||
const ask = await connect(url);
|
||||
const missing = await ask("rig_queue_create", { destination: "led", body: "Typo" });
|
||||
expect(missing.structured).toMatchObject({ code: "destination_not_found" });
|
||||
expect(missing.text).toContain("No seat here is named 'led'");
|
||||
expect(missing.text).toContain("rig_team lists the seats you can reach");
|
||||
broken = (req) => req.method === "GET" && /\/api\/rigs\/[^/]+\/nodes$/.test(new URL(req.url).pathname)
|
||||
? new Response(JSON.stringify({ error: "boom" }), { status: 500, headers: { "Content-Type": "application/json" } }) : undefined;
|
||||
expect((await ask("rig_queue_create", { destination: "lead", body: "Unread" })).structured).toMatchObject({ code: "destination_unchecked" });
|
||||
broken = undefined;
|
||||
expect(rows()).toBe(0);
|
||||
expect((await ask("rig_queue_create", { destination: "lead", body: "Reached" })).isError).toBe(false);
|
||||
expect(rows()).toBe(1);
|
||||
});
|
||||
|
||||
it("an exact name wins over another seat's slug twin, as a name alone and as the configured receiver", async () => {
|
||||
namedSeat("team-a", "lead", "pod-lead@team-a");
|
||||
namedSeat("team-a", "other", "Pod.Lead@team-a");
|
||||
const { url } = await listen(undefined, "pod-lead@team-a");
|
||||
const ask = await connect(url);
|
||||
expect((await ask("rig_queue_create", { qitemId: "qitem-exact-name", destination: "pod-lead", body: "Exact" })).isError).toBe(false);
|
||||
expect((await ask("rig_queue_create", { qitemId: "qitem-exact-receiver", body: "Receiver" })).isError).toBe(false);
|
||||
for (const qitemId of ["qitem-exact-name", "qitem-exact-receiver"]) expect(repo.getById(qitemId)).toMatchObject({ destinationSession: "pod-lead@team-a" });
|
||||
});
|
||||
|
||||
it("a pod-member name that reached one seat still does beside a self-attached slug twin", async () => {
|
||||
namedSeat("team-a", "lead", "lead@team-a");
|
||||
namedSeat("team-a", "twin", "Pod_Lead@team-a");
|
||||
const { url } = await listen();
|
||||
const ask = await connect(url);
|
||||
expect((await ask("rig_queue_create", { qitemId: "qitem-pod-member-kept", destination: "pod-lead", body: "As before" })).isError).toBe(false);
|
||||
expect(repo.getById("qitem-pod-member-kept")).toMatchObject({ destinationSession: "lead@team-a" });
|
||||
});
|
||||
|
||||
it("without team/list, another caller's name answers the same whether or not its seat exists, before the team is read", async () => {
|
||||
let nodeReads = 0;
|
||||
broken = (req) => { if (/\/api\/rigs\/[^/]+\/nodes$/.test(new URL(req.url).pathname)) nodeReads++; return undefined; };
|
||||
const { url } = await listen();
|
||||
const ask = await connect(url);
|
||||
const answers = async () => Promise.all(["cus-other-connection", "Cus_Other_Connection"].map(async (destination) =>
|
||||
(await ask("rig_queue_create", { destination, body: "Hidden?" })).structured));
|
||||
const absent = await answers();
|
||||
namedSeat("team-a", "other-connection", "cus-other-connection@team-a");
|
||||
const present = await answers();
|
||||
expect(present).toEqual(absent);
|
||||
// As written it's out of scope, without a read; a slug of it reads as no seat at all.
|
||||
expect(absent.map((answer) => answer.code)).toEqual(["out_of_scope", "destination_not_found"]);
|
||||
expect(nodeReads).toBe(2);
|
||||
expect(rows()).toBe(0);
|
||||
});
|
||||
|
||||
it("a seat the grant can't address never shadows one it can, by name alone", async () => {
|
||||
namedSeat("team-a", "visible", "Pod_Hidden@team-a");
|
||||
const { url } = await listen();
|
||||
const ask = await connect(url);
|
||||
expect((await ask("rig_queue_create", { qitemId: "qitem-slug-alone", destination: "pod-hidden", body: "Slug" })).isError).toBe(false);
|
||||
// Another caller's seat whose pod-member form is the name exactly: hidden without team/list, so it doesn't count.
|
||||
namedSeat("team-a", "hidden", "cus-other-connection@team-a");
|
||||
expect((await ask("rig_queue_create", { qitemId: "qitem-slug-beside-hidden", destination: "pod-hidden", body: "Slug" })).isError).toBe(false);
|
||||
for (const qitemId of ["qitem-slug-alone", "qitem-slug-beside-hidden"]) expect(repo.getById(qitemId)).toMatchObject({ destinationSession: "Pod_Hidden@team-a" });
|
||||
});
|
||||
|
||||
it("a configured receiver that names only a hidden seat is unavailable, as when it names none, and never names that seat", async () => {
|
||||
const { url } = await listen(undefined, "pod-hidden@team-a");
|
||||
const ask = await connect(url);
|
||||
const none = await ask("rig_queue_create", { body: "Receiver?" });
|
||||
namedSeat("team-a", "hidden", "cus-other-connection@team-a");
|
||||
const hidden = await ask("rig_queue_create", { body: "Receiver?" });
|
||||
expect(hidden.structured).toEqual(none.structured);
|
||||
expect(hidden.structured.code).toBe("task_destination_unavailable");
|
||||
expect(JSON.stringify(hidden)).not.toContain("cus-other-connection");
|
||||
expect(rows()).toBe(0);
|
||||
namedSeat("team-a", "visible", "Pod_Hidden@team-a");
|
||||
expect((await ask("rig_queue_create", { qitemId: "qitem-receiver-beside-hidden", body: "Receiver" })).isError).toBe(false);
|
||||
expect(repo.getById("qitem-receiver-beside-hidden")).toMatchObject({ destinationSession: "Pod_Hidden@team-a" });
|
||||
});
|
||||
});
|
||||
|
||||
it("with several rigs, a name alone reaches only the configured receiver of that name; a refusal says to omit destination, without naming it", async () => {
|
||||
seat("team-a", "lead");
|
||||
seat("team-a", "other"); // A seat of one of the rigs: with several, its name alone still doesn't pick that rig.
|
||||
@@ -236,7 +349,7 @@ describe("rig mcp serve --http --grant-service on the daemon's routes", () => {
|
||||
const { url } = await listen(undefined, "pod-lead@team-a");
|
||||
const ask = await connect(url);
|
||||
expect((await ask("rig_queue_create", { qitemId: "qitem-bare-receiver", destination: "pod-lead", body: "Do this directly" })).isError).toBe(false);
|
||||
expect(repo.getById("qitem-bare-receiver")).toMatchObject({ destinationSession: "pod-lead@team-a" });
|
||||
expect(repo.getById("qitem-bare-receiver")).toMatchObject({ destinationSession: "lead@team-a" });
|
||||
for (const destination of ["pod-other", "my-lead", "pod-lead@team-c"]) {
|
||||
const refused = await ask("rig_queue_create", { destination, body: "Ambiguous or unknown" });
|
||||
expect(refused.structured).toMatchObject({ code: "out_of_scope", message: expect.stringContaining("Omit destination to reach the team's configured receiver.") });
|
||||
@@ -251,9 +364,9 @@ describe("rig mcp serve --http --grant-service on the daemon's routes", () => {
|
||||
const { url } = await listen(undefined, "pod-lead@team-b");
|
||||
const ask = await connect(url);
|
||||
expect((await ask("rig_queue_create", { qitemId: "qitem-own-lead", destination: "pod-lead", body: "Do this directly" })).isError).toBe(false);
|
||||
expect(repo.getById("qitem-own-lead")).toMatchObject({ destinationSession: "pod-lead@team-a" });
|
||||
expect(repo.getById("qitem-own-lead")).toMatchObject({ destinationSession: "lead@team-a" });
|
||||
const refused = await ask("rig_queue_create", { destination: "my-lead", body: "Nobody" });
|
||||
expect(refused.structured.code).toBe("out_of_scope");
|
||||
expect(refused.structured.code).toBe("destination_not_found");
|
||||
expect(refused.structured.message).not.toContain("Omit destination");
|
||||
expect(rows()).toBe(1);
|
||||
});
|
||||
|
||||
@@ -96,7 +96,8 @@ describe("MCP operation table against the daemon", () => {
|
||||
external: { objects: { question: ["read", "answer"] }, person: "ada" } } });
|
||||
const personTools = (await asPerson.listTools()).tools;
|
||||
expect(personTools.map((tool) => tool.name).sort()).toEqual(MCP_OPERATIONS.filter((op) => op.access === "person").map((op) => op.tool).sort());
|
||||
const hints = Object.fromEntries([...tools, ...personTools].map((tool) => [tool.name, tool.annotations]));
|
||||
// A title is a display name, not a hint.
|
||||
const hints = Object.fromEntries([...tools, ...personTools].map((tool) => [tool.name, { ...tool.annotations, title: undefined }]));
|
||||
for (const op of MCP_OPERATIONS) {
|
||||
expect(hints[op.tool], op.tool).toEqual(HINTS[op.effect]);
|
||||
// Every tool says who an authenticated HTTP caller must be to be offered it.
|
||||
|
||||
@@ -4,7 +4,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
||||
import { createMcpServer, dropUnfilledSeat, mintUnknownSender, FRONT_DOOR_WORKFLOW, FRONT_DOOR_WORKFLOW_VERSION, MEMBER_WORKFLOW, MEMBER_WORKFLOW_VERSION, type McpAccess, type UnfilledSeat } from "../src/mcp-server.js";
|
||||
import { createMcpServer, dropUnfilledSeat, mintUnknownSender, FRONT_DOOR_WORKFLOW, FRONT_DOOR_WORKFLOW_VERSION, MEMBER_WORKFLOW, MEMBER_WORKFLOW_VERSION, PLAIN_WORDING, type McpAccess, type UnfilledSeat } from "../src/mcp-server.js";
|
||||
import { DaemonClient, DaemonConnectionError, senderIdentityHeaders, type DaemonResponse } from "../src/client.js";
|
||||
|
||||
function mockClient(overrides?: {
|
||||
@@ -246,7 +246,8 @@ describe("MCP Server", () => {
|
||||
// A delegated decision closes the person's ask; a handoff closes the item passed on; an unclaim ends a claim.
|
||||
const destructive = ["rig_bind", "rig_discover", "rig_down", "rig_queue_claim", "rig_queue_decide", "rig_queue_handoff", "rig_queue_unclaim",
|
||||
"rig_queue_update", "rig_restore", "rig_send", "rig_up"];
|
||||
const hints = Object.fromEntries(tools.map((tool) => [tool.name, tool.annotations]));
|
||||
// A title is a display name, not a hint (the plain-wording test checks it).
|
||||
const hints = Object.fromEntries(tools.map((tool) => [tool.name, { ...tool.annotations, title: undefined }]));
|
||||
expect(Object.keys(hints).sort()).toEqual([...reads, ...additive, ...destructive].sort());
|
||||
for (const name of reads) expect(hints[name], name).toEqual({ readOnlyHint: true });
|
||||
for (const name of additive) expect(hints[name], name).toEqual({ readOnlyHint: false, destructiveHint: false });
|
||||
@@ -881,6 +882,54 @@ describe("MCP Server", () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it("shows a connected assistant's person plain titles and openings, keeping each tool's rules and hints", async () => {
|
||||
// What a person sees: each tool's title and the first sentence of its description.
|
||||
const plain: Record<string, [title: string, sentence: string]> = {
|
||||
rig_queue_list: ["List my OpenRig requests", "Lists the requests addressed to this assistant, or that it sent."],
|
||||
rig_queue_show: ["Show one request", "Shows one request, who picked it up, and its answer."],
|
||||
rig_queue_create: ["Send a request", "Sends a request to another assistant on your team."],
|
||||
rig_queue_claim: ["Pick up a request", "Marks a request addressed to this assistant as picked up, so others see it's being worked on."],
|
||||
rig_queue_update: ["Answer a request", "Adds this assistant's answer to a request, or a note while it works."],
|
||||
rig_artifact_read: ["Read a shared file", "Reads a file attached to a request."],
|
||||
rig_team: ["Show my team", "Shows the assistants on your team, each one's address for sending it a request, and which one leads."],
|
||||
rig_whoami: ["Show this connection", "Shows which assistant this connection is and which team it belongs to."],
|
||||
};
|
||||
expect(Object.keys(PLAIN_WORDING).sort()).toEqual(Object.keys(plain).sort());
|
||||
const catalog = async (options?: Parameters<typeof createMcpServer>[1]) => {
|
||||
const server = createMcpServer(mockClient(), options);
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
const client = new Client({ name: "catalog-client", version: "1.0.0" });
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const { tools } = await client.listTools();
|
||||
await client.close();
|
||||
await server.close();
|
||||
return new Map(tools.map((tool) => [tool.name, tool]));
|
||||
};
|
||||
const local = await catalog();
|
||||
const external = await catalog({ seat: "cus-1-caller@team", access: { principal: "cus-1", owner: false, rigs: ["team"], roots: [], workspace: false,
|
||||
external: { objects: { queue: ["create", "show", "list", "claim", "update"], team: ["list"], artifact: ["read"] } } } });
|
||||
for (const [name, [title, sentence]] of Object.entries(plain)) {
|
||||
const own = local.get(name)!;
|
||||
// rig_whoami isn't offered to an external grant, and its plain text is true for every caller, so the local one shows it.
|
||||
const shown = name === "rig_whoami" ? own : external.get(name)!;
|
||||
expect(shown.title, name).toBe(title);
|
||||
expect(shown.annotations?.title, name).toBe(title);
|
||||
expect(shown.description?.slice(0, sentence.length + 1), name).toBe(`${sentence} `);
|
||||
if (name === "rig_whoami") continue;
|
||||
// Only the opening changes: every rule after it, and each hint, is the tool's own.
|
||||
const { replaces, with: opening } = PLAIN_WORDING[name]!;
|
||||
expect(own.description?.startsWith(replaces), name).toBe(true);
|
||||
expect(shown.description, name).toBe(opening + own.description!.slice(replaces.length));
|
||||
expect({ ...shown.annotations, title: undefined }, name).toEqual(own.annotations);
|
||||
// A local caller keeps the tool's own text, which says what only it can do.
|
||||
expect(own.title, name).toBeUndefined();
|
||||
expect(own.annotations?.title, name).toBeUndefined();
|
||||
}
|
||||
expect(local.get("rig_whoami")?.description).toBe("Shows which assistant this connection is and which team it belongs to. It answers with its rig, pod, peers and edges, and caller: authenticated (a validated HTTP credential, with its principal) or declared (OPENRIG_SESSION_NAME where the server was configured, which nothing verifies).");
|
||||
expect(local.get("rig_whoami")?.annotations).toEqual({ readOnlyHint: true, title: "Show this connection" });
|
||||
});
|
||||
|
||||
it("rig_down calls POST /api/down with correct params", async () => {
|
||||
const postFn = vi.fn(async () => ({
|
||||
status: 200,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openrig",
|
||||
"version": "0.1.0",
|
||||
"version": "0.7.14",
|
||||
"description": "Work with your OpenRig team from your coding agent: hand it work, follow that work, answer its questions and read what it returns.",
|
||||
"author": {
|
||||
"name": "Mike Schwarz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openrig",
|
||||
"version": "0.1.0",
|
||||
"version": "0.7.14",
|
||||
"description": "Work with your OpenRig team from your coding agent: hand it work, follow that work, answer its questions and read what it returns.",
|
||||
"author": {
|
||||
"name": "Mike Schwarz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openrig",
|
||||
"version": "0.1.0",
|
||||
"version": "0.7.14",
|
||||
"description": "Work with your OpenRig team from your coding agent: hand it work, follow that work, answer its questions and read what it returns.",
|
||||
"logo": "assets/logo.svg"
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
||||
"name": "openrig",
|
||||
"version": "0.1.0",
|
||||
"version": "0.7.14",
|
||||
"description": "Work with your OpenRig team from your coding agent: hand it work, follow that work, answer its questions and read what it returns.",
|
||||
"author": {
|
||||
"name": "Mike Schwarz",
|
||||
|
||||
@@ -10,8 +10,8 @@ already does it, or two verbs crossed already answer it. **You cannot look up a
|
||||
not know exists.** So the point of what follows is not skill. It is recognition: that when a task
|
||||
lands, something rhymes, and you go check instead of building.
|
||||
|
||||
Ninety-two top-level verbs ship (capability canon refreshed through
|
||||
`capability-delta-v0.7.12`). This marker describes the pack's teaching, not publication or live
|
||||
Ninety-three top-level verbs ship (capability canon refreshed through
|
||||
`capability-delta-v0.7.14`). This marker describes the pack's teaching, not publication or live
|
||||
adoption. Read this once for shape, and let it make you suspicious that a thing already exists.
|
||||
What changed in the installed version, and what to do about it, is one read away:
|
||||
`rig context get reference/whats-new.md`.
|
||||
@@ -33,7 +33,10 @@ one command away. Your memory of the fleet is a claim about the past.
|
||||
*including the broken ones*. **The bare form is the question you usually want:** `--active`
|
||||
filters to running and **hides detached, exited and attention states** — which are exactly the
|
||||
cells you were scanning for. A seat whose tmux session is confirmed gone reads `detached`
|
||||
(activity reason `session_missing`), and `rig ps --nodes` says to restore or relaunch it.
|
||||
(activity reason `session_missing`), and `rig ps --nodes` says to restore or relaunch it. Since 0.7.14 `rig ps` ends with connected
|
||||
assistants and their check-in line, and node reads take turns through their three synchronous
|
||||
steps, so other reads and sends aren't held behind a burst of them; a nodes response is no longer
|
||||
one snapshot across those steps, and in a burst each one comes later.
|
||||
A verified exited process in a retained pane reads `pane_exited`, instead of running and ready;
|
||||
outstanding work uses the existing recovery-finding route. An unknown observation is not an exit.
|
||||
`rig ps --nodes` also shows the model each seat was last seen using (`modelObservation`, with its
|
||||
@@ -109,7 +112,8 @@ one command away. Your memory of the fleet is a claim about the past.
|
||||
Closed work's content and metadata can also be erased on a schedule
|
||||
(`retention.closed_content_days`, `retention.closed_metadata_days`; `0`, the default, keeps them),
|
||||
with an optional `retention.erasure_ledger`, for a retention policy or an account deletion. It's
|
||||
opt-in, and a retried create can't bring erased work back.
|
||||
opt-in, and a retried create can't bring erased work back. Since 0.7.14 an account's erasure also
|
||||
removes OpenRig's own wakes and notices for that account's seats.
|
||||
- **Check the exit code.** Many commands used to print nothing and exit 0 when a daemon read
|
||||
failed; now they exit 1 or 2. Empty output from a failed read is not empty state.
|
||||
|
||||
@@ -124,7 +128,9 @@ unseen — which makes messages the one delivery channel that never gets skipped
|
||||
consequential delivery gets an effect check at the far end rather than a blind retry.
|
||||
`--verify` exits 1 when it can't confirm what its Enter did (`submission unconfirmed`, or text
|
||||
staged but not submitted): the message may still have arrived, so inspect with `rig capture`
|
||||
before any resend, and don't let a script resend on that exit code alone. A
|
||||
before any resend, and don't let a script resend on that exit code alone. Since 0.7.14 an ordinary
|
||||
send is refused as needing input while an open Claude Code or Codex `/model` picker is
|
||||
positively identified; nothing is typed. A
|
||||
producer-link advisory reading `no_activity_signal` means activity could not be determined;
|
||||
confirm by effect, never relaunch on it. A send or wake to a seat whose latest hook is a
|
||||
question waiting for a person is refused with `target_needs_input`, even when its screen
|
||||
@@ -175,7 +181,8 @@ unseen — which makes messages the one delivery channel that never gets skipped
|
||||
and the box was empty after Enter; it only reports.
|
||||
- **`rig broadcast`** — one fact to everyone at once, rather than relaying it N times and getting
|
||||
the wording wrong on the fourth. **Blast radius is real** — on a large rig this lands in every
|
||||
seat's turn. A broadcast that reached no one says so.
|
||||
seat's turn. A broadcast that reached no one says so, and a recipient a seat's setting held
|
||||
reads as retained, not sent.
|
||||
- **`rig capture <seat>`** — read what is on their screen right now. **`--rig` / `--pod`** captures
|
||||
every seat in one call. `--lines N` is the last N lines (stderr says how many earlier lines were
|
||||
cut); `--history-plus-pane` gives the older N history lines plus the whole pane. A short capture
|
||||
@@ -255,7 +262,7 @@ unseen — which makes messages the one delivery channel that never gets skipped
|
||||
default. **Opening `rig terminal open saved:kernel --window` is the step after install or start**:
|
||||
say you're opening it, run it on the daemon's desktop, then tell the person where it opened. Don't
|
||||
wait for a phrase like “show me my agents”; the same command brings their agents back later. No
|
||||
YAML, herdr when installed or plain tmux. The layout follows the terminal's width: on a narrow
|
||||
YAML, herdr when installed or plain tmux. Rex tiles show each seat's agent and its activity. The layout follows the terminal's width: on a narrow
|
||||
one the operator gets the first tab; on a wide one the dashboard and operator sit side by side
|
||||
and the advisor gets its own tab. A `kernel` view the person saved still wins.
|
||||
Tiles show their seat's screen without attaching, so whole lines show at any tile width; a
|
||||
@@ -480,7 +487,8 @@ relay is a reason to inspect the available routing, not to surrender judgment to
|
||||
- **`rig workflow validate`** — will this spec instantiate at all, before a run finds out for you. A
|
||||
step with no allowed exit that can finish it or route onward is reported as `step_cannot_finish`.
|
||||
An allowed mapped exit is valid; in a dependency graph, `handoff` can finish a sink without a next
|
||||
step.
|
||||
step. Since 0.7.14 each step with a `harness:` pin gets a `harness_pin_unchecked` warning:
|
||||
validation doesn't check pins against seats.
|
||||
- **`rig workflow compile <mission>`** / **`instantiate-lifecycle`** — derive one executable graph
|
||||
from `project.yaml` → `mission.yaml` → `slice.yaml`, inspect it without writing, then start an
|
||||
eligible graph with an opaque replay key. Typed acceptance candidate, verdict, and evidence
|
||||
@@ -632,6 +640,8 @@ not have to tear one down to change it, and you rarely have to start from nothin
|
||||
steps): hand it work, follow it, answer the team's questions in their own words, and read the
|
||||
result. VS Code, Copilot CLI and Cursor load it with no seat: give the app its own
|
||||
`rig mcp serve --front-door` server with `OPENRIG_SESSION_NAME` set and turn the plugin's off.
|
||||
A skill-only edition for Claude's plugin directory, in `plugins/claude-directory/`, follows
|
||||
handed-on work and keeps its reply route.
|
||||
- **A Claude member can bring 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 own storage, never the project, so one seat in a shared repository can have its own
|
||||
@@ -749,7 +759,10 @@ not have to tear one down to change it, and you rarely have to start from nothin
|
||||
conversation continuity stays unverified. Both refuse while typing protection is on.
|
||||
- **`rig unclaim` / `rig release`** — stop managing one adopted session, or all of them,
|
||||
**without killing the process or the agent inside it**. Handing something back to its human is a
|
||||
first-class move, not an abandonment.
|
||||
first-class move, not an abandonment. To stop an adopted session after unclaiming it, end its
|
||||
process and its tmux session; closing only the viewing window detaches it. Since 0.7.13 a fresh
|
||||
`rig launch` after `rig unclaim` ended the server's last session reopens the server (a released
|
||||
session that's still running blocks it, as before).
|
||||
|
||||
> **Two words that mean different things at different altitudes.** `unclaim` and `release` each
|
||||
> exist both here — *stop managing a live session* — and in the queue — *put a work row back down*.
|
||||
@@ -866,7 +879,9 @@ version, from checking the environment to a useful report to the OpenRig team.
|
||||
effectful handover path.** Both forms perform the operation by default; pass `--dry-run`
|
||||
to plan without changing the seat. Check the selected source and continuity evidence first. A
|
||||
seat can be handed over again into its own pane, so a seat left half-handed-over recovers by
|
||||
running the handover again. A Claude seat started through a `#!` wrapper that runs Claude without
|
||||
running the handover again. A handover whose successor was launched but not ready says it may be
|
||||
running: look at the pane before retrying. A handover cleans up the retiring occupant's own claims
|
||||
and jobs. A Claude seat started through a `#!` wrapper that runs Claude without
|
||||
`exec`, or through the installer's `~/.local/bin/claude` symlink, rebuilds with `--source
|
||||
rebuild`; a failed rebuild leaves the resume target on the earlier conversation, so rebuild again
|
||||
rather than relaunching it fresh.
|
||||
@@ -957,7 +972,8 @@ version, from checking the environment to a useful report to the OpenRig team.
|
||||
- **`rig compact-plan`** → **`rig compact`** — who is near the context wall, then act on it.
|
||||
**Ordering matters: running `compact` without the plan is guessing which seat needed it.**
|
||||
Managed compaction waits for the seat's restore map before compacting; `rig compact --state`,
|
||||
`--cancel` and `--skip-map` inspect, stop or skip that wait. If preparation stops at an ordinary
|
||||
`--cancel` and `--skip-map` inspect, stop or skip that wait; since 0.7.14 `--state` also names the
|
||||
input check behind a `draft_input_unknown` stop (`inputReadFailure`). If preparation stops at an ordinary
|
||||
approval, a manual `rig compact` keeps waiting for someone to answer it, within its 180-second
|
||||
budget; OpenRig never answers it, and the request times out after 240 seconds. The `claude-compaction-restore`
|
||||
skill covers writing and reading the map; `claude-compact-in-place` no longer ships. After a
|
||||
@@ -1231,6 +1247,9 @@ reaching them is ordinary work rather than an escalation.
|
||||
see which host you are acting on, and check a registered host is *actually* usable end to end.
|
||||
`rig host edit <id>` corrects an entry (its id and transport stay fixed), and `rig host remove <id>` deletes
|
||||
only the registry entry: credential files, the learned identity and the selection stay as they are.
|
||||
- **`rig network`** — **experimental, unsupported and at your own risk.** It connects one local seat
|
||||
to a hub you already have. The daemon starts the connector, sweeps leftover credential folders and
|
||||
schedules local rounds even with no binding, so it isn't inert, and its migrations apply on upgrade.
|
||||
- **A cross-host queue write that times out reads INDETERMINATE, not failed.** The CLI waits 15
|
||||
seconds for the daemon's answer, and `rig queue create` with no answer reads the row back once by
|
||||
its ID. Reconcile by that ID before any retry; don't send the write twice.
|
||||
@@ -1275,6 +1294,11 @@ reaching them is ordinary work rather than an escalation.
|
||||
a person can decide directly in the TUI's pairing settings. A relay connector that can't accept
|
||||
incoming connections can pull instead (`OPENRIG_RELAY_MODE=pull` in the relay secrets file); push
|
||||
stays the default, and a person's connector binding can be unset.
|
||||
**Since 0.7.13 a person's runner answer gets a note back:** who decides and in what state, or that
|
||||
the answer is kept and the tool hasn't run, and a runner waiting on it wakes on the decision. Read
|
||||
that note before asking the person to answer again. The answer a receiving agent reads says what an
|
||||
answer typed at the machine's terminal means, to act on a clear one with the request's own decision
|
||||
command, and how to ask the person back, with a new ask to them, when it's unclear.
|
||||
- **The `relay` gateway connector** carries a registered person's questions, updates and notices to a
|
||||
bridge process you run, over loopback HTTP, and takes their replies back with the sender resolved
|
||||
against their bindings. It's off until a person has a relay binding (`rig gateway human add <id> …
|
||||
@@ -1413,7 +1437,8 @@ your circumstances is configuration, and the ones that are not, another agent ca
|
||||
conversation it is in the middle of. **`provider signals` reports anomalies rather than a
|
||||
listing** — unbound seats, accounts shared across several seats. A Codex seat showing its
|
||||
usage-limit banner reads activity `unknown` with reason `usage_limit`, and keeps it while its
|
||||
screen redraws; `provider signals` can show a provider-event signal for that seat's account that
|
||||
screen redraws (a banner with a curly apostrophe included); a Claude seat whose usage-limit message
|
||||
states a reset time is parked until then, instead of being retried every few minutes; `provider signals` can show a provider-event signal for that seat's account that
|
||||
allows a switch decision. With `provider.usage_windows` on (off by default), `rig provider status`
|
||||
shows Codex's windows and Claude's 5-hour and weekly windows from the seats' status lines, each
|
||||
seat's configured account, and with `--all-hosts` or `--hosts <ids>` every registered machine's
|
||||
@@ -1484,7 +1509,14 @@ your circumstances is configuration, and the ones that are not, another agent ca
|
||||
(empty, the default, means every signed-in one). `--router-line yes|no` decides whether OpenRig
|
||||
adds its one line to shared guidance files (`skills.router_line.enabled`).
|
||||
Setup offers to register the person when none is registered, and a team started from the
|
||||
person's own assistant fits their own logins, with a review before anything is committed.
|
||||
person's own assistant fits their own logins, with a review before anything is committed. Since
|
||||
0.7.13, `rig daemon start` ends with that offer when the person registry can be read and holds
|
||||
nobody. The kernel operator's startup is also taught to run `rig gateway participants` before its
|
||||
first greeting and offer registration when nobody is registered. The agent asks first and
|
||||
registers only on the person's yes. Since 0.7.14 setup guidance tells the agent to fit a first
|
||||
team to the person's logins: a login `rig doctor` reports missing matters when a seat you'd start
|
||||
runs on that harness, so say which one before launch
|
||||
(`rig specs preview <team> --kind rig --json` shows each seat's runtime).
|
||||
- **`rig usage series`** — what a seat's token curve has looked like over time: climbing steadily,
|
||||
reset, or stopped reporting entirely. The last one is a signal, not a gap.
|
||||
- **`rig usage top`** — which of this machine's seats used the most tokens over a window (`--window
|
||||
@@ -1569,7 +1601,12 @@ your circumstances is configuration, and the ones that are not, another agent ca
|
||||
Clients on MCP protocol revision 2026-07-28 and on the 2025 revisions share the same endpoint.
|
||||
With a grant service (experimental and off by default; you serve it to the internet behind
|
||||
your own tunnel), `--task-destination <member@rig>` names where a caller's `rig_queue_create`
|
||||
goes when it gives no destination, and a seat's name alone resolves within the grant's team. A
|
||||
goes when it gives no destination, and a seat's name alone resolves within the grant's team. Since
|
||||
0.7.14 a plain name finds the seat's actual session (its own name, then its pod-member form, then
|
||||
that name's slug, among seats the grant can address): an ambiguous name returns
|
||||
`destination_ambiguous` with the candidates, an unknown one `destination_not_found`, and an
|
||||
unreadable team `destination_unchecked`. Its person sees each tool's plain title, and under a
|
||||
grant service's fair-use limit only new work is held back. A
|
||||
grant that names a person adds `rig_question_list` and `rig_question_answer`, so that person
|
||||
answers the team's questions from their assistant, recorded as their answer through that
|
||||
connection.
|
||||
@@ -1581,6 +1618,10 @@ your circumstances is configuration, and the ones that are not, another agent ca
|
||||
with secret names, never values): OpenRig then POSTs a pointer to the row when work lands, and a
|
||||
2xx records only that the ring was accepted, not that anything woke. When work waits for a
|
||||
member nothing can wake, or a rung member doesn't claim it in time, its person is told.
|
||||
A hosted member's roster entry shows whether it's checking in: its expected cadence and its last
|
||||
check-in, read as checking in, overdue, not yet checked in or checking in on request, and unknown
|
||||
when that can't be read. It's a status, never a gate on delivery. Since 0.7.14 `rig ps`,
|
||||
`rig_team` and the TUI's tables list connected assistants with that check-in line.
|
||||
**Internet-facing hosted members are experimental, unsupported, off by default and at your
|
||||
own risk.** Local assistants can connect locally. Internet access needs your own TLS tunnel or
|
||||
proxy and a narrowly scoped, revocable token; see `reference/hosted-members.md`. Task terminal
|
||||
@@ -1638,7 +1679,8 @@ database. It is one more set of primitives on a machine full of them.
|
||||
original byte size and the exact expansion command. Read the complete original record with
|
||||
`rig queue show <qitemId> --full --json`; a preview or a compact list is not the whole body.
|
||||
Compact `rig queue list` rows carry `summary`; the fields they leave out are absent, not empty,
|
||||
and `fieldsElided` names them.
|
||||
and `fieldsElided` names them. Since 0.7.14 `--tag` (repeatable; every tag must match),
|
||||
`--mission` and `--slice` filter exactly, and bounded listings over retained history use indexes.
|
||||
Delivery status is separate from content: inspect its receipt before claiming it was consumed.
|
||||
- **"What am I waiting on?"** — the queue row's `waiting` view derives its owner, exact blocker
|
||||
and blocker owner, last meaningful change, activity confidence and next backstop from live
|
||||
@@ -1676,6 +1718,6 @@ deliberate writing.
|
||||
|
||||
**`rig --help`**, then `rig <verb> --help`.
|
||||
|
||||
Ninety-two verbs ship, and this page named most of them once. **You will not remember which — you
|
||||
Ninety-three verbs ship, and this page named most of them once. **You will not remember which — you
|
||||
are meant to remember only that the list is long enough to be worth reading before you build
|
||||
anything.** The question is never *how do I write this*; it is **does this already exist**.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openrig/daemon",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.14",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -48,6 +48,23 @@ import { parseSqliteUtcMs } from "../domain/sqlite-time.js";
|
||||
const generationCensus = new ProcessCensus({ freshnessMs: 0 }); // coalesce concurrent receipts; recheck each later read
|
||||
const generationThreadIds = new CodexThreadIdResolver({ codexHome: process.env.CODEX_HOME || undefined });
|
||||
|
||||
/** The node inventory's synchronous steps run one at a time, each after a turn of the event loop. They're SQLite work
|
||||
* that can't yield, so concurrent requests ran their whole builds back to back and a read queued behind a burst (a
|
||||
* /healthz, a queue read) waited for all of them; now it waits for one step. Steps that await I/O run outside. */
|
||||
let inventoryTurn: Promise<void> = Promise.resolve();
|
||||
async function inventoryStep<T>(work: () => T): Promise<Awaited<T>> {
|
||||
const previous = inventoryTurn;
|
||||
let release = () => {};
|
||||
inventoryTurn = new Promise<void>((resolve) => { release = () => resolve(); });
|
||||
try {
|
||||
await previous;
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
return await work();
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
function terminalAuthGuard(): MiddlewareHandler {
|
||||
return async (c, next) => {
|
||||
const token = (c.get("terminalBearerToken" as never) as string | null | undefined) ?? null;
|
||||
@@ -130,9 +147,9 @@ nodesRoutes.get("/", observeInventoryHandler, async (c) => {
|
||||
|
||||
const contextUsageStore = c.get("contextUsageStore" as never) as ContextUsageStore | undefined;
|
||||
const transcriptStore = c.get("transcriptStore" as never) as TranscriptStore | undefined;
|
||||
const inventory = contextUsageStore
|
||||
const inventory = await inventoryStep(() => contextUsageStore
|
||||
? getNodeInventoryWithContext(deps.rigRepo.db, rigId, contextUsageStore, transcriptStore)
|
||||
: getNodeInventory(deps.rigRepo.db, rigId);
|
||||
: getNodeInventory(deps.rigRepo.db, rigId));
|
||||
// Slice 15 — enrich with the two new orthogonal primitives. Order is
|
||||
// independent (each enrichment reads its own source), so the chain
|
||||
// composes cleanly with attachAgentActivity. The non-inference
|
||||
@@ -142,7 +159,8 @@ nodesRoutes.get("/", observeInventoryHandler, async (c) => {
|
||||
// capture). Opt into the per-node fallback with ?full=true / ?refresh=true — the
|
||||
// latter already implies a fresh sweep above.
|
||||
const full = c.req.query("full") === "true" || refresh;
|
||||
const withActivity = await attachAgentActivity(inventory, {
|
||||
// The default path reads no pane, so it is one more synchronous step; a full read awaits pane captures, outside.
|
||||
const attachActivity = () => attachAgentActivity(inventory, {
|
||||
...inventoryCaptureOptions(deps.rigRepo.db, deps.shadowCapture),
|
||||
tmuxAdapter: deps.tmuxAdapter,
|
||||
activityStore: deps.agentActivityStore,
|
||||
@@ -163,11 +181,12 @@ nodesRoutes.get("/", observeInventoryHandler, async (c) => {
|
||||
seatActivity: deps.seatActivityService,
|
||||
captureFallback: full,
|
||||
});
|
||||
const withTerminalAndWork = attachTerminalActivityAndWork(withActivity, {
|
||||
const withActivity = full ? await attachActivity() : await inventoryStep(attachActivity);
|
||||
const withTerminalAndWork = await inventoryStep(() => attachTerminalActivityAndWork(withActivity, {
|
||||
db: deps.rigRepo.db,
|
||||
seatActivity: deps.seatActivityService,
|
||||
agentActivity: deps.agentActivityStore,
|
||||
});
|
||||
}));
|
||||
// Slice 13 fix 2 — HOST ATTRIBUTION AT THE SOURCE. A daemon only inventories its own seats, so
|
||||
// every row it serves lives on THIS host; stamping the boot-reconciled self-id here means a
|
||||
// merged multi-host roster stays attributable row-by-row (a roster missing a host is legible as
|
||||
|
||||
@@ -23,6 +23,10 @@ import { HEALTHZ_RESPONSIVENESS_BUDGET_MS } from "../src/domain/event-loop-monit
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(HERE, "../../..");
|
||||
const CHILD = path.resolve(HERE, "fixtures/ps-stall-http-child.ts");
|
||||
// The nodes wave repeats until its probes have this many /healthz samples (the ps+summary burst takes about 20),
|
||||
// so its worst probe is a measurement, not one tick; the cap keeps it inside the run's watchdog.
|
||||
const NODES_MIN_PROBES = 20;
|
||||
const NODES_BURST_CAP_MS = 20_000;
|
||||
|
||||
// exact SQL signatures (normalized, lowercased) of the two per-node fold scans
|
||||
const STARTUP_SIG = "type in ('node.startup_challenged','node.startup_proof_skipped','node.startup_proof_verified','node.startup_proof_rejected')";
|
||||
@@ -256,26 +260,39 @@ describe("slice-04 ps/summary event-loop regression", () => {
|
||||
})();
|
||||
if (!nodes) probe = startProbe();
|
||||
|
||||
const reqs: Promise<unknown>[] = [];
|
||||
for (let i = 0; i < 24; i++) {
|
||||
if (nodes) {
|
||||
const rig = `rig-act-${String(i % ACTIVE_RIGS).padStart(2, "0")}`;
|
||||
reqs.push(fetch(`${base}/api/rigs/${rig}/nodes`).then(async (r) => {
|
||||
psStatuses.push(r.status);
|
||||
const rows = await r.json();
|
||||
nodeCounts.push(Array.isArray(rows) && rows.every((n: { rigId: string }) => n.rigId === rig) ? rows.length : 0);
|
||||
}));
|
||||
continue;
|
||||
if (nodes) {
|
||||
// One wave of 24 nodes reads ends before a second probe can start, so one sample decided the budget. The same
|
||||
// wave is sent again, each one probed only while it runs, until the probes number NODES_MIN_PROBES (or the cap).
|
||||
const burstUntil = performance.now() + NODES_BURST_CAP_MS;
|
||||
do {
|
||||
const reqs: Promise<unknown>[] = [];
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const rig = `rig-act-${String(i % ACTIVE_RIGS).padStart(2, "0")}`;
|
||||
reqs.push(fetch(`${base}/api/rigs/${rig}/nodes`).then(async (r) => {
|
||||
psStatuses.push(r.status);
|
||||
const rows = await r.json();
|
||||
nodeCounts.push(Array.isArray(rows) && rows.every((n: { rigId: string }) => n.rigId === rig) ? rows.length : 0);
|
||||
}));
|
||||
}
|
||||
// Queue the competing reads after the wave's requests, not ahead of all of them.
|
||||
stop = false;
|
||||
probe = startProbe();
|
||||
await Promise.all(reqs);
|
||||
stop = true;
|
||||
await probe;
|
||||
probe = null;
|
||||
} while (health.length < NODES_MIN_PROBES && performance.now() < burstUntil);
|
||||
} else {
|
||||
const reqs: Promise<unknown>[] = [];
|
||||
for (let i = 0; i < 24; i++) {
|
||||
reqs.push(fetch(base + "/api/ps").then((r) => { psStatuses.push(r.status); return r.text(); }));
|
||||
reqs.push(fetch(base + "/api/rigs/summary").then((r) => { sumStatuses.push(r.status); return r.text(); }));
|
||||
}
|
||||
reqs.push(fetch(base + "/api/ps").then((r) => { psStatuses.push(r.status); return r.text(); }));
|
||||
reqs.push(fetch(base + "/api/rigs/summary").then((r) => { sumStatuses.push(r.status); return r.text(); }));
|
||||
await Promise.all(reqs);
|
||||
stop = true;
|
||||
await probe;
|
||||
probe = null;
|
||||
}
|
||||
// For nodes, queue the competing reads after the burst requests, not ahead of all of them.
|
||||
if (nodes) probe = startProbe();
|
||||
await Promise.all(reqs);
|
||||
stop = true;
|
||||
await probe;
|
||||
probe = null;
|
||||
} finally {
|
||||
// Stop + ABORT the probe first so its in-flight fetch cannot stall the drain.
|
||||
stop = true;
|
||||
@@ -306,7 +323,7 @@ describe("slice-04 ps/summary event-loop regression", () => {
|
||||
? `${Math.max(...health.map((h) => h.ms))} ms over ${health.length} probes`
|
||||
: "no probes completed";
|
||||
console.log(`[${nodes ? "nodes" : "ps-summary"} D2] worst /healthz ${measured} (budget ${HEALTHZ_RESPONSIVENESS_BUDGET_MS} ms; `
|
||||
+ `${nodes ? "nodes" : "ps"} 200s ${psStatuses.filter((st) => st === 200).length}/24`
|
||||
+ `${nodes ? "nodes" : "ps"} 200s ${psStatuses.filter((st) => st === 200).length}/${nodes ? psStatuses.length : 24}`
|
||||
+ (nodes ? ")" : `, summary 200s ${sumStatuses.filter((st) => st === 200).length}/24)`));
|
||||
if (nodes) {
|
||||
const stats = (samples: typeof health) => ({ count: samples.length, worstMs: Math.max(0, ...samples.map(s => s.ms)), statuses: samples.map(s => s.status) });
|
||||
@@ -315,10 +332,13 @@ describe("slice-04 ps/summary event-loop regression", () => {
|
||||
}
|
||||
|
||||
// Teardown is guaranteed done above; now assert (the budget pin is the LAST assertion).
|
||||
expect(psStatuses).toHaveLength(24);
|
||||
if (nodes) expect(psStatuses.length).toBeGreaterThanOrEqual(24);
|
||||
else expect(psStatuses).toHaveLength(24);
|
||||
expect(psStatuses.every((s) => s === 200)).toBe(true);
|
||||
if (nodes) {
|
||||
expect(nodeCounts).toHaveLength(24);
|
||||
expect(nodeCounts).toHaveLength(psStatuses.length);
|
||||
// Fewer means the burst ended (or hit its cap) before enough probes ran, and the worst below is too few samples.
|
||||
expect(health.length, `the nodes burst took only ${health.length} /healthz probes`).toBeGreaterThanOrEqual(NODES_MIN_PROBES);
|
||||
expect(nodeCounts.every(n => n > 0)).toBe(true);
|
||||
expect(baselineHealth).toHaveLength(8);
|
||||
expect(baselineQueue).toHaveLength(8);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openrig/ui",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.14",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"release": "0.7.12",
|
||||
"archiveSha256": "e90b2487fb744d7ca69e52e13b2aaef2a204fcfa76db00c373248acc0795eb52",
|
||||
"release": "0.7.13",
|
||||
"archiveSha256": "534e9d5d626368e585d1ba19df20df7d97577153aabef61b9fca68a05edc86e8",
|
||||
"plugins": {
|
||||
"openrig": {
|
||||
"version": "0.1.0",
|
||||
"sha256": "ee8a1300119576f36889cc6494988c69faa3dfb2c3172cfd856a7ecc3edc0506"
|
||||
"version": "0.7.13",
|
||||
"sha256": "62a5c308ec64b77d678b4276fc81dce933a7ef610da93790bc6a30a3dc3be990"
|
||||
},
|
||||
"openrig-core": {
|
||||
"version": "0.1.28",
|
||||
"sha256": "59b2fb34be931a423583283696c22034fa417530ea682268a68156a70730fb5f"
|
||||
"version": "0.1.29",
|
||||
"sha256": "5e08deffe33feed6f7beff6df39d70402248632f87e3b63f074835b400fff54d"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user