mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 17:31:56 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c00c6321e3 | ||
|
|
a962341d52 | ||
|
|
7da9a48c1f | ||
|
|
510719037d | ||
|
|
7ea2177018 | ||
|
|
6eb2cb97f1 | ||
|
|
d4d6a01df0 | ||
|
|
85ee9f4ec8 |
No files matched your search
+160
@@ -8,6 +8,166 @@ deprecations, and behavioral changes. Breaking changes are called out explicitly
|
||||
|
||||
---
|
||||
|
||||
## [0.7.6]
|
||||
|
||||
OpenRig 0.7.6 lets one terminal workspace follow the current owners of a slice's
|
||||
open work through handoffs and completions. It also keeps more seat settings
|
||||
through expansion and restore, reports retained dead panes honestly, and repairs
|
||||
queue and Slack delivery paths.
|
||||
|
||||
### Before you upgrade
|
||||
|
||||
- **Restart the daemon after installing** so the new code and database updates
|
||||
take effect:
|
||||
```bash
|
||||
npm install -g @openrig/cli@0.7.6
|
||||
rig daemon stop && rig daemon start
|
||||
```
|
||||
Two migrations add the selected account-home path to nodes and the runner
|
||||
declaration to sessions. Both have prefix 141; their distinct full filenames
|
||||
identify them. ([#2143](https://github.com/mvschwarz/openrig/pull/2143), [#2239](https://github.com/mvschwarz/openrig/pull/2239))
|
||||
- **The bundled openrig-core plugin advances from 0.1.22 to 0.1.23.** Claude's
|
||||
activity hook clears permission marks after a tool batch. Managed refresh
|
||||
preserves replaced files under `$OPENRIG_HOME/backups/managed-refresh/<id>/`;
|
||||
reapply local edits you still need from that copy. ([#2209](https://github.com/mvschwarz/openrig/pull/2209))
|
||||
|
||||
### Follow the work in a slice
|
||||
|
||||
- **Open `rig terminal open slice:<name-or-id> --follow --provider herdr`.**
|
||||
Membership comes from pending, in-progress and blocked queue rows tagged with
|
||||
that exact slice. Owners appear once with all their obligations listed;
|
||||
unavailable owners and tile overflow stay named.
|
||||
Handoffs retain the association unless their tags are replaced. New slice
|
||||
views default to four automatic tiles; `--max-tiles` changes that. Known empty
|
||||
slices stay following; incomplete reads retain the previous viewers. Pins,
|
||||
drafts, unknown input and focus retain departed viewers, and closing a viewer
|
||||
never stops its agent. Slice following targets the Linux Herdr 0.9.5 /
|
||||
protocol 22 path. Mission following is not included.
|
||||
([#2259](https://github.com/mvschwarz/openrig/pull/2259))
|
||||
- **Named Herdr sessions use their configured endpoint throughout viewer and
|
||||
follow operations.** The TUI's terminal action opens the agent, pod or rig
|
||||
currently in view. ([#2205](https://github.com/mvschwarz/openrig/pull/2205), [#2232](https://github.com/mvschwarz/openrig/pull/2232))
|
||||
- **Client-visible HTTP change:** `/api/terminal/viewer/ensure` now returns 400
|
||||
for `view_required` and `unknown_provider`, where it returned 200 with an
|
||||
error body. Clients should continue reading the body code. Missing slice
|
||||
identities return 404; ambiguous or unavailable identities return 409.
|
||||
([#2259](https://github.com/mvschwarz/openrig/pull/2259))
|
||||
|
||||
- **When a slice's folder name repeats across missions, tag its work with the
|
||||
slice's unique ID** (`rig queue create --slice <id>`) or add
|
||||
`--mission <mission>`. A row tagged only with the shared name cannot be placed,
|
||||
so the view reads incomplete until it is.
|
||||
([#2261](https://github.com/mvschwarz/openrig/pull/2261))
|
||||
|
||||
- **A join refused before a pane split can join on a later refresh.** Hold
|
||||
notices retain the original seat, phase and reason. A created viewer whose
|
||||
attach has not yet run can continue without replacing its pane.
|
||||
([#2271](https://github.com/mvschwarz/openrig/pull/2271))
|
||||
|
||||
### Seats, accounts and harnesses
|
||||
|
||||
- **Expansion keeps the member settings accepted by `rig up`,** including env,
|
||||
effort and startup settings. Restores retain the model saved with
|
||||
`rig seat set-model`. ([#2250](https://github.com/mvschwarz/openrig/pull/2250), [#2227](https://github.com/mvschwarz/openrig/pull/2227), [#2228](https://github.com/mvschwarz/openrig/pull/2228))
|
||||
- **Move a running Codex or Claude Code seat to an existing account home** with
|
||||
`rig provider switch --seat <seat> --to-account <name> --account-home <dir>`;
|
||||
preview with `--dry-run`. This starts a fresh conversation with a handover,
|
||||
retaining the seat's address and work. `--clear-account-home` returns to the
|
||||
configured defaults. ([#2143](https://github.com/mvschwarz/openrig/pull/2143))
|
||||
- **Claude subscription/API-key switching has a same-conversation path:**
|
||||
`rig provider switch --seat <seat> --to-account api --api-key-file <abs>`;
|
||||
return with `--to-account plan --token-file <abs>`. In-flight work is
|
||||
interrupted. The API-key path remains unqualified; verify the resulting
|
||||
account and conversation. A new Claude member can instead name a prepared
|
||||
subscription token in its spec with `claude_token: {label: name, file: /abs/token}`.
|
||||
([#2191](https://github.com/mvschwarz/openrig/pull/2191), [#2246](https://github.com/mvschwarz/openrig/pull/2246))
|
||||
- **Relaunch retains the selected seat and its observed executable.** Codex
|
||||
identity reads preserve OS argument boundaries, and in-place restart can
|
||||
recognize a verified Node launcher named `MainThread`. This does not establish
|
||||
that the reported #2200 restart refusal is fixed. ([#2196](https://github.com/mvschwarz/openrig/pull/2196), [#2212](https://github.com/mvschwarz/openrig/pull/2212), [#2247](https://github.com/mvschwarz/openrig/pull/2247))
|
||||
- **Managed seats inherit the daemon's SSH agent socket** for git over SSH;
|
||||
check the daemon's agent with `ssh-add -l`. ([#2222](https://github.com/mvschwarz/openrig/pull/2222))
|
||||
- **Community presets can report turns and accept a declared model.**
|
||||
`runner: true` with `launch_result: true` records runner activity per launch.
|
||||
`passes_model: true` passes the requested model as `OPENRIG_MODEL`; it does not
|
||||
observe the effective model. The Cursor and Hermes examples leave this off
|
||||
pending native qualification. These are opt-in preset fields, not a completed
|
||||
integration with every harness. ([#2239](https://github.com/mvschwarz/openrig/pull/2239), [#2243](https://github.com/mvschwarz/openrig/pull/2243))
|
||||
|
||||
- **Draft integration author contracts** describe harness, runner and platform
|
||||
duties, distinguishing implemented interfaces from proposals. Start at
|
||||
[the author-contract entry point](docs/reference/author-contracts.md).
|
||||
([#2272](https://github.com/mvschwarz/openrig/pull/2272))
|
||||
|
||||
### Delivery and Slack
|
||||
|
||||
- **Original queue wakes retry after a selection prompt clears.** Retries identify
|
||||
OpenRig as the sender, workflow entry keeps its caller, and prompt-blocked
|
||||
alerts remain routable when the original sender is on another host.
|
||||
([#2235](https://github.com/mvschwarz/openrig/pull/2235), [#2229](https://github.com/mvschwarz/openrig/pull/2229), [#2237](https://github.com/mvschwarz/openrig/pull/2237))
|
||||
- **Work sent to a terminal-only seat gets a recovery finding for a person.**
|
||||
Cross-host `rig queue create --verify` reads the destination's receipt;
|
||||
unreadable receipts remain unconfirmed without replaying the create.
|
||||
([#2192](https://github.com/mvschwarz/openrig/pull/2192), [#2193](https://github.com/mvschwarz/openrig/pull/2193))
|
||||
- **Slack button answers resume decisions parked on the person asked.** Replies
|
||||
Slack places outside a deleted thread are recorded at their actual location,
|
||||
so the next reply reaches the right conversation. ([#2245](https://github.com/mvschwarz/openrig/pull/2245), [#2240](https://github.com/mvschwarz/openrig/pull/2240))
|
||||
- **Use `rig slack setup --reactions off` to stop acknowledgement emoji waking
|
||||
agents.** The default remains `route`, and regenerating the app manifest
|
||||
preserves the choice. Alert selection filters eligible notifications without
|
||||
dropping older eligible asks behind an arbitrary row cap. ([#2241](https://github.com/mvschwarz/openrig/pull/2241), [#2183](https://github.com/mvschwarz/openrig/pull/2183))
|
||||
|
||||
### Status, inventory and help
|
||||
|
||||
- **A readiness deadline no longer ends a still-starting daemon.** The CLI
|
||||
returns a nonzero pending result naming the child PID, launch endpoint and
|
||||
log. It leaves the child running and retains the startup reservation, without
|
||||
declaring readiness or allowing a duplicate start. Follow that pending
|
||||
guidance before recovery. ([#2267](https://github.com/mvschwarz/openrig/pull/2267))
|
||||
- **A verified exited process in a retained pane reads `pane_exited`,** instead
|
||||
of running and ready. Inventory avoids discarded graph loads, scopes startup
|
||||
history to the requested rig's nodes, and reuses node identity for activity
|
||||
reads. These changes make no universal latency claim. ([#2248](https://github.com/mvschwarz/openrig/pull/2248), [#2225](https://github.com/mvschwarz/openrig/pull/2225),
|
||||
[#2226](https://github.com/mvschwarz/openrig/pull/2226), [#2236](https://github.com/mvschwarz/openrig/pull/2236))
|
||||
- **Completed mission labels reflect accepted proof**, without making proof
|
||||
readiness complete a mission. Snapshot ages use UTC correctly; emoji stay
|
||||
whole when TUI health, config and rig-table cells are shortened. ([#2249](https://github.com/mvschwarz/openrig/pull/2249),
|
||||
[#2251](https://github.com/mvschwarz/openrig/pull/2251), [#2252](https://github.com/mvschwarz/openrig/pull/2252), [#2253](https://github.com/mvschwarz/openrig/pull/2253))
|
||||
- **Smaller command fixes:** chatroom tmux rig names stay literal; a trailing
|
||||
slash in `OPENRIG_URL` still identifies the same daemon; a trailing slash in
|
||||
the missions root no longer hides a slice's mission. `rig host doctor` names
|
||||
the entry replies actually reach. ([#2218](https://github.com/mvschwarz/openrig/pull/2218), [#2220](https://github.com/mvschwarz/openrig/pull/2220), [#2221](https://github.com/mvschwarz/openrig/pull/2221), [#2214](https://github.com/mvschwarz/openrig/pull/2214))
|
||||
- **Help and contributor checks:** edge help explains deferred escalation
|
||||
targets; section addressing ignores HTML comments; the guide covers shared
|
||||
kernel-dashboard recovery. Install pins, help links and earlier release text
|
||||
are corrected. Scenario fixtures honor send verification, execute tmux
|
||||
directly, and reject unknown step verbs at schema load. ([#2204](https://github.com/mvschwarz/openrig/pull/2204), [#2211](https://github.com/mvschwarz/openrig/pull/2211),
|
||||
[#2213](https://github.com/mvschwarz/openrig/pull/2213), [#2238](https://github.com/mvschwarz/openrig/pull/2238), [#2244](https://github.com/mvschwarz/openrig/pull/2244), [#2254](https://github.com/mvschwarz/openrig/pull/2254), [#2256](https://github.com/mvschwarz/openrig/pull/2256), [#2257](https://github.com/mvschwarz/openrig/pull/2257), [#2215](https://github.com/mvschwarz/openrig/pull/2215), [#2216](https://github.com/mvschwarz/openrig/pull/2216),
|
||||
[#2217](https://github.com/mvschwarz/openrig/pull/2217))
|
||||
|
||||
### Known limits
|
||||
|
||||
- **The Codex hook-refresh refusal in [#2200](https://github.com/mvschwarz/openrig/issues/2200)
|
||||
remains unresolved.** If `rig seat restart <seat> --keep-conversation` refuses,
|
||||
nothing is stopped: leave the seat running and report the refusal with CLI
|
||||
and daemon versions. A refused restart has not refreshed the hooks.
|
||||
- **A viewer whose attach already ran and then exited back to a shell is not
|
||||
restarted automatically.** The whole slice view can hold until that tile is
|
||||
resolved. No tested workaround is offered for that case.
|
||||
- **Following protects uncertain input by retaining tiles.** A larger workspace
|
||||
or explicit `--max-tiles` can help with overflow. Protected Codex delivery can
|
||||
still retain messages it cannot confirm before paste; retained is not
|
||||
delivered. Disabling its typing guard removes protection and is not recovery.
|
||||
- **Internet-facing hosted members remain experimental, unsupported, off by
|
||||
default and at your own risk.** Slice following does not require them.
|
||||
|
||||
### Credits
|
||||
|
||||
Thanks to @seanmcn, @DeryFerd, @Aschenbath, @Vaishnavi220506, @bradvin,
|
||||
@jay23606, @rudycelekli and @Nikhi00718 for merged contributions, and to
|
||||
@akushonkamen, @jcmurrayii, @KM199 and @pdurlej for contributed changes, reports
|
||||
and reproductions carried into this release.
|
||||
|
||||
## [0.7.5]
|
||||
|
||||
OpenRig 0.7.5 adds `rig terminal open qitem:<id> --follow`: keep one terminal
|
||||
|
||||
@@ -26,9 +26,9 @@ use:
|
||||
|
||||
```sh
|
||||
# Claude Code
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.5/skills/rigs --skill rigs --agent claude-code
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.6/skills/rigs --skill rigs --agent claude-code
|
||||
# Codex
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.5/skills/rigs --skill rigs --agent codex
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.6/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.5` 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.6` 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.5/scripts/install.sh | sh -s -- --dry-run
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.5/scripts/install.sh | sh
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.6/scripts/install.sh | sh -s -- --dry-run
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.6/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
If a provider isn't signed in yet, the last step reports `FAILED [4/4]`, and `rig setup` lists the sign-in under "Some steps need attention". If the only remaining failures are provider sign-ins, the install steps finished; sign in to each selected provider as below and continue.
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# Integration author contracts — DRAFT for 0.7.6
|
||||
|
||||
OpenRig composes agents into a team. An integration author supplies the pieces that let a new harness participate, or let an existing harness run in a new environment. This guide divides that work into two contracts:
|
||||
|
||||
1. **[Harness and runner](harness-runner-contract.md):** launch the agent, report its conversation identity and activity, deliver input, handle permission requests and cancellation, and reopen the requested conversation.
|
||||
2. **[Execution and platform](execution-platform-contract.md):** choose placement, deliver files and credential references, distinguish owned from attached resources, preserve native state, return artifacts and reconcile shutdown.
|
||||
|
||||
These are draft author responsibilities. Some use implemented OpenRig interfaces; others describe a proposed integration whose code remains with its author. Including the guide in release documentation does not make every described capability available.
|
||||
|
||||
## Candidate and support status
|
||||
|
||||
This draft targets the **unsealed 0.7.6 candidate**. Its implemented-interface baseline is commit [`b6bd4633`](https://github.com/mvschwarz/openrig/tree/b6bd4633fbd94634a2b4bdef2574a6880f97be63). That base includes the runner declaration and `passes_model` changes. Later release repairs and version text are still being assembled; no final tag or package publication is asserted here. References below use the immutable base instead of a moving branch.
|
||||
|
||||
| Area | Status at this baseline | What the status means |
|
||||
|---|---|---|
|
||||
| Community launcher presets, RigSpec members and AgentSpec role/startup declarations | Implemented interfaces | They compose configuration. A launcher must implement its declared effects; successful parsing does not prove delivery or native behavior. |
|
||||
| `runner: true` with `launch_result: true` | Implemented candidate source | The declaration is retained at launch, and Core uses the runner identity/activity path. It does not supply a runner executable. |
|
||||
| `passes_model: true` | Implemented candidate source | Passes the declared model through `OPENRIG_MODEL`. It does not observe or verify the effective native model. Cursor and Hermes examples leave it off pending native qualification. |
|
||||
| The dsh/ACP input, cancellation and refusal-first permission recipe | Proposed author implementation | No end-to-end dsh integration is established. Native reopen capabilities and the tools-off case remain implementer questions. |
|
||||
| Exact native resume via an author launcher | Existing interface; integration-specific evidence | A requested conversation must reopen exactly or fail honestly. One provider/harness result does not establish portability to another. |
|
||||
| Environment preparation, projection, artifact export and owned-resource cleanup | Measured in a private wrapper; generic seam proposed | The platform chapter makes the responsibilities reusable. Core does not provide a generic platform driver or `DeploySpec` loader at this baseline. |
|
||||
| `HarnessSpec`, `DeploySpec`, generic lifecycle fields and wake-on-demand declarations | Proposed vocabulary or unsupported configuration | They are not new accepted YAML keys. Use existing composition and an explicit author implementation. |
|
||||
| Cross-host native resume, provider-wide cancellation or permission support | Unresolved unless qualified by an integration | Unsupported and untested are distinct from observed failure. Publish the actual boundary and version. |
|
||||
|
||||
## How the pieces compose
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
R["RigSpec: members, relationships, work arrangement"] --> A["AgentSpec: role, context and startup declarations"]
|
||||
R --> H["Built-in runtime or community preset"]
|
||||
H --> N["Harness / runner: native conversation and control"]
|
||||
H --> P["Author platform integration: placement and resources"]
|
||||
P --> N
|
||||
P --> F["Workspace, retained state and returned artifacts"]
|
||||
R --> S["Optional application services"]
|
||||
```
|
||||
|
||||
**RigSpec remains the composer.** AgentSpec describes reusable agent intent; the runtime or preset selects how the native agent starts. An integration can surround that launch with an environment-specific wrapper. A bundled application's database or Compose service is software the team operates; it is distinct from the environment in which an agent executes.
|
||||
|
||||
The terminal provider, native harness protocol and network connection also have different jobs. A terminal provider carries input and output; a runner understands native turn boundaries; a granted MCP or other connection exposes coordination operations. An HTTPS connection does not by itself provision compute, awaken a suspended application or establish native resume. Each integration states which of those effects it implements.
|
||||
|
||||
| Owner | Responsibility |
|
||||
|---|---|
|
||||
| OpenRig Core | Team and work identity, queue records, existing launcher contracts, activity interpretation and declared configuration. |
|
||||
| Harness/runner author | Native launch and protocol, exact native reference, prompt framing, truthful activity, permission/cancel behavior and native resume. |
|
||||
| Platform integration author | Placement binding, destination projection, resource ownership, state transfer, artifact delivery and resource reconciliation. |
|
||||
| Operator / credential owner | Selected environments, grants, provider credentials and spending authority. |
|
||||
|
||||
The queue records who owns work. It does not prove that an old provider process has stopped, that a new runtime consumed its history, or that guest bytes reached the consumer. Those observations belong in the launch, resume and export receipts alongside the work record. This division reuses the queue rather than creating another task system.
|
||||
|
||||
## Start from an existing example
|
||||
|
||||
For a terminal harness, begin with the pinned [community preset reference](https://github.com/mvschwarz/openrig/blob/b6bd4633fbd94634a2b4bdef2574a6880f97be63/docs/reference/community-harness-presets.md) and the [Cursor](https://github.com/mvschwarz/openrig/tree/b6bd4633fbd94634a2b4bdef2574a6880f97be63/docs/reference/community-harness-examples/cursor) or [Hermes](https://github.com/mvschwarz/openrig/tree/b6bd4633fbd94634a2b4bdef2574a6880f97be63/docs/reference/community-harness-examples/hermes) directory. Their presence is source guidance, not an effective-model or complete integration guarantee.
|
||||
|
||||
For a structured runner, the [runner chapter](harness-runner-contract.md#2-declare-it-what-the-preset-says-fixed-at-launch) supplies a minimal preset, launch record and activity message. The executable named by the preset must still be implemented and installed by its author.
|
||||
|
||||
For a contained worker, the [platform composition](execution-platform-contract.md#a-minimal-composition-using-existing-surfaces) pairs an ordinary coordinator with a worker preset. It uses existing member and preset fields. The wrapper must prepare or attach to the selected environment explicitly; declaring a runtime does not provision it. Its illustrated member list is a fragment, and its launcher is a skeleton, not a ready-to-run provider package.
|
||||
|
||||
A rig bundle can carry declarations, roles and supporting scripts. Do not confuse those files with hydrated image layers, installed binaries, registered presets, credentials or live sessions. State what the recipient must supply.
|
||||
|
||||
## Lifetime and recovery
|
||||
|
||||
**Persistent** and **Non-persistent** describe execution lifetime. A non-persistent invocation has an explicit completion condition tied to its assigned work. **Durable** describes interruption recovery and is independent of that lifetime. An agent can run briefly and still resume its retained conversation later.
|
||||
|
||||
Retaining a workspace and reconstruction material can restore useful competence without restoring the same native conversation. Label that as reconstruction. Native resume additionally requires the correct harness-qualified reference and supported native state. The platform chapter's measured example restored copied history into recreated containers on the same host; it does not establish new-host migration or RAM restoration.
|
||||
|
||||
## Questions contributors can help resolve
|
||||
|
||||
- At a pinned dsh version, which advertised method reopens a session, and can a useful first case run without permission requests?
|
||||
- Which additional harness would justify a shared protocol runner rather than a separate wrapper?
|
||||
- Which provider effects can use the existing projection, native-reference and lifecycle dispatch, and which need a new maintained interface?
|
||||
- What exact state and ownership must travel when compute is recreated or moved?
|
||||
|
||||
These questions remain part of the draft. An unanswered contributor thread does not prevent authors from using the implemented interfaces or reviewing the proposed responsibilities. Compatibility claims should name versions, observed behavior and unsupported operations; they should not imply a support guarantee from a manifest alone.
|
||||
@@ -4,6 +4,9 @@ A community harness is a coding harness an outside contributor maintains. You in
|
||||
rig member runs it by naming it as its `runtime`. OpenRig doesn't test these harnesses. It shows them as community
|
||||
harnesses, and it reads their activity only from what the screen shows.
|
||||
|
||||
For what an integration author owns end to end, including a runner's duties and running a harness somewhere else, see
|
||||
the draft [integration author contracts](author-contracts.md).
|
||||
|
||||
## What a community harness gets
|
||||
|
||||
What OpenRig does for a community-harness seat, capability by capability. **Partly** and **no** mean OpenRig shows that it doesn't know;
|
||||
@@ -182,6 +185,8 @@ one at a time and in order, without making the agent wait. It finds the address
|
||||
- **The screen still counts,** but only as evidence of work: on its own it never reads the seat idle.
|
||||
- **The generation attributes a post; it isn't authentication.** A post with an earlier occupant's generation doesn't
|
||||
count for the live seat. The hook token is one local token, so any process that holds it can post for any seat.
|
||||
- **A runner's full duties** (liveness, input, cancellation and permissions) are in the draft
|
||||
[harness and runner contract](harness-runner-contract.md).
|
||||
|
||||
## An example: Hermes
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
# Execution and platform integration contract — draft
|
||||
|
||||
This chapter describes the placement and resource responsibilities of an integration author. The harness/runner chapter owns native protocol, model input, activity, attention, permission exchange and turn cancellation. This chapter owns where that harness runs, which files and credentials it can reach, how native state survives an environment change, and how results and resources return to their owners.
|
||||
|
||||
**Status:** a design contract for authors, with an existing-compatible composition and one measured private implementation. The measured baseline used OpenRig 0.7.4, OpenShell 0.1.6 and Claude Code 2.1.310. Inclusion of this draft in 0.7.6 documentation does not implement a generic environment driver, a `DeploySpec` loader or a cross-host native-resume guarantee. Proposed operations below are not commands or accepted configuration keys. Capability support must be stated for the actual integration and version.
|
||||
|
||||
## Keep the identities and responsibilities separate
|
||||
|
||||
RigSpec composes members, their AgentSpecs/profiles, runtimes, working directories and relationships. AgentSpec supplies reusable role, startup and context declarations. A built-in runtime or registered community preset supplies a launch path. An environment integration supplies placement and provider-specific resource handling around that path. Existing declarations do not automatically provision a remote filesystem, credentials or compute.
|
||||
|
||||
| Identity or handle | Meaning and responsibility |
|
||||
|---|---|
|
||||
| Stable seat and work identity | The member's canonical address/node and owned work records. OpenRig retains this identity across executions; an environment IP or display name does not replace it. |
|
||||
| Native session identity | The harness's conversation token/UUID, qualified by harness and resume-reference type. The harness owns its format and resume semantics. |
|
||||
| Environment binding | The integration's durable record linking placement, immutable provider resource IDs, native-state location, projection and ownership. It is separate from the native session token. |
|
||||
| Execution generation | A particular launch/process instance, with observable process/start identity when available. A new process can resume an old conversation. |
|
||||
| Viewer/client | A terminal attachment or connection to an execution. Closing it need not stop either the native process or compute. |
|
||||
|
||||
An application service bundled with a rig is a separate responsibility. An artifact browser or database in `RigSpec.services` serves the team's application; its presence does not place an agent inside that service. Bundling service definitions also does not bundle image bytes, native binaries, authenticated homes or live runtime state.
|
||||
|
||||
## A minimal composition using existing surfaces
|
||||
|
||||
The following is a **fragment of one pod** (`pods[].members`), using existing RigSpec fields, not a complete standalone RigSpec:
|
||||
|
||||
```yaml
|
||||
pods:
|
||||
- id: dev
|
||||
label: Development
|
||||
members:
|
||||
- id: coordinator
|
||||
agent_ref: local:agents/coordinator
|
||||
profile: default
|
||||
runtime: claude-code
|
||||
- id: worker
|
||||
agent_ref: local:agents/worker
|
||||
profile: default
|
||||
runtime: example-contained-claude
|
||||
```
|
||||
|
||||
The worker runtime names an author-installed community preset. This preset shape uses existing fields exercised on the 0.7.4 baseline:
|
||||
|
||||
```yaml
|
||||
format: 1
|
||||
name: example-contained-claude
|
||||
maintainer: Integration author
|
||||
command: ./scripts/launch-contained-worker
|
||||
launch_result: true
|
||||
resume: true
|
||||
startup_assisted: true
|
||||
limits:
|
||||
- Requires an explicitly prepared environment binding and guest projection.
|
||||
- Native resume is qualified only for the documented retained-state recipe.
|
||||
- Viewer closure does not implement turn cancellation or resource deletion.
|
||||
```
|
||||
|
||||
This is an integration skeleton, not an installed or qualified preset. The author must supply the executable, resolve its path against the actual launch working directory, register the preset in the existing OpenRig home, and prepare the member's host working directory. The launcher implements the preset's declared launch-result and typed-resume behavior; the runner chapter defines those mechanics. `startup_assisted` requires a startup-submission check; it does not hold back submission or establish successful context delivery or ingestion. A member's model setting is not automatically honored by a custom launcher: its resolved native model/effort must be explicit in that launcher's supported configuration. With `passes_model: true`, Core passes the declared model through `OPENRIG_MODEL`; the launcher must map and apply it. The effective model remains unknown to Core.
|
||||
|
||||
For a contained worker, the launcher may resolve a private binding, create or attach to the selected resource, project explicit inputs, then connect to the native process inside it. In the measured recipe, OpenRig ran on the host, the plain coordinator used the built-in native runtime, and the worker used a private community launcher. The wrapper performed projection, provider-reference delivery, resume validation and artifact export explicitly. Those effects were not supplied by the preset declarations themselves. The integration must not assume host guidance/skills, hook paths or environment variables automatically reach a community runtime's guest.
|
||||
|
||||
A downstream author can implement this composition today around a qualified provider API and native harness. It remains an author-maintained integration until maintained platform support is separately implemented and documented. Validate against the target OpenRig schema and effective launch configuration; a parsed field or successful process start alone does not establish its effect.
|
||||
|
||||
## Placement and launch receipts
|
||||
|
||||
Before mutation, resolve the selected host/provider, workspace, image/native versions, reachability, credential references, resource request and ownership. Record existing resources and the selected resource scope. Host headroom includes supervisors, gateway and other team members; a workload memory request is not proof of their combined cap. Report requested, admitted and observed resources separately. Do not infer enforced CPU/memory or isolation policy from accepted flags.
|
||||
|
||||
Persist a launch intent before a create operation. Use an attempt/retry identity where the provider supports it; otherwise reconcile the original operation before another create. A public binding receipt carries only nonsecret values. If the harness's native resume reference works like a bearer credential (anyone holding it can reopen the conversation), keep it in secret-held receipt data, never in the public receipt. A useful public receipt identifies:
|
||||
|
||||
- canonical seat, harness version and native resume-reference type;
|
||||
- provider/host/workspace, immutable environment ID and execution generation;
|
||||
- image/policy versions, requested resources and observed readiness;
|
||||
- inner working directory, private native-state location and projection manifest;
|
||||
- a public-safe native reference (never a bearer-like resume token), creator or attached-consumer disposition, and exact owned resource handles;
|
||||
- lifecycle operations supported, completion observations, pending effects and errors.
|
||||
|
||||
A resource name or IP can be reused. Reconcile immutable ID, workspace and any available generation/fingerprint before acting. A lost response is **unconfirmed**, not success or absence. Record provider failures without exposing credentials. Readiness should identify what is ready: provider reachable, filesystem projected, native process started, team identity authenticated, or task completed.
|
||||
|
||||
## Project files and credential references deliberately
|
||||
|
||||
Stage only declared inputs: role/startup material, selected skills, current work context, schemas and required native settings. Preserve unmanaged files and report conflicts. Review every path-bearing setting for its destination: host-only hook scripts, interpreters, status collectors and loopback URLs may not exist or be reachable in the guest. Missing activity or permission transport must remain unsupported rather than being inferred from the base harness name.
|
||||
|
||||
Distinguish three observations: host staging, destination delivery and native ingestion. A destination manifest can record relative path, byte count and hash; a useful task using the current context revision/nonce can show consumption. Neither observation substitutes for the other. Current working input may change between tasks while an earlier artifact stays immutable.
|
||||
|
||||
Retained native history is a different data class from ordinary project context. Preserve its exact bytes through the harness's supported resume path; do not edit records or synthesize a conversation. Preserve a complete checkpoint prefix with byte length/hash if the harness may append after the checkpoint. Record which native-state files are required and which remain unavailable. Never substitute a fresh conversation seeded with a report while calling it native resume.
|
||||
|
||||
Keep secret values out of RigSpec, preset YAML, bindings, argv and public receipts. The provider/credential owner supplies scoped references or native-supported credential helpers and controls revocation. A successful login, provider registration or model-list request is not evidence of usable inference credit or an authorized spend envelope. Do not infer subscription portability from a provider name or copy an authenticated owner home into a guest.
|
||||
|
||||
Authenticate guest coordination through the actual granted member route. A shared source IP is not a seat identity. Record the grant-bound principal and scope separately from resource placement; distinct principals do not imply per-seat row ACLs or isolation between co-located secrets.
|
||||
|
||||
## Own only the effects assigned to the integration
|
||||
|
||||
Record **created** versus **attached** for each launch receipt. The creator may stop/delete the exact resource it created under the selected policy. An attached consumer normally closes only its own client and any separately identified process it owns; it must preserve the external environment, native session, workspace and shared services. Attachment must not silently transfer provider custody. An explicit owner-authorized transfer is a separate recorded action.
|
||||
|
||||
Describe these effects independently:
|
||||
|
||||
| Operation | Required distinction |
|
||||
|---|---|
|
||||
| Cancel a turn | Ask the runner to stop current work and observe its result. Unsupported cancellation must not be replaced silently by killing compute. |
|
||||
| Close a viewer | Release that client/connection; report whether native execution continues or is unknown. |
|
||||
| Stop native execution | End the identified native process and observe its exit. This is separate from task success. |
|
||||
| Stop compute | Release the documented provider allocation; identify retained storage and actual provider/process state. |
|
||||
| Delete an owned environment | Remove the exact creator-owned resource and reconcile final absence, including provider-managed dependents. |
|
||||
|
||||
Use graceful shutdown first where supported, then only explicitly owned, identified handles for stronger termination. Never equate pane disappearance with process or resource release. Persistent/non-persistent lifetime describes when execution should stop; durability describes recovery. An explicit non-persistent completion condition must name its invocation/work scope. An unrelated queue closure or an empty queue is not permission to terminate a member, and no new lifecycle YAML syntax is specified here.
|
||||
|
||||
## Resume exactly, or return an honest disposition
|
||||
|
||||
A resume request identifies the seat, harness-qualified native reference, prior binding, expected environment boundary and retained state. Validate those before launching the native harness. Reject mismatched reference types, missing/changed history, wrong immutable resources, unavailable required files and duplicate active executions. Never use “latest” as a replacement for an exact requested conversation or silently start fresh on failure.
|
||||
|
||||
Separate the outcomes: same native conversation resumed; new conversation rebuilt from artifacts; fresh launch; unsupported; refused; or unconfirmed. Environment continuity has its own outcome: same retained environment, recreated compute with copied state, different host, or unknown. A matching UUID alone does not show that native history was consumed.
|
||||
|
||||
If a provider stops exposing file transfer after canonical-process exit, capture artifacts/history and publish any retained resume descriptor **while transfer is still available**. Verify destination bytes before shutdown. After completion, use host/provider observations instead of assuming guest exec still works. For recreated compute, explicitly transfer the retained checkpoint and required artifact into the successor environment and validate them before publishing its resume descriptor. A new environment ID is expected and must be recorded as a successor, not disguised as the old environment.
|
||||
|
||||
The measured private recipe demonstrated this latter boundary: the original sandbox was deleted; copied native history and task bytes were restored into new containers on the same physical host; the native harness launched with `--resume` and the retained UUID. Its first new reply recalled a private conversation-only marker before tools, with no marker in the new input and no native-history tool read. It then verified artifact 1, used changed task context, produced artifact 2 and returned it for independent coordinator verification. This establishes one bounded native-return/work result. It does **not** establish new-host migration, RAM restoration, same-container stop/start, cgroup enforcement, general portability or shipped platform support.
|
||||
|
||||
## Export results before teardown
|
||||
|
||||
A guest path/hash note means bytes exist in a guest; it is not delivery to the consumer. Identify the actor holding the provider transfer handle. In the measured recipe that actor was the host wrapper, not the guest worker.
|
||||
|
||||
The worker publishes its report once and records the task/path/hash on its existing child. The wrapper validates the binding, task, allowed path, file type, size and expected hash; transfers to a fresh host staging path; checks the returned bytes/schema/context; and publishes without overwriting an existing result. It records an actual consumer-accessible destination, hash, byte count and actor through the authenticated work route. The worker closes its child after the matching export receipt; the coordinator independently reads/verifies those bytes before closing its parent. An ambiguous transfer is reconciled by existing bytes/hash, not a new report or duplicate task.
|
||||
|
||||
Complete exports before resource deletion. If output cannot be returned, preserve the exact resource/owner and failure state. Cleanup then reconciles the exact owned workload, supervisor, volumes and provider records to the intended stopped/absent state. Keep reused engines, caches, authenticated environments and attached resources. Revoke scoped credentials/grants through their owners and record remaining state. “Delete requested” and “absence verified” are different receipts.
|
||||
|
||||
## Proposed generic platform seam — not implemented
|
||||
|
||||
The existing composition need not acquire another YAML format to describe these responsibilities. If a maintained platform seam is selected, the following **design pseudocode** names the smallest useful operations; it is not a loader or API available in this release:
|
||||
|
||||
```text
|
||||
prepare(placement, ownership, resourceRequest, credentialRefs) -> bindingReceipt
|
||||
project(binding, explicitFiles, nativeCheckpoint?) -> deliveryReceipt
|
||||
launch(binding, harnessLaunch, exactResumeRequest?) -> executionReceipt
|
||||
observe(binding, execution) -> observedState | unsupported | unconfirmed
|
||||
export(binding, artifactRequest) -> publicationReceipt
|
||||
shutdown(binding, execution, selectedEffect) -> effectReceipt
|
||||
```
|
||||
|
||||
The provider implementation interprets its image/network/compute configuration. The runner interprets native protocol and control. Core would retain typed provenance and route the selected effects; it must not reinterpret arbitrary provider keys, leak secrets or collapse ownership into a stop boolean. Each mutating operation needs an attempt identity and reconciliation route; partial receipts remain useful after failure. Capability reporting must expose unsupported projection, cancellation, resume or cleanup rather than letting method names imply support.
|
||||
|
||||
The most useful author evidence follows the real work path: exact resolved placement and native launch; delivered current context; authenticated work ownership; returned artifact bytes; exact resume boundary and consumed retained state; and final resource disposition. A parser pass, launcher success, viewer reconnect or same display name leaves the corresponding effect unconfirmed. These distinctions let an author implement and diagnose an integration; they do not create a new release gate or require another runtime campaign.
|
||||
@@ -191,11 +191,11 @@ You can also read [`skills/rigs/SKILL.md`](../../skills/rigs/SKILL.md) directly.
|
||||
script will do and changes nothing:
|
||||
|
||||
```sh
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.5/scripts/install.sh | sh -s -- --dry-run
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.6/scripts/install.sh | sh -s -- --dry-run
|
||||
```
|
||||
|
||||
The same command without `--dry-run`,
|
||||
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.5/scripts/install.sh | sh`, installs the
|
||||
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.6/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
|
||||
@@ -859,9 +859,10 @@ the lifecycle API without booting a new kernel, then stop the remaining rigs.
|
||||
kernel's `operator.human` terminal seat. Restoring the operator, advisor and
|
||||
queue worker does not also restore an unselected terminal. Read
|
||||
`rig ps --nodes --rig kernel --json` and use the terminal's registered
|
||||
`canonicalSessionName`; the built-in layout uses `operator-human@kernel` in the
|
||||
examples below. Check the actual pane with `rig capture operator-human@kernel`:
|
||||
a persisted running row alone does not prove that its tmux session is alive.
|
||||
`canonicalSessionName` for the `operator.human` row. Replace `<dashboard-session>`
|
||||
in the commands below with that name. Check the actual pane with
|
||||
`rig capture "<dashboard-session>"`: a persisted running row alone does not prove
|
||||
that its tmux session is alive.
|
||||
A daemon or transport error is not proof that the terminal is absent.
|
||||
|
||||
- If only the viewing window closed and the TUI is still running, reopen it
|
||||
@@ -871,14 +872,14 @@ A daemon or transport error is not proof that the terminal is absent.
|
||||
- If the terminal session itself is absent, start only that existing seat:
|
||||
|
||||
```sh
|
||||
rig seat launch operator-human@kernel --fresh --reason "recover missing shared kernel dashboard"
|
||||
rig seat launch "<dashboard-session>" --fresh --reason "recover missing shared kernel dashboard"
|
||||
```
|
||||
|
||||
The terminal has no agent conversation to resume. Its fresh-start action
|
||||
runs `rig tui` after the shell is ready; sibling agent sessions stay
|
||||
unchanged. This command intentionally omits `--stop`, so it refuses to
|
||||
replace a live occupant. Check the result before repeating it, then run
|
||||
`rig capture operator-human@kernel` to confirm the TUI and
|
||||
`rig capture "<dashboard-session>"` to confirm the TUI and
|
||||
`rig ps --nodes --rig kernel --full` to check the seats. Once the dashboard
|
||||
is running, open its view with `rig terminal open saved:kernel --window`.
|
||||
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
# Harness and runner contract (DRAFT for 0.7.6)
|
||||
|
||||
> **Status.** This is a draft for contributors.
|
||||
> - **In the 0.7.6 candidate, not yet released:** the `runner` field and OpenRig's side of declaration, identity and activity
|
||||
> (sections 2-4).
|
||||
> - **Proposed runner-author behavior:** the input, cancellation and permission recipe in sections 5-7, and the
|
||||
> runner-side shutdown obligation in section 9. A dsh port implementing this recipe is not qualified.
|
||||
> - **Existing interfaces with author obligations:** launch-result identity, exact resume, first input and model
|
||||
> handoff. These interfaces do not establish that a particular runner implements them correctly.
|
||||
> - **Unchanged:** plain community presets and the built-in runtimes (Claude Code, Codex, Pi, OMP, terminal).
|
||||
>
|
||||
> No harness has been run end to end under this contract yet. The first candidate is dsh over ACP.
|
||||
>
|
||||
> Candidate scope and pinned references are in the [joined author guide](author-contracts.md). The
|
||||
> [community presets reference](https://github.com/mvschwarz/openrig/blob/b6bd4633fbd94634a2b4bdef2574a6880f97be63/docs/reference/community-harness-presets.md)
|
||||
> describes the implemented fields at that base; this chapter also labels proposed author behavior.
|
||||
|
||||
This chapter covers **which agent runs in a seat and how OpenRig talks to it**. *Where* that agent runs (a host, a
|
||||
sandbox, another machine) is the [execution platform chapter](execution-platform-contract.md). The two are separate on purpose:
|
||||
the same harness can run in many places, and one place can host many harnesses.
|
||||
|
||||
## 1. Plain preset or runner preset?
|
||||
|
||||
| | Plain community preset | Runner preset (`runner: true`) |
|
||||
|---|---|---|
|
||||
| **What runs in the pane** | The harness's own terminal app | A small program you maintain, *the runner*, which hosts the agent (for example an ACP agent over stdio) |
|
||||
| **How OpenRig reads activity** | From the screen: a changing screen reads working; a quiet one reads **unknown**, never idle | From the runner's reports: working, idle, needs input |
|
||||
| **How input arrives** | Typed into the app's prompt | Typed into the runner, which turns each submitted message into one agent prompt |
|
||||
| **Choose it when** | The harness has a usable terminal UI (Cursor, Hermes, OpenClaw examples) | The harness speaks a protocol rather than a screen (ACP, JSON-RPC, stream-JSON), or you can report turn boundaries reliably |
|
||||
|
||||
**If your harness works well as a terminal app, a plain preset is enough. Use a runner only when you can report state
|
||||
honestly.** A runner that guesses is worse than a quiet screen read as unknown.
|
||||
|
||||
**Vocabulary.** "Preset", "runner preset", `launch_result`, `resume`, `first_input` and `passes_model` are existing
|
||||
terms at the candidate base. Broader names you may see in design discussion, such as *HarnessSpec*, are **proposals**, not file
|
||||
formats or loaders that exist.
|
||||
|
||||
## 2. Declare it: what the preset says, fixed at launch
|
||||
|
||||
```yaml
|
||||
# $OPENRIG_HOME/community-harnesses/dsh.yaml
|
||||
format: 1
|
||||
name: dsh
|
||||
command: dsh-seat # your runner; it starts the agent itself
|
||||
maintainer: <you>
|
||||
runner: true # in the 0.7.6 candidate
|
||||
launch_result: true # required with runner
|
||||
resume: true # only after you've checked a real reopen and a refused missing one
|
||||
limits:
|
||||
- permission requests are refused (first version)
|
||||
- a prompt whose whole text is /abort can't be sent
|
||||
- fork unsupported
|
||||
```
|
||||
|
||||
- **`runner: true` requires `launch_result: true`,** and can't be combined with `names_conversation`.
|
||||
- **The declaration is fixed at launch.** Each launch, a restore's included, records what the preset declares at that
|
||||
moment. Editing the preset changes the next launch, not a running seat.
|
||||
- **A preset without `runner` behaves exactly as today,** and so does a session recorded before the field existed.
|
||||
- **The other fields keep their existing meanings:**
|
||||
- `first_input: true` passes the seat's first startup turn in a file named by `OPENRIG_FIRST_INPUT`;
|
||||
- `passes_model: true` passes the seat's declared model as `OPENRIG_MODEL`.
|
||||
|
||||
## 3. Identity: report the conversation once, in the launch record
|
||||
|
||||
- **When:** after the agent has opened or reopened its conversation (for ACP: after `session/new`, `session/load` or
|
||||
`session/resume`).
|
||||
- **How:** write the attempt's launch record to the path in `OPENRIG_LAUNCH_RESULT`, with the attempt from
|
||||
`OPENRIG_LAUNCH_ATTEMPT`. The record is either the conversation:
|
||||
|
||||
```json
|
||||
{"format": 1, "attempt": "<OPENRIG_LAUNCH_ATTEMPT>", "harness": "dsh", "nativeRef": "<conversation id>", "producer": "dsh-seat 0.3.0; dsh 0.2.1-rc.2; ACP 1"}
|
||||
```
|
||||
|
||||
or the agent's own word that it doesn't exist:
|
||||
|
||||
```json
|
||||
{"format": 1, "attempt": "<OPENRIG_LAUNCH_ATTEMPT>", "harness": "dsh", "error": "missing", "producer": "dsh-seat 0.3.0"}
|
||||
```
|
||||
|
||||
- **`producer` is free text** for your versions. There are no structured version or capability fields.
|
||||
- **The launch record is the only identity channel.** Don't report identity on the activity endpoint.
|
||||
- **No record means attention.** For a runner preset:
|
||||
- **a new launch** with no usable record within OpenRig's wait leaves the seat needing attention, not ready. So does a
|
||||
new launch where OpenRig can't create the record's folder: the runner still starts, without `OPENRIG_LAUNCH_RESULT`;
|
||||
- **on a resume,** a folder OpenRig can't create means nothing starts, because the launcher can't be told what to
|
||||
reopen. When the folder exists but no usable record arrives, the resume command has already been sent, and the
|
||||
resume reads unconfirmed (section 8).
|
||||
- **OpenRig shows this identity as launcher-reported.** It doesn't observe the conversation itself.
|
||||
|
||||
## 4. Activity: report turn boundaries
|
||||
|
||||
Post each event to `POST <base>/api/activity/hooks` with `Authorization: Bearer <token>`.
|
||||
- **Finding the base and token:** take them from `OPENRIG_URL` (or `OPENRIG_PORT`, with `OPENRIG_HOST` defaulting to
|
||||
`127.0.0.1`) and `OPENRIG_ACTIVITY_HOOK_TOKEN`, or else from `$OPENRIG_HOME/activity-endpoint.json`.
|
||||
- **Posting:** post one at a time, in order, with a short timeout. Never make the agent wait on a post.
|
||||
|
||||
```json
|
||||
{"sessionName": "dev-builder@my-rig", "nodeId": "<node id>", "runtime": "dsh",
|
||||
"generation": "<OPENRIG_OCCUPANT_GENERATION>", "sequence": 42, "occurredAt": "2026-10-29T12:00:00Z",
|
||||
"hookEvent": "UserPromptSubmit", "subtype": "accepted", "turnId": "7"}
|
||||
```
|
||||
|
||||
| When | `hookEvent` / `subtype` | The seat reads |
|
||||
|---|---|---|
|
||||
| You gave the agent the prompt for a submitted message | `UserPromptSubmit` / `accepted`, `turnId` = your prompt number | working |
|
||||
| The agent is working (message chunks, tool calls) | `active` / `agent_message`; `PreToolUse` / `<tool kind>` | working |
|
||||
| You refused a permission request (section 7) | `Notification` / `permission_prompt` | needs input |
|
||||
| The turn ended | `Stop` / its stop reason (`end_turn`, `cancelled`, `max_tokens`, `refusal`) | idle. While you hold attention (section 7), don't post it: OpenRig reads any non-exit `Stop` as idle and clears needs input |
|
||||
| Optional idle heartbeat | `Stop` / `idle_heartbeat`, with `livenessSeconds` | idle while beats keep coming |
|
||||
| The agent process ended | `Stop` / `<preset name>_exited` | unknown, **never idle** |
|
||||
| Your runner or the agent hit an error | `Notification` / `runtime_error` | recorded, but not used as activity: the prior activity evidence keeps its usual lifetime. It doesn't itself request or clear attention |
|
||||
|
||||
- **Liveness:** state `livenessSeconds` (a whole number of seconds from 1 to 3600; any other value is ignored) on every
|
||||
idle `Stop`, the turn's end as well as each beat. OpenRig stops reading the seat idle after 2 × `livenessSeconds` without
|
||||
a newer idle report. Without it, an idle report holds until your next post, even if your runner has died. Never send a
|
||||
non-exit `Stop` or a beat while holding permission attention.
|
||||
- **Exit:** post `Stop` / `<preset name>_exited` only when you observe the agent's process exit.
|
||||
|
||||
- **The screen still counts,** but only as evidence of work. On its own it never reads a runner seat idle.
|
||||
- **"Accepted" means your runner submitted its Nth prompt.** It isn't the agent's own acknowledgement, and it isn't
|
||||
tied to a particular `rig send`.
|
||||
- **The generation attributes a post; it isn't authentication.**
|
||||
- A post carrying an earlier occupant's generation never counts for the live seat.
|
||||
- The hook token is one local token, so any process holding it can post for any seat.
|
||||
|
||||
## 5. Input: one submitted message is one prompt
|
||||
|
||||
Sections 5-7 specify the proposed first dsh/ACP runner recipe. They do not change permission or input handling in
|
||||
existing built-in runtimes or require every possible runner protocol to use ACP.
|
||||
|
||||
- **Set up the pane:** put your TTY in raw mode and turn on bracketed paste (`\e[?2004h`).
|
||||
- **What arrives:** `rig send` and queue reminders paste the whole message, then press Enter. Everything up to that Enter
|
||||
is **one block**, newlines included. Send it to the agent as **one** prompt (for ACP, one `session/prompt`).
|
||||
- **Never split a block,** and never act on lines inside it.
|
||||
- **Control input:** the only control is a block that is exactly `/abort`, or Ctrl-C typed outside a paste.
|
||||
- Ordinary sends and reminders arrive inside a From/To envelope, so they never equal `/abort`, whatever lines they
|
||||
contain.
|
||||
- **A message that arrives mid-turn** waits for the next turn. Report it as queued, never as steering the current turn.
|
||||
- **Limits to keep and to list under `limits`:**
|
||||
- a prompt whose whole text is `/abort` can't be sent as a prompt;
|
||||
- a Ctrl-C byte inside a paste also aborts;
|
||||
- without a TTY, input is read line by line;
|
||||
- this isn't a binary-safe channel.
|
||||
|
||||
## 6. Cancel: requested isn't the same as done
|
||||
|
||||
- **On `/abort` or Ctrl-C:** send the agent's cancel (for ACP, the `session/cancel` notification, which has no reply),
|
||||
and record when you sent it.
|
||||
- **Report "cancelled" only when the agent says so:** for ACP, when `session/prompt` returns `stopReason: "cancelled"`.
|
||||
Then post `Stop` / `cancelled`.
|
||||
- **Whether the provider stopped work or spend is unknown.** Don't claim it.
|
||||
|
||||
## 7. Permissions: refuse every request (first version)
|
||||
|
||||
- **Refuse at once:** answer every permission request immediately with a refusal (for ACP,
|
||||
`{"outcome": {"outcome": "cancelled"}}`).
|
||||
- **Make it visible:** show what was asked in the pane, and post `Notification` / `permission_prompt`.
|
||||
- **Hold that attention** as your last post through the end of the turn, so the seat reads needs input. Don't post the
|
||||
turn's `Stop` or idle beats while holding it.
|
||||
- **It clears** when your next prompt is accepted, or when an operator clears it
|
||||
(`rig seat clear-attention <session> --reason "…"`).
|
||||
- **Never leave a request pending.**
|
||||
- **Not in the first version:**
|
||||
- answering from the seat's permission setting;
|
||||
- answers typed into the pane;
|
||||
- routing an approval to another seat.
|
||||
- So a job that needs an edit or a command approved can't finish under this version. Agents that can run without
|
||||
asking are the best first case.
|
||||
- **Unknown requests from the agent:** answer with JSON-RPC `-32601` and carry on.
|
||||
|
||||
## 8. Resume: exactly the same conversation, or say honestly that you couldn't
|
||||
|
||||
- On a resume, OpenRig passes the conversation to reopen in `OPENRIG_RESUME_REF`. Use the method your agent
|
||||
advertises: ACP's `session/load` (capability `loadSession`), or your agent's own reopen method, named with its
|
||||
capability and pinned version in your `limits`.
|
||||
|
||||
| What happened | Write in the launch record | What OpenRig does |
|
||||
|---|---|---|
|
||||
| The same conversation reopened | the same `nativeRef` | resumed |
|
||||
| The agent says it doesn't exist | `error: "missing"` | reports it missing; nothing new starts |
|
||||
| Anything else: a timeout, an error, or the working folder no longer matches the conversation's | no record, or an error other than `missing` | **unconfirmed**: the seat keeps its conversation and needs attention |
|
||||
| A different conversation opened | the other id | a mismatch; OpenRig doesn't switch to it |
|
||||
| OpenRig couldn't create the record's folder | (nothing to write) | nothing starts |
|
||||
|
||||
- **Never start a new conversation on a resume.**
|
||||
- A conversation saved under another harness's runtime is never handed to your launcher.
|
||||
|
||||
## 9. Context, model and shutdown: whose job is what
|
||||
|
||||
| Responsibility | OpenRig does | Your preset or runner does |
|
||||
|---|---|---|
|
||||
| **First startup turn** | Writes it to a private file and passes `OPENRIG_FIRST_INPUT` (with `first_input: true`) | Gives those exact bytes to the agent through its own first-prompt route |
|
||||
| **Guidance, skills, startup files** | Doesn't place them for community harnesses; a launch warns that they weren't delivered | Puts whatever the agent needs where *your* harness reads it, or says in `limits` that it isn't supported |
|
||||
| **Model** | Passes the seat's declared model as `OPENRIG_MODEL` (with `passes_model: true`); never substitutes one | Maps it to the harness's own setting. OpenRig shows the effective model as unknown |
|
||||
| **Placing the agent somewhere else** (a sandbox, another machine) | See the [execution platform chapter](execution-platform-contract.md) | Keeps the runner independent of where it runs |
|
||||
| **Shutdown** | `rig seat stop` and `rig down` end the seat's tmux session at once: no graceful stop handshake reaches the runner, its pane just closes. OpenRig ends nothing outside that session, and does no external provider cleanup | **(Proposed)** When the runner's pane closes (a hangup) or it's asked to stop, it attempts where possible to cancel any running turn and end the agent process it started, and lists in `limits` anything that may keep running. Cleanup of external compute needs the platform owner to reconcile it |
|
||||
|
||||
## 10. What counts as evidence
|
||||
|
||||
- **Keep three things separate:**
|
||||
- **`rig send --verify`** checks the pane before and after a send. It's pane evidence, not proof the agent received
|
||||
or finished anything. Mirror each accepted prompt in your pane (for example `you ▸ …`); OpenRig doesn't yet promise
|
||||
that `--verify` recognises it.
|
||||
- **A turn ending isn't the work being done.** Task completion is whatever the work's own record says: a queue task
|
||||
marked done, or an artifact written.
|
||||
- **Your own tests** should run against a fake agent that covers each section above.
|
||||
- **The useful real check** for a maintainer:
|
||||
- a seat starts and reads ready only after the handshake;
|
||||
- one multi-line message becomes one prompt;
|
||||
- the seat reads working, then idle;
|
||||
- a refused permission reads needs input;
|
||||
- `/abort` ends a turn as cancelled;
|
||||
- a resume reopens the same conversation, and a missing one is refused, never replaced.
|
||||
|
||||
That's the same promotion test the presets reference describes, applied to these declarations. Passing it is
|
||||
compatibility evidence for the versions you name, not certification.
|
||||
|
||||
## Contributor checklist
|
||||
|
||||
- [ ] The preset sets `runner: true` and `launch_result: true`, and not `names_conversation`.
|
||||
- [ ] After the agent opens or reopens its conversation, the runner writes the launch record, with versions in `producer`.
|
||||
- [ ] Activity posts carry `sessionName`, `nodeId`, `runtime`, `generation`, `sequence`, `occurredAt`, `hookEvent` and
|
||||
`subtype`. They're sent in order and never block the agent.
|
||||
- [ ] One submitted message becomes exactly one prompt. Only a bare `/abort` block, or Ctrl-C outside a paste, is control.
|
||||
- [ ] Cancel records "requested" when sent, and "cancelled" only on the agent's own stop reason.
|
||||
- [ ] Every permission request is refused at once, shown, and held as needs input until the next accepted prompt.
|
||||
- [ ] An agent exit posts `Stop` / `<preset name>_exited`, never a plain idle.
|
||||
- [ ] A resume reopens the exact conversation. It reports `missing` only on the agent's word, and otherwise leaves it
|
||||
unconfirmed.
|
||||
- [ ] Unknown agent requests get `-32601`.
|
||||
- [ ] `limits` names the input limits, the permission limit, what keeps running after a stop, and anything not supported.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **For the dsh maintainer:** which method and capability does dsh advertise for reopening a session, at a pinned
|
||||
version: `session/load` with `loadSession`, or `session/resume`?
|
||||
2. **For the dsh maintainer:** can dsh's ACP profile run with tools off? A first journey that needs no permission
|
||||
answers is the simplest to check.
|
||||
3. **For OpenRig:** should a later version route permission requests to a chosen seat, as `rig acp run` already does
|
||||
with queue tasks, instead of refusing them?
|
||||
4. **For OpenRig:** once a second ACP harness exists, should one shared ACP runner live in OpenRig rather than one per
|
||||
harness?
|
||||
@@ -17,7 +17,7 @@ npx skills add https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/ri
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/rigs --skill rigs --agent codex
|
||||
```
|
||||
|
||||
For a new install, use the latest release's tag, for example `v0.7.5`. If OpenRig is already installed, use the tag
|
||||
For a new install, use the latest release's tag, for example `v0.7.6`. If OpenRig is already installed, use the tag
|
||||
that matches `rig --version`, or upgrade first. If the command fails with `npx: command not found`, Node.js isn't
|
||||
installed or isn't on your PATH yet (see [What you need](#what-you-need)). Then type `/rigs` in Claude Code, or `$rigs` in Codex, and say what
|
||||
you want built.
|
||||
|
||||
@@ -320,8 +320,14 @@ name or unique frontmatter `id`. Tags must name it exactly: `rig queue create
|
||||
--slice <name-or-id> ...` associates a row. Handoff inherits those tags unless
|
||||
replaced; merely linking an untagged child does not associate it. A mission-only
|
||||
tag does not put a row in every slice. No project is inferred from a tag or the
|
||||
caller's working directory. Duplicate names or aliases require disambiguation
|
||||
in the configured index; no first-match scope is selected.
|
||||
caller's working directory. A bare name or ID claimed by multiple slices is
|
||||
`scope_ambiguous`; no first-match scope is selected. Selecting a unique key can
|
||||
still follow a slice whose other alias is reused: a row carrying that colliding
|
||||
alias must have exactly one `mission:` tag naming exactly one indexed claimant.
|
||||
Rows placed in another mission are excluded. Missing, contradictory or otherwise
|
||||
unplaceable mission/alias tags make the snapshot incomplete, retaining previous
|
||||
viewers. A unique alias on its own retains exact matching. Flat legacy slices
|
||||
without an indexed parent mission cannot disambiguate a colliding alias.
|
||||
|
||||
Slice keys are case-sensitive and can include Unicode, plus signs and internal
|
||||
spaces. They cannot be empty or contain slash, backslash, control characters,
|
||||
@@ -380,7 +386,8 @@ Error bodies remain authoritative. `scope_unsupported` is HTTP 400,
|
||||
`scope_not_found` is 404, and `scope_ambiguous`/`scope_unavailable` are 409.
|
||||
An incomplete queue snapshot is a successful preview with `task.complete:false`;
|
||||
ensure returns `viewer_unconfirmed` without a provider effect. Unsupported
|
||||
ensure view kinds retain `view_required` (400). Unsupported follow view kinds
|
||||
ensure view kinds now return `view_required` (400), where they previously returned
|
||||
200 with an error body. Unsupported follow view kinds
|
||||
retain `follow_view_required` in a 200 `ok:false` body, as before. A changed
|
||||
preview remains `preview_changed` (409). During automatic refresh a missing or
|
||||
ambiguous slice retains the existing tiles and names the source problem in the
|
||||
|
||||
+104
-178
@@ -1,192 +1,118 @@
|
||||
# What changed in OpenRig 0.7.1
|
||||
# What changed in OpenRig 0.7.6
|
||||
|
||||
This note is for an agent on an install that was just upgraded to 0.7.1. It lists only what behaves differently from
|
||||
0.7.0 and what to do about it. For everything OpenRig can do, read the capability map:
|
||||
`rig context get onboarding-width/public-what-you-can-do.md`. Each release replaces this file, so
|
||||
`rig context get reference/whats-new.md` describes the installed version once the upgraded daemon is running. Without
|
||||
a running daemon, read `$OPENRIG_HOME/reference/whats-new.md`. The daemon refreshes that copy when it starts, so right
|
||||
after an upgrade it can still hold the previous version's note.
|
||||
This note is for an agent on an install that was just upgraded to 0.7.6. It lists
|
||||
changes since 0.7.5 and what to do about them. For the full capability map, read
|
||||
`rig context get onboarding-width/public-what-you-can-do.md`. After the upgraded
|
||||
daemon starts, `rig context get reference/whats-new.md` describes this version.
|
||||
Without a running daemon, read the packaged `daemon/docs/reference/whats-new.md`;
|
||||
`$OPENRIG_HOME/reference/whats-new.md` can still be the previous copy until startup.
|
||||
|
||||
## After the upgrade
|
||||
|
||||
- **Restart the daemon** (`rig daemon stop`, then `rig daemon start`). Eight migrations run on the first start (124, 125,
|
||||
127, 128, 129, 130, 131 and 135). Nine settings are added: `policies.claude_compaction.preparation_root` (unset),
|
||||
`skills.router_line.enabled` (off), `decisions.enabled` (false), `decisions.consumers`, `decisions.template_dir`,
|
||||
`retention.stream_days` (30), `retention.stream_max_items` (20000), `provider.selected` (empty) and
|
||||
`daemon.db_conflict` (`warn`). None is removed.
|
||||
- **Stream retention is on.** Stream items emitted after the upgrade are deleted after 30 days, or beyond the newest
|
||||
20,000, unless a record names them (a classification, duplicate link, attempt or disposition). History from before the
|
||||
upgrade is kept. Raise `retention.stream_days` or `retention.stream_max_items` to keep more.
|
||||
- **The bundled openrig-core plugin goes from 0.1.17 to 0.1.18.** Startup replaces the installed plugin's changed files:
|
||||
its Claude hook definitions, three hook scripts and the pre-compaction hook (so hooks start on a seat whose PATH has
|
||||
no Node), and the `openrig-user`, `messaging-the-human` and `forming-an-openrig-mental-model` skills. As in
|
||||
0.7.0, it first copies each file it replaces under `$OPENRIG_HOME/backups/managed-refresh/<id>/`, edited or not, and
|
||||
logs `[plugin-vendor] preserved previous contents of <file> at <copy> before managed refresh`. A copy doesn't mean
|
||||
anyone edited the file; if someone did, reapply the change from the copy.
|
||||
- **Claude seats need Claude Code 2.1.280 or newer to see their team skills,** which now arrive as a per-seat plugin
|
||||
through `CLAUDE_CODE_PLUGIN_DIRS` (below). OpenRig doesn't check the version. A running seat gets the new layout at its
|
||||
next launch, restore or handover.
|
||||
- **If a worktree's `.claude/settings.local.json` says `"defaultMode": "acceptEdits"` and the person didn't choose it,**
|
||||
an earlier OpenRig wrote it when it added or freshly launched a managed Claude seat. 0.7.1 no longer writes it but
|
||||
doesn't repair it; ask the person which mode they want and set it once.
|
||||
- Restart the daemon (`rig daemon stop`, then `rig daemon start`) to load the new
|
||||
code. Two database migrations record account-home selection and whether a
|
||||
community harness launch declared a runner. Both use prefix 141, with distinct
|
||||
full filenames.
|
||||
- The bundled openrig-core plugin goes from 0.1.22 to 0.1.23. Its Claude activity
|
||||
hook clears permission marks after tool batches. Managed refresh preserves
|
||||
replaced files under `$OPENRIG_HOME/backups/managed-refresh/<id>/`; reapply
|
||||
local edits you still need from the preserved copy.
|
||||
|
||||
## New things you can do
|
||||
## Follow a slice's work in one workspace
|
||||
|
||||
- **Choose how much automatic input reaches a seat:** `rig seat set-typing-guard <seat> --mode <mode> --reason "…"`, where
|
||||
`<mode>` adds `openrig-urgent` (OpenRig's own notices and anything urgent) and `urgent-only` between `off` and `hold`.
|
||||
Mark a message urgent with `rig send --urgent`, or create the row at priority `urgent` or `critical`. Held input stays
|
||||
readable (`rig seat held-messages`) and is never replayed.
|
||||
- **Choose how far OpenRig and peers may act on a seat:** `rig seat set-terminal-policy <seat> --preset
|
||||
no-peer-interventions|help-within-instructions|full-control --reason "…"`, with `--helper <addresses…>`,
|
||||
`--rig-default` (seats added later), and exceptions such as `--peer-compaction deny` or `--openrig-model allow`.
|
||||
Managed compaction and saved model or effort changes are checked; a denial is `terminal_policy_refused`. Other
|
||||
interventions are guidance only.
|
||||
- **Read a seat's planned permissions:** `rig seat status <seat>` shows what its next fresh managed launch will pass and
|
||||
where each part comes from, and its terminal policy. `rig setup --router-line yes|no` decides whether OpenRig adds its
|
||||
one line to shared guidance files.
|
||||
- **Let a person choose whether every ask reaches Slack:** `rig gateway human set <id> slack-subscription
|
||||
every-ask|urgent-only|unset`. Every ask is on the TUI's Feed either way. Under `urgent-only`, only an ask
|
||||
created or handed off with `--alert` posts. An ask marked `--alert` stands out in Slack (an interrupt with a mention)
|
||||
under either setting, so use it only for something only they can do, or an approval whose wait costs now.
|
||||
- **Give one Claude member its own setup** in the rig spec: `agent_file`, `append_system_prompt_file`, `plugin_dirs` and
|
||||
`advisor` (an Advisor model). They're copied into that seat's storage, never the project.
|
||||
- **See a seat's own skills:** `rig skill loadout --seat <session|self>`.
|
||||
- **Reach the team from an assistant without a shell:** the MCP server has 34 tools, including `rig_whoami`,
|
||||
`rig_restore_status`, `rig_team`, `rig_telemetry_events`, following a handoff, reading a finished item's artifact and
|
||||
deciding inside a person's decision room. `rig mcp serve --http` serves them over HTTP; `rig mcp token add <name>
|
||||
--seat <session>` gives each caller its own seat.
|
||||
- **Hand a row's task to an outside agent:** `rig acp run <row>` for an ACP agent (Hermes, OpenClaw).
|
||||
`docs/reference/a2a-agents.md` covers A2A agents.
|
||||
- **Run a community harness that names, reports or reopens its conversations:** preset fields `launch_result`, `resume`,
|
||||
`names_conversation` and `startup_assisted`, with examples for Cursor, Hermes and OpenClaw under
|
||||
`docs/reference/community-harness-examples/`.
|
||||
- **Change one edge on a running rig:** `rig edge add <rig> <from> <to> --kind <kind>` and `rig edge remove <rig>
|
||||
<edge-id>` (`--plan`, `--json`). A `delegates_to` change says when it moves who work routes to.
|
||||
- **Run one spec as several rigs:** `rig up <spec> --name <rig>`.
|
||||
- **Retune a watchdog in place:** `rig watchdog update <jobId> [--interval-seconds n] [--message text] [--spec path]`
|
||||
keeps its id and attachments. The context watch takes `--threshold-tokens` or `--threshold-percent`.
|
||||
- **Put restore maps where seats may write:** `rig config set policies.claude_compaction.preparation_root <abs path>`.
|
||||
- **Pick a Herdr session:** `rig terminal open <view> --provider herdr --session <name>`.
|
||||
- **Close a row with its artifact:** `rig queue update <id> --state done --evidence-ref <path>`.
|
||||
- **File and read subscription move reports:** `rig provider moves report` and `rig provider moves`. `--host <id>` and
|
||||
`--codex-home <dir>` on `rig auth status|list|switch` and `rig seat restart` act on another machine's Codex home.
|
||||
- **Route stream observations with typed decisions (opt-in, off by default):** once `decisions.enabled` is true,
|
||||
`decisions.consumers` includes `stream` and the intake seat has `OPENROUTER_API_KEY`, `rig project wake --decide`
|
||||
gives each eligible stream observation one bounded typed decision during that seat's wake: create a row for a supplied
|
||||
owner, join supplied work, or record a duplicate or no action. Anything uncertain, refused or failed goes to the
|
||||
intake seat as its own disposition, and the model isn't asked again. `rig project decisions status` shows the state
|
||||
without making a call. Without `--decide`, or with decisions off, nothing changes; turning decisions off no longer
|
||||
strands an accepted create or join whose effect hadn't landed.
|
||||
- **Read the stream as a subscriber:** `rig stream subscribe <seat>` (`--tags`, `--type`; re-subscribing keeps the
|
||||
place), then `rig stream read` returns the next bounded page for the calling seat (at most 20 items and 64 KiB, with
|
||||
`more`, `unread` and any `gap`). `rig stream subscriptions` lists readers; `rig stream unsubscribe <seat>` stops one.
|
||||
`rig stream show <id>` says what became of an observation.
|
||||
- **Keep the kernel to the harnesses you chose:** `rig setup --providers <list>` is now saved as `provider.selected`, and
|
||||
the kernel starts seats only for those.
|
||||
- **Stop a second daemon on a live database:** `rig config set daemon.db_conflict stop` refuses a start whose database a
|
||||
matching live daemon already holds (the default `warn` only warns). `rig daemon start --allow-db-conflict` allows one
|
||||
deliberate start.
|
||||
Use `rig terminal open slice:<name-or-id> --follow --provider herdr`. Membership
|
||||
comes from pending, in-progress and blocked queue rows tagged with the exact
|
||||
slice. Associate a row with `rig queue create --slice <name-or-id>`; a handoff
|
||||
inherits its tags unless replaced. A mission-only tag does not associate every
|
||||
slice. One owner appears once, with all its obligations listed.
|
||||
|
||||
## Behaviour that changed
|
||||
When a slice's folder name repeats across missions, tag its work with the slice's
|
||||
unique ID (`rig queue create --slice <id>`) or add `--mission <mission>`. A row
|
||||
tagged only with the shared name cannot be placed, so the view reads incomplete
|
||||
until it is.
|
||||
|
||||
- **Team skills live in each seat's own storage** (`$OPENRIG_HOME/state/seats/<rig id>/<logical seat>/<runtime>/`), not
|
||||
in the project's `.claude/skills` or `.agents/skills`. Claude seats get them as a generated plugin (`openrig:<id>`),
|
||||
Codex seats as their own catalog. If a link sits in that path, the seat starts without them and the result names
|
||||
what wasn't delivered.
|
||||
- **Urgent wakes are labelled:** an urgent or critical row's wake starts with `Urgent (marked by …):` at every seat. A
|
||||
seat on `hold` gets one held notice per wake instead of re-nudges, and lifecycle work is refused at the middle levels as
|
||||
at `hold`.
|
||||
- **A daemon restart picks up a managed compaction where it stopped.** An owed restore step resumes, a `/compact` that
|
||||
may have been sent is never sent again, and `rig compact --state` shows anything stranded or owed.
|
||||
- **A Claude seat whose startup turn is refused** (an expired login, say) holds the rest of its startup context as owed.
|
||||
A startup input sent before the refusal is noticed can still be submitted (see Known issues in the
|
||||
[0.7.1 release notes](https://github.com/mvschwarz/openrig/releases/tag/v0.7.1)).
|
||||
- **`rig send` refuses with `target_needs_input`** when a Claude seat's hook has just reported it running and its screen
|
||||
shows an MCP enablement menu, a question or a permission prompt.
|
||||
- **`rig down` asks before stopping the caller's own rig.** Without a terminal it exits 1, stops nothing and prints the
|
||||
`--yes` command.
|
||||
- **`rig up --plan` doesn't start a stopped daemon:** it exits 1 with `daemon_not_running` and names `rig daemon start`
|
||||
and `--no-kernel`.
|
||||
- **`rig daemon start`** waits up to 20 seconds for a slow daemon, and starts with a warning when only a secondary
|
||||
listener, such as Tailscale's, can't be verified.
|
||||
- **A seat joined with `rig attach` as an outside app** (Claude Desktop) stays attached through `rig down` and restore
|
||||
(`left_attached`). To replace it, `rig unclaim <name>`, then launch.
|
||||
- **New seats get the daemon's `OPENRIG_DB` and `TMUX_TMPDIR`.**
|
||||
- **A rebuilt Codex seat keeps its observed permissions,** and a read-only Codex profile launches without OpenRig's
|
||||
writable roots.
|
||||
- **A seat's own role** goes only to that seat, never into a shared `CLAUDE.md` or `AGENTS.md`, and `rig down` returns
|
||||
guidance files byte for byte.
|
||||
- **Workflow actions** (`project`, `route`, `resume`, `abort`) record the calling agent; a request with neither identity
|
||||
nor actor gets the shared `actor_required` error.
|
||||
- **`rig provider moves` is back** for subscription move reports. It switches nothing.
|
||||
- **`--dangerously-interact` into a seat's input box stops first** when the box already holds typed or pasted text: it
|
||||
shows that text (apart from suggestions) and types nothing. Clear the box and retry, or pass `--type-without-checking`,
|
||||
which clears nothing. Keys for a recognized native menu are sent as before.
|
||||
- **A terminal seat gets exactly the text sent to it,** without the From/To lines (the result says `envelopeOmitted`).
|
||||
A queue wake to a terminal seat types nothing and fails `failed:terminal-seat`; the row stays queued.
|
||||
- **A daemon restart that can't see the seats' tmux server** no longer detaches seats whose recorded process still
|
||||
runs, so it doesn't relaunch them beside themselves.
|
||||
- **Provider credentials and the activity-hook token stay off tmux, shell and `env` command lines** at launch and
|
||||
handover. A launch whose staging fails stops instead of falling back.
|
||||
- **An answer retried from Slack or Steering** after an interrupted close reuses the first reply and finishes the
|
||||
request once.
|
||||
- **One bad row no longer stops the stuck sweep** from flagging the others; **a malformed unrelated diagnosis row** no
|
||||
longer stops health diagnoses from listing.
|
||||
New slice views default to four automatic tiles (`--max-tiles` overrides it);
|
||||
qitem views keep eight. A known empty slice stays following and can repopulate.
|
||||
An incomplete read keeps the prior viewers. Missing or ambiguous scope identities
|
||||
are errors, never an empty team. Pins, drafts, unknown input and focus retain
|
||||
former viewers. Closing a viewer does not stop its agent.
|
||||
|
||||
## Output that changed shape
|
||||
An owner's join refused before any pane split can join on a later refresh;
|
||||
hold notices keep the original seat, phase and reason. A created viewer whose
|
||||
attach has not yet run can continue without replacing its pane. A viewer whose
|
||||
attach already ran and then exited back to a shell is not restarted
|
||||
automatically, so the whole slice view can hold until that tile is resolved.
|
||||
No tested workaround is offered for that case.
|
||||
|
||||
- **`rig ps --nodes`** separates every cell with one space (the default row is 5 columns wider), adds an `ATTN` column
|
||||
(`yes`, `no` or `?`) before `REASON`, and marks a `running` seat whose tmux couldn't be reached as unconfirmed.
|
||||
- **`rig queue show --json`** no longer has `lastNudgeInputBox`, and send results no longer describe the input box.
|
||||
- **`rig queue show` and `rig queue whoami`** print their human notices on stderr, so stdout is the JSON.
|
||||
- **A claimed row whose owner waits on a person** reads pickup `needs-input`, with the reason.
|
||||
- **`rig down --json`** adds `leftRunning`; `rig reconcile-session` says whether resume metadata was recorded.
|
||||
- **`rig compact-plan`** labels its thresholds as its own and says whether automatic compaction is on.
|
||||
- **A send held for a draft** in the seat's input box reports `inputPhase: "not_reached"`: it was never typed.
|
||||
- **A stream body over 16 KiB** is stored truncated, ending `[truncated by OpenRig: kept N of M bytes]`, and reads mark it
|
||||
`bodyTruncated`. `rig stream list --after <sortKey>` from a pruned item returns 410 `stream_cursor_expired`, naming
|
||||
`--since` to resume, instead of an empty list.
|
||||
Slice following targets the Linux Herdr 0.9.5 / protocol 22 path. Mission
|
||||
following is not included. See
|
||||
`reference/terminal-sessions.md` for controls, bounds and refresh behavior.
|
||||
Configured Herdr session endpoints now carry through viewer/follow operations.
|
||||
The TUI's terminal action opens the agent, pod or rig currently being viewed.
|
||||
|
||||
## What to stop doing
|
||||
## Changes agents should act on
|
||||
|
||||
- **Don't read `lastNudgeInputBox`.** It's gone.
|
||||
- **Don't parse `rig ps --nodes` by column position.** Use `--json`, with `--fields` to pick fields.
|
||||
- **Don't look for a team seat's skills in the project folders.** Use `rig skill loadout --seat`.
|
||||
- **Don't stop and re-register a watchdog to change it.** Use `rig watchdog update`.
|
||||
- **Don't take a rig down to fix one edge.** Use `rig edge add` or `remove`.
|
||||
- **Don't print `OPENRIG_ACTIVITY_*` values** while looking for identity. Use `rig whoami`.
|
||||
- **Don't use `--dangerously-interact` to type over a half-written input box.** It stops and shows the text; choose
|
||||
explicitly, or use `rig send --urgent` where the seat's interruption level allows it.
|
||||
- **A readiness deadline does not end a still-starting daemon.** A nonzero
|
||||
pending result names the child PID, launch endpoint and log. The child stays
|
||||
running and the startup reservation stays in place; readiness is not
|
||||
confirmed. Follow the returned pending guidance before attempting recovery.
|
||||
- **Read exited state honestly.** A verified exited process in a retained pane
|
||||
reads `pane_exited`, instead of running and ready. Its outstanding work uses
|
||||
the existing recovery-finding route. Unknown observations still mean unknown.
|
||||
- **Use the same member settings when expanding.** `rig expand` now carries raw
|
||||
members to the canonical rig validator, keeping env, effort and startup
|
||||
settings. Restores keep the model saved with `rig seat set-model`.
|
||||
- **Account-home moves start a fresh conversation with a handover.**
|
||||
`rig provider switch --seat <seat> --to-account <name> --account-home <dir>`
|
||||
selects an existing home; preview with `--dry-run`. `--clear-account-home`
|
||||
returns to configured defaults, not historical credentials. Check the moved
|
||||
seat's account and conversation.
|
||||
- **Claude API-key switching resumes the same conversation but interrupts work.**
|
||||
Use `rig provider switch --seat <seat> --to-account api --api-key-file <abs>`;
|
||||
return with `--to-account plan --token-file <abs>`. The API-key path remains
|
||||
unqualified. Do not combine it with account-home selectors. Credential files
|
||||
stay private; budgets remain the caller's responsibility.
|
||||
- **A new Claude member can name its subscription token in the spec:**
|
||||
`claude_token: {label: name, file: /abs/token}`, beside its own
|
||||
`env.CLAUDE_CONFIG_DIR`. This stores a file reference, not the token. Validation
|
||||
does not read the file; a missing or unreadable token uses the existing launch
|
||||
fallback notice, so verify the account actually used.
|
||||
- **Git over SSH uses the daemon's SSH agent** in managed seats. Make sure that
|
||||
agent has the intended key (`ssh-add -l`).
|
||||
- **Slack button answers resume decisions parked on the person asked.** Set
|
||||
`rig slack setup --reactions off` if acknowledgement emoji should not wake
|
||||
agents. The default remains `route`; manifest regeneration preserves it.
|
||||
Replies posted outside a deleted Slack thread are tracked where Slack put them.
|
||||
- **Queue delivery keeps its original intent.** A wake refused at a selection
|
||||
prompt retries after it clears. Cross-host `queue create --verify` reads the
|
||||
destination's receipt. An unreadable receipt stays unconfirmed; do not replay
|
||||
a create just because verification could not establish delivery.
|
||||
- **Preset authors can opt into `runner: true` and `passes_model: true`.** The
|
||||
runner also requires `launch_result: true` and reports activity through the
|
||||
existing hook route. Model input arrives as `OPENRIG_MODEL`. Passed means the
|
||||
launch command was sent, not that the harness applied that model; the observed
|
||||
model remains unknown. Cursor and Hermes examples leave model passing off
|
||||
pending native qualification.
|
||||
Start with the draft [integration author contracts](author-contracts.md) for
|
||||
harness, runner and platform duties, with implemented interfaces distinguished from proposals.
|
||||
- **Update viewer API handling.** `/api/terminal/viewer/ensure` returns 400 for
|
||||
`view_required` and `unknown_provider` where it previously returned 200 with
|
||||
an error body. Slice not-found is 404; ambiguous/unavailable is 409. Continue
|
||||
reading the body code. Unsupported follow kinds keep `follow_view_required`
|
||||
in a 200 `ok:false` response.
|
||||
|
||||
## Known gaps in 0.7.1
|
||||
## Limits to keep in view
|
||||
|
||||
- **A new Claude seat's startup text can stay in its input box unsent.** If a fresh Claude seat sits idle with text at
|
||||
its prompt, press Enter in its pane, or ask the person to.
|
||||
- **Claude seats on a macOS native install can read as needing attention after a restore.** Check the pane before
|
||||
acting on it.
|
||||
- **A Slack channel-mapping change needs a daemon restart.** Don't use `rig slack disable` then `enable` for it: enabling
|
||||
marks any alerts waiting to post to Slack as already seen.
|
||||
- **Ubuntu 22.04's tmux 3.2a:** the query known to crash its server is gone, but a narrower race remains. Avoid 3.2a
|
||||
(`tmux -V`).
|
||||
- **`rig daemon start` can take 20 to 40 seconds** before its listener warning, longer than the TUI's 30-second "restore
|
||||
everything" wait. On a slow host, start the daemon yourself first.
|
||||
- **A launch that warns "Scoped tmux environment unavailable; retaining the previous environment"** didn't give that seat
|
||||
the clean environment. Check `env` in the seat before relying on OpenRig's variables.
|
||||
- **Going back to 0.7.0 with a Slack subscription set:** 0.7.0 can't read a person file that holds one, and its people
|
||||
registry stops loading. Save the choice first (`rig gateway human show <id> --json > <id>.json`), then run
|
||||
`rig gateway human set <id> slack-subscription unset` (`urgent-only` becomes every-ask, as on 0.7.0); set it again after
|
||||
returning to 0.7.1. A person file still holding a `subscriptions:` entry fails on 0.7.0 too. Don't restore an older
|
||||
gateway folder or database instead: that can lose newer people, bindings and delivery records.
|
||||
- **A Herdr tile over SSH** doesn't come back by itself after a Herdr restart. Reopen it with `rig terminal open <view>
|
||||
--provider herdr`.
|
||||
- **Delegated decisions:** the ask is created before the decision, so without quiet hours it can post to the person
|
||||
first. Pair a decision room with quiet hours, or decide before asking.
|
||||
- **`restore-check` still treats a missing startup file as rig-wide.** Trust `rig up --existing`'s per-seat result.
|
||||
- **Managed Claude launch requires `/usr/bin/env` support for `-0` and `-u`** (option support checked on GNU coreutils
|
||||
and macOS 15; BusyBox and older macOS unverified). Without the required option support, launch stops instead of
|
||||
falling back to credential-bearing command-line arguments.
|
||||
- **In draft-aware mode, an agent's multi-line message to a Claude seat can remain unsubmitted when the paste changes
|
||||
Claude's footer hint.** OpenRig withholds Enter, leaving the text in the composer and later sends waiting. Observed on
|
||||
Claude 2.1.309 with both 0.7.0 and the 0.7.1 pre-cut build; #14 remains open.
|
||||
- **A Claude seat signed in with a long-lived token (`claude setup-token`) gets no usage reading.** The reader accepts
|
||||
only claude.ai subscription logins and reports `not_subscription_login`.
|
||||
- The reported [Codex hook-refresh refusal (#2200)](https://github.com/mvschwarz/openrig/issues/2200)
|
||||
remains unresolved. A verified Node-launcher fix is included, but that does
|
||||
not establish that this report is fixed. If
|
||||
`rig seat restart <seat> --keep-conversation` refuses, nothing is stopped:
|
||||
leave the seat running and report the refusal with CLI and daemon versions.
|
||||
A refused restart has not refreshed hooks; review any Codex hook prompt.
|
||||
- Retained messages are not delivered. Protected Codex pastes and unreadable
|
||||
composers can still retain work; inspect the explanation, use a larger pane
|
||||
or shorten the message. Turning the typing guard off removes protection.
|
||||
- Inventory now avoids redundant reads, but no general speed or stability
|
||||
improvement is claimed without comparable measurements.
|
||||
- Internet-facing hosted members remain experimental, unsupported, off by
|
||||
default and at your own risk. Slice following does not require them.
|
||||
Generated
+5
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openrig",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openrig",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.6",
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
"packages/daemon",
|
||||
@@ -7346,7 +7346,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@openrig/cli",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.6",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -7384,7 +7384,7 @@
|
||||
},
|
||||
"packages/daemon": {
|
||||
"name": "@openrig/daemon",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.6",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.0",
|
||||
@@ -7426,7 +7426,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@openrig/ui",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.6",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openrig",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.6",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openrig/cli",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.6",
|
||||
"type": "module",
|
||||
"description": "Build your own network of agents from Claude Code, Codex and Pi: persistent teams with roles, shared context and owned work.",
|
||||
"keywords": [
|
||||
|
||||
@@ -842,6 +842,13 @@ async function startOwnedDaemon(opts: StartOptions, deps: LifecycleDeps, lock: D
|
||||
if (unverified) {
|
||||
throw new StartupChildPendingError(`Daemon child ${pid} responds at ${healthzUrl}, but listener verification is incomplete: ${lastProbe} Child left running; startup reservation retained. Inspect 'rig daemon status', daemon-start.lock and daemon.log before recovery.`);
|
||||
}
|
||||
// Expiring the readiness budget is not evidence that this owned child failed.
|
||||
// Keep launch exclusion until an operator can inspect a slow pre-bind startup.
|
||||
assertChild();
|
||||
if (deps.isProcessAlive(pid!)) {
|
||||
assertChild();
|
||||
throw new StartupChildPendingError(`Daemon startup is pending after the readiness deadline (child PID ${pid}). Readiness is not confirmed; child left running and startup reservation retained. Last probe: ${lastProbe}. Launch endpoint: ${healthzUrl}. Inspect that URL and ${LOG_FILE}; this check does not publish readiness, adopt the child or release the reservation. To restart manually, stop child PID ${pid} and wait for exit; inspect ${path.join(OPENRIG_DIR, "daemon-start.lock")}, and only after both recorded launcher and child PIDs are absent, archive it and run 'rig daemon start'. 'rig daemon stop' cannot target this unpublished child, and another start remains blocked by the reservation.`);
|
||||
}
|
||||
throw new Error(`${summarizeDaemonStartFailure(healthzUrl, deps.readFile(resolveLifecycleFile(deps, "daemon.log")))}. Last probe: ${lastProbe}`);
|
||||
}
|
||||
assertChild();
|
||||
|
||||
@@ -1202,6 +1202,7 @@ describe("Daemon Lifecycle", () => {
|
||||
it("start: surfaces native module runtime mismatch inline when healthz never comes up", async () => {
|
||||
vi.useFakeTimers();
|
||||
const deps = mockDeps({
|
||||
isProcessAlive: vi.fn(() => false), // Crash diagnostics require observed process absence.
|
||||
fetch: vi.fn(async () => { throw new Error("connection refused"); }),
|
||||
readFile: vi.fn((p: string) => {
|
||||
if (p === LOG_FILE) {
|
||||
@@ -1232,6 +1233,7 @@ describe("Daemon Lifecycle", () => {
|
||||
it("start: surfaces the recent daemon log line when startup crashes for another reason", async () => {
|
||||
vi.useFakeTimers();
|
||||
const deps = mockDeps({
|
||||
isProcessAlive: vi.fn(() => false), // Crash diagnostics require observed process absence.
|
||||
fetch: vi.fn(async () => { throw new Error("connection refused"); }),
|
||||
readFile: vi.fn((p: string) => {
|
||||
if (p === LOG_FILE) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ChildProcess } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { startDaemon, STATE_FILE, type LifecycleDeps } from "../src/daemon-lifecycle.js";
|
||||
import { startDaemon, getDaemonStatus, STATE_FILE, type LifecycleDeps } from "../src/daemon-lifecycle.js";
|
||||
import { acquireDaemonStartLock } from "../src/daemon-start-lock.js";
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
@@ -67,6 +67,8 @@ describe("child-bound startup", () => {
|
||||
? { ok: true, json: async () => ({ ...f.body, pid: 888 }) } : fetch(url);
|
||||
await expect(startDaemon(opts, f.deps)).rejects.toThrow(/identity mismatch.*127.0.0.2/);
|
||||
expect(f.files.has(STATE_FILE)).toBe(false);
|
||||
expect(f.deps.kill).toHaveBeenCalledWith(12345, "SIGTERM");
|
||||
expect(f.lock.release).toHaveBeenCalledWith(false);
|
||||
});
|
||||
it("publishes the verified child, naming the unverified listener, when only a secondary listener stays uncertain", async () => {
|
||||
vi.useFakeTimers();
|
||||
@@ -114,7 +116,7 @@ describe("child-bound startup", () => {
|
||||
expect((outcome as Error).message).toMatch(/identity mismatch.*observed 888/);
|
||||
expect(f.files.has(STATE_FILE)).toBe(false);
|
||||
});
|
||||
it("gives up on a never-answering endpoint within one readiness deadline", async () => {
|
||||
it("returns pending within one readiness deadline without stopping its still-starting child", async () => {
|
||||
vi.useFakeTimers();
|
||||
const f = fixture(); const fetch = f.deps.fetch;
|
||||
f.deps.fetch = async (url) => (await fetch(url), new Promise(() => {}));
|
||||
@@ -122,7 +124,11 @@ describe("child-bound startup", () => {
|
||||
expect((outcome as Error).message).toMatch(/Last probe: .*timed out|Last probe: .*healthz/);
|
||||
expect(elapsed).toBeLessThanOrEqual(20_000);
|
||||
expect(f.files.has(STATE_FILE)).toBe(false);
|
||||
expect(f.deps.kill).toHaveBeenCalledWith(12345, "SIGTERM");
|
||||
expect(f.deps.kill).not.toHaveBeenCalled();
|
||||
expect(f.lock.release).toHaveBeenCalledWith(true);
|
||||
expect((outcome as Error).message).toMatch(/pending.*12345/);
|
||||
expect((outcome as Error).message).toContain("Launch endpoint: http://127.0.0.1:19873/healthz");
|
||||
expect((outcome as Error).message).toContain("daemon.log");
|
||||
});
|
||||
// Synchronous work crossing the deadline is modelled by a clock jump inside one fetch, before
|
||||
// any timer runs. Post-spawn fetches: 0 primary read, 1 primary listener check, 2 secondary check.
|
||||
@@ -190,6 +196,8 @@ describe("child-bound startup", () => {
|
||||
};
|
||||
await expect(startDaemon(opts, f.deps)).rejects.toThrow(/exited/);
|
||||
expect(f.files.has(STATE_FILE)).toBe(false);
|
||||
expect(f.deps.kill).not.toHaveBeenCalled();
|
||||
expect(f.lock.release).toHaveBeenCalledWith(false);
|
||||
});
|
||||
it("handles an asynchronous native spawn error without a PID", async () => {
|
||||
const f = fixture();
|
||||
@@ -280,3 +288,52 @@ describe("local startup reservation", () => {
|
||||
} finally { fs.rmSync(home, { recursive: true, force: true }); }
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it.each(["missing", "stale"] as const)("keeps a slow child discoverable with %s old state and excludes a duplicate launch", async (oldState) => {
|
||||
vi.useFakeTimers();
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "openrig-pending-start-"));
|
||||
const f = fixture(), fetch = f.deps.fetch;
|
||||
const previousState = oldState === "stale"
|
||||
? JSON.stringify({ pid: 999, host: "127.0.0.2", port: 19874, db: "old.sqlite", startedAt: "old" })
|
||||
: undefined;
|
||||
if (previousState) f.files.set(STATE_FILE, previousState);
|
||||
f.deps.isProcessAlive = pid => pid === f.child.pid && f.child.exitCode === null;
|
||||
let ready = false;
|
||||
f.deps.acquireStartLock = () => acquireDaemonStartLock(home);
|
||||
f.deps.fetch = async url => {
|
||||
const response = await fetch(url);
|
||||
if (!ready) throw Object.assign(new Error("still initializing"), { code: "ECONNREFUSED" });
|
||||
return response;
|
||||
};
|
||||
vi.stubEnv("OPENRIG_URL", `http://127.0.0.1:${opts.port}`);
|
||||
vi.stubEnv("RIGGED_URL", "");
|
||||
try {
|
||||
const began = Date.now(), result = startDaemon(opts, f.deps).catch((error: Error) => error);
|
||||
await vi.runAllTimersAsync();
|
||||
const outcome = await result;
|
||||
expect(outcome).toBeInstanceOf(Error);
|
||||
expect((outcome as Error).message).toMatch(/pending.*12345/);
|
||||
const message = (outcome as Error).message;
|
||||
expect(message).toContain("Launch endpoint: http://127.0.0.1:19873/healthz");
|
||||
expect(message).not.toContain("127.0.0.2:19874");
|
||||
expect(message).toContain("this check does not publish readiness, adopt the child or release the reservation");
|
||||
expect(message).toContain("stop child PID 12345 and wait for exit");
|
||||
expect(message).toContain(path.join(path.dirname(STATE_FILE), "daemon-start.lock"));
|
||||
expect(message).toContain("only after both recorded launcher and child PIDs are absent, archive it and run 'rig daemon start'");
|
||||
expect(message).toContain("'rig daemon stop' cannot target this unpublished child");
|
||||
expect(Date.now() - began).toBeLessThanOrEqual(20_000);
|
||||
expect(f.deps.kill).not.toHaveBeenCalled();
|
||||
expect(f.child.exitCode).toBeNull();
|
||||
expect(f.files.get(STATE_FILE)).toBe(previousState);
|
||||
expect(fs.readFileSync(path.join(home, "daemon-start.lock"), "utf8")).toContain('"childPid":12345');
|
||||
ready = true;
|
||||
await expect(getDaemonStatus(f.deps)).resolves.toMatchObject({ state: "running", healthy: true, port: opts.port });
|
||||
expect(f.files.get(STATE_FILE)).toBe(previousState); // A status probe never retroactively publishes start readiness.
|
||||
await expect(startDaemon(opts, f.deps)).rejects.toThrow(/already reserved/);
|
||||
expect(f.deps.spawn).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openrig-core",
|
||||
"version": "0.1.21",
|
||||
"version": "0.1.23",
|
||||
"description": "OpenRig canonical skills and hooks for cross-runtime agent topology coordination, continuity, and operating discipline.",
|
||||
"author": {
|
||||
"name": "Mike Schwarz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openrig-core",
|
||||
"version": "0.1.21",
|
||||
"version": "0.1.23",
|
||||
"description": "OpenRig canonical skills and hooks for cross-runtime agent topology coordination, continuity, and operating discipline.",
|
||||
"author": {
|
||||
"name": "Mike Schwarz",
|
||||
|
||||
@@ -11,7 +11,7 @@ not know exists.** So the point of what follows is not skill. It is recognition:
|
||||
lands, something rhymes, and you go check instead of building.
|
||||
|
||||
Ninety top-level verbs ship (capability canon refreshed through
|
||||
`capability-delta-v0.7.5`). This marker describes the pack's teaching, not publication or live
|
||||
`capability-delta-v0.7.6`). This marker describes the pack's teaching, not publication or live
|
||||
adoption. Read this once for shape, and let it make you suspicious that a thing already exists.
|
||||
What changed in the installed version, and what to do about it, is one read away:
|
||||
`rig context get reference/whats-new.md`.
|
||||
@@ -34,6 +34,8 @@ one command away. Your memory of the fleet is a claim about the past.
|
||||
filters to running and **hides detached, exited and attention states** — which are exactly the
|
||||
cells you were scanning for. A seat whose tmux session is confirmed gone reads `detached`
|
||||
(activity reason `session_missing`), and `rig ps --nodes` says to restore or relaunch it.
|
||||
A verified exited process in a retained pane reads `pane_exited`, instead of running and ready;
|
||||
outstanding work uses the existing recovery-finding route. An unknown observation is not an exit.
|
||||
`rig ps --nodes` also shows the model each seat was last seen using (`modelObservation`, with its
|
||||
source and time), or unknown; it never shows the configured model in its place. A Claude seat at
|
||||
Claude Code's MCP-server approval menu reads as needing input (`mcp_gate`), a Codex seat at its
|
||||
@@ -227,6 +229,15 @@ unseen — which makes messages the one delivery channel that never gets skipped
|
||||
The exercised journey used shell stand-ins, not native agents, and active render cost remains
|
||||
unmeasured. See `reference/terminal-sessions.md` for controls and limits. Preview and open
|
||||
responses carry `viewDescriptor` (`kind`, `name`); membership grants no input authority.
|
||||
**`rig terminal open slice:<name-or-id> --follow --provider herdr`** follows owners of the
|
||||
slice's exact-tagged pending, in-progress and blocked rows, with every obligation listed.
|
||||
Associate rows with `rig queue create --slice <name-or-id>`; handoffs inherit tags unless replaced.
|
||||
New slice views default to four automatic tiles (`--max-tiles` overrides it). Known empty slices
|
||||
stay following; incomplete reads keep prior viewers, and draft/focus protection still applies.
|
||||
A join refused before a pane split can join on a later refresh, with the original hold reason retained.
|
||||
An attach that ran and then exited to a shell is not restarted automatically; the whole slice view
|
||||
can hold until that tile is resolved. No tested workaround is offered for that case.
|
||||
Slice following targets the Linux Herdr 0.9.5 / protocol 22 path. Mission following is not included.
|
||||
An unconfirmed pane is reported as `pane_unconfirmed`, not counted as opened. Inspect before
|
||||
retrying: an unconfirmed result does not prove that nothing opened.
|
||||
Add `--session <name>` to select a Herdr session for that request; `--session default`
|
||||
@@ -262,8 +273,10 @@ unseen — which makes messages the one delivery channel that never gets skipped
|
||||
beats a rig match; the rest use the default channel). After a change, invite the app to each
|
||||
channel, run `rig slack verify`, and restart the daemon (`rig daemon stop`, then `start`).
|
||||
Disabling and enabling Slack instead marks alerts still waiting as already posted, so they're
|
||||
never posted. An emoji reaction on any message posted for an ask reaches the asking seat
|
||||
never posted. By default, an emoji reaction on a message posted for an ask reaches the asking seat
|
||||
as a task tagged `human-reaction`: a signal to interpret, not an answer, and the ask stays open.
|
||||
`rig slack setup --reactions off` disables that routing; the default is `route`, and regenerating
|
||||
the app manifest preserves the setting.
|
||||
It needs the `reactions:read` scope and the `reaction_added` event (reinstall the app);
|
||||
`rig slack verify` warns when the scope is missing. A long ask is posted in numbered thread parts; one that
|
||||
still can't be posted comes back to the asking seat as a `slack-undeliverable` task. Replies
|
||||
@@ -517,6 +530,10 @@ not have to tear one down to change it, and you rarely have to start from nothin
|
||||
`append_system_prompt_file`, `plugin_dirs` and `advisor` (an Advisor model). They're copied into
|
||||
that seat's own storage, never the project, so one seat in a shared repository can have its own
|
||||
persona, prompt or plugins. A bundle's before-install view lists them.
|
||||
- **A new Claude member can name a prepared subscription token** with
|
||||
`claude_token: {label: name, file: /abs/token}` beside its `env.CLAUDE_CONFIG_DIR`; the seat stores
|
||||
the file reference and reads it at launch. A missing/unreadable token uses the existing fallback
|
||||
notice, so verify the account actually used.
|
||||
- **`rig community-harness list`**: list installed community-harness presets and declared limits
|
||||
(not tested by OpenRig). A preset can declare how its app reports the conversation each launch
|
||||
made (`launch_result`), reopens one on resume (`resume`) or lets OpenRig name it
|
||||
@@ -524,7 +541,10 @@ not have to tear one down to change it, and you rarely have to start from nothin
|
||||
that's gone reads `missing`, never quietly replaced. A preset's `first_input: true` hands the
|
||||
harness its first startup text through a private file named by `OPENRIG_FIRST_INPUT`. Examples for
|
||||
Cursor, Hermes and OpenClaw are in `docs/reference/community-harness-examples/`; OpenClaw's local
|
||||
and Gateway-client examples each resume a named conversation.
|
||||
and Gateway-client examples each resume a named conversation. A preset can declare `runner: true`
|
||||
(with `launch_result: true`) so activity follows its runner reports, recorded per launch.
|
||||
`passes_model: true` sends the declared model as `OPENRIG_MODEL`; it does not establish the
|
||||
effective model. Cursor and Hermes examples leave model passing off pending native qualification.
|
||||
`docs/reference/community-harness-presets.md` is the contract: what a community harness gets from
|
||||
OpenRig and what it doesn't (no turn-cancel verb, no approvals reaching OpenRig), how to write
|
||||
one, and how promotion is judged. Whether processes a harness starts outside its pane survive
|
||||
@@ -1223,6 +1243,13 @@ your circumstances is configuration, and the ones that are not, another agent ca
|
||||
never the token, and `--stored-login` moves it back. These seat account and runtime moves were
|
||||
tested with stubs, not real accounts, before 0.7.2: check a moved seat's account and conversation
|
||||
yourself after the first move.
|
||||
- **Move to an existing account home** with `rig provider switch --seat <seat> --to-account <name>
|
||||
--account-home <dir>` (preview with `--dry-run`). It starts a fresh conversation with a handover
|
||||
and retains the seat's address and work. `--clear-account-home` returns to configured defaults.
|
||||
- **Claude subscription/API-key switching resumes the same conversation and interrupts work:**
|
||||
`rig provider switch --seat <seat> --to-account api --api-key-file <abs>`; return with
|
||||
`--to-account plan --token-file <abs>`. The API-key path remains unqualified; verify the resulting
|
||||
account and conversation. Do not combine API-key and account-home selectors.
|
||||
- **`rig provider status --refresh --provider claude --claude-token-file <absolute path>
|
||||
--credential-label <label>`** — read a labelled Claude token's usage on demand. Each refresh spends
|
||||
one model turn; status and the TUI show only the cached reading, and the label is yours, not an
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openrig/daemon",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.6",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -31,7 +31,7 @@ channel for decisions. If the TUI has exited but its shell remains, run
|
||||
`rig tui` there once. If the terminal session itself is absent, inspect
|
||||
`rig ps --nodes --rig kernel --json` for its registered `canonicalSessionName`
|
||||
and recover only that seat with `rig seat launch <session> --fresh --reason
|
||||
"recover missing shared kernel dashboard"` (normally `operator-human@kernel`).
|
||||
"recover missing shared kernel dashboard"`.
|
||||
Its fresh-start action runs `rig tui`; confirm that with `rig capture <session>`
|
||||
before opening `saved:kernel`. Keep healthy agent sessions running and omit
|
||||
`--stop`. Restoring selected agent seats does not restore an unselected
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createHash, randomUUID } from "node:crypto";
|
||||
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { getDefaultOpenRigPath } from "../../openrig-compat.js";
|
||||
import { HerdrViewer, findAttributedViewerPanes, readHerdrViewerBody as body, parseHerdrViewerPane as pane,
|
||||
import { HerdrViewer, ViewerCreateNotDispatchedError, findAttributedViewerPanes, readHerdrViewerBody as body, parseHerdrViewerPane as pane,
|
||||
type HerdrViewerRun, type InspectViewerSeat, type ViewerKey, type ViewerMapping, type ViewerPane } from "./herdr-viewer.js";
|
||||
import { DEFAULT_FOLLOW_POLICY, planFollowingTiles, type FollowControls, type FollowPolicy, type FollowTile, type FollowingStatus } from "./terminal-follow-policy.js";
|
||||
import type { ComposedPane, ComposedView, OpenViewResult } from "./terminal-provider.js";
|
||||
@@ -161,9 +161,12 @@ export class HerdrFollowView {
|
||||
throw new Error(`${tile.seat}: Core's current attachment differs from the retained viewer; keeping the previous view unconfirmed.`);
|
||||
}
|
||||
}
|
||||
const current = await currentPane(tile.viewer);
|
||||
let current: ViewerPane;
|
||||
try { current = await currentPane(tile.viewer); }
|
||||
catch (error) { throw new Error(`${tile.seat}: unknown: ${error instanceof Error ? error.message : String(error)}`); }
|
||||
const verified = await viewers.lookup(tile.member, tile.target);
|
||||
if (!verified.ok || JSON.stringify(verified.viewer) !== JSON.stringify(tile.viewer)) throw new Error("A retained viewer is unconfirmed; keeping the whole view.");
|
||||
if (!verified.ok) throw new Error(`${tile.seat}: ${verified.phase ?? "unknown"}: ${verified.error} Keeping the whole view.`);
|
||||
if (JSON.stringify(verified.viewer) !== JSON.stringify(tile.viewer)) throw new Error(`${tile.seat}: Viewer mapping changed; keeping the whole view.`);
|
||||
observations.set(tile.key, { focused: current.focused!, input: await this.deps.input?.(tile.target) ?? "unknown" });
|
||||
}
|
||||
const plan = planFollowingTiles(state.tiles, members, true, observations, policy, now());
|
||||
@@ -174,19 +177,24 @@ export class HerdrFollowView {
|
||||
if (!target || !member.key.hostId || !member.key.nodeId) { notice(`${join.seat}: ${member.reason ?? "Terminal is unavailable; the member remains in this task."}`); continue; }
|
||||
const memberKey = { hostId: member.key.hostId, nodeId: member.key.nodeId };
|
||||
let verified = await viewers.ensure(memberKey, target, stillCurrent, async (cwd) => {
|
||||
await currentPane(state!.pane!);
|
||||
if (!await stillCurrent()) throw new Error("Membership changed before the split.");
|
||||
const anchor = state!.tiles.at(-1)?.viewer;
|
||||
if (anchor) await currentPane(anchor);
|
||||
try {
|
||||
await currentPane(state!.pane!);
|
||||
if (!await stillCurrent()) throw new Error("Membership changed before the split.");
|
||||
if (anchor) await currentPane(anchor);
|
||||
} catch (error) {
|
||||
throw new ViewerCreateNotDispatchedError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
const split = body(await this.deps.run(["pane", "split", "--pane", anchor?.paneId ?? state!.pane!.pane_id,
|
||||
"--direction", anchor && state!.tiles.length % 2 ? "right" : "down", "--no-focus", "--cwd", cwd]));
|
||||
return JSON.stringify({ root_pane: split.pane });
|
||||
});
|
||||
const originalFailure = !verified.ok ? `${verified.phase ?? "unknown"}: ${verified.error}` : undefined;
|
||||
for (let attempt = 0; !verified.ok && attempt < 5; attempt++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
verified = await viewers.lookup(memberKey, target); // read-only: never repeats a create/run.
|
||||
}
|
||||
if (!verified.ok) { notice(`${join.seat}: ${verified.error}`); continue; }
|
||||
if (!verified.ok) { notice(`${join.seat}: ${originalFailure}; confirmation: ${verified.phase ?? "unknown"}: ${verified.error}`); continue; }
|
||||
state.tiles.push({ key: join.key, seat: join.seat, target, member: { hostId: member.key.hostId, nodeId: member.key.nodeId }, viewer: verified.viewer });
|
||||
notice(`${join.seat}: ${join.reason}`); save();
|
||||
}
|
||||
|
||||
@@ -10,9 +10,11 @@ import type { ComposedPane } from "./terminal-provider.js";
|
||||
export interface ViewerKey { hostId: string; nodeId: string }
|
||||
export interface ViewerMapping { workspaceId: string; paneId: string; terminalId: string; readOnly: boolean }
|
||||
export type ViewerResult = { ok: true; viewer: ViewerMapping; created: boolean }
|
||||
| { ok: false; code: string; error: string };
|
||||
| { ok: false; code: string; error: string; phase?: Entry["phase"] };
|
||||
export type InspectViewerSeat = (argv: string[]) => Promise<{ sessionId: string; nodeId: string }>;
|
||||
export type HerdrViewerRun = (argv: string[]) => Promise<string>;
|
||||
/** Only the caller which has not dispatched a create may assert this. */
|
||||
export class ViewerCreateNotDispatchedError extends Error {}
|
||||
export type CreateViewerPane = (cwd: string, label: string) => Promise<string>;
|
||||
export interface ViewerPane { workspace_id: string; pane_id: string; terminal_id: string; cwd: string; focused?: boolean }
|
||||
type Pane = ViewerPane;
|
||||
@@ -21,7 +23,6 @@ interface Entry {
|
||||
phase: "creating" | "created" | "starting"; pane?: Pane;
|
||||
}
|
||||
interface Journal { version: 1; entries: Record<string, Entry> }
|
||||
const unavailable = (error: string): ViewerResult => ({ ok: false, code: "viewer_unconfirmed", error });
|
||||
const runFile = promisify(execFile);
|
||||
|
||||
/** The supported CLI, never guessed socket RPC method parity. Its reported
|
||||
@@ -103,16 +104,18 @@ export class HerdrViewer {
|
||||
return findAttributedViewerPanes(this.run, entry.cwd);
|
||||
}
|
||||
private async ensureOnce(key: ViewerKey, target: ComposedPane, provision = true, stillCurrent: () => Promise<boolean> = async () => true, createPane?: CreateViewerPane): Promise<ViewerResult> {
|
||||
let entry: Entry | undefined;
|
||||
const unconfirmed = (error: string): ViewerResult => ({ ok: false, code: "viewer_unconfirmed", error, ...(entry ? { phase: entry.phase } : {}) });
|
||||
try {
|
||||
if (this.platform !== "linux") return { ok: false, code: "viewer_unsupported", error: "Ensure-viewer is qualified on Linux Herdr 0.9.5/protocol 22 only." };
|
||||
const status = body(await this.run(["status", "server", "--json"]));
|
||||
if (status.version !== "0.9.5" || status.protocol !== 22 || status.running !== true || status.socket !== this.socketPath) {
|
||||
return unavailable("The qualified Herdr version/protocol or selected socket was not confirmed.");
|
||||
return unconfirmed("The qualified Herdr version/protocol or selected socket was not confirmed.");
|
||||
}
|
||||
if (provision && !await stillCurrent()) return unavailable("Task membership changed while waiting to provision; refresh its preview.");
|
||||
if (!target.attachArgv) return unavailable("Only verified local tmux bindings support ensure-viewer.");
|
||||
if (provision && !await stillCurrent()) return unconfirmed("Task membership changed while waiting to provision; refresh its preview.");
|
||||
if (!target.attachArgv) return unconfirmed("Only verified local tmux bindings support ensure-viewer.");
|
||||
const binding = await this.inspect(target.attachArgv);
|
||||
if (binding.nodeId !== key.nodeId) return unavailable("The current tmux binding does not match this member’s node identity.");
|
||||
if (binding.nodeId !== key.nodeId) return unconfirmed("The current tmux binding does not match this member’s node identity.");
|
||||
if (provision) mkdirSync(this.root, { recursive: true, mode: 0o700 });
|
||||
const endpoint = createHash("sha256").update(this.socketPath).digest("hex");
|
||||
const file = path.join(this.root, `${endpoint}.json`);
|
||||
@@ -122,36 +125,49 @@ export class HerdrViewer {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
journal = { version: 1, entries: {} };
|
||||
}
|
||||
if (journal.version !== 1 || !journal.entries || typeof journal.entries !== "object" || Array.isArray(journal.entries)) return unavailable("Viewer provenance is unreadable; no provider mutation was attempted.");
|
||||
if (journal.version !== 1 || !journal.entries || typeof journal.entries !== "object" || Array.isArray(journal.entries)) return unconfirmed("Viewer provenance is unreadable; no provider mutation was attempted.");
|
||||
const save = () => {
|
||||
const tmp = `${file}.${randomUUID()}.tmp`;
|
||||
writeFileSync(tmp, `${JSON.stringify(journal)}\n`, { mode: 0o600 }); renameSync(tmp, file);
|
||||
};
|
||||
const id = JSON.stringify([key.hostId, key.nodeId]);
|
||||
let entry = journal.entries[id];
|
||||
entry = journal.entries[id];
|
||||
let created = false;
|
||||
if (entry && (entry.command !== target.paneCommand || entry.readOnly !== target.readOnly)) {
|
||||
return unavailable("The seat attachment changed; the existing viewer must be inspected before reprovisioning.");
|
||||
return unconfirmed("The seat attachment changed; the existing viewer must be inspected before reprovisioning.");
|
||||
}
|
||||
if (!entry) {
|
||||
if (!provision) return { ok: false, code: "viewer_not_provisioned", error: "No owned viewer has been provisioned for this member." };
|
||||
if (Object.keys(journal.entries).length >= 256) return unavailable("This endpoint has 256 retained viewer records; inspect its viewer provenance before adding more.");
|
||||
if (Object.keys(journal.entries).length >= 256) return unconfirmed("This endpoint has 256 retained viewer records; inspect its viewer provenance before adding more.");
|
||||
const token = randomUUID();
|
||||
const cwd = path.join(this.root, endpoint, token); mkdirSync(cwd, { recursive: true, mode: 0o700 });
|
||||
entry = { cwd, label: `openrig-viewer-${token}`, command: target.paneCommand, argv: target.attachArgv, readOnly: target.readOnly, phase: "creating" };
|
||||
journal.entries[id] = entry; save();
|
||||
// No retry here. A timeout is reconciled by enumeration on the next request.
|
||||
const reply = body(await (createPane ? createPane(cwd, entry.label)
|
||||
: this.run(["workspace", "create", "--cwd", cwd, "--label", entry.label, "--no-focus"])));
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await (createPane ? createPane(cwd, entry.label)
|
||||
: this.run(["workspace", "create", "--cwd", cwd, "--label", entry.label, "--no-focus"]));
|
||||
} catch (error) {
|
||||
if (createPane && error instanceof ViewerCreateNotDispatchedError) {
|
||||
// This invocation positively refused BEFORE dispatch, not an empty
|
||||
// enumeration or a timeout after a possible provider effect.
|
||||
delete journal.entries[id]; save();
|
||||
try { rmdirSync(cwd); } catch { /* An unused directory must not prevent a later join. */ }
|
||||
return unconfirmed(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const reply = body(raw);
|
||||
const made = pane(reply.root_pane);
|
||||
if (!made || made.cwd !== cwd) return unavailable("Create returned no attributable pane/terminal pair; its effect remains uncertain.");
|
||||
if (!made || made.cwd !== cwd) return unconfirmed("Create returned no attributable pane/terminal pair; its effect remains uncertain.");
|
||||
entry.pane = made; entry.phase = "created"; save(); created = true;
|
||||
}
|
||||
if (!entry.pane) {
|
||||
// A unique private cwd attributes a previously initiated create, not a
|
||||
// caller ID or mutable display label. Empty/unreadable is still unknown.
|
||||
const found = await this.ownedPanes(entry);
|
||||
if (found.length !== 1) return unavailable("Uncertain viewer creation has no unique private-cwd mapping.");
|
||||
if (found.length !== 1) return unconfirmed("Uncertain viewer creation has no unique private-cwd mapping.");
|
||||
entry.pane = found[0]!; entry.phase = "created"; if (provision) save();
|
||||
}
|
||||
let current: Pane | null;
|
||||
@@ -160,13 +176,13 @@ export class HerdrViewer {
|
||||
if (!current || current.pane_id !== entry.pane.pane_id || current.terminal_id !== entry.pane.terminal_id
|
||||
|| current.workspace_id !== entry.pane.workspace_id || current.cwd !== entry.cwd) {
|
||||
if (!provision || (await this.ownedPanes(entry)).length !== 0) {
|
||||
return unavailable("Viewer identity changed or disappeared; an enumerable viewer cannot be replaced.");
|
||||
return unconfirmed("Viewer identity changed or disappeared; an enumerable viewer cannot be replaced.");
|
||||
}
|
||||
// Only a complete empty readback retires provenance. Remove our empty
|
||||
// attribution directory, never provider panes or arbitrary journal paths.
|
||||
if (!/^openrig-viewer-[0-9a-f-]{36}$/.test(entry.label)
|
||||
|| entry.cwd !== path.join(this.root, endpoint, entry.label.slice("openrig-viewer-".length))) {
|
||||
return unavailable("Absent viewer provenance has no owned attribution directory.");
|
||||
return unconfirmed("Absent viewer provenance has no owned attribution directory.");
|
||||
}
|
||||
try { rmdirSync(entry.cwd); }
|
||||
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
||||
@@ -174,8 +190,10 @@ export class HerdrViewer {
|
||||
return { ok: false, code: "viewer_not_provisioned", error: "The old viewer is confirmed absent; ensure again to create a verified replacement." };
|
||||
}
|
||||
if (entry.phase === "created") {
|
||||
if (!provision) return unavailable("The created viewer process is not yet confirmed.");
|
||||
if (!await stillCurrent()) return unavailable("Task membership changed before the viewer process started.");
|
||||
if (!provision) return unconfirmed("The created viewer process is not yet confirmed.");
|
||||
if (!await stillCurrent()) return unconfirmed("Task membership changed before the viewer process started.");
|
||||
const beforeRun = await this.inspect(target.attachArgv);
|
||||
if (beforeRun.nodeId !== key.nodeId || beforeRun.sessionId !== binding.sessionId) return unconfirmed("The seat binding changed before the viewer process started.");
|
||||
entry.phase = "starting"; save();
|
||||
// pane run rc0/empty stdout is not proof. It is sent ONCE then checked.
|
||||
await this.run(["pane", "run", current.pane_id, entry.command]);
|
||||
@@ -195,11 +213,11 @@ export class HerdrViewer {
|
||||
&& (actual[expected.length] === `=${targetName}` || actual[expected.length] === binding.sessionId);
|
||||
});
|
||||
const afterBinding = await this.inspect(target.attachArgv);
|
||||
if (afterBinding.nodeId !== key.nodeId || afterBinding.sessionId !== binding.sessionId) return unavailable("The seat binding changed while provisioning its viewer.");
|
||||
if (!verified) return unavailable("The viewer process is not confirmed; no pane run was repeated.");
|
||||
if (afterBinding.nodeId !== key.nodeId || afterBinding.sessionId !== binding.sessionId) return unconfirmed("The seat binding changed while provisioning its viewer.");
|
||||
if (!verified) return unconfirmed("The viewer process is not confirmed; no pane run was repeated.");
|
||||
return { ok: true, viewer: { workspaceId: current.workspace_id, paneId: current.pane_id, terminalId: current.terminal_id, readOnly: entry.readOnly }, created };
|
||||
} catch (error) {
|
||||
return unavailable(`Viewer effect is unconfirmed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return unconfirmed(`Viewer effect is unconfirmed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { projectTaskOwners, type TaskSeat, type TaskTerminalView } from "./task-
|
||||
|
||||
export interface ScopeViewError { code: "scope_unsupported" | "scope_not_found" | "scope_ambiguous" | "scope_unavailable"; error: string }
|
||||
type IndexContext = Pick<SliceIndexer, "slicesRoot" | "additionalSliceRoots" | "projectId">;
|
||||
interface Scope { name: string; id?: string; title: string; source: string }
|
||||
interface Scope { name: string; id?: string; title: string; source: string; mission?: string; missionSource?: string }
|
||||
const MAX_ENTRIES = 4096, MAX_HEADER_BYTES = 16384, MAX_SOURCE_BYTES = 8 * 1024 * 1024;
|
||||
const MAX_ROWS = 50000, MAX_TAG_BYTES = 16384, MAX_TOTAL_TAG_BYTES = 8 * 1024 * 1024;
|
||||
class ScopeLimit extends Error {}
|
||||
@@ -20,7 +20,7 @@ const unavailable = (error: string): ScopeViewError => ({ code: "scope_unavailab
|
||||
* for retiring viewers. No inferred project, substring match or remote I/O. */
|
||||
function readScopeIndex(index: IndexContext): Scope[] | ScopeViewError {
|
||||
let entries = 0, bytes = 0;
|
||||
const scopes: Scope[] = [], seenRoots = new Set<string>(), seenScopes = new Set<string>();
|
||||
const scopes: Scope[] = [], seenRoots = new Set<string>(), seenScopes = new Map<string, Scope>();
|
||||
const list = (dir: string, visit: (name: string) => void) => {
|
||||
const handle = fs.opendirSync(dir);
|
||||
try {
|
||||
@@ -30,10 +30,12 @@ function readScopeIndex(index: IndexContext): Scope[] | ScopeViewError {
|
||||
}
|
||||
} finally { handle.closeSync(); }
|
||||
};
|
||||
const add = (dir: string, name: string) => {
|
||||
const source = fs.realpathSync(dir);
|
||||
if (seenScopes.has(source)) return;
|
||||
seenScopes.add(source);
|
||||
const add = (dir: string, name: string, mission?: string, missionSource?: string) => {
|
||||
const source = fs.realpathSync(dir), existing = seenScopes.get(source);
|
||||
if (existing) {
|
||||
if (missionSource && !existing.missionSource) { existing.mission = mission; existing.missionSource = missionSource; }
|
||||
return;
|
||||
}
|
||||
let raw = "";
|
||||
for (const file of NODE_FILE_PRECEDENCE) {
|
||||
let fd: number;
|
||||
@@ -56,7 +58,8 @@ function readScopeIndex(index: IndexContext): Scope[] | ScopeViewError {
|
||||
const id = typeof fields.id === "string" && fields.id ? fields.id : undefined;
|
||||
const title = typeof fields.title === "string" && fields.title ? fields.title
|
||||
: typeof fields.slice === "string" && fields.slice ? fields.slice : raw.match(/^# (.+)$/m)?.[1]?.trim() || name;
|
||||
scopes.push({ name, id, title, source });
|
||||
const scope = { name, id, title, source, mission, missionSource };
|
||||
seenScopes.set(source, scope); scopes.push(scope);
|
||||
};
|
||||
try {
|
||||
for (const [position, root] of [index.slicesRoot, ...index.additionalSliceRoots].entries()) {
|
||||
@@ -75,7 +78,10 @@ function readScopeIndex(index: IndexContext): Scope[] | ScopeViewError {
|
||||
let isMission = false;
|
||||
try { isMission = fs.statSync(nested).isDirectory(); }
|
||||
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
||||
if (isMission) list(nested, child => add(path.join(nested, child), child));
|
||||
if (isMission) {
|
||||
const missionSource = fs.realpathSync(dir);
|
||||
list(nested, child => add(path.join(nested, child), child, name, missionSource));
|
||||
}
|
||||
else add(dir, name);
|
||||
});
|
||||
}
|
||||
@@ -91,8 +97,8 @@ function selectSlice(scopes: Scope[], key: string): Scope | ScopeViewError {
|
||||
if (!matches.length) return { code: "scope_not_found", error: `No indexed slice exactly matches '${key}'.` };
|
||||
if (matches.length !== 1) return { code: "scope_ambiguous", error: `Multiple indexed slices match '${key}'; choose an unambiguous scope.` };
|
||||
const selected = matches[0]!;
|
||||
// A unique name cannot certify which rows an ambiguous alias names.
|
||||
if (scopes.some(other => other !== selected && (other.name === selected.name || other.id === selected.name
|
||||
// Flat legacy slices have no mission context to place a colliding alias.
|
||||
if (!selected.missionSource && scopes.some(other => other !== selected && (other.name === selected.name || other.id === selected.name
|
||||
|| (selected.id && (other.id === selected.id || other.name === selected.id))))) {
|
||||
return { code: "scope_ambiguous", error: `Slice '${key}' has a colliding name or id association.` };
|
||||
}
|
||||
@@ -108,6 +114,17 @@ export function readScopedTaskTerminalView(db: Database.Database, view: string,
|
||||
if ("code" in identities) return identities;
|
||||
const scope = selectSlice(identities, view.slice(6));
|
||||
if ("code" in scope) return scope;
|
||||
// At most two linear index passes per resolve, never a scan per queue row.
|
||||
const claims = new Map([...new Set([scope.name, scope.id].filter((key): key is string => !!key))].map(key => {
|
||||
const candidates = identities.filter(candidate => candidate.name === key || candidate.id === key);
|
||||
const byMission = new Map<string, Scope[]>();
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate.mission) continue;
|
||||
const sameMission = byMission.get(candidate.mission) ?? [];
|
||||
sameMission.push(candidate); byMission.set(candidate.mission, sameMission);
|
||||
}
|
||||
return [key, { count: candidates.length, byMission }] as const;
|
||||
}));
|
||||
return db.transaction(() => {
|
||||
const issues = new Set<string>();
|
||||
const selected: Array<{ qitem_id: string; destination_session: string; state: string }> = [];
|
||||
@@ -129,7 +146,20 @@ export function readScopedTaskTerminalView(db: Database.Database, view: string,
|
||||
}
|
||||
if (index.projectId && !belongsToProject(row.tags, index.projectId)) continue;
|
||||
const parsed = parseScopeTags(row.tags);
|
||||
if (parsed.slices.has(scope.name) || (scope.id && parsed.slices.has(scope.id))) selected.push(row);
|
||||
let here = false, elsewhere = false, unplaced = false;
|
||||
const mission = parsed.missions.size === 1 ? parsed.missions.values().next().value : undefined;
|
||||
for (const [key, claim] of claims) {
|
||||
if (!parsed.slices.has(key)) continue;
|
||||
if (claim.count === 1) { here = true; continue; }
|
||||
// A reused name/id requires exactly one mission tag and one indexed
|
||||
// claimant in that mission. Never assign it by first-match or absence.
|
||||
const placed = mission === undefined ? [] : claim.byMission.get(mission) ?? [];
|
||||
if (placed.length !== 1) { unplaced = true; continue; }
|
||||
if (placed[0]!.source === scope.source) here = true; else elsewhere = true;
|
||||
}
|
||||
if (unplaced || (here && elsewhere)) {
|
||||
issues.add(`Queue row '${row.qitem_id}' has unplaceable or contradictory colliding slice tags; membership is incomplete.`);
|
||||
} else if (here) selected.push(row);
|
||||
}
|
||||
selected.sort((a, b) => a.qitem_id.localeCompare(b.qitem_id));
|
||||
return projectTaskOwners({ kind: "slice", id: scope.id ?? scope.name, name: scope.title,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { mkdtempSync, readdirSync, readFileSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { TerminalService } from "../src/domain/terminal/terminal-service.js";
|
||||
@@ -30,7 +30,7 @@ function fixture() {
|
||||
const frames = new Map<string, ComposerSnapshot | null>();
|
||||
let transform = (view: ComposedView) => view;
|
||||
let lateFocus: { seat: string; reads: number } | undefined;
|
||||
let clock = 100, lostCreate = false, lostClose = false, readsFailed = false;
|
||||
let clock = 100, lostCreate = false, lostClose = false, readsFailed = false, hideViewers = false;
|
||||
const run = async (args: string[]) => {
|
||||
calls.push(args);
|
||||
const command = args.slice(0, 2).join(" ");
|
||||
@@ -48,7 +48,7 @@ function fixture() {
|
||||
if (args.length !== 2) throw new Error("usage: herdr workspace list");
|
||||
return JSON.stringify({ result: { workspaces: [{ workspace_id: "w1" }] } });
|
||||
}
|
||||
if (command === "pane list") return JSON.stringify({ result: { panes: [...panes.values()] } });
|
||||
if (command === "pane list") return JSON.stringify({ result: { panes: [...panes.values()].filter(p => !hideViewers || !p.cwd.includes("/viewers/")) } });
|
||||
if (command === "pane get") {
|
||||
const selected = panes.get(args[2]!); if (!selected) throw new Error("missing pane");
|
||||
return JSON.stringify({ result: { pane: selected } });
|
||||
@@ -89,8 +89,14 @@ function fixture() {
|
||||
return { type: "pane_focused" };
|
||||
} } });
|
||||
const provider = create();
|
||||
const follow = (seats = ["a", "b", "c"], controls = {}, complete = true) => provider.follow("qitem:task", transform(task(seats, complete)), { enabled: true, ...controls }, async () => true);
|
||||
return { follow, create, panes, calls, rpc, inputs, frames, advance: (ms: number) => { clock += ms; },
|
||||
const follow = (seats = ["a", "b", "c"], controls = {}, complete = true, current = async () => true) => provider.follow("qitem:task", transform(task(seats, complete)), { enabled: true, ...controls }, current);
|
||||
const journal = () => {
|
||||
const directory = path.join(root, readdirSync(root)[0]!, "viewers");
|
||||
return path.join(directory, readdirSync(directory).find(name => name.endsWith(".json"))!);
|
||||
};
|
||||
const exitViewer = (seat: string) => { [...panes.values()].find(p => p.argv.at(-1) === `$${seat}`)!.argv = ["/bin/bash"]; };
|
||||
return { follow, create, panes, calls, rpc, inputs, frames, exitViewer, journal,
|
||||
hideViewers: () => { hideViewers = true; }, advance: (ms: number) => { clock += ms; },
|
||||
loseCreate: () => { lostCreate = true; }, loseClose: () => { lostClose = true; }, failReads: () => { readsFailed = true; },
|
||||
changeTarget: () => { transform = view => ({ ...view, opened: view.opened.map(target => target.seat === "a"
|
||||
? { ...target, paneCommand: `${target.paneCommand} --changed`, attachArgv: [...target.attachArgv!.slice(0, 4), "replacement-a", ...target.attachArgv!.slice(5)] } : target) }); },
|
||||
@@ -99,6 +105,75 @@ function fixture() {
|
||||
}
|
||||
|
||||
describe("stable task-following workspace", () => {
|
||||
it("resumes a join paused before attach dispatch through its existing journal without changing drafts or focus", async () => {
|
||||
const f = fixture(); await f.follow(); f.inputs.set("b", "text"); f.focusSeat("b");
|
||||
const before = [...f.panes.values()].map(p => [p.pane_id, p.terminal_id]); const count = f.calls.length; let reads = 0;
|
||||
const paused = await f.follow(["a", "b", "c", "d"], {}, true, async () => ++reads !== 4);
|
||||
expect(paused.opened).toEqual(["a", "b", "c"]);
|
||||
const entry = JSON.parse(readFileSync(f.journal(), "utf8")).entries[JSON.stringify(["self", "d"])];
|
||||
expect(entry.phase).toBe("created"); expect(entry.pane).toBeTruthy();
|
||||
expect(f.calls.slice(count).filter(c => c[1] === "run")).toHaveLength(0);
|
||||
expect(await f.follow(["a", "b", "c", "d"])).toMatchObject({ ok: true, opened: ["a", "b", "c", "d"] });
|
||||
expect(await f.follow(["a", "b", "c", "d"])).toMatchObject({ ok: true });
|
||||
expect(f.calls.slice(count).filter(c => c[1] === "split")).toHaveLength(1);
|
||||
expect(f.calls.slice(count).filter(c => c[1] === "run")).toHaveLength(1);
|
||||
expect([...f.panes.values()].slice(0, 4).map(p => [p.pane_id, p.terminal_id])).toEqual(before);
|
||||
expect([...f.panes.values()].find(p => p.focused)?.argv.at(-1)).toBe("$b");
|
||||
expect(f.inputs.get("b")).toBe("text"); expect(f.rpc).toHaveLength(0);
|
||||
});
|
||||
it("an unconfirmed older viewer holds the whole view before a new join", async () => {
|
||||
const f = fixture(); await f.follow(); f.exitViewer("c"); const count = f.calls.length;
|
||||
expect(await f.follow(["a", "b", "c", "d"])).toMatchObject({ ok: false, opened: ["a", "b", "c"], following: { state: "retained" } });
|
||||
expect(f.calls.slice(count).some(c => ["run", "close", "split", "create"].includes(c[1]!))).toBe(false);
|
||||
});
|
||||
it("keeps an already dispatched attach unconfirmed with its original seat, phase and reason", async () => {
|
||||
const f = fixture(); await f.follow(); f.exitViewer("a"); const count = f.calls.length;
|
||||
const held = await f.follow();
|
||||
expect(held).toMatchObject({ ok: false, opened: ["a", "b", "c"] });
|
||||
expect(held.error).toContain("a:"); expect(held.error).toContain("starting");
|
||||
expect(held.error).toContain("The viewer process is not confirmed");
|
||||
await f.follow(); expect(f.calls.slice(count).some(c => ["run", "close", "split"].includes(c[1]!))).toBe(false);
|
||||
});
|
||||
it("a known refusal before split preserves its reason and allows a later join", async () => {
|
||||
const f = fixture(); await f.follow(); const count = f.calls.length; let reads = 0;
|
||||
const held = await f.follow(["a", "b", "c", "d"], {}, true, async () => ++reads !== 3);
|
||||
expect(held.opened).toEqual(["a", "b", "c"]);
|
||||
expect(held.following!.events.join("\n")).toContain("d: creating: Membership changed before the split.");
|
||||
expect(f.calls.slice(count).some(c => ["split", "run", "close"].includes(c[1]!))).toBe(false);
|
||||
expect(await f.follow(["a", "b", "c", "d"])).toMatchObject({ ok: true, opened: ["a", "b", "c", "d"] });
|
||||
expect(f.calls.slice(count).filter(c => c[1] === "split")).toHaveLength(1);
|
||||
});
|
||||
it("a known pre-split refusal retires its unused intent even when directory cleanup fails", async () => {
|
||||
const f = fixture(); await f.follow(); const count = f.calls.length; let reads = 0, retainedFile = "";
|
||||
const held = await f.follow(["a", "b", "c", "d"], {}, true, async () => {
|
||||
if (++reads !== 3) return true;
|
||||
const entry = JSON.parse(readFileSync(f.journal(), "utf8")).entries[JSON.stringify(["self", "d"])];
|
||||
retainedFile = path.join(entry.cwd, "retained-note"); writeFileSync(retainedFile, "keep this file");
|
||||
return false;
|
||||
});
|
||||
expect(held.following!.events.join("\n")).toContain("d: creating: Membership changed before the split.");
|
||||
expect(JSON.parse(readFileSync(f.journal(), "utf8")).entries[JSON.stringify(["self", "d"])]).toBeUndefined();
|
||||
expect(readFileSync(retainedFile, "utf8")).toBe("keep this file");
|
||||
expect(await f.follow(["a", "b", "c", "d"])).toMatchObject({ ok: true, opened: ["a", "b", "c", "d"] });
|
||||
expect(f.calls.slice(count).filter(c => c[1] === "split")).toHaveLength(1);
|
||||
});
|
||||
it("an uncertain dispatched split keeps its intent even when enumeration is empty", async () => {
|
||||
const f = fixture(); await f.follow(); f.loseCreate(); f.hideViewers(); const count = f.calls.length;
|
||||
const held = await f.follow(["a", "b", "c", "d"]);
|
||||
expect(held.following!.events.join("\n")).toContain("d: creating: Viewer effect is unconfirmed: lost create response");
|
||||
const before = readFileSync(f.journal(), "utf8");
|
||||
await f.follow(["a", "b", "c", "d"]);
|
||||
expect(readFileSync(f.journal(), "utf8")).toBe(before);
|
||||
expect(f.calls.slice(count).filter(c => c[1] === "split")).toHaveLength(1);
|
||||
expect(f.calls.slice(count).filter(c => c[1] === "run")).toHaveLength(0);
|
||||
});
|
||||
it("a changed terminal identity holds the whole view before joining another member", async () => {
|
||||
const f = fixture(); await f.follow();
|
||||
[...f.panes.values()].find(p => p.cwd.includes("/viewers/"))!.terminal_id = "other-generation";
|
||||
const count = f.calls.length;
|
||||
expect(await f.follow(["a", "b", "c", "d"])).toMatchObject({ ok: false, following: { state: "retained" } });
|
||||
expect(f.calls.slice(count).some(c => ["run", "split", "close"].includes(c[1]!))).toBe(false);
|
||||
});
|
||||
it.each(["", "\x1b[2mAsk Codex to do anything\x1b[22m"])("closes only a departed viewer when the real reader confirms its two-footer Codex composer is empty (%s)", async body => {
|
||||
const f = fixture(); f.frames.set("c", codexTwoRowComposer(body));
|
||||
await f.follow();
|
||||
|
||||
@@ -70,10 +70,10 @@ describe("authoritative scoped terminal membership", () => {
|
||||
f.add("unqualified", "other");
|
||||
expect(f.read()).toMatchObject({ task: { complete: true, members: [{ seat: "a", obligations: [{ qitemId: "ours" }] }] } });
|
||||
});
|
||||
it.each(["same-name", "same-id", "name-id"])("reports %s collisions instead of choosing the first scope", mode => {
|
||||
it.each(["same-name", "same-id", "name-id"])("reports bare %s collisions instead of choosing the first scope", mode => {
|
||||
const f = fixture();
|
||||
f.addSlice("another", mode === "same-name" ? "03-build" : mode === "name-id" ? "OPR.3" : "other", mode === "same-id" ? "OPR.3" : "different");
|
||||
expect(f.read()).toMatchObject({ code: "scope_ambiguous" });
|
||||
expect(f.read(mode === "same-name" ? "slice:03-build" : "slice:OPR.3")).toMatchObject({ code: "scope_ambiguous" });
|
||||
});
|
||||
it("honors SPEC precedence and exact Unicode, plus, internal-space and percent-literal ids", () => {
|
||||
const f = fixture(), dir = f.addSlice("another", "04-other", "révision + 50%", "Other scope");
|
||||
@@ -113,10 +113,10 @@ describe("authoritative scoped terminal membership", () => {
|
||||
});
|
||||
|
||||
|
||||
it("rejects a slice name claimed as another alias even when selected through its unique id", () => {
|
||||
it("keeps an unplaced inverse-alias row incomplete when selected through a unique id", () => {
|
||||
const f = fixture(); f.addSlice("other-mission", "other", "03-build");
|
||||
f.add("wrong-owner", "b", "pending", ["slice:03-build"]);
|
||||
expect(f.read("slice:OPR.3")).toMatchObject({ code: "scope_ambiguous" });
|
||||
expect(f.read("slice:OPR.3")).toMatchObject({ task: { complete: false, members: [] } });
|
||||
});
|
||||
|
||||
function cachedFixture() {
|
||||
@@ -151,3 +151,57 @@ it("rereads identities for an existing unwatched legacy root and after a scope e
|
||||
f.addSlice("another", "new");
|
||||
expect(f.resolver.resolve("slice:new")).toMatchObject({ task: { complete: true } });
|
||||
});
|
||||
|
||||
|
||||
describe("mission placement of colliding slice aliases", () => {
|
||||
it("follows repeated release template names by their unique IDs without mixing owners", () => {
|
||||
const f = fixture();
|
||||
f.addSlice("release-0.7.6", "01-regression-checks", "OPR.0.7.6.1");
|
||||
f.addSlice("release-0.6.13", "01-regression-checks", "OPR.0.6.13.1");
|
||||
f.add("current", "a", "pending", ["mission:release-0.7.6", "slice:01-regression-checks"]);
|
||||
f.add("older", "b", "blocked", ["mission:release-0.6.13", "slice:01-regression-checks"]);
|
||||
f.add("by-id", "a", "in-progress", ["slice:OPR.0.7.6.1"]);
|
||||
expect(f.read("slice:OPR.0.7.6.1")).toMatchObject({ task: { complete: true, members: [
|
||||
{ seat: "a", obligations: [{ qitemId: "by-id" }, { qitemId: "current" }] }] } });
|
||||
expect(f.read("slice:OPR.0.6.13.1")).toMatchObject({ task: { complete: true, members: [{ seat: "b" }] } });
|
||||
expect(f.read("slice:01-regression-checks")).toMatchObject({ code: "scope_ambiguous" });
|
||||
f.db.prepare("UPDATE queue_items SET state='done'").run();
|
||||
expect(f.read("slice:OPR.0.7.6.1")).toMatchObject({ task: { complete: true, members: [] } });
|
||||
});
|
||||
it.each(["id-name", "name-id", "id-id"])("places a %s alias only through its one indexed mission", mode => {
|
||||
const f = fixture();
|
||||
f.addSlice("other-mission", mode === "id-name" ? "OPR.3" : "other", mode === "name-id" ? "03-build" : mode === "id-id" ? "OPR.3" : "OTHER");
|
||||
const alias = mode === "name-id" ? "03-build" : "OPR.3";
|
||||
const selector = mode === "name-id" ? "slice:OPR.3" : "slice:03-build";
|
||||
f.add("ours", "a", "pending", ["mission:release", `slice:${alias}`]);
|
||||
f.add("theirs", "b", "pending", ["mission:other-mission", `slice:${alias}`]);
|
||||
expect(f.read(selector)).toMatchObject({ task: { complete: true, members: [{ seat: "a" }] } });
|
||||
expect(f.read(`slice:${alias}`)).toMatchObject({ code: "scope_ambiguous" });
|
||||
});
|
||||
it.each([
|
||||
{ label: "no mission", tags: [] },
|
||||
{ label: "contradictory missions", tags: ["mission:release", "mission:other-mission"] },
|
||||
{ label: "non-claimant mission", tags: ["mission:not-a-claimant"] },
|
||||
])("retains known owners and reports an unplaceable colliding row with $label", ({ tags }) => {
|
||||
const f = fixture(); f.addSlice("other-mission", "03-build", "OTHER");
|
||||
f.add("known", "a", "pending", ["slice:OPR.3"]);
|
||||
f.add("unplaced", "b", "blocked", ["slice:03-build", ...tags]);
|
||||
const result = f.read("slice:OPR.3"); if ("code" in result) throw new Error(result.error);
|
||||
expect(result.task).toMatchObject({ complete: false, members: [{ seat: "a" }] });
|
||||
expect(result.task.issues.join(" ")).toContain("unplaced");
|
||||
});
|
||||
it("does not choose between two same-mission claimants or contradictory alias placements", () => {
|
||||
const f = fixture(); f.addSlice("release", "other", "03-build");
|
||||
f.add("same-mission", "b", "pending", ["slice:03-build", "mission:release"]);
|
||||
expect(f.read("slice:OPR.3")).toMatchObject({ task: { complete: false, members: [] } });
|
||||
f.db.prepare("DELETE FROM queue_items").run();
|
||||
f.addSlice("other-mission", "03-build", "OTHER");
|
||||
f.add("contradictory", "b", "pending", ["slice:OPR.3", "slice:03-build", "mission:other-mission"]);
|
||||
expect(f.read("slice:OPR.3")).toMatchObject({ task: { complete: false, members: [] } });
|
||||
});
|
||||
it("does not invent a mission context for colliding flat legacy slices", () => {
|
||||
const f = fixture();
|
||||
const dir = path.join(f.root, "legacy"); mkdirSync(dir); writeFileSync(path.join(dir, "SPEC.md"), "---\nid: 03-build\n---\n");
|
||||
expect(f.read("slice:legacy")).toMatchObject({ code: "scope_ambiguous" });
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openrig/ui",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.6",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"release": "0.7.2",
|
||||
"archiveSha256": "42b4d4980c062f49f99db7e843a2ba96c0066a420cbaace381f435e22699d4c5",
|
||||
"release": "0.7.5",
|
||||
"archiveSha256": "22d67c2c7e0d0ab4a5ec9faac0a725d54f9ead2c0f71564985cf4ae240bb9754",
|
||||
"plugins": {
|
||||
"openrig-core": {
|
||||
"version": "0.1.19",
|
||||
"sha256": "21343eb7a36b0726a50372edc781b8e5b344c819a456499552f2560fb9712cb1"
|
||||
"version": "0.1.22",
|
||||
"sha256": "c8f53abddd1a4e5be22b8851c038e256dd25dddac81a9c979730d692e81cd948"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user