mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-12 01:41:39 -10:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3026b90574 | ||
|
|
fce58ecf5c | ||
|
|
01b0b4c341 | ||
|
|
c00289e268 | ||
|
|
7db3a63360 | ||
|
|
b269302e63 | ||
|
|
d198a031f7 | ||
|
|
f06edfa410 |
No files matched your search
+58
-22
@@ -13,12 +13,13 @@ deprecations, and behavioral changes. Breaking changes are called out explicitly
|
||||
OpenRig 0.7.12 is a patch for people running their own agent teams, cut from
|
||||
main `16e8b7fb`. When you answer your team, by typing, clicking or qualifying
|
||||
an answer, the answer now goes to an agent that reads it with the question and
|
||||
decides. Answers go to the configured receiving agent or kernel operator when
|
||||
available; otherwise they wait. Setup offers to register you as the team's
|
||||
person. `rig down` no longer stops a terminal it
|
||||
decides. When no receiving agent is available, your answer waits. Setup's
|
||||
guidance asks your agent to offer to register you as the team's person, and
|
||||
registering works when you ask for it. `rig down` no longer stops a terminal it
|
||||
can't show belongs to the seat. There are fixes across delivery, Codex,
|
||||
restore, the queue, Slack and speed, many contributed by people using
|
||||
OpenRig. Two repairs aren't in this release: see Known limits.
|
||||
OpenRig. Two repairs aren't in this release, and some checks weren't run: see
|
||||
Known limits.
|
||||
|
||||
### Before you upgrade
|
||||
|
||||
@@ -26,7 +27,8 @@ OpenRig. Two repairs aren't in this release: see Known limits.
|
||||
seat's own `OPENRIG_NODE_ID`, on the server and session it read. A terminal
|
||||
OpenRig didn't create, a mismarked one or a replaced one is left running, and
|
||||
`rig down` exits 2 naming it and how to stop it yourself. A seat joined with
|
||||
`rig attach --self` is in that group. ([#2962](https://github.com/mvschwarz/openrig/pull/2962))
|
||||
`rig attach` as an external CLI is listed as not stopped, since OpenRig never
|
||||
stops it; that alone doesn't make `rig down` exit 2. ([#2962](https://github.com/mvschwarz/openrig/pull/2962))
|
||||
- **openrig-core is 0.1.28.** A Codex session kept running across the upgrade
|
||||
can ask for hook approval until it's relaunched.
|
||||
|
||||
@@ -34,15 +36,29 @@ OpenRig. Two repairs aren't in this release: see Known limits.
|
||||
|
||||
- Answers nothing else reads, such as a host pairing request or a mode's
|
||||
scheduled end, use the configured receiving agent or the kernel operator.
|
||||
Answers wait when no receiver is available. ([#2923](https://github.com/mvschwarz/openrig/pull/2923))
|
||||
Answers wait when no receiver is available, and nothing decides them
|
||||
meanwhile. To decide one yourself, use its own command: `rig host approve` or
|
||||
`rig host deny` for a pairing request, `rig seat decide-permission` for a
|
||||
runner's request. Closing an ask isn't permission, and words that were kept
|
||||
aren't a default permit. ([#2923](https://github.com/mvschwarz/openrig/pull/2923))
|
||||
- Pairing answers go to the receiving agent, and you can decide directly in the
|
||||
TUI's pairing settings. A pairing request says who it went to and who reads
|
||||
the answer. ([#2931](https://github.com/mvschwarz/openrig/pull/2931), [#2935](https://github.com/mvschwarz/openrig/pull/2935))
|
||||
- A runner's permission answer goes to the receiving agent, which decides while
|
||||
the runner waits, so a qualified answer such as "only if it's the test file"
|
||||
is read before anything acts. ([#2934](https://github.com/mvschwarz/openrig/pull/2934), [#2938](https://github.com/mvschwarz/openrig/pull/2938), [#2950](https://github.com/mvschwarz/openrig/pull/2950), [#2966](https://github.com/mvschwarz/openrig/pull/2966))
|
||||
- Setup offers to register you as the team's person, and the empty-registry
|
||||
hint is right. A team you start from your own assistant fits your own logins,
|
||||
is read before anything acts. Your answer is kept and an explicit decision
|
||||
works, but the runner's confirmation updates to you aren't written in this
|
||||
release. Don't expect a confirmation in the Feed, a notice of the receiver's
|
||||
state or an immediate wake: the decision reaches the runner at its next read
|
||||
(up to about 21 seconds in testing). To decide one yourself:
|
||||
`rig seat decide-permission <askId> --option <id> --reason "…"`. This is the
|
||||
runner's path only: a pairing request says who reads the answer, as above. ([#2934](https://github.com/mvschwarz/openrig/pull/2934), [#2938](https://github.com/mvschwarz/openrig/pull/2938), [#2950](https://github.com/mvschwarz/openrig/pull/2950), [#2966](https://github.com/mvschwarz/openrig/pull/2966))
|
||||
- Setup's guidance asks your agent to offer to register you as the team's
|
||||
person. In testing the agent didn't reliably make that offer (0 of 5 runs).
|
||||
Registering works when you ask for it: `rig gateway human add`. Nothing is
|
||||
registered without your yes, an existing default person is kept, and no person
|
||||
is chosen automatically. The empty-registry hint is right. A team you start
|
||||
from your own assistant fits your own logins,
|
||||
with a review before anything is committed. ([#2924](https://github.com/mvschwarz/openrig/pull/2924), [#2905](https://github.com/mvschwarz/openrig/pull/2905))
|
||||
|
||||
### Harnesses and connections
|
||||
@@ -80,8 +96,9 @@ OpenRig. Two repairs aren't in this release: see Known limits.
|
||||
|
||||
- **Delivery and drafts:** Claude seats with a custom status line read idle and
|
||||
verify sends; a fresh idle hook doesn't type into a current Claude dialog;
|
||||
Claude's context hint and drawn cursor are read correctly; a deliberate
|
||||
override is delivered when the input can't be read; update notices beside the
|
||||
Claude's context hint and drawn cursor are read correctly; when the input
|
||||
can't be read, a deliberate override (`--dangerously-interact`) pastes with a
|
||||
warning and doesn't press Enter, so it isn't submitted; update notices beside the
|
||||
paste hint are recognized. ([#2807](https://github.com/mvschwarz/openrig/pull/2807), [#2813](https://github.com/mvschwarz/openrig/pull/2813), [#2947](https://github.com/mvschwarz/openrig/pull/2947), [#2942](https://github.com/mvschwarz/openrig/pull/2942), [#2933](https://github.com/mvschwarz/openrig/pull/2933), [#2800](https://github.com/mvschwarz/openrig/pull/2800), [#2786](https://github.com/mvschwarz/openrig/pull/2786))
|
||||
- **Codex:** when you answer a Codex choice prompt, a message waiting on it is
|
||||
delivered instead of escalated; a working counter is compared only within one pane process; hook
|
||||
@@ -114,8 +131,9 @@ OpenRig. Two repairs aren't in this release: see Known limits.
|
||||
config accepts two keys the daemon already resolves; `rig whoami` says the
|
||||
team is still being built during `rig up`; rigs with `@` in the name find
|
||||
attention; a seat waiting on a person names its question; the installer
|
||||
reports PATH gaps with shell hints; daemon credentials stay out of the tmux
|
||||
server's environment. ([#2914](https://github.com/mvschwarz/openrig/pull/2914), [#2969](https://github.com/mvschwarz/openrig/pull/2969), [#2955](https://github.com/mvschwarz/openrig/pull/2955), [#2949](https://github.com/mvschwarz/openrig/pull/2949), [#2891](https://github.com/mvschwarz/openrig/pull/2891), [#2888](https://github.com/mvschwarz/openrig/pull/2888), [#2883](https://github.com/mvschwarz/openrig/pull/2883), [#2850](https://github.com/mvschwarz/openrig/pull/2850))
|
||||
reports PATH gaps with shell hints; daemon credentials stay out of a tmux
|
||||
server's environment from its next start (a server already running keeps its
|
||||
old environment until it restarts). ([#2914](https://github.com/mvschwarz/openrig/pull/2914), [#2969](https://github.com/mvschwarz/openrig/pull/2969), [#2955](https://github.com/mvschwarz/openrig/pull/2955), [#2949](https://github.com/mvschwarz/openrig/pull/2949), [#2891](https://github.com/mvschwarz/openrig/pull/2891), [#2888](https://github.com/mvschwarz/openrig/pull/2888), [#2883](https://github.com/mvschwarz/openrig/pull/2883), [#2850](https://github.com/mvschwarz/openrig/pull/2850))
|
||||
- **Pi and OMP:** typed edits submit in order on the affected Node releases
|
||||
(22.21.x to 22.22.0, 24.9.0 to 24.11.1, 25.0.0 to 25.1.0) instead of appending an
|
||||
insertion at the end. ([#2847](https://github.com/mvschwarz/openrig/pull/2847))
|
||||
@@ -161,17 +179,35 @@ OpenRig. Two repairs aren't in this release: see Known limits.
|
||||
- **Custom blocker IDs** explain the wait, but their progress gives no event
|
||||
wake; the timer remains. ([#2983](https://github.com/mvschwarz/openrig/pull/2983))
|
||||
- **Delivery evidence** records what was known, not that the agent read the
|
||||
message; where the input ended up can read unknown. ([#2937](https://github.com/mvschwarz/openrig/pull/2937), [#2943](https://github.com/mvschwarz/openrig/pull/2943))
|
||||
- **Runner permission answers:** when a person answers a runner's permission
|
||||
request, the answer is kept, and an explicit decision works. In 0.7.12 the
|
||||
runner's confirmation updates fail to record, so no receipt or Feed
|
||||
confirmation appears, and the decision can reach the runner on its next read
|
||||
rather than at once (up to about 21 seconds in our test). To settle a request
|
||||
directly, run `rig seat decide-permission <request> --option <id>`. Pairing
|
||||
answers are separate, and their receipts are kept. Tested on one native daemon
|
||||
with the TUI and a runner driven by a stand-in agent. ([#2934](https://github.com/mvschwarz/openrig/pull/2934))
|
||||
message; where the input ended up can read unknown. Events are best effort, so
|
||||
a missing event isn't proof nothing was sent. ([#2937](https://github.com/mvschwarz/openrig/pull/2937), [#2943](https://github.com/mvschwarz/openrig/pull/2943))
|
||||
- **Runner permission answers** were run end to end with a real receiving
|
||||
agent and the example runner, whose requesting agent was a stand-in; a
|
||||
model-driven runner waiting on a person's answer hasn't been run. ([#2934](https://github.com/mvschwarz/openrig/pull/2934))
|
||||
- **herdr-app views** are one-shot; following isn't claimed. **Pull relay**
|
||||
needs a connector that implements pull. ([#2744](https://github.com/mvschwarz/openrig/pull/2744), [#2844](https://github.com/mvschwarz/openrig/pull/2844))
|
||||
- **A receiving agent you choose** (`agents.operator_session`) that runs in an
|
||||
explicit or auto permission mode gets no OpenRig allowance for
|
||||
`rig host approve` or `rig host deny`, so Claude's review can refuse them. A
|
||||
kernel receiver in auto mode is included. The default kernel with the default
|
||||
policy passed at its tested scope.
|
||||
- **A receiver that was stopped and then started** may not act on an answer that
|
||||
waited for it. In the tested case where it needed to ask you something back,
|
||||
the question appeared in its own conversation, not in the Feed or Steering.
|
||||
- **Delivery isn't fixed in general:** a footer row OpenRig doesn't recognize can
|
||||
still hold a delivery ([#2925](https://github.com/mvschwarz/openrig/issues/2925)), and a held message's full envelope can stay held;
|
||||
clearing a draft doesn't deliver it ([#2515](https://github.com/mvschwarz/openrig/pull/2515)).
|
||||
- **Not run for this release** (not run, not passed): the two-seat Codex journey
|
||||
with `AGENTS.md` restore; Codex account-home, upgrade and continuity checks;
|
||||
the OpenShell worker's second report; Pi model switches on real providers; ACP
|
||||
in a desktop app; Slack directory paging; some Slack setup cases (channel and
|
||||
thread precedence, a stopped receiver's delayed pickup, an inherited inbound
|
||||
default, a plain yes with no receiver); native checks of [#2786](https://github.com/mvschwarz/openrig/pull/2786), [#2799](https://github.com/mvschwarz/openrig/pull/2799), [#2807](https://github.com/mvschwarz/openrig/pull/2807)
|
||||
and [#2813](https://github.com/mvschwarz/openrig/pull/2813), with [#2947](https://github.com/mvschwarz/openrig/pull/2947) partly; a button-and-words answer, a non-pairing ask
|
||||
with an explicit owner, Steering's Approve page, and these answer and setup
|
||||
checks on a default model (the tested runs used smaller models);
|
||||
`--wait-for-kernel` ([#2888](https://github.com/mvschwarz/openrig/pull/2888)), the installed effect of [#2800](https://github.com/mvschwarz/openrig/pull/2800), and [#2720](https://github.com/mvschwarz/openrig/issues/2720) beyond one
|
||||
platform.
|
||||
- **From 0.7.11:** its known limits below still apply, except Codex hook setup
|
||||
with smol-toml 1.9.x ([#2720](https://github.com/mvschwarz/openrig/issues/2720)), fixed by [#2804](https://github.com/mvschwarz/openrig/pull/2804). 0.7.12 changes openrig-core
|
||||
again, so the Codex hook note there applies to this upgrade too. They weren't
|
||||
|
||||
@@ -26,9 +26,9 @@ use:
|
||||
|
||||
```sh
|
||||
# Claude Code
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.11/skills/rigs --skill rigs --agent claude-code
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.12/skills/rigs --skill rigs --agent claude-code
|
||||
# Codex
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.11/skills/rigs --skill rigs --agent codex
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.12/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.11` release. It checks Node.js and npm, installs the latest published `@openrig/cli` with `npm install -g`, runs the Node.js and SQLite check, then runs `rig setup --dry-run` and `rig setup`. The first line prints that plan and changes nothing:
|
||||
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:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.11/scripts/install.sh | sh -s -- --dry-run
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.11/scripts/install.sh | sh
|
||||
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
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
@@ -220,6 +220,26 @@ providers. Cached plugin versions are excluded from that duplicate-provider chec
|
||||
constructs the service while building the daemon's dependencies and doesn't catch that
|
||||
rejection, so a duplicate vendored hook stops daemon creation.
|
||||
|
||||
Vendoring at daemon start (`ensureVendored` in `plugin-vendor-service.ts`) seeds a bundled
|
||||
plugin that isn't installed, and refreshes an installed one only when the bundled manifest
|
||||
version is strictly newer, backing up what it replaces. At an equal version it leaves the
|
||||
installed content unchanged, though it may reconcile file modes on byte-identical files; a newer
|
||||
installed version is left as it is. That describes this start-time path only, not every way a
|
||||
plugin can be installed or updated. The `openrig` plugin ships inside the CLI, so its four
|
||||
manifests carry the CLI's release version, set in release preparation; `openrig-core` keeps its
|
||||
own `0.1.x` line. So each release refreshes an older installed `openrig` plugin, while a daemon
|
||||
run from development source, whose `openrig` manifests stay at `0.1.0`, leaves a newer released
|
||||
install as it is rather than downgrading it. `npm run pack:release` refuses a missing `openrig`
|
||||
manifest, or one that doesn't carry the CLI's version, in source or in the assembled CLI. It also
|
||||
refuses a baseline (`scripts/fixtures/packaged-plugins-last-release.json`) whose release isn't
|
||||
earlier than the CLI's, or that has no archive hash or plugins, and changed plugin bytes without a
|
||||
greater version than that release's. Those baseline checks read the file's own fields: they don't
|
||||
prove the baseline is the published, latest or complete release archive, so a successful pack
|
||||
prints the baseline it used, to confirm against the registry. Refresh the baseline from the
|
||||
published archive with the snapshot, validate and replace steps at the end of
|
||||
[`scripts/check-packaged-plugin-versions.mjs`](../../../scripts/check-packaged-plugin-versions.mjs);
|
||||
`--snapshot` on its own only prints the JSON.
|
||||
|
||||
`packages/daemon/src/routes/plugins.ts` exposes GET-only inspection at `/api/plugins`:
|
||||
list, detail, used-by and file list/read. The file routes use the discovered plugin root as
|
||||
their path allowlist. `?cwd=` supplies rig-cwd discovery roots.
|
||||
|
||||
@@ -180,20 +180,22 @@ user.
|
||||
|
||||
**Agents:** if you're setting OpenRig up for someone, load the `rigs` skill and follow it. It carries the install, the
|
||||
OpenRig view and the handoff to the operator as one procedure. From 0.6.7, OpenRig installs it as `rigs` in
|
||||
`~/.claude/skills` and `~/.agents/skills` when the daemon starts. To add it manually, use the release tag matching
|
||||
your installed `rig --version`: replace `<release-tag>` below with that tag, including its `v` prefix.
|
||||
`rig --version` prints the version and a commit, for example `0.6.11 (379f0a0d)`; the tag is `v0.6.11`.
|
||||
The skill is in the repository from v0.6.6; on an older install, use the v0.6.6 tag or upgrade first.
|
||||
Choose the command for the agent doing the setup:
|
||||
`~/.claude/skills` and `~/.agents/skills` when the daemon starts. For a new install, add it with the command for the
|
||||
agent doing the setup. Both use this release's tag:
|
||||
|
||||
```sh
|
||||
# Claude Code
|
||||
npx skills add "https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/rigs" --skill rigs --agent claude-code
|
||||
npx skills add "https://github.com/mvschwarz/openrig/tree/v0.7.12/skills/rigs" --skill rigs --agent claude-code
|
||||
|
||||
# Codex
|
||||
npx skills add "https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/rigs" --skill rigs --agent codex
|
||||
npx skills add "https://github.com/mvschwarz/openrig/tree/v0.7.12/skills/rigs" --skill rigs --agent codex
|
||||
```
|
||||
|
||||
If OpenRig is already installed, use the release tag matching your installed `rig --version` instead: replace the tag
|
||||
after `tree/` with it, including its `v` prefix. `rig --version` prints the version and a commit, for example
|
||||
`0.6.11 (379f0a0d)`; the tag is `v0.6.11`. The skill is in the repository from v0.6.6; on an older install, use the
|
||||
v0.6.6 tag or upgrade first.
|
||||
|
||||
These are the pinned, per-agent forms used on the [rigs pages](https://openrig.dev/rigs).
|
||||
You can also read [`skills/rigs/SKILL.md`](../../skills/rigs/SKILL.md) directly.
|
||||
|
||||
@@ -201,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.11/scripts/install.sh | sh -s -- --dry-run
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.12/scripts/install.sh | sh -s -- --dry-run
|
||||
```
|
||||
|
||||
The same command without `--dry-run`,
|
||||
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.11/scripts/install.sh | sh`, installs the
|
||||
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.12/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,13 +12,13 @@ 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/<release-tag>/skills/rigs --skill rigs --agent claude-code
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.12/skills/rigs --skill rigs --agent claude-code
|
||||
# Codex
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/rigs --skill rigs --agent codex
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.12/skills/rigs --skill rigs --agent codex
|
||||
```
|
||||
|
||||
For a new install, use the latest release's tag, for example `v0.7.11`. If OpenRig is already installed, use the tag
|
||||
that matches `rig --version`, or upgrade first. If the command fails with `npx: command not found`, Node.js isn't
|
||||
These use this release's tag, for a new install. If OpenRig is already installed, replace the tag after `tree/` with
|
||||
the one that matches `rig --version` (`v` and its version number), or upgrade first. If the command fails with `npx: command not found`, Node.js isn't
|
||||
installed or isn't on your PATH yet (see [What you need](#what-you-need)). Then type `/rigs` in Claude Code, or `$rigs` in Codex, and say what
|
||||
you want built.
|
||||
|
||||
|
||||
@@ -166,7 +166,7 @@ person needs in their project or home folder and name that path.
|
||||
(never the logical ID or a guessed name) and send:
|
||||
`rig send <canonicalSessionName> 'This is the agent that installed OpenRig. The person will answer in your pane. Goal: <goal>. Project folder: <absolute path>. Branch: <branch>.'`
|
||||
If the person stays here, replace "The person will answer in your pane" with "The person stays with me; send me your questions and
|
||||
results to relay." Carry their constraints and any declined permission choices in either handoff.
|
||||
results to relay." Carry their constraints and any permission choice they made, chosen or declined, in either handoff.
|
||||
Or the person types the goal and folder in the operator's pane. If they give you a goal later, forward it the same
|
||||
way. The operator helps them pick a team that fits their logins, starts it (`rig up`) only on their yes to the plan it
|
||||
shows, and gives the goal to the team's lead. Before that yes, it says what launching into their folder changes for their own agent there; relay
|
||||
@@ -190,7 +190,7 @@ person needs in their project or home folder and name that path.
|
||||
(`--dry-run` first). If it isn't, or you can't tell, say so and offer their choices: sign in to Claude Code, or give the
|
||||
seat a model their account can run with `rig seat set-model`, then start it again with
|
||||
`rig seat launch <seat> --fresh --stop --reason "…"` and check the model it reports. Either way the operator starts a
|
||||
new conversation without your handoff, so send it again: the goal, folder, branch, constraints and declined choices.
|
||||
new conversation without your handoff, so send it again: the goal, folder, branch, constraints and permission choices.
|
||||
- **Installation is finished** when the operator is ready and the person is talking to it; a healthy daemon alone isn't
|
||||
that. If they'd rather talk later, keep that choice, leave them the exact connection step and say the handoff to the
|
||||
operator is still pending.
|
||||
@@ -207,6 +207,9 @@ person needs in their project or home folder and name that path.
|
||||
whole action, answer only when it's clearly inside what they said, and tell them you answered under that instruction,
|
||||
not that they said yes. Bring anything unclear or outside it back to them; a command's name or a path that looks like
|
||||
their project doesn't show what the action touches. Their instruction changes no permission setting.
|
||||
Don't send them to a bare `tmux attach` to answer: relay their choice this way, or point them to that seat in the
|
||||
team's terminals the operator opened. If they want fewer prompts, the operator offers once to allow the commands
|
||||
that keep asking, for their project only (`applying-a-permission-policy`); tell it what they chose.
|
||||
Another kind of menu you can answer with
|
||||
the key that screen shows, or ask the person. Capture it again before sending anything more.
|
||||
- **Replies:** in a plain shell, other agents can't type into this session. Read work sent to you with
|
||||
|
||||
@@ -119,7 +119,8 @@ configurations, as below; do not take the pin from a cached page summary.
|
||||
from the installing agent; do not ask for them again. If the goal is missing,
|
||||
ask once: "What would you like to build or change?" If they already named a
|
||||
team, use it and skip the questions it answers. Keep any choice the
|
||||
handoff carries, such as a trust or hook-review prompt they declined.
|
||||
handoff carries, such as a trust or hook-review prompt they declined or
|
||||
a permission choice they made.
|
||||
2. **Where it works.** If the folder is missing, ask which folder the team should
|
||||
work in (usually a clone of their repository) and use its absolute path. Your
|
||||
own working directory is OpenRig's: the workspace's `kernel` folder (on an older
|
||||
@@ -249,11 +250,30 @@ configurations, as below; do not take the pin from a cached page summary.
|
||||
separates project files, not that Codex home. `rig setup` lists these writes. If they'd rather keep
|
||||
their folder as it is, offer a separate copy, such as a git worktree.
|
||||
If the plan declares non-interruptive or broad access, say so plainly before asking for their yes.
|
||||
If they keep permission prompts on, say what that will be like before
|
||||
their yes: each seat stops and asks before an action its permissions
|
||||
don't already cover, the asks come often, especially early, and each one
|
||||
needs their answer, in the seat's pane or through you. Their yes starts
|
||||
the team; it isn't a yes to those later asks.
|
||||
Before their yes, explain the team's permission defaults. Claude's team
|
||||
default adds allowances for ordinary `rig` commands, project reads and
|
||||
common tests. Branches, commits, running their program, installs and other
|
||||
shell commands may ask, depending on existing native rules. A Codex seat
|
||||
uses its configured writable roots and may ask for broader access. When a
|
||||
prompt appears, they can answer in that seat's terminal or through you.
|
||||
Their own agent's mode isn't the team's; existing user and project rules
|
||||
can still affect the seats. Their yes starts the team; it isn't a yes to
|
||||
later prompts.
|
||||
- **A permission choice already made** (in the handoff or to you: fewer
|
||||
prompts for this project, a mode, a rule, or keep prompts on): use it,
|
||||
and don't ask again.
|
||||
- **Otherwise, one offer if they want fewer interruptions:** name the
|
||||
command families this goal will repeat in their folder (for example
|
||||
`git switch`, `git add`, `git commit` and the project's test command)
|
||||
and offer to allow just those, for this project only. For Claude seats
|
||||
that's allow rules in the project's `.claude/settings.local.json`,
|
||||
normally loaded by Claude sessions in that folder, including their own,
|
||||
under the default setting sources; for a Codex seat, follow the same
|
||||
skill's Codex rules. On their yes, apply it with
|
||||
`applying-a-permission-policy`. Say what still asks: a command joined
|
||||
with `&&`, `;` or a pipe asks unless every part is allowed, and on Claude
|
||||
seats OpenRig's lifecycle commands still ask. On a no, keep the team
|
||||
default and don't offer again.
|
||||
For a team that already exists, `rig seat status <seat>` shows each seat's
|
||||
planned permissions: the native mode, where it comes from, what OpenRig
|
||||
adds and what isn't known. Read it to them as planned, not observed.
|
||||
@@ -339,10 +359,14 @@ configurations, as below; do not take the pin from a cached page summary.
|
||||
names. Tell them it's a usage limit, which seat, and when it resets, with
|
||||
the menu's choices in its own words. One of them may spend usage credits,
|
||||
so the choice is theirs.
|
||||
When you see one, tell them where they already are: which seat, what its
|
||||
prompt asks (`rig capture <session>` with the seat's
|
||||
`canonicalSessionName` shows it) and the seat's `tmuxAttachCommand`, or
|
||||
just its name if it has none. Don't answer another seat's prompt yourself.
|
||||
When you see one, tell them where it is and where they can answer: which
|
||||
seat, what its prompt asks (`rig capture <session>` with the seat's
|
||||
`canonicalSessionName` shows it), and that seat's terminal in the team's
|
||||
space from step 8, or through you. Only when no space is open, give the
|
||||
seat's `tmuxAttachCommand` as a command for a new terminal, with one
|
||||
line on what it does (it shows that seat; closing the window leaves the
|
||||
seat running), or just its name if it has none. Never send them to a
|
||||
bare `tmux attach`. Don't answer another seat's prompt yourself.
|
||||
With prompts on, several seats may wait at once. Gather them, then tell
|
||||
the person in one message, one line per prompt: the seat, the exact
|
||||
action it asks to take (the command or file), and the choices the prompt
|
||||
@@ -366,12 +390,14 @@ configurations, as below; do not take the pin from a cached page summary.
|
||||
and check the result before sending anything more; never resend an answer
|
||||
whose effect you can't see. Otherwise they answer in the seat's pane.
|
||||
Grouping can shorten the asking, not the number of prompts. If they find
|
||||
the prompts too many, lay out their choices without picking one: keep
|
||||
prompts on; take a prompt's own standing approval for one command when it
|
||||
offers one; or change the team's permissions deliberately, as a separate
|
||||
choice (`applying-a-permission-policy`, `rig seat set-permissions` for
|
||||
later launches, or Workshop's broad-access bundle). An answer to a prompt
|
||||
never changes a permission mode.
|
||||
the prompts too many and haven't already chosen, make step 6's one offer,
|
||||
named from the commands that actually asked: allow those command families
|
||||
for this project, applied on their yes with `applying-a-permission-policy`,
|
||||
which says how to check it took effect in the seats. Their other choices
|
||||
stay open: keep prompts on; take a prompt's own standing approval for one
|
||||
command when it offers one; `rig seat set-permissions` for later launches;
|
||||
or Workshop's broad-access bundle. An answer to a prompt never changes a
|
||||
permission mode.
|
||||
|
||||
Avoid these:
|
||||
- launching a team without the person's yes;
|
||||
|
||||
@@ -256,6 +256,13 @@ export class QueueWakeRepository {
|
||||
row.state === "blocked" && this.getStatus(row.qitemId)?.ref === jobId);
|
||||
}
|
||||
|
||||
/** An ordinary one-shot park has no later cycle to replace a failed wake.
|
||||
* Repeating waits and provider-limit expiry keep their own existing lifecycle. */
|
||||
hasOwedOneShotParkWake(jobId: string): boolean {
|
||||
return this.findCurrentQitemsByGeneratedTimer(jobId).some(row => !this.isUsageLimitBlocker(row.qitemId))
|
||||
&& !this.isRepeatingTimer(jobId);
|
||||
}
|
||||
|
||||
/** Active park-generated timers that are still their blocked row's current wake and still target the
|
||||
* row's owner. These wake the seat that owns the row, not a particular occupant, so a seat swap keeps
|
||||
* them; any other job the retiring occupant registered still stops. */
|
||||
|
||||
@@ -1335,10 +1335,11 @@ export class SeatHandoverService {
|
||||
});
|
||||
// (e/Class-B): capture the RETIRING occupant's generation BEFORE registerClaimedSession mints the
|
||||
// successor's tenure below — after that mint the node's "current" generation IS the successor's
|
||||
// (the name is reused), so this is the only point the retiree's generation is resolvable.
|
||||
const retiringGeneration =
|
||||
this.sessionRegistry.currentOccupantGenerationForSession(input.latestSession.session_name) ?? undefined;
|
||||
// (the name is reused), so this is the only point the retiree's generation is resolvable. It is read by
|
||||
// this node, not by its session name: an earlier generation of the seat (an archived rig of the same
|
||||
// name) can hold a newer session row under that name.
|
||||
const tenureBefore = this.readOccupantTenure(input.node.id);
|
||||
const retiringGeneration = tenureBefore?.generationUuid ?? undefined;
|
||||
// atom-B: a seat handover mints a HANDOVER-kind occupant generation (not the default 'adopt').
|
||||
const newSession = this.sessionRegistry.registerClaimedSession(
|
||||
input.node.id,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { performance } from "node:perf_hooks";
|
||||
import { Worker } from "node:worker_threads";
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
|
||||
export const SLOW_OPERATION_BARRIER_TIMEOUT_MS = 250;
|
||||
export const SLOW_OPERATION_THRESHOLD_MS = 250;
|
||||
export const SLOW_OPERATION_ROTATION_BYTES = 1024 * 1024;
|
||||
export const SLOW_OPERATION_ROTATION_COUNT = 3;
|
||||
@@ -89,7 +88,6 @@ interface SlowOpRecorderOptions {
|
||||
maxBytes?: number;
|
||||
rotationCount?: number;
|
||||
slowThresholdMs?: number;
|
||||
barrierTimeoutMs?: number;
|
||||
}
|
||||
|
||||
interface Span {
|
||||
@@ -141,13 +139,7 @@ const WORKER_SOURCE = String.raw`
|
||||
ok = false;
|
||||
error = caught instanceof Error ? caught.message : String(caught);
|
||||
}
|
||||
if (message.signal) {
|
||||
const state = new Int32Array(message.signal);
|
||||
Atomics.store(state, 0, ok ? 1 : 2);
|
||||
Atomics.notify(state, 0);
|
||||
} else if (message.id) {
|
||||
parentPort.postMessage({ id: message.id, ok, error });
|
||||
}
|
||||
if (message.id) parentPort.postMessage({ id: message.id, ok, error });
|
||||
});
|
||||
`;
|
||||
|
||||
@@ -157,7 +149,6 @@ export class SlowOpRecorder implements SlowOperationInstrumentation {
|
||||
private readonly maxBytes: number;
|
||||
private readonly rotationCount: number;
|
||||
private readonly slowThresholdMs: number;
|
||||
private readonly barrierTimeoutMs: number;
|
||||
private readonly pending = new Map<string, { resolve: () => void; reject: (error: unknown) => void }>();
|
||||
private degraded: SlowOperationSnapshot = { healthy: true };
|
||||
private degradedHandler?: (snapshot: Required<Pick<SlowOperationSnapshot, "reason" | "site">>) => void;
|
||||
@@ -181,7 +172,6 @@ export class SlowOpRecorder implements SlowOperationInstrumentation {
|
||||
this.maxBytes = options.maxBytes ?? SLOW_OPERATION_ROTATION_BYTES;
|
||||
this.rotationCount = options.rotationCount ?? SLOW_OPERATION_ROTATION_COUNT;
|
||||
this.slowThresholdMs = options.slowThresholdMs ?? SLOW_OPERATION_THRESHOLD_MS;
|
||||
this.barrierTimeoutMs = options.barrierTimeoutMs ?? SLOW_OPERATION_BARRIER_TIMEOUT_MS;
|
||||
this.worker = new Worker(WORKER_SOURCE, { eval: true, execArgv: [] });
|
||||
this.worker.unref();
|
||||
this.worker.on("message", (message: { id?: string; ok?: boolean }) => {
|
||||
@@ -220,27 +210,10 @@ export class SlowOpRecorder implements SlowOperationInstrumentation {
|
||||
|
||||
beginSyncSpan(site: string): Span {
|
||||
const span: Span = { spanId: randomUUID(), site, startedAt: performance.now() };
|
||||
const signal = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT);
|
||||
const state = new Int32Array(signal);
|
||||
try {
|
||||
this.worker.postMessage({
|
||||
type: "append",
|
||||
signal,
|
||||
logPath: this.logPath,
|
||||
maxBytes: this.maxBytes,
|
||||
rotationCount: this.rotationCount,
|
||||
record: { v: 1, ts: new Date().toISOString(), spanId: span.spanId, phase: "begin", site },
|
||||
});
|
||||
} catch {
|
||||
// Synchronous postMessage failure means the Worker is gone: degrade
|
||||
// through the terminal transition but still return the span so the
|
||||
// wrapped operation runs to completion (its value/error stays exact).
|
||||
this.handleTerminalFailure("recorder_post_failed", site);
|
||||
return span;
|
||||
}
|
||||
const wait = Atomics.wait(state, 0, 0, this.barrierTimeoutMs);
|
||||
if (wait === "timed-out") this.markDegraded("begin_barrier_timeout", site);
|
||||
else if (Atomics.load(state, 0) !== 1) this.markDegraded("begin_barrier_failed", site);
|
||||
// Queue before the operation without waiting on worker startup or fsync. The
|
||||
// worker can write even while the main thread is wedged; only flush/close
|
||||
// acknowledge durability. A crash before the worker appends can lose begin.
|
||||
this.appendAsync({ v: 1, ts: new Date().toISOString(), spanId: span.spanId, phase: "begin", site });
|
||||
return span;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { execFile } from "node:child_process";
|
||||
import type { TmuxAdapter } from "../adapters/tmux.js";
|
||||
import { runSyncSite } from "./sync-site-wrap.js";
|
||||
import { runAsyncSite } from "./sync-site-wrap.js";
|
||||
|
||||
export interface TmuxOptionDefaultsDeps {
|
||||
tmuxAdapter: TmuxAdapter;
|
||||
@@ -17,11 +17,11 @@ export interface TmuxOptionDefaultsDeps {
|
||||
*/
|
||||
platform?: NodeJS.Platform;
|
||||
/**
|
||||
* OPR.0.4.6.02 S1 — cheap `command -v <bin>` probe for the linux
|
||||
* copy-command fallback chain. Defaults to a real shell probe; injectable
|
||||
* OPR.0.4.6.02 S1 — asynchronous `command -v <bin>` probe for the linux
|
||||
* copy-command fallback chain. Defaults to a bounded asynchronous shell probe; injectable
|
||||
* for deterministic tests.
|
||||
*/
|
||||
hasCommand?: (bin: string) => boolean;
|
||||
hasCommand?: (bin: string) => boolean | Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,7 +44,7 @@ export class TmuxOptionDefaultsApplier {
|
||||
private tmuxAdapter: TmuxAdapter;
|
||||
private readTmuxOptionDefaults: () => { statusBar: boolean };
|
||||
private platform: NodeJS.Platform;
|
||||
private hasCommand: (bin: string) => boolean;
|
||||
private hasCommand: (bin: string) => boolean | Promise<boolean>;
|
||||
private serverDefaultsAsserted = false;
|
||||
|
||||
constructor(deps: TmuxOptionDefaultsDeps) {
|
||||
@@ -120,7 +120,14 @@ export class TmuxOptionDefaultsApplier {
|
||||
warnings.push(`tmux "set-clipboard" server option not set: ${clip.message}`);
|
||||
}
|
||||
|
||||
const copyCommand = resolveCopyCommand(this.platform, this.hasCommand);
|
||||
let copyCommand: string | null;
|
||||
try {
|
||||
copyCommand = await resolveCopyCommand(this.platform, this.hasCommand);
|
||||
} catch {
|
||||
this.serverDefaultsAsserted = false;
|
||||
warnings.push("Clipboard lookup interrupted (250 ms limit); copy-command left unchanged (OSC 52 if unset). Will retry on the next fresh session.");
|
||||
return;
|
||||
}
|
||||
if (copyCommand !== null) {
|
||||
const cc = await this.tmuxAdapter.setServerOption("copy-command", copyCommand);
|
||||
if (!cc.ok) {
|
||||
@@ -141,14 +148,14 @@ export class TmuxOptionDefaultsApplier {
|
||||
* Pure: platform + a `command -v` probe in, the command string (or null)
|
||||
* out. One table, no side effects — the applier decides whether to write.
|
||||
*/
|
||||
export function resolveCopyCommand(
|
||||
export async function resolveCopyCommand(
|
||||
platform: NodeJS.Platform,
|
||||
hasCommand: (bin: string) => boolean,
|
||||
): string | null {
|
||||
hasCommand: (bin: string) => boolean | Promise<boolean>,
|
||||
): Promise<string | null> {
|
||||
if (platform === "darwin") return "pbcopy";
|
||||
if (platform === "linux") {
|
||||
if (hasCommand("wl-copy")) return "wl-copy";
|
||||
if (hasCommand("xclip")) return "xclip -selection clipboard -i";
|
||||
if (await hasCommand("wl-copy")) return "wl-copy";
|
||||
if (await hasCommand("xclip")) return "xclip -selection clipboard -i";
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
@@ -156,16 +163,18 @@ export function resolveCopyCommand(
|
||||
|
||||
/**
|
||||
* OPR.0.4.6.02 S1 — default `command -v <bin>` probe (POSIX shell builtin).
|
||||
* Returns true when the binary resolves on PATH. Cheap + best-effort; any
|
||||
* failure (spawn error, non-shell env) reads as absent.
|
||||
* Returns true when the binary resolves on PATH. A killed probe rejects so
|
||||
* the applier can warn and retry; other failures read as absent.
|
||||
*/
|
||||
function defaultHasCommand(bin: string): boolean {
|
||||
async function defaultHasCommand(bin: string): Promise<boolean> {
|
||||
try {
|
||||
const r = runSyncSite("tmux_options.command_v", () =>
|
||||
spawnSync(`command -v ${bin}`, { shell: true, stdio: "ignore" })
|
||||
);
|
||||
return r.status === 0;
|
||||
} catch {
|
||||
return await runAsyncSite("tmux_options.command_v", () => new Promise<boolean>((resolve, reject) => {
|
||||
execFile("/bin/sh", ["-c", 'command -v "$1"', "--", bin], {
|
||||
timeout: 250, killSignal: "SIGKILL", maxBuffer: 1024,
|
||||
}, error => error?.killed ? reject(error) : resolve(!error));
|
||||
}));
|
||||
} catch (error) {
|
||||
if ((error as { killed?: boolean } | null)?.killed) throw error;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { OutboxHandler } from "./outbox-handler.js";
|
||||
import { QueueWakeRepository } from "./queue-wake-repository.js";
|
||||
import { resolveWakeRetryCap, resolveWakeRetryIntervalSeconds } from "./queue-wake-ladder.js";
|
||||
import type { DeliveryFn, DeliveryOutcome, WatchdogDeliverySource } from "./watchdog-policy-engine.js";
|
||||
|
||||
@@ -11,6 +12,7 @@ export function withWatchdogReceipts(send: DeliveryFn, deps: {
|
||||
outbox: OutboxHandler; now?: () => Date; retryIntervalSeconds?: number; retryCap?: number;
|
||||
}): DeliveryFn {
|
||||
const { outbox } = deps;
|
||||
const wakes = new QueueWakeRepository(outbox.db);
|
||||
const now = deps.now ?? (() => new Date());
|
||||
const interval = deps.retryIntervalSeconds ?? resolveWakeRetryIntervalSeconds();
|
||||
const cap = deps.retryCap ?? resolveWakeRetryCap();
|
||||
@@ -92,14 +94,17 @@ export function withWatchdogReceipts(send: DeliveryFn, deps: {
|
||||
for (const member of selected) {
|
||||
const receipt = read(member.id)!;
|
||||
let retryAt: string | undefined, nextAction: string | undefined, terminalReason: string | undefined;
|
||||
const exhausted = receipt.attempts > cap;
|
||||
// No input reached the seat: keep this park's same occurrence owed until delivery or the park ends.
|
||||
const owedParkWake = noInput && wakes.hasOwedOneShotParkWake(member.jobId);
|
||||
const exhausted = receipt.attempts > cap && !owedParkWake;
|
||||
const unavailable = permanent && !owedParkWake;
|
||||
const state = result.status === "ok" ? "delivered" : result.status === "retained" ? "retained"
|
||||
: noInput ? "failed" : "indeterminate";
|
||||
if (result.status === "retained") {
|
||||
nextAction = `Retained by existing input protection; inspect held messages ${(result.outboxIds ?? []).join(", ")}. Nothing was delivered.`;
|
||||
}
|
||||
if (result.status === "failed") {
|
||||
if (noInput && permanent) {
|
||||
if (noInput && unavailable) {
|
||||
terminalReason = "watchdog_delivery_unavailable";
|
||||
nextAction = `Inspect outbox ${member.id}: ${result.error ?? result.transportReason ?? "delivery failed"}. Fix the destination and register a replacement reminder.`;
|
||||
} else if (noInput && exhausted) {
|
||||
@@ -111,7 +116,7 @@ export function withWatchdogReceipts(send: DeliveryFn, deps: {
|
||||
nextAction = `Inspect outbox ${member.id} and the recipient before any manual replay; input is unconfirmed. This occurrence ends; the next regular cycle is a new occurrence.`;
|
||||
}
|
||||
}
|
||||
const ended = result.status === "failed" && (!noInput || permanent || exhausted);
|
||||
const ended = result.status === "failed" && (!noInput || unavailable || exhausted);
|
||||
outbox.finalizeDelivery(member.id, state);
|
||||
save(member.id, { ...receipt, ...(ended ? { ended: true, error: result.error } : {}), ...(result.status === "retained" ? { heldIds: result.outboxIds ?? [] } : {}),
|
||||
...(retryAt ? { retryAt } : {}), ...(nextAction ? { nextAction } : {}),
|
||||
|
||||
@@ -187,6 +187,44 @@ describe("PluginVendorService — vendoring (HG-2.3)", () => {
|
||||
expect(fs._store["/home/test/.openrig/plugins/openrig-core/.claude-plugin/plugin.json"]).toContain('"0.2.0"');
|
||||
});
|
||||
|
||||
// The openrig plugin carries the CLI's release version from 0.7.13, so an install still at its earlier 0.1.0 refreshes;
|
||||
// a daemon run from development source (still 0.1.0) leaves a newer released install as it is.
|
||||
it("refreshes an openrig plugin installed at 0.1.0 to a release-stamped 0.7.13, backing up what it replaces, and never downgrades", async () => {
|
||||
const manifest = (version: string) => `{"name":"openrig","version":"${version}"}`;
|
||||
const bundled = (version: string, skill: string) => ({
|
||||
"/asset-root/openrig/.claude-plugin/plugin.json": manifest(version),
|
||||
"/asset-root/openrig/.codex-plugin/plugin.json": manifest(version),
|
||||
"/asset-root/openrig/skills/working-with-an-openrig-team/SKILL.md": skill,
|
||||
});
|
||||
const installedSkill = "/fixture/home/.openrig/plugins/openrig/skills/working-with-an-openrig-team/SKILL.md";
|
||||
const installedManifest = "/fixture/home/.openrig/plugins/openrig/.claude-plugin/plugin.json";
|
||||
const service = (fs: ReturnType<typeof mockFs>) => new PluginVendorService({ vendoredAssetsDir: "/asset-root",
|
||||
userPluginsDir: "/fixture/home/.openrig/plugins", fs, httpClient: vi.fn(), logger: vi.fn() });
|
||||
|
||||
const older = mockFs({ ...bundled("0.7.13", "# released 0.7.13"), [installedManifest]: manifest("0.1.0"), [installedSkill]: "# installed 0.1.0" });
|
||||
await service(older).ensureVendored("openrig");
|
||||
expect(older._store[installedSkill]).toBe("# released 0.7.13");
|
||||
expect(older._store[installedManifest]).toBe(manifest("0.7.13"));
|
||||
const backups = Object.entries(older._store).filter(([path]) => path.startsWith("/fixture/home/.openrig/backups/managed-refresh/"));
|
||||
expect(backups.map(([path, text]) => [path.split("/").slice(-1)[0], text])).toEqual(expect.arrayContaining([["SKILL.md", "# installed 0.1.0"]]));
|
||||
|
||||
const devSource = mockFs({ ...bundled("0.1.0", "# development source"), [installedManifest]: manifest("0.7.13"), [installedSkill]: "# released 0.7.13" });
|
||||
await service(devSource).ensureVendored("openrig");
|
||||
expect(devSource._store[installedSkill]).toBe("# released 0.7.13");
|
||||
expect(devSource._store[installedManifest]).toBe(manifest("0.7.13"));
|
||||
|
||||
// An openrig install with no version manifest was put there another way: left as it is, with no version invented.
|
||||
const unversioned = mockFs({ ...bundled("0.7.13", "# released 0.7.13"), [installedSkill]: "# a person's own openrig copy" });
|
||||
const before = { ...unversioned._store };
|
||||
const logger = vi.fn();
|
||||
await new PluginVendorService({ vendoredAssetsDir: "/asset-root", userPluginsDir: "/fixture/home/.openrig/plugins", fs: unversioned,
|
||||
httpClient: vi.fn(), logger }).ensureVendored("openrig");
|
||||
expect(unversioned._store).toEqual(before);
|
||||
expect(unversioned.exists(installedManifest)).toBe(false);
|
||||
expect(unversioned.exists("/fixture/home/.openrig/plugins/openrig/.codex-plugin/plugin.json")).toBe(false);
|
||||
expect(logger).toHaveBeenCalledWith(expect.stringMatching(/existing plugin 'openrig' has no version authority .*leaving it unchanged/));
|
||||
});
|
||||
|
||||
it("the shipped core upgrades 0.1.5 installs to daemon-publication instructions", async () => {
|
||||
const source = "/asset-root/openrig-core";
|
||||
const target = "/home/test/.openrig/plugins/openrig-core";
|
||||
|
||||
@@ -102,8 +102,9 @@ describe("SeatHandoverService source execution (OPR.0.5.5.5)", () => {
|
||||
return { runtime: "codex", launchHarness, checkReady } as unknown as RuntimeAdapter;
|
||||
}
|
||||
|
||||
function newService(): SeatHandoverService {
|
||||
function newService(extra: Partial<ConstructorParameters<typeof SeatHandoverService>[0]> = {}): SeatHandoverService {
|
||||
return new SeatHandoverService({
|
||||
...extra,
|
||||
db,
|
||||
rigRepo,
|
||||
sessionRegistry,
|
||||
@@ -654,6 +655,32 @@ describe("SeatHandoverService source execution (OPR.0.5.5.5)", () => {
|
||||
expect(discoveryRepo.getDiscoveredSession(previous.discovered.id)?.claimedNodeId).toBe(other.id);
|
||||
});
|
||||
|
||||
it.each(["fresh", "rebuild"])("%s hands the invalidator the handed-over seat's own generation, not a newer same-name row's", async (source) => {
|
||||
// Re-running a spec archives a stopped rig and makes a new one with its name; the old one, unarchived and restored
|
||||
// while the new one is stopped, records a newer session under the seat's name, and is then archived again.
|
||||
const old = rigRepo.createRig("seat-rig");
|
||||
const oldNode = rigRepo.addNode(old.id, "dev.impl", { runtime: "codex", cwd: "/project" });
|
||||
sessionRegistry.updateStatus(sessionRegistry.registerSession(oldNode.id, "dev-impl@seat-rig").id, "exited");
|
||||
rigRepo.archiveRig(old.id);
|
||||
const { node, sessionId } = seedSeat();
|
||||
sessionRegistry.updateStatus(sessionId, "exited");
|
||||
const retiring = sessionRegistry.currentOccupantTenure(node.id)!.generationUuid;
|
||||
rigRepo.unarchiveRig(old.id);
|
||||
await new Promise((resolve) => setTimeout(resolve, 2)); // a later millisecond, so its row sorts newer
|
||||
sessionRegistry.updateStatus(sessionRegistry.registerSession(oldNode.id, "dev-impl@seat-rig").id, "exited");
|
||||
rigRepo.archiveRig(old.id);
|
||||
const newer = sessionRegistry.currentOccupantTenure(oldNode.id)!.generationUuid;
|
||||
expect(newer).not.toBe(retiring);
|
||||
expect(sessionRegistry.currentOccupantGenerationForSession("dev-impl@seat-rig")).toBe(newer);
|
||||
const invalidations: Array<{ retiringGeneration?: string }> = [];
|
||||
service = newService({ occupantInvalidator: { invalidateRetiringOccupant: (input) => { invalidations.push(input); } } });
|
||||
|
||||
const result = await service.handover({ seatRef: "dev-impl@seat-rig", reason: "context-wall", source });
|
||||
|
||||
expect(result).toMatchObject({ ok: true, result: { mutated: true } });
|
||||
expect(invalidations).toEqual([expect.objectContaining({ retiringSessionName: "dev-impl@seat-rig", retiringGeneration: retiring })]);
|
||||
});
|
||||
|
||||
it.each([false, true])("a foreign pane claim refuses with a running session present=%s (#237 holds)", async (runningSession) => {
|
||||
const { rig, node, sessionId } = seedSeat();
|
||||
const other = rigRepo.addNode(rig.id, "dev.other", { runtime: "codex", cwd: "/project" });
|
||||
|
||||
@@ -5,7 +5,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import type { Worker } from "node:worker_threads";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import { createDaemon } from "../src/startup.js";
|
||||
import { createSlowOpRequestMiddleware } from "../src/domain/slow-op-recorder.js";
|
||||
@@ -67,12 +67,37 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("SlowOpRecorder locked instrumentation contract", () => {
|
||||
it("returns promptly while begin acknowledgements are delayed", async () => {
|
||||
const mod = await loadRecorderModule();
|
||||
if (!mod) throw new Error("recorder module missing");
|
||||
const logPath = path.join(tempDir(), "pending.jsonl");
|
||||
const recorder = new mod.SlowOpRecorder({ logPath });
|
||||
const worker: Worker = (recorder as any).worker;
|
||||
const post = worker.postMessage;
|
||||
const held: unknown[] = [];
|
||||
worker.postMessage = message => {
|
||||
if (message?.record?.phase === "begin") held.push(message);
|
||||
else post.call(worker, message);
|
||||
};
|
||||
try {
|
||||
const start = performance.now();
|
||||
const values = [1, 2, 3].map(value => recorder.runSync("test.delayed.begin", () => value));
|
||||
const elapsedMs = performance.now() - start;
|
||||
expect(values).toEqual([1, 2, 3]);
|
||||
expect(held).toHaveLength(3);
|
||||
expect(elapsedMs).toBeLessThan(100);
|
||||
} finally {
|
||||
worker.postMessage = post;
|
||||
for (const message of held) post.call(worker, message);
|
||||
await recorder.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("pins the absolute boundary, rotation, filename, and secret-safe record contract", async () => {
|
||||
const mod = await loadRecorderModule();
|
||||
expect(mod, "slow-op-recorder production module is missing").not.toBeNull();
|
||||
if (!mod) return;
|
||||
|
||||
expect(mod.SLOW_OPERATION_BARRIER_TIMEOUT_MS).toBe(250);
|
||||
expect(mod.SLOW_OPERATION_THRESHOLD_MS).toBe(250);
|
||||
expect(mod.SLOW_OPERATION_ROTATION_BYTES).toBe(1024 * 1024);
|
||||
expect(mod.SLOW_OPERATION_ROTATION_COUNT).toBe(3);
|
||||
@@ -85,9 +110,6 @@ describe("SlowOpRecorder locked instrumentation contract", () => {
|
||||
process.env.OPENRIG_TEST_SECRET = secret;
|
||||
try {
|
||||
const value = recorder.runSync("test.sync.boundary", () => {
|
||||
const records = fs.readFileSync(logPath, "utf8");
|
||||
expect(records).toContain('"phase":"begin"');
|
||||
expect(records).toContain('"site":"test.sync.boundary"');
|
||||
return 41;
|
||||
});
|
||||
expect(value).toBe(41);
|
||||
@@ -107,88 +129,34 @@ describe("SlowOpRecorder locked instrumentation contract", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves a durable open span after the begin barrier and exposes fail-visible degradation", async () => {
|
||||
it("flushes a queued open span and exposes acknowledged write failure in health", async () => {
|
||||
const mod = await loadRecorderModule();
|
||||
expect(mod, "slow-op-recorder production module is missing").not.toBeNull();
|
||||
if (!mod) return;
|
||||
|
||||
if (!mod) throw new Error("recorder module missing");
|
||||
const dir = tempDir();
|
||||
const logPath = path.join(dir, "slow-operations.jsonl");
|
||||
const logPath = path.join(dir, "open.jsonl");
|
||||
const recorder = new mod.SlowOpRecorder({ logPath });
|
||||
const span = recorder.beginSyncSpan("test.sync.wedge");
|
||||
expect(span).toBeTruthy();
|
||||
const records = await readRecords(logPath);
|
||||
expect(records).toHaveLength(1);
|
||||
expect(records[0]).toMatchObject({ phase: "begin", site: "test.sync.wedge" });
|
||||
expect(recorder.snapshot()).toMatchObject({ healthy: true });
|
||||
recorder.beginSyncSpan("test.sync.wedge");
|
||||
await recorder.flush();
|
||||
expect(await readRecords(logPath)).toMatchObject([{ phase: "begin", site: "test.sync.wedge" }]);
|
||||
await recorder.close();
|
||||
|
||||
const degraded = new mod.SlowOpRecorder({
|
||||
logPath: path.join(dir, "degraded.jsonl"),
|
||||
barrierTimeoutMs: 0,
|
||||
});
|
||||
const degraded = new mod.SlowOpRecorder({ logPath: dir });
|
||||
const oldNoKernel = process.env.OPENRIG_NO_KERNEL;
|
||||
process.env.OPENRIG_NO_KERNEL = "1";
|
||||
const daemon = await createTestDaemon({
|
||||
dbPath: ":memory:",
|
||||
tmuxExec: async () => "",
|
||||
cmuxExec: async () => "",
|
||||
slowOpRecorder: degraded,
|
||||
} as never);
|
||||
const daemon = await createTestDaemon({ dbPath: ":memory:", tmuxExec: async () => "", cmuxExec: async () => "", slowOpRecorder: degraded } as never);
|
||||
try {
|
||||
const worker: Worker = (degraded as any).worker;
|
||||
const postMessage = worker.postMessage;
|
||||
let intercepted = 0;
|
||||
let withheldSignal: SharedArrayBuffer | undefined;
|
||||
try {
|
||||
worker.postMessage = (message, transferList) => {
|
||||
if (message?.type === "append"
|
||||
&& message.record?.phase === "begin"
|
||||
&& message.record?.site === "test.sync.degraded"
|
||||
&& message.signal instanceof SharedArrayBuffer) {
|
||||
intercepted++;
|
||||
const { signal, ...withoutSignal } = message;
|
||||
withheldSignal = signal;
|
||||
// Append through the real worker, but withhold this acknowledgement.
|
||||
return postMessage.call(worker, withoutSignal, transferList);
|
||||
}
|
||||
return postMessage.call(worker, message, transferList);
|
||||
};
|
||||
expect(degraded.runSync("test.sync.degraded", () => "continued")).toBe("continued");
|
||||
} finally {
|
||||
worker.postMessage = postMessage;
|
||||
}
|
||||
expect(intercepted).toBe(1);
|
||||
expect(worker.postMessage).toBe(postMessage);
|
||||
expect(withheldSignal).toBeInstanceOf(SharedArrayBuffer);
|
||||
expect(new Int32Array(withheldSignal!)[0]).toBe(0);
|
||||
expect(degraded.snapshot()).toMatchObject({
|
||||
healthy: false,
|
||||
reason: "begin_barrier_timeout",
|
||||
site: "test.sync.degraded",
|
||||
});
|
||||
|
||||
const healthResponse = await daemon.app.request("/healthz");
|
||||
expect(await healthResponse.json()).toMatchObject({
|
||||
status: "ok",
|
||||
slowOperations: {
|
||||
healthy: false,
|
||||
reason: "begin_barrier_timeout",
|
||||
site: "test.sync.degraded",
|
||||
},
|
||||
});
|
||||
expect(degraded.runSync("test.sync.degraded", () => "continued")).toBe("continued");
|
||||
await expect(degraded.flush()).rejects.toBeInstanceOf(mod.SlowOpRecorderWriteError);
|
||||
expect(degraded.snapshot()).toMatchObject({ healthy: false, reason: "recorder_write_failed", site: "recorder.worker" });
|
||||
const response = await daemon.app.request("/healthz");
|
||||
expect(await response.json()).toMatchObject({ status: "ok", slowOperations: { healthy: false, reason: "recorder_write_failed" } });
|
||||
const observations = daemon.deps.streamStore!.list();
|
||||
expect(observations).toHaveLength(1);
|
||||
expect(observations[0]!.body).toContain("begin_barrier_timeout");
|
||||
expect(observations[0]!.body).toContain("test.sync.degraded");
|
||||
await degraded.flush();
|
||||
const degradedRecords = await readRecords(path.join(dir, "degraded.jsonl"));
|
||||
expect(degradedRecords.filter((record) => record.phase === "begin"
|
||||
&& record.site === "test.sync.degraded")).toHaveLength(1);
|
||||
expect(observations[0]!.body).toContain("recorder_write_failed");
|
||||
} finally {
|
||||
daemon.eventLoopMonitor.stop();
|
||||
daemon.db.close();
|
||||
await degraded.close();
|
||||
await degraded.close().catch(() => {});
|
||||
if (oldNoKernel === undefined) delete process.env.OPENRIG_NO_KERNEL;
|
||||
else process.env.OPENRIG_NO_KERNEL = oldNoKernel;
|
||||
}
|
||||
@@ -211,20 +179,17 @@ describe("SlowOpRecorder locked instrumentation contract", () => {
|
||||
});
|
||||
expect(value).toBe("continued");
|
||||
expect(ran).toBe(true);
|
||||
expect(recorder.snapshot()).toEqual({
|
||||
healthy: false,
|
||||
reason: "begin_barrier_failed",
|
||||
site: "test.sync.observer_failure",
|
||||
});
|
||||
await expect(recorder.flush()).rejects.toBeInstanceOf(mod.SlowOpRecorderWriteError);
|
||||
expect(recorder.snapshot()).toEqual({ healthy: false, reason: "recorder_write_failed", site: "recorder.worker" });
|
||||
} finally {
|
||||
// This recorder's log path is intentionally unwritable (to force the
|
||||
// begin-barrier failure above), so close() now correctly rejects on the
|
||||
// asynchronously acknowledged write failure above), so close() now correctly rejects on the
|
||||
// latched lost-write durability — tolerate it in teardown.
|
||||
await recorder.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it("durably records an open span before a main-thread sync wedge is killed", async () => {
|
||||
it("writes a queued begin while the main thread is wedged, before a later kill", async () => {
|
||||
const dir = tempDir();
|
||||
const logPath = path.join(dir, "crash.jsonl");
|
||||
fs.writeFileSync(logPath, "", { mode: 0o600 });
|
||||
@@ -278,6 +243,7 @@ describe("SlowOpRecorder locked instrumentation contract", () => {
|
||||
});
|
||||
});
|
||||
expect(callbackEvidence).toEqual({ isMainThread: true, threadId: 0 });
|
||||
await vi.waitFor(async () => expect(await readRecords(logPath)).toMatchObject([{ phase: "begin", site: "test.sync.crash" }]));
|
||||
child.kill("SIGKILL");
|
||||
await once(child, "exit");
|
||||
|
||||
@@ -351,7 +317,7 @@ describe("SlowOpRecorder locked instrumentation contract", () => {
|
||||
expect(JSON.stringify(requests)).not.toContain("must-not-appear");
|
||||
});
|
||||
|
||||
it("wraps all nine accepted sync calls without moving them off the main thread", () => {
|
||||
it("keeps the accepted slow-operation site labels", () => {
|
||||
const srcRoot = path.resolve(import.meta.dirname, "../src");
|
||||
for (const [relativePath, sites] of expectedSites) {
|
||||
const source = fs.readFileSync(path.join(srcRoot, relativePath), "utf8");
|
||||
@@ -496,7 +462,7 @@ describe("SlowOpRecorder locked instrumentation contract", () => {
|
||||
expect((await app.request("/definitely-missing")).status).toBe(404);
|
||||
});
|
||||
|
||||
it("drives the real tmux command-v spawnSync site through a hermetic daemon", async () => {
|
||||
it("drives the asynchronous tmux command-v site through a hermetic daemon", async () => {
|
||||
const mod = await loadRecorderModule();
|
||||
expect(mod, "slow-op-recorder production module is missing").not.toBeNull();
|
||||
if (!mod) return;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { TmuxAdapter } from "../src/adapters/tmux.js";
|
||||
import { TmuxOptionDefaultsApplier } from "../src/domain/tmux-option-defaults.js";
|
||||
|
||||
const execute = vi.hoisted(() => vi.fn());
|
||||
vi.mock("node:child_process", () => ({ execFile: execute }));
|
||||
beforeEach(() => { execute.mockReset(); });
|
||||
|
||||
describe("clipboard discovery deadline", () => {
|
||||
it.each([null, "custom-copy"])("warns and retries a killed default probe, preserving %j", async initial => {
|
||||
let copyCommand = initial;
|
||||
const setServerOption = vi.fn(async (name: string, value: string) => {
|
||||
if (name === "copy-command") copyCommand = value;
|
||||
return { ok: true };
|
||||
});
|
||||
const adapter = { setSessionOption: async () => ({ ok: true }), setServerOption } as unknown as TmuxAdapter;
|
||||
execute.mockImplementationOnce((_file, _args, options, callback) => {
|
||||
expect(options).toMatchObject({ timeout: 250, killSignal: "SIGKILL" });
|
||||
callback(Object.assign(new Error("probe deadline"), { killed: true }));
|
||||
}).mockImplementationOnce((_file, _args, _options, callback) => callback(null));
|
||||
const applier = new TmuxOptionDefaultsApplier({ tmuxAdapter: adapter, platform: "linux" });
|
||||
const warnings = await applier.applyToFreshSession("first@test");
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0]).toMatch(/clipboard.*250 ms/i);
|
||||
expect(warnings[0]).toMatch(/retry/i);
|
||||
expect(copyCommand).toBe(initial);
|
||||
expect(execute).toHaveBeenCalledTimes(1);
|
||||
expect(await applier.applyToFreshSession("next@test")).toEqual([]);
|
||||
expect(copyCommand).toBe("wl-copy");
|
||||
expect(execute).toHaveBeenCalledTimes(2);
|
||||
expect(await applier.applyToFreshSession("later@test")).toEqual([]);
|
||||
expect(execute).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("memoizes confirmed absence without a deadline warning", async () => {
|
||||
execute.mockImplementation((_file, _args, _options, callback) => callback(Object.assign(new Error("absent"), { code: 1 })));
|
||||
const setServerOption = vi.fn(async () => ({ ok: true }));
|
||||
const adapter = { setSessionOption: async () => ({ ok: true }), setServerOption } as unknown as TmuxAdapter;
|
||||
const applier = new TmuxOptionDefaultsApplier({ tmuxAdapter: adapter, platform: "linux" });
|
||||
expect(await applier.applyToFreshSession("first@test")).toEqual([]);
|
||||
expect(await applier.applyToFreshSession("next@test")).toEqual([]);
|
||||
expect(execute).toHaveBeenCalledTimes(2);
|
||||
expect(setServerOption).not.toHaveBeenCalledWith("copy-command", expect.anything());
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,24 @@ function mockAdapter(overrides?: {
|
||||
}
|
||||
|
||||
describe("TmuxOptionDefaultsApplier", () => {
|
||||
it("awaits an asynchronous clipboard lookup without holding unrelated work", async () => {
|
||||
const { adapter, setServerOption } = mockAdapter();
|
||||
let release!: (present: boolean) => void;
|
||||
const pending = new Promise<boolean>(resolve => { release = resolve; });
|
||||
const probe = vi.fn((bin: string) => bin === "wl-copy" ? pending : Promise.resolve(true));
|
||||
let finished = false;
|
||||
const applier = new TmuxOptionDefaultsApplier({ tmuxAdapter: adapter, platform: "linux", hasCommand: probe });
|
||||
const applied = applier.applyToFreshSession("async-clipboard@test").then(value => { finished = true; return value; });
|
||||
try {
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(finished).toBe(false);
|
||||
expect(setServerOption).not.toHaveBeenCalledWith("copy-command", expect.anything());
|
||||
} finally { release(false); }
|
||||
expect(await applied).toEqual([]);
|
||||
expect(probe.mock.calls.map(([bin]) => bin)).toEqual(["wl-copy", "xclip"]);
|
||||
expect(setServerOption).toHaveBeenCalledWith("copy-command", "xclip -selection clipboard -i");
|
||||
});
|
||||
|
||||
it("sets mouse on + status off (default) on the given session via SESSION scope only", async () => {
|
||||
const { adapter, setSessionOption, setServerOption } = mockAdapter();
|
||||
// default reader (omitted) → statusBar false.
|
||||
@@ -109,20 +127,20 @@ describe("TmuxOptionDefaultsApplier", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveCopyCommand (pure per-platform table)", () => {
|
||||
it("darwin → pbcopy", () => {
|
||||
expect(resolveCopyCommand("darwin", () => false)).toBe("pbcopy");
|
||||
describe("resolveCopyCommand (pure per-platform table)", async () => {
|
||||
it("darwin → pbcopy", async () => {
|
||||
expect(await resolveCopyCommand("darwin", () => false)).toBe("pbcopy");
|
||||
});
|
||||
it("linux → wl-copy when present", () => {
|
||||
expect(resolveCopyCommand("linux", (b) => b === "wl-copy")).toBe("wl-copy");
|
||||
it("linux → wl-copy when present", async () => {
|
||||
expect(await resolveCopyCommand("linux", (b) => b === "wl-copy")).toBe("wl-copy");
|
||||
});
|
||||
it("linux → xclip when wl-copy absent but xclip present", () => {
|
||||
expect(resolveCopyCommand("linux", (b) => b === "xclip")).toBe("xclip -selection clipboard -i");
|
||||
it("linux → xclip when wl-copy absent but xclip present", async () => {
|
||||
expect(await resolveCopyCommand("linux", (b) => b === "xclip")).toBe("xclip -selection clipboard -i");
|
||||
});
|
||||
it("linux → null when neither present (OSC 52 fallback)", () => {
|
||||
expect(resolveCopyCommand("linux", () => false)).toBeNull();
|
||||
it("linux → null when neither present (OSC 52 fallback)", async () => {
|
||||
expect(await resolveCopyCommand("linux", () => false)).toBeNull();
|
||||
});
|
||||
it("other platforms → null", () => {
|
||||
expect(resolveCopyCommand("win32", () => true)).toBeNull();
|
||||
it("other platforms → null", async () => {
|
||||
expect(await resolveCopyCommand("win32", () => true)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -94,16 +94,42 @@ it("records a retryable refusal on the parked row without consuming its one-shot
|
||||
expect(f.repo.getParkWakeStatus(row.qitemId)).toMatchObject({ phase: "fired", deliveryStatus: "ok", live: false });
|
||||
});
|
||||
|
||||
it("records exhausted one-shot retries as a failed wake before ending the timer", async () => {
|
||||
const send = vi.fn(async () => absent), f = fixture(send), row = await f.park();
|
||||
it.each([false, true])("keeps a refused one-shot owed beyond the retry cap and across restart (digest=%s)", async digest => {
|
||||
let result = absent;
|
||||
const send = vi.fn(async () => result), f = fixture(send, undefined, digest), rows = [await f.park()];
|
||||
if (digest) rows.push(await f.park());
|
||||
later(3600); await f.scheduler.runTickNow();
|
||||
for (let i = 0; i < 3; i++) { later(300); await f.scheduler.runTickNow(); }
|
||||
expect(f.errors).toHaveLength(0); expect(send).toHaveBeenCalledTimes(4);
|
||||
expect(f.repo.getById(row.qitemId)?.state).toBe("blocked");
|
||||
expect(f.repo.getParkWakeStatus(row.qitemId)).toMatchObject({ phase: "fired", deliveryStatus: "failed", live: false, unconsumed: true });
|
||||
expect(f.jobs.getByIdOrThrow(row.jobId).terminalReason).toBe("park_timer_fired_once");
|
||||
expect(f.history.listForJob(row.jobId)[0]?.evaluationNotes?.nextAction).toContain("exhausted");
|
||||
later(7200); await f.scheduler.runTickNow(); expect(send).toHaveBeenCalledTimes(4);
|
||||
for (let i = 0; i < 5; i++) { later(300); await f.scheduler.runTickNow(); }
|
||||
expect(f.errors).toHaveLength(0); expect(send).toHaveBeenCalledTimes(6);
|
||||
const ids = f.outbox.listForSender("watchdog@system").map(e => e.outboxId).sort();
|
||||
for (const row of rows) {
|
||||
expect(f.repo.getParkWakeStatus(row.qitemId)).toMatchObject({ phase: "armed", live: true });
|
||||
expect(f.jobs.getByIdOrThrow(row.jobId)).toMatchObject({ state: "active", lastFireAt: null });
|
||||
expect(f.history.listForJob(row.jobId)[0]?.evaluationNotes?.nextAction).toContain("remains owed");
|
||||
}
|
||||
const saved = f.db.serialize(); f.db.close(); const resumed = fixture(send, saved, digest);
|
||||
result = delivered; later(299); await resumed.scheduler.runTickNow(); expect(send).toHaveBeenCalledTimes(6);
|
||||
later(1); await resumed.scheduler.runTickNow(); expect(send).toHaveBeenCalledTimes(7);
|
||||
for (const row of rows) {
|
||||
expect(resumed.repo.getParkWakeStatus(row.qitemId)).toMatchObject({ phase: "fired", deliveryStatus: "ok", live: false });
|
||||
expect(resumed.jobs.getByIdOrThrow(row.jobId).terminalReason).toBe("park_timer_fired_once");
|
||||
expect(resumed.repo.getById(row.qitemId)).toMatchObject({ state: "blocked", blockedOn: "external:wait", body: "Continue work" });
|
||||
expect(resumed.repo.currentParkContinuation(row.qitemId)).toBe("work");
|
||||
}
|
||||
expect(resumed.outbox.listForSender("watchdog@system").map(e => e.outboxId).sort()).toEqual(ids);
|
||||
expect(resumed.outbox.listForSender("watchdog@system").every(e => e.deliveryState === "delivered")).toBe(true);
|
||||
later(7200); await resumed.scheduler.runTickNow(); expect(send).toHaveBeenCalledTimes(7);
|
||||
expect(resumed.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps a one-shot owed when its transport is unavailable, then delivers when repaired", async () => {
|
||||
let result: SendResult = { ...absent, reason: "transport_unavailable" };
|
||||
const send = vi.fn(async () => result), f = fixture(send), row = await f.park();
|
||||
later(3600); await f.scheduler.runTickNow();
|
||||
expect(f.repo.getParkWakeStatus(row.qitemId)).toMatchObject({ phase: "armed", live: true });
|
||||
result = delivered; later(300); await f.scheduler.runTickNow();
|
||||
expect(send).toHaveBeenCalledTimes(2);
|
||||
expect(f.repo.getParkWakeStatus(row.qitemId)).toMatchObject({ phase: "fired", deliveryStatus: "ok", live: false });
|
||||
});
|
||||
|
||||
it.each([false, true])("repairs a post-fire accounting crash without duplicate input (digest=%s)", async digest => {
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
{
|
||||
"release": "0.7.10",
|
||||
"archiveSha256": "c55aff7e7c7d1429e6d12ee89540961005bcb1efd6e7017753071155b1952732",
|
||||
"release": "0.7.11",
|
||||
"archiveSha256": "71e3b727db0d8ab68ff0da99f5d46eebe123b9474446a7ff3e6e5497b2506419",
|
||||
"plugins": {
|
||||
"openrig": {
|
||||
"version": "0.1.0",
|
||||
"sha256": "ee8a1300119576f36889cc6494988c69faa3dfb2c3172cfd856a7ecc3edc0506"
|
||||
},
|
||||
"openrig-core": {
|
||||
"version": "0.1.26",
|
||||
"sha256": "23ed778d65d7feeff7e22b29538627973678241d1676a8d41253371d8ff4ec65"
|
||||
"version": "0.1.27",
|
||||
"sha256": "006f018d2a80bd22949a651461269010627e2ed68c7be91a51fc57c4ed723f43"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { packEntry } from './npm-pack-json.mjs';
|
||||
import { checkPackagedPluginVersions } from './check-packaged-plugin-versions.mjs';
|
||||
|
||||
const LISTING = ['packages/acp/registry/agent.json', 'packages/acp/registry/icon.svg'];
|
||||
const readJson = (root, rel) => JSON.parse(readFileSync(join(root, rel), 'utf8'));
|
||||
@@ -27,12 +28,81 @@ export function launcherMismatches(root) {
|
||||
].filter(([, actual, wanted]) => actual !== wanted).map(([where, actual, wanted]) => `${where} is ${JSON.stringify(actual)}, not ${JSON.stringify(wanted)}`);
|
||||
}
|
||||
|
||||
// The pages a newcomer copies install commands from. Each links the rigs skill, and the README and getting started
|
||||
// also link the install script, at a release tag, so a copied command fetches that release's skill or install script
|
||||
// (the script itself installs the latest published CLI): never a placeholder, a stale tag or main's unreleased skill.
|
||||
// build:package copies docs/reference into the assembled CLI, and npm packs that copy, so both copies are checked.
|
||||
const INSTALL_PAGES = ['README.md', 'docs/reference/getting-started.md', 'docs/reference/quickstart.md',
|
||||
'packages/cli/daemon/docs/reference/getting-started.md', 'packages/cli/daemon/docs/reference/quickstart.md'];
|
||||
const INSTALL_LINK = /github(?:usercontent)?\.com\/mvschwarz\/openrig\/(?:tree\/)?([^/\s"'`]+)\/(skills\/rigs|scripts\/install\.sh)/g;
|
||||
|
||||
/** Where an install page links the rigs skill or the install script at anything but this release's tag, each named by
|
||||
* file and line. A page with no rigs-skill link is named too, so a rewritten page can't pass by linking nothing. */
|
||||
export function installTagMismatches(root, version) {
|
||||
const tag = `v${version}`;
|
||||
return INSTALL_PAGES.flatMap(rel => {
|
||||
if (!existsSync(join(root, rel))) return [`${rel} is missing`];
|
||||
const links = readFileSync(join(root, rel), 'utf8').split('\n')
|
||||
.flatMap((line, i) => [...line.matchAll(INSTALL_LINK)].map(([, ref, target]) => ({ line: i + 1, ref, target })));
|
||||
return [
|
||||
...(links.some(link => link.target === 'skills/rigs') ? [] : [`${rel} has no rigs-skill install link`]),
|
||||
...links.filter(link => link.ref !== tag).map(link => `${rel}:${link.line} links ${link.target} at ${JSON.stringify(link.ref)}, not ${JSON.stringify(tag)}`),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
const BASELINE = 'scripts/fixtures/packaged-plugins-last-release.json';
|
||||
// The openrig plugin in source, and as the assembled CLI packs it.
|
||||
const OPENRIG_PLUGIN_DIRS = ['packages/daemon/assets/plugins/openrig', 'packages/cli/daemon/assets/plugins/openrig'];
|
||||
const OPENRIG_MANIFESTS = ['plugin.json', '.claude-plugin/plugin.json', '.codex-plugin/plugin.json', '.cursor-plugin/plugin.json'];
|
||||
const parts = version => version.split('.').map(Number);
|
||||
const earlier = (a, b) => { const [x, y] = [parts(a), parts(b)]; const i = x.findIndex((n, k) => n !== y[k]); return i >= 0 && x[i] < y[i]; };
|
||||
|
||||
/** The packaged plugins this release is checked against: the last published release's, refreshed from that published
|
||||
* archive with `node scripts/check-packaged-plugin-versions.mjs <published.tgz> --snapshot`. An earlier release with
|
||||
* its archive hash and plugins, never this candidate's own. */
|
||||
export function releaseBaseline(root, version) {
|
||||
const baseline = readJson(root, BASELINE);
|
||||
const problems = [
|
||||
...(typeof baseline.release === 'string' && /^\d+\.\d+\.\d+$/.test(baseline.release) && earlier(baseline.release, version) ? []
|
||||
: [`its release is ${JSON.stringify(baseline.release)}, not a release published before ${version}`]),
|
||||
...(/^[0-9a-f]{64}$/.test(baseline.archiveSha256 ?? '') ? [] : ['it has no archive sha256']),
|
||||
...(baseline.plugins && Object.keys(baseline.plugins).length > 0 ? [] : ['it lists no plugins']),
|
||||
];
|
||||
if (problems.length > 0) throw new Error(`${BASELINE} must be the last published release's packaged plugins: ${problems.join('; ')}. Refresh it from that published archive (node scripts/check-packaged-plugin-versions.mjs <published.tgz> --snapshot).`);
|
||||
return baseline;
|
||||
}
|
||||
|
||||
/** The openrig plugin ships inside the CLI, so all four of its manifests, in source and in the assembled CLI, carry the
|
||||
* CLI's release version; openrig-core keeps its own, and other plugins' optional manifests stay optional. */
|
||||
export function openrigPluginMismatches(root, version) {
|
||||
return OPENRIG_PLUGIN_DIRS.flatMap(dir => OPENRIG_MANIFESTS.map(rel => `${dir}/${rel}`)).flatMap(path => {
|
||||
if (!existsSync(join(root, path))) return [`${path} is missing`];
|
||||
const actual = readJson(root, path).version;
|
||||
return actual === version ? [] : [`${path} "version" is ${JSON.stringify(actual)}, not ${JSON.stringify(version)}`];
|
||||
});
|
||||
}
|
||||
|
||||
/** What a successful pack says about its baseline: the checks read the baseline's own fields, so whether it's the
|
||||
* registry's latest @openrig/cli is for the person releasing to confirm. */
|
||||
export function baselineNotice(manifest) {
|
||||
const { release, archiveSha256 } = manifest.plugins.baseline;
|
||||
return `Plugins checked against ${release} (archive sha256 ${archiveSha256}); confirm that's the registry's latest @openrig/cli before publishing.`;
|
||||
}
|
||||
|
||||
export function packRelease(root, dest) {
|
||||
const version = readJson(root, 'packages/cli/package.json').version;
|
||||
const mismatches = launcherMismatches(root);
|
||||
if (mismatches.length > 0) throw new Error(`The launcher must match @openrig/cli ${version}:\n- ${mismatches.join('\n- ')}`);
|
||||
// This catches a build that never ran, not a partial one: a successful build:package is the precondition.
|
||||
if (!existsSync(join(root, 'packages/cli/daemon/dist'))) throw new Error('packages/cli has no daemon/dist, so build:package hasn\'t run: run npm run build:package first');
|
||||
const links = installTagMismatches(root, version);
|
||||
if (links.length > 0) throw new Error(`The install pages must link this release's tag, v${version}, in source and in the assembled CLI; set them in the release preparation, then run npm run build:package again:\n- ${links.join('\n- ')}`);
|
||||
const stamp = openrigPluginMismatches(root, version);
|
||||
if (stamp.length > 0) throw new Error(`The openrig plugin's four manifests must carry @openrig/cli ${version}, in source and in the assembled CLI; set them in the release preparation, then run npm run build:package again:\n- ${stamp.join('\n- ')}`);
|
||||
// The assembled CLI's plugins are what's packed: changed plugin bytes need a greater version than the last release's.
|
||||
const baseline = releaseBaseline(root, version);
|
||||
const plugins = checkPackagedPluginVersions(join(root, 'packages/cli'), baseline);
|
||||
mkdirSync(dest, { recursive: true });
|
||||
// The CLI is packed as the release producer has packed it; the launcher isn't a workspace, so it's packed by path.
|
||||
// --ignore-scripts skips pack's prepack and postpack, but npm 10 still runs a `prepare`; neither package has one.
|
||||
@@ -43,7 +113,9 @@ export function packRelease(root, dest) {
|
||||
return { name: packed.name, version: packed.version, path, size: packed.size, sha256: sha256(path), integrity: packed.integrity };
|
||||
});
|
||||
const listing = LISTING.map(rel => ({ path: join(root, rel), sha256: sha256(join(root, rel)) }));
|
||||
const manifest = { version, artifacts, listing, publishOrder: ['@openrig/cli', '@openrig/acp', 'ACP registry listing'] };
|
||||
const manifest = { version, artifacts, listing,
|
||||
plugins: { baseline: { release: baseline.release, archiveSha256: baseline.archiveSha256 }, packed: Object.fromEntries(Object.entries(plugins).map(([name, p]) => [name, p.version])) },
|
||||
publishOrder: ['@openrig/cli', '@openrig/acp', 'ACP registry listing'] };
|
||||
writeFileSync(join(dest, 'RELEASE-ARTIFACTS.json'), `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
return manifest;
|
||||
}
|
||||
@@ -58,6 +130,7 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
|
||||
const manifest = packRelease(resolve(fileURLToPath(import.meta.url), '../..'), resolve(dest));
|
||||
for (const a of manifest.artifacts) console.log(`${a.name}@${a.version} ${a.path} sha256 ${a.sha256} ${a.size} B`);
|
||||
for (const l of manifest.listing) console.log(`listing ${l.path} sha256 ${l.sha256}`);
|
||||
console.log(baselineNotice(manifest));
|
||||
console.log(`Wrote ${join(resolve(dest), 'RELEASE-ARTIFACTS.json')}. Publish these archive files themselves, by path, in this order: ${manifest.publishOrder.join(', then ')}.`);
|
||||
console.log('Publishing from the packages/cli folder instead runs its prepublishOnly, which rebuilds, so the bytes published could differ from these.');
|
||||
} catch (error) {
|
||||
|
||||
@@ -4,12 +4,19 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { launcherMismatches, packRelease } from './pack-release.mjs';
|
||||
import { baselineNotice, installTagMismatches, launcherMismatches, openrigPluginMismatches, packRelease, releaseBaseline } from './pack-release.mjs';
|
||||
import { snapshotPlugins } from './check-packaged-plugin-versions.mjs';
|
||||
|
||||
const sha256 = file => createHash('sha256').update(readFileSync(file)).digest('hex');
|
||||
|
||||
/** A repo with an assembled CLI at `version` and a launcher whose four pins are `pins`. */
|
||||
function fixture(t, version, pins = {}, { assembled = true } = {}) {
|
||||
const MANIFESTS = ['plugin.json', '.claude-plugin/plugin.json', '.codex-plugin/plugin.json', '.cursor-plugin/plugin.json'];
|
||||
|
||||
/** A repo with an assembled CLI at `version` and a launcher whose four pins are `pins`. Its openrig plugin carries
|
||||
* `plugin.source` in source and `plugin.assembled` in the assembled CLI (both `version` by default), and its baseline is
|
||||
* the published `baseline.release` (0.7.10 by default) with openrig-core's `baseline.core` bytes. Its install pages
|
||||
* link the rigs skill and the install script at `pages.tag` in source and `pages.assembled` in the assembled CLI (both
|
||||
* this release's tag by default). */
|
||||
function fixture(t, version, pins = {}, { assembled = true, plugin = {}, baseline = {}, pages = {} } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'pack-release-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const write = (rel, text) => { mkdirSync(join(root, rel, '..'), { recursive: true }); writeFileSync(join(root, rel), text); };
|
||||
@@ -24,6 +31,34 @@ function fixture(t, version, pins = {}, { assembled = true } = {}) {
|
||||
json('packages/acp/registry/agent.json', { id: 'openrig', version: pins.listing ?? version,
|
||||
distribution: { npx: { package: pins.npx ?? `@openrig/acp@${version}` } } });
|
||||
write('packages/acp/registry/icon.svg', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"/>\n');
|
||||
const skill = tag => `npx skills add https://github.com/mvschwarz/openrig/tree/${tag}/skills/rigs --skill rigs --agent codex\n`;
|
||||
const script = tag => `curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/${tag}/scripts/install.sh | sh\n`;
|
||||
const reference = (dir, tag) => {
|
||||
write(`${dir}/getting-started.md`, `# Getting started\n\n${skill(tag).replace(/(https:\S+)/, '"$1"')}${script(tag)}`);
|
||||
write(`${dir}/quickstart.md`, `# Quickstart\n\n${skill(tag)}`);
|
||||
};
|
||||
const tag = pages.tag ?? `v${version}`;
|
||||
write('README.md', `# OpenRig\n\n${skill(tag)}${script(tag)}`);
|
||||
reference('docs/reference', tag);
|
||||
if (assembled) reference('packages/cli/daemon/docs/reference', pages.assembled ?? tag);
|
||||
const plugins = (dir, openrig, core) => {
|
||||
for (const rel of MANIFESTS) json(`${dir}/openrig/${rel}`, { name: 'openrig', version: openrig });
|
||||
write(`${dir}/openrig/skills/team/SKILL.md`, '# team\n');
|
||||
for (const rel of MANIFESTS.slice(1, 3)) json(`${dir}/openrig-core/${rel}`, { name: 'openrig-core', version: '0.1.28' });
|
||||
write(`${dir}/openrig-core/skills/user/SKILL.md`, core);
|
||||
};
|
||||
plugins('packages/daemon/assets/plugins', plugin.source ?? version, '# user\n');
|
||||
if (assembled) plugins('packages/cli/daemon/assets/plugins', plugin.assembled ?? plugin.source ?? version, plugin.core ?? '# user\n');
|
||||
// The published baseline: openrig at its earlier 0.1.0, and openrig-core 0.1.28 with the bytes given.
|
||||
const published = mkdtempSync(join(tmpdir(), 'pack-release-published-'));
|
||||
t.after(() => rmSync(published, { recursive: true, force: true }));
|
||||
const pub = (rel, text) => { mkdirSync(join(published, rel, '..'), { recursive: true }); writeFileSync(join(published, rel), text); };
|
||||
for (const rel of MANIFESTS) pub(`daemon/assets/plugins/openrig/${rel}`, `${JSON.stringify({ name: 'openrig', version: '0.1.0' }, null, 2)}\n`);
|
||||
pub('daemon/assets/plugins/openrig/skills/team/SKILL.md', '# team, 0.1.0\n');
|
||||
for (const rel of MANIFESTS.slice(1, 3)) pub(`daemon/assets/plugins/openrig-core/${rel}`, `${JSON.stringify({ name: 'openrig-core', version: '0.1.28' }, null, 2)}\n`);
|
||||
pub('daemon/assets/plugins/openrig-core/skills/user/SKILL.md', baseline.core ?? '# user\n');
|
||||
json('scripts/fixtures/packaged-plugins-last-release.json', { release: baseline.release ?? '0.7.10',
|
||||
archiveSha256: baseline.archiveSha256 ?? 'a'.repeat(64), plugins: snapshotPlugins(published) });
|
||||
return root;
|
||||
}
|
||||
|
||||
@@ -65,6 +100,83 @@ test('packs both packages at the one version, with exact paths and hashes, and t
|
||||
assert.deepEqual(JSON.parse(readFileSync(join(dest, 'RELEASE-ARTIFACTS.json'), 'utf8')), manifest);
|
||||
});
|
||||
|
||||
test("the openrig plugin's four manifests must carry the CLI version, in source and in the assembled CLI", t => {
|
||||
const SOURCE = 'packages/daemon/assets/plugins/openrig', ASSEMBLED = 'packages/cli/daemon/assets/plugins/openrig';
|
||||
const root = fixture(t, '0.7.13', {}, { plugin: { source: '0.1.0' } });
|
||||
assert.deepEqual(openrigPluginMismatches(root, '0.7.13'), [SOURCE, ASSEMBLED].flatMap(dir => MANIFESTS.map(rel =>
|
||||
`${dir}/${rel} "version" is "0.1.0", not "0.7.13"`)));
|
||||
const dest = join(root, 'out');
|
||||
assert.throws(() => packRelease(root, dest), /The openrig plugin's four manifests must carry @openrig\/cli 0\.7\.13, in source and in the assembled CLI/);
|
||||
assert.equal(existsSync(dest), false);
|
||||
// A stale assembly is named file by file, with the rebuild step.
|
||||
const stale = fixture(t, '0.7.13', {}, { plugin: { source: '0.7.13', assembled: '0.1.0' } });
|
||||
assert.deepEqual(openrigPluginMismatches(stale, '0.7.13'), MANIFESTS.map(rel => `${ASSEMBLED}/${rel} "version" is "0.1.0", not "0.7.13"`));
|
||||
assert.throws(() => packRelease(stale, join(stale, 'out')), /run npm run build:package again/);
|
||||
// Each of the four is required in both places, even when the others carry the version.
|
||||
for (const dir of [SOURCE, ASSEMBLED]) {
|
||||
const missing = fixture(t, '0.7.13');
|
||||
rmSync(join(missing, dir, '.cursor-plugin/plugin.json'));
|
||||
assert.deepEqual(openrigPluginMismatches(missing, '0.7.13'), [`${dir}/.cursor-plugin/plugin.json is missing`]);
|
||||
assert.throws(() => packRelease(missing, join(missing, 'out')), /\.cursor-plugin\/plugin\.json is missing/);
|
||||
assert.equal(existsSync(join(missing, 'out')), false);
|
||||
}
|
||||
assert.deepEqual(openrigPluginMismatches(fixture(t, '0.7.13'), '0.7.13'), []);
|
||||
});
|
||||
|
||||
test("the install pages must link this release's tag, and every other link is named by file and line", t => {
|
||||
const root = fixture(t, '0.7.13', {}, { pages: { tag: '<release-tag>', assembled: 'v0.7.13' } });
|
||||
assert.deepEqual(installTagMismatches(root, '0.7.13'), [
|
||||
'README.md:3 links skills/rigs at "<release-tag>", not "v0.7.13"',
|
||||
'README.md:4 links scripts/install.sh at "<release-tag>", not "v0.7.13"',
|
||||
'docs/reference/getting-started.md:3 links skills/rigs at "<release-tag>", not "v0.7.13"',
|
||||
'docs/reference/getting-started.md:4 links scripts/install.sh at "<release-tag>", not "v0.7.13"',
|
||||
'docs/reference/quickstart.md:3 links skills/rigs at "<release-tag>", not "v0.7.13"',
|
||||
]);
|
||||
const dest = join(root, 'out');
|
||||
assert.throws(() => packRelease(root, dest), /The install pages must link this release's tag, v0\.7\.13, in source and in the assembled CLI; set them in the release preparation/);
|
||||
assert.equal(existsSync(dest), false);
|
||||
// The last release's tag and main's unreleased skill are refused the same way.
|
||||
for (const tag of ['v0.7.12', 'main']) assert.equal(installTagMismatches(fixture(t, '0.7.13', {}, { pages: { tag } }), '0.7.13').length, 8);
|
||||
// npm packs the assembled copies: current source over a stale assembly is refused, with the rebuild step.
|
||||
const stale = fixture(t, '0.7.13', {}, { pages: { assembled: 'v0.7.12' } });
|
||||
assert.deepEqual(installTagMismatches(stale, '0.7.13'), [
|
||||
'packages/cli/daemon/docs/reference/getting-started.md:3 links skills/rigs at "v0.7.12", not "v0.7.13"',
|
||||
'packages/cli/daemon/docs/reference/getting-started.md:4 links scripts/install.sh at "v0.7.12", not "v0.7.13"',
|
||||
'packages/cli/daemon/docs/reference/quickstart.md:3 links skills/rigs at "v0.7.12", not "v0.7.13"',
|
||||
]);
|
||||
assert.throws(() => packRelease(stale, join(stale, 'out')), /then run npm run build:package again/);
|
||||
assert.equal(existsSync(join(stale, 'out')), false);
|
||||
// A page that links no rigs skill is named, rather than passing with nothing checked.
|
||||
const bare = fixture(t, '0.7.13');
|
||||
writeFileSync(join(bare, 'docs/reference/quickstart.md'), '# Quickstart\n');
|
||||
assert.deepEqual(installTagMismatches(bare, '0.7.13'), ['docs/reference/quickstart.md has no rigs-skill install link']);
|
||||
assert.deepEqual(installTagMismatches(fixture(t, '0.7.13'), '0.7.13'), []);
|
||||
});
|
||||
|
||||
test('the baseline must be an earlier published release with its archive hash, never the candidate', t => {
|
||||
for (const [release, why] of [['0.7.13', /not a release published before 0\.7\.13/], ['0.7.14', /not a release published before/], ['candidate', /not a release published before/]]) {
|
||||
const root = fixture(t, '0.7.13', {}, { baseline: { release } });
|
||||
assert.throws(() => releaseBaseline(root, '0.7.13'), why);
|
||||
assert.throws(() => packRelease(root, join(root, 'out')), /must be the last published release's packaged plugins/);
|
||||
}
|
||||
assert.throws(() => releaseBaseline(fixture(t, '0.7.13', {}, { baseline: { archiveSha256: '' } }), '0.7.13'), /no archive sha256/);
|
||||
assert.equal(releaseBaseline(fixture(t, '0.7.13', {}, { baseline: { release: '0.7.9' } }), '0.7.13').release, '0.7.9');
|
||||
});
|
||||
|
||||
test('changed plugin bytes need a greater version than the published release, and nothing is packed otherwise', t => {
|
||||
const root = fixture(t, '0.7.13', {}, { plugin: { core: '# user, changed\n' } });
|
||||
const dest = join(root, 'out');
|
||||
assert.throws(() => packRelease(root, dest), /openrig-core: packaged files changed since 0\.7\.10, but version 0\.1\.28 is not greater than 0\.1\.28/);
|
||||
assert.equal(existsSync(dest), false);
|
||||
});
|
||||
|
||||
test('the release manifest records the baseline it was checked against and each packed plugin version', t => {
|
||||
const root = fixture(t, '0.7.13', {}, { baseline: { release: '0.7.12', archiveSha256: 'b'.repeat(64) } });
|
||||
const manifest = packRelease(root, join(root, 'out'));
|
||||
assert.deepEqual(manifest.plugins, { baseline: { release: '0.7.12', archiveSha256: 'b'.repeat(64) }, packed: { openrig: '0.7.13', 'openrig-core': '0.1.28' } });
|
||||
assert.equal(baselineNotice(manifest), `Plugins checked against 0.7.12 (archive sha256 ${'b'.repeat(64)}); confirm that's the registry's latest @openrig/cli before publishing.`);
|
||||
});
|
||||
|
||||
test('the pack step never publishes', () => {
|
||||
const source = readFileSync(new URL('./pack-release.mjs', import.meta.url), 'utf8');
|
||||
assert.doesNotMatch(source, /\['publish'|"publish"|npm publish/);
|
||||
|
||||
@@ -166,7 +166,7 @@ person needs in their project or home folder and name that path.
|
||||
(never the logical ID or a guessed name) and send:
|
||||
`rig send <canonicalSessionName> 'This is the agent that installed OpenRig. The person will answer in your pane. Goal: <goal>. Project folder: <absolute path>. Branch: <branch>.'`
|
||||
If the person stays here, replace "The person will answer in your pane" with "The person stays with me; send me your questions and
|
||||
results to relay." Carry their constraints and any declined permission choices in either handoff.
|
||||
results to relay." Carry their constraints and any permission choice they made, chosen or declined, in either handoff.
|
||||
Or the person types the goal and folder in the operator's pane. If they give you a goal later, forward it the same
|
||||
way. The operator helps them pick a team that fits their logins, starts it (`rig up`) only on their yes to the plan it
|
||||
shows, and gives the goal to the team's lead. Before that yes, it says what launching into their folder changes for their own agent there; relay
|
||||
@@ -190,7 +190,7 @@ person needs in their project or home folder and name that path.
|
||||
(`--dry-run` first). If it isn't, or you can't tell, say so and offer their choices: sign in to Claude Code, or give the
|
||||
seat a model their account can run with `rig seat set-model`, then start it again with
|
||||
`rig seat launch <seat> --fresh --stop --reason "…"` and check the model it reports. Either way the operator starts a
|
||||
new conversation without your handoff, so send it again: the goal, folder, branch, constraints and declined choices.
|
||||
new conversation without your handoff, so send it again: the goal, folder, branch, constraints and permission choices.
|
||||
- **Installation is finished** when the operator is ready and the person is talking to it; a healthy daemon alone isn't
|
||||
that. If they'd rather talk later, keep that choice, leave them the exact connection step and say the handoff to the
|
||||
operator is still pending.
|
||||
@@ -207,6 +207,9 @@ person needs in their project or home folder and name that path.
|
||||
whole action, answer only when it's clearly inside what they said, and tell them you answered under that instruction,
|
||||
not that they said yes. Bring anything unclear or outside it back to them; a command's name or a path that looks like
|
||||
their project doesn't show what the action touches. Their instruction changes no permission setting.
|
||||
Don't send them to a bare `tmux attach` to answer: relay their choice this way, or point them to that seat in the
|
||||
team's terminals the operator opened. If they want fewer prompts, the operator offers once to allow the commands
|
||||
that keep asking, for their project only (`applying-a-permission-policy`); tell it what they chose.
|
||||
Another kind of menu you can answer with
|
||||
the key that screen shows, or ask the person. Capture it again before sending anything more.
|
||||
- **Replies:** in a plain shell, other agents can't type into this session. Read work sent to you with
|
||||
|
||||
Reference in new issue
Block a user