mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-12 18:48:31 -10:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fc45a0bf2 | ||
|
|
126efafeeb | ||
|
|
568ccef975 | ||
|
|
ec2ad9e1bd | ||
|
|
7ee288cdaf | ||
|
|
6d2b7468ce | ||
|
|
761edea6a9 | ||
|
|
77bc2161ce | ||
|
|
18337c55ec |
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
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import Database from "better-sqlite3";
|
||||
|
||||
/** Rowids one statement may cover before the read yields to the event loop. Soft: one large row's conversion, native
|
||||
* I/O or a correlated subquery on a matching row can still make a window take longer. */
|
||||
const WINDOW_ROWS = 256;
|
||||
/** Rows read per slice, and the time a slice may take, before yielding to the event loop. Both are soft: one native
|
||||
* step or row conversion can take longer. */
|
||||
const SLICE_ROWS = 64;
|
||||
const SLICE_MS = 8;
|
||||
|
||||
/**
|
||||
* The main database file when the execution view's candidate read can run cooperatively on its own connection, or
|
||||
@@ -17,66 +18,45 @@ export function cooperativeReadFile(db: Database.Database): string | null {
|
||||
return db.pragma("journal_mode", { simple: true }) === "wal" ? main.file : null;
|
||||
}
|
||||
|
||||
/** One cooperative read: its rows, the windows it read, and the longest synchronous stretch it ran between yields. */
|
||||
export interface CooperativeRead<Row> {
|
||||
rows: Row[];
|
||||
windows: number;
|
||||
longestStretchMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads every row of one SELECT over `table` (a table name from code, never input) on a new readonly connection, one
|
||||
* rowid window at a time, yielding to the event loop between windows so unrelated requests progress during a long
|
||||
* scan. `sql` reads one window: it binds the window's exclusive lower and inclusive upper rowid first
|
||||
* (`table.rowid > ? AND table.rowid <= ?`), then `binds`. Each statement steps over at most `windowRows` rowids however
|
||||
* few rows its filter matches; a cursor that yields between returned rows can scan the whole table in one step when
|
||||
* matches are rare. One read transaction keeps one snapshot for every window, and rows come back in rowid order. The
|
||||
* transaction and the connection end before returning, on every path; a read error is rethrown as-is and no partial
|
||||
* rows are returned.
|
||||
* Reads every row of one SELECT on a new readonly connection to `file`, yielding to the event loop between slices so
|
||||
* unrelated requests progress during a long scan. One cursor keeps one snapshot for the whole read. The cursor and
|
||||
* the connection are closed before returning, on every path; a read error is rethrown as-is and no partial rows are
|
||||
* returned.
|
||||
*/
|
||||
export async function readRowsCooperatively<Row>(
|
||||
file: string,
|
||||
table: string,
|
||||
sql: string,
|
||||
binds: unknown[],
|
||||
open: (file: string) => Database.Database = (path) => new Database(path, { readonly: true, fileMustExist: true }),
|
||||
windowRows = WINDOW_ROWS,
|
||||
): Promise<CooperativeRead<Row>> {
|
||||
): Promise<Row[]> {
|
||||
let reader: Database.Database | undefined;
|
||||
let cursor: IterableIterator<unknown> | undefined;
|
||||
let rows: Row[] = [];
|
||||
let windows = 0, longestStretchMs = 0, stretchStart = performance.now();
|
||||
const endStretch = () => { longestStretchMs = Math.max(longestStretchMs, performance.now() - stretchStart); };
|
||||
let failure: { error: unknown } | undefined;
|
||||
let cleanupFailure: { error: unknown } | undefined;
|
||||
try {
|
||||
reader = open(file);
|
||||
reader.exec("BEGIN");
|
||||
// The first read takes the snapshot every window shares. Each scalar subquery seeks one end of the rowid b-tree;
|
||||
// one SELECT of MIN and MAX together would scan a whole index instead.
|
||||
const { lo, hi } = reader.prepare(`SELECT (SELECT MIN(rowid) FROM ${table}) AS lo, (SELECT MAX(rowid) FROM ${table}) AS hi`)
|
||||
.get() as { lo: number | null; hi: number | null };
|
||||
const window = reader.prepare(sql);
|
||||
if (lo !== null && hi !== null) {
|
||||
for (let after = lo - 1; after < hi;) {
|
||||
const through = Math.min(after + windowRows, hi);
|
||||
for (const row of window.all(after, through, ...binds)) rows.push(row as Row);
|
||||
windows++;
|
||||
after = through;
|
||||
if (after < hi) {
|
||||
endStretch();
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
stretchStart = performance.now();
|
||||
}
|
||||
cursor = reader.prepare(sql).iterate(...binds);
|
||||
for (;;) {
|
||||
const sliceStart = performance.now();
|
||||
let read = 0;
|
||||
let step = cursor.next();
|
||||
while (!step.done) {
|
||||
rows.push(step.value as Row);
|
||||
if (++read >= SLICE_ROWS || performance.now() - sliceStart >= SLICE_MS) break;
|
||||
step = cursor.next();
|
||||
}
|
||||
if (step.done) break;
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
}
|
||||
} catch (error) {
|
||||
failure = { error };
|
||||
} finally {
|
||||
try { if (reader?.inTransaction) reader.exec("ROLLBACK"); } catch (error) { cleanupFailure = { error }; }
|
||||
try { cursor?.return?.(); } catch (error) { cleanupFailure = { error }; }
|
||||
try { reader?.close(); } catch (error) { cleanupFailure ??= { error }; }
|
||||
}
|
||||
if (failure) { rows = []; throw failure.error; }
|
||||
if (cleanupFailure) { rows = []; throw cleanupFailure.error; }
|
||||
endStretch();
|
||||
return { rows, windows, longestStretchMs };
|
||||
return rows;
|
||||
}
|
||||
@@ -54,7 +54,6 @@ import { resolveLegacyTopologyRigsRoot } from "./user-settings/settings-store.js
|
||||
import { BUILD_INFO, type BuildInfo } from "../build-info.js";
|
||||
import { validateMissionComposition } from "./lifecycle-manifest.js";
|
||||
import { cooperativeReadFile, readRowsCooperatively } from "./execution-candidate-reader.js";
|
||||
import { recordSlowDiagnostic } from "./sync-site-wrap.js";
|
||||
|
||||
export const INDETERMINATE = "INDETERMINATE" as const;
|
||||
export type Indeterminate = typeof INDETERMINATE;
|
||||
@@ -749,7 +748,6 @@ async function gitAncestor(exec: ExecutionViewDeps["exec"], repoCtx: string, sha
|
||||
}
|
||||
|
||||
export async function buildExecutionView(deps: ExecutionViewDeps, opts?: { mission?: string; rig?: string; project?: string }): Promise<Record<string, unknown>> {
|
||||
const projectionStarted = performance.now();
|
||||
const now = deps.now ?? (() => new Date());
|
||||
const exec = deps.exec ?? defaultExec;
|
||||
const buildInfo = deps.buildInfo ?? BUILD_INFO;
|
||||
@@ -808,30 +806,23 @@ export async function buildExecutionView(deps: ExecutionViewDeps, opts?: { missi
|
||||
// post_claim_motion feeds only derivePickup's legacy fallback, which pending and in-progress rows reach: a blocked row
|
||||
// returns parked and a closed row terminal before the count is read. Every row and body is still selected here (the
|
||||
// ladder, EC-3 and wave-map evidence need historical rows); only their unused history count is skipped.
|
||||
const candidateSelect = `SELECT qitem_id, source_session, destination_session, state, tags, body, summary, claimed_at,
|
||||
const candidateSql = `SELECT qitem_id, source_session, destination_session, state, tags, body, summary, claimed_at,
|
||||
last_heartbeat, blocked_on, ts_created, ts_updated,
|
||||
CASE WHEN queue_items.state IN ('pending','in-progress') THEN (SELECT COUNT(*) FROM queue_transitions t
|
||||
WHERE t.qitem_id = queue_items.qitem_id
|
||||
AND t.ts > queue_items.claimed_at
|
||||
AND t.transition_note IS NOT 'claimed') ELSE 0 END AS post_claim_motion
|
||||
FROM queue_items`;
|
||||
FROM queue_items
|
||||
WHERE (${missionWhere})`;
|
||||
const candidateBinds = missionLikes.flatMap((like) => [like, like]);
|
||||
// On a WAL database file this full scan runs on its own readonly connection in rowid windows, yielding between
|
||||
// windows so unrelated work can run even when few rows match; the same filter runs inside each window, so the rows
|
||||
// are the ones the single statement returns. A native open, a large row or a window's matching rows can still
|
||||
// block for a while, since the window bound is soft. It sees committed data as of its first read. Inside a
|
||||
// transaction, or for a memory or non-WAL database, it stays one synchronous read on the main handle (no await on
|
||||
// that branch).
|
||||
// On a WAL database file this full scan runs on its own readonly cursor and yields between slices so unrelated
|
||||
// work can run; a native open, prepare or step can still block, since the slice budget is soft. It sees committed
|
||||
// data as of its first step. Inside a transaction, or for a memory or non-WAL database, it stays a synchronous
|
||||
// read on the main handle (no await on that branch).
|
||||
const cooperativeFile = cooperativeReadFile(deps.db);
|
||||
const candidateStarted = performance.now();
|
||||
const candidateRead = cooperativeFile === null
|
||||
? null
|
||||
: await readRowsCooperatively<QueueRowLite>(cooperativeFile, "queue_items",
|
||||
`${candidateSelect} WHERE queue_items.rowid > ? AND queue_items.rowid <= ? AND (${missionWhere})`, candidateBinds);
|
||||
const candidateRows = candidateRead
|
||||
? candidateRead.rows
|
||||
: deps.db.prepare(`${candidateSelect} WHERE (${missionWhere})`).all(...candidateBinds) as QueueRowLite[];
|
||||
const candidateMs = performance.now() - candidateStarted;
|
||||
const candidateRows = cooperativeFile === null
|
||||
? deps.db.prepare(candidateSql).all(...candidateBinds) as QueueRowLite[]
|
||||
: await readRowsCooperatively<QueueRowLite>(cooperativeFile, candidateSql, candidateBinds);
|
||||
|
||||
const rows = candidateRows
|
||||
.filter(row => mission === INDETERMINATE || rowBindsToMission(row.tags, missionRefs))
|
||||
@@ -1366,7 +1357,7 @@ export async function buildExecutionView(deps: ExecutionViewDeps, opts?: { missi
|
||||
df_margin: dfMargin,
|
||||
};
|
||||
|
||||
const view = {
|
||||
return {
|
||||
...(opts?.project ? { project: opts.project, membership: "exact project:<id> queue tags and lifecycle identity; unscoped rows excluded" } : {}),
|
||||
view: "execution",
|
||||
readiness,
|
||||
@@ -1419,19 +1410,4 @@ export async function buildExecutionView(deps: ExecutionViewDeps, opts?: { missi
|
||||
q5_park: q5,
|
||||
q6_parallelism: q6,
|
||||
};
|
||||
// Measurement only, for a slow projection: its elapsed time, the candidate read's elapsed time, and the candidate
|
||||
// read's longest synchronous stretch, which a whole-view or awaited span cannot show. The main-handle read is one
|
||||
// stretch. Counts and times only; no row content.
|
||||
const projectionMs = performance.now() - projectionStarted;
|
||||
recordSlowDiagnostic(projectionMs, {
|
||||
schema: "openrig.execution-view/v1",
|
||||
utc: new Date().toISOString(),
|
||||
projectionMs,
|
||||
candidateMs,
|
||||
candidateRows: candidateRows.length,
|
||||
candidateRead: candidateRead ? "windowed" : "main-handle",
|
||||
candidateWindows: candidateRead?.windows ?? null,
|
||||
candidateLongestStretchMs: candidateRead?.longestStretchMs ?? candidateMs,
|
||||
});
|
||||
return view;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SLOW_OPERATION_THRESHOLD_MS, type SlowOperationInstrumentation } from "./slow-op-recorder.js";
|
||||
import type { SlowOperationInstrumentation } from "./slow-op-recorder.js";
|
||||
|
||||
let recorder: SlowOperationInstrumentation | undefined;
|
||||
|
||||
@@ -21,13 +21,3 @@ export function runSyncSite<T>(site: string, fn: () => T): T {
|
||||
export async function runAsyncSite<T>(site: string, fn: () => Promise<T>): Promise<T> {
|
||||
return recorder?.runStage ? recorder.runStage(site, fn) : fn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Offers one diagnostic record to the configured recorder's bounded log, only when `elapsedMs` reached the slow
|
||||
* threshold, so a hot path adds no record per call. Measurement only: no recorder, a dropped record or a throwing
|
||||
* recorder changes nothing for the caller.
|
||||
*/
|
||||
export function recordSlowDiagnostic(elapsedMs: number, record: Record<string, unknown>): void {
|
||||
if (!(elapsedMs >= SLOW_OPERATION_THRESHOLD_MS)) return;
|
||||
try { recorder?.recordDiagnostic?.(record); } catch { /* the recorder reports its own health */ }
|
||||
}
|
||||
@@ -17,8 +17,6 @@ import { viewsCustomSchema } from "../src/db/migrations/030_views_custom.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { ViewProjector } from "../src/domain/view-projector.js";
|
||||
import { cooperativeReadFile, readRowsCooperatively } from "../src/domain/execution-candidate-reader.js";
|
||||
import { buildExecutionView } from "../src/domain/execution-view.js";
|
||||
import { configureSyncSiteRecorder, recordSlowDiagnostic } from "../src/domain/sync-site-wrap.js";
|
||||
|
||||
const dirs: string[] = [];
|
||||
const handles: Database.Database[] = [];
|
||||
@@ -55,69 +53,42 @@ function turns(): { count: () => number; stop: () => void } {
|
||||
return { count: () => count, stop: () => { stopped = true; } };
|
||||
}
|
||||
|
||||
/** One window of t, the way the execution view binds its windows: exclusive lower and inclusive upper rowid first. */
|
||||
const WINDOW_SQL = "SELECT id, v FROM t WHERE id > ? AND id <= ? ORDER BY id";
|
||||
|
||||
describe("cooperative candidate reads", () => {
|
||||
it("reads every row in rowid order on its own connection, a window at a time, letting other callbacks run between windows", async () => {
|
||||
it("reads every row in order on its own connection, letting other callbacks run between slices", async () => {
|
||||
const { file } = walTable(500);
|
||||
const loop = turns(), reader = recordingOpen();
|
||||
const read = await readRowsCooperatively<{ id: number }>(file, "t", WINDOW_SQL, [], reader.open, 64);
|
||||
const rows = await readRowsCooperatively<{ id: number }>(file, "SELECT id, v FROM t ORDER BY id", [], reader.open);
|
||||
loop.stop();
|
||||
expect(reader.readers.map((db) => [db.readonly, db.open, db.inTransaction])).toEqual([[true, false, false]]); // readonly, and closed before returning
|
||||
expect(read.rows.map((r) => r.id)).toEqual(Array.from({ length: 500 }, (_, i) => i + 1));
|
||||
expect(read.windows).toBe(8);
|
||||
expect(reader.readers.map((db) => [db.readonly, db.open])).toEqual([[true, false]]); // readonly, and closed before returning
|
||||
expect(rows.map((r) => r.id)).toEqual(Array.from({ length: 500 }, (_, i) => i + 1));
|
||||
expect(loop.count()).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("yields between windows even when the filter matches almost nothing", async () => {
|
||||
const { file } = walTable(2000);
|
||||
const loop = turns();
|
||||
const read = await readRowsCooperatively<{ id: number }>(file, "t", "SELECT id FROM t WHERE id > ? AND id <= ? AND v LIKE ?", ['%"a":2000}%']);
|
||||
loop.stop();
|
||||
expect(read.rows).toEqual([{ id: 2000 }]);
|
||||
expect(read.windows).toBe(8); // 2000 rowids, 256 per window
|
||||
// A cursor that yields only between returned rows reaches its one match in a single native step, with no turn between.
|
||||
expect(loop.count()).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("reads an empty table as no rows, and steps over rowid gaps", async () => {
|
||||
const { file, db } = walTable(0);
|
||||
expect(await readRowsCooperatively(file, "t", WINDOW_SQL, [])).toMatchObject({ rows: [], windows: 0 });
|
||||
db.prepare("INSERT INTO t(id, v) VALUES (1, 'a'), (1000, 'b')").run();
|
||||
const read = await readRowsCooperatively<{ id: number }>(file, "t", WINDOW_SQL, []);
|
||||
expect(read.rows.map((r) => r.id)).toEqual([1, 1000]);
|
||||
expect(read.windows).toBe(4);
|
||||
});
|
||||
|
||||
it("keeps one snapshot for every window while writers commit between windows; the next read sees them", async () => {
|
||||
it("keeps one snapshot for the whole read while writers commit between slices; the next read sees them", async () => {
|
||||
const { file, db } = walTable(500);
|
||||
// Runs at the reader's first yield, after its first window: every change lands in a window it has not read yet.
|
||||
// Runs at the reader's first yield, after its cursor has started.
|
||||
setImmediate(() => db.transaction(() => {
|
||||
db.prepare("UPDATE t SET v = 'changed' WHERE id = 500").run();
|
||||
db.prepare("DELETE FROM t WHERE id = 400").run();
|
||||
db.prepare("UPDATE t SET v = 'changed' WHERE id = 1").run();
|
||||
db.prepare("INSERT INTO t(id, v) VALUES (501, 'new')").run();
|
||||
})());
|
||||
const during = await readRowsCooperatively<{ id: number; v: string }>(file, "t", WINDOW_SQL, [], undefined, 64);
|
||||
expect(during.rows.map((r) => r.id)).toEqual(Array.from({ length: 500 }, (_, i) => i + 1));
|
||||
expect(during.rows.at(-1)).toEqual({ id: 500, v: JSON.stringify({ a: 500 }) });
|
||||
const after = await readRowsCooperatively<{ id: number; v: string }>(file, "t", WINDOW_SQL, []);
|
||||
expect(after.rows).toHaveLength(500);
|
||||
expect(after.rows.some((r) => r.id === 400)).toBe(false);
|
||||
expect(after.rows.slice(-2)).toEqual([{ id: 500, v: "changed" }, { id: 501, v: "new" }]);
|
||||
const during = await readRowsCooperatively<{ id: number; v: string }>(file, "SELECT id, v FROM t ORDER BY id", []);
|
||||
expect(during).toHaveLength(500);
|
||||
expect(during[0]!.v).toBe(JSON.stringify({ a: 1 }));
|
||||
const after = await readRowsCooperatively<{ id: number; v: string }>(file, "SELECT id, v FROM t ORDER BY id", []);
|
||||
expect(after).toHaveLength(501);
|
||||
expect(after[0]!.v).toBe("changed");
|
||||
});
|
||||
|
||||
it("rethrows a native error raised in a later window, returns nothing, and leaves no reader pinning the WAL", async () => {
|
||||
it("rethrows a native error raised mid-read, returns nothing, and leaves no reader pinning the WAL", async () => {
|
||||
const { file, db } = walTable(500, 300);
|
||||
const reader = recordingOpen();
|
||||
const sql = "SELECT json_extract(v, '$.a') AS a FROM t WHERE id > ? AND id <= ? ORDER BY id";
|
||||
const read = readRowsCooperatively(file, "t", sql, [], reader.open, 64);
|
||||
const read = readRowsCooperatively(file, "SELECT json_extract(v, '$.a') AS a FROM t ORDER BY id", [], reader.open);
|
||||
await expect(read).rejects.toMatchObject({ name: "SqliteError", code: "SQLITE_ERROR" });
|
||||
expect(reader.readers.map((db) => db.open)).toEqual([false]);
|
||||
db.prepare("INSERT INTO t(id, v) VALUES (501, '{}')").run();
|
||||
expect(db.pragma("wal_checkpoint(TRUNCATE)")).toEqual([{ busy: 0, log: 0, checkpointed: 0 }]);
|
||||
db.prepare("DELETE FROM t WHERE id = 300").run();
|
||||
expect((await readRowsCooperatively(file, "t", sql, [])).rows).toHaveLength(500);
|
||||
expect(await readRowsCooperatively(file, "SELECT json_extract(v, '$.a') AS a FROM t ORDER BY id", [])).toHaveLength(500);
|
||||
});
|
||||
|
||||
it("stays on the main handle inside a transaction and for memory or non-WAL databases", () => {
|
||||
@@ -139,18 +110,12 @@ describe("the projector's execution queue", () => {
|
||||
migrate(db, [coreSchema, bindingsSessionsSchema, rigArchiveSchema, eventsSchema, queueItemSummarySchema, queueItemsSchema, queueTransitionsSchema, queueTransitionWakesSchema, viewsCustomSchema]);
|
||||
return db;
|
||||
}
|
||||
const insertRow = (db: Database.Database, id: string, state: string, tags: string[] | null, body: string, claimedAt: string | null = "2026-10-25T11:00:00.000Z") =>
|
||||
const insertRow = (db: Database.Database, id: string, state: string, tags: string[], body: string, claimedAt: string | null = "2026-10-25T11:00:00.000Z") =>
|
||||
db.prepare(`INSERT INTO queue_items (qitem_id, ts_created, ts_updated, source_session, destination_session, state, priority, tier, tags, body, claimed_at, last_heartbeat, blocked_on)
|
||||
VALUES (?, '2026-10-25T10:00:00.000Z', '2026-10-25T10:00:00.000Z', 'lead@r', 'builder@r', ?, 'normal', 'light', ?, ?, ?, NULL, ?)`)
|
||||
.run(id, state, tags && JSON.stringify(tags), body, claimedAt, state === "blocked" ? "external:review" : null);
|
||||
.run(id, state, JSON.stringify(tags), body, claimedAt, state === "blocked" ? "external:review" : null);
|
||||
const laneIds = (doc: Record<string, unknown>) => (doc.q1_lanes as Array<{ qitem_id: string }>).map((l) => l.qitem_id).sort();
|
||||
const tick = () => new Promise<void>((resolve) => setImmediate(resolve));
|
||||
// The free-disk reading is taken live at read time (fs.statfsSync); every other field must match.
|
||||
const withoutLiveDisk = (doc: unknown) => {
|
||||
const copy = structuredClone(doc) as { q6_parallelism?: { df_margin?: { available_kib?: unknown } } };
|
||||
if (copy.q6_parallelism?.df_margin) delete copy.q6_parallelism.df_margin.available_kib;
|
||||
return copy;
|
||||
};
|
||||
|
||||
it("builds one execution at a time in arrival order, each reading fresh, and a failed build does not block the next", async () => {
|
||||
const db = fixture(tempFile("queue.sqlite"));
|
||||
@@ -211,90 +176,14 @@ describe("the projector's execution queue", () => {
|
||||
seed(memory); seed(file);
|
||||
expect(cooperativeReadFile(memory)).toBeNull();
|
||||
expect(cooperativeReadFile(file)).not.toBeNull();
|
||||
// The free-disk reading is taken live at read time (fs.statfsSync); every other field must match.
|
||||
const withoutLiveDisk = (doc: unknown) => {
|
||||
const copy = structuredClone(doc) as { q6_parallelism?: { df_margin?: { available_kib?: unknown } } };
|
||||
if (copy.q6_parallelism?.df_margin) delete copy.q6_parallelism.df_margin.available_kib;
|
||||
return copy;
|
||||
};
|
||||
const legacy = await documentOf(memory);
|
||||
expect(withoutLiveDisk(await documentOf(file))).toEqual(withoutLiveDisk(legacy));
|
||||
expect((legacy as { q1_lanes: unknown[] }).q1_lanes.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("binds the same rows through the windowed read when the mission filter matches few of them, edge cases included", async () => {
|
||||
const missionsRoot = path.dirname(tempFile("missions"));
|
||||
fs.mkdirSync(path.join(missionsRoot, "release-9"));
|
||||
fs.writeFileSync(path.join(missionsRoot, "release-9", "SPEC.md"), "---\nid: OPR_9\n---\n\n# Fixture mission\n");
|
||||
const edges: Array<[id: string, state: string, tags: string[] | null, body: string]> = [
|
||||
["qitem-tagged", "in-progress", ["mission:release-9", "project:p1", "slice:S1"], "Tagged."],
|
||||
["qitem-alias-tag", "in-progress", ["mission:OPR_9", "project:p2", "slice:S2"], "Tagged with the mission's SPEC id."],
|
||||
["qitem-case", "in-progress", ["mission:RELEASE-9", "slice:S3"], "Tagged in another ASCII case."],
|
||||
["qitem-longer-tag", "in-progress", ["mission:release-90", "slice:S4"], "Another mission whose name contains this one."],
|
||||
["qitem-other-tag", "in-progress", ["mission:other", "slice:S5"], "Tagged elsewhere; mentions release-9."],
|
||||
["qitem-body", "in-progress", ["slice:S6", "project:p1"], "Untagged work for release-9."],
|
||||
["qitem-wildcard", "in-progress", ["slice:S7"], "Untagged; mentions OPRx9, which LIKE's _ matches."],
|
||||
["qitem-null-tags", "in-progress", null, "Slice: S8\nNo tags; mentions release-9."],
|
||||
["qitem-empty-body", "in-progress", ["mission:release-9", "slice:S9"], ""],
|
||||
["qitem-null-tags-empty-body", "in-progress", null, ""],
|
||||
["qitem-moved", "in-progress", ["mission:release-9", "slice:S10"], "Claimed, then moved three times."],
|
||||
];
|
||||
// 700 unrelated rows around the edge rows, so matches are sparse and fall in different windows.
|
||||
const seed = (db: Database.Database) => db.transaction(() => {
|
||||
const fill = (from: number, to: number) => {
|
||||
for (let i = from; i < to; i++) insertRow(db, `qitem-filler-${String(i).padStart(4, "0")}`, "done", ["slice:S1"], `Unrelated work ${i}.`);
|
||||
};
|
||||
fill(0, 300);
|
||||
for (const [id, state, tags, body] of edges.slice(0, 4)) insertRow(db, id, state, tags, body);
|
||||
fill(300, 600);
|
||||
for (const [id, state, tags, body] of edges.slice(4)) insertRow(db, id, state, tags, body);
|
||||
fill(600, 700);
|
||||
for (let n = 0; n < 3; n++) {
|
||||
db.prepare("INSERT INTO queue_transitions (qitem_id, ts, state, transition_note, actor_session) VALUES ('qitem-moved', ?, 'in-progress', 'moved', 'builder@r')")
|
||||
.run(`2026-10-25T11:3${n}:00.000Z`);
|
||||
}
|
||||
})();
|
||||
const memory = fixture(), file = fixture(tempFile("sparse.sqlite"));
|
||||
seed(memory); seed(file);
|
||||
expect(cooperativeReadFile(file)).not.toBeNull();
|
||||
const deps = (db: Database.Database) => ({ db, now: () => NOW, slicesRoot: () => missionsRoot, rigsRoot: () => "/nonexistent-rigs-root",
|
||||
exec: async (): Promise<string> => { throw new Error("not a repository"); } });
|
||||
for (const project of [undefined, "p1"]) {
|
||||
const legacy = await buildExecutionView(deps(memory), { mission: "release-9", project });
|
||||
expect(withoutLiveDisk(await buildExecutionView(deps(file), { mission: "release-9", project }))).toEqual(withoutLiveDisk(legacy));
|
||||
expect(laneIds(legacy)).toEqual(project ? ["qitem-body", "qitem-tagged"] : [
|
||||
"qitem-alias-tag", "qitem-body", "qitem-case", "qitem-empty-body", "qitem-moved", "qitem-null-tags", "qitem-tagged", "qitem-wildcard",
|
||||
]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("slow execution projections in the slow-operation log", () => {
|
||||
afterEach(() => configureSyncSiteRecorder(undefined));
|
||||
|
||||
it("offers a diagnostic only at the slow threshold, and a throwing recorder changes nothing for the caller", () => {
|
||||
const offered: unknown[] = [];
|
||||
configureSyncSiteRecorder({ recordDiagnostic: (record) => { offered.push(record); return true; } });
|
||||
recordSlowDiagnostic(249.9, { n: 1 });
|
||||
recordSlowDiagnostic(250, { n: 2 });
|
||||
recordSlowDiagnostic(Number.NaN, { n: 3 });
|
||||
expect(offered).toEqual([{ n: 2 }]);
|
||||
configureSyncSiteRecorder({ recordDiagnostic: () => { throw new Error("recorder broke"); } });
|
||||
expect(() => recordSlowDiagnostic(300, { n: 4 })).not.toThrow();
|
||||
});
|
||||
|
||||
it("records a slow projection's elapsed times and the candidate read's longest stretch, with no row content", async () => {
|
||||
const offered: Record<string, unknown>[] = [];
|
||||
configureSyncSiteRecorder({ recordDiagnostic: (record) => { offered.push(record); return true; } });
|
||||
const db = track(createDb(tempFile("slow.sqlite")));
|
||||
migrate(db, [coreSchema, bindingsSessionsSchema, rigArchiveSchema, eventsSchema, queueItemSummarySchema, queueItemsSchema, queueTransitionsSchema, queueTransitionWakesSchema, viewsCustomSchema]);
|
||||
db.prepare(`INSERT INTO queue_items (qitem_id, ts_created, ts_updated, source_session, destination_session, state, priority, tier, tags, body)
|
||||
VALUES ('qitem-slow', '2026-10-25T10:00:00.000Z', '2026-10-25T10:00:00.000Z', 'lead@r', 'builder@r', 'in-progress', 'normal', 'light', ?, ?)`)
|
||||
.run(JSON.stringify(["slice:S1"]), "Private words.\nworktree_path=/nowhere/slow\n");
|
||||
// Each Git probe of the row's worktree takes 260 ms, so the projection crosses the 250 ms threshold.
|
||||
await buildExecutionView({ db, now: () => new Date("2026-10-25T12:00:00.000Z"), slicesRoot: () => null, rigsRoot: () => "/nonexistent-rigs-root",
|
||||
exec: async (): Promise<string> => { await new Promise((resolve) => setTimeout(resolve, 260)); throw new Error("not a repository"); } });
|
||||
expect(offered).toHaveLength(1);
|
||||
const record = offered[0]!;
|
||||
expect(record).toMatchObject({ schema: "openrig.execution-view/v1", candidateRead: "windowed", candidateRows: 1, candidateWindows: 1 });
|
||||
expect(record.projectionMs).toBeGreaterThanOrEqual(250);
|
||||
expect(record.candidateMs).toBeLessThanOrEqual(record.projectionMs as number);
|
||||
expect(record.candidateLongestStretchMs).toBeLessThanOrEqual(record.candidateMs as number);
|
||||
expect(Object.keys(record).sort()).toEqual(["candidateLongestStretchMs", "candidateMs", "candidateRead", "candidateRows", "candidateWindows", "projectionMs", "schema", "utc"]);
|
||||
expect(JSON.stringify(record)).not.toMatch(/Private|nowhere|builder@r|S1/);
|
||||
});
|
||||
});
|
||||
@@ -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