mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 17:31:56 -10:00
Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3de06efe4c | ||
|
|
c415da618d | ||
|
|
a8574f8094 | ||
|
|
30b8232181 | ||
|
|
ae04d252ba | ||
|
|
4147d7d0d8 | ||
|
|
2a17eef1de | ||
|
|
1c9d0076cf | ||
|
|
2add9baaf5 | ||
|
|
0964fb7bd4 | ||
|
|
e1dedbf531 | ||
|
|
faff36aa93 | ||
|
|
e806c4d3db | ||
|
|
5eaf0c1a2e | ||
|
|
0a91a8f0f1 | ||
|
|
1760d6e776 | ||
|
|
b40bc71ca6 | ||
|
|
4d7c50d514 | ||
|
|
1538231b1f | ||
|
|
44affadaa6 | ||
|
|
011b995835 | ||
|
|
ce0186c408 | ||
|
|
545a423489 | ||
|
|
d2c5bbdbd2 |
No files matched your search
+351
@@ -8,6 +8,357 @@ deprecations, and behavioral changes. Breaking changes are called out explicitly
|
||||
|
||||
---
|
||||
|
||||
## [0.7.10]
|
||||
|
||||
Published 2026-11-08 as `@openrig/[email protected]`, built from the commit tagged
|
||||
`v0.7.10` (`a226b1b6`).
|
||||
|
||||
OpenRig 0.7.10 is a patch for people running their own agent teams. After a
|
||||
managed compaction, a step held because OpenRig can't read the seat's input box
|
||||
now goes in after two idle polls instead of waiting with no end. Wakes and sends
|
||||
get through a seat pane left in more than one tmux mode, and `rig exec` hands a
|
||||
queue row's task to Claude Code or Codex as one finite run that outlives the
|
||||
command (not yet run with a real adapter). Installing through the rigs skill tries a user-writable npm prefix
|
||||
before a project-local install, and agent spec advisories now show at launch.
|
||||
There are fixes across refocus, routing, status and speed under load, many
|
||||
contributed by people using OpenRig. The OpenShell worker now does real work and
|
||||
exports results, but its cleanup still fails against OpenShell 0.1.6, so don't
|
||||
rely on cleanup or recreate yet: see [the OpenShell worker](#the-openshell-worker-in-0710).
|
||||
|
||||
### Before you upgrade
|
||||
|
||||
- **Restart the daemon after installing:**
|
||||
```bash
|
||||
npm install -g @openrig/cli@0.7.10
|
||||
rig daemon stop && rig daemon start
|
||||
```
|
||||
This release adds no migrations.
|
||||
- **The bundled openrig-core plugin advances from 0.1.25 to 0.1.26.** Its Claude
|
||||
hooks add a `PermissionRequest` hook that reports permission requests to seat
|
||||
status, and its refocus hook and the refocusing skill's trace script changed.
|
||||
Managed refresh preserves replaced files under
|
||||
`$OPENRIG_HOME/backups/managed-refresh/<id>/`; reapply local edits you still
|
||||
need from that copy. ([#2615](https://github.com/mvschwarz/openrig/pull/2615), [#2562](https://github.com/mvschwarz/openrig/pull/2562), [#2582](https://github.com/mvschwarz/openrig/pull/2582), [#2587](https://github.com/mvschwarz/openrig/pull/2587))
|
||||
- **Check your agent specs:** `rig agent validate <path>` now prints each
|
||||
advisory. A profile's `uses.<category>` written as a single value now selects
|
||||
that resource instead of being dropped, so a member using that profile fails to
|
||||
resolve if the resource pool doesn't hold it (see Behaviour that changed).
|
||||
([#2585](https://github.com/mvschwarz/openrig/pull/2585), [#2606](https://github.com/mvschwarz/openrig/pull/2606))
|
||||
- **If you run an OpenShell environment, rebuild its guest image** with this
|
||||
release's `guest.py` (`daemon/assets/openshell/README.md` describes the build).
|
||||
The CA repair below is in that file, which the image carries. ([#2602](https://github.com/mvschwarz/openrig/pull/2602))
|
||||
|
||||
### Finite runs: `rig exec`
|
||||
|
||||
- **`rig exec` runs one turn of a queue row's task on Claude Code or Codex**
|
||||
through its maintained ACP adapter, in a connector that outlives the command.
|
||||
`rig exec harnesses` shows the versions installed,
|
||||
`rig exec run <row> --harness claude` (or `codex`) starts it (`--plan` starts
|
||||
nothing, `--wait` waits), and `rig exec status` and `rig exec cancel` follow
|
||||
it. ([#2597](https://github.com/mvschwarz/openrig/pull/2597))
|
||||
- **One run per row, and the row stays open.** Running it again returns the
|
||||
row's run instead of sending the task twice; `--again` starts another turn once
|
||||
it has ended. A run whose connector died without recording an outcome reads
|
||||
`interrupted`, outcome unknown. The outcome is the agent's report, and
|
||||
accepting the work stays with you. `--wait` exits 0 for `completed`, 2 for any
|
||||
other outcome and 3 when you stop waiting; a Ctrl-C there stops the wait, not
|
||||
the run. ([#2597](https://github.com/mvschwarz/openrig/pull/2597), [#2601](https://github.com/mvschwarz/openrig/pull/2601))
|
||||
- **An ACP run's outcome note,** `rig acp run`'s included, carries the usage and
|
||||
cost the agent reported, each with its source, or unknown. ([#2597](https://github.com/mvschwarz/openrig/pull/2597))
|
||||
- **`docs/reference/acp-agents.md`** covers both adapters
|
||||
(`@agentclientprotocol/claude-agent-acp` 0.101.0 and
|
||||
`@agentclientprotocol/codex-acp` 2.5.0): installing them, which native
|
||||
versions run, and how their permissions, refusals and cancels differ. ([#2596](https://github.com/mvschwarz/openrig/pull/2596))
|
||||
|
||||
### The OpenShell worker in 0.7.10
|
||||
|
||||
**The OpenShell worker hasn't run end to end on 0.7.10. Don't rely on this path
|
||||
yet.**
|
||||
|
||||
0.7.10 changes the two things our release testing on 0.7.9 found. Tested against
|
||||
OpenShell 0.1.6, the first fix holds and the second doesn't:
|
||||
- **The guest's CA settings reach its Claude.** The guest launcher built
|
||||
Claude's environment from scratch with only the provider settings, so a CA the
|
||||
OpenShell exec supplied never arrived, and API calls failed with
|
||||
`SELF_SIGNED_CERT_IN_CHAIN`. It now also passes `NODE_EXTRA_CA_CERTS`,
|
||||
`SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE` and `CURL_CA_BUNDLE`, each only when the
|
||||
exec supplies it. Nothing else is copied, and nothing turns TLS checks off.
|
||||
With OpenShell 0.1.6 the guest now authenticates, does useful work, makes a
|
||||
verified export and completes its task.
|
||||
Rebuild the guest image to get it (above). ([#2602](https://github.com/mvschwarz/openrig/pull/2602))
|
||||
- **Deleting an owned environment reads Podman's inventory.** The cleanup step
|
||||
asked for routes Podman serves only under a version prefix, misread the "Not
|
||||
Found" reply, and never sent the delete. It now uses routes Podman serves
|
||||
without one, and a volume counts as gone only when one complete volume listing
|
||||
leaves it out. Any other answer stops as `environment_cleanup_unconfirmed`,
|
||||
with the HTTP status. The maintained cleanup still fails against OpenShell
|
||||
0.1.6, which doesn't mount a workspace volume (see Known limits). ([#2603](https://github.com/mvschwarz/openrig/pull/2603))
|
||||
|
||||
An exact restore hasn't been shown: recreate and restore can't run while the
|
||||
cleanup fails.
|
||||
|
||||
### Compaction, delivery and terminals
|
||||
|
||||
- **A held post-compaction step now goes in.** Each step after a managed
|
||||
compaction (the turn boundary, the restore request, the audit) went only into
|
||||
an input box that read empty, so a box OpenRig couldn't read held the restore
|
||||
request on every poll, and the seat sat idle without its restore. Now, after
|
||||
two idle polls in a row that can't read the box, the next poll sends the step,
|
||||
and `rig compact <seat> --state` and `rig seat status` say it went in over a
|
||||
box OpenRig couldn't read, never that the box was empty. Some cases still hold
|
||||
(see Known limits). ([#2576](https://github.com/mvschwarz/openrig/pull/2576), refs [#2575](https://github.com/mvschwarz/openrig/issues/2575))
|
||||
- **Wakes and sends leave every tmux mode.** A pane scrolled into copy mode, with
|
||||
run-shell output, a chooser, customize or clock mode on top, kept a mode after
|
||||
one cancel, and the send was refused until someone left it by hand. Delivery
|
||||
now runs tmux's `copy-mode -q`, which leaves them all, with a single cancel
|
||||
where that fails (tmux before 3.1a), and a refusal names why leaving failed.
|
||||
([#2594](https://github.com/mvschwarz/openrig/pull/2594))
|
||||
- **A scrolled pane with a stale record no longer blocks delivery.** tmux
|
||||
reuses pane IDs after a server restart, so a stale binding, from another rig
|
||||
for example, can name the same pane as a live seat. Leaving the mode was
|
||||
addressed by that raw pane ID, which then matched two seats, so it was
|
||||
refused before anything was written and the send failed with nothing sent.
|
||||
Leaving the mode now goes through the seat's address, as the text and Enter
|
||||
writes already did. The stale binding itself is unchanged.
|
||||
([#2644](https://github.com/mvschwarz/openrig/pull/2644))
|
||||
- **A writable rig, pod or seat view sizes the pane it shows,** instead of
|
||||
keeping an older attached client's size. Read-only views are unchanged. ([#2614](https://github.com/mvschwarz/openrig/pull/2614))
|
||||
- **Herdr views kept across an upgrade keep working,** on Linux Herdr 0.9.5 /
|
||||
protocol 22. A viewer or follow view kept from 0.7.5 through 0.7.9 now counts
|
||||
as the same attachment when only OpenRig's tile script changed, and moves to
|
||||
the current command, so the viewer stays confirmed and the follow view goes on
|
||||
adding and closing tiles. A kept viewer that's already running keeps its
|
||||
earlier sizing for as long as it runs, its own re-attaches included; writable
|
||||
viewers opened after the upgrade size their pane. A different session, tmux,
|
||||
server or read-only setting still holds the view, as before. Checked against
|
||||
the scripts those releases used, not yet with a live Herdr. It follows
|
||||
@natefaerber's approach in
|
||||
[#2467](https://github.com/mvschwarz/openrig/pull/2467). ([#2634](https://github.com/mvschwarz/openrig/pull/2634))
|
||||
|
||||
### Coordination and status
|
||||
|
||||
- **Routing and receipts:**
|
||||
- a send to `<seat>@<rig>@<this host's ID>` goes to this host even when a
|
||||
remote host is selected;
|
||||
- `rig broadcast` prints `UNCONFIRMED`, not `FAILED`, for a recipient its text
|
||||
or Enter may have reached, and points to `rig capture` before any resend;
|
||||
- `rig seat stop`, `clean` and in-place restart, when they refuse a seat
|
||||
handed over before 0.7.9, say why and no longer suggest `rig unclaim`;
|
||||
- `rig roster show` and `find` read a seat's activity as unknown
|
||||
("Conflicting activity observations") when two routes to it disagree;
|
||||
- `rig seat set-runtime` finds a pod seat's authored recap.
|
||||
([#2600](https://github.com/mvschwarz/openrig/pull/2600), [#2572](https://github.com/mvschwarz/openrig/pull/2572), [#2574](https://github.com/mvschwarz/openrig/pull/2574), [#2593](https://github.com/mvschwarz/openrig/pull/2593))
|
||||
- **Refocus:** unresolved typed work, and a `rig queue whoami` that timed out,
|
||||
print a named trace gap with project-level orientation, labelled as not
|
||||
evidence of a current mission, instead of counting as a refocus failure.
|
||||
([#2582](https://github.com/mvschwarz/openrig/pull/2582), [#2587](https://github.com/mvschwarz/openrig/pull/2587))
|
||||
- **Updates to people:** the stuck sweep no longer files an informational update
|
||||
to a registered person as an unclaimed obligation, unless its quiet-hours hold
|
||||
was orphaned. ([#2586](https://github.com/mvschwarz/openrig/pull/2586))
|
||||
- **Slack:** `rig slack status` shows relay attempts to another host still
|
||||
awaiting a response. It's a count for the current connector at that moment, so
|
||||
zero doesn't prove nothing is in flight. ([#2569](https://github.com/mvschwarz/openrig/pull/2569))
|
||||
- **Permission requests in seat status:** `rig seat status` shows a Claude
|
||||
seat's latest permission request as openrig-core's hook observed it, with
|
||||
Claude's own suggested rules labelled as suggestions. Nothing is applied,
|
||||
approved or stored. ([#2562](https://github.com/mvschwarz/openrig/pull/2562))
|
||||
|
||||
### Install, specs and errors
|
||||
|
||||
- **The rigs skill's install step,** when npm's global prefix isn't writable,
|
||||
now has the agent point npm's prefix at a folder the person owns whose `bin` is
|
||||
on their login PATH, or use a version manager's Node, saying first what that
|
||||
changes, and install inside the project only if neither works. ([#2611](https://github.com/mvschwarz/openrig/pull/2611))
|
||||
- **Plain `rig` works in managed Codex seats however OpenRig was installed,**
|
||||
with a Codex that supports `allow_login_shell` (checked in Codex's source at
|
||||
0.162.1 and 0.166); not yet run with a live Codex seat. OpenRig now passes Codex `-c allow_login_shell=false`, so tool commands run in
|
||||
a non-login shell and inherit the environment their pane's shell set up,
|
||||
OpenRig's command folder included. To defer to Codex's own setting instead,
|
||||
run `rig config set launch.codex_login_shell true`; it applies at each seat's
|
||||
next launch, and a restart in place keeps the running choice. ([#2613](https://github.com/mvschwarz/openrig/pull/2613))
|
||||
- **Agent spec advisories show where you'll see them:** `rig up`, a fresh restore
|
||||
and a first-start retry report each as a launch warning naming the member,
|
||||
spec and path, and `rig agent validate` prints them whether or not the spec is
|
||||
valid. They never stop a launch. ([#2606](https://github.com/mvschwarz/openrig/pull/2606))
|
||||
- **An unknown key under a profile's `uses`,** such as a typo,
|
||||
gets an advisory instead of being ignored silently. ([#2618](https://github.com/mvschwarz/openrig/pull/2618))
|
||||
- **Clearer errors:**
|
||||
- a rejected terminal-token request says "Terminal auth failed" and names
|
||||
`OPENRIG_TERMINAL_BEARER_TOKEN`, or `OPENRIG_AUTH_BEARER_TOKEN` when
|
||||
terminal auth falls back to it;
|
||||
- the TUI shows the daemon's own message when it refuses a launch;
|
||||
- `rig doctor` gives up on its cmux status and `--spec` topology reads after 2
|
||||
seconds each, so a listener that never answers can't hold it.
|
||||
([#2577](https://github.com/mvschwarz/openrig/pull/2577), [#2556](https://github.com/mvschwarz/openrig/pull/2556), [#2564](https://github.com/mvschwarz/openrig/pull/2564))
|
||||
- **Docs:** a Codex config fragment can't install a named Codex profile, with
|
||||
how to keep a profile with full access or a `codex_permissions` envelope; and
|
||||
a Claude seat that selects openrig-core loads its skills with the plugin.
|
||||
([#2604](https://github.com/mvschwarz/openrig/pull/2604), [#2584](https://github.com/mvschwarz/openrig/pull/2584))
|
||||
|
||||
### Speed under load
|
||||
|
||||
- **The execution view** (`GET /api/views/execution`, behind the TUI's execution
|
||||
view and `rig view show execution`) reads the review registry once per request
|
||||
instead of once per slice, and yields to the event loop while it does. Its
|
||||
other synchronous steps are unchanged. ([#2578](https://github.com/mvschwarz/openrig/pull/2578))
|
||||
- **A queue listing** looks recovery rows up through one index per listing
|
||||
instead of a scan for each listed row. It hasn't been measured on a live
|
||||
daemon. ([#2588](https://github.com/mvschwarz/openrig/pull/2588))
|
||||
- **The event notification drain** reads pages of at most 500 rows instead of
|
||||
the whole remaining tail. It bounds rows per read, not bytes, and still runs to
|
||||
the end synchronously; no latency change on a live install is claimed.
|
||||
([#2610](https://github.com/mvschwarz/openrig/pull/2610))
|
||||
|
||||
### For runner authors and integrators
|
||||
|
||||
- **The runner contract says what has run with a real runner.**
|
||||
`docs/reference/harness-runner-contract.md` and the dsh example say what has
|
||||
run natively, at what scope, and what hasn't; any other runner, provider or
|
||||
path stays unqualified until shown. The 0.7.8 Known limits line below that said
|
||||
no harness had run end to end is struck and corrected in place.
|
||||
([#2592](https://github.com/mvschwarz/openrig/pull/2592), [#2595](https://github.com/mvschwarz/openrig/pull/2595))
|
||||
- **Telemetry:** `docs/reference/telemetry.md` says OpenRig's guarantee ends at
|
||||
its finite reads, versioned by `schemaVersion` 1, and links the separately
|
||||
maintained openrig-telemetry exporter, which OpenRig doesn't test or
|
||||
guarantee. ([#2589](https://github.com/mvschwarz/openrig/pull/2589))
|
||||
- **Also:** the real-tmux transport test waits for the shell's prompt before
|
||||
typing. ([#2559](https://github.com/mvschwarz/openrig/pull/2559))
|
||||
|
||||
### Behaviour that changed
|
||||
|
||||
- **Managed Codex seats run tool commands in a non-login shell** (above).
|
||||
Inherited setup isn't re-run for each command, so setup that lives only in
|
||||
login or rc files (such as `~/.bashrc` lines your login file doesn't source, or
|
||||
a tmux `default-command` that doesn't start a login shell), shell functions and
|
||||
aliases may be missing from tool commands. A model request for `login: true`
|
||||
gets Codex's own refusal, "login shell is disabled by config". With
|
||||
`launch.codex_login_shell` on, OpenRig omits its override rather than forcing
|
||||
login shells, so a login file that sets `PATH` outright can then hide `rig`.
|
||||
([#2613](https://github.com/mvschwarz/openrig/pull/2613))
|
||||
- **A held post-compaction step goes in after two idle polls that can't read the
|
||||
input box** (above). A draft OpenRig can't recognise in that box may receive
|
||||
the restore text. ([#2576](https://github.com/mvschwarz/openrig/pull/2576))
|
||||
- **A send or wake leaves every tmux mode in a seat's pane,** including a
|
||||
chooser, customize or clock mode a person has open, where before it was
|
||||
refused. ([#2594](https://github.com/mvschwarz/openrig/pull/2594))
|
||||
- **A send to `<seat>@<rig>@<this host's ID>`** goes to this host, not the
|
||||
selected remote host. ([#2600](https://github.com/mvschwarz/openrig/pull/2600))
|
||||
- **A profile's `uses.<category>` written as a single value** (for example
|
||||
`uses.skills: review`) used to validate and then be dropped. It now counts as a
|
||||
one-item list, with an advisory suggesting list syntax: a member using that
|
||||
profile gets the resource, or fails to resolve with "not found in resource
|
||||
pool" when the pool doesn't hold it. Other malformed `uses` shapes are
|
||||
advisories, not new errors. ([#2585](https://github.com/mvschwarz/openrig/pull/2585))
|
||||
- **`rig up` can print new warnings,** one per agent spec advisory, and
|
||||
`rig agent validate` prints them. ([#2606](https://github.com/mvschwarz/openrig/pull/2606))
|
||||
- **`rig broadcast`** prints `UNCONFIRMED` for a recipient it may have reached;
|
||||
its exit code and `N/M delivered` count are unchanged. ([#2572](https://github.com/mvschwarz/openrig/pull/2572))
|
||||
- **`rig roster show` and `find`** read conflicting activity as unknown.
|
||||
([#2574](https://github.com/mvschwarz/openrig/pull/2574))
|
||||
- **The refocus hook** no longer counts unresolved typed work or a timed-out
|
||||
`rig queue whoami` as a failure, and a timed-out refocus clears the pending
|
||||
post-compaction retry. ([#2582](https://github.com/mvschwarz/openrig/pull/2582), [#2587](https://github.com/mvschwarz/openrig/pull/2587))
|
||||
- **The stuck sweep** no longer flags informational updates to registered people
|
||||
as unclaimed obligations. ([#2586](https://github.com/mvschwarz/openrig/pull/2586))
|
||||
- **A rejected terminal-token request** gets "Terminal auth failed" instead of
|
||||
"Mission Control auth failed"; its status code and fields are unchanged.
|
||||
([#2577](https://github.com/mvschwarz/openrig/pull/2577))
|
||||
|
||||
### Known limits
|
||||
|
||||
- **OpenShell:** maintained cleanup (`delete-owned`) fails against OpenShell
|
||||
0.1.6, which doesn't mount a workspace volume, so recreate and restore can't
|
||||
run (above). A cleanup error that's thrown still omits its stage and whether
|
||||
its delete was sent, and the inventory is a snapshot, not an atomic read.
|
||||
0.7.8's requirements, cost note
|
||||
and cleanup limits still apply, including "Never grant the guest access to the
|
||||
daemon's main API port (normally 7433)." A same-generation reconciled launch
|
||||
opens no viewer, so run `rig environment connect` yourself; non-text startup
|
||||
actions aren't supported on environment seats. ([#2603](https://github.com/mvschwarz/openrig/pull/2603))
|
||||
- **`rig exec`:** no real adapter has run under it yet; one run per row,
|
||||
detached ownership and reconciling were checked against a stand-in agent.
|
||||
Concurrent runs, long or background tasks, recovery across a reboot, MCP
|
||||
servers and Windows aren't covered. A cancel is acknowledged, not settled, and
|
||||
says nothing about further spend. Cancel an exec run with `rig exec cancel`:
|
||||
`rig acp cancel` given its ID while it's still requested can signal the wrong
|
||||
process. A Ctrl-C just before the connector is launched isn't caught by
|
||||
`--wait`.
|
||||
([#2597](https://github.com/mvschwarz/openrig/pull/2597), [#2601](https://github.com/mvschwarz/openrig/pull/2601))
|
||||
- **After a managed compaction, a step can still hold:** when the input box
|
||||
reads as typed, with or without a suggestion; on a seat whose draft-aware
|
||||
delivery or typing guard holds OpenRig's input; and after a `/compact` whose
|
||||
result was lost. To take over by hand, run `rig compact <seat> --cancel`, then
|
||||
send the seat a restore request naming its newest restore map, its restore
|
||||
marker and its packet; a step already being pasted can still land. Which
|
||||
reading the reported seat's box gave isn't established, so the report stays
|
||||
open. ([#2575](https://github.com/mvschwarz/openrig/issues/2575))
|
||||
- **Draft-aware delivery to Claude:** on 0.7.8 with Claude Code 2.1.319,
|
||||
deliveries to idle Claude agents, queue wakes included, were reported held at
|
||||
an empty prompt when a narrow pane showed
|
||||
`✔ Update installed · Restart to update` on its own line. 0.7.10 still doesn't
|
||||
recognize that bare banner, though it recognizes `/rc` and the full versioned
|
||||
banner. A message the draft-aware delivery path holds isn't replayed
|
||||
automatically by that path. ([#2605](https://github.com/mvschwarz/openrig/issues/2605))
|
||||
- **Grant-service assistants aren't woken:** "an item stays queued until its
|
||||
receiver claims it."
|
||||
- **Open updates for a person** read a 1,001-row window, so a page can come back
|
||||
short, marked `truncated`.
|
||||
- **Slack:** a private channel needs the documented route, which we don't test,
|
||||
and direct messages aren't supported. The channel and people reads, and a
|
||||
click on a re-parked ask, haven't been run against a live Slack workspace.
|
||||
Redaction of the bot token from a transport error can still leave a malformed
|
||||
token fragment shorter than six characters, so review an error before sharing
|
||||
it. ([#2503](https://github.com/mvschwarz/openrig/pull/2503))
|
||||
- **SSH host status** reads `unknown` behind a ProxyJump or ProxyCommand; that
|
||||
isn't proof the connection is broken. ([#2488](https://github.com/mvschwarz/openrig/pull/2488))
|
||||
- **Shared asks:** "a click's option written before its answer row … can stand
|
||||
beside or inside the closer's answer without the first clicker's authorship."
|
||||
- **Existing seats** last launched fresh before 0.7.9 pick up Codex launch
|
||||
instructions and the single skill load at their next fresh launch. A
|
||||
`CLAUDE.md` or `AGENTS.md` that already grew isn't trimmed.
|
||||
- **Permit records** last as long as stream retention keeps them. A permit a
|
||||
runner makes on its own, when it can't reach OpenRig, is in its pane only. For
|
||||
an ask opened before 0.7.9, a record may omit the option's kind. ([#2361](https://github.com/mvschwarz/openrig/pull/2361))
|
||||
- **Permission requests in seat status** show only the latest one, held in
|
||||
memory for five minutes and lost on a daemon restart. Claude's suggested rules
|
||||
can contain sensitive values, and `rig seat status` and both seat-status routes
|
||||
can return them. ([#2562](https://github.com/mvschwarz/openrig/pull/2562))
|
||||
- **Agent spec advisories** don't show for a native resume that continues a
|
||||
saved conversation, since it doesn't reload the spec. ([#2606](https://github.com/mvschwarz/openrig/pull/2606))
|
||||
- **Seats handed over before 0.7.9** keep their adopted origin until a later
|
||||
launched handover or managed launch records a new session, so until then
|
||||
`rig seat stop`, `restart` and `clean` refuse them; the refusal now says so.
|
||||
([#2440](https://github.com/mvschwarz/openrig/pull/2440), [#2572](https://github.com/mvschwarz/openrig/pull/2572))
|
||||
- **Codex hooks after an upgrade:** Codex sessions kept running across the
|
||||
upgrade to 0.7.9 showed "Hook failed — plugin contents changed; hook approval
|
||||
required" until relaunched; in that upgrade test, one seat restarted with
|
||||
OpenRig's managed restart showed no review afterwards. 0.7.10 changes
|
||||
openrig-core's files again.
|
||||
- **The runner contract** has run natively only at the scope its status section
|
||||
states (`docs/reference/harness-runner-contract.md`); any other runner,
|
||||
provider or path is unqualified until shown.
|
||||
- **Fixed or changed from 0.7.9's limits:** the OpenShell worker's CA settings
|
||||
(fixed, and shown working with OpenShell 0.1.6) and owned-delete inventory
|
||||
(changed, but the cleanup still fails against 0.1.6; see above), a
|
||||
restore request held after a managed compaction, `rig broadcast`'s `FAILED`
|
||||
for an uncertain recipient, the refusal for a seat handed over before 0.7.9,
|
||||
where to read what has run under the runner contract, and Codex seats'
|
||||
`rig: command not found` (now a non-login shell by default; see Behaviour that
|
||||
changed).
|
||||
- **The 0.7.8 limits that 0.7.9 didn't fix or change, and the 0.7.7 and 0.7.6
|
||||
limits, below still apply.**
|
||||
|
||||
### Credits
|
||||
|
||||
Thanks to @dajiaohuang, @Aummadour, @billbenetti, @smota, @Nikhila-Dhevi-R and
|
||||
@Vaishnavi220506 for merged contributions, and to @AlephCasara, whose work on a
|
||||
fork pointed us to the unpaged event notification drain
|
||||
([#2610](https://github.com/mvschwarz/openrig/pull/2610)), and to @natefaerber, whose
|
||||
approach in [#2467](https://github.com/mvschwarz/openrig/pull/2467) the Herdr view fix follows. Thanks to
|
||||
@billbenetti, @KM199, @ziadeh, @dajiaohuang, @jajabong, @george661 and
|
||||
@aanestesia for reports carried into this release, and to @smota for the
|
||||
OpenTelemetry discussion behind [#2589](https://github.com/mvschwarz/openrig/pull/2589).
|
||||
|
||||
## [0.7.9]
|
||||
|
||||
Published 2026-11-06 as `@openrig/[email protected]`, built from the commit tagged
|
||||
|
||||
+2
-2
@@ -6,9 +6,9 @@ This isn't a schedule or a promise: we release often, and an item ships when a g
|
||||
To see what's being worked on, by release and theme, open the [OpenRig roadmap board](https://github.com/users/mvschwarz/projects/1).
|
||||
Issues labelled `good first issue` are also listed on the [contribute page](https://github.com/mvschwarz/openrig/contribute).
|
||||
|
||||
## Released in 0.7.9
|
||||
## Released in 0.7.10
|
||||
|
||||
[0.7.9 is published](https://github.com/mvschwarz/openrig/releases/tag/v0.7.9): it follows through on 0.7.8. A person's typed words on a runner seat's permission ask now reach the agent, each permit OpenRig grants a runner by default is recorded, and a runner can keep a long, quiet turn reading working. See its release notes for what shipped, known issues, upgrade instructions and contributor credits.
|
||||
[0.7.10 is published](https://github.com/mvschwarz/openrig/releases/tag/v0.7.10): it is a patch for people running their own agent teams. A step held after a managed compaction now goes in instead of waiting forever. See its release notes for what shipped, known issues, upgrade instructions and contributor credits.
|
||||
|
||||
## How to help
|
||||
|
||||
|
||||
@@ -160,8 +160,9 @@ Restore behavior, each point checked in `restore-orchestrator.ts`:
|
||||
`:780`; if `status === "restoring"` the node is skipped with a warning and
|
||||
reported as `fresh`, `:783–785`; `degraded` only adds a warning).
|
||||
- Reuses the seat's durably bound tmux session name when the prior binding
|
||||
has a usable one (`:840`), and otherwise falls back to the derived name
|
||||
with a warning (`:869`).
|
||||
has a usable one, and otherwise falls back to the derived name. A missing
|
||||
binding after the latest session's recorded intentional exit is quiet;
|
||||
unclean/legacy missing bindings and incomplete or invalid binding rows still warn.
|
||||
- Replays restore-safe startup using persisted startup context;
|
||||
prefilters missing optional artifacts into warnings; **hard-fails a node if
|
||||
a required startup file is missing** (`:1161` — status `"failed"`, error
|
||||
|
||||
@@ -84,6 +84,15 @@ Diagnostic-only rows, moved sources, and versions retained for unfinished
|
||||
work have separate paths in `readThrough`, `writeDiagnostic`, and
|
||||
`isPinnedByUnfinishedWork`.
|
||||
|
||||
Only instantiate (and `rig workflow run`, which posts to the same route) and
|
||||
the workflows-folder scan register specs through `readThrough`.
|
||||
`WorkflowRuntime.validate` (`rig workflow validate`, `POST /validate`) reads
|
||||
the file with `WorkflowSpecCache.readFromDisk`: the same file and parse
|
||||
checks, in memory, with no `workflow_specs` write. A spec that was only
|
||||
validated from outside the workflows folder is not listed by
|
||||
`rig workflow specs` and is not found by its bare name; its path still
|
||||
instantiates (#2726).
|
||||
|
||||
## Durable state
|
||||
|
||||
The migration files record the storage evolution; they are not a count of
|
||||
|
||||
@@ -188,7 +188,7 @@ the sections that were not read are `null`.
|
||||
|---|---|---|
|
||||
| `rig gateway` | — | — |
|
||||
| `rig gateway human` | — | — |
|
||||
| `rig gateway human add <entityId>` | — | `--display-name <name>` **required**<br>`--binding <kind:connectorRef:secretsRef:role[:handle=<id>]>` **required** (repeatable)<br>`--delivery-class <A\|B\|C\|D>` **required**<br>`--away`<br>`--replace`<br>`--reason <reason>`<br>`--actor <actor>`<br>`--organization <id>`<br>`--group <id>` (repeatable)<br>`--policy <text>`<br>`--policy-file <path>`<br>`--default` |
|
||||
| `rig gateway human add <entityId>` | — | `--display-name <name>` **required**<br>`--binding <kind:connectorRef:secretsRef:role[:handle=<id>]>` (repeatable; without one, the person is reached in the Feed and Steering only)<br>`--delivery-class <A\|B\|C\|D>` **required**<br>`--away`<br>`--replace`<br>`--reason <reason>`<br>`--actor <actor>`<br>`--organization <id>`<br>`--group <id>` (repeatable)<br>`--policy <text>`<br>`--policy-file <path>`<br>`--default` |
|
||||
| `rig gateway human list` | — | `--json` |
|
||||
| `rig gateway human quiet` | — | — |
|
||||
| `rig gateway human quiet set <entityId>` | — | `--daily <window>`<br>`--until <time>`<br>`--tz <zone>`<br>`--actor <actor>`<br>`--json` |
|
||||
|
||||
@@ -816,6 +816,19 @@ option, the seat's agent spec for the target runtime, or the runtime's own
|
||||
default), what is cleared, and the costs below. Nothing changes until you run it
|
||||
without `--dry-run`.
|
||||
|
||||
Use `--runtime pi` to move a Claude Code or Codex seat to Pi; use `--runtime
|
||||
claude-code` or `--runtime codex` to move back. Pi uses a fresh managed state
|
||||
directory. Select a prepared Pi account with `--account-home /absolute/path`
|
||||
(the directory must contain readable `auth.json`), or use the target's configured
|
||||
login. A source account override or Claude token is cleared only at commit;
|
||||
credentials are never converted between runtimes. Pi-to-Pi account changes keep
|
||||
Pi settings, but cross-runtime switches resolve settings afresh for the target.
|
||||
|
||||
A configured `launch_wrapper` carries across runtime switches among Pi, Codex
|
||||
and Claude Code. It must support each successor executable and preserve that
|
||||
runtime's credential environment. A wrapped Pi seat cannot switch to Claude
|
||||
Code; set `launch_wrapper: []` in the authored spec and re-apply it first.
|
||||
|
||||
The new runtime takes over before the old one stops. Its successor starts beside
|
||||
the current occupant, reads a handover (the seat's recap, its open rows and the
|
||||
predecessor's screen), and confirms it with `rig seat set-runtime <seat>
|
||||
@@ -847,7 +860,7 @@ What it costs:
|
||||
again afterwards to reach whoever holds the seat.
|
||||
- **Leftover files after a failed switch.** The target runtime's prepared seat
|
||||
storage and projections can remain.
|
||||
- **Codex and Claude Code, single-pane seats.** Startup slash commands aren't
|
||||
- **Codex, Claude Code and Pi, single-pane seats.** Startup slash commands aren't
|
||||
run by a switch; the dry run lists them.
|
||||
|
||||
## Stop your teams
|
||||
|
||||
@@ -129,6 +129,10 @@ and nothing else is offered:
|
||||
- It reads the items it sent and the items addressed to it from those seats, never the team's internal items.
|
||||
- With `queue`/`claim` and `queue`/`update`, it claims and closes only items addressed to its own seat that it can read,
|
||||
and waits (blocked) only on an item it names and can read. Its answers hide what reading the item hides.
|
||||
- **A first task without a seat address:** with `--task-destination <member@rig>` on the same `rig mcp serve`, a
|
||||
`rig_queue_create` that leaves out `destination` goes to that receiver. The operator sets it; a caller can't. It must be
|
||||
exactly one existing local node within that request's grant, or nothing is written. An explicit `destination` works as
|
||||
before.
|
||||
- **Two assistants together:** a grant with `team`/`list` sees its rigs' seats with `rig_team`, and may address and hear from
|
||||
another connected assistant's seat. A grant without it never reaches a seat in the service's caller form
|
||||
(`cus-<id>-<connection>@<rig>`), so an existing connection keeps the reach it had.
|
||||
@@ -148,7 +152,8 @@ pair, the person's assistant can read the team's questions waiting on them and s
|
||||
Steering. The caller's seat is never taken for a person: without `person`, the `question` pair offers nothing, and an
|
||||
unusable `person` withholds only these two tools while the grant's other tools keep working.
|
||||
|
||||
- **Register the person** on the instance, once: `rig gateway human add <entity_id> --display-name "<name>" --default`.
|
||||
- **Register the person** on the instance, once:
|
||||
`rig gateway human add <entity_id> --display-name "<name>" --delivery-class B --default`.
|
||||
Without `--binding`, they're asked in the Feed and on Steering only. As the default person, today's asks addressed to no
|
||||
registered person (such as `human@host`) are theirs too.
|
||||
- **`rig_question_list`** shows what Steering shows that person as answerable: an ask addressed to them, or a row parked on
|
||||
|
||||
@@ -587,9 +587,13 @@ rig provider switch --seat pi-worker@my-rig --to-account work \
|
||||
rig provider switch --seat pi-worker@my-rig --clear-account-home --reason "Return to configured login"
|
||||
```
|
||||
|
||||
When switching into Pi with `--clear-account-home`, OpenRig restores the seat's original Pi login only if no effective member or rig Pi account home is configured. A member's `PI_CODING_AGENT_DIR` shadows the rig's value; if the member value is relative, it is ignored and the rig home does not take effect.
|
||||
|
||||
Pi account changes work with or without `launch_wrapper`. The wrapper receives the successor's managed Pi directory;
|
||||
it must preserve the selected credentials if you want OpenRig's account choice to apply. Switching a seat between Pi
|
||||
and another runtime is not supported by this account-home feature.
|
||||
it must preserve the selected credentials if you want OpenRig's account choice to apply.
|
||||
Use `rig seat set-runtime <seat> --runtime <runtime> --reason "Change provider"` with `pi`, `codex` or `claude-code` for cross-runtime switches.
|
||||
Those resolve the target's settings afresh rather than keeping the source model or effort;
|
||||
see [runtime switching](getting-started.md#move-a-seat-to-the-other-runtime).
|
||||
|
||||
This switch starts a fresh conversation with a handover, even when the runtime stays the same. The existing
|
||||
subscription-login switch preserves the conversation. Once the successor confirms, OpenRig commits its home, runtime,
|
||||
@@ -917,7 +921,9 @@ This requires a Claude Code version that supports `--advisor`. OpenRig never wri
|
||||
choose different Advisors. When Advisor is unset, no flag is emitted and Claude's
|
||||
own defaults/settings apply. Other runtimes ignore this field.
|
||||
|
||||
### Launch wrapper (`runtime: pi` and `codex`)
|
||||
<a id="launch-wrapper-runtime-pi-and-codex"></a>
|
||||
|
||||
### Launch wrapper (`runtime: pi`, `codex` and `claude-code`)
|
||||
|
||||
Set `launch_wrapper` on a local built-in `pi`, `codex` or `claude-code` member to prefix its native executable and arguments with a literal argv vector:
|
||||
|
||||
|
||||
@@ -356,7 +356,10 @@ accounting. Historical outbox receipts do not suppress a lone reminder or preven
|
||||
message tells its recipient that delivery may repeat after an unconfirmed attempt. Its job history records the
|
||||
current transport result; an unknown write is not proof that nothing arrived.
|
||||
|
||||
The transport still protects approval/question prompts, human drafts and recipient identity. Scheduler timing,
|
||||
The transport still protects approval/question prompts and recipient identity. Under the default
|
||||
`transport.draft_delivery` (`deliver`), an ordinary send or work notice that meets a person's draft at a Claude Code or
|
||||
Codex prompt is pasted into it and the combined input is submitted, unless the seat's typing guard holds input
|
||||
(`draft-aware`, `openrig-urgent`, `urgent-only` or `hold`). With `refuse`, a draft refuses the send. Scheduler timing,
|
||||
current parked-row eligibility and stop checks still apply. This changes message retries, not the idempotence of
|
||||
structured continuity actions such as creating a cutover baton.
|
||||
|
||||
|
||||
@@ -405,9 +405,11 @@ budget window when exhausted. Slack's per-method `Retry-After` is retained acros
|
||||
Only an acknowledged edit gets a success receipt. `rig slack status` shows queued reconciliation
|
||||
and the last edit failure. Transient failures retry with increasing waits, capped at an hour,
|
||||
behind other queued work. Permanent Slack failures (such as missing scope, a deleted original,
|
||||
an archived channel or an uneditable message) remain unfinished and are retried only on the next
|
||||
connector start. They do not keep consuming edit capacity. These edit calls have their own
|
||||
budget, separate from missed-message scanning below.
|
||||
an archived channel or an uneditable message) remain unfinished. A deleted original is recorded
|
||||
on the row and never asked about again; a new post of the same ask is tried afresh. The others,
|
||||
which a scope, membership, a different bot token or an upgrade can fix, are retried on the next
|
||||
connector start. A message is uneditable when the configured token didn't post it. They do not keep consuming edit capacity. These edit calls have their own budget,
|
||||
separate from missed-message scanning below.
|
||||
|
||||
The baseline already includes public-channel history access and `chat:write`. Private-channel
|
||||
reads may require `groups:history`, which the baseline does not grant; a refused read never
|
||||
@@ -454,9 +456,18 @@ not covered by that margin. A corrupt or unreadable checkpoint leaves recovery u
|
||||
and preserves the existing file; live inbound continues.
|
||||
|
||||
Each pass admits at most four history calls, 100 entries and 15 seconds of work, with a
|
||||
five-second request ceiling. Channels and threads take turns within that one budget, so with many
|
||||
tracked threads each thread is scanned every few passes; `rig slack status` shows how far behind
|
||||
the oldest waiting thread is. Work already started on a message, such as
|
||||
five-second request ceiling. Channels and threads take turns within that one budget. Each tracked
|
||||
thread is scanned once after each start and after each Socket Mode reconnect that followed a gap
|
||||
(not a refresh Slack overlaps with the old connection), which catches replies sent while no
|
||||
connection was up; replies while connected arrive live. A thread the pass at the connect itself
|
||||
reaches is scanned once more, since that pass stops five seconds short of the connect. Those scans go in need order: a thread
|
||||
leaving the seven-day window within six hours first, since once it leaves a missed reply is never
|
||||
recovered; then threads whose ask still waits on the person (its queue row is pending, in progress
|
||||
or blocked) before the rest, each newest first. The order comes from the saved checkpoints, so a
|
||||
restart starts with the newest open ask, not the oldest thread. After that a thread is scanned again
|
||||
only as a backstop, hourly while its ask waits on the person and daily otherwise. A machine the hub
|
||||
relays to can't see a gap in relayed events, so there a settled thread's backstop is three hours. `rig slack status` shows how far behind the oldest waiting thread is and how
|
||||
many threads are settled. Work already started on a message, such as
|
||||
an attachment transfer, is not cancelled by these limits or by a stop, and a new
|
||||
pass waits for it to finish. Rate limits retain Slack's Retry-After across restart, and hold back
|
||||
only the method Slack limited (channel history or thread replies).
|
||||
@@ -465,8 +476,8 @@ When Slack reports a plan history limit, recovery still lands the available page
|
||||
and advances normally, retaining an older-history limitation in status across restarts.
|
||||
Missing bot credentials, history scope, membership, retention limits and API
|
||||
failures appear as recovery limitations. A tracked thread Slack can't find stays incomplete in
|
||||
status, with no further call, until its thread mapping changes or the daemon restarts or rewires
|
||||
the connector; that isn't proof the thread was deleted. No additional scope is required to keep
|
||||
status and is asked again at most once a day, across restarts; that isn't proof the thread was
|
||||
deleted. No additional scope is required to keep
|
||||
live delivery working. Threads nobody tracks, older open threads, threads closed more than seven
|
||||
days ago and other channels remain outside this recovery scope; global chronological order is
|
||||
not promised. This change does not replay intervals already checkpointed before broadcast-reply
|
||||
|
||||
@@ -72,11 +72,11 @@ verified viewer mapping.
|
||||
|
||||
New tiles keep their place when a seat's tmux session is replaced. They wait up
|
||||
to 30 seconds for the same session name and managed seat identity to return,
|
||||
then reattach with the original read-only setting. This uses the shared tile
|
||||
command for Herdr, cmux, Rex and plain tmux, including SSH tiles. In-place
|
||||
handover and restart keep the existing attachment.
|
||||
then show the new session with the original read-only setting. This uses the
|
||||
shared tile command for Herdr, cmux, Rex and plain tmux, including SSH tiles.
|
||||
In-place handover and restart keep showing the same session.
|
||||
|
||||
A deliberate detach closes the tile after about two seconds. A different seat
|
||||
Detaching a tile you typed in returns it to showing the seat. A different seat
|
||||
under the old name is never followed. If no replacement returns in time, or a
|
||||
session has no managed identity, reopen the rig view with
|
||||
`rig terminal open rig:<name> --window` (or your explicit provider). Views
|
||||
@@ -240,8 +240,8 @@ opening; the result explains why. A failed endpoint/identity read or an existing
|
||||
canvas stays unconfirmed, preserving it instead of creating a fallback view.
|
||||
Other providers and selector kinds retain ordinary one-shot open. Use
|
||||
`--provider herdr --json` to operate without opening a desktop window, and
|
||||
`--session <name>` to select that endpoint. Opening a tile can change the
|
||||
agent's terminal size; `ignore-size` does not guarantee isolation.
|
||||
`--session <name>` to select that endpoint. Typing in a tile can change the
|
||||
agent's terminal size while you type.
|
||||
|
||||
```bash
|
||||
rig terminal open qitem:example --follow --provider herdr --json
|
||||
@@ -321,17 +321,22 @@ small presentation provenance, separate from queue membership. Herdr display
|
||||
labels are not that provenance. Other providers/platforms remain unsupported
|
||||
for ensure-viewer until qualified; ordinary open retains its existing behavior.
|
||||
|
||||
A writable tile attaches with `tmux attach -f ignore-size` when its session
|
||||
already has a client that sizes it, so the tile doesn't fight that client over
|
||||
the agent window's dimensions. When the session has no sizing client, as with a
|
||||
freshly launched seat, the tile attaches without the flag and sizes the window
|
||||
itself: tmux applies ignore-size across the whole server, so a session viewed
|
||||
only by ignore-size clients would otherwise keep its 80x24 creation size while
|
||||
any other session has a sizing client. Read-only clients keep `-r` (which also
|
||||
implies ignore-size), and the printed manual attach commands keep
|
||||
`-f ignore-size`. This is **not resize isolation**: opening a tile can change
|
||||
the agent's terminal size. Observers of a Herdr terminal do not themselves resize it;
|
||||
a controlling browser can. No cross-version resize guarantee is implied.
|
||||
A tmux window has one size, and a tmux client narrower than it sees only the
|
||||
left part of each line. So a tile shows its seat without attaching to it: about
|
||||
once a second it reads the seat's screen with `tmux capture-pane`, redraws only
|
||||
after tmux records new activity, and lets the tile's own terminal wrap each
|
||||
line. A tile narrower than the agent's window still shows whole lines, and
|
||||
showing a seat never changes its size. A rule drawn across the window is cut to
|
||||
the tile's width. A read-only tile only shows the seat. In a writable tile, the
|
||||
first thing you type or paste attaches the tile and goes through tmux as it
|
||||
would in any attached client: a paste arrives as one paste, and tmux's prefix
|
||||
works (prefix then `[` to scroll back, for example). The tile sizes the window
|
||||
while you use it, as any client you type in does; after 30 seconds without a
|
||||
key it detaches and shows the seat again. A tile that's only showing has no
|
||||
scrollback of its own. If the seat's tmux server restarts, a tile shows the
|
||||
seat again only when the same managed seat returns. The printed manual attach commands
|
||||
keep `-f ignore-size`. Observers of a Herdr terminal do not themselves resize
|
||||
it; a controlling browser can. No cross-version resize guarantee is implied.
|
||||
|
||||
To type into a seat without changing its size, send the text through OpenRig
|
||||
(`rig send`, or `POST /api/transport/send`): it is written to the pane with tmux
|
||||
|
||||
@@ -35,7 +35,7 @@ type LaunchResponse = {
|
||||
// operator must act). These are NOT successful launches — never print "Launched" for
|
||||
// them; exit non-zero. Mirrors the daemon's NON_RUNNING_LAUNCH_STATUSES.
|
||||
const NON_RUNNING_LAUNCH_STATUSES = new Set(["awaiting-decision", "attention_required", "failed", "left_attached"]);
|
||||
function launchStatusRunning(status: string): boolean {
|
||||
export function launchStatusRunning(status: string): boolean {
|
||||
return !NON_RUNNING_LAUNCH_STATUSES.has(status);
|
||||
}
|
||||
|
||||
|
||||
@@ -605,7 +605,7 @@ and native credential-store requirements are unchanged. No fresh account home or
|
||||
Do not combine --api-key-file with --account-home or --clear-account-home; select a home and switch credentials separately.
|
||||
Pi seats support --account-home with an existing readable auth.json, or --clear-account-home, through a fresh successor
|
||||
with a handover. Their current model and effort are retained unless overridden. Pi account changes work with or without
|
||||
a launch_wrapper; changing a seat between Pi and another runtime is not supported.
|
||||
a launch_wrapper. To change runtime, use rig seat set-runtime with --runtime pi, codex or claude-code.
|
||||
--clear-account-home returns to the configured member/rig/daemon/native home through a fresh successor with a handover.
|
||||
Use it without --to-account, --stored-login, --account-home or --token-file. It removes an account-home token reference
|
||||
only after confirmation; it does not restore historical login bytes. Anthropic active/default profiles live outside
|
||||
|
||||
@@ -1035,13 +1035,13 @@ resume/successor launch reads it at call time. Examples:
|
||||
cmd
|
||||
.command("set-runtime")
|
||||
.argument("<seat>", "Canonical session name or logical seat ref")
|
||||
.option("--runtime <runtime>", "codex or claude-code")
|
||||
.option("--runtime <runtime>", "codex, claude-code or pi")
|
||||
.option("--reason <text>", "Why the seat moves; recorded with the switch")
|
||||
.option("--model <id>", "Target model; otherwise the seat's agent spec for the target, else the target's default")
|
||||
.option("--effort <level>", "Target effort, the target runtime's own level (not translated)")
|
||||
.option("--codex-config-profile <name>", "Codex configuration profile for a Codex successor")
|
||||
.option("--clear-account-home", "Return to member/rig/daemon/native login selection through a fresh successor")
|
||||
.option("--account-home <path>", "Isolated target CODEX_HOME or CLAUDE_CONFIG_DIR; also permits a fresh successor on the same runtime")
|
||||
.option("--account-home <path>", "Target Codex/Claude home or Pi account directory containing auth.json; also permits a same-runtime successor")
|
||||
.option("--token-file <path>", "With a Claude account home, its required subscription token reference")
|
||||
.option("--account-label <label>", "With --token-file, the selected account's label")
|
||||
.option("--permission-mode <mode>", "Target native permission choice; default: inherit the seat's member and rig policy")
|
||||
@@ -1051,7 +1051,7 @@ resume/successor launch reads it at call time. Examples:
|
||||
.option("--note <text>", "With --confirm: what you'll do first")
|
||||
.option("--recover", "Finish an interrupted switch from its record")
|
||||
.option("--json", "JSON output for agents")
|
||||
.description("Move a live Codex or Claude Code seat to the other runtime, successor first")
|
||||
.description("Move a live Codex, Claude Code or Pi seat to another runtime, successor first")
|
||||
.addHelpText("after", `
|
||||
The seat keeps its address, connections, queue rows and claims. Its successor
|
||||
starts on the target runtime beside the current occupant, reads a handover, and
|
||||
@@ -1065,7 +1065,7 @@ later converge reports a deferred change_runtime); the source runtime's model,
|
||||
effort, Codex profile and native permission choice are cleared, not mapped; two
|
||||
processes run until cutover; messages sent to the seat during the switch are
|
||||
held, not typed, and need sending again afterwards.
|
||||
Codex and Claude Code, single-pane seats only. --dry-run lists the details.
|
||||
Codex, Claude Code and Pi, single-pane seats only. --dry-run lists the details.
|
||||
A seat that has never started has no pane to switch: declare the other runtime for it in its rig spec and bring the team
|
||||
up again from that spec. That's a new generation of the seat, without this seat's edges, settings or queued work.
|
||||
--clear-account-home returns to configured member/rig/daemon/native selection; it does not restore historical credentials.
|
||||
|
||||
@@ -258,7 +258,7 @@ export function slackCommand(deps: SlackDeps = {}): Command {
|
||||
coverage?: { coverageStart: string; coveredThrough: string; pending?: { upper: string; nextLatest: string }; nextRetryAt?: number } | null;
|
||||
channels?: Array<{ channel?: string | null; state?: string; reason?: string; lastScanAt?: string;
|
||||
coverage?: { coverageStart: string; coveredThrough: string; nextRetryAt?: number } | null }>;
|
||||
threads?: { state?: string; reason?: string; tracked?: number; scanned?: number; pending?: number; oldestPendingSeconds?: number | null;
|
||||
threads?: { state?: string; reason?: string; tracked?: number; scanned?: number; pending?: number; settled?: number; settledRescanHours?: number; oldestPendingSeconds?: number | null;
|
||||
backoff?: Record<string, number>; incomplete?: Record<string, number> };
|
||||
limits?: string[] } } | undefined;
|
||||
if (connector) {
|
||||
@@ -288,6 +288,7 @@ export function slackCommand(deps: SlackDeps = {}): Command {
|
||||
const behind = threads.oldestPendingSeconds != null ? ` (oldest ${Math.round(threads.oldestPendingSeconds / 60)} min behind)` : "";
|
||||
log(` Thread recovery: ${threads.state ?? "unknown"}${threads.reason ? ` (${threads.reason})` : ""}; ${threads.tracked ?? 0} tracked (open, or closed within seven days); `
|
||||
+ `${threads.scanned ?? 0} scanned, ${threads.pending ?? 0} waiting their turn${behind}`
|
||||
+ `${threads.settled ? `, ${threads.settled} settled (nobody waiting; rescanned ${!threads.settledRescanHours || threads.settledRescanHours === 24 ? "daily" : `every ${threads.settledRescanHours} hours`})` : ""}`
|
||||
+ `${reasons(threads.backoff) ? `; backing off: ${reasons(threads.backoff)}` : ""}${reasons(threads.incomplete) ? `; incomplete: ${reasons(threads.incomplete)}` : ""}`);
|
||||
}
|
||||
log(` Recovery counts since connector start: accepted ${recovery?.acceptedThisProcess ?? "unknown"}; dead-lettered ${recovery?.deadLetteredThisProcess ?? "unknown"} (custody, not delivery)`);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { getDaemonStatus, getDaemonUrl, startDaemon, type LifecycleDeps, daemonS
|
||||
import { shouldAutoStartDaemon } from "../daemon-reachability.js";
|
||||
import { prepareDaemonAutoStart } from "../daemon-auto-start.js";
|
||||
import { freshSeatCommand, launchFreshSeat } from "../fresh-seat-command.js";
|
||||
import { launchStatusRunning } from "./launch.js";
|
||||
import type { StartOptions } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
@@ -120,6 +121,17 @@ snapshot, resuming seats where their harness allows; it refuses while any of its
|
||||
whose rig name already exists starts a new team in place of the stopped one, which
|
||||
is archived, and refuses if the old one may still be running. To bring a stopped
|
||||
team back with its conversations, run rig up <rig-name> --existing, not the spec.
|
||||
|
||||
After a restore, rig up exits 0 only when the rig came back whole (rigResult
|
||||
fully_restored). It exits 1 when it didn't (partially_restored, failed or
|
||||
not_attempted), including when every seat is running but some started fresh
|
||||
without their conversation, or came back with a guidance or restore gap. With
|
||||
--json, read rigResult and each node's status, noConversation, guidanceGaps and
|
||||
restoreGaps to tell these apart: a seat with a gap keeps its own status, such as
|
||||
resumed, and a fresh seat marked noConversation (a terminal seat) lost nothing.
|
||||
A refused request exits 1 for a conflict (HTTP 409, such as a rig still running
|
||||
or a restore not attempted) and 2 for any other refusal (400, 404 or 500, such
|
||||
as no rig by that name or a failed boot).
|
||||
`);
|
||||
const getDepsF = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
|
||||
|
||||
@@ -706,7 +718,7 @@ team back with its conversations, run rig up <rig-name> --existing, not the spec
|
||||
}
|
||||
console.log(`Rig "${rigName ?? rigId}" restored (ID: ${rigId})`);
|
||||
if (rigResult) console.log(`Result: ${rigResult}`);
|
||||
const nodes = (res.data["nodes"] as Array<{ logicalId: string; status: string; error?: string }>) ?? [];
|
||||
const nodes = (res.data["nodes"] as Array<{ logicalId: string; status: string; error?: string; noConversation?: true; guidanceGaps?: string[]; restoreGaps?: string[] }>) ?? [];
|
||||
for (const n of nodes) {
|
||||
// OPR.0.3.4.2 — the five-term vocabulary renders distinctly; the
|
||||
// awaiting-decision reason is part of the line (not a dead end), and so is a failed seat's (such as a seat held
|
||||
@@ -717,6 +729,16 @@ team back with its conversations, run rig up <rig-name> --existing, not the spec
|
||||
console.log(` ${n.logicalId}: ${n.status}`);
|
||||
}
|
||||
}
|
||||
// Every seat running still exits 1 when the restore isn't whole; say why, so nobody retries a start that worked.
|
||||
if (rigResult === "partially_restored" && nodes.length > 0 && nodes.every((n) => launchStatusRunning(n.status))) {
|
||||
const fresh = nodes.filter((n) => (n.status === "fresh" || n.status === "fresh-primed") && !n.noConversation).length;
|
||||
const gapped = nodes.filter((n) => (n.guidanceGaps?.length ?? 0) > 0 || (n.restoreGaps?.length ?? 0) > 0).map((n) => n.logicalId);
|
||||
const reasons = [
|
||||
...(fresh > 0 ? [`${fresh} started fresh without their conversation`] : []),
|
||||
...(gapped.length > 0 ? [`${gapped.join(", ")} came back with a gap named in a warning`] : []),
|
||||
];
|
||||
console.log(`${nodes.length === 1 ? "The one seat is" : `All ${nodes.length} seats are`} running${reasons.length > 0 ? `; ${reasons.join("; ")}` : ""}. The restore isn't whole, so rig up exits 1.`);
|
||||
}
|
||||
const warnings = (res.data["warnings"] as string[]) ?? [];
|
||||
for (const w of warnings) {
|
||||
console.error(` warning: ${w}`);
|
||||
|
||||
@@ -72,7 +72,7 @@ export function describeDaemonRejection(body: unknown, instanceHint?: string): T
|
||||
return {
|
||||
fact: "The failed step no longer exists in the cached workflow spec.",
|
||||
consequence: "The spec drifted after this instance failed; rebinding would route a step the workflow no longer declares.",
|
||||
action: "Restore the step in the spec (re-validate to refresh the cache) or instantiate a fresh run.",
|
||||
action: "Restore the step in the spec (then re-register it through the workflows-folder scan or an instantiate by path to refresh the cache) or instantiate a fresh run.",
|
||||
};
|
||||
case "instance_version_conflict":
|
||||
return {
|
||||
|
||||
@@ -172,7 +172,7 @@ export function workflowCommand(depsOverride?: WorkflowDeps): Command {
|
||||
|
||||
cmd
|
||||
.command("validate <specPath>")
|
||||
.description("Validate a workflow spec file (returns structured ok/error report)")
|
||||
.description("Validate a workflow spec file in memory, without registering it (returns structured ok/error report)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
|
||||
@@ -67,6 +67,13 @@ const plan = {
|
||||
describe("rig seat set-runtime", () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("passes a Pi target and its selected account through the existing dry-run workflow", async () => {
|
||||
const { calls, exitCode } = await run({ status: 200, data: { ...plan, to: "pi", accountHome: "/accounts/pi" } },
|
||||
["dev.impl@my-rig", "--runtime", "pi", "--account-home", "/accounts/pi", "--reason", "Change runtime", "--dry-run"]);
|
||||
expect(calls[0]!.body).toMatchObject({ runtime: "pi", accountHome: "/accounts/pi", reason: "Change runtime", dryRun: true });
|
||||
expect(exitCode).toBeFalsy();
|
||||
});
|
||||
|
||||
it("an older daemon cannot silently ignore the chosen home and execute a switch", async () => {
|
||||
const { calls, exitCode } = await run({ status: 200, data: plan }, ["dev.impl@my-rig", "--runtime", "claude-code",
|
||||
"--account-home", "/accounts/claude-api", "--reason", "Plan limit", "--json"]);
|
||||
|
||||
@@ -387,4 +387,95 @@ describe("rig up --existing — resume-original default (OPR.0.3.4.2)", () => {
|
||||
expect(output).toContain("e: failed");
|
||||
expect(exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("every seat running, some fresh without their conversation: one line says so, and the exit stays 1", async () => {
|
||||
respond = () => ({
|
||||
status: "restored", rigId: "rig-1", rigName: "myrig", rigResult: "partially_restored",
|
||||
nodes: [
|
||||
{ logicalId: "orch.lead", status: "resumed" },
|
||||
{ logicalId: "dev.impl", status: "fresh-primed" },
|
||||
{ logicalId: "dev.qa", status: "fresh" },
|
||||
{ logicalId: "infra.shell", status: "fresh", noConversation: true },
|
||||
],
|
||||
warnings: [],
|
||||
});
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "up", "myrig", "--existing"]);
|
||||
});
|
||||
expect(logs).toContain("All 4 seats are running; 2 started fresh without their conversation. The restore isn't whole, so rig up exits 1.");
|
||||
expect(exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("one seat running fresh: the line reads for one seat", async () => {
|
||||
respond = () => ({
|
||||
status: "restored", rigId: "rig-1", rigName: "myrig", rigResult: "partially_restored",
|
||||
nodes: [{ logicalId: "dev.impl", status: "fresh-primed" }],
|
||||
warnings: [],
|
||||
});
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "up", "myrig", "--existing"]);
|
||||
});
|
||||
expect(logs).toContain("The one seat is running; 1 started fresh without their conversation. The restore isn't whole, so rig up exits 1.");
|
||||
});
|
||||
|
||||
it("every seat running with a guidance or restore gap: the line names those seats and points at the warnings", async () => {
|
||||
respond = () => ({
|
||||
status: "restored", rigId: "rig-1", rigName: "myrig", rigResult: "partially_restored",
|
||||
nodes: [
|
||||
{ logicalId: "orch.lead", status: "resumed", guidanceGaps: ["openrig-core"] },
|
||||
{ logicalId: "dev.impl", status: "resumed", restoreGaps: ["claude_activity_hooks: the current selection could not be re-applied: no Claude runtime adapter was available."] },
|
||||
{ logicalId: "dev.qa", status: "resumed" },
|
||||
],
|
||||
warnings: ["Restore activity hooks: the current selection could not be re-applied: no Claude runtime adapter was available."],
|
||||
});
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "up", "myrig", "--existing"]);
|
||||
});
|
||||
expect(logs).toContain("All 3 seats are running; orch.lead, dev.impl came back with a gap named in a warning. The restore isn't whole, so rig up exits 1.");
|
||||
expect(exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("a seat that isn't running gets no all-running line", async () => {
|
||||
respond = () => ({
|
||||
status: "restored", rigId: "rig-1", rigName: "myrig", rigResult: "partially_restored",
|
||||
nodes: [
|
||||
{ logicalId: "dev.impl", status: "fresh" },
|
||||
{ logicalId: "ext.cli", status: "left_attached", error: "attached with rig attach; left as it is" },
|
||||
],
|
||||
warnings: [],
|
||||
});
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "up", "myrig", "--existing"]);
|
||||
});
|
||||
expect(logs.join("\n")).not.toContain("seats are running");
|
||||
expect(exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("a whole restore prints no summary line and exits 0", async () => {
|
||||
respond = () => ({
|
||||
status: "restored", rigId: "rig-1", rigName: "myrig", rigResult: "fully_restored",
|
||||
nodes: [
|
||||
{ logicalId: "orch.lead", status: "resumed" },
|
||||
{ logicalId: "infra.shell", status: "fresh", noConversation: true },
|
||||
],
|
||||
warnings: [],
|
||||
});
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "up", "myrig", "--existing"]);
|
||||
});
|
||||
expect(logs.join("\n")).not.toContain("seats are running");
|
||||
expect(exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it("help says what exit 1 means after a restore and where to look", () => {
|
||||
let text = "";
|
||||
const cmd = upCommand(deps());
|
||||
cmd.configureOutput({ writeOut: (s) => { text += s; } });
|
||||
cmd.outputHelp();
|
||||
const help = text.replace(/\s+/g, " ");
|
||||
expect(help).toContain("After a restore, rig up exits 0 only when the rig came back whole (rigResult fully_restored).");
|
||||
expect(help).toContain("It exits 1 when it didn't (partially_restored, failed or not_attempted), including when every seat is running but some started fresh without their conversation, or came back with a guidance or restore gap.");
|
||||
expect(help).toContain("read rigResult and each node's status, noConversation, guidanceGaps and restoreGaps to tell these apart");
|
||||
expect(help).toContain("A refused request exits 1 for a conflict (HTTP 409, such as a rig still running or a restore not attempted) and 2 for any other refusal (400, 404 or 500, such as no rig by that name or a failed boot).");
|
||||
});
|
||||
});
|
||||
@@ -128,7 +128,7 @@ Startup uses a private, hash-checked `control/first-input.txt` file and a bounde
|
||||
|
||||
The placement name is supplied by the caller. Create refuses an already-listed name, and each binding retains its one selected name; OpenRig does not reserve names globally across bindings. Immediately before stop/delete, OpenRig checks the saved immutable identity, then the pinned vendor call addresses the name and workspace. A deliberate reuse of that name between inspection and the vendor call could affect the replacement. The pinned CLI has no expected-id condition for these calls. Later reconciliation cannot undo that mutation.
|
||||
|
||||
Exact restore onto recreated compute stages the current resolved role, skills, plugins and required startup files as destination files, including files originally intended only for a fresh start. Declared context changes, additions and removals are allowed. Current source and destination bytes are verified through the ordinary projection checks; a missing or stale required destination refuses launch. Startup actions and text are not replayed into the conversation. The checkpoint pins the original native history and verified artifact bytes, which remain separate and unchanged; it does not freeze the context.
|
||||
Exact restore onto recreated compute stages the current resolved role, skills, plugins and required startup files as destination files, including files originally intended only for a fresh start. Declared context changes, additions and removals are allowed. Current source and destination bytes are verified through the ordinary projection checks; a missing or stale required destination refuses launch. Saved startup actions are not resent, but the restored guest's first input asks it to read the projected `CLAUDE.md` and its startup files again, so it takes in its startup content again and strict recall after a restore isn't met. The checkpoint pins the original native history and verified artifact bytes, which remain separate and unchanged; it does not freeze the context.
|
||||
|
||||
A pending finite-completion attempt records its last outcome and attempt time in the existing operation receipt. Queue events and boot/manual checks observe it at most once per 60 seconds; there is no new polling timer. Eligibility is checked again after the driver's identity await, immediately before its stop command. A known refusal before dispatch releases only the unstarted effect claim; an uncertain dispatched request is never replayed. With no pending selection, a queue event performs one filtered query and no provider call; the operation table has no additional index or migration in this increment.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openrig-core",
|
||||
"version": "0.1.26",
|
||||
"version": "0.1.27",
|
||||
"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.26",
|
||||
"version": "0.1.27",
|
||||
"description": "OpenRig canonical skills and hooks for cross-runtime agent topology coordination, continuity, and operating discipline.",
|
||||
"author": {
|
||||
"name": "Mike Schwarz",
|
||||
|
||||
@@ -1367,8 +1367,8 @@ your circumstances is configuration, and the ones that are not, another agent ca
|
||||
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.
|
||||
--account-home <dir> --reason "<why>"` (preview with `--dry-run`). It starts a fresh conversation with a handover
|
||||
and retains the seat's address and work. `--clear-account-home --reason "<why>"` 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
|
||||
|
||||
@@ -43,7 +43,7 @@ export class OpenShellPodmanObserver {
|
||||
const workloadContainer = workload[0], supervisorContainer = supervisor[0];
|
||||
if (workload.length !== 1 || supervisor.length !== 1 || !workloadContainer || !supervisorContainer || workloadContainer.Labels?.["openshell.ai/sandbox-workspace"] !== binding.placement.workspace)
|
||||
throw new EnvironmentError("environment_cleanup_unconfirmed", "The exact local workload and supervisor must be observed before deletion");
|
||||
const volumes = [`openshell-sandbox-${id}-workspace`, `openshell-control-${id}`, `openshell-channel-${id}`];
|
||||
const requiredVolumes = [`openshell-control-${id}`, `openshell-channel-${id}`];
|
||||
const containers = [workloadContainer, supervisorContainer];
|
||||
const mounted = new Set<string>();
|
||||
for (const container of containers) {
|
||||
@@ -53,7 +53,9 @@ export class OpenShellPodmanObserver {
|
||||
if (inspected.status !== 200 || actual?.Id !== container.Id || !Array.isArray(actual.Mounts)) throw new EnvironmentError("environment_cleanup_unconfirmed", "The captured container cannot be inspected");
|
||||
for (const mount of actual.Mounts) if (mount.Type === "volume" && typeof mount.Name === "string") mounted.add(mount.Name);
|
||||
}
|
||||
if (volumes.some(volume => !mounted.has(volume))) throw new EnvironmentError("environment_cleanup_unconfirmed", "Expected workspace/control/channel volumes are not attributable to the selected containers");
|
||||
if (requiredVolumes.some(volume => !mounted.has(volume))) throw new EnvironmentError("environment_cleanup_unconfirmed", "Expected control/channel volumes are not attributable to the selected containers");
|
||||
// Custom-image workspaces have no managed workspace volume. Keep only observed provider-owned mounts.
|
||||
const volumes = [`openshell-sandbox-${id}-workspace`, ...requiredVolumes].filter(volume => mounted.has(volume));
|
||||
return { version: 1, ...selected, resourceId: id, workspace: binding.placement.workspace, workloadId: workloadContainer.Id, supervisorId: supervisorContainer.Id, volumes };
|
||||
}
|
||||
async observe(binding: EnvironmentBinding, selected?: PodmanCleanupSelection): Promise<DependentObservation> {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { randomUUID } from "node:crypto";
|
||||
import type { ComposerSnapshot } from "../domain/composer-prompts.js";
|
||||
import { inspectComposerInput } from "../domain/session-transport.js";
|
||||
import { parentSessionEnvKeys, removeParentSessionEnv } from "../domain/parent-session-env.js";
|
||||
import { addSeatRuntimeDir } from "../domain/seat-runtime-dir.js";
|
||||
import { addSeatRuntimeDir, seatRuntimeDir } from "../domain/seat-runtime-dir.js";
|
||||
import type { TmuxControlExec } from "./tmux-control.js";
|
||||
|
||||
export type ExecFn = (cmd: string) => Promise<string>;
|
||||
@@ -197,6 +197,15 @@ function tmuxReportedError(err: unknown): unknown {
|
||||
return typeof stderr === "string" ? new Error(stderr) : err;
|
||||
}
|
||||
|
||||
/** No server answers on the socket: tmux's "no server running", or a socket that is missing or refuses, read from what
|
||||
* tmux said (its stderr when the exec kept it, else the message past an exec's "Command failed" line). */
|
||||
function isServerMissingError(err: unknown): boolean {
|
||||
const reported = tmuxReportedError(err);
|
||||
if (!(reported instanceof Error)) return false;
|
||||
const said = new Error(tmuxDiagnosticStart(reported.message).trim());
|
||||
return isNoServerError(said) || isTmuxTransportAbsentError(said);
|
||||
}
|
||||
|
||||
function isSessionAbsenceError(err: unknown): boolean {
|
||||
if (!(err instanceof Error)) return false;
|
||||
const msg = tmuxDiagnosticStart(err.message).toLowerCase();
|
||||
@@ -310,6 +319,28 @@ function posixJoinArgv(argv: string[]): string {
|
||||
return argv.map((a) => (SHELL_SAFE.test(a) ? a : shellQuote(a))).join(" ");
|
||||
}
|
||||
|
||||
/** The stderr a failed exec kept apart, or "" (a timeout, a spawn error). Never the message: it echoes the command. */
|
||||
function execStderrText(error: unknown): string {
|
||||
const e = error as { execStderr?: unknown; stderr?: unknown };
|
||||
return typeof e.execStderr === "string" && e.execStderr.trim() ? e.execStderr
|
||||
: typeof e.stderr === "string" ? e.stderr : "";
|
||||
}
|
||||
|
||||
/** systemd-run's own refusal of an option it does not have (before 254), read from its stderr only. */
|
||||
const NO_EXPAND_ENVIRONMENT = /(?:unrecognized|unknown) option[^\n]*--expand-environment/i;
|
||||
/** The user manager's refusal of OOMPolicy= for a scope (before 253), read from systemd-run's stderr only. */
|
||||
const NO_OOM_POLICY = /Cannot set property OOMPolicy|Unknown (?:property|assignment)[^\n]*OOMPolicy/i;
|
||||
|
||||
/** What a failed exec said, on one line: its stderr when it kept one apart, else its message past the command. */
|
||||
function execFailureReason(error: unknown): string {
|
||||
const e = error as { execStderr?: unknown; stderr?: unknown; message?: unknown; code?: unknown };
|
||||
const stderr = typeof e.execStderr === "string" && e.execStderr.trim() ? e.execStderr
|
||||
: typeof e.stderr === "string" && e.stderr.trim() ? e.stderr : "";
|
||||
const text = stderr || tmuxDiagnosticStart(String(e.message ?? error));
|
||||
const reason = text.trim().split(/\r?\n/).map(line => line.trim()).filter(Boolean).join("; ");
|
||||
return reason || `systemd-run failed${e.code !== undefined ? ` (exit ${String(e.code)})` : ""}`;
|
||||
}
|
||||
|
||||
function parseSessionLine(line: string): TmuxSession | null {
|
||||
const parts = line.split(TMUX_FIELD_SEPARATOR);
|
||||
if (parts.length < 4) return null;
|
||||
@@ -471,6 +502,7 @@ export class TmuxAdapter {
|
||||
// uses. An id alone is unique only for one server's lifetime.
|
||||
let created: { id: string; at: string } | null;
|
||||
try {
|
||||
await this.startServerBeforeImplicitStart();
|
||||
const out = await this.exec(this.onServer(`tmux new-session -d -P -F ${shellQuote("#{session_id} #{session_created}")} -s ${shellQuote(name)}${cwd != null ? ` -c ${shellQuote(cwd)}` : ""}`));
|
||||
const match = /^(\$\d+) (\d+)$/.exec(out.trim());
|
||||
created = match ? { id: match[1]!, at: match[2]! } : null;
|
||||
@@ -587,6 +619,8 @@ export class TmuxAdapter {
|
||||
) { this.serverArgs = [...serverArgs]; }
|
||||
|
||||
private readonly serverArgs: readonly string[];
|
||||
/** #2710: the "no scope tried" reason is logged once per adapter, not at every server start. */
|
||||
private scopeSkipLogged = false;
|
||||
|
||||
/** Prefix one adapter-owned tmux invocation, including its chained commands.
|
||||
* The daemon's explicit server wins over an inherited TMUX or wrapper socket. */
|
||||
@@ -614,11 +648,30 @@ export class TmuxAdapter {
|
||||
* A failed/uncertain call retains its private file: it may not have been consumed yet.
|
||||
*/
|
||||
private async runEnvironmentCommand(argv: string[], legacy?: string, startServer = false): Promise<string> {
|
||||
const file = this.fileOps.tmpName();
|
||||
await this.fileOps.writeFile(file, (legacy ?? posixJoinArgv(argv)).slice("tmux ".length) + "\n", { mode: 0o600, flag: "wx" });
|
||||
const output = await this.run(["tmux", ...(startServer ? ["start-server", ";"] : []), "source-file", file]);
|
||||
await this.fileOps.unlink(file).catch(() => {});
|
||||
return output;
|
||||
const body = (legacy ?? posixJoinArgv(argv)).slice("tmux ".length) + "\n";
|
||||
const source = async (startFirst: boolean): Promise<string> => {
|
||||
const file = this.fileOps.tmpName();
|
||||
await this.fileOps.writeFile(file, body, { mode: 0o600, flag: "wx" });
|
||||
try {
|
||||
const output = await this.run(["tmux", ...(startFirst ? ["start-server", ";"] : []), "source-file", file]);
|
||||
await this.fileOps.unlink(file).catch(() => {});
|
||||
return output;
|
||||
} catch (error) {
|
||||
// No server read it, so the private file was never consumed.
|
||||
if (!startFirst && isServerMissingError(error)) await this.fileOps.unlink(file).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
if (!startServer || !this.userScopePossible()) return source(startServer);
|
||||
// #2710: `start-server` would start a missing server in the daemon's cgroup. Try on the running server first;
|
||||
// only when there is none, start it as startServer does (in the user scope) and run the command as before.
|
||||
try {
|
||||
return await source(false);
|
||||
} catch (error) {
|
||||
if (!isServerMissingError(error)) throw error;
|
||||
}
|
||||
await this.startServer();
|
||||
return source(true);
|
||||
}
|
||||
|
||||
/** Ends the daemon's tmux control client, and with it its session (tmux-control.ts), when tmux is driven through one. */
|
||||
@@ -629,24 +682,108 @@ export class TmuxAdapter {
|
||||
/** Start an empty native terminal server, without inventing a seat/session. */
|
||||
async startServer(): Promise<TmuxResult> {
|
||||
const probeName = `openrig-startup-${randomUUID()}`;
|
||||
const serverUp = async () => (await this.probeSession(probeName)).state !== "transport_unavailable";
|
||||
const awaitServer = async () => {
|
||||
for (let attempt = 0; attempt < 20; attempt++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
if (await serverUp()) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
try {
|
||||
if ((await this.probeSession(probeName)).state !== "transport_unavailable") return { ok: true };
|
||||
if (await serverUp()) return { ok: true };
|
||||
// #2710: on Linux, the server (and with it every seat) goes into a transient systemd --user scope of its own,
|
||||
// so stopping or restarting the daemon's unit no longer takes the seats with it. Any failure keeps the start below.
|
||||
if (await this.startServerInUserScope()) {
|
||||
if (await awaitServer()) return { ok: true };
|
||||
console.warn("[tmux] The systemd user scope was made but the seat terminal server did not answer in time; starting it in the daemon's cgroup as before.");
|
||||
}
|
||||
// tmux -D keeps an empty server alive. Stays on the shell-string path
|
||||
// deliberately: detach + stdio redirection have no argv equivalent,
|
||||
// and server bootstrap is POSIX-specific (psmux auto-starts its own).
|
||||
// Native socket ownership arbitrates
|
||||
// concurrent starts; the readback below, not shell exit, proves availability.
|
||||
await this.exec(this.onServer("tmux -D </dev/null >/dev/null 2>&1 &"));
|
||||
for (let attempt = 0; attempt < 20; attempt++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
if ((await this.probeSession(probeName)).state !== "transport_unavailable") return { ok: true };
|
||||
}
|
||||
if (await awaitServer()) return { ok: true };
|
||||
return { ok: false, code: "tmux_unavailable", message: "The terminal server did not become available. Check tmux and its socket permissions." };
|
||||
} catch (error) {
|
||||
return { ok: false, code: "tmux_unavailable", message: `Terminal server unavailable: ${(error as Error).message}` };
|
||||
}
|
||||
}
|
||||
|
||||
/** Linux only: run `tmux -D` as the command of a transient `systemd-run --user --scope`. systemd-run exits once the
|
||||
* scope holds the backgrounded server, so its exit status says whether the scope was made. True when it was; false,
|
||||
* with a log line giving the reason, when the start must fall back to the daemon's own cgroup. A start where no scope
|
||||
* is tried (not a user service, no user manager) says why once per adapter, not at every server start. */
|
||||
private async startServerInUserScope(): Promise<boolean> {
|
||||
if (process.platform !== "linux") return false;
|
||||
const fallback = (reason: string) => {
|
||||
console.warn(`[tmux] Seat terminal server not in a systemd user scope (${reason}); starting it in the daemon's cgroup as before.`);
|
||||
return false;
|
||||
};
|
||||
const unavailable = this.userScopeUnavailable();
|
||||
if (unavailable) {
|
||||
if (this.scopeSkipLogged) return false;
|
||||
this.scopeSkipLogged = true;
|
||||
return fallback(unavailable);
|
||||
}
|
||||
const server = posixJoinArgv(["tmux", ...this.serverArgs, "-D"]);
|
||||
// systemd-run 254+ expands ${VAR} in the command's arguments (by default from 258, also for --scope): the
|
||||
// socket path must reach /bin/sh exactly as given, so expansion is turned off where the option exists.
|
||||
const scoped = (oomPolicy: boolean, literal: boolean) => posixJoinArgv(["systemd-run", "--user", "--scope",
|
||||
"--quiet", "--collect", "--unit", `openrig-tmux-${randomUUID().slice(0, 8)}`,
|
||||
"--description", "OpenRig seat terminal server", ...(literal ? ["--expand-environment=no"] : []),
|
||||
// A scope's default OOM policy stops every process in it when the kernel OOM-kills one: one seat would take all.
|
||||
...(oomPolicy ? ["-p", "OOMPolicy=continue"] : []),
|
||||
"--", "/bin/sh", "-c", `${server} </dev/null >/dev/null 2>&1 &`]);
|
||||
let oomPolicy = true;
|
||||
let literal = true;
|
||||
const dropped: string[] = [];
|
||||
for (;;) {
|
||||
try {
|
||||
await this.exec(scoped(oomPolicy, literal));
|
||||
if (!oomPolicy) {
|
||||
console.warn(`[tmux] Seat terminal server in a systemd user scope without OOMPolicy=continue (${dropped.join("; ")}).`);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
const reason = execFailureReason(error);
|
||||
// Only systemd's own message on stderr drops an option: a timeout or an unknown failure echoes the whole
|
||||
// command (options included) in its message, and must fall back with the reason, not retry without them.
|
||||
const stderr = execStderrText(error);
|
||||
// systemd before 254 has no --expand-environment (and never expands): retry without it.
|
||||
if (literal && NO_EXPAND_ENVIRONMENT.test(stderr)) { literal = false; dropped.push(reason); continue; }
|
||||
// systemd before 253 has no OOMPolicy= for scopes: the scope is still worth having without it.
|
||||
if (oomPolicy && NO_OOM_POLICY.test(stderr)) { oomPolicy = false; dropped.push(reason); continue; }
|
||||
return fallback(reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Before a command that would start a missing server itself (new-session), start it the way startServer does (#2710),
|
||||
* so that first start also lands in the systemd user scope. Only where such a scope can exist; the caller's start stays. */
|
||||
private async startServerBeforeImplicitStart(): Promise<void> {
|
||||
if (this.userScopePossible()) await this.startServer();
|
||||
}
|
||||
|
||||
/** Where startServer may put the server in a systemd user scope (#2710). */
|
||||
private userScopePossible(): boolean {
|
||||
return this.userScopeUnavailable() === null;
|
||||
}
|
||||
|
||||
/** Why no systemd user scope is tried, or null. Only the environment is read. The scope is for a daemon that is itself
|
||||
* a service of the systemd user manager (the manager sets MANAGERPID for its services; a process started by hand
|
||||
* from a shell that inherited it, under such a service, also counts, which is harmless): restarting that unit no longer
|
||||
* takes the seats along. A daemon started from a login session keeps its seats in that session, as before: there a
|
||||
* scope under the user manager would die with it when the last session ends without lingering. */
|
||||
private userScopeUnavailable(): string | null {
|
||||
if (process.platform !== "linux") return "not Linux";
|
||||
// systemd-run --user reaches the user manager through $XDG_RUNTIME_DIR; without it there is none to ask.
|
||||
if (seatRuntimeDir(process.env) === undefined) return "no systemd user manager: XDG_RUNTIME_DIR is not an absolute path";
|
||||
if (!process.env.MANAGERPID) return "the daemon is not a systemd user service: MANAGERPID is unset";
|
||||
return null;
|
||||
}
|
||||
|
||||
async listSessions(): Promise<TmuxSession[]> {
|
||||
try {
|
||||
const output = await this.run(["tmux", "list-sessions", "-F", SESSION_FORMAT],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { TerminalActor, TerminalPolicyStore } from "./terminal-policy.js";
|
||||
import { TerminalPolicyRefusal } from "./terminal-policy.js";
|
||||
import { DeliveryGuardError } from "./seat-delivery-guard.js";
|
||||
import { CAPTURE_READABLE_BUDGET_MS, type SessionTransport, type SendResult } from "./session-transport.js";
|
||||
import { CAPTURE_READABLE_BUDGET_MS, SEND_READINESS_FRESHNESS_MS, type SessionTransport, type SendResult } from "./session-transport.js";
|
||||
import type { ClaudeCompactionPolicy, SettingsStore } from "./user-settings/settings-store.js";
|
||||
import { postCompactExtraSetting } from "./managed-restore-read-access.js";
|
||||
import * as fs from "node:fs";
|
||||
@@ -460,6 +460,8 @@ export interface ObservedCompaction {
|
||||
detail: string;
|
||||
}
|
||||
export interface PreparationState {
|
||||
/** The reading that held this preparation send; never includes draft text. */
|
||||
inputBox?: SendResult["heldInputBoxReading"];
|
||||
policyActor?: TerminalActor;
|
||||
attemptId: string;
|
||||
occupantGeneration: string | null;
|
||||
@@ -536,6 +538,7 @@ export const RESTART_RECOVERY_WINDOW_MS_DEFAULT = 6 * 60 * 60_000;
|
||||
const DAEMON_PROCESS_ID = randomUUID();
|
||||
|
||||
interface CompactionRecord {
|
||||
inputBox?: PreparationState["inputBox"];
|
||||
/** The daemon process that last wrote it. */
|
||||
daemon: string;
|
||||
attemptId: string;
|
||||
@@ -718,7 +721,8 @@ export class ClaudeCompactionEnforcer {
|
||||
this.now = opts?.now ?? (() => Date.now());
|
||||
this.sleep = opts?.sleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms)));
|
||||
this.postCompactSendWaitMs = opts?.postCompactSendWaitMs ?? POST_COMPACT_SEND_WAIT_MS_DEFAULT;
|
||||
this.compactSendIdleWaitMs = opts?.compactSendIdleWaitMs ?? 1000;
|
||||
// A fresh preparation hook can veto even a visibly idle pane for the full send-readiness window.
|
||||
this.compactSendIdleWaitMs = opts?.compactSendIdleWaitMs ?? SEND_READINESS_FRESHNESS_MS;
|
||||
this.restartRecoveryWindowMs = opts?.restartRecoveryWindowMs ?? RESTART_RECOVERY_WINDOW_MS_DEFAULT;
|
||||
this.daemonInstance = opts?.daemonInstance ?? DAEMON_PROCESS_ID;
|
||||
this.resolveOccupantGeneration = opts?.resolveOccupantGeneration;
|
||||
@@ -1565,7 +1569,8 @@ export class ClaudeCompactionEnforcer {
|
||||
// Record this attempt's effect even if cancellation/retry happened during the await.
|
||||
// Neither a failed response nor its loss proves no paste/Enter. Never replay uncertainty.
|
||||
attempt.delivery = prep?.ok && prep.outcome !== "retained" ? "delivered" : notSent ? "not_sent" : "uncertain";
|
||||
this.persist(input.sessionName, attempt.attemptId, { delivery: attempt.delivery, sends: attempt.sends });
|
||||
attempt.inputBox = prep?.heldInputBoxReading;
|
||||
this.persist(input.sessionName, attempt.attemptId, { delivery: attempt.delivery, sends: attempt.sends, inputBox: attempt.inputBox });
|
||||
// The replacement request went out (or may have): the ended attempt's gate is used up.
|
||||
if (!notSent && attempt.afterEnded !== undefined && this.pendingPreCompactPrep.get(input.sessionName) === attempt) this.lastEnded.delete(input.sessionName);
|
||||
if (this.pendingPreCompactPrep.get(input.sessionName) !== attempt || attempt.controller.signal.aborted) return;
|
||||
@@ -1875,7 +1880,7 @@ export class ClaudeCompactionEnforcer {
|
||||
compact: "not_sent", sends: record.sends,
|
||||
deadlineAt: record.deadlineAt ?? (record.delivery === "not_sent" ? null : record.updatedAt + AUTO_PREP_WAIT_MS_DEFAULT),
|
||||
policyWasEnabled: true, preparationRoot: record.preparationRoot, controller: new AbortController(),
|
||||
compactInstruction: record.compactInstruction,
|
||||
compactInstruction: record.compactInstruction, inputBox: record.inputBox,
|
||||
// A held attempt comes back held, with its recorded hold (never stamped again) and its map's age and usage.
|
||||
...(held ? { prepared: true as const, preparedAt: record.preparedAt ?? record.updatedAt, preparedReason: record.preparedReason ?? "map_complete_seat_never_idle" } : {}),
|
||||
...(held ? { mapReadyAt: record.mapReadyAt ?? record.preparedAt ?? record.updatedAt } : record.mapReadyAt !== undefined ? { mapReadyAt: record.mapReadyAt } : {}),
|
||||
|
||||
@@ -12,6 +12,10 @@ import { TYPED_REPLY_HINT, CHANNEL_REPLY_HINT, HUMAN_REQUIRED_REPLY_HINT, QUESTI
|
||||
const UPDATE = "chat.update";
|
||||
const EDITED = "slack-ask-settled";
|
||||
const RATE_LIMIT = "slack-ask-edit-rate-limit";
|
||||
/** The posted message itself is gone or can never be edited by us: recorded, so a restart doesn't ask Slack again. */
|
||||
const UNAVAILABLE = "slack-ask-settle-unavailable";
|
||||
// cant_update_message means our token didn't author the message, which a token change can repair, so it isn't here.
|
||||
const goneErrors = new Set(["message_not_found", "thread_not_found", "original message unavailable"]);
|
||||
const STATUS_BLOCK = "or-settled";
|
||||
const permanentErrors = new Set(["missing_scope", "not_in_channel", "channel_not_found", "is_archived",
|
||||
"cant_update_message", "invalid_blocks", "message_not_found", "thread_not_found", "original message unavailable"]);
|
||||
@@ -133,7 +137,19 @@ export class SlackAskSettlement {
|
||||
private failed(id: string, key: string, method: string, result: WebApiResult): number {
|
||||
this.lastFailure = `${id}: ${method} not done (${result.error ?? result.status})`;
|
||||
this.opts.log(`slack ask settlement retained: ${this.lastFailure}`);
|
||||
if (permanentErrors.has(result.error ?? "")) { this.unavailable.add(key); return 0; }
|
||||
if (permanentErrors.has(result.error ?? "")) {
|
||||
this.unavailable.add(key);
|
||||
// Scope, membership, the token or a rendering fault can be fixed, so those are asked again after a restart; a gone message can't.
|
||||
if (goneErrors.has(result.error ?? "")) {
|
||||
try {
|
||||
this.opts.queue.update({ qitemId: id, actorSession: "daemon@kernel",
|
||||
transitionNote: `${UNAVAILABLE}${key.slice(EDITED.length)} error=${(result.error ?? "").replace(/\s+/g, "_")}` });
|
||||
} catch (error) {
|
||||
this.opts.log(`slack ask settlement: couldn't record ${id} as unavailable (${(error as Error).message}); skipped until restart`);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
const count = (this.failures.get(key) ?? 0) + 1;
|
||||
this.failures.set(key, count);
|
||||
const wait = Math.max(Math.min(30_000 * 2 ** Math.min(count - 1, 7), 3_600_000), (result.retryAfterSeconds ?? 0) * 1000);
|
||||
@@ -150,7 +166,7 @@ export class SlackAskSettlement {
|
||||
const item = this.opts.queue.getById(id);
|
||||
if (!item) return undefined;
|
||||
const after = transitions.filter((t) => t.transitionId > episode.transitionId && ![
|
||||
"slack-posted ", "slack-owner-notification-posted ", EDITED + " ", RATE_LIMIT + " ",
|
||||
"slack-posted ", "slack-owner-notification-posted ", EDITED + " ", RATE_LIMIT + " ", UNAVAILABLE + " ",
|
||||
].some((prefix) => (t.transitionNote ?? "").startsWith(prefix)));
|
||||
const make = (t: QueueTransition, label?: string) => {
|
||||
const status = statusLine(t, label);
|
||||
@@ -204,6 +220,9 @@ export class SlackAskSettlement {
|
||||
if (!stamp) continue; // No authoritative channel/timestamp: never guess a replacement post.
|
||||
const edited = `${EDITED} notification_key=${key} channel=${stamp.channel} message_ts=${messageTs}`;
|
||||
if (this.unavailable.has(edited)) continue;
|
||||
// Recorded as gone for this exact message; a new post of the episode has its own message_ts and is tried.
|
||||
const gone = `${UNAVAILABLE} notification_key=${key} channel=${stamp.channel} message_ts=${messageTs} `;
|
||||
if (transitions.some((t) => t.transitionNote?.startsWith(gone))) continue;
|
||||
const desired = this.desired(id, episode, stamp.human, transitions);
|
||||
if (!desired) continue;
|
||||
const lastAck = transitions.filter((t) => t.transitionNote === edited || t.transitionNote?.startsWith(edited + " ")).at(-1);
|
||||
|
||||
@@ -5,14 +5,18 @@ import path from "node:path";
|
||||
import type { FetchImpl } from "./slack-api.js";
|
||||
import type { InboundRouter } from "./inbound.js";
|
||||
import { ChannelRecovery } from "./channel-recovery.js";
|
||||
import { ThreadRecovery, threadKey } from "./thread-recovery.js";
|
||||
import { QUIET_RESCAN_MS, RELAYED_QUIET_RESCAN_MS, ThreadRecovery, threadKey } from "./thread-recovery.js";
|
||||
import { ChannelCoverageStore, ThreadCoverageStore, slackMicros, slackTimestamp, type ThreadCoverage } from "./state-store.js";
|
||||
import { budgetLeft, HISTORY, MethodBackoff, newRecoveryBudget, REPLIES, type RecoverySource } from "./recovery-budget.js";
|
||||
import { ACTIVE_QUEUE_STATES } from "../../queue-repository.js";
|
||||
|
||||
/** A thread is tracked while its root was posted, or it closed, within this window. */
|
||||
export const THREAD_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
/** A thread leaving the window within this long goes first after a gap: once it leaves, a missed reply is never recovered. */
|
||||
const EXPIRING_SOON_MS = 6 * 60 * 60 * 1000;
|
||||
|
||||
export interface TrackedThread { channel: string; threadTs: string; state?: string; closedAt?: string | null }
|
||||
/** `askState`: the state of the queue row the thread's root belongs to; null when that row is gone. */
|
||||
export interface TrackedThread { channel: string; threadTs: string; state?: string; closedAt?: string | null; askState?: string | null }
|
||||
|
||||
/** The channels this machine recovers: its default channel and its channel map's, except a channel
|
||||
* its configuration relays to the machine that owns it (that machine recovers it). */
|
||||
@@ -45,7 +49,7 @@ interface SlackRecoveryOptions {
|
||||
relayed?: boolean;
|
||||
}
|
||||
|
||||
interface TrackedSource { source: ThreadRecovery; mapping: string }
|
||||
interface TrackedSource { source: ThreadRecovery; mapping: string; waiting: boolean; expiresAt: number }
|
||||
|
||||
export class SlackRecovery {
|
||||
private readonly channels: ChannelRecovery[];
|
||||
@@ -57,7 +61,9 @@ export class SlackRecovery {
|
||||
private pass?: Promise<void>;
|
||||
private stopped = false;
|
||||
private lastChannel: string | undefined;
|
||||
private lastThread: string | undefined;
|
||||
/** When live events last began arriving without a gap: the connector's start, or the latest Socket Mode connect that
|
||||
* followed a gap. A reply after it in a thread nobody waits on arrives live. */
|
||||
private listeningSince: number;
|
||||
private threadsUnavailable: string | undefined;
|
||||
private initialized = false;
|
||||
private readonly adoptedHere = new Set<string>();
|
||||
@@ -66,6 +72,7 @@ export class SlackRecovery {
|
||||
|
||||
constructor(private readonly opts: SlackRecoveryOptions) {
|
||||
this.now = opts.now ?? Date.now;
|
||||
this.listeningSince = this.now();
|
||||
this.channelStore = opts.channelStore ?? new ChannelCoverageStore(path.join(opts.stateDir, "slack-channel-coverage.json"));
|
||||
const channels: Array<{ channel: string | null; seed?: "adoption" }> = opts.channels.length ? opts.channels : [{ channel: null }];
|
||||
this.channels = channels.map((c) => new ChannelRecovery({ channel: c.channel, token: opts.token, stateDir: opts.stateDir,
|
||||
@@ -94,6 +101,12 @@ export class SlackRecovery {
|
||||
} catch { /* an unreadable checkpoint file is never replaced; the channel keeps waiting */ }
|
||||
}
|
||||
|
||||
/** The Socket Mode connection opened after a gap (not a refresh that overlapped the old one). Replies sent in the gap
|
||||
* past Slack's own retries weren't delivered live, so every settled thread is due once more. */
|
||||
connected(): void {
|
||||
this.listeningSince = this.now();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.stopped = true;
|
||||
for (const channel of this.channels) channel.stop();
|
||||
@@ -145,14 +158,13 @@ export class SlackRecovery {
|
||||
this.restoreMethodHolds();
|
||||
// Channels and threads take turns one step at a time, each kind in its own rotation. A source with
|
||||
// more to scan rejoins the back of its kind's queue; a kind with nothing left gives its turns away.
|
||||
const queues: RecoverySource[][] = [rotate(this.activeChannels(), this.lastChannel), rotate(this.trackedThreadSources(), this.lastThread)];
|
||||
const queues: RecoverySource[][] = [rotate(this.activeChannels(), this.lastChannel), this.trackedThreadSources()];
|
||||
const budget = newRecoveryBudget(this.now());
|
||||
let kind = 0;
|
||||
while (!this.stopped && budgetLeft(budget, this.now()) && (queues[0]!.length || queues[1]!.length)) {
|
||||
if (!queues[kind]!.length) kind = 1 - kind;
|
||||
const source = queues[kind]!.shift()!;
|
||||
if (source instanceof ThreadRecovery) {
|
||||
this.lastThread = source.key;
|
||||
const before = { accepted: source.accepted, deadLettered: source.deadLettered };
|
||||
const more = await source.step(budget, this.backoff);
|
||||
this.threadTotals.accepted += source.accepted - before.accepted;
|
||||
@@ -166,6 +178,8 @@ export class SlackRecovery {
|
||||
}
|
||||
}
|
||||
|
||||
/** The threads this pass scans, in the order it scans them. Refreshes the tracked set from the seat map (a thread keeps its
|
||||
* source while its mapping is unchanged), then keeps only the threads that are due and sorts them by `threadOrder`. */
|
||||
private trackedThreadSources(): ThreadRecovery[] {
|
||||
if (!this.opts.trackedThreads || !this.opts.token) return [];
|
||||
let stored: Record<string, ThreadCoverage>;
|
||||
@@ -178,20 +192,27 @@ export class SlackRecovery {
|
||||
return [];
|
||||
}
|
||||
const owned = this.activeChannels().flatMap((c) => (c.channel ? [c.channel] : []));
|
||||
const since = new Date(this.now() - THREAD_WINDOW_MS).toISOString();
|
||||
const now = this.now();
|
||||
const since = new Date(now - THREAD_WINDOW_MS).toISOString();
|
||||
const next = new Map<string, TrackedSource>();
|
||||
for (const t of this.opts.trackedThreads(owned, since)) {
|
||||
if (!owned.includes(t.channel) || slackMicros(t.threadTs) === null) continue;
|
||||
const key = threadKey(t.channel, t.threadTs);
|
||||
const mapping = `${t.state ?? ""}|${t.closedAt ?? ""}`;
|
||||
const waiting = (ACTIVE_QUEUE_STATES as readonly string[]).includes(t.askState ?? "");
|
||||
const closedMs = t.closedAt ? Date.parse(t.closedAt) : NaN;
|
||||
const expiresAt = Math.max(Number(slackMicros(t.threadTs)! / 1000n), Number.isFinite(closedMs) ? closedMs : 0) + THREAD_WINDOW_MS;
|
||||
const prior = this.threads.get(key);
|
||||
// A changed mapping gets a fresh source (so a thread Slack couldn't find is asked again); saved coverage carries over.
|
||||
next.set(key, prior && prior.mapping === mapping ? prior : { mapping, source: new ThreadRecovery({ channel: t.channel,
|
||||
threadTs: t.threadTs, token: this.opts.token, store: this.threadStore, stored: stored[key], router: this.opts.router,
|
||||
fetchImpl: this.opts.fetchImpl, now: this.now, stopped: () => this.stopped }) });
|
||||
// A changed mapping gets a fresh source; saved coverage, and its turn among retries, carry over.
|
||||
if (prior && prior.mapping === mapping) { next.set(key, { ...prior, waiting, expiresAt }); continue; }
|
||||
const source = new ThreadRecovery({ channel: t.channel, threadTs: t.threadTs, token: this.opts.token, store: this.threadStore,
|
||||
stored: stored[key], router: this.opts.router, fetchImpl: this.opts.fetchImpl, now: this.now, stopped: () => this.stopped });
|
||||
source.lastStalledAt = prior?.source.lastStalledAt;
|
||||
next.set(key, { mapping, waiting, expiresAt, source });
|
||||
}
|
||||
this.threads = next;
|
||||
return [...next.values()].map((t) => t.source).sort((a, b) => (a.key < b.key ? -1 : 1));
|
||||
const quiet = this.opts.relayed ? RELAYED_QUIET_RESCAN_MS : undefined;
|
||||
return threadOrder([...next.values()].filter((t) => t.source.due(now, t.waiting, this.listeningSince, quiet)), this.listeningSince, now);
|
||||
}
|
||||
|
||||
status() {
|
||||
@@ -226,6 +247,8 @@ export class SlackRecovery {
|
||||
"coverage means scanned available history; dead letters are custody, not delivery",
|
||||
"five-second settle margin; larger clock skew or history visibility lag remains unverified",
|
||||
"four calls / 100 entries / 15 seconds per pass, taken in turn by channels and threads; no global chronological ordering",
|
||||
"each thread is scanned once after boot or a reconnect from a gap (twice if the pass at the connect itself reached it, since that pass stops at the settle margin): any leaving the seven-day window within six hours first, then those whose ask waits on the person, then the rest, newest first; "
|
||||
+ `after that hourly while its ask waits, otherwise ${this.opts.relayed ? "every three hours (relayed: no gap is visible here)" : "daily"}`,
|
||||
],
|
||||
channels: others.map(({ limits: _limits, ...rest }) => rest),
|
||||
threads: {
|
||||
@@ -237,11 +260,41 @@ export class SlackRecovery {
|
||||
oldestPendingSeconds: pending.length ? Math.max(...pending.map(behind)) : null,
|
||||
backoff: byReason(["backoff"]),
|
||||
incomplete: byReason(["incomplete"]),
|
||||
settled: count("settled"),
|
||||
settledRescanHours: (this.opts.relayed ? RELAYED_QUIET_RESCAN_MS : QUIET_RESCAN_MS) / 3_600_000,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** The order a pass takes the due threads in, derived from saved coverage so a restart keeps it. First every thread not
|
||||
* yet scanned since live events last began: any about to leave the window, soonest first; then those whose ask waits on
|
||||
* the person, then the rest, each newest root first. Then the backstop rescans, least recently scanned first. So a boot
|
||||
* reaches the newest open ask first, after only the few threads that would otherwise never be scanned. A thread whose
|
||||
* call got nowhere since then (an error, or a page that couldn't be trusted) waits behind every other unscanned thread,
|
||||
* least recently stalled first, so a thread that keeps failing can't hold the same turn pass after pass. A thread that
|
||||
* made progress but stopped short, at the settle margin or partway through a long backlog, keeps its place. */
|
||||
function threadOrder(threads: TrackedSource[], listeningSince: number, now: number): ThreadRecovery[] {
|
||||
const floor = BigInt(listeningSince) * 1000n;
|
||||
const scannedAt = (t: ThreadRecovery) => { const through = slackMicros(t.coveredThrough)!; return through >= floor ? through : -1n; };
|
||||
const stalledAt = (t: ThreadRecovery) => (t.lastStalledAt !== undefined && t.lastStalledAt >= listeningSince ? t.lastStalledAt : -1);
|
||||
const root = (t: ThreadRecovery) => slackMicros(t.threadTs)!;
|
||||
return threads.sort((a, b) => {
|
||||
const sa = scannedAt(a.source), sb = scannedAt(b.source);
|
||||
if (sa !== sb) return sa < sb ? -1 : 1;
|
||||
if (sa === -1n) {
|
||||
const qa = stalledAt(a.source), qb = stalledAt(b.source);
|
||||
if (qa !== qb) return qa - qb;
|
||||
const ea = a.expiresAt - now < EXPIRING_SOON_MS, eb = b.expiresAt - now < EXPIRING_SOON_MS;
|
||||
if (ea !== eb) return ea ? -1 : 1;
|
||||
if (ea && a.expiresAt !== b.expiresAt) return a.expiresAt - b.expiresAt;
|
||||
}
|
||||
if (a.waiting !== b.waiting) return a.waiting ? -1 : 1;
|
||||
const ra = root(a.source), rb = root(b.source);
|
||||
return ra === rb ? (a.source.key < b.source.key ? -1 : 1) : ra > rb ? -1 : 1;
|
||||
}).map((t) => t.source);
|
||||
}
|
||||
|
||||
function rotate<T extends { key: string }>(sources: T[], last: string | undefined): T[] {
|
||||
const start = last === undefined ? 0 : sources.findIndex((s) => s.key === last) + 1;
|
||||
return [...sources.slice(start), ...sources.slice(0, start)];
|
||||
|
||||
@@ -57,7 +57,7 @@ export interface SocketInboundDeps {
|
||||
/** How often liveness is checked (default 5s). */
|
||||
livenessCheckMs?: number;
|
||||
receipts?: InboundReceiptStore;
|
||||
recovery?: { run(): Promise<void>; stop(): void };
|
||||
recovery?: { run(): Promise<void>; stop(): void; connected?(): void };
|
||||
log?: (msg: string) => void;
|
||||
}
|
||||
|
||||
@@ -344,6 +344,10 @@ export function startSocketInbound(appToken: string, router: InboundRouter, deps
|
||||
status.unechoedPosts = 0;
|
||||
status.autoReconnectSuppressedUntil = undefined; // a new connection: nothing is held back for it
|
||||
receipt({ generation: conn.generation, status: "connected" });
|
||||
// Live events had a gap unless this connection overlaps one Slack asked to refresh, which drains until Slack closes it.
|
||||
const overlapped = previous && previous !== conn && previous.retired && !previous.closed &&
|
||||
(previous.retiredFor === "refresh_requested" || previous.retiredFor === "warning");
|
||||
if (!overlapped) deps.recovery?.connected?.();
|
||||
// A connection replaced for a failed signal is presumed broken: close it now. One Slack
|
||||
// asked to refresh stays open so it drains what it is handed, until Slack closes it.
|
||||
if (previous && previous !== conn && previous.retired && !previous.closed) {
|
||||
|
||||
@@ -402,6 +402,8 @@ export interface ThreadCoverage {
|
||||
nextRetryAt?: number;
|
||||
/** Whether nextRetryAt came from Slack's rate limit, which holds the whole method. */
|
||||
rateLimited?: boolean;
|
||||
/** When Slack last answered thread_not_found (ms). Saved, so a restart doesn't ask again at once. */
|
||||
notFoundAt?: number;
|
||||
}
|
||||
|
||||
/** Scan boundaries for tracked threads, keyed `channel:thread_ts`. A separate file, so a channel
|
||||
@@ -409,6 +411,8 @@ export interface ThreadCoverage {
|
||||
export class ThreadCoverageStore {
|
||||
constructor(private readonly file: string, private readonly fsops: StateFsOps = nodeStateFs) {}
|
||||
|
||||
/** Every saved thread checkpoint, or none if the file doesn't exist yet. Throws on a malformed file or checkpoint, so a
|
||||
* bad file is never overwritten. */
|
||||
read(): Record<string, ThreadCoverage> {
|
||||
const raw = readOptional(this.file, this.fsops);
|
||||
if (raw === undefined) return {};
|
||||
@@ -418,7 +422,8 @@ export class ThreadCoverageStore {
|
||||
const start = slackMicros(c?.coverageStart), through = slackMicros(c?.coveredThrough);
|
||||
if (start === null || through === null || start > through ||
|
||||
(c.rateLimited !== undefined && typeof c.rateLimited !== "boolean") ||
|
||||
(c.nextRetryAt !== undefined && (!Number.isFinite(c.nextRetryAt) || c.nextRetryAt < 0))) throw new Error("invalid thread checkpoint");
|
||||
(c.nextRetryAt !== undefined && (!Number.isFinite(c.nextRetryAt) || c.nextRetryAt < 0)) ||
|
||||
(c.notFoundAt !== undefined && (!Number.isFinite(c.notFoundAt) || c.notFoundAt < 0))) throw new Error("invalid thread checkpoint");
|
||||
if (c.pending) {
|
||||
const upper = slackMicros(c.pending.upper);
|
||||
if (upper === null || upper <= through) throw new Error("invalid pending interval");
|
||||
|
||||
@@ -6,6 +6,15 @@ import { slackMicros, slackTimestamp, type ThreadCoverage, type ThreadCoverageSt
|
||||
import { budgetLeft, REPLIES, type MethodBackoff, type RecoveryBudget, type RecoverySource } from "./recovery-budget.js";
|
||||
/** Slack's page cap for apps distributed outside the Marketplace. */
|
||||
const PAGE = 15;
|
||||
/** How long a thread Slack couldn't find, or a settled thread already scanned since live events began, waits before it's
|
||||
* asked again. */
|
||||
export const QUIET_RESCAN_MS = 24 * 60 * 60 * 1000;
|
||||
/** The same for a thread whose ask still waits on the person: a shorter backstop, should Slack drop a live event without
|
||||
* the connection showing a gap. */
|
||||
export const WAITING_RESCAN_MS = 60 * 60 * 1000;
|
||||
/** A settled thread's backstop on a machine the hub relays to: it has no connection of its own, so it can't see a gap in
|
||||
* relayed events, and checks about as often as a full rotation of tracked threads used to. */
|
||||
export const RELAYED_QUIET_RESCAN_MS = 3 * 60 * 60 * 1000;
|
||||
|
||||
export function threadKey(channel: string, threadTs: string): string {
|
||||
return `${channel}:${threadTs}`;
|
||||
@@ -30,10 +39,11 @@ export class ThreadRecovery implements RecoverySource {
|
||||
lastScanAt: string | undefined;
|
||||
accepted = 0;
|
||||
deadLettered = 0;
|
||||
/** When a call for the thread's replies last got nowhere (ms): an error, a page that couldn't be trusted, or a landing
|
||||
* that couldn't finish. Orders retries fairly. A call that made progress, or stopped short at the settle margin or the
|
||||
* pass budget, never sets it, so that thread keeps its first-attempt priority. In memory only. */
|
||||
lastStalledAt: number | undefined;
|
||||
private coverage: ThreadCoverage;
|
||||
/** Slack can't find the thread. This source stays incomplete, without a call, until the coordinator replaces it because its
|
||||
* mapping or the configuration changed. Not proof of deletion, and never set by a transient error. */
|
||||
private missing = false;
|
||||
|
||||
constructor(private readonly opts: ThreadRecoveryOptions) {
|
||||
this.coverage = opts.stored ?? { coverageStart: opts.threadTs, coveredThrough: opts.threadTs };
|
||||
@@ -41,13 +51,35 @@ export class ThreadRecovery implements RecoverySource {
|
||||
|
||||
get key(): string { return `thread:${threadKey(this.opts.channel, this.opts.threadTs)}`; }
|
||||
get coveredThrough(): string { return this.coverage.coveredThrough; }
|
||||
get threadTs(): string { return this.opts.threadTs; }
|
||||
|
||||
/** Whether this thread has anything for a pass to do. Partway through an interval: always. Slack couldn't find it: once
|
||||
* a day (not proof of deletion, and never set by a transient error). Scanned through `listeningSince` (ms, when live
|
||||
* events last began arriving without a gap): nothing, since replies after that arrive live, until a backstop rescan
|
||||
* an hour on for an ask still waiting on the person, a day on for any other thread. Coverage must reach
|
||||
* `listeningSince` itself: a scan in the first seconds after connecting stops at the settle margin, short of it. */
|
||||
due(now: number, waiting: boolean, listeningSince: number, quietRescanMs = QUIET_RESCAN_MS): boolean {
|
||||
if (this.coverage.notFoundAt !== undefined && now - this.coverage.notFoundAt < QUIET_RESCAN_MS) {
|
||||
this.state = "incomplete"; this.reason = "thread_not_found"; return false;
|
||||
}
|
||||
if (this.coverage.pending) return true;
|
||||
const through = Number(slackMicros(this.coverage.coveredThrough)! / 1000n);
|
||||
if (through >= listeningSince && now - through < (waiting ? Math.min(WAITING_RESCAN_MS, quietRescanMs) : quietRescanMs)) {
|
||||
// A waiting ask stays "scanned" in status; only a thread nobody waits on counts as settled.
|
||||
if (!waiting && (this.state === "pending" || this.state === "scanned")) { this.state = "settled"; this.reason = undefined; }
|
||||
if (waiting && this.state === "settled") this.state = "scanned"; // its ask was reopened
|
||||
return false;
|
||||
}
|
||||
if (this.state === "settled") this.state = "pending";
|
||||
return true;
|
||||
}
|
||||
|
||||
async step(budget: RecoveryBudget, backoff: MethodBackoff): Promise<"more" | "done"> {
|
||||
if (this.opts.stopped()) return "done";
|
||||
try {
|
||||
return await this.page(budget, backoff);
|
||||
} catch {
|
||||
if (!this.opts.stopped()) { this.state = "incomplete"; this.reason = "storage-or-routing-failed"; }
|
||||
if (!this.opts.stopped()) { this.state = "incomplete"; this.reason = "storage-or-routing-failed"; this.lastStalledAt = this.opts.now(); }
|
||||
return "done";
|
||||
}
|
||||
}
|
||||
@@ -58,9 +90,11 @@ export class ThreadRecovery implements RecoverySource {
|
||||
this.coverage = next; // publish only after the atomic write succeeds
|
||||
}
|
||||
|
||||
/** One conversations.replies call over the pending interval (opening one up to five seconds ago if none is open), routing
|
||||
* each reply it returns. "more" when the interval has replies left past this page; "done" when it is covered, or the
|
||||
* thread is backing off, out of budget, not found or the page couldn't be trusted (each recorded in state and reason). */
|
||||
private async page(budget: RecoveryBudget, backoff: MethodBackoff): Promise<"more" | "done"> {
|
||||
const now = this.opts.now();
|
||||
if (this.missing) { this.state = "incomplete"; this.reason = "thread_not_found"; return "done"; }
|
||||
if (backoff.blocked(REPLIES, now)) { this.state = "backoff"; this.reason = "rate-limited"; return "done"; }
|
||||
if (this.coverage.nextRetryAt && this.coverage.nextRetryAt > now) { this.state = "backoff"; return "done"; }
|
||||
if (!this.coverage.pending) {
|
||||
@@ -83,8 +117,12 @@ export class ThreadRecovery implements RecoverySource {
|
||||
}, this.opts.fetchImpl, Math.min(5000, budget.deadline - this.opts.now()), "get-query");
|
||||
if (this.opts.stopped()) return "done";
|
||||
if (!r.ok) {
|
||||
this.lastStalledAt = now;
|
||||
const known = ["missing_scope", "not_in_channel", "channel_not_found", "thread_not_found", "invalid_auth", "token_revoked"];
|
||||
if (r.error === "thread_not_found") { this.missing = true; this.state = "incomplete"; this.reason = "thread_not_found"; return "done"; }
|
||||
if (r.error === "thread_not_found") {
|
||||
this.save({ ...c, notFoundAt: this.opts.now() });
|
||||
this.state = "incomplete"; this.reason = "thread_not_found"; return "done";
|
||||
}
|
||||
this.reason = r.status === 429 ? "rate-limited" : known.includes(r.error ?? "") ? r.error : "replies-api-unavailable";
|
||||
const retrySeconds = r.retryAfterSeconds ?? (r.status === 0 || r.status >= 500 ? 5 : 300);
|
||||
if (r.status === 429) backoff.hold(REPLIES, this.opts.now() + retrySeconds * 1000);
|
||||
@@ -100,11 +138,11 @@ export class ThreadRecovery implements RecoverySource {
|
||||
(cursor !== undefined && typeof cursor !== "string") || replies.length > limit ||
|
||||
replies.some(m => !m || typeof m !== "object" || m.thread_ts !== this.opts.threadTs || slackMicros(m.ts) === null ||
|
||||
slackMicros(m.ts)! < lower || slackMicros(m.ts)! >= upper)) {
|
||||
this.state = "incomplete"; this.reason = "invalid-replies-page"; return "done";
|
||||
this.state = "incomplete"; this.reason = "invalid-replies-page"; this.lastStalledAt = now; return "done";
|
||||
}
|
||||
const ordered = [...replies].sort((a, b) => slackMicros(a.ts)! < slackMicros(b.ts)! ? -1 : 1);
|
||||
const more = r.json.has_more === true || (typeof cursor === "string" && cursor.length > 0);
|
||||
if (more && !ordered.length) { this.state = "incomplete"; this.reason = "replies-page-no-progress"; return "done"; }
|
||||
if (more && !ordered.length) { this.state = "incomplete"; this.reason = "replies-page-no-progress"; this.lastStalledAt = now; return "done"; }
|
||||
for (const message of ordered) {
|
||||
if (this.opts.stopped()) return "done";
|
||||
if (this.opts.now() >= budget.deadline || budget.entries >= 100) { this.state = "incomplete"; this.reason = "pass-budget"; return "done"; }
|
||||
@@ -117,7 +155,7 @@ export class ThreadRecovery implements RecoverySource {
|
||||
const landed = await this.opts.router.route(ev, 0, () => !this.opts.stopped());
|
||||
if (this.opts.stopped()) return "done";
|
||||
if (landed.reason === "inflight" || landed.reason === "inactive") {
|
||||
this.state = "incomplete"; this.reason = "landing-in-progress"; return "done";
|
||||
this.state = "incomplete"; this.reason = "landing-in-progress"; this.lastStalledAt = now; return "done";
|
||||
}
|
||||
if (landed.disposition === "accepted") this.accepted++;
|
||||
if (landed.disposition === "dead-lettered") this.deadLettered++;
|
||||
@@ -126,7 +164,7 @@ export class ThreadRecovery implements RecoverySource {
|
||||
// refused by sender policy or skipped as not ingestible.
|
||||
const through = more ? slackMicros(ordered.at(-1)!.ts)! + 1n : upper;
|
||||
if (through >= upper) {
|
||||
this.save({ ...c, coveredThrough: c.pending!.upper, pending: undefined, nextRetryAt: undefined, rateLimited: undefined });
|
||||
this.save({ ...c, coveredThrough: c.pending!.upper, pending: undefined, nextRetryAt: undefined, rateLimited: undefined, notFoundAt: undefined });
|
||||
this.state = "scanned"; return "done";
|
||||
}
|
||||
this.save({ ...c, coveredThrough: slackTimestamp(through), nextRetryAt: undefined, rateLimited: undefined });
|
||||
|
||||
@@ -149,14 +149,17 @@ export class ThreadSeatMap {
|
||||
}
|
||||
|
||||
/** Missed-reply recovery's threads: roots in these channels posted at or after `since` (ISO), by the root's own Slack
|
||||
* timestamp, or closed at or after it. Never by opened_at: a rebuild from stamps sets that to the rebuild clock. */
|
||||
trackedForRecovery(channels: string[], since: string): Array<{ channel: string; threadTs: string; state: string; closedAt: string | null }> {
|
||||
* timestamp, or closed at or after it. Never by opened_at: a rebuild from stamps sets that to the rebuild clock.
|
||||
* `askState` is the state of the queue row the root belongs to (null when that row is gone). The map's own `state`
|
||||
* says only whether the root can still be posted into; it doesn't follow the ask. */
|
||||
trackedForRecovery(channels: string[], since: string): Array<{ channel: string; threadTs: string; state: string; closedAt: string | null; askState: string | null }> {
|
||||
if (!channels.length) return [];
|
||||
const rows = this.db
|
||||
.prepare(`SELECT channel, thread_ts, state, closed_at FROM thread_seat_map
|
||||
WHERE channel IN (${channels.map(() => "?").join(", ")}) AND (CAST(thread_ts AS REAL) >= ? OR closed_at >= ?)`)
|
||||
.all(...channels, Date.parse(since) / 1000, since) as Array<{ channel: string; thread_ts: string; state: string; closed_at: string | null }>;
|
||||
return rows.map((r) => ({ channel: r.channel, threadTs: r.thread_ts, state: r.state, closedAt: r.closed_at }));
|
||||
.prepare(`SELECT t.channel, t.thread_ts, t.state, t.closed_at, q.state AS ask_state FROM thread_seat_map t
|
||||
LEFT JOIN queue_items q ON q.qitem_id = t.conversation_id
|
||||
WHERE t.channel IN (${channels.map(() => "?").join(", ")}) AND (CAST(t.thread_ts AS REAL) >= ? OR t.closed_at >= ?)`)
|
||||
.all(...channels, Date.parse(since) / 1000, since) as Array<{ channel: string; thread_ts: string; state: string; closed_at: string | null; ask_state: string | null }>;
|
||||
return rows.map((r) => ({ channel: r.channel, threadTs: r.thread_ts, state: r.state, closedAt: r.closed_at, askState: r.ask_state }));
|
||||
}
|
||||
|
||||
close(threadTs: string, channel?: string): void {
|
||||
|
||||
@@ -25,26 +25,37 @@ export type NativeRuntime = "claude-code" | "codex";
|
||||
function tokens(command: string): string[] {
|
||||
// ps flattens argv: inline settings JSON retains its string delimiters.
|
||||
// A quote inside a name or filename is literal, not a shell span delimiter.
|
||||
// A `--settings` or `--mcp-config` JSON value keeps the spaces between its members too, as in
|
||||
// `{"env": {"A": "b"}}`: it stays one token until its braces balance. Braces inside its strings don't count, and
|
||||
// a brace that opens any other option's value is literal (#2508).
|
||||
const result: string[] = [];
|
||||
let start = 0;
|
||||
let quote: string | null = null;
|
||||
let depth = 0;
|
||||
const append = (end: number) => {
|
||||
if (start === end) return;
|
||||
const token = command.slice(start, end);
|
||||
result.push(token[0] === '"' && token.at(-1) === '"'
|
||||
? token.slice(1, -1) : token);
|
||||
};
|
||||
const structured = () => command.startsWith('"{', start) || command[start] === "{"
|
||||
|| command.startsWith("'{", start) || command.startsWith("--settings={", start)
|
||||
|| command.startsWith('--settings="', start) || command.startsWith("--mcp-config={", start)
|
||||
|| command.startsWith('--mcp-config="', start);
|
||||
const jsonValue = () => command.startsWith("--settings={", start) || command.startsWith("--mcp-config={", start)
|
||||
|| (command[start] === "{" && (result.at(-1) === "--settings" || result.at(-1) === "--mcp-config"));
|
||||
for (let index = 0; index < command.length; index += 1) {
|
||||
const char = command[index]!;
|
||||
if (quote !== null) {
|
||||
if (quote === '"' && char === "\\") { index += 1; continue; }
|
||||
if (char === quote) quote = null;
|
||||
} else if (char === '"' && (command.startsWith('"{', start) || command[start] === "{"
|
||||
|| command.startsWith("'{", start) || command.startsWith("--settings={", start)
|
||||
|| command.startsWith('--settings="', start) || command.startsWith("--mcp-config={", start)
|
||||
|| command.startsWith('--mcp-config="', start))) {
|
||||
} else if (char === '"' && structured()) {
|
||||
quote = char;
|
||||
} else if (/\s/.test(char)) {
|
||||
} else if (char === "{" && jsonValue()) {
|
||||
depth += 1;
|
||||
} else if (char === "}" && depth > 0) {
|
||||
depth -= 1;
|
||||
} else if (/\s/.test(char) && depth === 0) {
|
||||
append(index);
|
||||
start = index + 1;
|
||||
}
|
||||
|
||||
@@ -1057,6 +1057,7 @@ export class QueueRepository {
|
||||
private readonly promptClearRetries = new Map<string, number>();
|
||||
private resolveOccupantGeneration?: (sessionName: string) => string | null;
|
||||
private readonly resolveSessionAddresses: (name: string) => string[];
|
||||
private readonly hasPendingPostCompactStage?: (sessionName: string) => boolean;
|
||||
private readonly wakeRepo: QueueWakeRepository;
|
||||
private watchdogJobsRepo: WatchdogJobsRepository | undefined;
|
||||
/** PL-007 Workspace Primitive — true when migration 038 has applied the
|
||||
@@ -1123,6 +1124,8 @@ export class QueueRepository {
|
||||
*/
|
||||
resolveOccupantGeneration?: (sessionName: string) => string | null;
|
||||
resolveSessionAddresses?: (name: string) => string[];
|
||||
/** True while a managed compaction still owes this seat a post-compaction step (turn boundary, restore or audit). */
|
||||
hasPendingPostCompactStage?: (sessionName: string) => boolean;
|
||||
loadHumanRegistry?: () => LoadResult;
|
||||
sharedChannels?: () => ReadonlyArray<{ address: string }>;
|
||||
hostedWake?: (destination: string) => RosterWake | null;
|
||||
@@ -1140,6 +1143,7 @@ export class QueueRepository {
|
||||
this.workflowFrontierPredicate = opts?.workflowFrontierPredicate;
|
||||
this.resolveOccupantGeneration = opts?.resolveOccupantGeneration;
|
||||
this.resolveSessionAddresses = opts?.resolveSessionAddresses ?? ((name) => [name]);
|
||||
this.hasPendingPostCompactStage = opts?.hasPendingPostCompactStage;
|
||||
this.loadHumanRegistryFn = opts?.loadHumanRegistry ?? (() => loadHumanRegistry());
|
||||
this.sharedChannelsFn = opts?.sharedChannels ?? (() => sharedChannelAddresses());
|
||||
this.hostedWakeFn = opts?.hostedWake ?? defaultHostedWake;
|
||||
@@ -1860,6 +1864,9 @@ export class QueueRepository {
|
||||
}
|
||||
if (state !== "running" && state !== "idle") return;
|
||||
if (addresses.every(address => this.promptRecoveryState.get(address) === false)) return;
|
||||
// A compaction's "Compacted" is also a clear. While its turn boundary, restore or audit is still owed, a retried wake
|
||||
// would reach the agent before its restore (#2575): stay armed, and the next clear after those steps retries it.
|
||||
if ([sessionName, ...addresses].some(address => this.hasPendingPostCompactStage?.(address))) return;
|
||||
for (const address of addresses) this.promptRecoveryState.set(address, false);
|
||||
// Correlation may expand the group or release members for separate sends. Each claim must
|
||||
// independently qualify for this clear-triggered pass; general recovery keeps its broader policy.
|
||||
|
||||
@@ -1288,7 +1288,8 @@ export async function runStuckSweep(deps: StuckSweepDeps): Promise<StuckSweepRes
|
||||
: `Stuck sweep: ${c.kind} on ${c.row.qitemId} (${now_.ageMinutes} min)`,
|
||||
evidenceRef: `rig queue show ${c.row.qitemId}`,
|
||||
tags: [STUCK_SWEEP_FINDING_TAG, dedupTag, recoveryTag(c.row.qitemId),
|
||||
...(routed.has(c.row.qitemId) || now_.row.tags?.includes(ESCALATION_HOP_TAG) ? [ESCALATION_HOP_TAG] : [])],
|
||||
...(routed.has(c.row.qitemId) || now_.row.tags?.includes(ESCALATION_HOP_TAG) ? [ESCALATION_HOP_TAG] : []),
|
||||
...(now_.row.tags ?? []).filter((tag) => /^(project|mission|slice):/.test(tag))],
|
||||
// OpenRig's own finding, whoever created the stuck row.
|
||||
wakeTrigger: "openrig" as const,
|
||||
};
|
||||
|
||||
@@ -1285,9 +1285,14 @@ export class RestoreOrchestrator {
|
||||
warnings?.push(`FR-5: durably-bound session name "${pinnedTarget}" for ${node.logicalId} is invalid; falling back to a derived name.`);
|
||||
}
|
||||
} else {
|
||||
// No durably-bound session name (old data / null binding or empty
|
||||
// tmux_session) → observable derived-name fallback (PRD back-compat AC).
|
||||
warnings?.push(`FR-5: no durably-bound session name for ${node.logicalId}; falling back to a derived session name.`);
|
||||
// A supported stop deliberately removes the binding after recording an intentional exit.
|
||||
// Keep the warning for unclean/legacy state and incomplete binding rows.
|
||||
const latest = this.db.prepare(
|
||||
"SELECT status, intentionally_stopped FROM sessions WHERE node_id = ? ORDER BY created_at DESC, id DESC LIMIT 1",
|
||||
).get(nodeId) as { status: string; intentionally_stopped: number } | undefined;
|
||||
if (priorState.binding || latest?.status !== "exited" || latest.intentionally_stopped !== 1) {
|
||||
warnings?.push(`FR-5: no durably-bound session name for ${node.logicalId}; falling back to a derived session name.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Derived-name FALLBACK — only when no usable pin. The existing derive,
|
||||
|
||||
@@ -220,6 +220,12 @@ export class SeatLaunchEnvironment {
|
||||
].join("\n");
|
||||
}
|
||||
const assignments = Object.entries(env).map(([key, value]) => shellQuote(`${key}=${value}`));
|
||||
// A long Pi command is exec'd from a staged script, and exec takes one command: `exec unset …; …` started no agent.
|
||||
// Its account step goes to env as -u instead (the selected home is already an assignment above).
|
||||
if (target.runtime === "pi" && accountStep) {
|
||||
const unset = [...(clearing && homeVar ? [homeVar] : []), ...(accountHome || selectedPiHome ? scrub : [])];
|
||||
return `/usr/bin/env ${unset.map((name) => `-u ${name}`).join(" ")} ${assignments.join(" ")} PATH=${shellQuote(binDir)}:"$PATH" ${prefix}${command}`;
|
||||
}
|
||||
return `${accountStep}/usr/bin/env ${assignments.join(" ")} PATH=${shellQuote(binDir)}:"$PATH" ${prefix}${command}`;
|
||||
} catch {
|
||||
// This best-effort correction is not a new launch admission gate. Do not
|
||||
|
||||
@@ -157,7 +157,7 @@ const COSTS = [
|
||||
"A second process runs on the target's account until cutover.",
|
||||
"Messages sent to the seat while it switches are held, not typed; rig send says the seat is switching and until when, and they need sending again afterwards.",
|
||||
"A failed switch can leave the target runtime's prepared files (its seat storage and projections).",
|
||||
"Codex and Claude Code runtime switches, or Pi account-home changes; single-pane seats only.",
|
||||
"Codex, Claude Code and Pi runtime switches; single-pane seats only.",
|
||||
];
|
||||
|
||||
const PI_ACCOUNT_COSTS = [
|
||||
@@ -195,7 +195,7 @@ export class SeatRuntimeSwitchService {
|
||||
async setRuntime(input: SetRuntimeInput): Promise<SetRuntimeResult> {
|
||||
const refuse = (code: string, message: string, guidance?: string): RuntimeSwitchRefusal => ({ ok: false, code, message, ...(guidance ? { guidance } : {}) });
|
||||
if (input.runtime !== "codex" && input.runtime !== "claude-code" && input.runtime !== "pi") {
|
||||
return refuse("invalid_runtime_settings", "Choose --runtime codex or --runtime claude-code; Pi supports account-home changes on an existing Pi seat.");
|
||||
return refuse("invalid_runtime_settings", "Choose --runtime codex, --runtime claude-code or --runtime pi.");
|
||||
}
|
||||
const target: SwitchRuntime = input.runtime;
|
||||
if (input.clearAccountHome !== undefined && typeof input.clearAccountHome !== "boolean") return refuse("invalid_runtime_settings", "clearAccountHome must be true or false.");
|
||||
@@ -251,10 +251,16 @@ export class SeatRuntimeSwitchService {
|
||||
`Inspect it with rig seat status ${seatRef}; finish an interrupted one with rig seat set-runtime ${seatRef} --recover.`), switchId: open.id };
|
||||
}
|
||||
if (node.runtime !== "codex" && node.runtime !== "claude-code" && node.runtime !== "pi") {
|
||||
return refuse("runtime_switch_unsupported", `${seatRef} runs ${node.runtime ?? "an unknown runtime"}; only Codex and Claude Code seats can change runtime; Pi supports account-home changes.`);
|
||||
return refuse("runtime_switch_unsupported", `${seatRef} runs ${node.runtime ?? "an unknown runtime"}; only Codex, Claude Code and Pi seats can change runtime.`);
|
||||
}
|
||||
if ((target === "pi" || node.runtime === "pi") && target !== node.runtime) {
|
||||
return refuse("runtime_switch_unsupported", "Pi supports account-home changes on an existing Pi seat; changing to or from Pi is not supported.");
|
||||
if (node.runtime === "pi" && target === "claude-code") {
|
||||
try {
|
||||
if (readSeatLaunchWrapper(db, node.id)?.length) {
|
||||
return refuse("runtime_switch_failed", `${seatRef} has a launch_wrapper configured; switching a wrapped Pi seat to Claude Code is unsupported. Clear the wrapper and re-apply the rig spec first.`);
|
||||
}
|
||||
} catch (error) {
|
||||
return refuse("runtime_switch_failed", (error as Error).message);
|
||||
}
|
||||
}
|
||||
if (target === "pi" && input.accountHome !== undefined) {
|
||||
try {
|
||||
@@ -278,7 +284,7 @@ export class SeatRuntimeSwitchService {
|
||||
if (problem) return refuse("invalid_runtime_settings", problem);
|
||||
}
|
||||
// An account change keeps this Pi seat's current model and effort unless explicitly changed.
|
||||
if (target === "pi") {
|
||||
if (target === "pi" && node.runtime === "pi") {
|
||||
explicit.model ??= node.model ?? undefined;
|
||||
explicit.effort ??= readSeatEffort(db, node.id)?.effective ?? node.effort ?? undefined;
|
||||
}
|
||||
@@ -340,7 +346,7 @@ export class SeatRuntimeSwitchService {
|
||||
settings: materials.settings,
|
||||
permission: selection ? { mode: selection.mode, source: "explicit" } : { mode: "inherit", source: "member_or_rig_policy" },
|
||||
dropped,
|
||||
costs: target === "pi" ? PI_ACCOUNT_COSTS : COSTS,
|
||||
costs: target === "pi" && node.runtime === "pi" ? PI_ACCOUNT_COSTS : COSTS,
|
||||
notRun: materials.notRun.map((command) => `startup slash command not run by a switch: ${command}`),
|
||||
warnings: materials.warnings,
|
||||
};
|
||||
@@ -374,6 +380,7 @@ export class SeatRuntimeSwitchService {
|
||||
}): Promise<SetRuntimeResult> {
|
||||
const { db, rigRepo, sessionRegistry, tmuxAdapter: tmux } = this.deps;
|
||||
const { node, sessionName, target, materials } = ctx;
|
||||
const changesPiRuntime = target !== node.runtime && (target === "pi" || node.runtime === "pi");
|
||||
const failed = (message: string, guidance?: string): RuntimeSwitchRefusal => ({ ok: false, code: "runtime_switch_failed", committed: false, message,
|
||||
guidance: guidance ?? `${ctx.seatRef} is unchanged on ${node.runtime}. Correct the cause and try again.` });
|
||||
// Refuse unreadable target settings before an active switch can hold sends to the current occupant.
|
||||
@@ -439,7 +446,7 @@ export class SeatRuntimeSwitchService {
|
||||
claudePermissions: override.claudePermissions, kernelAuthority: override.kernelAuthority, teamPermissionDefault: override.teamPermissionDefault,
|
||||
...(override.codexPermissions ? { codexPermissions: override.codexPermissions } : {}),
|
||||
nonInterruptive: rigRepo.getRigNonInterruptive(node.rigId), launchPosture, ...(permissionMode ? { permissionMode } : {}),
|
||||
seatEnv: withPluginDirs({ ...rigRepo.getEffectiveSeatEnv(node.id, ctx.input.accountHome === undefined),
|
||||
seatEnv: withPluginDirs({ ...rigRepo.getEffectiveSeatEnv(node.id, changesPiRuntime || ctx.input.accountHome === undefined),
|
||||
...(ctx.input.accountHome !== undefined ? { [RUNTIME_HOME_VAR[target]]: ctx.input.accountHome } : {}) }, [corePluginDir, target === "claude-code" ? materials.seatSkills.pluginDir : undefined]),
|
||||
...((corePluginDir || codexCorePluginDir) && node.cwd ? { seatProjectDir: node.cwd } : {}) },
|
||||
onPaneCreated: (pane) => {
|
||||
@@ -451,7 +458,7 @@ export class SeatRuntimeSwitchService {
|
||||
const base = nodePath.join(this.deps.openrigHome ?? getOpenRigHome(), "state", "pi");
|
||||
const source = piSeatPaths(piAccountStateRoot(db, base, sessionName), sessionName).agentDir;
|
||||
const destination = piSeatPaths(piAccountSwitchStateRoot(base, successorGeneration), sessionName).agentDir;
|
||||
if (fs.existsSync(source)) fs.cpSync(source, destination, { recursive: true, verbatimSymlinks: true,
|
||||
if (node.runtime === "pi" && fs.existsSync(source)) fs.cpSync(source, destination, { recursive: true, verbatimSymlinks: true,
|
||||
filter: file => file !== nodePath.join(source, "auth.json") && file !== nodePath.join(source, ".openrig-account") });
|
||||
// Clearing without a configured home returns to the original seat-local login, if it had one.
|
||||
const baseline = piSeatPaths(base, sessionName).agentDir;
|
||||
@@ -469,7 +476,10 @@ export class SeatRuntimeSwitchService {
|
||||
? retireSeatCoreSkillCopies(materials.seatSkills, corePluginDir, { namesKept: false }) : [];
|
||||
return materials.prepare(binding, adapter).then((notes) => [...coreCopyNotes, ...notes]);
|
||||
},
|
||||
binding: { ...materials.bindingAdditions(), ...(ctx.input.accountHome !== undefined ? { accountHome: ctx.input.accountHome, accountToken: ctx.input.accountToken ?? null }
|
||||
binding: { ...materials.bindingAdditions(),
|
||||
// Explicit nulls make adapters resolve target defaults without reading a legacy source token.
|
||||
...(changesPiRuntime ? { accountHome: null, accountToken: null } : {}),
|
||||
...(ctx.input.accountHome !== undefined ? { accountHome: ctx.input.accountHome, accountToken: ctx.input.accountToken ?? null }
|
||||
: ctx.input.clearAccountHome || node.accountHome ? { accountHome: null, ...(node.accountHome ? { accountToken: null } : {}) } : {}), runtimeSwitchFrom: { runtime: node.runtime!, session: sessionName, pane: ctx.predecessorPane, generation: ctx.predecessorGeneration } } },
|
||||
});
|
||||
if (!launch.ok) {
|
||||
@@ -587,11 +597,11 @@ export class SeatRuntimeSwitchService {
|
||||
db.prepare(`UPDATE nodes SET runtime = ?, model = ?, effort = ?, effort_choice = ?, advisor = ?, codex_config_profile = ?,
|
||||
${spec ? "resolved_spec_name = ?, resolved_spec_version = ?, resolved_spec_hash = ?, restore_policy = ?," : ""}
|
||||
occupant_lifecycle = 'active', continuity_outcome = 'fresh', handover_result = 'complete', previous_occupant = ?, handover_at = ? WHERE id = ?`)
|
||||
.run(target, materials.settings.model.value, target === "pi" && ctx.input.effort === undefined ? node.effort ?? null : materials.settings.effort.value,
|
||||
target === "pi" && ctx.input.effort === undefined ? readSeatEffort(db, node.id)?.choice ?? null : null, materials.settings.advisor.value, materials.settings.codexConfigProfile.value,
|
||||
.run(target, materials.settings.model.value, target === "pi" && node.runtime === "pi" && ctx.input.effort === undefined ? node.effort ?? null : materials.settings.effort.value,
|
||||
target === "pi" && node.runtime === "pi" && ctx.input.effort === undefined ? readSeatEffort(db, node.id)?.choice ?? null : null, materials.settings.advisor.value, materials.settings.codexConfigProfile.value,
|
||||
...(spec ? [spec.name, spec.version, spec.hash, spec.restorePolicy] : []), ctx.sessionName, at, node.id);
|
||||
db.prepare("UPDATE nodes SET account_home = ?, claude_token_ref = CASE WHEN ? THEN ? ELSE claude_token_ref END WHERE id = ?")
|
||||
.run(ctx.input.accountHome ?? null, ctx.input.accountHome !== undefined || node.accountHome ? 1 : 0,
|
||||
.run(ctx.input.accountHome ?? null, ctx.input.accountHome !== undefined || node.accountHome || (target !== node.runtime && (target === "pi" || node.runtime === "pi")) ? 1 : 0,
|
||||
ctx.input.accountToken ? JSON.stringify(ctx.input.accountToken) : null, node.id);
|
||||
new NativePermissionStore(db).write(node.id, ctx.selection, ctx.input.requestedBy?.trim() || ctx.sessionName, ctx.input.reason);
|
||||
// One explicit Codex choice: the switch's mode replaces the seat's envelope here, and the event says so.
|
||||
|
||||
@@ -61,7 +61,7 @@ import { getLatestStartupFailedEvent, hasClaudeResumeIdentityWork, isRecoverable
|
||||
// (daintree #3938) cite Claude 1-3s / Codex 3-5s inter-tool-call gaps with a 6s idle debounce. A 15s
|
||||
// window comfortably spans one inter-tool gap (so a mid-turn reading stays trusted) while refusing to
|
||||
// authorize a send from a reading tens of seconds old. (EXA: agtx#14, daintree#3938.)
|
||||
const SEND_READINESS_FRESHNESS_MS = 15_000;
|
||||
export const SEND_READINESS_FRESHNESS_MS = 15_000;
|
||||
|
||||
// transport.verify_input_box: each extra input-box read gives up after this long and reads `unavailable`. A deadline
|
||||
// budget, not a guarantee: the pre-Enter read comes after the existing 200 ms wait and can add about this much before
|
||||
@@ -2099,6 +2099,8 @@ export interface SendResult {
|
||||
pastedOverInputBox?: InputBoxAnnotation["status"];
|
||||
/** A `preserveInput` hold: the input box reading it held on. */
|
||||
heldInputBox?: InputBoxAnnotation["status"];
|
||||
/** The existing preserve-input reading, without the draft or suggestion text. No extra capture. */
|
||||
heldInputBoxReading?: Pick<InputBoxAnnotation, "status" | "confidence" | "evidence" | "cursor">;
|
||||
}
|
||||
|
||||
/** One reading of a Claude Code or Codex input box: `exact` (the sent text, word boundaries kept), `ambiguous`
|
||||
@@ -3035,11 +3037,12 @@ export class SessionTransport {
|
||||
} : null;
|
||||
let existingInput: ExistingInput | undefined;
|
||||
let pastedOverInputBox: InputBoxAnnotation["status"] | undefined, heldInputBox: InputBoxAnnotation["status"] | undefined;
|
||||
let heldInputBoxReading: SendResult["heldInputBoxReading"];
|
||||
let overrideBeforePaste: (() => Promise<void>) | undefined;
|
||||
const observe = (result: SendResult): SendResult => {
|
||||
if (checkedIdentity) result = { ...result, checkedIdentity, identityContract: 1 };
|
||||
if (pastedOverInputBox) result = { ...result, pastedOverInputBox };
|
||||
if (heldInputBox && !result.ok) result = { ...result, heldInputBox };
|
||||
if (heldInputBox && !result.ok) result = { ...result, heldInputBox, ...(heldInputBoxReading ? { heldInputBoxReading } : {}) };
|
||||
if (existingInput) result = { ...result, existingInput };
|
||||
if (result.ok && runtimeAdvisory) result = { ...result, warning: [runtimeAdvisory, result.warning].filter(Boolean).join(" ") };
|
||||
if (observed && this.captureObserver) {
|
||||
@@ -3547,6 +3550,10 @@ export class SessionTransport {
|
||||
const typed = box?.status === "typed" || box?.status === "typed-with-suggestion";
|
||||
if (!clear && (opts?.preserveInput !== "typed" || typed)) {
|
||||
heldInputBox = box?.status ?? "unknown";
|
||||
if (box) {
|
||||
const { status, confidence, evidence, cursor } = box;
|
||||
heldInputBoxReading = { status, confidence, evidence, cursor };
|
||||
}
|
||||
throw new DeliveryGuardError(box?.status === "typed" ? "draft_input_busy" : "draft_input_unknown",
|
||||
`Managed input waits for an empty composer (it reads ${box?.status ?? "unknown"}). Your draft was left untouched; inspect the pane before retrying.`);
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@ const normalize = (text: string): string => text.replace(/\s+/g, "");
|
||||
// so a mode-bar line is also known by its leading marker.
|
||||
const VIM_MODE_PREFIX = /^-- INSERT --\s*/;
|
||||
const MODE_BAR_START = /^(?:⏵⏵|⏸)(?:\s|$)/;
|
||||
// The suffix is a circle glyph and an effort level, e.g. `◐ medium` or `● high`; arbitrary text is not one.
|
||||
const PASTE_HINT = /^paste again to expand(?:\s{2,}[◐●○◑◒◓] (?:low|medium|high|xhigh|max) · \/effort)?$/;
|
||||
// Only a known effort hint or installed-update notice may share the paste-hint row.
|
||||
const PASTE_HINT = /^paste again to expand(?:\s{2,}(?:[◐●○◑◒◓] (?:low|medium|high|xhigh|max) · \/effort|✔ Update installed · (?:\/restart to apply|Restart to (?:apply|update))))?$/;
|
||||
export const isClaudePasteHint = (line: string): boolean => PASTE_HINT.test(line.replace(VIM_MODE_PREFIX, ""));
|
||||
export const isComposerFooter = (line: string): boolean => {
|
||||
const bar = line.replace(VIM_MODE_PREFIX, "");
|
||||
|
||||
@@ -1,6 +1,51 @@
|
||||
// The tile commands earlier releases generated, verbatim, so a view kept across an upgrade is recognised only by
|
||||
// a command OpenRig actually generated for the same seat. Kept views exist from 0.7.5 on.
|
||||
// 0.7.9: tile-attach.ts at 7cabac44 (#2464). 0.7.5 through 0.7.8: tile-attach.ts at 80616b79 (#2133).
|
||||
// 0.7.10: tile-attach.ts at 6a46172d (#2634). 0.7.9: tile-attach.ts at 7cabac44 (#2464). 0.7.5 through 0.7.8:
|
||||
// tile-attach.ts at 80616b79 (#2133).
|
||||
export function tileAttachArgv0710(
|
||||
tmux: string, server: readonly string[], session: string, readOnly: boolean,
|
||||
retrySeconds = 30,
|
||||
): string[] {
|
||||
// Writable tiles contribute their visible size even when an older client is attached.
|
||||
// Read-only tiles keep tmux's observer sizing behaviour.
|
||||
const ro = readOnly ? "-r " : "";
|
||||
// No apostrophes or control characters in the script: Herdr also stores this
|
||||
// argv as its restart command. All external values stay positional arguments.
|
||||
const script = [
|
||||
'target=$1; shift;',
|
||||
`snapshot=$("$@" display-message -p -t "=$target:" "#{pid} #{session_id}:#{session_name}" 2>/dev/null) || { exec "$@" attach ${ro}-t "=$target"; };`,
|
||||
// display-message may succeed with empty/fallback context for a missing target.
|
||||
`if [ "\${snapshot#*:}" != "$target" ]; then exec "$@" attach ${ro}-t "=$target"; fi;`,
|
||||
'snapshot=${snapshot%%:*};',
|
||||
'node=$("$@" show-environment -t "${snapshot#* }" OPENRIG_NODE_ID 2>/dev/null) || node=;',
|
||||
'case "$node" in OPENRIG_NODE_ID=?*) ;; *) node=;; esac;',
|
||||
'if [ -z "$node" ]; then',
|
||||
'printf "%s\\n" "No managed identity for $target; reopen this view after replacement.";',
|
||||
`exec "$@" attach ${ro}-t "\${snapshot#* }"; fi;`,
|
||||
'while :; do',
|
||||
`"$@" attach ${ro}-t "\${snapshot#* }";`,
|
||||
`left=${retrySeconds}; same=0;`,
|
||||
'while [ "$left" -gt 0 ]; do',
|
||||
'next=$("$@" display-message -p -t "=$target:" "#{pid} #{session_id}:#{session_name}" 2>/dev/null) || next=;',
|
||||
'if [ "${next#*:}" = "$target" ]; then next=${next%%:*}; else next=; fi;',
|
||||
'if [ -n "$next" ]; then',
|
||||
'identity=$("$@" show-environment -t "${next#* }" OPENRIG_NODE_ID 2>/dev/null) || identity=;',
|
||||
// A session can disappear between the two metadata reads; unknown waits.
|
||||
'if [ -n "$identity" ]; then',
|
||||
'if [ "$identity" != "$node" ]; then printf "%s\\n" "Seat identity changed for $target; reopen its view."; exit 1; fi;',
|
||||
'if [ "$next" != "$snapshot" ]; then snapshot=$next; break; fi;',
|
||||
// The stop adapter detaches BEFORE killing. Give that kill a chance to
|
||||
// finish; an ordinary Ctrl-b d leaves the original alive and ends the tile.
|
||||
'same=$((same + 1)); if [ "$same" -ge 3 ]; then exit 0; fi;',
|
||||
'else same=0; fi; else same=0; fi;',
|
||||
'sleep 1; left=$((left - 1));',
|
||||
'done;',
|
||||
'if [ "$left" -eq 0 ]; then printf "%s\\n" "No replacement for $target; reopen the view with rig terminal open."; exit 0; fi;',
|
||||
'done',
|
||||
].join(" ");
|
||||
return ["/bin/sh", "-c", script, "openrig-tile", session, tmux, ...server];
|
||||
}
|
||||
|
||||
export function tileAttachArgv079(
|
||||
tmux: string, server: readonly string[], session: string, readOnly: boolean,
|
||||
retrySeconds = 30,
|
||||
|
||||
@@ -247,15 +247,17 @@ export class HerdrViewer {
|
||||
}
|
||||
const process = body(await this.run(["pane", "process-info", "--pane", current.pane_id]));
|
||||
const info = process.process_info as { foreground_processes?: Array<{ argv?: unknown }> } | undefined;
|
||||
// The foreground tmux client carries the exact server/session argv. The
|
||||
// shared attach wrapper may be its parent; no mutable title/agent label.
|
||||
// The tile's own script (attach.sh execs it) carries the exact server/session argv, and stays in the foreground
|
||||
// while it views the seat and while it attaches. Kept tiles from earlier releases may show only their foreground
|
||||
// tmux client, with the same exact argv. No mutable title/agent label.
|
||||
const argv = entry.argv;
|
||||
const server = argv.slice(6); // tileAttachArgv: sh,-c,script,name,session,tmux,...server
|
||||
// Recognize legacy ignore-size writable clients as well as current sizing attaches.
|
||||
// Recognize legacy ignore-size writable clients as well as sizing attaches.
|
||||
const flags = entry.readOnly ? [["-r"]] : [["-f", "ignore-size"], []];
|
||||
const targetName = argv[4]!;
|
||||
const verified = info?.foreground_processes?.some(p => {
|
||||
const actual = p.argv;
|
||||
if (Array.isArray(actual) && actual.length === argv.length && argv.every((word, index) => actual[index] === word)) return true;
|
||||
return Array.isArray(actual) && flags.some(flag => {
|
||||
const expected = [argv[5]!, ...server, "attach", ...flag, "-t"];
|
||||
return expected.every((word, index) => actual[index] === word)
|
||||
|
||||
@@ -1,31 +1,129 @@
|
||||
import { tileAttachArgv078, tileAttachArgv079 } from "./earlier-tile-attach.js";
|
||||
import { tileAttachArgv078, tileAttachArgv079, tileAttachArgv0710 } from "./earlier-tile-attach.js";
|
||||
|
||||
// A tile owns its attach process; no daemon watcher or steady-state polling.
|
||||
// A tile owns its view and attach processes; no daemon watcher. Its view reads the seat only after new activity.
|
||||
// Session names survive fresh launch, handover and restart. The node identity
|
||||
// must survive too: never follow a same-name session belonging to another seat.
|
||||
export function tileAttachArgv(
|
||||
tmux: string, server: readonly string[], session: string, readOnly: boolean,
|
||||
retrySeconds = 30,
|
||||
retrySeconds = 30, idleSeconds = 30,
|
||||
): string[] {
|
||||
// Writable tiles contribute their visible size even when an older client is attached.
|
||||
// Read-only tiles keep tmux's observer sizing behaviour.
|
||||
const ro = readOnly ? "-r " : "";
|
||||
// A tmux window has one size, and a client narrower than it sees only the left part of each line. So a tile shows the
|
||||
// seat by mirroring it rather than attaching, and attaches only while the person types in it.
|
||||
// - view: the seat's own screen, printed into the tile, whose terminal wraps each line at the tile's width. The tile is
|
||||
// then no tmux client of the seat, so it never sizes, reflows or types into it. The screen is read again only when
|
||||
// tmux records new activity in the window (read each second), and the view ends when the session goes, or, for a
|
||||
// writable tile, when the person presses a key.
|
||||
// - use (writable tiles): that first key goes to the seat and the tile attaches, sizing the seat while it's used, as
|
||||
// any client the person types in does. Once the tile has had no key for idleSeconds it detaches back to the view,
|
||||
// as any other detach of it does.
|
||||
// - A read-only tile only views.
|
||||
const open = readOnly
|
||||
? 'open() { t=$1; shift; stty -echo -isig -ixon 2>/dev/null; view "$t" "" "$@"; };'
|
||||
: 'open() { t=$1; shift; while use "$t" "$@"; do :; done; };';
|
||||
// No apostrophes or control characters in the script: Herdr also stores this
|
||||
// argv as its restart command. All external values stay positional arguments.
|
||||
const script = [
|
||||
// Files the script makes (the first input) are readable by this user alone.
|
||||
'umask 077; b0=$(printf "\\033[200~"); b1=$(printf "\\033[201~");',
|
||||
// A box-drawing rule (Claude Code draws them as wide as its window) is cut to the tile's width, so a narrow tile
|
||||
// doesn't spend most of its rows on one wrapped rule. The cut works on bytes, so no character can blank the view.
|
||||
// One tmux call a second reads the activity time and the screen together; the screen is drawn only after new
|
||||
// activity, and once more after it, since tmux records activity in whole seconds. The tile's width is read again
|
||||
// only after it's resized.
|
||||
// A session id is unique only within one tmux server, and a restarted server numbers its sessions from $0 again.
|
||||
// So each read also checks the server, and for a managed seat its identity, and anything else ends the view.
|
||||
'view() { t=$1; keys=$2; shift 2; last=; again=; d=$(printf "\\342\\224\\200"); nl=$(printf "\\nx"); nl=${nl%x};',
|
||||
'case "$t" in =*) p=;; *) p=${snapshot%% *};; esac; n=$node; cols=; rw=; rule=; trap "cols=" WINCH;',
|
||||
'while :; do',
|
||||
'if [ -n "$n" ]; then r=$("$@" display-message -p -t "$t" "#{pid} #{window_activity}" \\; show-environment -t "$t" OPENRIG_NODE_ID \\; capture-pane -p -e -t "$t" 2>/dev/null) || break;',
|
||||
'else r=$("$@" display-message -p -t "$t" "#{pid} #{window_activity}" \\; capture-pane -p -e -t "$t" 2>/dev/null) || break; fi;',
|
||||
'l=${r%%"$nl"*}; case "$r" in *"$nl"*) s=${r#*"$nl"};; *) s=;; esac; a=${l#* };',
|
||||
'[ -n "$a" ] && { [ -z "$p" ] || [ "${l%% *}" = "$p" ]; } || break;',
|
||||
'if [ -n "$n" ]; then [ "${s%%"$nl"*}" = "$n" ] || break; case "$s" in *"$nl"*) s=${s#*"$nl"};; *) s=;; esac; fi;',
|
||||
'if [ "$a" != "$last" ] || [ -n "$again" ]; then',
|
||||
'if [ "$a" != "$last" ]; then again=1; else again=; fi;',
|
||||
'case "$s" in *"$d$d$d$d"*) if [ -z "$cols" ]; then cols=$(stty size 2>/dev/null) || cols=; cols=${cols#* }; fi;',
|
||||
'case "$cols" in ""|*[!0-9]*) ;; *) if [ "$cols" -gt 0 ] && [ "$cols" -le 255 ]; then',
|
||||
'if [ "$cols" != "$rw" ]; then rule=$(printf "%${cols}s" "" | LC_ALL=C sed "s/ /$d/g"); rw=$cols; fi;',
|
||||
's=$(printf "%s\\n" "$s" | LC_ALL=C sed "s/\\($d\\)\\{$cols,\\}/$rule/g"); fi;; esac;; esac;',
|
||||
'printf "\\033[H\\033[2J%s\\n" "$s"; last=$a; fi;',
|
||||
// A writable tile waits for its next read on its own input, so the first key ends the wait at once. The input
|
||||
// goes to a file, which keeps every byte. A wait that can't run ends the view rather than reading without pause.
|
||||
'if [ -n "$keys" ]; then dd bs=4096 count=1 of="$f" 2>/dev/null || return 1; if [ -s "$f" ]; then return 0; fi;',
|
||||
'else sleep 1 || return 1; fi;',
|
||||
'done; return 1; };',
|
||||
// The first input goes through tmux as an attached client's does, and only while it's still the same seat. A
|
||||
// writable tile's terminal frames pastes while it shows, and until the tile attaches. The whole first burst is read
|
||||
// into a file, through any paste it opens, with a UTF-8 character it ends inside completed. Nothing passes through
|
||||
// a shell variable, so every byte is kept, NUL included, and flow-control and other special keys are read as keys.
|
||||
// - Keys alone go in the attach command itself, through the new client (send-keys -K): tmux runs them for that
|
||||
// client before anything typed since, so its key table applies, the prefix included.
|
||||
// - A burst with a paste goes to the pane in order before the tile attaches: keys, then the paste (paste-buffer
|
||||
// frames it if the agent asked), then keys. tmux reads anything typed since only once the tile has attached.
|
||||
// The tile's terminal keeps that one mode (raw, paste framing on) from the first key until tmux takes the terminal at
|
||||
// attach, and tmux puts it back at detach; only a tile that doesn't attach restores it itself.
|
||||
// Without a terminal to read keys from, or a file to keep them in, a writable tile only views.
|
||||
'use() { t=$1; shift; f=$(mktemp 2>/dev/null) || f=;',
|
||||
'if [ -n "$f" ] && stty -icanon -echo -isig -iexten -ixon -icrnl min 0 time 10 2>/dev/null; then',
|
||||
'printf "\\033[?2004h"; view "$t" 1 "$@"; seen=$?; [ "$seen" -ne 0 ] || take;',
|
||||
'else view "$t" "" "$@"; seen=$?; fi;',
|
||||
'if [ "$seen" -ne 0 ] || ! here "$@"; then printf "\\033[?2004l"; stty sane 2>/dev/null; [ -z "$f" ] || rm -f "$f" "$f".*; return 1; fi;',
|
||||
'pz=; if LC_ALL=C grep -aqF -- "$b0" "$f"; then pz=1; put "$@"; fi;',
|
||||
'me=$(tty 2>/dev/null) || me=; w=; if [ -n "$me" ]; then idle "$me" "$@" & w=$!; fi;',
|
||||
'if [ -z "$pz" ] && [ -s "$f" ]; then go "$@"; else "$@" attach -t "$t"; fi;',
|
||||
'if [ -n "$w" ]; then kill "$w" 2>/dev/null; wait "$w" 2>/dev/null; fi; rm -f "$f" "$f".*; return 0; };',
|
||||
'take() { i=0; while [ "$i" -lt 256 ]; do',
|
||||
'if [ $(($(LC_ALL=C grep -abo -F -- "$b0" "$f" | wc -l))) -gt $(($(LC_ALL=C grep -abo -F -- "$b1" "$f" | wc -l))) ];',
|
||||
'then stty min 0 time 10 2>/dev/null; else stty min 0 time 1 2>/dev/null; i=$((i + 1)); fi;',
|
||||
'dd bs=4096 count=1 of="$f.n" 2>/dev/null && [ -s "$f.n" ] || break; cat "$f.n" >> "$f"; done; rm -f "$f.n";',
|
||||
'm=0; for c in $(tail -c 4 "$f" | od -An -tu1 -v); do',
|
||||
'if [ "$c" -lt 128 ]; then m=0; elif [ "$c" -lt 192 ]; then [ "$m" -eq 0 ] || m=$((m - 1)); elif [ "$c" -lt 224 ]; then m=1;',
|
||||
'elif [ "$c" -lt 240 ]; then m=2; elif [ "$c" -lt 248 ]; then m=3; else m=0; fi; done;',
|
||||
'if [ "$m" -gt 0 ]; then stty min 0 time 10 2>/dev/null; dd bs=1 count="$m" 2>/dev/null >> "$f"; fi; };',
|
||||
'here() { v=$("$@" display-message -p -t "$t" "#{pid} #{session_id}" 2>/dev/null) || return 1; [ -n "${v#* }" ] || return 1;',
|
||||
'[ -z "$p" ] || [ "${v%% *}" = "$p" ] || return 1;',
|
||||
'[ -z "$n" ] || [ "$("$@" show-environment -t "$t" OPENRIG_NODE_ID 2>/dev/null)" = "$n" ]; };',
|
||||
'kn() { case "$1" in 0) k=C-Space;; 9) k=Tab;; 13) k=Enter;; 27) k=Escape;; 28) k=C-\\\\;; 29) k=C-];; 30) k=C-^;; 31) k=C-_;; 127) k=BSpace;;',
|
||||
'*) k=C-$(printf "\\\\$(printf %o $(($1 + 96)))");; esac; };',
|
||||
// Keys alone: in the attach command, through the new client.
|
||||
'go() { set -- "$@" attach -t "$t"; x=0; r0=0; for c in $(od -An -tu1 -v "$f"); do if [ "$c" -lt 32 ] || [ "$c" -eq 127 ]; then',
|
||||
'if [ "$x" -gt "$r0" ]; then tx "$r0" "$x"; set -- "$@" \\; send-keys -K -l -- "$u"; fi;',
|
||||
'kn "$c"; set -- "$@" \\; send-keys -K "$k"; r0=$((x + 1)); fi; x=$((x + 1)); done;',
|
||||
'if [ "$x" -gt "$r0" ]; then tx "$r0" "$x"; set -- "$@" \\; send-keys -K -l -- "$u"; fi; "$@"; };',
|
||||
// tmux ends a command at an argument ending in ;, so a run of text keeps its last ; escaped.
|
||||
'tx() { u=$(dd if="$f" bs=1 skip="$1" count=$(($2 - $1)) 2>/dev/null); case "$u" in *";") u="${u%?}\\\\;";; esac; };',
|
||||
// A burst with a paste: each part to the pane, in order, before the tile attaches.
|
||||
'put() { z=$(($(wc -c < "$f"))); pos=0; j=0; while [ "$pos" -lt "$z" ]; do',
|
||||
'a=$(dd if="$f" bs=1 skip="$pos" 2>/dev/null | LC_ALL=C grep -abo -F -- "$b0" | head -n 1); a=${a%%:*};',
|
||||
'if [ -z "$a" ]; then kz "$pos" "$z" "$@"; break; fi; kz "$pos" $((pos + a)) "$@"; a=$((pos + a + 6));',
|
||||
'e=$(dd if="$f" bs=1 skip="$a" 2>/dev/null | LC_ALL=C grep -abo -F -- "$b1" | head -n 1); e=${e%%:*}; [ -n "$e" ] || e=$((z - a));',
|
||||
'j=$((j + 1)); dd if="$f" of="$f.p$j" bs=1 skip="$a" count="$e" 2>/dev/null;',
|
||||
'"$@" load-buffer -b "openrig-tile-$$-$j" "$f.p$j" \\; paste-buffer -p -d -b "openrig-tile-$$-$j" -t "$t" 2>/dev/null; pos=$((a + e + 6)); done; };',
|
||||
'kz() { u=$1; y=$2; shift 2; [ "$y" -gt "$u" ] || return 0; x=$u; r0=$u;',
|
||||
'for c in $(dd if="$f" bs=1 skip="$u" count=$((y - u)) 2>/dev/null | od -An -tu1 -v); do if [ "$c" -lt 32 ] || [ "$c" -eq 127 ]; then',
|
||||
'if [ "$x" -gt "$r0" ]; then tx "$r0" "$x"; "$@" send-keys -t "$t" -l -- "$u" 2>/dev/null; fi;',
|
||||
'kn "$c"; "$@" send-keys -t "$t" "$k" 2>/dev/null; r0=$((x + 1)); fi; x=$((x + 1)); done;',
|
||||
'if [ "$x" -gt "$r0" ]; then tx "$r0" "$x"; "$@" send-keys -t "$t" -l -- "$u" 2>/dev/null; fi; };',
|
||||
'idle() { me=$1; shift; while sleep 1; do',
|
||||
'a=$("$@" display-message -p -c "$me" "#{client_activity}" 2>/dev/null) || return 0;',
|
||||
`if [ $(($(date +%s) - a)) -ge ${idleSeconds} ]; then "$@" detach-client -t "$me" 2>/dev/null; return 0; fi;`,
|
||||
'done; };',
|
||||
open,
|
||||
'target=$1; shift;',
|
||||
`snapshot=$("$@" display-message -p -t "=$target:" "#{pid} #{session_id}:#{session_name}" 2>/dev/null) || { exec "$@" attach ${ro}-t "=$target"; };`,
|
||||
// When the seat can't be read, any view ends as soon as it starts, and tmux's own error (or the shell's, when
|
||||
// tmux isn't found) says why.
|
||||
`snapshot=$("$@" display-message -p -t "=$target:" "#{pid} #{session_id}:#{session_name}" 2>/dev/null) || { open "=$target" "$@"; exec "$@" has-session -t "=$target"; };`,
|
||||
// display-message may succeed with empty/fallback context for a missing target.
|
||||
`if [ "\${snapshot#*:}" != "$target" ]; then exec "$@" attach ${ro}-t "=$target"; fi;`,
|
||||
`if [ "\${snapshot#*:}" != "$target" ]; then open "=$target" "$@"; exec "$@" has-session -t "=$target"; fi;`,
|
||||
'snapshot=${snapshot%%:*};',
|
||||
'node=$("$@" show-environment -t "${snapshot#* }" OPENRIG_NODE_ID 2>/dev/null) || node=;',
|
||||
'case "$node" in OPENRIG_NODE_ID=?*) ;; *) node=;; esac;',
|
||||
'if [ -z "$node" ]; then',
|
||||
'printf "%s\\n" "No managed identity for $target; reopen this view after replacement.";',
|
||||
`exec "$@" attach ${ro}-t "\${snapshot#* }"; fi;`,
|
||||
`open "\${snapshot#* }" "$@"; exit 0; fi;`,
|
||||
'while :; do',
|
||||
`"$@" attach ${ro}-t "\${snapshot#* }";`,
|
||||
`left=${retrySeconds}; same=0;`,
|
||||
`open "\${snapshot#* }" "$@";`,
|
||||
`left=${retrySeconds};`,
|
||||
'while [ "$left" -gt 0 ]; do',
|
||||
'next=$("$@" display-message -p -t "=$target:" "#{pid} #{session_id}:#{session_name}" 2>/dev/null) || next=;',
|
||||
'if [ "${next#*:}" = "$target" ]; then next=${next%%:*}; else next=; fi;',
|
||||
@@ -34,11 +132,9 @@ export function tileAttachArgv(
|
||||
// A session can disappear between the two metadata reads; unknown waits.
|
||||
'if [ -n "$identity" ]; then',
|
||||
'if [ "$identity" != "$node" ]; then printf "%s\\n" "Seat identity changed for $target; reopen its view."; exit 1; fi;',
|
||||
'if [ "$next" != "$snapshot" ]; then snapshot=$next; break; fi;',
|
||||
// The stop adapter detaches BEFORE killing. Give that kill a chance to
|
||||
// finish; an ordinary Ctrl-b d leaves the original alive and ends the tile.
|
||||
'same=$((same + 1)); if [ "$same" -ge 3 ]; then exit 0; fi;',
|
||||
'else same=0; fi; else same=0; fi;',
|
||||
// A view that ended while its session is still there (a failed read) views it again.
|
||||
'if [ "$next" = "$snapshot" ]; then sleep 1 || exit 1; fi; snapshot=$next; break;',
|
||||
'fi; fi;',
|
||||
'sleep 1; left=$((left - 1));',
|
||||
'done;',
|
||||
'if [ "$left" -eq 0 ]; then printf "%s\\n" "No replacement for $target; reopen the view with rig terminal open."; exit 0; fi;',
|
||||
@@ -47,13 +143,13 @@ export function tileAttachArgv(
|
||||
return ["/bin/sh", "-c", script, "openrig-tile", session, tmux, ...server];
|
||||
}
|
||||
|
||||
/** Whether a kept tile is the one an earlier release generated for the same seat: exactly the command 0.7.9's, or
|
||||
* 0.7.5-0.7.8's, script makes for the current tile's session, tmux, server and read-only intent. An upgrade changes
|
||||
/** Whether a kept tile is the one an earlier release generated for the same seat: exactly the command 0.7.10's, 0.7.9's,
|
||||
* or 0.7.5-0.7.8's, script makes for the current tile's session, tmux, server and read-only intent. An upgrade changes
|
||||
* only OpenRig's own script; any other difference, an edited script included, is a different attachment. */
|
||||
export function earlierTileAttachment(kept: readonly string[], current: readonly string[], readOnly: boolean): boolean {
|
||||
if (current.length < 6) return false;
|
||||
const [session, tmux, ...server] = current.slice(4) as [string, string, ...string[]];
|
||||
return [tileAttachArgv079, tileAttachArgv078].some((make) => {
|
||||
return [tileAttachArgv0710, tileAttachArgv079, tileAttachArgv078].some((make) => {
|
||||
const earlier = make(tmux, server, session, readOnly);
|
||||
return earlier.length === kept.length && earlier.every((word, i) => word === kept[i]);
|
||||
});
|
||||
|
||||
@@ -259,7 +259,7 @@ export class WorkflowProjector {
|
||||
if (!specRow) {
|
||||
throw new WorkflowProjectorError(
|
||||
"spec_not_cached",
|
||||
`workflow spec ${instance.workflowName}@${instance.workflowVersion} is not in the spec cache. Re-run validate to refresh the cache.`,
|
||||
`workflow spec ${instance.workflowName}@${instance.workflowVersion} is not in the spec cache. Re-register its file (touch it in the workflows folder for the next scan, or instantiate it by path) to refresh the cache.`,
|
||||
{ workflowName: instance.workflowName, workflowVersion: instance.workflowVersion },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -367,9 +367,9 @@ export class WorkflowRuntime {
|
||||
};
|
||||
|
||||
validate(specPath: string, seatLivenessCheck?: SeatLivenessCheckFn): ValidationResult & { exceptionReadiness: ExceptionReadiness } {
|
||||
const specRow = this.specCache.readThrough(specPath);
|
||||
return { ...this.validator.validate(specRow.spec, seatLivenessCheck ?? this.seatLivenessCheck, this.hostRegistryLookup),
|
||||
exceptionReadiness: this.readExceptionReadiness(specRow.spec, exceptionConfigurationSource(specRow.sourcePath)) };
|
||||
const read = this.specCache.readFromDisk(specPath); // #2726: in memory, never written to workflow_specs
|
||||
return { ...this.validator.validate(read.spec, seatLivenessCheck ?? this.seatLivenessCheck, this.hostRegistryLookup),
|
||||
exceptionReadiness: this.readExceptionReadiness(read.spec, exceptionConfigurationSource(read.sourcePath)) };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1149,7 +1149,7 @@ export class WorkflowRuntime {
|
||||
if (!specRow) {
|
||||
throw new WorkflowProjectorError(
|
||||
"spec_not_cached",
|
||||
`workflow spec ${instance.workflowName}@${instance.workflowVersion} is not in the spec cache; re-run validate to refresh it before resuming`,
|
||||
`workflow spec ${instance.workflowName}@${instance.workflowVersion} is not in the spec cache; re-register its file (workflows-folder scan, or instantiate by path) to refresh it before resuming`,
|
||||
{ workflowName: instance.workflowName, workflowVersion: instance.workflowVersion },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -618,22 +618,32 @@ export class WorkflowSpecCache {
|
||||
: `workflow spec ${existing.name}@${existing.version} from ${existing.source_path} was replaced by ${sourcePath}: ${runs.length} unfinished run(s) started from the first file keep the spec it last gave them.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* #2726: read and parse a workflow spec file without touching the cache.
|
||||
* Same file checks and parse errors as readThrough (spec_file_missing,
|
||||
* the parser's spec_* errors), but no workflow_specs row is inserted,
|
||||
* updated or cleared. `rig workflow validate` reads specs this way, so
|
||||
* validating a draft never registers it or replaces a registered
|
||||
* name@version; instantiate/run and the workflows-folder scan still go
|
||||
* through readThrough and register as before.
|
||||
*/
|
||||
readFromDisk(sourcePath: string): { spec: WorkflowSpec; sourcePath: string; sourceHash: string } {
|
||||
const { spec, sourceHash } = readSpecFile(sourcePath);
|
||||
return {
|
||||
// Same default readThrough stores in the row's column and returns.
|
||||
spec: { ...spec, coordination_terminal_turn_rule: spec.coordination_terminal_turn_rule ?? "hot_potato" },
|
||||
sourcePath,
|
||||
sourceHash,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a workflow spec from disk through the cache. Returns the
|
||||
* cached row (re-caching if source_hash differs OR if the spec was
|
||||
* not previously cached).
|
||||
*/
|
||||
readThrough(sourcePath: string): WorkflowSpecRow {
|
||||
if (!existsSync(sourcePath)) {
|
||||
throw new WorkflowSpecError(
|
||||
"spec_file_missing",
|
||||
`workflow spec file not found at ${sourcePath}`,
|
||||
{ sourcePath },
|
||||
);
|
||||
}
|
||||
const raw = readFileSync(sourcePath, "utf-8");
|
||||
const sourceHash = createHash("sha256").update(raw).digest("hex");
|
||||
const spec = parseWorkflowSpec(raw, sourcePath);
|
||||
const { spec, sourceHash } = readSpecFile(sourcePath);
|
||||
const named = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM workflow_specs WHERE name = ? AND version = ?`,
|
||||
@@ -1102,10 +1112,23 @@ function warnLegacyRehydrationOnce(name: string, version: string): void {
|
||||
if (warnedLegacyRehydrations.has(key)) return;
|
||||
warnedLegacyRehydrations.add(key);
|
||||
console.warn(
|
||||
`workflow spec ${key} rehydrated WITHOUT full fidelity (pre-050 cache row: loop_guards/invariants/closure/entry unavailable at projection). Re-validate the spec file (rig workflow validate <path>) to heal the cache row.`,
|
||||
`workflow spec ${key} rehydrated WITHOUT full fidelity (pre-050 cache row: loop_guards/invariants/closure/entry unavailable at projection). Re-register the spec file to heal the cache row: touch it in the workflows folder so the next scan re-reads it, or instantiate it by path (rig workflow validate is read-only and does not heal it).`,
|
||||
);
|
||||
}
|
||||
|
||||
function readSpecFile(sourcePath: string): { spec: WorkflowSpec; sourceHash: string } {
|
||||
if (!existsSync(sourcePath)) {
|
||||
throw new WorkflowSpecError(
|
||||
"spec_file_missing",
|
||||
`workflow spec file not found at ${sourcePath}`,
|
||||
{ sourcePath },
|
||||
);
|
||||
}
|
||||
const raw = readFileSync(sourcePath, "utf-8");
|
||||
const sourceHash = createHash("sha256").update(raw).digest("hex");
|
||||
return { spec: parseWorkflowSpec(raw, sourcePath), sourceHash };
|
||||
}
|
||||
|
||||
function rowToWorkflowSpec(row: SpecRow, parsedSpec?: WorkflowSpec): WorkflowSpecRow {
|
||||
// OPR.0.4.6.WF1: prefer, in order — the freshly file-parsed spec
|
||||
// (readThrough's file-authoritative override), then the STORED full
|
||||
|
||||
@@ -475,6 +475,8 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
// (same injected-predicate layering — the queue never imports the session domain).
|
||||
resolveOccupantGeneration: (sessionName) => sessionRegistry.currentOccupantGenerationForSession(sessionName),
|
||||
resolveSessionAddresses: (sessionName) => sessionRegistry.sessionAddresses(sessionName),
|
||||
// #2575: a prompt-clear wake retry waits for a managed compaction's owed steps, as parked-owner reminders do.
|
||||
hasPendingPostCompactStage: (sessionName) => deps.compactionEnforcer?.hasPendingPostCompactStage(sessionName) ?? false,
|
||||
});
|
||||
// W1 (transactional closure): ONE OutboxHandler shared between the deps slot
|
||||
// (sender-side audit surface) and the queue repo's durable wake-intent
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from "vitest";
|
||||
import { ClaudeCompactionEnforcer as ActualEnforcer, buildPostCompactRestorePrompt } from "../src/domain/claude-compaction-enforcer.js";
|
||||
import { CAPTURE_READABLE_BUDGET_MS, type SessionTransport } from "../src/domain/session-transport.js";
|
||||
import { CAPTURE_READABLE_BUDGET_MS, SEND_READINESS_FRESHNESS_MS, type SessionTransport } from "../src/domain/session-transport.js";
|
||||
import type { ClaudeCompactionPolicy, SettingsStore } from "../src/domain/user-settings/settings-store.js";
|
||||
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
||||
@@ -1333,7 +1333,7 @@ describe("ClaudeCompactionEnforcer", () => {
|
||||
return { ok: true, activity: { state: "idle" } as any, attempts: 1, waitedMs: 0 };
|
||||
};
|
||||
await enforcer.triggerManualCompact({ sessionName: SEAT, runtime: "claude-code", usedPercentage: 20 });
|
||||
expect(callOrder).toEqual(["prep", "idle", `compact:${JSON.stringify({ openrigNotice: true, preserveInput: true, waitForIdleMs: 1_000 + CAPTURE_READABLE_BUDGET_MS, readinessFromPaneOnly: true })}`]);
|
||||
expect(callOrder).toEqual(["prep", "idle", `compact:${JSON.stringify({ openrigNotice: true, preserveInput: true, waitForIdleMs: SEND_READINESS_FRESHNESS_MS + CAPTURE_READABLE_BUDGET_MS, readinessFromPaneOnly: true })}`]);
|
||||
});
|
||||
|
||||
it.each([0, 75])("wait-for-idle failure means /compact never landed: the back-half is NOT seeded (preparation delay=%ims)", async (delayMs) => {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// in-memory database; the tmux adapter records commands instead of running them.
|
||||
import Database from "better-sqlite3";
|
||||
import stringWidth from "string-width";
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -12,7 +12,7 @@ import { TmuxAdapter } from "../src/adapters/tmux.js";
|
||||
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import type { ComposerSnapshot } from "../src/domain/composer-prompts.js";
|
||||
import { ClaudeCompactionEnforcer } from "../src/domain/claude-compaction-enforcer.js";
|
||||
import { ClaudeCompactionEnforcer, COMPACTION_RECORD_FILE } from "../src/domain/claude-compaction-enforcer.js";
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import { SeatDeliveryGuard, resolveGuardTarget } from "../src/domain/seat-delivery-guard.js";
|
||||
import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
@@ -108,11 +108,17 @@ it("checks the composer after buffer setup, immediately before the preparation p
|
||||
expect(f.writes).toEqual([]);
|
||||
});
|
||||
|
||||
it("an unreadable composer holds managed preparation without input", async () => {
|
||||
it.each([null, { x: 0, y: 0, width: 100000, height: 60 }])("an unreadable composer holds managed preparation without input (cursor=%j)", async cursor => {
|
||||
const f = await fixture({ pane: "%1", mode: "off" });
|
||||
vi.spyOn(f.tmux, "getPaneCursorPosition").mockResolvedValue(null);
|
||||
vi.spyOn(f.tmux, "getPaneCursorPosition").mockResolvedValue(cursor);
|
||||
const outcome = await f.e.triggerManualCompact(input, { operatorInitiated: true, skipMap: true });
|
||||
expect(outcome).toMatchObject({ reason: "draft_input_unknown", preparation: { delivery: "not_sent" } });
|
||||
const state = f.e.getPreparationState(seat)!;
|
||||
expect(state).toMatchObject({ inputBox: { status: "unknown", confidence: "unknown", cursor,
|
||||
evidence: expect.arrayContaining([cursor ? "cursor outside the input box" : "cursor unavailable"]) } });
|
||||
expect(Object.keys(state.inputBox!).sort()).toEqual(["confidence", "cursor", "evidence", "status"]);
|
||||
const saved = JSON.parse(readFileSync(join(dirname(state.mapPath), COMPACTION_RECORD_FILE), "utf8"));
|
||||
expect(saved).toMatchObject({ delivery: "not_sent", inputBox: state.inputBox });
|
||||
expect(f.writes).toEqual([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import {mkdtempSync, mkdirSync, writeFileSync, renameSync, rmSync, readFileSync,
|
||||
import {tmpdir} from 'node:os';
|
||||
import {join, dirname} from 'node:path';
|
||||
import {ClaudeCompactionEnforcer, COMPACTION_RECORD_FILE, AUTO_PREP_WAIT_MS_DEFAULT, AUTO_RETRY_AFTER_STOP_MS_DEFAULT, MANUAL_PREP_WAIT_MS_DEFAULT} from '../src/domain/claude-compaction-enforcer.js';
|
||||
import {SessionTransport, CAPTURE_READABLE_BUDGET_MS} from '../src/domain/session-transport.js';
|
||||
import {SessionTransport, CAPTURE_READABLE_BUDGET_MS, SEND_READINESS_FRESHNESS_MS} from '../src/domain/session-transport.js';
|
||||
import {SeatDeliveryGuard} from '../src/domain/seat-delivery-guard.js';
|
||||
import {TmuxAdapter} from '../src/adapters/tmux.js';
|
||||
import {ContextMonitor} from '../src/domain/context-monitor.js';
|
||||
@@ -41,9 +41,9 @@ function stageMap(f:ReturnType<typeof fixture>,suffix=''){
|
||||
}
|
||||
const compacts=(f:ReturnType<typeof fixture>)=>f.writes.filter(t=>t.startsWith('/compact'));
|
||||
// Real readable capture and managed send, including the adapter's final beforePaste check.
|
||||
async function tinyPane(kind:'empty'|'unknown'|'typed'='empty',liveProbe=true){
|
||||
const f=fixture();vi.spyOn(Date,'now').mockImplementation(()=>f.clock());
|
||||
await f.e.maybeAutoCompact(input);stageMap(f);
|
||||
async function tinyPane(kind:'empty'|'unknown'|'typed'='empty',liveProbe=true,manualBudget?:number){
|
||||
const f=fixture(manualBudget);vi.spyOn(Date,'now').mockImplementation(()=>f.clock());
|
||||
if(manualBudget===undefined){await f.e.maybeAutoCompact(input);stageMap(f);}
|
||||
const frame=readFileSync(join(import.meta.dirname,'fixtures/input-box/claude-code-2.1.296/openrig-seat-accept-edits-idle.txt'),'utf8').trimEnd().split('\n');
|
||||
const row=frame.findLastIndex(line=>line.startsWith('❯'));
|
||||
if(kind==='typed')frame[row]='❯ keep this draft';
|
||||
@@ -67,6 +67,55 @@ async function tinyPane(kind:'empty'|'unknown'|'typed'='empty',liveProbe=true){
|
||||
if(liveProbe)delete(f.transport as any).classifySendReadiness;
|
||||
return{...f,tmux,window};
|
||||
}
|
||||
// Exercise the real hook freshness, pane classifier, final composer check and transport wait.
|
||||
// Only the activity-store read and tmux I/O are fixtures; no readiness result is injected.
|
||||
function exhaustedPrompt(f:Awaited<ReturnType<typeof tinyPane>>){
|
||||
const screen=f.tmux.capturePaneScreen.getMockImplementation()!;
|
||||
f.tmux.capturePaneScreen.mockImplementation(async()=>(await screen()).replace(/^▎.*$/m,'Context left until auto-compact: 0%'));
|
||||
f.tmux.capturePaneContent.mockImplementation(f.tmux.capturePaneScreen.getMockImplementation()!);
|
||||
}
|
||||
function preparationHook(f:ReturnType<typeof fixture>){
|
||||
const hook={state:'running',reason:'user_prompt_submit',evidenceSource:'runtime_hook',stale:false,eventAt:new Date(f.clock()).toISOString()};
|
||||
(f.transport as any).agentActivityStore={getLatestForNode:()=>hook};
|
||||
}
|
||||
it('fresh preparation hook ages out before the guarded compact send on a visibly idle pane',async()=>{
|
||||
const f=await tinyPane();exhaustedPrompt(f);preparationHook(f);const start=f.clock();
|
||||
await f.e.maybeAutoCompact(input);
|
||||
expect(compacts(f)).toHaveLength(1);
|
||||
expect(f.clock()-start).toBeGreaterThan(15_000);
|
||||
expect(f.window).toEqual({width:11,height:20,own:null,recorded:''});
|
||||
});
|
||||
for(const budget of [40_000,9_000])it(`manual skip-map final send keeps its original ${budget}ms deadline with a delayed preparation hook`,async()=>{
|
||||
const f=await tinyPane('empty',true,budget);f.window.width=120;f.window.height=40;
|
||||
exhaustedPrompt(f);
|
||||
// The prep hook can arrive between the initial idle observation and final guarded send.
|
||||
const wait=f.transport.waitUntilIdle.bind(f.transport);
|
||||
f.transport.waitUntilIdle=async(...args)=>{const result=await wait(...args);preparationHook(f);return result;};
|
||||
const start=f.clock(),result=await f.e.triggerManualCompact(input,{operatorInitiated:true,skipMap:true});
|
||||
expect(f.writes.filter(text=>!text.startsWith('/compact'))).toHaveLength(1);
|
||||
expect(result).toMatchObject({triggered:budget>15_000});
|
||||
expect(compacts(f)).toHaveLength(budget>15_000?1:0);
|
||||
expect(f.clock()-start).toBeLessThanOrEqual(budget);
|
||||
expect(f.e.getPreparationState(seat)?.deadlineAt).toBe(start+budget);
|
||||
});
|
||||
for(const pane of ['running','permission','typed','unknown'] as const)it(`an aged preparation hook still cannot compact a ${pane} pane`,async()=>{
|
||||
const f=await tinyPane(pane==='typed'?'typed':pane==='unknown'?'unknown':'empty');
|
||||
if(pane==='running'||pane==='permission'){
|
||||
const screen=pane==='running'
|
||||
?readFileSync(join(import.meta.dirname,'fixtures/input-box/claude-code-2.1.296/after-enter-0.4s.J.txt'),'utf8')
|
||||
:'Allow this command?\n❯ 1. Yes, continue\n 2. No, cancel\nPress enter to continue';
|
||||
f.tmux.capturePaneScreen.mockImplementation(async()=>screen);f.tmux.capturePaneContent.mockImplementation(async()=>screen);
|
||||
}
|
||||
preparationHook(f);const start=f.clock();await f.e.maybeAutoCompact(input);
|
||||
expect(f.clock()-start).toBeGreaterThan(15_000);
|
||||
expect(compacts(f)).toHaveLength(0);
|
||||
});
|
||||
for(const stop of ['cancel','replacement'] as const)it(`fresh-hook final wait still honors ${stop}`,async()=>{
|
||||
const f=await tinyPane();preparationHook(f);
|
||||
(f.transport as any).sleep=async(ms:number)=>{f.advance(ms);if(stop==='cancel')f.e.cancelPreparation(seat);else f.generation('generation-two');};
|
||||
await f.e.maybeAutoCompact(input);
|
||||
expect(compacts(f)).toHaveLength(0);
|
||||
});
|
||||
it.each(['live-pane','final-composer'])('submits managed /compact from an 11x20 pane through the %s readable read and restores the window',async gate=>{
|
||||
const f=await tinyPane('empty',gate==='live-pane');
|
||||
// The pre-existing readable path can classify this fixture and give the inherited sizing back.
|
||||
@@ -870,7 +919,7 @@ for(const fresh of [false,null,"below","high"] as const)it(`an armed completed m
|
||||
const monitor=new ContextMonitor(db as any,store as any,undefined,f.e);
|
||||
const beforeBusyPoll=f.clock();
|
||||
await monitor.pollOnce();expect(compacts(f)).toHaveLength(0);
|
||||
expect(f.clock()-beforeBusyPoll).toBeLessThanOrEqual(1_000+CAPTURE_READABLE_BUDGET_MS);
|
||||
expect(f.clock()-beforeBusyPoll).toBeLessThanOrEqual(SEND_READINESS_FRESHNESS_MS+CAPTURE_READABLE_BUDGET_MS);
|
||||
expect(f.e.getPreparationState(seat)?.status).toBe('waiting');
|
||||
f.activity('idle');await monitor.pollOnce();
|
||||
expect(compacts(f)).toHaveLength(fresh==='below'?0:1);expect(f.writes.filter(t=>!t.startsWith('/compact'))).toHaveLength(1);
|
||||
|
||||
@@ -132,4 +132,45 @@ describe.skipIf(process.platform === "win32")("native draft-aware terminal deliv
|
||||
db.close(); rmSync(scratch, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it("submits a multiline paste under a combined update footer through real tmux", async () => {
|
||||
const scratch = mkdtempSync(join(tmpdir(), "draft-banner-"));
|
||||
const socket = join(scratch, "s");
|
||||
const env = { ...process.env }; delete env.TMUX; delete env.TMUX_TMPDIR;
|
||||
const tmux = async (args: string[]) => (await run("tmux", ["-S", socket, ...args], { env })).stdout;
|
||||
const stateFile = join(scratch, "state.json");
|
||||
const state = () => JSON.parse(readFileSync(stateFile, "utf8")) as { input: string; submissions: string[] };
|
||||
const db = new Database(":memory:");
|
||||
let transport: SessionTransport | undefined;
|
||||
try {
|
||||
const command = [process.execPath, composer, "claude", stateFile, "update-banner"].map(shellQuote).join(" ");
|
||||
const pane = (await tmux(["-f", "/dev/null", "new-session", "-d", "-P", "-F", "#{pane_id}", "-x", "120", "-y", "40", "-s", "worker@test", command])).trim();
|
||||
await vi.waitFor(() => expect(state().input).toBe(""), { timeout: 5000 });
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
db.exec(`INSERT INTO rigs(id,name) VALUES ('rig','test');
|
||||
INSERT INTO nodes(id,rig_id,logical_id,runtime) VALUES ('a','rig','worker','terminal');
|
||||
INSERT INTO sessions(id,node_id,session_name,status) VALUES ('sa','a','worker@test','running');
|
||||
INSERT INTO occupant_tenures(id,node_id,generation_ordinal,generation_uuid,kind) VALUES ('ga','a',1,'g1','fresh');`);
|
||||
db.prepare("INSERT INTO bindings(id,node_id,tmux_session,tmux_pane) VALUES ('binding','a','worker@test',?)").run(pane);
|
||||
const adapter = new TmuxAdapter(async () => { throw new Error("Fixture requires argv execution"); }, undefined,
|
||||
async args => tmux(args.slice(1)));
|
||||
const guard = new SeatDeliveryGuard(db, name => resolveGuardTarget(db, name)); adapter.deliveryGuard = guard;
|
||||
transport = new SessionTransport({ db, tmuxAdapter: adapter, rigRepo: new RigRepository(db), sessionRegistry: new SessionRegistry(db) });
|
||||
await guard.set("a", { mode: "draft-aware" }, "human@test", "protect fixture input");
|
||||
await vi.waitFor(async () => expect(inspectComposerInput(await adapter.captureComposerSnapshot(pane), "claude-code").state).toBe("empty"));
|
||||
const text = "first line\nsecond line\nlast line";
|
||||
const result = await transport.send("worker@test", text, { deliveryId: "banner", actorSession: "sender@test", verify: false });
|
||||
expect(result, JSON.stringify(result)).toMatchObject({ ok: true, sent: true });
|
||||
await vi.waitFor(() => expect(state()).toEqual({ input: "", submissions: [text] }));
|
||||
await tmux(["send-keys", "-t", pane, "-l", "person's draft"]);
|
||||
await vi.waitFor(() => expect(state().input).toBe("person's draft"));
|
||||
expect(await transport.send("worker@test", "another message", { deliveryId: "draft", verify: false }))
|
||||
.toMatchObject({ outcome: "retained", sent: false });
|
||||
expect(state()).toEqual({ input: "person's draft", submissions: [text] });
|
||||
} finally {
|
||||
await transport?.guardedDelivery?.stop();
|
||||
await tmux(["kill-server"]).catch(() => {});
|
||||
db.close(); rmSync(scratch, { recursive: true, force: true });
|
||||
}
|
||||
}, 15_000);
|
||||
});
|
||||
@@ -347,6 +347,44 @@ describe("current draft-aware delivery through the real transport", () => {
|
||||
expect(f.outbox.getById("wake-intent-" + item.qitemId)!.deliveryState).toBe("delivered");
|
||||
});
|
||||
|
||||
// Constructed footer transition: U3 retained no immediate pre-Enter snapshot.
|
||||
// Exercise the real transport/guard path, not just the empty-composer parser.
|
||||
it.each(["✔ Update installed · Restart to update", "✔ Update installed · Restart to apply", "✔ Update installed · /restart to apply"])(
|
||||
"submits one owned paste when its hint shares the footer with %s", async banner => {
|
||||
const f = fixture(undefined, { runtime: "claude-code" }); await f.enable();
|
||||
const text = "first line\nsecond line\nlast line";
|
||||
vi.mocked(f.tmux.captureComposerSnapshot).mockImplementation(async () => {
|
||||
const staged = f.bodies.a === text;
|
||||
const snapshot = frame(staged ? "[Pasted text #1 +2 lines]" : f.bodies.a ?? "");
|
||||
snapshot.screen = snapshot.screen.replace("? for shortcuts\n",
|
||||
staged ? `paste again to expand ${banner}\n`
|
||||
: `⏵⏵ accept edits on (shift+tab to cycle)\n${banner}\n`);
|
||||
return snapshot;
|
||||
});
|
||||
const result = await f.send("update-banner-paste", text, { verify: false });
|
||||
expect(result, JSON.stringify(result)).toMatchObject({ ok: true, sent: true });
|
||||
expect(f.submissions).toEqual([text]);
|
||||
expect(f.bodies.a).toBe("");
|
||||
expect(f.writes.filter(write => write.startsWith("paste:"))).toHaveLength(1);
|
||||
expect(f.writes.filter(write => write.startsWith("keys:"))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each(["✔ Update installed · Restart to update now", "Update installed · Restart to update", "✔ Update downloaded · Restart to update"])(
|
||||
"does not submit an owned paste under an unrecognized footer suffix %s", async suffix => {
|
||||
const f = fixture(undefined, { runtime: "claude-code" }); await f.enable();
|
||||
const text = "first line\nsecond line\nlast line";
|
||||
vi.mocked(f.tmux.captureComposerSnapshot).mockImplementation(async () => {
|
||||
const snapshot = frame(f.bodies.a ?? "");
|
||||
snapshot.screen = snapshot.screen.replace("? for shortcuts\n", f.bodies.a === text
|
||||
? `paste again to expand ${suffix}\n`
|
||||
: "⏵⏵ accept edits on (shift+tab to cycle)\n");
|
||||
return snapshot;
|
||||
});
|
||||
expect(await f.send("unknown-paste-footer", text, { verify: false })).toMatchObject({ ok: false, sent: true, reason: "draft_input_unknown" });
|
||||
expect(f.submissions).toEqual([]);
|
||||
expect(f.bodies.a).toBe(text);
|
||||
});
|
||||
|
||||
it.each(Array.from({ length: nativePaste.afterSamples }, (_, i) => i + 1))("submits its retained native paste across the footer change (sample %i)", async () => {
|
||||
const f = fixture(undefined, { runtime: "claude-code" }); await f.enable();
|
||||
const text = Array.from({ length: 71 }, (_, i) => `delivery line ${i}`).join("\n");
|
||||
|
||||
+6
-1
@@ -4,7 +4,7 @@ import { writeFileSync, renameSync } from "node:fs";
|
||||
import { StringDecoder } from "node:string_decoder";
|
||||
import stringWidth from "string-width";
|
||||
|
||||
const [runtime, stateFile] = process.argv.slice(2);
|
||||
const [runtime, stateFile, footerVariant] = process.argv.slice(2);
|
||||
let input = "", pending = "", pasting = false, cursor = 0, ghost = false;
|
||||
const submissions = [];
|
||||
const decoder = new StringDecoder("utf8");
|
||||
@@ -22,6 +22,11 @@ function render() {
|
||||
rows[rows.length - 1] = "⏵⏵ accept edits on (shift+tab to cycle) · ← for agents";
|
||||
rows.push("✘ Auto-update failed: no write permission to npm prefix · Run claude doctor");
|
||||
}
|
||||
if (runtime === "claude" && footerVariant === "update-banner") {
|
||||
const banner = "✔ Update installed · Restart to update";
|
||||
rows[rows.length - 1] = input ? `paste again to expand ${banner}` : "⏵⏵ accept edits on (shift+tab to cycle)";
|
||||
if (!input) rows.push(banner);
|
||||
}
|
||||
const beforeCursor = input.slice(0, cursor).split("\n");
|
||||
process.stdout.write(`\x1b[2J\x1b[H${rows.join("\r\n")}\x1b[${start + beforeCursor.length};${3 + stringWidth(beforeCursor.at(-1))}H`);
|
||||
writeFileSync(`${stateFile}.tmp`, JSON.stringify({ input, submissions }));
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
# Captured on a Linux host (systemd 255, tmux 3.8) on 2026-11-08 for #2710. Data lines are copied verbatim from
|
||||
# the capture; the section headers are added. Read-only commands: no scope was created.
|
||||
## systemd-run --version
|
||||
systemd 255 (255.4-1ubuntu8.18)
|
||||
## systemctl --user is-system-running, user manager up
|
||||
degraded
|
||||
exit=1
|
||||
## systemd-run --user --scope, XDG_RUNTIME_DIR and DBUS_SESSION_BUS_ADDRESS unset
|
||||
Failed to connect to bus: No medium found
|
||||
exit=1
|
||||
## systemd-run --user --scope, XDG_RUNTIME_DIR an empty directory, DBUS_SESSION_BUS_ADDRESS unset
|
||||
Failed to connect to bus: No such file or directory
|
||||
exit=1
|
||||
## systemctl --user, with the bus address a seat's shell gets
|
||||
Failed to connect to bus: Connection refused
|
||||
exit=1
|
||||
## systemd-run missing from PATH, run by /bin/sh -c
|
||||
/bin/sh: 1: systemd-run: not found
|
||||
exit=127
|
||||
## the tmux server the daemon started today, and its cgroup
|
||||
pid=2723922 cmdline: tmux -D
|
||||
cgroup: 0::/user.slice/user-1000.slice/[email protected]/app.slice/app-cadeiras.slice/openrig-cadeiras.scope
|
||||
## the daemon's cgroup
|
||||
0::/user.slice/user-1000.slice/[email protected]/app.slice/openrig-daemon.service
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
workflow:
|
||||
id: wf-validate-ro-invalid-key
|
||||
version: 1
|
||||
bogus_top_level_key: true
|
||||
entry:
|
||||
role: producer
|
||||
roles:
|
||||
producer:
|
||||
preferred_targets:
|
||||
- producer@rig
|
||||
steps:
|
||||
- id: produce
|
||||
actor_role: producer
|
||||
allowed_exits:
|
||||
- done
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
workflow:
|
||||
id: wf-validate-ro-invalid-role
|
||||
version: 1
|
||||
entry:
|
||||
role: producer
|
||||
roles:
|
||||
producer:
|
||||
preferred_targets:
|
||||
- producer@rig
|
||||
steps:
|
||||
- id: produce
|
||||
actor_role: ghost
|
||||
allowed_exits:
|
||||
- done
|
||||
@@ -0,0 +1 @@
|
||||
{"ok":true,"issues":[{"code":"role_no_live_preferred_target","message":"role \"producer\" has preferred_targets [\"producer@rig\"] but none are live. The instance may stall at the first step requiring this role; ensure at least one target is up before instantiating, or rely on dynamic resolution at runtime.","field":"workflow.roles.producer.preferred_targets","severity":"warning"}],"summary":{"workflowId":"wf-validate-ro","workflowVersion":"1","targetRig":null,"entryRole":"producer","stepCount":1},"exceptionReadiness":{"scope":"future-occurrences","posture":"advisory","selection":{"state":"missing","role":null,"source":"<TMP>/specs-a/spec.workflow.yaml#workflow.exception_routing.orchestrator_role","entryRole":"producer"},"routes":[{"exceptionClass":"unmapped_failed","state":"registry-unavailable","roleResolution":"missing-selection","destinationSession":null,"position":"orchestrator","resolvedVia":"engine-default","identity":"unverified","message":"no human configured yet — register one: rig gateway human add <entityId> --display-name … --binding … --delivery-class …"},{"exceptionClass":"stuck_overdue","state":"registry-unavailable","roleResolution":"missing-selection","destinationSession":null,"position":"orchestrator","resolvedVia":"engine-default","identity":"unverified","message":"no human configured yet — register one: rig gateway human add <entityId> --display-name … --binding … --delivery-class …"}],"nextAction":"A defined entry/ordinary role does not select exception ownership. Select the intended declared role at <TMP>/specs-a/spec.workflow.yaml#workflow.exception_routing.orchestrator_role. Compile again after correction. This is a pre-failure advisory. At detection, a required resolution read that fails blocks that occurrence; ordinary work is not gated by this inspection."}}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"ok":true,"issues":[{"code":"role_no_live_preferred_target","message":"role \"producer\" has preferred_targets [\"producer@rig\"] but none are live. The instance may stall at the first step requiring this role; ensure at least one target is up before instantiating, or rely on dynamic resolution at runtime.","field":"workflow.roles.producer.preferred_targets","severity":"warning"}],"summary":{"workflowId":"wf-validate-ro","workflowVersion":"1","targetRig":null,"entryRole":"producer","stepCount":1},"exceptionReadiness":{"scope":"future-occurrences","posture":"advisory","selection":{"state":"missing","role":null,"source":"<TMP>/specs-b/spec.workflow.yaml#workflow.exception_routing.orchestrator_role","entryRole":"producer"},"routes":[{"exceptionClass":"unmapped_failed","state":"registry-unavailable","roleResolution":"missing-selection","destinationSession":null,"position":"orchestrator","resolvedVia":"engine-default","identity":"unverified","message":"no human configured yet — register one: rig gateway human add <entityId> --display-name … --binding … --delivery-class …"},{"exceptionClass":"stuck_overdue","state":"registry-unavailable","roleResolution":"missing-selection","destinationSession":null,"position":"orchestrator","resolvedVia":"engine-default","identity":"unverified","message":"no human configured yet — register one: rig gateway human add <entityId> --display-name … --binding … --delivery-class …"}],"nextAction":"A defined entry/ordinary role does not select exception ownership. Select the intended declared role at <TMP>/specs-b/spec.workflow.yaml#workflow.exception_routing.orchestrator_role. Compile again after correction. This is a pre-failure advisory. At detection, a required resolution read that fails blocks that occurrence; ordinary work is not gated by this inspection."}}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"ok":true,"issues":[{"code":"role_no_live_preferred_target","message":"role \"producer\" has preferred_targets [\"producer@rig\"] but none are live. The instance may stall at the first step requiring this role; ensure at least one target is up before instantiating, or rely on dynamic resolution at runtime.","field":"workflow.roles.producer.preferred_targets","severity":"warning"}],"summary":{"workflowId":"wf-validate-ro-new","workflowVersion":"1","targetRig":null,"entryRole":"producer","stepCount":1},"exceptionReadiness":{"scope":"future-occurrences","posture":"advisory","selection":{"state":"missing","role":null,"source":"<TMP>/specs-b/new.workflow.yaml#workflow.exception_routing.orchestrator_role","entryRole":"producer"},"routes":[{"exceptionClass":"unmapped_failed","state":"registry-unavailable","roleResolution":"missing-selection","destinationSession":null,"position":"orchestrator","resolvedVia":"engine-default","identity":"unverified","message":"no human configured yet — register one: rig gateway human add <entityId> --display-name … --binding … --delivery-class …"},{"exceptionClass":"stuck_overdue","state":"registry-unavailable","roleResolution":"missing-selection","destinationSession":null,"position":"orchestrator","resolvedVia":"engine-default","identity":"unverified","message":"no human configured yet — register one: rig gateway human add <entityId> --display-name … --binding … --delivery-class …"}],"nextAction":"A defined entry/ordinary role does not select exception ownership. Select the intended declared role at <TMP>/specs-b/new.workflow.yaml#workflow.exception_routing.orchestrator_role. Compile again after correction. This is a pre-failure advisory. At detection, a required resolution read that fails blocks that occurrence; ordinary work is not gated by this inspection."}}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"ok":true,"issues":[{"code":"role_no_live_preferred_target","message":"role \"producer\" has preferred_targets [\"producer@rig\"] but none are live. The instance may stall at the first step requiring this role; ensure at least one target is up before instantiating, or rely on dynamic resolution at runtime.","field":"workflow.roles.producer.preferred_targets","severity":"warning"}],"summary":{"workflowId":"wf-validate-ro","workflowVersion":"1","targetRig":null,"entryRole":"producer","stepCount":1},"exceptionReadiness":{"scope":"future-occurrences","posture":"advisory","selection":{"state":"missing","role":null,"source":"<TMP>/specs-b/spec.workflow.yaml#workflow.exception_routing.orchestrator_role","entryRole":"producer"},"routes":[{"exceptionClass":"unmapped_failed","state":"registry-unavailable","roleResolution":"missing-selection","destinationSession":null,"position":"orchestrator","resolvedVia":"engine-default","identity":"unverified","message":"no human configured yet — register one: rig gateway human add <entityId> --display-name … --binding … --delivery-class …"},{"exceptionClass":"stuck_overdue","state":"registry-unavailable","roleResolution":"missing-selection","destinationSession":null,"position":"orchestrator","resolvedVia":"engine-default","identity":"unverified","message":"no human configured yet — register one: rig gateway human add <entityId> --display-name … --binding … --delivery-class …"}],"nextAction":"A defined entry/ordinary role does not select exception ownership. Select the intended declared role at <TMP>/specs-b/spec.workflow.yaml#workflow.exception_routing.orchestrator_role. Compile again after correction. This is a pre-failure advisory. At detection, a required resolution read that fails blocks that occurrence; ordinary work is not gated by this inspection."}}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
workflow:
|
||||
id: wf-validate-ro
|
||||
version: 1
|
||||
entry:
|
||||
role: producer
|
||||
roles:
|
||||
producer:
|
||||
preferred_targets:
|
||||
- producer@rig
|
||||
steps:
|
||||
- id: produce
|
||||
actor_role: producer
|
||||
allowed_exits:
|
||||
- done
|
||||
invariants:
|
||||
allowed_exits:
|
||||
- done
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
workflow:
|
||||
id: wf-validate-ro-new
|
||||
version: 1
|
||||
entry:
|
||||
role: producer
|
||||
roles:
|
||||
producer:
|
||||
preferred_targets:
|
||||
- producer@rig
|
||||
steps:
|
||||
- id: produce
|
||||
actor_role: producer
|
||||
allowed_exits:
|
||||
- done
|
||||
invariants:
|
||||
allowed_exits:
|
||||
- done
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
workflow:
|
||||
id: wf-validate-ro
|
||||
version: 1
|
||||
entry:
|
||||
role: producer
|
||||
roles:
|
||||
producer:
|
||||
preferred_targets:
|
||||
- producer@rig
|
||||
steps:
|
||||
- id: produce
|
||||
actor_role: producer
|
||||
allowed_exits:
|
||||
- done
|
||||
invariants:
|
||||
allowed_exits:
|
||||
- done
|
||||
# other path, other bytes
|
||||
@@ -1,9 +1,11 @@
|
||||
# A private tmux server and real PTYs only: no OpenRig daemon, agents or desktop.
|
||||
# argv: the shell to run the tile script with, then the writable and read-only tile argvs (JSON).
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import pty
|
||||
import shutil
|
||||
import signal
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -11,96 +13,317 @@ import tempfile
|
||||
import termios
|
||||
import time
|
||||
|
||||
writable, readonly = json.loads(sys.argv[1]), json.loads(sys.argv[2])
|
||||
shell, writable, readonly = sys.argv[1], json.loads(sys.argv[2]), json.loads(sys.argv[3])
|
||||
tmux = shutil.which("tmux")
|
||||
# Honor TMPDIR: CI permits socket connections only inside its isolated IPC root.
|
||||
with tempfile.TemporaryDirectory(prefix="tile-size-") as temporary:
|
||||
root = os.path.realpath(temporary)
|
||||
socket = root + "/s"
|
||||
env = {"HOME": root, "PATH": os.environ["PATH"], "TERM": "xterm-256color", "LC_ALL": "C"}
|
||||
clients = []
|
||||
# Wider than any tile below, so a tile that attached would show only the start of it.
|
||||
LINE = "START " + " ".join(str(i) for i in range(1, 45)) + " END"
|
||||
# A rule as wide as the seat's window, as Claude Code draws them.
|
||||
RULE = "\u2500" * 160
|
||||
|
||||
def call(*args):
|
||||
return subprocess.check_output([tmux, "-S", socket, *args], env=env,
|
||||
|
||||
class Server:
|
||||
def __init__(self, root, name):
|
||||
self.socket = root + "/" + name
|
||||
self.env = {"HOME": root, "TMPDIR": root, "PATH": os.environ["PATH"], "TERM": "xterm-256color", "LC_ALL": "C"}
|
||||
self.clients = []
|
||||
self.output = {}
|
||||
|
||||
def call(self, *args):
|
||||
return subprocess.check_output([tmux, "-S", self.socket, *args], env=self.env,
|
||||
stderr=subprocess.PIPE, text=True, timeout=3).strip()
|
||||
|
||||
def drain_output():
|
||||
for process, master, slave, tty in clients:
|
||||
try:
|
||||
while os.read(master, 65536):
|
||||
pass
|
||||
except (BlockingIOError, OSError):
|
||||
pass
|
||||
def start_seat(self, command=None):
|
||||
self.call("-f", "/dev/null", "new-session", "-d", "-x", "160", "-y", "20", "-s", "seat",
|
||||
command or "printf \"%s\\n%s\\n\" \"" + LINE + "\" \"" + RULE + "\"; exec cat")
|
||||
self.call("set-option", "-g", "status", "off")
|
||||
self.call("set-option", "-g", "window-size", "latest")
|
||||
self.call("set-environment", "-t", "seat", "OPENRIG_NODE_ID", "tile-fixture")
|
||||
if not command:
|
||||
self.wait_for(lambda: LINE in self.seat_screen(), "the seat must print its line")
|
||||
|
||||
def attach(argv, columns, rows):
|
||||
def tile(self, argv, columns, rows):
|
||||
argv = [self.socket if arg == "TILE_SOCKET" else arg for arg in argv]
|
||||
if argv[0] == "/bin/sh":
|
||||
argv[0] = shell
|
||||
master, slave = pty.openpty()
|
||||
fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", rows, columns, 0, 0))
|
||||
|
||||
def terminal():
|
||||
os.setsid()
|
||||
fcntl.ioctl(slave, termios.TIOCSCTTY, 0)
|
||||
process = subprocess.Popen([socket if arg == "TILE_SOCKET" else arg for arg in argv],
|
||||
stdin=slave, stdout=slave, stderr=slave, env=env,
|
||||
# As in a real terminal pane: a test runner may hand its children Ctrl-C ignored.
|
||||
signal.signal(signal.SIGINT, signal.SIG_DFL)
|
||||
process = subprocess.Popen(argv, stdin=slave, stdout=slave, stderr=slave, env=self.env,
|
||||
preexec_fn=terminal, close_fds=True)
|
||||
os.set_blocking(master, False)
|
||||
client = (process, master, slave, os.ttyname(slave))
|
||||
clients.append(client)
|
||||
self.clients.append(client)
|
||||
self.output[master] = b""
|
||||
return client
|
||||
|
||||
def geometry():
|
||||
return call("display-message", "-p", "-t", "seat:", "#{pane_width}x#{pane_height}")
|
||||
def drain(self):
|
||||
for process, master, slave, tty in self.clients:
|
||||
try:
|
||||
while True:
|
||||
chunk = os.read(master, 65536)
|
||||
if not chunk:
|
||||
break
|
||||
self.output[master] += chunk
|
||||
except (BlockingIOError, OSError):
|
||||
pass
|
||||
|
||||
def flags(tty):
|
||||
for line in call("list-clients", "-F", "#{client_tty}|#{client_flags}").splitlines():
|
||||
name, value = line.split("|", 1)
|
||||
if name == tty:
|
||||
return value
|
||||
return None
|
||||
def seen(self, client):
|
||||
self.drain()
|
||||
return self.output[client[1]].decode("utf-8", "replace")
|
||||
|
||||
def wait_for(predicate, expected):
|
||||
deadline = time.monotonic() + 4
|
||||
def geometry(self):
|
||||
return self.call("display-message", "-p", "-t", "seat:", "#{window_width}x#{window_height}")
|
||||
|
||||
def client_ttys(self):
|
||||
return [line for line in self.call("list-clients", "-F", "#{client_tty}").splitlines() if line]
|
||||
|
||||
def seat_screen(self):
|
||||
return self.call("capture-pane", "-p", "-J", "-t", "seat:")
|
||||
|
||||
def wait_for(self, predicate, expected, seconds=6, detail=None):
|
||||
deadline = time.monotonic() + seconds
|
||||
while time.monotonic() < deadline:
|
||||
drain_output()
|
||||
self.drain()
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(0.04)
|
||||
raise AssertionError(expected + "; actual pane=" + geometry() + "; clients=" +
|
||||
call("list-clients", "-F", "#{client_width}x#{client_height}:#{client_flags}"))
|
||||
time.sleep(0.05)
|
||||
raise AssertionError(expected + "; window=" + self.geometry() + "; clients=" + repr(self.client_ttys())
|
||||
+ ("; " + detail() if detail else ""))
|
||||
|
||||
try:
|
||||
call("-f", "/dev/null", "new-session", "-d", "-x", "80", "-y", "24", "-s", "seat", "/bin/sleep 60")
|
||||
call("set-option", "-g", "status", "off")
|
||||
call("set-option", "-g", "window-size", "latest")
|
||||
call("set-environment", "-t", "seat", "OPENRIG_NODE_ID", "tile-fixture")
|
||||
attach([tmux, "-S", socket, "attach", "-t", "=seat"], 14, 18)
|
||||
wait_for(lambda: geometry() == "14x18", "old sizing client must establish 14x18")
|
||||
viewer = attach(writable, 101, 32)
|
||||
wait_for(lambda: flags(viewer[3]) is not None, "writable tile must attach")
|
||||
wait_for(lambda: geometry() == "101x32", "writable tile must size the pane to 101x32")
|
||||
writable_flags = flags(viewer[3])
|
||||
assert "ignore-size" not in writable_flags and "read-only" not in writable_flags
|
||||
fcntl.ioctl(viewer[2], termios.TIOCSWINSZ, struct.pack("HHHH", 43, 121, 0, 0))
|
||||
wait_for(lambda: geometry() == "121x43", "real PTY resize must reach the pane")
|
||||
observer = attach(readonly, 111, 44)
|
||||
wait_for(lambda: flags(observer[3]) is not None, "read-only observer must attach")
|
||||
observer_flags = flags(observer[3])
|
||||
assert "read-only" in observer_flags and "ignore-size" in observer_flags
|
||||
assert geometry() == "121x43", "read-only observer must not change geometry"
|
||||
result = {"initial": "101x32", "resized": geometry(),
|
||||
"writableFlags": writable_flags, "observerFlags": observer_flags}
|
||||
finally:
|
||||
def stop(self):
|
||||
try:
|
||||
call("kill-server")
|
||||
self.call("kill-server")
|
||||
except subprocess.CalledProcessError:
|
||||
pass
|
||||
for process, master, slave, tty in clients:
|
||||
for process, master, slave, tty in self.clients:
|
||||
os.close(master)
|
||||
os.close(slave)
|
||||
for process, master, slave, tty in clients:
|
||||
for process, master, slave, tty in self.clients:
|
||||
try:
|
||||
process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.terminate()
|
||||
process.wait(timeout=3)
|
||||
assert all(process.poll() is not None for process, *_ in clients)
|
||||
result["clientsReaped"] = len(clients)
|
||||
return all(process.poll() is not None for process, *_ in self.clients)
|
||||
|
||||
|
||||
def cell(root, name, body, command=None):
|
||||
server = Server(root, name)
|
||||
try:
|
||||
server.start_seat(command(server) if command else None)
|
||||
return body(server)
|
||||
finally:
|
||||
assert server.stop(), name + ": every tile and client must exit"
|
||||
|
||||
|
||||
def read_only_alone(server):
|
||||
tile = server.tile(readonly, 40, 30)
|
||||
server.wait_for(lambda: LINE in server.seen(tile), "a read-only tile shows the whole line")
|
||||
# The tile is no client of the seat, so the seat keeps its own size.
|
||||
assert server.client_ttys() == [], "a read-only tile must not attach"
|
||||
assert server.geometry() == "160x20", "a read-only tile must not resize the seat"
|
||||
# The seat's rule is cut to the tile's 40 columns rather than wrapped over four rows.
|
||||
assert "\u2500" * 40 + "\r\n" in server.seen(tile) and "\u2500" * 41 not in server.seen(tile), "a rule fits the tile"
|
||||
before = len(server.seen(tile))
|
||||
server.call("send-keys", "-t", "seat:", "-l", "MARK-NEW-OUTPUT")
|
||||
server.wait_for(lambda: "MARK-NEW-OUTPUT" in server.seen(tile)[before:], "new seat output reaches the view")
|
||||
# While it shows, a read-only tile's terminal neither echoes keys nor turns Ctrl-C into a signal.
|
||||
iflags, flags = termios.tcgetattr(tile[2])[0], termios.tcgetattr(tile[2])[3]
|
||||
assert not flags & termios.ECHO and not flags & termios.ISIG, "a read-only tile's terminal must be quiet"
|
||||
assert not iflags & termios.IXON, "Ctrl-S must not freeze a read-only tile"
|
||||
os.write(tile[1], b"typed")
|
||||
time.sleep(1.5)
|
||||
assert "typed" not in server.seat_screen(), "keys typed in a read-only tile must not reach the seat"
|
||||
assert tile[0].poll() is None, "typing must not end a read-only tile"
|
||||
assert server.client_ttys() == [], "a read-only tile must not attach when typed in"
|
||||
server.call("kill-session", "-t", "seat")
|
||||
server.wait_for(lambda: tile[0].poll() is not None, "a read-only tile ends when its seat goes and none replaces it", 8)
|
||||
return {"window": "160x20"}
|
||||
|
||||
|
||||
def read_only_beside_another_client(server):
|
||||
other = server.tile([tmux, "-S", server.socket, "attach", "-t", "=seat"], 160, 20)
|
||||
server.wait_for(lambda: len(server.client_ttys()) == 1, "the other client attaches")
|
||||
tile = server.tile(readonly, 40, 30)
|
||||
server.wait_for(lambda: LINE in server.seen(tile), "a read-only tile beside another client shows the whole line")
|
||||
assert server.client_ttys() == [other[3]], "only the other client is attached"
|
||||
assert server.geometry() == "160x20"
|
||||
return {"window": server.geometry()}
|
||||
|
||||
|
||||
def writable_tile(server):
|
||||
tile = server.tile(writable, 40, 30)
|
||||
server.wait_for(lambda: LINE in server.seen(tile), "a writable tile shows the whole line before anyone types")
|
||||
# Every key is read as a key: no flow control, and no other special handling.
|
||||
iflags, flags = termios.tcgetattr(tile[2])[0], termios.tcgetattr(tile[2])[3]
|
||||
assert not iflags & termios.IXON and not flags & termios.IEXTEN, "a showing tile must read every key"
|
||||
assert server.client_ttys() == [] and server.geometry() == "160x20", "an unused writable tile is no client"
|
||||
# The first key reaches the seat, and the tile attaches for the rest.
|
||||
os.write(tile[1], b"hello")
|
||||
server.wait_for(lambda: server.client_ttys() == [tile[3]], "the first key attaches the tile")
|
||||
server.wait_for(lambda: "hello" in server.seat_screen(), "the first key reaches the seat")
|
||||
os.write(tile[1], b" world")
|
||||
server.wait_for(lambda: "hello world" in server.seat_screen(), "typing in the attached tile reaches the seat")
|
||||
# The tile sizes the seat while it's used, as any client the person types in does.
|
||||
server.wait_for(lambda: server.geometry() == "40x30", "the tile in use sizes the seat")
|
||||
used = server.geometry()
|
||||
# Idle for the tile's idle time (2 s here): it detaches back to the view.
|
||||
server.wait_for(lambda: server.client_ttys() == [], "an idle tile detaches", 8)
|
||||
# After tmux's detach line, the view redraws the seat's screen itself.
|
||||
def viewing_again():
|
||||
after = server.seen(tile).rpartition("[detached")[2]
|
||||
return "\x1b[H\x1b[2J" in after and "hello world" in after.rpartition("\x1b[H\x1b[2J")[2]
|
||||
server.wait_for(viewing_again, "the detached tile views the seat again")
|
||||
# Enter as the first key goes to the seat, and attaches the tile again.
|
||||
os.write(tile[1], b"\r")
|
||||
server.wait_for(lambda: server.seat_screen().count("hello world") == 2, "Enter as the first key reaches the seat")
|
||||
server.wait_for(lambda: server.client_ttys() == [tile[3]], "the first key attaches the tile again")
|
||||
return {"used": used}
|
||||
|
||||
|
||||
def recorder(server):
|
||||
# The seat asks for bracketed paste, as Claude Code does, and records every byte it's given, as a raw terminal
|
||||
# delivers them.
|
||||
return "printf \"\\033[?2004h\"; stty raw -echo; exec dd bs=1 of=" + server.socket + ".got 2>/dev/null"
|
||||
|
||||
|
||||
def received(server):
|
||||
try:
|
||||
return open(server.socket + ".got", "rb").read()
|
||||
except FileNotFoundError:
|
||||
return b""
|
||||
|
||||
|
||||
def writable_first_input(server):
|
||||
tile = server.tile(writable, 40, 30)
|
||||
server.wait_for(lambda: "\x1b[H\x1b[2J" in server.seen(tile), "the tile views the seat")
|
||||
# A paste that breaks inside a UTF-8 character, the rest arriving after the break.
|
||||
os.write(tile[1], b"A" * 63 + b"\xc3")
|
||||
time.sleep(0.3)
|
||||
os.write(tile[1], b"\xa9END")
|
||||
expected = b"A" * 63 + "\u00e9".encode() + b"END"
|
||||
server.wait_for(lambda: received(server) == expected, "the first input reaches the seat byte for byte")
|
||||
server.wait_for(lambda: server.client_ttys() == [tile[3]], "the first input attaches the tile")
|
||||
server.wait_for(lambda: server.client_ttys() == [], "an idle tile detaches", 8)
|
||||
# Ctrl-Space types a NUL.
|
||||
os.write(tile[1], b"\x00")
|
||||
expected += b"\x00"
|
||||
server.wait_for(lambda: received(server) == expected, "a NUL first key reaches the seat")
|
||||
server.wait_for(lambda: server.client_ttys() == [tile[3]], "a NUL first key attaches the tile")
|
||||
server.wait_for(lambda: server.client_ttys() == [], "an idle tile detaches", 8)
|
||||
# tmux's prefix works as in an attached tile: Ctrl-b d detaches at once, and nothing reaches the seat.
|
||||
os.write(tile[1], b"\x02d")
|
||||
server.wait_for(lambda: "[detached" in server.seen(tile).rpartition("\x1b[?2004h")[0][-400:] or
|
||||
server.seen(tile).count("[detached") >= 3, "Ctrl-b d detaches the tile")
|
||||
time.sleep(0.5)
|
||||
assert received(server) == expected, "the prefix and its key must not reach the seat: " + repr(received(server)[len(expected):])
|
||||
# A paste while showing: the tile's terminal frames it only if the tile asked for that, as a terminal does.
|
||||
server.wait_for(lambda: server.seen(tile).rfind("\x1b[?2004h") > server.seen(tile).rfind("[detached"), "the tile shows again")
|
||||
framed = server.seen(tile).rfind("\x1b[?2004h") > server.seen(tile).rfind("\x1b[?2004l")
|
||||
paste = b"first line\rsecond line"
|
||||
os.write(tile[1], b"\x1b[200~" + paste + b"\x1b[201~" if framed else paste)
|
||||
expected += b"\x1b[200~" + paste + b"\x1b[201~"
|
||||
server.wait_for(lambda: received(server) == expected, "a paste reaches the seat as one paste")
|
||||
server.wait_for(lambda: server.client_ttys() == [], "an idle tile detaches", 8)
|
||||
# Ctrl-S and Ctrl-Q are keys like any other.
|
||||
os.write(tile[1], b"\x13\x11")
|
||||
expected += b"\x13\x11"
|
||||
server.wait_for(lambda: received(server) == expected, "Ctrl-S and Ctrl-Q reach the seat")
|
||||
server.wait_for(lambda: server.client_ttys() == [tile[3]], "Ctrl-S and Ctrl-Q attach the tile")
|
||||
server.wait_for(lambda: server.client_ttys() == [], "an idle tile detaches", 8)
|
||||
# A key and, at once, a paste: the paste is still framed, and both arrive in order.
|
||||
server.wait_for(lambda: server.seen(tile).rfind("\x1b[?2004h") > server.seen(tile).rfind("[detached"), "the tile shows again")
|
||||
mark = len(server.seen(tile))
|
||||
os.write(tile[1], b"A")
|
||||
time.sleep(0.03)
|
||||
framed = server.seen(tile).rfind("\x1b[?2004h") > server.seen(tile).rfind("\x1b[?2004l")
|
||||
os.write(tile[1], b"\x1b[200~" + paste + b"\x1b[201~" if framed else paste)
|
||||
expected += b"A\x1b[200~" + paste + b"\x1b[201~"
|
||||
server.wait_for(lambda: received(server) == expected, "a key then a paste arrive in order, the paste framed",
|
||||
detail=lambda: "framed=%r; seat got %r; expected %r" % (framed, received(server)[-80:], expected[-80:]))
|
||||
# From the first key until tmux takes the terminal, its paste framing stays on: a paste in that gap stays one paste.
|
||||
server.wait_for(lambda: "\x1b[?2004h" in server.seen(tile)[mark:], "tmux takes the tile's terminal")
|
||||
gap = server.seen(tile)[mark:].split("\x1b[?2004h", 1)[0]
|
||||
assert "\x1b[?2004l" not in gap, "the tile turned paste framing off before tmux attached: " + repr(gap[-80:])
|
||||
server.wait_for(lambda: server.client_ttys() == [], "an idle tile detaches", 8)
|
||||
# A run of text that ends in ; keeps it, through the client and before a paste alike.
|
||||
os.write(tile[1], b"ls;\r")
|
||||
expected += b"ls;\r"
|
||||
server.wait_for(lambda: received(server) == expected, "a trailing ; reaches the seat")
|
||||
server.wait_for(lambda: server.client_ttys() == [], "an idle tile detaches", 8)
|
||||
server.wait_for(lambda: server.seen(tile).rfind("\x1b[?2004h") > server.seen(tile).rfind("[detached"), "the tile shows again")
|
||||
os.write(tile[1], b"x;\x1b[200~" + paste + b"\x1b[201~")
|
||||
expected += b"x;\x1b[200~" + paste + b"\x1b[201~"
|
||||
server.wait_for(lambda: received(server) == expected, "a trailing ; before a paste reaches the seat")
|
||||
server.wait_for(lambda: server.client_ttys() == [], "an idle tile detaches", 8)
|
||||
# An arrow as the first key reaches the agent as typed.
|
||||
os.write(tile[1], b"\x1b[A")
|
||||
expected += b"\x1b[A"
|
||||
server.wait_for(lambda: received(server) == expected, "Up as the first key reaches the seat as Up")
|
||||
server.wait_for(lambda: server.client_ttys() == [], "an idle tile detaches", 8)
|
||||
# The prefix, then PgUp as a person types them: tmux scrolls back, and nothing reaches the seat.
|
||||
os.write(tile[1], b"\x02")
|
||||
server.wait_for(lambda: server.client_ttys() == [tile[3]], "the prefix attaches the tile")
|
||||
os.write(tile[1], b"\x1b[5~")
|
||||
server.wait_for(lambda: server.call("display-message", "-p", "-t", "seat:", "#{pane_in_mode}") == "1", "Ctrl-b PgUp opens copy mode")
|
||||
os.write(tile[1], b"q")
|
||||
server.wait_for(lambda: server.call("display-message", "-p", "-t", "seat:", "#{pane_in_mode}") == "0", "q leaves copy mode")
|
||||
time.sleep(0.3)
|
||||
assert received(server) == expected, "Ctrl-b PgUp must not type into the seat: " + repr(received(server)[len(expected):])
|
||||
return {"bytes": len(received(server))}
|
||||
|
||||
|
||||
def writable_big_paste(server):
|
||||
tile = server.tile(writable, 40, 30)
|
||||
server.wait_for(lambda: "\x1b[?2004h" in server.seen(tile), "the tile frames pastes while it shows")
|
||||
# A paste of about 450 KB, framed as a terminal frames it, written as fast as the tile reads it.
|
||||
data = b"\x1b[200~" + b"".join(b"line %06d of a long paste\r" % i for i in range(16000)) + b"\x1b[201~"
|
||||
sent, deadline = 0, time.monotonic() + 90
|
||||
while sent < len(data):
|
||||
try:
|
||||
sent += os.write(tile[1], data[sent:sent + 4096])
|
||||
except BlockingIOError:
|
||||
time.sleep(0.002)
|
||||
server.drain()
|
||||
assert time.monotonic() < deadline, "the tile must keep reading the paste"
|
||||
server.wait_for(lambda: received(server) == data, "a paste over 256 KiB reaches the seat whole and framed", 90)
|
||||
server.wait_for(lambda: server.client_ttys() == [], "the tile detaches when idle after the paste", 10)
|
||||
return {"bytes": len(data)}
|
||||
|
||||
|
||||
def writable_first_input_window(server):
|
||||
# The attach is slowed to a second, so keys typed after the first burst land before tmux takes the terminal.
|
||||
slow = server.socket + ".slow-tmux"
|
||||
open(slow, "w").write("#!/bin/sh\ncase \" $* \" in *\" attach \"*) sleep 1;; esac\nexec " + tmux + " \"$@\"\n")
|
||||
os.chmod(slow, 0o700)
|
||||
argv = list(writable)
|
||||
argv[5] = slow
|
||||
tile = server.tile(argv, 40, 30)
|
||||
server.wait_for(lambda: "\x1b[H\x1b[2J" in server.seen(tile), "the tile views the seat")
|
||||
os.write(tile[1], b"y")
|
||||
time.sleep(0.5)
|
||||
# Enter and Ctrl-C, before the attach completes.
|
||||
os.write(tile[1], b"\r\x03")
|
||||
server.wait_for(lambda: received(server) == b"y\r\x03", "Enter and Ctrl-C before the attach reach the seat as typed")
|
||||
assert tile[0].poll() is None, "Ctrl-C before the attach must not end the tile"
|
||||
return {"bytes": len(received(server))}
|
||||
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="tile-size-") as temporary:
|
||||
root = os.path.realpath(temporary)
|
||||
if sys.argv[4:] == ["big-paste"]:
|
||||
print(json.dumps({"bigPaste": cell(root, "f", writable_big_paste, recorder)}))
|
||||
sys.exit(0)
|
||||
result = {
|
||||
"readOnlyAlone": cell(root, "a", read_only_alone),
|
||||
"readOnlyBesideAnother": cell(root, "b", read_only_beside_another_client),
|
||||
"writable": cell(root, "c", writable_tile),
|
||||
"firstInput": cell(root, "d", writable_first_input, recorder),
|
||||
"firstInputWindow": cell(root, "e", writable_first_input_window, recorder),
|
||||
}
|
||||
print(json.dumps(result))
|
||||
@@ -11,7 +11,7 @@ import type { ViewerPane } from "../src/domain/terminal/herdr-viewer.js";
|
||||
import { inspectComposerInput } from "../src/domain/session-transport.js";
|
||||
import type { ComposerSnapshot } from "../src/domain/composer-prompts.js";
|
||||
import { codexTwoRowComposer } from "./fixtures/codex-two-row-composer.js";
|
||||
import { tileAttachArgv078, tileAttachArgv079 } from "../src/domain/terminal/earlier-tile-attach.js";
|
||||
import { tileAttachArgv078, tileAttachArgv079, tileAttachArgv0710 } from "../src/domain/terminal/earlier-tile-attach.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(() => { vi.useRealTimers(); for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); });
|
||||
@@ -569,7 +569,7 @@ it("keeps a real empty mission workspace enabled through Core with a mission wai
|
||||
|
||||
describe("follow views kept from an earlier release", () => {
|
||||
const quote = (s: string) => "'" + s.replace(/'/g, "'\"'\"'") + "'";
|
||||
it.each([["0.7.9", tileAttachArgv079], ["0.7.8", tileAttachArgv078]] as const)(
|
||||
it.each([["0.7.10", tileAttachArgv0710], ["0.7.9", tileAttachArgv079], ["0.7.8", tileAttachArgv078]] as const)(
|
||||
"a writable follow view kept from %s still adds and closes tiles, and moves to the current script", async (_, make) => {
|
||||
const f = fixture(); expect(await f.follow()).toMatchObject({ ok: true, opened: ["a", "b", "c"] });
|
||||
// Rewrite the saved canvas and the viewer journal as that release left them.
|
||||
|
||||
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { HerdrViewer, type HerdrViewerRun } from "../src/domain/terminal/herdr-viewer.js";
|
||||
import { tileAttachArgv } from "../src/domain/terminal/tile-attach.js";
|
||||
import { tileAttachArgv078, tileAttachArgv079 } from "../src/domain/terminal/earlier-tile-attach.js";
|
||||
import { tileAttachArgv078, tileAttachArgv079, tileAttachArgv0710 } from "../src/domain/terminal/earlier-tile-attach.js";
|
||||
import { composeView } from "../src/domain/terminal/view-composer.js";
|
||||
import { Hono } from "hono";
|
||||
import { HerdrAdapter } from "../src/domain/terminal/herdr-adapter.js";
|
||||
@@ -22,6 +22,7 @@ function fixture() {
|
||||
let ids = { workspace_id: "w1", pane_id: "w1:p1", terminal_id: "term_001", cwd: "" };
|
||||
let label = "", running = false, uncertainCreate = false, uncertainRun = false, idless = false;
|
||||
let gone = false, enumerable = true, unreadable = false, attachFlags = ["-f", "ignore-size"];
|
||||
let foreground: string[] | undefined;
|
||||
let workspaceInventory: unknown[] | undefined, paneInventory: Record<string, unknown> | undefined;
|
||||
let canonicalCreateCwd = false, changedCreateCwd = false, canonicalLimit = 0;
|
||||
const inspect = async () => ({ sessionId: "$1", nodeId: key.nodeId });
|
||||
@@ -50,7 +51,7 @@ function fixture() {
|
||||
return "";
|
||||
}
|
||||
if (command === "pane process-info") return JSON.stringify({ result: { process_info: { foreground_processes: [{ argv: running
|
||||
? ["/usr/bin/tmux", "-S", "/tmp/own-tmux.sock", "attach", ...attachFlags, "-t", "$1"] : ["/bin/bash"] }] } } });
|
||||
? foreground ?? ["/usr/bin/tmux", "-S", "/tmp/own-tmux.sock", "attach", ...attachFlags, "-t", "$1"] : ["/bin/bash"] }] } } });
|
||||
throw new Error(`Unexpected provider operation ${args}`);
|
||||
};
|
||||
const viewer = (viewerRoot = root, platform: NodeJS.Platform = "linux", status = {}) => new HerdrViewer(socket, async args => args[0] === "status" ? JSON.stringify({ running: true, version: "0.9.5", protocol: 22, socket, ...status }) : run(args), platform, viewerRoot, inspect);
|
||||
@@ -62,7 +63,8 @@ function fixture() {
|
||||
loseChanged: () => { gone = false; enumerable = false; },
|
||||
setEnumeration: (workspaces: unknown[], panes: Record<string, unknown>) => { workspaceInventory = workspaces; paneInventory = panes; },
|
||||
setCreateLost: () => { uncertainCreate = true; }, setRunLost: () => { uncertainRun = true; }, setIdless: () => { idless = true; }, exit: () => { running = false; },
|
||||
setAttachFlags: (flags: string[]) => { attachFlags = flags; } };
|
||||
setAttachFlags: (flags: string[]) => { attachFlags = flags; },
|
||||
setForeground: (argv: string[]) => { foreground = argv; } };
|
||||
}
|
||||
|
||||
describe("owned Herdr viewer", () => {
|
||||
@@ -240,15 +242,31 @@ describe("owned Herdr viewer", () => {
|
||||
expect(await new HerdrViewer(socket, async () => { throw new Error("should not run"); }, "win32", f.root).ensure(key, f.target)).toMatchObject({ ok: false, code: "viewer_unsupported" });
|
||||
expect(await new HerdrViewer(socket, async () => JSON.stringify({ running: true, version: "0.9.5", protocol: 22, socket: "/other.sock" }), "linux", f.root).ensure(key, f.target)).toMatchObject({ ok: false });
|
||||
});
|
||||
it("sizes writable initial/fallback/replacement attaches and recovery, and preserves read-only", () => {
|
||||
it("a writable tile attaches, sizing, only from its one typing path; a read-only tile never attaches; recovery still attaches", () => {
|
||||
const rw = tileAttachArgv("tmux", [], "a@team", false)[2]!;
|
||||
expect(rw.match(/attach -t/g)).toHaveLength(4);
|
||||
// Both attaches are the typing path's: a plain one (also after a paste), and one that carries the first keys.
|
||||
expect(rw.match(/attach -t/g)).toEqual(['attach -t', 'attach -t']);
|
||||
expect(rw).toContain('then pz=1; put "$@"; fi;');
|
||||
expect(rw).toContain('then go "$@"; else "$@" attach -t "$t"; fi;');
|
||||
expect(rw).toContain('go() { set -- "$@" attach -t "$t";');
|
||||
// The first input's files are readable by this user alone.
|
||||
expect(rw.startsWith("umask 077;")).toBe(true);
|
||||
expect(rw.match(/open "/g)).toHaveLength(4);
|
||||
expect(rw).not.toContain("ignore-size");
|
||||
const ro = tileAttachArgv("tmux", [], "a@team", true)[2]!;
|
||||
expect(ro.match(/attach -r -t/g)).toHaveLength(4);
|
||||
expect(ro).toContain('open() { t=$1; shift; stty -echo -isig -ixon 2>/dev/null; view "$t" "" "$@"; };');
|
||||
expect(ro).not.toMatch(/attach -r|while use/);
|
||||
expect(ro).not.toContain("pick");
|
||||
const f = fixture(); expect(f.target.recoveryCommand).toContain("attach -t");
|
||||
});
|
||||
it("verifies a tile by its own script in the foreground, as when it views the seat, but not another seat's script", async () => {
|
||||
const f = fixture(); f.setForeground(f.target.attachArgv!);
|
||||
expect(await f.viewer().ensure(key, f.target)).toMatchObject({ ok: true });
|
||||
const g = fixture(); g.setForeground(tileAttachArgv("/usr/bin/tmux", ["-S", "/tmp/own-tmux.sock"], "other@team", false));
|
||||
expect(await g.viewer().ensure(key, g.target)).toMatchObject({ ok: false });
|
||||
const h = fixture(); h.setForeground([...h.target.attachArgv!, "extra"]);
|
||||
expect(await h.viewer().ensure(key, h.target)).toMatchObject({ ok: false });
|
||||
});
|
||||
it("verifies a writable viewer that sized its session (no ignore-size), but not a read-only spelling", async () => {
|
||||
const f = fixture(); f.setAttachFlags([]);
|
||||
expect(await f.viewer().ensure(key, f.target)).toMatchObject({ ok: true });
|
||||
@@ -317,7 +335,7 @@ describe("viewers kept from an earlier release", () => {
|
||||
return () => JSON.parse(readFileSync(file, "utf8")).entries[id] as { command: string; argv: string[] };
|
||||
}
|
||||
|
||||
it.each([["0.7.9", tileAttachArgv079], ["0.7.8", tileAttachArgv078]] as const)(
|
||||
it.each([["0.7.10", tileAttachArgv0710], ["0.7.9", tileAttachArgv079], ["0.7.8", tileAttachArgv078]] as const)(
|
||||
"keeps a writable viewer recorded by %s and moves it to the current script", async (_, make) => {
|
||||
const f = fixture(); expect((await f.viewer().ensure(key, f.target)).ok).toBe(true);
|
||||
const stored = keepFrom(f, make, false);
|
||||
@@ -328,7 +346,7 @@ describe("viewers kept from an earlier release", () => {
|
||||
expect(f.calls.filter(a => a[1] === "create")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each([["0.7.9", tileAttachArgv079], ["0.7.8", tileAttachArgv078]] as const)(
|
||||
it.each([["0.7.10", tileAttachArgv0710], ["0.7.9", tileAttachArgv079], ["0.7.8", tileAttachArgv078]] as const)(
|
||||
"keeps a read-only viewer recorded by %s", async (_, make) => {
|
||||
const f = fixture(); f.setAttachFlags(["-r"]); const target = readOnlyTarget();
|
||||
expect((await f.viewer().ensure(key, target)).ok).toBe(true);
|
||||
|
||||
@@ -483,6 +483,8 @@ describe("Claude identity with inline settings in ps output", () => {
|
||||
permissions: { allow: ["Bash(npm run test:*)"] },
|
||||
hooks: { PreToolUse: [{ hooks: [{ type: "command", command: 'node "/fixture with spaces/check.cjs" --label "it\'s fine"', timeout: 5 }] }] },
|
||||
})],
|
||||
// #2508: a proxy that writes its settings with spaces between JSON members, as ps shows the argument.
|
||||
["spaced JSON", '{"env": {"ANTHROPIC_BASE_URL": "http://127.0.0.1:8080", "ANTHROPIC_AUTH_TOKEN": "fixture"}}'],
|
||||
];
|
||||
function input(args: string) {
|
||||
const list = [
|
||||
@@ -513,6 +515,9 @@ describe("Claude identity with inline settings in ps output", () => {
|
||||
`--settings /fixture/settings.json --session-id ${token}`,
|
||||
`--settings /fixture/review's-settings.json --session-id ${token}`,
|
||||
`--settings /fixture/review"settings.json --session-id ${token}`,
|
||||
// #2508: a brace inside a spaced value's string doesn't end the value.
|
||||
`--settings {"env": {"note": "a } b {"}} --resume ${token}`,
|
||||
`--settings={"env": {"A": "b"}} --session-id=${token}`,
|
||||
])("accepts inline and file settings: %s", async args => {
|
||||
expect((await verifyClaudePaneProcess(input(args)))?.process.pid).toBe(21);
|
||||
expect((await observeClaudeDelivery(input(args))).state).toBe("verified");
|
||||
@@ -594,12 +599,28 @@ describe("Claude identity with inline settings in ps output", () => {
|
||||
it.each([
|
||||
`--settings ${JSON.stringify({ label: `two words --session-id ${token}` })}`,
|
||||
`--settings {"env":{"note":"text --session-id ${token} --model trailing`,
|
||||
// #2508: spaced settings stay one value, so an identity inside them, or after a value that never closes, proves nothing.
|
||||
`--settings {"env": {"note": "text --session-id ${token}"}}`,
|
||||
`--settings {"env": {"note": "x"} --session-id ${token}`,
|
||||
`--settings ${settings[0]![1]} --session-id ${token} --session-id different`,
|
||||
`--settings ${settings[1]![1]} --session-id ${token} --unknown`,
|
||||
])("does not promote ambiguous argv: %s", async args => {
|
||||
expect(await verifyClaudePaneProcess(input(args))).toBeNull();
|
||||
expect((await observeClaudeDelivery(input(args))).state).not.toBe("verified");
|
||||
});
|
||||
// #2508: braces group only a `--settings` or `--mcp-config` value. Any other value that opens with `{` still ends at
|
||||
// a space, so the options after it are read as on main.
|
||||
it("keeps a second resume after another option's brace-led value visible", async () => {
|
||||
const actual = input(`--resume ${token} --name {label --resume ${parent}`);
|
||||
expect(await verifyClaudePaneProcess(actual)).toBeNull();
|
||||
expect((await observeClaudeDelivery(actual)).state).not.toBe("verified");
|
||||
});
|
||||
it("reads the identity after another option's brace-led value", async () => {
|
||||
const actual = input(`--name {label --resume ${token}`);
|
||||
expect((await verifyClaudePaneProcess(actual))?.process.pid).toBe(21);
|
||||
expect((await observeClaudeDelivery(actual)).state).toBe("verified");
|
||||
expect(await verifyClaudePaneProcess({ ...actual, expectedToken: "different" })).toBeNull();
|
||||
});
|
||||
it.each([
|
||||
`--resume ${parent} --fork-session --session-id ${token}`,
|
||||
`--fork-session --session-id=${token} --resume=${parent}`,
|
||||
|
||||
@@ -40,11 +40,11 @@ it.skipIf(!supported)("does not treat an engine error or missing original invent
|
||||
await expect(f.observer.observe(f.binding)).rejects.toMatchObject({ code: "environment_cleanup_unconfirmed" });
|
||||
});
|
||||
|
||||
async function httpFixture(failure?: "404" | "malformed") {
|
||||
async function httpFixture(failure?: "404" | "malformed", mountedVolumes?: string[]) {
|
||||
const root = realpathSync(mkdtempSync(join(tmpdir(), "os-")));
|
||||
closers.push(() => rmSync(root, { recursive: true, force: true }));
|
||||
const socket = join(root, "s"), id = "compute-one", workloadId = "a".repeat(64), supervisorId = "b".repeat(64);
|
||||
const ownedVolumes = [`openshell-sandbox-${id}-workspace`, `openshell-control-${id}`, `openshell-channel-${id}`];
|
||||
const ownedVolumes = mountedVolumes ?? [`openshell-sandbox-${id}-workspace`, `openshell-control-${id}`, `openshell-channel-${id}`];
|
||||
const unrelated = { Id: "c".repeat(64), Names: ["unrelated"], Labels: {} };
|
||||
let containers = [unrelated,
|
||||
{ Id: workloadId, Names: ["workload"], Labels: { "openshell.managed": "true", "openshell.ai/sandbox-id": id, "openshell.ai/isolation-role": "sandbox", "openshell.ai/sandbox-workspace": "workspace" } },
|
||||
@@ -100,3 +100,22 @@ it.each(["404", "malformed"] as const)("keeps an HTTP %s inventory response unco
|
||||
expect(f.requests).toHaveLength(1);
|
||||
expect(f.requests[0]?.method).toBe("GET");
|
||||
});
|
||||
|
||||
it("captures a custom-image sandbox without inventing a workspace volume or selecting user mounts", async () => {
|
||||
const volumes = ["openshell-control-compute-one", "openshell-channel-compute-one"];
|
||||
const f = await httpFixture(undefined, [...volumes, "user-data", "openshell-control-another"]);
|
||||
const selected = await f.observer.capture(f.binding, f.socket);
|
||||
expect(selected.volumes).toEqual(volumes);
|
||||
f.retireContainers();
|
||||
expect(await f.observer.observe(f.binding, selected)).toEqual({ workload: false, supervisor: false, volumes: true });
|
||||
f.retireVolumes();
|
||||
expect(await f.observer.observe(f.binding, selected)).toEqual({ workload: false, supervisor: false, volumes: false });
|
||||
expect(f.requests.every(req => req.method === "GET")).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["control", "channel"])("refuses an unattributable %s volume even when other mounts exist", async missing => {
|
||||
const volumes = ["openshell-control-compute-one", "openshell-channel-compute-one", "openshell-sandbox-compute-one-workspace"]
|
||||
.filter(name => name !== `openshell-${missing}-compute-one`);
|
||||
const f = await httpFixture(undefined, volumes);
|
||||
await expect(f.observer.capture(f.binding, f.socket)).rejects.toMatchObject({ code: "environment_cleanup_unconfirmed" });
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
// A Pi seat's command is staged into a private script that execs it (execInScript), so the agent replaces the staging
|
||||
// shell. A declared account home adds a credential scrub to that command; composed as `unset …; …`, the staged
|
||||
// `exec unset …` failed and no agent started (0.7.11). The real SeatLaunchEnvironment composes each Pi caller's
|
||||
// command, the real TmuxAdapter stages it, and /bin/sh runs the staged script with stale provider keys in its environment.
|
||||
import { spawn } from "node:child_process";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { SeatLaunchEnvironment } from "../src/domain/seat-launch-environment.js";
|
||||
import { TmuxAdapter, type TmuxFileOps } from "../src/adapters/tmux.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(() => { for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); });
|
||||
|
||||
const stale = { PI_CODING_AGENT_DIR: "/pi/stale", MISTRAL_API_KEY: "fixture-mistral", GEMINI_API_KEY: "fixture-gemini", OPENAI_API_KEY: "fixture-openai", ANTHROPIC_API_KEY: "fixture-anthropic" };
|
||||
|
||||
async function staged(target: Parameters<SeatLaunchEnvironment["command"]>[2], selection: "selected" | "none") {
|
||||
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "pi-staged-exec-")));
|
||||
roots.push(root);
|
||||
const cli = path.join(root, "rig");
|
||||
fs.writeFileSync(cli, "#!/bin/sh\nexit 0\n", { mode: 0o700 });
|
||||
// Stands in for the Pi runner: says which process it is and what it was given. A real seat's command runs past the
|
||||
// 512-byte direct-typing bound (its runner path, state root and environment), so it is staged; this path does too.
|
||||
const runnerDir = path.join(root, ...Array.from({ length: 8 }, () => "nested-runner-directory-" + "x".repeat(24)));
|
||||
fs.mkdirSync(runnerDir, { recursive: true });
|
||||
const runner = path.join(runnerDir, "pi-runner");
|
||||
fs.writeFileSync(runner, "#!/bin/sh\nprintf 'pid=%s\\n' \"$$\"\nenv\n", { mode: 0o700 });
|
||||
const paneTmux = {
|
||||
getPaneCommand: async () => "bash",
|
||||
getSessionEnv: async (_session: string, key: string) => key === "OPENRIG_NODE_ID" ? "pi-node" : "seat@rig",
|
||||
} as unknown as TmuxAdapter;
|
||||
const launch = new SeatLaunchEnvironment(paneTmux, { PATH: "/usr/bin:/bin", OPENRIG_HOME: root }, root, cli, undefined,
|
||||
(_node, _runtime, clearing) => selection === "none" ? undefined : clearing ? "/pi/configured" : "/pi/selected");
|
||||
const command = await launch.command("seat@rig", runner, target);
|
||||
const script = path.join(root, "launch.sh");
|
||||
const files = new Map<string, string>();
|
||||
let names = 0;
|
||||
const fileOps: TmuxFileOps = {
|
||||
// The first name is the staged script; later ones are the paste buffer that types its invocation.
|
||||
tmpName: () => names++ === 0 ? script : path.join(root, "paste.txt"), bufferName: () => "buffer",
|
||||
writeFile: vi.fn(async (file, text) => { files.set(file, text); }),
|
||||
unlink: vi.fn(async file => { files.delete(file); }),
|
||||
};
|
||||
const transport = new TmuxAdapter(vi.fn(async (request: string) => request.includes("#{pane_current_command}") ? "bash" : ""), fileOps);
|
||||
expect(await transport.sendShellCommand("seat@rig", command, undefined, { stageIfLong: true, execInScript: true })).toEqual({ ok: true });
|
||||
const body = files.get(script);
|
||||
if (body === undefined) return { command, body, ran: null };
|
||||
fs.writeFileSync(script, body, { mode: 0o600 });
|
||||
const child = spawn("/bin/sh", [script], { env: { PATH: "/usr/bin:/bin", ...stale } });
|
||||
let out = "", err = "";
|
||||
child.stdout.on("data", c => { out += c; });
|
||||
child.stderr.on("data", c => { err += c; });
|
||||
const code = await new Promise<number | null>(resolve => child.on("close", resolve));
|
||||
return { command, body, ran: { code, out, err, shellPid: child.pid } };
|
||||
}
|
||||
|
||||
it.each([
|
||||
["launch, selection read from the seat", { nodeId: "pi-node", runtime: "pi", generation: "g1" }, "/pi/selected"],
|
||||
["resume with the seat's selection", { runtime: "pi", nodeId: "pi-node", accountHome: "/pi/selected" }, "/pi/selected"],
|
||||
["account switch to a new home", { nodeId: "pi-node", runtime: "pi", accountHome: "/pi/switched" }, "/pi/switched"],
|
||||
["clear back to the configured home", { nodeId: "pi-node", runtime: "pi", accountHome: null }, "/pi/configured"],
|
||||
] as const)("%s: the staged script scrubs provider keys and execs the agent in its own process", async (_case, target, home) => {
|
||||
const { body, ran } = await staged(target, "selected");
|
||||
expect(body).toBeDefined();
|
||||
expect(ran!.err).toBe("");
|
||||
expect(ran!.code).toBe(0);
|
||||
expect(ran!.out).toContain(`PI_CODING_AGENT_DIR=${home}\n`);
|
||||
expect(ran!.out).not.toContain("fixture-");
|
||||
// exec: the agent took over the staging shell's process.
|
||||
expect(ran!.out).toContain(`pid=${ran!.shellPid}\n`);
|
||||
});
|
||||
|
||||
it("a Pi seat with no account selection composes and stages exactly as before", async () => {
|
||||
const { command, body, ran } = await staged({ nodeId: "pi-node", runtime: "pi" }, "none");
|
||||
expect(command.startsWith("/usr/bin/env '")).toBe(true);
|
||||
expect(command).not.toMatch(/(^|[ ;])(unset|-u) /);
|
||||
if (body !== undefined) {
|
||||
expect(body.split("\n")[1]).toBe(`exec ${command}`);
|
||||
expect(ran!.code).toBe(0);
|
||||
expect(ran!.out).toContain(`pid=${ran!.shellPid}\n`);
|
||||
}
|
||||
});
|
||||
|
||||
it("a clear with no configured home still removes the stale home, and the agent still execs", async () => {
|
||||
const { body, ran } = await staged({ nodeId: "pi-node", runtime: "pi", accountHome: null }, "none");
|
||||
expect(body).toBeDefined();
|
||||
expect(ran!.err).toBe("");
|
||||
expect(ran!.code).toBe(0);
|
||||
expect(ran!.out).not.toContain("PI_CODING_AGENT_DIR=");
|
||||
expect(ran!.out).toContain(`pid=${ran!.shellPid}\n`);
|
||||
});
|
||||
@@ -113,6 +113,100 @@ describe("S02 standing stuck sweep — both halves, routed findings, quiet-but-o
|
||||
);
|
||||
}
|
||||
|
||||
it.each([
|
||||
{ label: "project, mission and slice", scope: ["project:sample-app", "mission:qitem-sample-mission", "slice:queue-recovery"] },
|
||||
{ label: "unscoped source", scope: [] as string[] },
|
||||
])("#2758 newly created per-row recovery retains $label without copying unrelated tags", async ({ scope }) => {
|
||||
const unrelated = ["gate:spec-review", "priority:rush", "note:project:unrelated"];
|
||||
const row = await repo.create({
|
||||
sourceSession: "sender@r", destinationSession: "worker@r", body: "scoped work",
|
||||
tags: [...scope, ...unrelated], nudge: false,
|
||||
});
|
||||
repo.claim({ qitemId: row.qitemId, destinationSession: "worker@r" });
|
||||
ageClaim(row.qitemId, 60);
|
||||
expect(repo.getById(row.qitemId)!.pickup?.state).toBe("stalled-after-claim");
|
||||
|
||||
const first = await runSweep();
|
||||
const [finding] = await findingsFor(row.qitemId);
|
||||
expect(finding).toBeDefined();
|
||||
expect(first.result.findings).toContainEqual(expect.objectContaining({
|
||||
kind: "stalled-after-claim", qitemId: row.qitemId, findingQitemId: finding!.qitemId, action: "created",
|
||||
}));
|
||||
expect(finding).toMatchObject({ sourceSession: "sender@r", destinationSession: "worker@r", state: "pending" });
|
||||
expect(finding!.tags).toEqual(expect.arrayContaining([
|
||||
"stuck-sweep-finding", `stuck-sweep:stalled-after-claim:${row.qitemId}`, `recovery-for:${row.qitemId}`,
|
||||
]));
|
||||
for (const tag of unrelated) expect(finding!.tags).not.toContain(tag);
|
||||
expect(finding!.tags).not.toContain(ESCALATION_HOP_TAG);
|
||||
expect(repo.getById(row.qitemId)!.tags).toEqual([...scope, ...unrelated]);
|
||||
|
||||
const second = await runSweep();
|
||||
expect(second.result.findings).toContainEqual(expect.objectContaining({
|
||||
qitemId: row.qitemId, findingQitemId: finding!.qitemId, action: "refreshed",
|
||||
}));
|
||||
expect(await findingsFor(row.qitemId)).toEqual([expect.objectContaining({ qitemId: finding!.qitemId, state: "pending" })]);
|
||||
await repo.update({ qitemId: row.qitemId, actorSession: "worker@r", transitionNote: "resumed scoped work" });
|
||||
const progress = await runSweep();
|
||||
expect(progress.result.findings).toContainEqual(expect.objectContaining({
|
||||
qitemId: row.qitemId, findingQitemId: finding!.qitemId, action: "closed",
|
||||
}));
|
||||
expect(repo.getById(finding!.qitemId)).toMatchObject({ state: "done", closureReason: "no-follow-on" });
|
||||
const actualScope = (finding!.tags ?? []).filter(tag => /^(project|mission|slice):/.test(tag));
|
||||
expect(actualScope).toEqual(scope);
|
||||
});
|
||||
|
||||
it("#2758 new recovery uses the current source scope after the sweep awaits another row's wake", async () => {
|
||||
const beforeScope = ["project:before", "mission:qitem-before", "slice:before"];
|
||||
const currentScope = ["project:current", "mission:qitem-current", "slice:current"];
|
||||
const row = await repo.create({
|
||||
sourceSession: "sender@r", destinationSession: "worker@r", body: "work with changing scope",
|
||||
tags: beforeScope, nudge: false,
|
||||
});
|
||||
repo.claim({ qitemId: row.qitemId, destinationSession: "worker@r" });
|
||||
ageClaim(row.qitemId, 60);
|
||||
const wake = await repo.create({
|
||||
sourceSession: "sender@r", destinationSession: "new-seat@r", body: "retry fixture wake", nudge: false,
|
||||
});
|
||||
setNudgeResult(wake.qitemId, "unroutable:no-pane", new Date(Date.now() - 10 * 60_000));
|
||||
let listedScope: string[] | undefined;
|
||||
const route = () => {
|
||||
listedScope ??= repo.getById(row.qitemId)!.tags ?? [];
|
||||
return null;
|
||||
};
|
||||
const classify = vi.spyOn(repo, "classifyDestinationOf").mockReturnValue({
|
||||
class: "pane-bound",
|
||||
} as ReturnType<typeof repo.classifyDestinationOf>);
|
||||
// Only the external wake is fake; sweep selection, tag reads, creation and SQLite writes are real.
|
||||
const deliver = vi.spyOn(repo, "deliverWakeForSuccessor").mockImplementation(async (id) => {
|
||||
expect(id).toBe(wake.qitemId);
|
||||
await Promise.resolve();
|
||||
db.prepare("UPDATE queue_items SET tags = ? WHERE qitem_id = ?").run(JSON.stringify(currentScope), row.qitemId);
|
||||
repo.recordNudgeAttempt(wake.qitemId, "ok");
|
||||
});
|
||||
try {
|
||||
const first = await runSweep({ resolveOrchestrator: route });
|
||||
expect(deliver).toHaveBeenCalledTimes(1);
|
||||
expect(listedScope, "the candidate was listed before the awaited scope update").toEqual(beforeScope);
|
||||
expect(repo.getById(row.qitemId)!.tags).toEqual(currentScope);
|
||||
const findings = await findingsFor(row.qitemId);
|
||||
expect(findings).toHaveLength(1);
|
||||
const finding = findings[0]!;
|
||||
expect(first.result.findings).toContainEqual(expect.objectContaining({
|
||||
kind: "stalled-after-claim", qitemId: row.qitemId, findingQitemId: finding.qitemId, action: "created",
|
||||
}));
|
||||
expect(finding).toMatchObject({ sourceSession: "sender@r", destinationSession: "worker@r", state: "pending" });
|
||||
expect(finding.tags).toEqual(expect.arrayContaining([
|
||||
"stuck-sweep-finding", `stuck-sweep:stalled-after-claim:${row.qitemId}`, `recovery-for:${row.qitemId}`,
|
||||
]));
|
||||
for (const tag of beforeScope) expect(finding.tags).not.toContain(tag);
|
||||
const actualScope = (finding.tags ?? []).filter(tag => /^(project|mission|slice):/.test(tag));
|
||||
expect(actualScope).toEqual(currentScope);
|
||||
} finally {
|
||||
deliver.mockRestore();
|
||||
classify.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("updates the current reason of a retained recovery while preserving its original evidence", async () => {
|
||||
const row = await mkRow();
|
||||
repo.claim({ qitemId: row.qitemId, destinationSession: "worker@r" });
|
||||
|
||||
@@ -14,14 +14,15 @@ const destination = "worker@demo";
|
||||
const databases: ReturnType<typeof createDb>[] = [];
|
||||
afterEach(() => { for (const db of databases.splice(0)) db.close(); });
|
||||
|
||||
function fixture(resolveSessionAddresses?: (name: string) => string[]) {
|
||||
function fixture(resolveSessionAddresses?: (name: string) => string[], hasPendingPostCompactStage?: (name: string) => boolean) {
|
||||
const db = createDb(); databases.push(db); migrate(db, ALL_MIGRATIONS);
|
||||
const bus = new EventBus(db), outbox = new OutboxHandler(db);
|
||||
let refused = true;
|
||||
const send = vi.fn(async () => refused
|
||||
? { ok: false, sent: false, reason: "target_needs_input", error: `Refused: '${destination}' is at an interactive prompt (selection_prompt). No text was sent.` }
|
||||
: { ok: true, verified: true });
|
||||
const queue = new QueueRepository(db, bus, { transport: { send }, resolveSessionAddresses, loadHumanRegistry: () => ({ ok: true, entities: [] }) });
|
||||
const queue = new QueueRepository(db, bus, { transport: { send }, resolveSessionAddresses, hasPendingPostCompactStage,
|
||||
loadHumanRegistry: () => ({ ok: true, entities: [] }) });
|
||||
queue.attachOutbox(outbox);
|
||||
const observe = async (state: AgentActivity["state"] | undefined, paneNeedsInput = false) => queue.retryPromptWakesOnClear(destination, state, paneNeedsInput);
|
||||
const handoff = async () => {
|
||||
@@ -47,6 +48,21 @@ it("a refused handoff retries once on a clear, reusing its committed wake and fr
|
||||
expect(f.queue.list({ limit: 100 })).toHaveLength(2);
|
||||
});
|
||||
|
||||
// #2575: "Compacted" reads as a clear too. While the compaction still owes the turn boundary, restore or audit, the retried
|
||||
// wake ("Recovered queue notification") would reach the agent before its restore; it waits, and the next clear sends it.
|
||||
it("a prompt-clear retry waits while a managed compaction owes the seat a post-compaction step", async () => {
|
||||
let owed = true;
|
||||
const f = fixture(undefined, name => owed && name === destination);
|
||||
await f.observe("needs_input"); const id = await f.handoff();
|
||||
f.clear(); await f.observe("idle"); await f.observe("running");
|
||||
expect(f.send).toHaveBeenCalledTimes(1);
|
||||
expect(f.outbox.getById(WAKE_INTENT_PREFIX + id)?.deliveryState).toBe("failed");
|
||||
owed = false; await f.observe("idle");
|
||||
expect(f.send).toHaveBeenCalledTimes(2);
|
||||
expect(f.send.mock.calls[1][1]).toContain("Recovered queue notification");
|
||||
expect(f.outbox.getById(WAKE_INTENT_PREFIX + id)?.deliveryState).toBe("delivered");
|
||||
});
|
||||
|
||||
it("a retry refused by a returning prompt stops after that one attempt", async () => {
|
||||
const f = fixture(); await f.observe("needs_input"); const id = await f.handoff();
|
||||
await f.observe("idle"); await f.observe("idle"); await f.observe("running");
|
||||
|
||||
@@ -19,6 +19,7 @@ import { nodeSpecFieldsSchema } from "../src/db/migrations/007_node_spec_fields.
|
||||
import { agentspecRebootSchema } from "../src/db/migrations/014_agentspec_reboot.js";
|
||||
import { startupContextSchema } from "../src/db/migrations/015_startup_context.js";
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import { RigTeardownOrchestrator } from "../src/domain/rig-teardown.js";
|
||||
import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { SnapshotRepository } from "../src/domain/snapshot-repository.js";
|
||||
@@ -5649,8 +5650,43 @@ describe("RestoreOrchestrator", () => {
|
||||
expect(names).not.toContain("dev-driver@r99");
|
||||
});
|
||||
|
||||
it("no binding row: falls back to the derived name, observably", async () => {
|
||||
const { snapId } = seedSeat({ logicalId: "worker-a", boundName: null });
|
||||
it.each(["restore", "subset"] as const)("clean down then %s derives the same name without a missing-binding warning", async door => {
|
||||
const { rigId, nodeId } = seedSeat({ logicalId: "worker-a", boundName: "r99-worker-a" });
|
||||
const tmux = mockTmux();
|
||||
const down = await new RigTeardownOrchestrator({ db, rigRepo, sessionRegistry, eventBus, tmuxAdapter: tmux, snapshotCapture }).teardown(rigId);
|
||||
expect(down.errors).toEqual([]);
|
||||
expect(down.sessionsKilled).toBe(1);
|
||||
expect(down.snapshotId).not.toBeNull();
|
||||
expect(sessionRegistry.getBindingForNode(nodeId)).toBeNull();
|
||||
expect(db.prepare("SELECT status, intentionally_stopped FROM sessions WHERE node_id = ?").get(nodeId))
|
||||
.toMatchObject({ status: "exited", intentionally_stopped: 1 });
|
||||
const claude = mockClaudeResume();
|
||||
const orch = createOrchestrator({ tmux, claude });
|
||||
const result = door === "restore" ? await orch.restore(down.snapshotId!) : await orch.launchNodeSubset(rigId, ["worker-a"]);
|
||||
expect(result.ok).toBe(true);
|
||||
const warnings = "result" in result ? result.result.warnings : result.warnings;
|
||||
expect(warnings?.some(w => w.includes("FR-5") && w.includes("no durably-bound"))).toBe(false);
|
||||
expect(createdSessionNames(tmux)).toContain("r99-worker-a");
|
||||
expect(sessionRegistry.getBindingForNode(nodeId)?.tmuxSession).toBe("r99-worker-a");
|
||||
expect(vi.mocked(claude.resume).mock.calls.at(-1)?.slice(0, 3)).toEqual(["r99-worker-a", "claude_id", "tok-abc"]);
|
||||
});
|
||||
|
||||
it("an older intentional stop does not hide a newer missing-binding warning", async () => {
|
||||
const { snapId, nodeId } = seedSeat({ logicalId: "worker-a", boundName: null });
|
||||
const old = sessionRegistry.registerSession(nodeId, "old@r99");
|
||||
sessionRegistry.markIntentionallyStopped(old.id);
|
||||
db.prepare("UPDATE sessions SET created_at = '2000-01-01' WHERE id = ?").run(old.id);
|
||||
const tmux = mockTmux();
|
||||
const result = await createOrchestrator({ tmux }).restore(snapId);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.result.warnings.some(w => w.includes("FR-5") && w.includes("no durably-bound"))).toBe(true);
|
||||
expect(createdSessionNames(tmux)).toContain("r99-worker-a");
|
||||
});
|
||||
|
||||
it.each(["running", "exited"])("no binding row after an unintentional %s: falls back to the derived name, observably", async status => {
|
||||
const { snapId, nodeId, rigId } = seedSeat({ logicalId: "worker-a", boundName: null });
|
||||
const session = sessionRegistry.getSessionsForRig(rigId).find(s => s.nodeId === nodeId)!;
|
||||
sessionRegistry.updateStatus(session.id, status);
|
||||
const tmux = mockTmux();
|
||||
const orch = createOrchestrator({ tmux });
|
||||
|
||||
@@ -5660,8 +5696,9 @@ describe("RestoreOrchestrator", () => {
|
||||
expect(result.result.warnings.some((w) => w.includes("FR-5") && w.includes("no durably-bound"))).toBe(true);
|
||||
});
|
||||
|
||||
it("binding row with empty tmux_session: falls back to the derived name, observably", async () => {
|
||||
const { snapId } = seedSeat({ logicalId: "worker-a", bindEmpty: true });
|
||||
it.each([false, true])("binding row with empty tmux_session still warns after intentional stop %s", async stopped => {
|
||||
const { snapId, rigId } = seedSeat({ logicalId: "worker-a", bindEmpty: true });
|
||||
if (stopped) sessionRegistry.markIntentionallyStopped(sessionRegistry.getSessionsForRig(rigId)[0]!.id);
|
||||
const tmux = mockTmux();
|
||||
const orch = createOrchestrator({ tmux });
|
||||
|
||||
|
||||
@@ -14,6 +14,9 @@ import { seatSkillsPointerStartupFile } from "../src/domain/rigspec-instantiator
|
||||
import type { ParsedStartupContext } from "../src/domain/seat-lifecycle-service.js";
|
||||
import type { Node } from "../src/domain/types.js";
|
||||
import type { NodeBinding } from "../src/domain/runtime-adapter.js";
|
||||
import { PiRuntimeAdapter } from "../src/adapters/pi-runtime-adapter.js";
|
||||
import { piSeatPaths } from "../src/adapters/pi-runner-protocol.js";
|
||||
import { piAccountSwitchStateRoot } from "../src/domain/pi-account-state.js";
|
||||
import { ClaudeCodeAdapter } from "../src/adapters/claude-code-adapter.js";
|
||||
import { managedBlockText } from "../src/domain/managed-blocks.js";
|
||||
import { managedBlockRecords } from "../src/domain/managed-block-records.js";
|
||||
@@ -111,6 +114,39 @@ describe("a runtime switch plans the successor from the seat's own agent spec",
|
||||
expect(materials.dropped).toContainEqual({ setting: "model (agent spec, profile default, declared for claude-code)", value: "claude-opus-5-5" });
|
||||
});
|
||||
|
||||
it.each(["codex", "claude-code"] as const)("prepares %s → Pi in isolated successor storage with target settings", async from => {
|
||||
const node = seat(from, { model: "source-model" });
|
||||
const materials = plan(node, from, "pi");
|
||||
expect(materials.settings.model).toEqual({ value: null, source: "native_default" });
|
||||
expect(materials.settings.effort).toEqual({ value: null, source: "native_default" });
|
||||
expect(materials.firstTurn).toContain("- runtime: pi");
|
||||
expect(materials.files.map(file => file.absolutePath)).not.toContain(seatSkillPaths({ rigId, logicalId: "dev.impl", runtime: from }, home).catalogPath);
|
||||
const explicit = plan(node, from, "pi", { model: "provider/pi-model", effort: "medium" });
|
||||
expect(explicit.settings.model).toEqual({ value: "provider/pi-model", source: "explicit" });
|
||||
expect(explicit.settings.effort).toEqual({ value: "medium", source: "explicit" });
|
||||
const base = nodePath.join(home, "state", "pi");
|
||||
const canonical = "dev-impl@seat-rig";
|
||||
const adapter = new PiRuntimeAdapter({ stateRoot: base, runnerEntryPath: "/unused-runner.js", tmux: {},
|
||||
fsOps: { exists: existsSync, readFile: (p: string) => readFileSync(p, "utf8"), writeFile: writeFileSync,
|
||||
mkdirp: (p: string) => mkdirSync(p, { recursive: true }), listFiles: readdirSync } } as never);
|
||||
await materials.prepare({ nodeId: node.id, cwd: node.cwd, tmuxSession: "openrig-switch-staged", launchGeneration: "next",
|
||||
runtimeSwitchFrom: { runtime: from, session: canonical, pane: "%0", generation: "old" }, ...materials.bindingAdditions() } as NodeBinding, adapter);
|
||||
const target = piSeatPaths(piAccountSwitchStateRoot(base, "next"), canonical);
|
||||
expect(readFileSync(nodePath.join(target.agentDir, "skills", "review", "SKILL.md"), "utf8")).toContain("# Review");
|
||||
expect(existsSync(piSeatPaths(base, canonical).agentDir)).toBe(false);
|
||||
expect(existsSync(nodePath.join(node.cwd!, ".claude"))).toBe(false);
|
||||
expect(materials.notRun).toEqual(["/rename impl"]);
|
||||
});
|
||||
|
||||
it.each(["codex", "claude-code"] as const)("Pi → %s resolves that runtime's spec and materials", to => {
|
||||
const materials = plan(seat("pi", { model: "provider/pi-model" }), "pi", to);
|
||||
expect(materials.settings.model).toEqual({ value: to === "codex" ? "gpt-5.5" : "claude-opus-5-5", source: "agent_spec" });
|
||||
expect(materials.settings.effort.value).toBe(to === "codex" ? "high" : null);
|
||||
expect(materials.firstTurn).toContain(`- runtime: ${to}`);
|
||||
expect(materials.bindingAdditions().seatSkillsRoot).toBe(seatSkillPaths({ rigId, logicalId: "dev.impl", runtime: to }, home).skillsRoot);
|
||||
expect(materials.dropped).toContainEqual({ setting: "model", value: "provider/pi-model" });
|
||||
});
|
||||
|
||||
it("an explicit option wins over the spec", () => {
|
||||
const materials = plan(seat("codex"), "codex", "claude-code", { model: "claude-sonnet-5", effort: "max" });
|
||||
expect(materials.settings.model).toEqual({ value: "claude-sonnet-5", source: "explicit" });
|
||||
|
||||
@@ -57,7 +57,7 @@ describe("live seat runtime switch", () => {
|
||||
listWindows: vi.fn(async (name: string) => panes.has(name) ? [{ id: "@1", index: 0, name: "main", panes: 1, active: true }] : []),
|
||||
probeSession: vi.fn(async (name: string) => ({ state: panes.has(name) ? "present" : "absent" })),
|
||||
capturePaneScreen: vi.fn(async () => "predecessor screen tail"),
|
||||
createSession: vi.fn(async (name: string) => { panes.set(name, "%5"); return { ok: true }; }),
|
||||
createSession: vi.fn(async (name: string, _cwd?: string, _env?: Record<string, string>) => { panes.set(name, "%5"); return { ok: true }; }),
|
||||
getDefaultShell: vi.fn(async () => "/bin/zsh"),
|
||||
getPaneCommand: vi.fn(async () => "zsh"),
|
||||
sendText: vi.fn(async (target: string, text: string) => { typed.push({ target, text }); return { ok: true }; }),
|
||||
@@ -137,8 +137,8 @@ describe("live seat runtime switch", () => {
|
||||
});
|
||||
afterEach(() => { db.close(); fs.rmSync(home, { recursive: true, force: true }); });
|
||||
|
||||
it("switches a Pi account only after confirmation, preserves its seat and selects durable isolated state", async () => {
|
||||
db.prepare("UPDATE nodes SET runtime = 'pi', model = 'mistral/devstral', effort_choice = 'max', codex_config_profile = NULL WHERE id = ?").run(nodeId);
|
||||
it.each(["pi", "codex", "claude-code"])("switches %s to Pi only after confirmation and selects durable isolated state", async from => {
|
||||
db.prepare("UPDATE nodes SET runtime = ?, model = 'mistral/devstral', effort_choice = 'max', codex_config_profile = NULL WHERE id = ?").run(from, nodeId);
|
||||
const base = path.join(home, "state", "pi");
|
||||
const original = piSeatPaths(base, SEAT).agentDir;
|
||||
fs.mkdirSync(original, { recursive: true });
|
||||
@@ -151,12 +151,13 @@ describe("live seat runtime switch", () => {
|
||||
const target = adapter("pi");
|
||||
vi.mocked(target.launchHarness).mockImplementation(async binding => {
|
||||
expect(binding.accountHome).toBe(accountHome);
|
||||
expect(binding.launchWrapper).toEqual(["wrapper", "--"]);
|
||||
expect(binding.launchWrapper).toEqual(from === "claude-code" ? undefined : ["wrapper", "--"]);
|
||||
expect(rigRepo.getEffectiveSeatEnv(nodeId).PI_CODING_AGENT_DIR).toBeUndefined();
|
||||
expect(piAccountStateRoot(db, base, SEAT)).toBe(base);
|
||||
expect(fs.readFileSync(path.join(original, "auth.json"), "utf8")).toBe("original-seat-login");
|
||||
const successor = piSeatPaths(piAccountSwitchStateRoot(base, binding.launchGeneration!), SEAT);
|
||||
expect(fs.readFileSync(path.join(successor.agentDir, "models.json"), "utf8")).toBe("{\"providers\":{}}");
|
||||
if (from === "pi") expect(fs.readFileSync(path.join(successor.agentDir, "models.json"), "utf8")).toBe("{\"providers\":{}}");
|
||||
else expect(fs.existsSync(path.join(successor.agentDir, "models.json"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(successor.agentDir, "auth.json"))).toBe(false);
|
||||
fs.mkdirSync(successor.sessionsDir, { recursive: true });
|
||||
const token = path.join(successor.sessionsDir, "conversation.jsonl");
|
||||
@@ -164,11 +165,13 @@ describe("live seat runtime switch", () => {
|
||||
fs.writeFileSync(successor.runnerStatePath, JSON.stringify({ ready: true, sessionFile: token, launchId: "successor", updatedAt: "now" }));
|
||||
return { ok: true, resumeType: "pi_session_file", resumeToken: token };
|
||||
});
|
||||
db.prepare("UPDATE nodes SET launch_wrapper = ? WHERE id = ?").run(JSON.stringify(["wrapper", "--"]), nodeId);
|
||||
if (from !== "claude-code") db.prepare("UPDATE nodes SET launch_wrapper = ? WHERE id = ?").run(JSON.stringify(["wrapper", "--"]), nodeId);
|
||||
const svc = service({ runtimeAdapters: { pi: target } }); successorConfirms(svc);
|
||||
expect(await svc.setRuntime({ seatRef: SEAT, runtime: "pi", accountHome, reason: "Change budget" }))
|
||||
.toMatchObject({ ok: true, changed: true, from: "pi", to: "pi", claimsMoved: 1, recoveryPending: false });
|
||||
expect(nodeRow()).toMatchObject({ runtime: "pi", model: "mistral/devstral", effort: "high", effort_choice: "max" });
|
||||
.toMatchObject({ ok: true, changed: true, from, to: "pi", claimsMoved: 1, recoveryPending: false });
|
||||
expect(nodeRow()).toMatchObject(from === "pi"
|
||||
? { runtime: "pi", model: "mistral/devstral", effort: "high", effort_choice: "max" }
|
||||
: { runtime: "pi", model: null, effort: null, effort_choice: null });
|
||||
expect(rigRepo.getEffectiveSeatEnv(nodeId).PI_CODING_AGENT_DIR).toBe(accountHome);
|
||||
expect(piAccountStateRoot(db, base, SEAT)).toBe(piAccountSwitchStateRoot(base, openSwitch()!.successor_generation));
|
||||
expect(sessionRegistry.getBindingForNode(nodeId)).toMatchObject({ tmuxSession: SEAT, tmuxPane: "%5" });
|
||||
@@ -234,13 +237,118 @@ describe("live seat runtime switch", () => {
|
||||
expect(result.settings.effort.value).toBe("max");
|
||||
});
|
||||
|
||||
it("refuses Pi homes without credentials before staging, and keeps cross-runtime Pi switches out of scope", async () => {
|
||||
it("refuses Pi homes without credentials before staging", async () => {
|
||||
db.prepare("UPDATE nodes SET runtime = 'pi' WHERE id = ?").run(nodeId);
|
||||
expect(await service().setRuntime({ seatRef: SEAT, runtime: "pi", accountHome: home, reason: "No auth", dryRun: true })).toMatchObject({ ok: false, code: "invalid_runtime_settings" });
|
||||
expect(await service().setRuntime({ seatRef: SEAT, runtime: "codex", reason: "Change runtime", dryRun: true })).toMatchObject({ ok: false, code: "runtime_switch_unsupported" });
|
||||
expect(fakeTmux.createSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("switches to Pi and back with a confirmed successor and an isolated account", async () => {
|
||||
const accountHome = path.join(home, "mistral-plan");
|
||||
fs.mkdirSync(accountHome);
|
||||
fs.writeFileSync(path.join(accountHome, "auth.json"), "{}");
|
||||
const svc = service();
|
||||
successorConfirms(svc);
|
||||
expect(await svc.setRuntime({ seatRef: SEAT, runtime: "pi", model: "mistral/devstral-latest", accountHome, reason: "Mistral plan" }))
|
||||
.toMatchObject({ ok: true, changed: true, to: "pi" });
|
||||
expect(rigRepo.getRig((db.prepare("SELECT rig_id FROM nodes WHERE id = ?").get(nodeId) as { rig_id: string }).rig_id)?.nodes.find(n => n.id === nodeId)).toMatchObject({ runtime: "pi", accountHome });
|
||||
expect(await svc.setRuntime({ seatRef: SEAT, runtime: "codex", reason: "Pi allowance ran out" }))
|
||||
.toMatchObject({ ok: true, changed: true, to: "codex" });
|
||||
expect(rigRepo.getRig((db.prepare("SELECT rig_id FROM nodes WHERE id = ?").get(nodeId) as { rig_id: string }).rig_id)?.nodes.find(n => n.id === nodeId)).toMatchObject({ runtime: "codex" });
|
||||
});
|
||||
|
||||
it("entering Pi does not revive an earlier Pi occupant's settings or credentials", async () => {
|
||||
const base = path.join(home, "state", "pi");
|
||||
const original = piSeatPaths(base, SEAT).agentDir;
|
||||
fs.mkdirSync(original, { recursive: true });
|
||||
fs.writeFileSync(path.join(original, "settings.json"), '{"defaultModel":"old-pi-model"}');
|
||||
fs.writeFileSync(path.join(original, "auth.json"), "old-pi-login");
|
||||
const target = adapter("pi");
|
||||
vi.mocked(target.launchHarness).mockImplementation(async binding => {
|
||||
const staged = piSeatPaths(piAccountSwitchStateRoot(base, binding.launchGeneration!), SEAT).agentDir;
|
||||
expect(fs.existsSync(path.join(staged, "settings.json"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(staged, "auth.json"))).toBe(false);
|
||||
expect(fs.readFileSync(path.join(original, "auth.json"), "utf8")).toBe("old-pi-login");
|
||||
return { ok: true };
|
||||
});
|
||||
const svc = service({ runtimeAdapters: { pi: target } }); successorConfirms(svc);
|
||||
expect(await svc.setRuntime({ seatRef: SEAT, runtime: "pi", reason: "Fresh target defaults" })).toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
const piTransitions = [
|
||||
["codex", "pi"], ["claude-code", "pi"], ["pi", "codex"], ["pi", "claude-code"],
|
||||
] as const;
|
||||
|
||||
describe.each(piTransitions)("%s → %s", (from, to) => {
|
||||
it.each(["confirm", "timeout", "launch-failure"])("keeps identity, claims and credentials through %s", async outcome => {
|
||||
const oldToken = JSON.stringify({ label: "source", tokenFile: "/synthetic/source-token" });
|
||||
db.prepare("UPDATE nodes SET runtime = ?, account_home = '/accounts/source', claude_token_ref = ?, effort_choice = 'max', advisor = 'source-advisor' WHERE id = ?")
|
||||
.run(from, oldToken, nodeId);
|
||||
const claimed = await queueRepo.create({ sourceSession: "orch-lead@seat-rig", destinationSession: SEAT, body: "review" });
|
||||
queueRepo.claim({ qitemId: claimed.qitemId, destinationSession: SEAT });
|
||||
const pending = await queueRepo.create({ sourceSession: "orch-lead@seat-rig", destinationSession: SEAT, body: "next" });
|
||||
const accountHome = path.join(home, "target-account");
|
||||
fs.mkdirSync(accountHome);
|
||||
fs.writeFileSync(path.join(accountHome, "auth.json"), "{}");
|
||||
const target = adapter(to);
|
||||
vi.mocked(target.launchHarness).mockImplementation(async binding => {
|
||||
expect(nodeRow().runtime).toBe(from);
|
||||
expect(sessionRegistry.currentOccupantTenure(nodeId)?.generationUuid).toBe(predecessorGeneration);
|
||||
expect(fakeTmux.signalPaneProcess).not.toHaveBeenCalled();
|
||||
expect(binding).toMatchObject({ accountHome, accountToken: null });
|
||||
const stagedEnv = fakeTmux.createSession.mock.calls[0]![2]!;
|
||||
const sourceHomeVar = from === "pi" ? "PI_CODING_AGENT_DIR" : from === "codex" ? "CODEX_HOME" : "CLAUDE_CONFIG_DIR";
|
||||
expect(stagedEnv[sourceHomeVar]).toBeUndefined();
|
||||
expect(binding.model).toBeUndefined();
|
||||
expect(binding.effort).toBeUndefined();
|
||||
expect(binding.launchWrapper).toBeUndefined();
|
||||
return outcome === "launch-failure" ? { ok: false, error: "synthetic launch failure" } : { ok: true };
|
||||
});
|
||||
const svc = service({ runtimeAdapters: { [to]: target } });
|
||||
if (outcome === "confirm") successorConfirms(svc);
|
||||
const result = await svc.setRuntime({ seatRef: SEAT, runtime: to, accountHome, reason: "Change provider", confirmTimeoutMs: 1 });
|
||||
const committed = outcome === "confirm";
|
||||
expect(result).toMatchObject(committed ? { ok: true, changed: true, from, to, claimsMoved: 1 } : { ok: false, committed: false });
|
||||
expect(nodeRow()).toMatchObject(committed
|
||||
? { runtime: to, model: null, effort: null, effort_choice: null, advisor: null, codex_config_profile: null }
|
||||
: { runtime: from, model: "gpt-5.5", effort: "high", effort_choice: "max", advisor: "source-advisor" });
|
||||
expect(db.prepare("SELECT account_home, claude_token_ref FROM nodes WHERE id = ?").get(nodeId))
|
||||
.toEqual({ account_home: committed ? accountHome : "/accounts/source", claude_token_ref: committed ? null : oldToken });
|
||||
expect(sessionRegistry.getBindingForNode(nodeId)).toMatchObject({ tmuxSession: SEAT, tmuxPane: committed ? "%5" : "%0" });
|
||||
expect(db.prepare("SELECT state, claimed_by_generation_uuid g FROM queue_items WHERE qitem_id = ?").get(claimed.qitemId))
|
||||
.toMatchObject({ state: "in-progress", g: committed ? openSwitch()!.successor_generation : predecessorGeneration });
|
||||
expect(db.prepare("SELECT state, destination_session FROM queue_items WHERE qitem_id = ?").get(pending.qitemId))
|
||||
.toMatchObject({ state: "pending", destination_session: SEAT });
|
||||
expect([...panes.keys()]).toEqual([SEAT]);
|
||||
});
|
||||
|
||||
it.each([true, false])("clears the source selection and token without reusing it (home selected: %s)", async selected => {
|
||||
db.prepare("UPDATE nodes SET runtime = ?, account_home = ?, claude_token_ref = ? WHERE id = ?")
|
||||
.run(from, selected ? "/accounts/source" : null, JSON.stringify({ label: "source", tokenFile: "/synthetic/token" }), nodeId);
|
||||
const target = adapter(to);
|
||||
vi.mocked(target.launchHarness).mockImplementation(async binding => {
|
||||
expect(binding.accountHome).toBeNull();
|
||||
expect(binding.accountToken).toBeNull();
|
||||
return { ok: true };
|
||||
});
|
||||
const svc = service({ runtimeAdapters: { [to]: target } }); successorConfirms(svc);
|
||||
expect(await svc.setRuntime({ seatRef: SEAT, runtime: to, reason: "Target default", clearAccountHome: selected })).toMatchObject({ ok: true });
|
||||
expect(db.prepare("SELECT account_home, claude_token_ref FROM nodes WHERE id = ?").get(nodeId))
|
||||
.toEqual({ account_home: null, claude_token_ref: null });
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["codex", "pi"] as const)("carries the explicitly configured wrapper from %s to the other supported runtime", async from => {
|
||||
const to = from === "pi" ? "codex" : "pi";
|
||||
db.prepare("UPDATE nodes SET runtime = ? WHERE id = ?").run(from, nodeId);
|
||||
rigRepo.setNodeLaunchWrapper(nodeId, ["wrapper", "--"]);
|
||||
const target = adapter(to);
|
||||
const svc = service({ runtimeAdapters: { [to]: target } }); successorConfirms(svc);
|
||||
expect(await svc.setRuntime({ seatRef: SEAT, runtime: to, reason: "Wrapped switch" })).toMatchObject({ ok: true });
|
||||
expect(vi.mocked(target.launchHarness).mock.calls[0]![0].launchWrapper).toEqual(["wrapper", "--"]);
|
||||
expect(rigRepo.getRig(rigRepo.listRigs()[0]!.id)?.nodes[0]?.launchWrapper).toEqual(["wrapper", "--"]);
|
||||
});
|
||||
|
||||
it("switches a Codex seat to another account home on the same runtime, committing only after confirmation", async () => {
|
||||
const accountHome = path.join(home, "api-account");
|
||||
fs.mkdirSync(accountHome);
|
||||
@@ -384,7 +492,8 @@ describe("live seat runtime switch", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("a target Claude setup-token reference is staged and committed with its home, replacing any previous token", async () => {
|
||||
it.each(["codex", "pi"])("a %s seat stages and commits the target Claude token with its home", async from => {
|
||||
db.prepare("UPDATE nodes SET runtime = ? WHERE id = ?").run(from, nodeId);
|
||||
const accountHome = path.join(home, "plan-account"); fs.mkdirSync(accountHome);
|
||||
const tokenFile = path.join(home, "plan-token"); fs.writeFileSync(tokenFile, "synthetic-fixture-not-a-token", { mode: 0o600 });
|
||||
const accountToken = { label: "claude-plan", tokenFile };
|
||||
@@ -822,7 +931,7 @@ describe("live seat runtime switch", () => {
|
||||
it("a seat on another runtime", async () => {
|
||||
db.prepare("UPDATE nodes SET runtime = 'omp' WHERE id = ?").run(nodeId);
|
||||
expect(await service().setRuntime({ seatRef: SEAT, runtime: "claude-code", reason: "x", dryRun: true }))
|
||||
.toMatchObject({ ok: false, code: "runtime_switch_unsupported", message: expect.stringMatching(/only Codex and Claude Code seats can change runtime/) });
|
||||
.toMatchObject({ ok: false, code: "runtime_switch_unsupported", message: expect.stringMatching(/only Codex, Claude Code and Pi seats can change runtime/) });
|
||||
});
|
||||
it("a target this daemon can't start", async () => {
|
||||
expect(await service({ runtimeAdapters: { codex: adapter("codex") } }).setRuntime({ seatRef: SEAT, runtime: "claude-code", reason: "x", dryRun: true }))
|
||||
@@ -890,6 +999,25 @@ describe("live seat runtime switch", () => {
|
||||
expect(piAccountStateRoot(db, home, SEAT)).toBe(phase === "committed" ? piAccountSwitchStateRoot(home, "g-next") : home);
|
||||
});
|
||||
|
||||
describe.each(piTransitions)("recover %s → %s", (from, to) => {
|
||||
it.each(["preparing", "confirmed", "committed"] as const)("retains the durable runtime and state after %s", async phase => {
|
||||
record(phase);
|
||||
const committed = phase === "committed";
|
||||
db.prepare("UPDATE seat_runtime_switches SET from_runtime = ?, to_runtime = ?").run(from, to);
|
||||
db.prepare("UPDATE nodes SET runtime = ?, account_home = ? WHERE id = ?")
|
||||
.run(committed ? to : from, committed ? "/accounts/target" : "/accounts/source", nodeId);
|
||||
panes.set(SEAT, phase === "preparing" ? "%0" : "%5");
|
||||
panes.set("openrig-switch-sw1", phase === "preparing" ? "%5" : "%0");
|
||||
expect(await service().recover(nodeId)).toMatchObject({ ok: true });
|
||||
expect(nodeRow().runtime).toBe(committed ? to : from);
|
||||
expect(db.prepare("SELECT account_home FROM nodes WHERE id = ?").get(nodeId))
|
||||
.toEqual({ account_home: committed ? "/accounts/target" : "/accounts/source" });
|
||||
expect(panes.get(SEAT)).toBe(committed ? "%5" : "%0");
|
||||
expect([...panes.keys()]).toEqual([SEAT]);
|
||||
expect(piAccountStateRoot(db, home, SEAT)).toBe(committed && to === "pi" ? piAccountSwitchStateRoot(home, "g-next") : home);
|
||||
});
|
||||
});
|
||||
|
||||
it("panes that match neither side: nothing is touched, and the seat's status says why", async () => {
|
||||
record("preparing");
|
||||
panes.set(SEAT, "%9");
|
||||
@@ -1073,6 +1201,21 @@ describe("live seat runtime switch", () => {
|
||||
});
|
||||
|
||||
describe("launch_wrapper runtime-switch guard and carry", () => {
|
||||
it("refuses a wrapped Pi-to-Claude switch in dry-run and execution before side effects", async () => {
|
||||
db.prepare("UPDATE nodes SET runtime = 'pi' WHERE id = ?").run(nodeId);
|
||||
rigRepo.setNodeLaunchWrapper(nodeId, ["rtk", "--wrap"]);
|
||||
const svc = service();
|
||||
for (const dryRun of [true, false]) {
|
||||
const result = await svc.setRuntime({ seatRef: SEAT, runtime: "claude-code", reason: "try-claude", dryRun });
|
||||
expect(result).toMatchObject({ ok: false, code: "runtime_switch_failed" });
|
||||
expect((result as { message: string }).message).toMatch(/wrapped Pi seat to Claude Code/i);
|
||||
expect(openSwitch()).toBeUndefined();
|
||||
expect(fakeTmux.createSession).not.toHaveBeenCalled();
|
||||
expect(nodeRow().runtime).toBe("pi");
|
||||
expect(rigRepo.getRig(rigRepo.listRigs()[0]!.id)?.nodes[0]?.launchWrapper).toEqual(["rtk", "--wrap"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts switching a wrapped Codex seat to Claude Code in the dry run, and the real switch hands the Claude successor the same wrapper", async () => {
|
||||
rigRepo.setNodeLaunchWrapper(nodeId, ["rtk", "--to-claude"]);
|
||||
// The dry run reports acceptance, as execution does, and changes nothing.
|
||||
|
||||
@@ -377,6 +377,113 @@ describe("posted asks follow settlement outside Slack", () => {
|
||||
expect(postCount).toBe(7);
|
||||
});
|
||||
|
||||
it("remembers a deleted original across restarts: asked once, not again after a restart", async () => {
|
||||
const item = await postAsk();
|
||||
apiHook = (method) => method === "conversations.history" ? response({ ok: true, messages: [] }) : undefined;
|
||||
repo.update({ qitemId: item.qitemId, actorSession: "author@rig", state: "done", closureReason: "no-follow-on" });
|
||||
await vi.waitFor(() => expect(logs.join("\n")).toContain("original message unavailable"));
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
const reads = () => methods.filter((m) => m === "conversations.history").length;
|
||||
expect(reads()).toBe(1);
|
||||
expect(repo.listTransitions(item.qitemId).filter((t) => t.transitionNote?.startsWith("slack-ask-settle-unavailable "))).toHaveLength(1);
|
||||
expect(repo.getById(item.qitemId)?.state).toBe("done");
|
||||
|
||||
wire.stop(); start();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(reads()).toBe(1);
|
||||
expect(methods).not.toContain("chat.update");
|
||||
expect(repo.listTransitions(item.qitemId).filter((t) => t.transitionNote?.startsWith("slack-ask-settle-unavailable "))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("still edits a later post of the same ask after its first post was recorded as gone", async () => {
|
||||
const item = await repo.create({ sourceSession: "author@rig", destinationSession: "worker@rig",
|
||||
summary: "Choose the next step", body: "Original work", nudge: false });
|
||||
const park = () => repo.update({ qitemId: item.qitemId, actorSession: "worker@rig", state: "blocked", blockedOn: human,
|
||||
summary: "May I continue?", transitionNote: "Need the person's answer." });
|
||||
const send = async (ts: string) => {
|
||||
const [alert] = await makeQueuePorts(repo, { loadHumanRegistry: () => registry }).listHumanAlerts({});
|
||||
expect(wire.dispatcher.dispatch("post_message", human, alert)).toMatchObject({ ok: true });
|
||||
await vi.waitFor(() => expect(repo.currentHumanRequiredEpisode(item.qitemId)?.receipt?.messageTs).toBe(ts));
|
||||
};
|
||||
const reads = (ts: string) => requests.filter((r) => new URL(r.url).searchParams.get("latest") === ts
|
||||
|| new URL(r.url).searchParams.get("oldest") === ts).length;
|
||||
// The first post was deleted in Slack: reading it finds nothing.
|
||||
apiHook = (method, params) => method === "conversations.history" && params.latest === "1.000001" ? response({ ok: true, messages: [] }) : undefined;
|
||||
park(); await send("1.000001");
|
||||
repo.update({ qitemId: item.qitemId, actorSession: "worker@rig", state: "in-progress", transitionNote: "Answered in the pane; continuing." });
|
||||
await vi.waitFor(() => expect(repo.listTransitions(item.qitemId).some((t) => t.transitionNote?.startsWith("slack-ask-settle-unavailable ")
|
||||
&& t.transitionNote.includes("message_ts=1.000001 "))).toBe(true));
|
||||
|
||||
park(); await send("2.000001");
|
||||
repo.update({ qitemId: item.qitemId, actorSession: "worker@rig", state: "in-progress", transitionNote: "Second answer received." });
|
||||
|
||||
await vi.waitFor(() => expect(updates).toHaveLength(1));
|
||||
expect(updates[0]!.ts).toBe("2.000001");
|
||||
expect(reads("1.000001")).toBe(1);
|
||||
wire.stop(); start();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(reads("1.000001")).toBe(1);
|
||||
expect(updates).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("still skips a deleted original until restart when recording it as unavailable fails", async () => {
|
||||
const item = await postAsk();
|
||||
apiHook = (method) => method === "conversations.history" ? response({ ok: true, messages: [] }) : undefined;
|
||||
const update = repo.update.bind(repo);
|
||||
const spy = vi.spyOn(repo, "update").mockImplementation((input) => {
|
||||
if (input.transitionNote?.startsWith("slack-ask-settle-unavailable ")) throw new Error("disk full");
|
||||
return update(input);
|
||||
});
|
||||
repo.update({ qitemId: item.qitemId, actorSession: "author@rig", state: "done", closureReason: "no-follow-on" });
|
||||
await vi.waitFor(() => expect(logs.join("\n")).toContain("couldn't record"));
|
||||
spy.mockRestore();
|
||||
const reads = () => methods.filter((m) => m === "conversations.history").length;
|
||||
expect(reads()).toBe(1);
|
||||
expect(repo.listTransitions(item.qitemId).some((t) => t.transitionNote?.startsWith("slack-ask-settle-unavailable "))).toBe(false);
|
||||
|
||||
// Another queue change re-enqueues the ask; the in-memory note keeps it from being read again.
|
||||
repo.update({ qitemId: item.qitemId, actorSession: "author@rig", transitionNote: "Follow-up note." });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(reads()).toBe(1);
|
||||
|
||||
// Nothing durable was written, so a restart asks Slack once more and records it this time.
|
||||
wire.stop(); start();
|
||||
await vi.waitFor(() => expect(repo.listTransitions(item.qitemId).filter((t) => t.transitionNote?.startsWith("slack-ask-settle-unavailable "))).toHaveLength(1));
|
||||
expect(reads()).toBe(2);
|
||||
expect(methods).not.toContain("chat.update");
|
||||
});
|
||||
|
||||
it("retries an edit refused as cant_update_message after a restart, once the token can edit it", async () => {
|
||||
const item = await postAsk();
|
||||
apiHook = (method) => method === "chat.update" ? response({ ok: false, error: "cant_update_message" }) : undefined;
|
||||
repo.update({ qitemId: item.qitemId, actorSession: "author@rig", state: "done", closureReason: "no-follow-on" });
|
||||
await vi.waitFor(() => expect(logs.join("\n")).toContain("cant_update_message"));
|
||||
const edits = () => methods.filter((m) => m === "chat.update").length;
|
||||
expect(edits()).toBe(1);
|
||||
expect(repo.listTransitions(item.qitemId).some((t) => t.transitionNote?.startsWith("slack-ask-settle-unavailable "))).toBe(false);
|
||||
|
||||
// Skipped until restart: another queue change doesn't try the edit again.
|
||||
repo.update({ qitemId: item.qitemId, actorSession: "author@rig", transitionNote: "Follow-up note." });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(edits()).toBe(1);
|
||||
|
||||
// The token is corrected and the daemon restarts: the same message is edited.
|
||||
wire.stop(); apiHook = undefined; start();
|
||||
await vi.waitFor(() => expect(updates).toHaveLength(1));
|
||||
expect(updates[0]!.ts).toBe("1.000001");
|
||||
expect(repo.listTransitions(item.qitemId).some((t) => t.transitionNote?.startsWith("slack-ask-settled "))).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps retrying after a restart when the failure isn't about the message itself", async () => {
|
||||
const item = await postAsk();
|
||||
apiHook = (method) => method === "conversations.history" ? response({ ok: false, error: "not_in_channel" }) : undefined;
|
||||
repo.update({ qitemId: item.qitemId, actorSession: "author@rig", state: "done", closureReason: "no-follow-on" });
|
||||
await vi.waitFor(() => expect(logs.join("\n")).toContain("not_in_channel"));
|
||||
expect(repo.listTransitions(item.qitemId).some((t) => t.transitionNote?.startsWith("slack-ask-settle-unavailable "))).toBe(false);
|
||||
wire.stop(); apiHook = undefined; start();
|
||||
await vi.waitFor(() => expect(updates).toHaveLength(1));
|
||||
});
|
||||
|
||||
it("shows a later close's new reason after the same direct ask was reopened", async () => {
|
||||
const item = await postAsk();
|
||||
wire.stop(); vi.useFakeTimers();
|
||||
|
||||
@@ -39,6 +39,7 @@ describe("Socket Mode liveness", () => {
|
||||
let openGate: Promise<void> | null;
|
||||
let openFails: boolean;
|
||||
let openCalls: number;
|
||||
let gapConnects: number;
|
||||
const pingChannel: PingChannel = { subscribe: (fn) => { pings.add(fn); }, unsubscribe: (fn) => { pings.delete(fn); } };
|
||||
const ping = () => { for (const fn of pings) fn({ payload: Buffer.from("") }); };
|
||||
|
||||
@@ -50,6 +51,7 @@ describe("Socket Mode liveness", () => {
|
||||
openGate = null;
|
||||
openFails = false;
|
||||
openCalls = 0;
|
||||
gapConnects = 0;
|
||||
const fsx = memFs();
|
||||
receipts = new InboundReceiptStore("/s/inbound-receipts.jsonl", fsx);
|
||||
const router = new InboundRouter({
|
||||
@@ -74,6 +76,7 @@ describe("Socket Mode liveness", () => {
|
||||
},
|
||||
pingChannel,
|
||||
receipts,
|
||||
recovery: { run: async () => {}, stop: () => {}, connected: () => { gapConnects += 1; } },
|
||||
log: () => {},
|
||||
});
|
||||
});
|
||||
@@ -159,6 +162,19 @@ describe("Socket Mode liveness", () => {
|
||||
expect(handle!.status()).toMatchObject({ state: "connected", generation: 2 });
|
||||
});
|
||||
|
||||
it("tells recovery about a connect after a gap, never about a refresh that overlapped the old connection", async () => {
|
||||
const first = await openSocket(1);
|
||||
expect(gapConnects).toBe(1);
|
||||
message(first, { envelope_id: "d-9", type: "disconnect", reason: "refresh_requested" });
|
||||
const second = await openSocket(2);
|
||||
expect(gapConnects).toBe(1);
|
||||
first.ws.onclose!();
|
||||
second.ws.onclose!();
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
await openSocket(3);
|
||||
expect(gapConnects).toBe(2);
|
||||
});
|
||||
|
||||
it("reports disconnected when the retired connection closes before its replacement opens", async () => {
|
||||
const first = await openSocket(1);
|
||||
for (let i = 0; i < 2; i++) { ping(); await vi.advanceTimersByTimeAsync(10_000); }
|
||||
|
||||
@@ -69,23 +69,31 @@ function fixture() {
|
||||
const channel = (history[u.searchParams.get("channel")!] ?? []).filter(inRange).sort(byTs).reverse();
|
||||
return Response.json({ ok: true, messages: channel.slice(0, limit), has_more: channel.length > limit });
|
||||
};
|
||||
const recovery = (opts: { channels?: Array<{ channel: string; seed?: "adoption" }> } = {}) => {
|
||||
const recovery = (opts: { channels?: Array<{ channel: string; seed?: "adoption" }>; relayed?: boolean } = {}) => {
|
||||
const r = new SlackRecovery({ channels: opts.channels ?? [{ channel: "C1" }], token: "fixture-token", stateDir: "/s", router,
|
||||
trackedThreads: (channels, closedSince) => map.trackedForRecovery(channels, closedSince),
|
||||
fetchImpl, now: () => now, channelStore, threadStore });
|
||||
fetchImpl, now: () => now, channelStore, threadStore, ...(opts.relayed ? { relayed: true } : {}) });
|
||||
r.initialize();
|
||||
closes.push(() => r.stop());
|
||||
return r;
|
||||
};
|
||||
const track = (root: string, seat = "asker@test", channel = "C1") =>
|
||||
map.open({ threadTs: root, channel, human: "human-fixture@external", seat, conversationId: `q-${seat}-${root}` });
|
||||
// An ask: its queue row in `state`, and the thread root it posted.
|
||||
const ask = (root: string, state: string, seat = "asker@test") => {
|
||||
const id = `q-${seat}-${root}`, stamp = new Date(now).toISOString();
|
||||
db.prepare(`INSERT INTO queue_items (qitem_id, ts_created, ts_updated, source_session, destination_session, state, body)
|
||||
VALUES (?, ?, ?, ?, 'human-fixture@external', ?, 'ask')`).run(id, stamp, stamp, seat, state);
|
||||
track(root, seat);
|
||||
return { settle: () => db.prepare(`UPDATE queue_items SET state = 'done', ts_updated = ? WHERE qitem_id = ?`).run(new Date(now).toISOString(), id) };
|
||||
};
|
||||
const reply = (seconds: number, root = ROOT, extra: Partial<SlackEvent> = {}): SlackEvent =>
|
||||
({ type: "message", user: "U1", channel: "C1", text: `reply ${seconds}`, ts: ts(seconds), thread_ts: root, ...extra });
|
||||
const message = (seconds: number, channel = "C1"): SlackEvent =>
|
||||
({ type: "message", user: "U1", channel, text: `task ${seconds}`, ts: ts(seconds) });
|
||||
const calls = (method: string) => queries.filter(u => u.pathname.endsWith(method));
|
||||
const rows = () => repo.list({ limit: 500 });
|
||||
return { db, files, seen, dead, map, router, deps, routed, resolveHumanReply, history, replies, queries, recovery, track, reply, message,
|
||||
return { db, files, seen, dead, map, router, deps, routed, resolveHumanReply, history, replies, queries, recovery, track, ask, reply, message,
|
||||
calls, rows, time: (ms: number) => { now = ms; }, advance: (ms: number) => { now += ms; },
|
||||
respond: (fn?: typeof respond) => { respond = fn; }, down: (v: boolean) => { queueDown = v; } };
|
||||
}
|
||||
@@ -144,7 +152,7 @@ it("tracks a thread whose root or close is within seven days, never by the map's
|
||||
|
||||
await r.run();
|
||||
|
||||
expect(f.calls("conversations.replies").map(u => u.searchParams.get("ts"))).toEqual([root("oldClosed"), root("recentRoot")]);
|
||||
expect(f.calls("conversations.replies").map(u => u.searchParams.get("ts"))).toEqual([root("recentRoot"), root("oldClosed")]);
|
||||
// The old root closed yesterday is scanned from its root: eligibility isn't a seven-day history cap.
|
||||
expect(f.rows().map(row => row.destinationSession).sort()).toEqual(["a@test", "c@test"]);
|
||||
});
|
||||
@@ -163,16 +171,18 @@ it("keeps recovery counts for the connector's lifetime after a thread leaves the
|
||||
expect(r.status().acceptedThisProcess).toBe(1);
|
||||
});
|
||||
|
||||
it("a thread Slack can't find stays incomplete without a call each pass until its mapping changes", async () => {
|
||||
it("a thread Slack can't find isn't asked again each pass or after a restart, only once a day", async () => {
|
||||
const f = fixture(); f.track(ROOT);
|
||||
f.respond(u => u.pathname.endsWith("conversations.replies") ? Response.json({ ok: false, error: "thread_not_found" }) : undefined);
|
||||
const r = f.recovery();
|
||||
await r.run();
|
||||
f.time(at(1000)); await r.run();
|
||||
r.stop();
|
||||
f.time(at(2000)); const restarted = f.recovery(); await restarted.run();
|
||||
|
||||
expect(f.calls("conversations.replies")).toHaveLength(1);
|
||||
expect(r.status().threads).toMatchObject({ incomplete: { thread_not_found: 1 } });
|
||||
f.map.close(ROOT, "C1"); f.time(at(1100)); await r.run();
|
||||
expect(restarted.status().threads).toMatchObject({ incomplete: { thread_not_found: 1 } });
|
||||
f.time(at(100 + DAY + 1)); await restarted.run();
|
||||
expect(f.calls("conversations.replies")).toHaveLength(2);
|
||||
});
|
||||
|
||||
@@ -313,7 +323,7 @@ it.each(["missing_scope", "not_in_channel", "thread_not_found"])("%s leaves the
|
||||
|
||||
it("channels and threads take turns under one budget: every thread is reached and the channel is visited every pass", async () => {
|
||||
const f = fixture();
|
||||
for (let i = 1; i <= 10; i++) { f.track(ts(i), `s${i}@test`); f.replies[`C1:${ts(i)}`] = [f.reply(i + 20, ts(i))]; }
|
||||
for (let i = 1; i <= 10; i++) { f.ask(ts(i), "pending", `s${i}@test`); f.replies[`C1:${ts(i)}`] = [f.reply(i + 20, ts(i))]; }
|
||||
f.history.C1 = [];
|
||||
const r = f.recovery();
|
||||
for (let pass = 1; pass <= 4; pass++) {
|
||||
@@ -321,7 +331,8 @@ it("channels and threads take turns under one budget: every thread is reached an
|
||||
const before = f.calls("conversations.replies").length;
|
||||
await r.run();
|
||||
expect(f.calls("conversations.history")).toHaveLength(pass);
|
||||
expect(f.calls("conversations.replies").length - before).toBe(3);
|
||||
// Three thread calls a pass until each thread has had its scan since boot: the last pass has one left.
|
||||
expect(f.calls("conversations.replies").length - before).toBe(pass < 4 ? 3 : 1);
|
||||
if (pass === 1) expect(r.status().threads).toMatchObject({ tracked: 10, scanned: 3, pending: 7 });
|
||||
if (pass === 1) expect(r.status().threads.oldestPendingSeconds).toBeGreaterThan(100);
|
||||
}
|
||||
@@ -407,3 +418,248 @@ it.each(["thread reply", "broadcast reply in a newly covered channel"])("a recov
|
||||
expect(f.resolveHumanReply).not.toHaveBeenCalled();
|
||||
expect(f.rows()[0]!.body).toContain("It was not applied as an answer to q-decision");
|
||||
});
|
||||
|
||||
it("a missed reply on an open ask is recovered in the first pass after boot, ahead of 100 older threads, and again after a restart", async () => {
|
||||
const f = fixture();
|
||||
// A week of settled asks, newer and older than the open one, none scanned since they closed.
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const root = ts(-6 * DAY + i * 3000);
|
||||
f.ask(root, "done", `old${i}@test`); f.replies[`C1:${root}`] = [f.reply(-6 * DAY + i * 3000 + 10, root)];
|
||||
}
|
||||
const open = ts(-3 * DAY - 1);
|
||||
f.ask(open, "pending", "asker@test");
|
||||
f.replies[`C1:${open}`] = [f.reply(50, open, { text: "Yes, ship it" })];
|
||||
const first = f.recovery();
|
||||
|
||||
await first.run();
|
||||
|
||||
expect(f.calls("conversations.replies")[0]!.searchParams.get("ts")).toBe(open);
|
||||
expect(f.rows().filter(row => row.destinationSession === "asker@test")).toHaveLength(1);
|
||||
// The rest follow newest first.
|
||||
expect(f.calls("conversations.replies").slice(1).map(u => u.searchParams.get("ts")))
|
||||
.toEqual([ts(-6 * DAY + 99 * 3000), ts(-6 * DAY + 98 * 3000), ts(-6 * DAY + 97 * 3000)]);
|
||||
f.time(at(400)); await first.run();
|
||||
first.stop();
|
||||
|
||||
// Down for an hour; the person replies again meanwhile.
|
||||
f.replies[`C1:${open}`].push(f.reply(1000, open, { text: "And the docs too" }));
|
||||
f.time(at(3700));
|
||||
const restarted = f.recovery();
|
||||
await restarted.run();
|
||||
|
||||
const asker = f.rows().filter(row => row.destinationSession === "asker@test");
|
||||
expect(asker).toHaveLength(2);
|
||||
expect(asker.map(row => row.body).join("\n")).toContain("And the docs too");
|
||||
expect(f.calls("conversations.replies").at(-3)!.searchParams.get("ts")).toBe(open);
|
||||
expect(f.resolveHumanReply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("asks still waiting on the person go first, newest first, then the rest; nothing is scanned again while connected", async () => {
|
||||
const f = fixture();
|
||||
for (let i = 1; i <= 5; i++) f.ask(ts(i), "pending", `open${i}@test`);
|
||||
f.ask(ts(10), "done", "settled@test");
|
||||
const r = f.recovery();
|
||||
f.advance(5000); // past the settle margin, so a scan covers through boot (the boundary has its own test)
|
||||
const order = async () => { const before = f.calls("conversations.replies").length; await r.run(); f.advance(10_000);
|
||||
return f.calls("conversations.replies").slice(before).map(u => u.searchParams.get("ts")); };
|
||||
|
||||
// (The first pass has four thread calls: the channel has no gap yet.)
|
||||
expect(await order()).toEqual([ts(5), ts(4), ts(3), ts(2)]);
|
||||
expect(await order()).toEqual([ts(1), ts(10)]);
|
||||
// Replies while connected arrive live, so a thread scanned since boot isn't asked again every pass.
|
||||
expect(await order()).toEqual([]);
|
||||
});
|
||||
|
||||
it("a waiting ask scanned since boot is scanned again within the hour, a settled thread within the day", async () => {
|
||||
const f = fixture();
|
||||
f.ask(ts(1), "pending", "open@test");
|
||||
f.ask(ts(2), "done", "settled@test");
|
||||
const r = f.recovery();
|
||||
f.advance(5000); // past the settle margin, so a scan covers through boot (the boundary has its own test)
|
||||
await r.run();
|
||||
const scanned = () => f.calls("conversations.replies").map(u => u.searchParams.get("ts"));
|
||||
expect(scanned()).toEqual([ts(1), ts(2)]);
|
||||
|
||||
f.time(at(100 + 59 * 60)); await r.run();
|
||||
expect(scanned()).toHaveLength(2);
|
||||
f.time(at(100 + 61 * 60)); await r.run();
|
||||
expect(scanned()).toEqual([ts(1), ts(2), ts(1)]);
|
||||
f.time(at(100 + DAY + 60)); await r.run();
|
||||
expect(scanned().slice(3).sort()).toEqual([ts(1), ts(2)]);
|
||||
});
|
||||
|
||||
it("a settled thread is due again after the socket reconnects from a gap, not a day later", async () => {
|
||||
const f = fixture();
|
||||
f.ask(ts(1), "done", "settled@test");
|
||||
const r = f.recovery();
|
||||
f.advance(5000); // past the settle margin, so a scan covers through boot (the boundary has its own test)
|
||||
await r.run();
|
||||
f.time(at(400)); await r.run();
|
||||
expect(f.calls("conversations.replies")).toHaveLength(1);
|
||||
|
||||
// The socket drops for longer than Slack retries; a reply lands in the settled thread meanwhile.
|
||||
f.replies[`C1:${ts(1)}`] = [f.reply(500, ts(1), { text: "one more thing" })];
|
||||
f.time(at(1200)); r.connected();
|
||||
f.advance(5000); await r.run();
|
||||
|
||||
expect(f.calls("conversations.replies")).toHaveLength(2);
|
||||
expect(f.rows().filter(row => row.destinationSession === "settled@test")).toHaveLength(1);
|
||||
f.time(at(1500)); await r.run();
|
||||
expect(f.calls("conversations.replies")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("a settled thread is scanned once after each boot and then daily, not every pass, and a reply after close still lands", async () => {
|
||||
const f = fixture();
|
||||
const done = f.ask(ts(1), "pending", "settled@test");
|
||||
f.ask(ts(2), "pending", "open@test");
|
||||
const r = f.recovery();
|
||||
f.advance(5000); // past the settle margin, so a scan covers through boot (the boundary has its own test)
|
||||
await r.run();
|
||||
done.settle();
|
||||
const settledCalls = () => f.calls("conversations.replies").filter(u => u.searchParams.get("ts") === ts(1)).length;
|
||||
expect(settledCalls()).toBe(1);
|
||||
|
||||
f.time(at(400)); await r.run();
|
||||
f.time(at(700)); await r.run();
|
||||
expect(settledCalls()).toBe(1); // caught up while this process listened live: nothing to recover
|
||||
expect(r.status().threads).toMatchObject({ tracked: 2, settled: 1, settledRescanHours: 24 });
|
||||
r.stop();
|
||||
|
||||
// Off for a while; a reply arrives in the settled thread meanwhile.
|
||||
f.replies[`C1:${ts(1)}`] = [f.reply(800, ts(1), { text: "one more thing" })];
|
||||
f.time(at(1000));
|
||||
const restarted = f.recovery();
|
||||
f.advance(5000); await restarted.run();
|
||||
expect(settledCalls()).toBe(2);
|
||||
expect(f.rows().filter(row => row.destinationSession === "settled@test")).toHaveLength(1);
|
||||
|
||||
f.time(at(1300)); await restarted.run();
|
||||
expect(settledCalls()).toBe(2);
|
||||
f.time(at(1000 + DAY + 10)); await restarted.run();
|
||||
expect(settledCalls()).toBe(3);
|
||||
});
|
||||
|
||||
it("a thread about to leave the seven-day window is scanned first after boot, so its missed reply isn't lost", async () => {
|
||||
const f = fixture();
|
||||
for (let i = 1; i <= 5; i++) f.ask(ts(i), "pending", `open${i}@test`);
|
||||
// Posted seven days ago less an hour: it leaves the window within the hour.
|
||||
const expiring = ts(100 - 7 * DAY + 3600);
|
||||
f.ask(expiring, "done", "old@test");
|
||||
f.replies[`C1:${expiring}`] = [f.reply(50, expiring)];
|
||||
const r = f.recovery();
|
||||
|
||||
await r.run();
|
||||
|
||||
expect(f.calls("conversations.replies")[0]!.searchParams.get("ts")).toBe(expiring);
|
||||
expect(f.rows().filter(row => row.destinationSession === "old@test")).toHaveLength(1);
|
||||
// The waiting asks follow, newest first.
|
||||
expect(f.calls("conversations.replies").slice(1).map(u => u.searchParams.get("ts"))).toEqual([ts(5), ts(4), ts(3)]);
|
||||
});
|
||||
|
||||
it("a reply in the last five seconds before a reconnect is recovered on the next pass, not left for the daily backstop", async () => {
|
||||
const f = fixture();
|
||||
f.ask(ts(1), "done", "settled@test");
|
||||
const r = f.recovery();
|
||||
f.time(at(400)); await r.run();
|
||||
f.time(at(700)); await r.run();
|
||||
expect(f.calls("conversations.replies")).toHaveLength(1);
|
||||
|
||||
// A reply three seconds before live delivery resumes: a scan at the connect stops at the settle margin, before it.
|
||||
f.replies[`C1:${ts(1)}`] = [f.reply(1197, ts(1), { text: "just before" })];
|
||||
f.time(at(1200)); r.connected();
|
||||
await r.run();
|
||||
expect(f.calls("conversations.replies").at(-1)!.searchParams.get("latest")).toBe(`${BASE + 1195}.000000`);
|
||||
expect(f.rows().filter(row => row.destinationSession === "settled@test")).toHaveLength(0);
|
||||
|
||||
f.time(at(1210)); await r.run();
|
||||
expect(f.rows().filter(row => row.destinationSession === "settled@test")).toHaveLength(1);
|
||||
const calls = f.calls("conversations.replies").length;
|
||||
f.time(at(1500)); await r.run();
|
||||
expect(f.calls("conversations.replies")).toHaveLength(calls);
|
||||
});
|
||||
|
||||
it("threads that keep failing ahead of a reachable waiting ask can't take its turn pass after pass", async () => {
|
||||
const f = fixture();
|
||||
// Four newer waiting asks Slack refuses every time, enough to fill a pass's thread calls, and an older one it answers.
|
||||
const failing = [ts(11), ts(12), ts(13), ts(14)];
|
||||
failing.forEach((root, i) => f.ask(root, "pending", `failing${i}@test`));
|
||||
f.ask(ts(1), "pending", "reachable@test");
|
||||
f.replies[`C1:${ts(1)}`] = [f.reply(50, ts(1), { text: "the answer" })];
|
||||
f.respond(u => u.pathname.endsWith("conversations.replies") && failing.includes(u.searchParams.get("ts")!)
|
||||
? Response.json({ ok: false, error: "not_in_channel" }) : undefined);
|
||||
const r = f.recovery();
|
||||
|
||||
await r.run();
|
||||
expect(f.calls("conversations.replies").map(u => u.searchParams.get("ts"))).toEqual([ts(14), ts(13), ts(12), ts(11)]);
|
||||
expect(f.rows().filter(row => row.destinationSession === "reachable@test")).toHaveLength(0);
|
||||
|
||||
// Every failing thread's 300-second retry is due again at the next pass; the reachable one still goes first.
|
||||
f.time(at(100 + 301)); await r.run();
|
||||
const second = f.calls("conversations.replies").slice(4).map(u => u.searchParams.get("ts"));
|
||||
expect(second[0]).toBe(ts(1));
|
||||
expect(f.rows().filter(row => row.destinationSession === "reachable@test")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("a reply on the newest waiting ask just before a reconnect is recovered on the next pass, though the connect's own pass stopped short", async () => {
|
||||
const f = fixture();
|
||||
// Ten waiting asks, more than a pass's four thread calls.
|
||||
for (let i = 1; i <= 10; i++) f.ask(ts(i), "pending", `open${i}@test`);
|
||||
const r = f.recovery();
|
||||
f.advance(5000);
|
||||
for (let pass = 0; pass < 3; pass++) { await r.run(); f.advance(300_000); }
|
||||
expect(new Set(f.calls("conversations.replies").map(u => u.searchParams.get("ts"))).size).toBe(10);
|
||||
|
||||
// A reply three seconds before live delivery resumes, and a pass at the connect itself, which stops short of it.
|
||||
f.replies[`C1:${ts(10)}`] = [f.reply(1997, ts(10), { text: "just before" })];
|
||||
f.time(at(2000)); r.connected();
|
||||
const before = f.calls("conversations.replies").length;
|
||||
await r.run();
|
||||
const atConnect = f.calls("conversations.replies").slice(before).map(u => u.searchParams.get("ts"));
|
||||
expect(atConnect[0]).toBe(ts(10));
|
||||
expect(atConnect.length).toBeLessThan(10);
|
||||
expect(f.rows().filter(row => row.destinationSession === "open10@test")).toHaveLength(0);
|
||||
|
||||
// Its call made progress, so it keeps its place ahead of the six threads not yet asked since the connect.
|
||||
f.time(at(2300));
|
||||
const next = f.calls("conversations.replies").length;
|
||||
await r.run();
|
||||
expect(f.calls("conversations.replies")[next]!.searchParams.get("ts")).toBe(ts(10));
|
||||
expect(f.rows().filter(row => row.destinationSession === "open10@test")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("a waiting ask whose backlog needs a second page keeps its place ahead of threads not yet asked", async () => {
|
||||
const f = fixture();
|
||||
for (let i = 1; i <= 10; i++) f.ask(ts(i), "pending", `open${i}@test`);
|
||||
// The newest ask has twenty missed replies: more than a page.
|
||||
f.replies[`C1:${ts(10)}`] = Array.from({ length: 20 }, (_, i) => f.reply(11 + i, ts(10)));
|
||||
const r = f.recovery();
|
||||
f.advance(5000);
|
||||
|
||||
await r.run();
|
||||
const first = f.calls("conversations.replies").map(u => u.searchParams.get("ts"));
|
||||
expect(first[0]).toBe(ts(10));
|
||||
expect(first.length).toBeLessThan(10);
|
||||
expect(f.rows().filter(row => row.destinationSession === "open10@test").length).toBeLessThan(20);
|
||||
|
||||
f.advance(300_000);
|
||||
await r.run();
|
||||
expect(f.calls("conversations.replies")[first.length]!.searchParams.get("ts")).toBe(ts(10));
|
||||
expect(f.rows().filter(row => row.destinationSession === "open10@test")).toHaveLength(20);
|
||||
});
|
||||
|
||||
it("on a machine the hub relays to, which can't see a gap in relayed events, a settled thread's backstop is three hours", async () => {
|
||||
const f = fixture();
|
||||
f.ask(ts(1), "done", "settled@test");
|
||||
const r = f.recovery({ channels: [{ channel: "C1", seed: "adoption" }], relayed: true });
|
||||
r.adoptRelayed("C1", at(100));
|
||||
f.advance(5000); // past the settle margin, so a scan covers through boot (the boundary has its own test)
|
||||
await r.run();
|
||||
const scans = () => f.calls("conversations.replies").length;
|
||||
expect(scans()).toBe(1);
|
||||
|
||||
f.time(at(100 + 2 * 3600)); await r.run();
|
||||
expect(scans()).toBe(1);
|
||||
expect(r.status().threads).toMatchObject({ settled: 1, settledRescanHours: 3 });
|
||||
f.time(at(100 + 3 * 3600 + 60)); await r.run();
|
||||
expect(scans()).toBe(2);
|
||||
});
|
||||
@@ -12,8 +12,9 @@ it.each(["inherit", "isolated"] as const)("keeps remote session names intact thr
|
||||
// Simulate ssh's remote-command reconstruction. The local and remote shell
|
||||
// parsing are real; no SSH server or terminal provider is claimed here.
|
||||
writeFileSync(join(bin, "ssh"), '#!/bin/sh\n[ "$1" = -t ] || exit 64\nshift 2\nexec /bin/sh -c "$*"\n', { mode: 0o700 });
|
||||
// Inherited replacement tracking needs only remote tmux and a POSIX shell, not rig.
|
||||
writeFileSync(join(bin, "tmux"), '#!/bin/sh\ncase $1 in display-message) printf "%s\\n" "$4" >> "$OPR_ARGS"; target=$4; target=${target#=}; printf "7 session-id:%s\\n" "${target%:}";; show-environment) exit 1;; attach) printf "%s\\n" "$@" >> "$OPR_ARGS";; esac\n', { mode: 0o700 });
|
||||
// Inherited replacement tracking needs only remote tmux and a POSIX shell, not rig. The tile reads the seat's
|
||||
// activity and screen once; with no sleep on this PATH, its view then ends rather than reading again at once.
|
||||
writeFileSync(join(bin, "tmux"), '#!/bin/sh\ncase $1 in display-message) printf "%s\\n" "$4" >> "$OPR_ARGS"; case $5 in "#{pid} #{window_activity}") [ -e "$OPR_ARGS.seen" ] && exit 0; : > "$OPR_ARGS.seen"; printf "7 1\\n";; *) target=$4; target=${target#=}; printf "7 session-id:%s\\n" "${target%:}";; esac;; show-environment) exit 1;; attach) printf "%s\\n" "$@" >> "$OPR_ARGS";; esac\n', { mode: 0o700 });
|
||||
if (mode === "isolated") {
|
||||
writeFileSync(join(bin, "rig"), '#!/bin/sh\nprintf "%s\\n" "$OPENRIG_HOST_SELECTED" "$@" > "$OPR_ARGS"\n', { mode: 0o700 });
|
||||
}
|
||||
@@ -25,17 +26,21 @@ it.each(["inherit", "isolated"] as const)("keeps remote session names intact thr
|
||||
seat: session, label: session, tmuxSession: session, host: "edge", readOnly, alive: true,
|
||||
}], { resolveHost: () => ({ id: "edge", transport: "ssh", target: "fixture" }),
|
||||
localTmuxServer: ["-S", "/local/only/socket"], localTmuxServerMode: mode });
|
||||
writeFileSync(argsFile, "");
|
||||
writeFileSync(argsFile, ""); rmSync(`${argsFile}.seen`, { force: true });
|
||||
execFileSync("/bin/sh", ["-c", view.opened[0]!.paneCommand], {
|
||||
env: { PATH: mode === "inherit" ? bin : `${bin}:/usr/bin:/bin`, OPR_ARGS: argsFile, OPENRIG_HOST_SELECTED: "another-host" },
|
||||
// A tile that never ends fails here rather than hanging the run.
|
||||
timeout: 10000,
|
||||
});
|
||||
expect(readFileSync(argsFile, "utf8").trimEnd().split("\n")).toEqual(mode === "isolated"
|
||||
? ["local", "terminal", "attach", ...(readOnly ? ["--read-only"] : []), "--", session]
|
||||
: [`=${session}:`, "attach", ...(readOnly ? ["-r"] : []), "-t", "session-id"]);
|
||||
: [`=${session}:`, "session-id"]);
|
||||
expect(view.opened[0]!.paneCommand).not.toContain("/local/only/socket");
|
||||
writeFileSync(argsFile, "");
|
||||
execFileSync("/bin/sh", ["-c", view.opened[0]!.recoveryCommand!], {
|
||||
env: { PATH: mode === "inherit" ? bin : `${bin}:/usr/bin:/bin`, OPR_ARGS: argsFile, OPENRIG_HOST_SELECTED: "another-host" },
|
||||
// A tile that never ends fails here rather than hanging the run.
|
||||
timeout: 10000,
|
||||
});
|
||||
expect(readFileSync(argsFile, "utf8").trimEnd().split("\n")).toEqual(mode === "isolated"
|
||||
? ["local", "terminal", "attach", ...(readOnly ? ["--read-only"] : []), "--", session]
|
||||
@@ -63,7 +68,7 @@ it.each(["missing", "failed"])("does not attach to a namesake when the isolated
|
||||
seat: "seat@rig", label: "seat", tmuxSession: "seat@rig", host: "edge", readOnly: false, alive: true,
|
||||
}], { resolveHost: () => ({ id: "edge", transport: "ssh", target: "fixture" }), localTmuxServerMode: "isolated" });
|
||||
const result = spawnSync("/bin/sh", ["-c", view.opened[0]!.paneCommand], {
|
||||
env: { PATH: bin, OPR_ARGS: argsFile }, encoding: "utf8",
|
||||
env: { PATH: bin, OPR_ARGS: argsFile }, encoding: "utf8", timeout: 10000,
|
||||
});
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.status).toBe(failure === "missing" ? 127 : 69);
|
||||
|
||||
@@ -59,54 +59,117 @@ it.skipIf(process.platform === "win32")("keeps all four terminal tiles on their
|
||||
panes.push(await viewer(i === 0 ? ["new-session", "-d", "-P", "-F", "#{pane_id}", "-s", "view", terminal(tile.paneCommand)] : ["split-window", "-d", "-P", "-F", "#{pane_id}", "-t", "=view:", terminal(tile.paneCommand)]));
|
||||
await viewer(["select-layout", "-t", "=view:", "tiled"]);
|
||||
}
|
||||
const attached = () => seats(["list-sessions", "-F", "#{session_name}:#{session_attached}"]);
|
||||
const expected = ["anchor:0", ...names.map(name => `${name}:1`)].sort();
|
||||
await until(async () => JSON.stringify((await attached()).split("\n").sort()) === JSON.stringify(expected));
|
||||
expect(await seats(["list-clients", "-t", `=${names[3]}`, "-F", "#{client_readonly}"])).toBe("1");
|
||||
const shows = async (i: number, text: string) => (await viewer(["capture-pane", "-p", "-t", panes[i]!])).includes(text);
|
||||
// Each tile views its own seat, and none of them is a client of it.
|
||||
for (let i = 0; i < 4; i++) await until(() => shows(i, `SEAT${i}`));
|
||||
expect(await seats(["list-clients", "-F", "#{client_tty}"])).toBe("");
|
||||
expect((await adapter.killSession(names[1]!)).ok).toBe(true);
|
||||
await create(1, "REPLACED_SEAT1");
|
||||
await until(async () => JSON.stringify((await attached()).split("\n").sort()) === JSON.stringify(expected));
|
||||
await until(() => shows(1, "REPLACED_SEAT1"));
|
||||
expect((await viewer(["list-panes", "-t", "=view:", "-F", "#{pane_dead}"])).split("\n")).toEqual(["0", "0", "0", "0"]);
|
||||
await until(async () => (await viewer(["capture-pane", "-p", "-t", panes[1]!])).includes("REPLACED_SEAT1"));
|
||||
// Current handover/restart respawn in place; the same tile stays attached.
|
||||
for (const i of [0, 2, 3]) expect(await shows(i, `SEAT${i}`)).toBe(true);
|
||||
// Current handover/restart respawn in place; the same tile keeps viewing it.
|
||||
await seats(["respawn-pane", "-k", "-t", `=${names[0]}:`, "printf 'RESPAWNED_SEAT0\\n'; sleep 60"]);
|
||||
await until(async () => (await viewer(["capture-pane", "-p", "-t", panes[0]!])).includes("RESPAWNED_SEAT0"));
|
||||
expect((await attached()).split("\n").sort()).toEqual(expected);
|
||||
await until(() => shows(0, "RESPAWNED_SEAT0"));
|
||||
// A fresh launch of the last session restarts the server and can reuse $0.
|
||||
for (const i of [0, 2, 3]) expect((await adapter.killSession(names[i]!)).ok).toBe(true);
|
||||
await seats(["kill-session", "-t", "=anchor"]);
|
||||
expect((await adapter.killSession(names[1]!)).ok).toBe(true);
|
||||
await create(1, "NEW_SERVER_SEAT1");
|
||||
await until(async () => (await viewer(["capture-pane", "-p", "-t", panes[1]!])).includes("NEW_SERVER_SEAT1"));
|
||||
expect(await seats(["list-clients", "-F", "#{session_name}"])).toBe(names[1]);
|
||||
await until(() => shows(1, "NEW_SERVER_SEAT1"));
|
||||
expect(await viewer(["display-message", "-p", "-t", panes[1]!, "#{pane_dead}"])).toBe("0");
|
||||
});
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32").each(["detach", "identity", "removed", "unmanaged", "removed-marker"])("ends the tile on %s without attaching a sibling", async mode => {
|
||||
it.skipIf(process.platform === "win32").each(["identity", "removed", "unmanaged", "removed-marker"])("ends the tile on %s without viewing a sibling", async mode => {
|
||||
await fixture(async ({ socket, seats, viewer, adapter }) => {
|
||||
await seats(["new-session", "-d", "-s", "seat", ...(mode === "unmanaged" ? [] : ["-e", "OPENRIG_NODE_ID=original"]), "sleep 60"]);
|
||||
await seats(["new-session", "-d", "-s", "seat", ...(mode === "unmanaged" ? [] : ["-e", "OPENRIG_NODE_ID=original"]), "printf 'ORIGINAL\\n'; sleep 60"]);
|
||||
if (mode === "removed-marker") await seats(["set-environment", "-r", "-t", "=seat", "OPENRIG_NODE_ID"]);
|
||||
const command = tileAttachArgv("tmux", ["-S", socket], "seat", false, mode === "detach" ? 4 : 2).map(shellQuote).join(" ");
|
||||
const command = tileAttachArgv("tmux", ["-S", socket], "seat", false, 2).map(shellQuote).join(" ");
|
||||
await viewer(["new-session", "-d", "-s", "view", terminal(command)]);
|
||||
await until(async () => (await seats(["list-clients", "-t", "=seat", "-F", "#{session_name}"])) === "seat");
|
||||
if (mode === "detach") await seats(["detach-client", "-s", "=seat"]);
|
||||
else {
|
||||
expect((await adapter.killSession("seat")).ok).toBe(true);
|
||||
const name = mode === "removed" ? "seat-prefix" : "seat";
|
||||
await seats(["new-session", "-d", "-s", name, "-e", "OPENRIG_NODE_ID=other-seat", "sleep 60",
|
||||
...(mode === "removed-marker" ? [";", "set-environment", "-r", "-t", "=seat", "OPENRIG_NODE_ID"] : [])]);
|
||||
}
|
||||
await until(async () => (await viewer(["capture-pane", "-p", "-t", "=view:"])).includes("ORIGINAL"));
|
||||
expect((await adapter.killSession("seat")).ok).toBe(true);
|
||||
const name = mode === "removed" ? "seat-prefix" : "seat";
|
||||
await seats(["new-session", "-d", "-s", name, "-e", "OPENRIG_NODE_ID=other-seat", "printf 'SIBLING\\n'; sleep 60",
|
||||
...(mode === "removed-marker" ? [";", "set-environment", "-r", "-t", "=seat", "OPENRIG_NODE_ID"] : [])]);
|
||||
await until(async () => (await viewer(["display-message", "-p", "-t", "=view:", "#{pane_dead}"])) === "1");
|
||||
expect(await seats(["list-clients", "-F", "#{session_name}"])).toBe("");
|
||||
if (mode === "detach") expect(await viewer(["capture-pane", "-p", "-t", "=view:"])).not.toContain("No replacement");
|
||||
expect(await viewer(["capture-pane", "-p", "-t", "=view:"])).not.toContain("SIBLING");
|
||||
});
|
||||
});
|
||||
|
||||
// A restarted server numbers its sessions afresh, so the seat's old session id can belong to another seat.
|
||||
it.skipIf(process.platform === "win32").each([["seat-other", true], ["seat", true], ["seat-other", false]])("never shows or types into another seat that holds the old session id on a restarted server, named %s (managed: %s)", async (name, managed) => {
|
||||
await fixture(async ({ socket, seats, viewer }) => {
|
||||
await seats(["new-session", "-d", "-s", "seat", ...(managed ? ["-e", "OPENRIG_NODE_ID=original"] : []), "printf 'ORIGINAL\\n'; exec cat"]);
|
||||
const command = tileAttachArgv("tmux", ["-S", socket], "seat", false, 2).map(shellQuote).join(" ");
|
||||
await viewer(["new-session", "-d", "-s", "view", terminal(command)]);
|
||||
await until(async () => (await viewer(["capture-pane", "-p", "-t", "=view:"])).includes("ORIGINAL"));
|
||||
const id = await seats(["display-message", "-p", "-t", "=seat:", "#{session_id}"]);
|
||||
await seats(["kill-server"]);
|
||||
await seats(["-f", "/dev/null", "new-session", "-d", "-s", "anchor", "sleep 60", ";",
|
||||
"new-session", "-d", "-s", name, "-e", "OPENRIG_NODE_ID=DIFFERENT", "printf 'OTHER-SEAT\\n'; exec cat"]);
|
||||
expect(await seats(["display-message", "-p", "-t", id, "#{session_name}"])).toBe(name);
|
||||
await viewer(["send-keys", "-t", "=view:", "-l", "KEY-FOR-ORIGINAL"]);
|
||||
await until(async () => (await viewer(["display-message", "-p", "-t", "=view:", "#{pane_dead}"])) === "1");
|
||||
expect(await viewer(["capture-pane", "-p", "-t", "=view:"])).not.toContain("OTHER-SEAT");
|
||||
expect(await seats(["capture-pane", "-p", "-t", `=${name}:`])).not.toContain("KEY-FOR-ORIGINAL");
|
||||
expect(await seats(["list-clients", "-F", "#{session_name}"])).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
// With no key typed, only the view's own reads can tell the seat changed.
|
||||
it.skipIf(process.platform === "win32").each([false, true])("a read-only tile stops showing a restarted server's session that another seat now holds (managed: %s)", async managed => {
|
||||
await fixture(async ({ socket, seats, viewer }) => {
|
||||
await seats(["new-session", "-d", "-s", "seat", ...(managed ? ["-e", "OPENRIG_NODE_ID=original"] : []), "printf 'ORIGINAL\\n'; exec cat"]);
|
||||
const command = tileAttachArgv("tmux", ["-S", socket], "seat", true, 2).map(shellQuote).join(" ");
|
||||
await viewer(["new-session", "-d", "-s", "view", terminal(command)]);
|
||||
await until(async () => (await viewer(["capture-pane", "-p", "-t", "=view:"])).includes("ORIGINAL"));
|
||||
const id = await seats(["display-message", "-p", "-t", "=seat:", "#{session_id}"]);
|
||||
await seats(["kill-server"]);
|
||||
await seats(["-f", "/dev/null", "new-session", "-d", "-s", "anchor", "sleep 60", ";",
|
||||
"new-session", "-d", "-s", "seat-other", "-e", "OPENRIG_NODE_ID=DIFFERENT", "printf 'OTHER-SEAT\\n'; exec cat"]);
|
||||
expect(await seats(["display-message", "-p", "-t", id, "#{session_name}"])).toBe("seat-other");
|
||||
await new Promise(r => setTimeout(r, 2500));
|
||||
expect(await viewer(["capture-pane", "-p", "-t", "=view:"])).not.toContain("OTHER-SEAT");
|
||||
await until(async () => (await viewer(["display-message", "-p", "-t", "=view:", "#{pane_dead}"])) === "1");
|
||||
});
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")("stops showing a session whose seat identity changed in place", async () => {
|
||||
await fixture(async ({ socket, seats, viewer }) => {
|
||||
await seats(["new-session", "-d", "-s", "seat", "-e", "OPENRIG_NODE_ID=original", "printf 'ORIGINAL\\n'; exec cat"]);
|
||||
const command = tileAttachArgv("tmux", ["-S", socket], "seat", false, 2).map(shellQuote).join(" ");
|
||||
await viewer(["new-session", "-d", "-s", "view", terminal(command)]);
|
||||
await until(async () => (await viewer(["capture-pane", "-p", "-t", "=view:"])).includes("ORIGINAL"));
|
||||
await seats(["set-environment", "-t", "=seat", "OPENRIG_NODE_ID", "DIFFERENT", ";", "send-keys", "-t", "=seat:", "-l", "OTHER-SEAT"]);
|
||||
await until(async () => (await viewer(["display-message", "-p", "-t", "=view:", "#{pane_dead}"])) === "1");
|
||||
expect(await viewer(["capture-pane", "-p", "-t", "=view:"])).not.toContain("OTHER-SEAT");
|
||||
});
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")("a detach returns a tile someone typed in to viewing its seat", async () => {
|
||||
await fixture(async ({ socket, seats, viewer }) => {
|
||||
await seats(["new-session", "-d", "-s", "seat", "-e", "OPENRIG_NODE_ID=original", "printf 'ORIGINAL\\n'; exec cat"]);
|
||||
const command = tileAttachArgv("tmux", ["-S", socket], "seat", false, 2).map(shellQuote).join(" ");
|
||||
await viewer(["new-session", "-d", "-s", "view", terminal(command)]);
|
||||
await until(async () => (await viewer(["capture-pane", "-p", "-t", "=view:"])).includes("ORIGINAL"));
|
||||
await viewer(["send-keys", "-t", "=view:", "-l", "typed"]);
|
||||
await until(async () => (await seats(["list-clients", "-t", "=seat", "-F", "#{session_name}"])) === "seat");
|
||||
await until(async () => (await seats(["capture-pane", "-p", "-t", "=seat:"])).includes("typed"));
|
||||
await seats(["detach-client", "-s", "=seat"]);
|
||||
await until(async () => (await seats(["list-clients", "-F", "#{session_name}"])) === "");
|
||||
await until(async () => (await viewer(["capture-pane", "-p", "-t", "=view:"])).includes("typed"));
|
||||
expect(await viewer(["display-message", "-p", "-t", "=view:", "#{pane_dead}"])).toBe("0");
|
||||
expect(await viewer(["capture-pane", "-p", "-t", "=view:"])).not.toContain("No replacement");
|
||||
});
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")("waits through missing-target observations without reading a sibling's identity", async () => {
|
||||
await fixture(async ({ socket, seats, viewer, adapter }) => {
|
||||
await seats(["set-environment", "-t", "=anchor", "OPENRIG_NODE_ID", "sibling"]);
|
||||
const create = () => seats(["new-session", "-d", "-s", "seat", "-e", "OPENRIG_NODE_ID=original", "printf 'REPLACEMENT\\n'; sleep 60"]);
|
||||
await create();
|
||||
const create = (text: string) => seats(["new-session", "-d", "-s", "seat", "-e", "OPENRIG_NODE_ID=original", `printf '${text}\\n'; sleep 60`]);
|
||||
await create("ORIGINAL");
|
||||
const wrapper = `${socket}-probe`, missing = `${socket}-missing`;
|
||||
// Observe actual tmux absence; don't create the replacement until two retry probes saw it.
|
||||
writeFileSync(wrapper, `#!/bin/sh
|
||||
@@ -117,11 +180,11 @@ exec tmux "$@"
|
||||
`, { mode: 0o700 });
|
||||
const command = tileAttachArgv(wrapper, ["-S", socket], "seat", false).map(shellQuote).join(" ");
|
||||
await viewer(["new-session", "-d", "-s", "view", terminal(command)]);
|
||||
await until(async () => (await seats(["list-clients", "-t", "=seat", "-F", "#{session_name}"])) === "seat");
|
||||
await until(async () => (await viewer(["capture-pane", "-p", "-t", "=view:"])).includes("ORIGINAL"));
|
||||
expect((await adapter.killSession("seat")).ok).toBe(true);
|
||||
await until(async () => existsSync(missing) && readFileSync(missing, "utf8").trim().split("\n").length >= 2);
|
||||
await create();
|
||||
await until(async () => (await seats(["list-clients", "-F", "#{session_name}"])) === "seat");
|
||||
await create("REPLACEMENT");
|
||||
await until(async () => (await viewer(["capture-pane", "-p", "-t", "=view:"])).includes("REPLACEMENT"));
|
||||
expect(await viewer(["display-message", "-p", "-t", "=view:", "#{pane_dead}"])).toBe("0");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -100,7 +100,9 @@ describe("SSH tiles follow the viewing instance's effective server mode", () =>
|
||||
// Main's inherited SSH tiles use remote tmux with identity-pinned replacement handling, not remote rig.
|
||||
expect(command).toMatch(/^ssh -t 'fixture' /);
|
||||
expect(command).toContain("OPENRIG_NODE_ID");
|
||||
expect(command).toContain("attach -r -t");
|
||||
// A read-only tile only views its seat: it never attaches.
|
||||
expect(command).toContain('open() { t=$1; shift; stty -echo -isig -ixon 2>/dev/null; view "$t" "" "$@"; };');
|
||||
expect(command).not.toMatch(/attach -r|while use/);
|
||||
expect(command).toContain("No replacement for $target; reopen the view with rig terminal open.");
|
||||
expect(command).not.toContain("rig terminal attach");
|
||||
expect(herdr.lastView!.opened[0]!.recoveryCommand).toBe("ssh -t 'fixture' 'tmux attach -r -t '\"'\"'worker@edge'\"'\"''");
|
||||
@@ -238,7 +240,8 @@ describe("TerminalService — view resolution + one-shape result", () => {
|
||||
const res = await svc.openView({ view });
|
||||
expect(res.ok).toBe(true);
|
||||
expect(herdr.lastView?.opened.every((p) => p.readOnly === true)).toBe(true);
|
||||
expect(herdr.lastView?.opened[0]?.paneCommand).toContain("attach -r -t");
|
||||
expect(herdr.lastView?.opened[0]?.paneCommand).toContain('open() { t=$1; shift; stty -echo -isig -ixon 2>/dev/null; view "$t" "" "$@"; };');
|
||||
expect(herdr.lastView?.opened[0]?.paneCommand).not.toMatch(/attach -r|while use/);
|
||||
expect(res.notes?.join("\n") ?? "").not.toMatch(/lead pane|Can you see the team/);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,16 +1,43 @@
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { execFile, spawnSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { expect, it } from "vitest";
|
||||
import { promisify } from "node:util";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { tileAttachArgv } from "../src/domain/terminal/tile-attach.js";
|
||||
|
||||
const available = spawnSync("tmux", ["-V"]).status === 0 && spawnSync("python3", ["--version"]).status === 0;
|
||||
it.skipIf(!available)("a writable tile sizes and resizes with an older competing client, while a read-only observer does not", () => {
|
||||
const result = JSON.parse(execFileSync("python3", [
|
||||
fileURLToPath(new URL("./helpers/tmux-tile-size.py", import.meta.url)),
|
||||
JSON.stringify(tileAttachArgv("tmux", ["-S", "TILE_SOCKET"], "seat", false, 1)),
|
||||
JSON.stringify(tileAttachArgv("tmux", ["-S", "TILE_SOCKET"], "seat", true, 1)),
|
||||
], { encoding: "utf8", timeout: 15000 }));
|
||||
expect(result).toMatchObject({ initial: "101x32", resized: "121x43", clientsReaped: 3 });
|
||||
expect(result.writableFlags).not.toContain("ignore-size");
|
||||
expect(result.observerFlags).toContain("read-only");
|
||||
}, 20000);
|
||||
// Linux's /bin/sh is often dash, whose background commands read /dev/null unless given another input.
|
||||
const shells = ["/bin/sh", ...(existsSync("/bin/dash") ? ["/bin/dash"] : [])];
|
||||
// The helper runs for many seconds; running it without blocking keeps the test worker able to report to vitest.
|
||||
const helper = async (shell: string, ...more: string[]) => JSON.parse((await promisify(execFile)("python3", [
|
||||
fileURLToPath(new URL("./helpers/tmux-tile-size.py", import.meta.url)),
|
||||
shell,
|
||||
JSON.stringify(tileAttachArgv("tmux", ["-S", "TILE_SOCKET"], "seat", false, 1, 2)),
|
||||
JSON.stringify(tileAttachArgv("tmux", ["-S", "TILE_SOCKET"], "seat", true, 1, 2)),
|
||||
...more,
|
||||
], { encoding: "utf8", timeout: 150000 })).stdout);
|
||||
// tmux 3.4 and later turn paste framing on only when a client starts, so a framing-off write between the first key and
|
||||
// the attach leaves a paste in that gap unframed: each of its lines would reach the agent as Enter.
|
||||
it("a writable tile turns paste framing off only on the return that doesn't attach", () => {
|
||||
const script = tileAttachArgv("tmux", ["-S", "TILE_SOCKET"], "seat", true, 1, 2).find((arg) => arg.includes("use()")) ?? "";
|
||||
const use = script.slice(script.indexOf("use()"), script.indexOf("take()"));
|
||||
expect(use.length).toBeGreaterThan(0);
|
||||
expect(use.split("\\033[?2004l").length - 1).toBe(1);
|
||||
expect(use).toContain(`then printf "\\033[?2004l"; stty sane 2>/dev/null;`);
|
||||
});
|
||||
|
||||
describe.skipIf(!available)("a tile views its seat without sizing it, and attaches only while the person types", () => {
|
||||
it.each(shells)("under %s: whole lines, the seat's own size, keys reaching the seat, and back to the view when idle", async (shell) => {
|
||||
expect(await helper(shell)).toEqual({
|
||||
readOnlyAlone: { window: "160x20" },
|
||||
readOnlyBesideAnother: { window: "160x20" },
|
||||
writable: { used: "40x30" },
|
||||
firstInput: { bytes: 183 },
|
||||
firstInputWindow: { bytes: 3 },
|
||||
});
|
||||
}, 180000);
|
||||
|
||||
it("a paste over 256 KiB arrives whole and framed, and the tile still detaches when idle after it", async () => {
|
||||
expect(await helper("/bin/sh", "big-paste")).toEqual({ bigPaste: { bytes: 6 + 16000 * 28 + 6 } });
|
||||
}, 180000);
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { shellQuote } from "../src/adapters/shell-quote.js";
|
||||
// exits at once must not be reported as opened. The shell parsing below is real; no terminal
|
||||
// provider, tmux server or ssh server is claimed, and the herdr replies are fakes.
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";
|
||||
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
@@ -17,28 +17,42 @@ import type { ComposedPane, ComposedView, OpenViewResult, TerminalProvider } fro
|
||||
const local = (seat: string, readOnly = false): ViewMemberInput => ({ seat, label: seat, tmuxSession: seat, host: null, readOnly, alive: true });
|
||||
|
||||
describe("#707 local tiles run the tmux the daemon resolves", () => {
|
||||
it("a provider PATH without tmux fails the bare command; the resolved path attaches, keeping -r", () => {
|
||||
it("a provider PATH without tmux fails the bare command; the resolved path views the seat, and neither mode attaches untyped", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "openrig-707-"));
|
||||
const narrow = join(root, "provider-path"); // the provider's PATH: no tmux here
|
||||
const narrow = join(root, "provider-path"); // the provider's PATH: system tools, no tmux
|
||||
const elsewhere = join(root, "elsewhere"); // where the daemon found tmux
|
||||
mkdirSync(narrow);
|
||||
mkdirSync(elsewhere);
|
||||
for (const tool of ["cat", "date", "dd", "mktemp", "rm", "sed", "sleep", "stty", "tty"]) {
|
||||
symlinkSync(execFileSync("/bin/sh", ["-c", `command -v ${tool}`], { encoding: "utf8" }).trim(), join(narrow, tool));
|
||||
}
|
||||
const tmux = join(elsewhere, "tmux");
|
||||
writeFileSync(tmux, '#!/bin/sh\ncase $1 in display-message) target=$4; target=${target#=}; printf "7 session-id:%s\\n" "${target%:}";; show-environment) exit 1;; list-clients) printf "%s\\n" $OPR_CLIENTS;; attach) printf "%s\\n" "$@" > "$OPR_ARGS";; esac\n', { mode: 0o700 });
|
||||
const argsFile = join(root, "args");
|
||||
const env = { PATH: narrow, OPR_ARGS: argsFile, OPR_CLIENTS: "" };
|
||||
// One read of the activity time and the screen (one tmux call), then the seat reads as gone; the call records how
|
||||
// the screen was read.
|
||||
writeFileSync(tmux, `#!/bin/sh
|
||||
case $1 in
|
||||
display-message) case $5 in "#{pid} #{window_activity}") [ -e "$OPR_SEEN" ] && exit 0; : > "$OPR_SEEN"; printf "7 %s\\n" "$(date +%s)"; shift 6; printf "%s\\n" "$@" > "$OPR_ARGS"; printf "SCREEN\\n";; *) target=$4; target=\${target#=}; printf "7 session-id:%s\\n" "\${target%:}";; esac;;
|
||||
show-environment) exit 1;;
|
||||
capture-pane) printf "%s\\n" "$@" > "$OPR_ARGS"; printf "SCREEN\\n";;
|
||||
*) printf "%s\\n" "$@" >> "$OPR_OTHER";;
|
||||
esac
|
||||
`, { mode: 0o700 });
|
||||
const argsFile = join(root, "args"), seen = join(root, "seen"), other = join(root, "other");
|
||||
const env = { PATH: narrow, OPR_ARGS: argsFile, OPR_SEEN: seen, OPR_OTHER: other };
|
||||
try {
|
||||
for (const readOnly of [false, true]) {
|
||||
const bare = composeView("v", [local("seat'a", readOnly)], { resolveHost: () => null });
|
||||
// The reported failure: `sh: tmux: command not found`, exit 127.
|
||||
expect(spawnSync("/bin/sh", ["-c", bare.opened[0]!.paneCommand], { env }).status).toBe(127);
|
||||
// A tile that never ends fails here rather than hanging the run.
|
||||
expect(spawnSync("/bin/sh", ["-c", bare.opened[0]!.paneCommand], { env, timeout: 10000 }).status).toBe(127);
|
||||
|
||||
const resolved = composeView("v", [local("seat'a", readOnly)], { resolveHost: () => null, localTmux: tmux });
|
||||
// A writable tile sizes its session even when an older sizing client is still attached.
|
||||
for (const clients of ["", "attached,ignore-size,read-only attached,focused", "attached,ignore-size"]) {
|
||||
execFileSync("/bin/sh", ["-c", resolved.opened[0]!.paneCommand], { env: { ...env, OPR_CLIENTS: clients } });
|
||||
expect(readFileSync(argsFile, "utf8").trimEnd().split("\n")).toEqual(["attach", ...(readOnly ? ["-r"] : []), "-t", "session-id"]);
|
||||
}
|
||||
rmSync(seen, { force: true });
|
||||
const out = execFileSync("/bin/sh", ["-c", resolved.opened[0]!.paneCommand], { env, encoding: "utf8", timeout: 10000 });
|
||||
expect(out).toContain("SCREEN");
|
||||
expect(readFileSync(argsFile, "utf8").trimEnd().split("\n")).toEqual(["capture-pane", "-p", "-e", "-t", "session-id"]);
|
||||
// Nobody typed, so nothing attached or typed into the seat.
|
||||
expect(existsSync(other)).toBe(false);
|
||||
expect(resolved.opened[0]!.readOnly).toBe(readOnly);
|
||||
}
|
||||
} finally {
|
||||
@@ -46,6 +60,34 @@ describe("#707 local tiles run the tmux the daemon resolves", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("a managed tile with no sleep on the provider's PATH ends rather than rereading its seat without pause", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "openrig-nosleep-"));
|
||||
const narrow = join(root, "provider-path"); // system tools, but neither sleep nor tmux
|
||||
mkdirSync(narrow);
|
||||
for (const tool of ["cat", "date", "dd", "mktemp", "od", "rm", "sed", "stty", "tail", "tty"]) {
|
||||
symlinkSync(execFileSync("/bin/sh", ["-c", `command -v ${tool}`], { encoding: "utf8" }).trim(), join(narrow, tool));
|
||||
}
|
||||
const tmux = join(root, "tmux");
|
||||
// A managed seat that stays readable and keeps its identity.
|
||||
writeFileSync(tmux, `#!/bin/sh
|
||||
case $1 in
|
||||
display-message) case $5 in "#{pid} #{window_activity}") printf "7 1\\nOPENRIG_NODE_ID=n1\\nSCREEN\\n";; *) target=$4; target=\${target#=}; printf "7 session-id:%s\\n" "\${target%:}";; esac;;
|
||||
show-environment) printf "OPENRIG_NODE_ID=n1\\n";;
|
||||
esac
|
||||
`, { mode: 0o700 });
|
||||
try {
|
||||
for (const readOnly of [false, true]) {
|
||||
const view = composeView("v", [local("seat", readOnly)], { resolveHost: () => null, localTmux: tmux });
|
||||
const result = spawnSync("/bin/sh", ["-c", view.opened[0]!.paneCommand], { env: { PATH: narrow }, encoding: "utf8", timeout: 10000 });
|
||||
expect(result.signal).toBeNull();
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stdout).toContain("SCREEN");
|
||||
}
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("an ssh tile's remote command is unchanged when a local path is resolved", () => {
|
||||
const remote: ViewMemberInput = { seat: "r", label: "r", tmuxSession: "r", host: "edge", readOnly: true, alive: true };
|
||||
const resolveHost = () => ({ id: "edge", transport: "ssh" as const, target: "fixture" });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { afterEach, describe, it, expect, vi } from "vitest";
|
||||
import { TmuxAdapter, type ArgvExecFn } from "../src/adapters/tmux.js";
|
||||
|
||||
function memFs() {
|
||||
@@ -25,7 +25,11 @@ function argvAdapter(seen: string[][], impl?: (argv: string[]) => string) {
|
||||
}
|
||||
|
||||
describe("tmux argv exec path", () => {
|
||||
afterEach(() => { vi.unstubAllEnvs(); });
|
||||
|
||||
it("createSession preserves cwd/env in a private config", async () => {
|
||||
// Without a user runtime directory no systemd user scope is tried (#2710), so the create starts a missing server itself.
|
||||
vi.stubEnv("XDG_RUNTIME_DIR", undefined);
|
||||
const seen: string[][] = [];
|
||||
const files = memFs();
|
||||
const exec = vi.fn(async () => { throw new Error("argv only"); });
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
// #2710 edges around the systemd user scope of the seats' tmux server, beyond the main contract
|
||||
// (tmux-server-systemd-scope.test.ts): who gets a scope, a systemd that refuses OOMPolicy= for scopes, and a scope
|
||||
// whose server never answers. Every command reaches the injected exec; nothing touches the host's systemd or tmux.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { TmuxAdapter } from "../src/adapters/tmux.js";
|
||||
|
||||
const NO_SERVER = Object.assign(new Error("Command failed: tmux has-session\nno server running on /tmp/tmux-1000/default"), {
|
||||
execStderr: "no server running on /tmp/tmux-1000/default\n",
|
||||
});
|
||||
|
||||
/** A host where `tmux -D` (loose, or inside the scope's sh -c) brings the server up unless `scopedServerStarts` is false. */
|
||||
function host(options: { systemdRun?: (cmd: string) => string; scopedServerStarts?: boolean; serverArgs?: string[] } = {}) {
|
||||
let up = false;
|
||||
const calls: string[] = [];
|
||||
const exec = vi.fn(async (cmd: string) => {
|
||||
calls.push(cmd);
|
||||
if (cmd.startsWith("systemd-run ")) {
|
||||
const out = options.systemdRun ? options.systemdRun(cmd) : "";
|
||||
if (options.scopedServerStarts !== false) up = true;
|
||||
return out;
|
||||
}
|
||||
// `tmux [-S <socket>] -D` / `has-session`: the socket of serverArgs comes first when there is one
|
||||
if (/^tmux (-S \S+ )?-D/.test(cmd)) { up = true; return ""; }
|
||||
if (/^tmux (-S \S+ )?has-session/.test(cmd)) throw up ? new Error("can't find session: x") : NO_SERVER;
|
||||
return "";
|
||||
});
|
||||
return {
|
||||
adapter: new TmuxAdapter(exec, undefined, undefined, options.serverArgs ?? []),
|
||||
calls,
|
||||
systemdRuns: () => calls.filter(c => c.startsWith("systemd-run ")),
|
||||
/** The server goes away (killed, or never started): the next startServer starts it again. */
|
||||
down: () => { up = false; },
|
||||
};
|
||||
}
|
||||
|
||||
const UNRECOGNIZED = (cmd: string) => Object.assign(
|
||||
new Error(`Command failed: ${cmd}\nsystemd-run: unrecognized option '--expand-environment=no'`),
|
||||
{ execStderr: "systemd-run: unrecognized option '--expand-environment=no'\n" });
|
||||
const NO_OOM = (cmd: string) => Object.assign(
|
||||
new Error(`Command failed: ${cmd}\nFailed to start transient scope unit: Cannot set property OOMPolicy, or unknown property.`),
|
||||
{ execStderr: "Failed to start transient scope unit: Cannot set property OOMPolicy, or unknown property.\n" });
|
||||
|
||||
describe("seat tmux server systemd user scope: edges (#2710)", () => {
|
||||
const platform = Object.getOwnPropertyDescriptor(process, "platform")!;
|
||||
let warnings: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(process, "platform", { ...platform, value: "linux" });
|
||||
vi.stubEnv("XDG_RUNTIME_DIR", "/run/user/1000");
|
||||
vi.stubEnv("MANAGERPID", "1234");
|
||||
warnings = [];
|
||||
vi.spyOn(console, "warn").mockImplementation((...args: unknown[]) => { warnings.push(args.map(String).join(" ")); });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, "platform", platform);
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("a daemon that is not a systemd user service (no MANAGERPID) starts the server as before, says why, and never calls systemd-run", async () => {
|
||||
vi.stubEnv("MANAGERPID", undefined);
|
||||
const h = host();
|
||||
expect(await h.adapter.startServer()).toEqual({ ok: true });
|
||||
expect(h.systemdRuns()).toEqual([]);
|
||||
expect(h.calls.filter(c => /^tmux (-S \S+ )?-D/.test(c))).toHaveLength(1);
|
||||
expect(warnings.filter(w => w.includes("MANAGERPID"))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("a systemd that refuses OOMPolicy= for a scope (before 253) still gets the scope, without the property", async () => {
|
||||
const h = host({
|
||||
systemdRun: cmd => {
|
||||
if (cmd.includes("OOMPolicy=continue")) {
|
||||
throw Object.assign(new Error(`Command failed: ${cmd}\nFailed to start transient scope unit: Cannot set property OOMPolicy, or unknown property.`), {
|
||||
execStderr: "Failed to start transient scope unit: Cannot set property OOMPolicy, or unknown property.\n",
|
||||
});
|
||||
}
|
||||
return "";
|
||||
},
|
||||
});
|
||||
expect(await h.adapter.startServer()).toEqual({ ok: true });
|
||||
expect(h.systemdRuns()).toHaveLength(2);
|
||||
expect(h.systemdRuns()[1]).not.toContain("OOMPolicy");
|
||||
expect(h.calls.filter(c => /^tmux (-S \S+ )?-D/.test(c))).toEqual([]);
|
||||
expect(warnings.filter(w => w.includes("without OOMPolicy=continue"))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("any other systemd-run failure is not retried", async () => {
|
||||
const h = host({
|
||||
systemdRun: cmd => {
|
||||
throw Object.assign(new Error(`Command failed: ${cmd}\nFailed to connect to bus: Connection refused`), {
|
||||
execStderr: "Failed to connect to bus: Connection refused\n",
|
||||
});
|
||||
},
|
||||
});
|
||||
expect(await h.adapter.startServer()).toEqual({ ok: true });
|
||||
expect(h.systemdRuns()).toHaveLength(1);
|
||||
expect(h.calls.filter(c => /^tmux (-S \S+ )?-D/.test(c))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("a socket path with a literal $ reaches the server unexpanded on a systemd that expands ${VAR} (258)", async () => {
|
||||
// systemd-run 258 expands ${VAR} in the command's arguments unless --expand-environment=no; 255 does not for --scope.
|
||||
const socket = "/tmp/rig-${USER}/s";
|
||||
const seen: string[] = [];
|
||||
const h = host({
|
||||
serverArgs: ["-S", socket],
|
||||
systemdRun: cmd => {
|
||||
seen.push(cmd.includes("--expand-environment=no") ? cmd : cmd.replace(/\$\{USER\}/g, "bruno"));
|
||||
return "";
|
||||
},
|
||||
});
|
||||
expect(await h.adapter.startServer()).toEqual({ ok: true });
|
||||
expect(h.systemdRuns()).toHaveLength(1);
|
||||
expect(h.systemdRuns()[0]).toContain("--expand-environment=no");
|
||||
expect(seen[0]).toContain("${USER}");
|
||||
expect(seen[0]).not.toContain("rig-bruno");
|
||||
expect(h.calls.filter(c => /^tmux (-S \S+ )?-D/.test(c))).toEqual([]);
|
||||
});
|
||||
|
||||
it("a systemd before 254 (no --expand-environment, which never expands) gets the scope on a retry without it", async () => {
|
||||
const h = host({ systemdRun: cmd => { if (cmd.includes("--expand-environment")) throw UNRECOGNIZED(cmd); return ""; } });
|
||||
expect(await h.adapter.startServer()).toEqual({ ok: true });
|
||||
expect(h.systemdRuns()).toHaveLength(2);
|
||||
expect(h.systemdRuns()[1]).not.toContain("--expand-environment");
|
||||
expect(h.systemdRuns()[1]).toContain("OOMPolicy=continue");
|
||||
expect(h.calls.filter(c => /^tmux (-S \S+ )?-D/.test(c))).toEqual([]);
|
||||
expect(warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it("a systemd-run timeout (empty stderr, the command echoed in the message) falls back without dropping an option", async () => {
|
||||
// The production exec (tmux-exec.ts) kills the client at 30 s: no stderr, and the message echoes the whole
|
||||
// command, --expand-environment=no and OOMPolicy=continue included. Neither may be read as an unsupported option.
|
||||
const h = host({
|
||||
systemdRun: cmd => {
|
||||
throw Object.assign(new Error(`tmux did not answer within 30 s and its client was ended; the write may still have happened. Check with rig capture before resending.\nCommand failed: /bin/sh -c ${cmd}\n`), {
|
||||
killed: true, signal: "SIGKILL", code: null, stdout: "", stderr: "", execStderr: "",
|
||||
});
|
||||
},
|
||||
});
|
||||
expect(await h.adapter.startServer()).toEqual({ ok: true });
|
||||
expect(h.systemdRuns()).toHaveLength(1);
|
||||
expect(h.systemdRuns()[0]).toContain("--expand-environment=no");
|
||||
expect(h.calls.filter(c => /^tmux (-S \S+ )?-D/.test(c))).toHaveLength(1);
|
||||
expect(warnings.filter(w => w.includes("not in a systemd user scope"))).toHaveLength(1);
|
||||
expect(warnings.find(w => w.includes("not in a systemd user scope"))).toContain("30 s");
|
||||
});
|
||||
|
||||
it("an unknown systemd-run failure whose message names the options falls back with its reason, without a retry", async () => {
|
||||
const h = host({
|
||||
systemdRun: cmd => {
|
||||
throw Object.assign(new Error(`Command failed: ${cmd}\nFailed to connect to bus: No medium found`), {
|
||||
code: 1, execStderr: "Failed to connect to bus: No medium found\n",
|
||||
});
|
||||
},
|
||||
});
|
||||
expect(await h.adapter.startServer()).toEqual({ ok: true });
|
||||
expect(h.systemdRuns()).toHaveLength(1);
|
||||
expect(warnings.filter(w => w.includes("Failed to connect to bus"))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("a systemd that knows neither (before 253) still gets the scope, dropping one option per retry", async () => {
|
||||
const h = host({
|
||||
systemdRun: cmd => {
|
||||
if (cmd.includes("--expand-environment")) throw UNRECOGNIZED(cmd);
|
||||
if (cmd.includes("OOMPolicy=continue")) throw NO_OOM(cmd);
|
||||
return "";
|
||||
},
|
||||
});
|
||||
expect(await h.adapter.startServer()).toEqual({ ok: true });
|
||||
expect(h.systemdRuns()).toHaveLength(3);
|
||||
expect(h.systemdRuns()[2]).not.toMatch(/expand-environment|OOMPolicy/);
|
||||
expect(warnings.filter(w => w.includes("without OOMPolicy=continue"))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("the reason no scope is tried is logged once per adapter, not at every server start", async () => {
|
||||
vi.stubEnv("MANAGERPID", undefined);
|
||||
const h = host();
|
||||
expect(await h.adapter.startServer()).toEqual({ ok: true });
|
||||
h.down();
|
||||
expect(await h.adapter.startServer()).toEqual({ ok: true });
|
||||
expect(h.calls.filter(c => /^tmux (-S \S+ )?-D/.test(c))).toHaveLength(2);
|
||||
expect(warnings.filter(w => w.includes("MANAGERPID"))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("a scope whose server never answers falls back to the start of today with a log line", async () => {
|
||||
const h = host({ scopedServerStarts: false });
|
||||
expect(await h.adapter.startServer()).toEqual({ ok: true });
|
||||
expect(h.systemdRuns()).toHaveLength(1);
|
||||
expect(h.calls.filter(c => /^tmux (-S \S+ )?-D/.test(c))).toHaveLength(1);
|
||||
expect(warnings.filter(w => w.includes("did not answer in time"))).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,497 @@
|
||||
// #2710: on Linux with a systemd user manager, the seats' tmux server runs in a transient
|
||||
// `systemd-run --user --scope` of its own, so seat processes leave the daemon's cgroup (a daemon unit
|
||||
// stop or restart no longer takes every seat with it). Without a user manager, or when systemd-run
|
||||
// fails, the server starts exactly as before, with a log line that gives the reason.
|
||||
//
|
||||
// The daemon is composed by createDaemon with an injected string exec, so every command (tmux,
|
||||
// systemd-run, systemctl) reaches the fake below and nothing touches the host's systemd or tmux. A
|
||||
// systemd-run/systemctl on PATH that only records a marker catches a command that bypasses that exec.
|
||||
// The systemd and tmux answers are read from a capture on a real host (fixtures/systemd-user-scope);
|
||||
// the one answer that is not captured is noted where it is used.
|
||||
//
|
||||
// Contract this test relies on: the daemon detects a usable user manager only through the injected exec
|
||||
// and the environment (XDG_RUNTIME_DIR, DBUS_SESSION_BUS_ADDRESS, INVOCATION_ID, MANAGERPID, whichever it
|
||||
// reads). A probe of the host's filesystem (/run/systemd/system, /proc/1/comm) would make the result depend
|
||||
// on the machine running the test (the macOS CI job, a container), so it is out of contract here.
|
||||
//
|
||||
// The fake parses each command as a POSIX shell would split it (`;`, `&&`, `||`, `|`, `&`, newlines,
|
||||
// quotes): only a tmux that is the program systemd-run runs, directly or inside its `/bin/sh -c '…'`,
|
||||
// counts as started in the scope, and a `tmux -D` that would hold child_process.exec open (foreground,
|
||||
// or in the background with stdout/stderr still on exec's pipes) is recorded as blocking.
|
||||
import fs from "node:fs";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import Database from "better-sqlite3";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ExecFn, TmuxAdapter as TmuxAdapterClass } from "../src/adapters/tmux.js";
|
||||
import type { CmuxTransportFactory } from "../src/adapters/cmux.js";
|
||||
|
||||
type CreateDaemon = typeof import("../src/startup.js").createDaemon;
|
||||
type Daemon = Awaited<ReturnType<CreateDaemon>>;
|
||||
|
||||
const cmuxFactory: CmuxTransportFactory = async () => { throw Object.assign(new Error(""), { code: "ENOENT" }); };
|
||||
|
||||
// ---- the real host capture -------------------------------------------------------------------------------
|
||||
const capture = fs.readFileSync(new URL("./fixtures/systemd-user-scope/linux-systemd-255-tmux-3.8-2026-11-08.txt", import.meta.url), "utf8");
|
||||
function section(header: string): string[] {
|
||||
const lines = capture.split("\n");
|
||||
const start = lines.findIndex(line => line.startsWith(`## ${header}`));
|
||||
if (start < 0) throw new Error(`capture section missing: ${header}`);
|
||||
const body: string[] = [];
|
||||
for (const line of lines.slice(start + 1)) { if (line.startsWith("## ")) break; body.push(line); }
|
||||
return body.filter(line => line.length > 0);
|
||||
}
|
||||
interface Answer { stdout: string; stderr: string; exit: number }
|
||||
function answer(header: string, stream: "stdout" | "stderr"): Answer {
|
||||
const body = section(header);
|
||||
const exit = Number(/^exit=(\d+)$/.exec(body.at(-1) ?? "")?.[1]);
|
||||
if (!Number.isInteger(exit)) throw new Error(`capture section without exit=: ${header}`);
|
||||
const text = body.slice(0, -1).join("\n") + "\n";
|
||||
return { stdout: stream === "stdout" ? text : "", stderr: stream === "stderr" ? text : "", exit };
|
||||
}
|
||||
const REAL = {
|
||||
// The user manager is up and usable, yet reports `degraded` with exit 1 (one failed unit is enough).
|
||||
isSystemRunningUp: answer("systemctl --user is-system-running, user manager up", "stdout"),
|
||||
noManager: answer("systemd-run --user --scope, XDG_RUNTIME_DIR and DBUS_SESSION_BUS_ADDRESS unset", "stderr"),
|
||||
// A bus address that is set but refuses the connection, as a seat's shell has it.
|
||||
busRefused: answer("systemctl --user, with the bus address a seat's shell gets", "stderr"),
|
||||
systemdRunMissing: answer("systemd-run missing from PATH, run by /bin/sh -c", "stderr"),
|
||||
// What the daemon's tmux server runs as today (TmuxAdapter.startServer).
|
||||
serverCmdline: /cmdline: (.*)$/.exec(section("the tmux server the daemon started today")[0] ?? "")?.[1]?.trim() ?? "",
|
||||
};
|
||||
// Not captured: asking the system manager for a scope without --user. Capturing it would create (or try to
|
||||
// create) a system scope on the host, so this one answer is systemd's documented text, used only to refuse it.
|
||||
const SYSTEM_MANAGER_REFUSED: Answer = { stdout: "", stderr: "Failed to start transient scope unit: Interactive authentication required.\n", exit: 1 };
|
||||
// The command origin/main runs to start the empty server, byte for byte; the fallback keeps it.
|
||||
const TODAY_START = "tmux -D </dev/null >/dev/null 2>&1 &";
|
||||
|
||||
// ---- a small POSIX shell reader ---------------------------------------------------------------------------
|
||||
interface Segment { text: string; end: "" | ";" | "&&" | "||" | "|" | "&" | "\n" }
|
||||
/** Split a command line into simple commands at unquoted `;`, `&&`, `||`, `|`, `&` and newlines. */
|
||||
function splitCommands(line: string): Segment[] {
|
||||
const out: Segment[] = [];
|
||||
let cur = "";
|
||||
let quote: "'" | '"' | null = null;
|
||||
const push = (end: Segment["end"]) => { if (cur.trim()) out.push({ text: cur.trim(), end }); cur = ""; };
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const c = line[i]!;
|
||||
if (quote) {
|
||||
cur += c;
|
||||
if (c === quote) quote = null;
|
||||
else if (quote === '"' && c === "\\" && i + 1 < line.length) cur += line[++i];
|
||||
continue;
|
||||
}
|
||||
if (c === "'" || c === '"') { quote = c; cur += c; continue; }
|
||||
if (c === "\\" && i + 1 < line.length) { cur += c + line[++i]; continue; }
|
||||
const two = line.slice(i, i + 2);
|
||||
if (two === "&&" || two === "||") { push(two); i++; continue; }
|
||||
if (c === "&") {
|
||||
// `2>&1`, `<&0` and bash's `&>` are redirections, not the background operator.
|
||||
if (/[<>]$/.test(cur) || line[i + 1] === ">") { cur += c; continue; }
|
||||
push("&");
|
||||
continue;
|
||||
}
|
||||
if (c === ";" || c === "|" || c === "\n") { push(c); continue; }
|
||||
cur += c;
|
||||
}
|
||||
push("");
|
||||
return out;
|
||||
}
|
||||
/** The words of one simple command, quotes removed. */
|
||||
function words(text: string): string[] {
|
||||
const out: string[] = [];
|
||||
let cur = "";
|
||||
let has = false;
|
||||
let quote: "'" | '"' | null = null;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const c = text[i]!;
|
||||
if (quote) {
|
||||
if (c === quote) quote = null;
|
||||
else if (quote === '"' && c === "\\" && /["\\$`]/.test(text[i + 1] ?? "")) cur += text[++i];
|
||||
else cur += c;
|
||||
continue;
|
||||
}
|
||||
if (c === "'" || c === '"') { quote = c; has = true; continue; }
|
||||
if (c === "\\" && i + 1 < text.length) { cur += text[++i]; has = true; continue; }
|
||||
if (/\s/.test(c)) { if (has) out.push(cur); cur = ""; has = false; continue; }
|
||||
cur += c;
|
||||
has = true;
|
||||
}
|
||||
if (has) out.push(cur);
|
||||
return out;
|
||||
}
|
||||
const REDIRECT = /^(\d*|&)(>>?|<)(&?)(.*)$/;
|
||||
/** argv without its redirections, and whether stdout and stderr leave exec's pipes. */
|
||||
function parseSimple(text: string): { argv: string[]; stdoutAway: boolean; stderrAway: boolean } {
|
||||
const all = words(text);
|
||||
const argv: string[] = [];
|
||||
let stdoutAway = false;
|
||||
let stderrAway = false;
|
||||
for (let i = 0; i < all.length; i++) {
|
||||
const m = REDIRECT.exec(all[i]!);
|
||||
if (!m) { argv.push(all[i]!); continue; }
|
||||
const [, fd, op] = m;
|
||||
if (!m[4] && !m[3]) i++; // `> file`: the target is the next word
|
||||
if (op === "<") continue;
|
||||
if (fd === "&") { stdoutAway = stderrAway = true; continue; }
|
||||
if (fd === "" || fd === "1") stdoutAway = true;
|
||||
if (fd === "2") stderrAway = true;
|
||||
}
|
||||
return { argv, stdoutAway, stderrAway };
|
||||
}
|
||||
/** The program a simple command runs, past assignments and exec/env/nice/nohup/setsid wrappers. */
|
||||
function program(argv: string[]): { name: string; args: string[] } {
|
||||
let i = 0;
|
||||
for (;;) {
|
||||
while (i < argv.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(argv[i]!)) i++;
|
||||
const word = argv[i] ?? "";
|
||||
const base = path.posix.basename(word);
|
||||
if (base === "exec" || base === "nohup" || base === "setsid") { i++; continue; }
|
||||
if (base === "command" && !/^-[vV]$/.test(argv[i + 1] ?? "")) { i++; continue; }
|
||||
if (base === "env") { i++; while (i < argv.length && argv[i]!.startsWith("-")) { if (argv[i] === "-u") i++; i++; } continue; }
|
||||
if (base === "nice") { i++; if (argv[i] === "-n") i += 2; else if (/^-\d+$/.test(argv[i] ?? "")) i++; continue; }
|
||||
return { name: base, args: argv.slice(i + 1) };
|
||||
}
|
||||
}
|
||||
/** systemd-run options that take a separate value word. */
|
||||
const SYSTEMD_RUN_VALUE_OPTIONS = new Set(["-p", "--property", "-u", "--unit", "--description", "--slice", "-E", "--setenv",
|
||||
"--uid", "--gid", "--nice", "--working-directory", "-M", "--machine", "-H", "--host", "--service-type", "--on-active",
|
||||
"--on-boot", "--on-startup", "--on-unit-active", "--on-unit-inactive", "--on-calendar", "--path-property",
|
||||
"--socket-property", "--timer-property"]);
|
||||
const isShell = (name: string) => ["sh", "bash", "dash", "zsh"].includes(name);
|
||||
/** A tmux client that starts a server when none runs: `tmux -D`, `start-server` or `new-session`. */
|
||||
function startsServer(args: string[]): boolean {
|
||||
return args.includes("-D") || args.includes("start-server") || args.includes("new-session");
|
||||
}
|
||||
|
||||
// ---- the fake host behind the injected exec ---------------------------------------------------------------
|
||||
type Mode = "manager" | "no-manager" | "bus-refused" | "systemd-run-missing";
|
||||
|
||||
function execError(cmd: string, result: Answer): Error {
|
||||
// child_process.exec's shape, as execTmuxCommand rethrows it (message with the output, execStderr apart).
|
||||
const output = [result.stdout.trim(), result.stderr.trim()].filter(Boolean).join("\n");
|
||||
return Object.assign(new Error(`Command failed: ${cmd}\n${output}`), {
|
||||
code: result.exit, stdout: result.stdout, stderr: result.stderr, execStderr: result.stderr,
|
||||
});
|
||||
}
|
||||
|
||||
interface ServerStart { wrapped: boolean; user: boolean; scope: boolean; blocking: boolean; command: string }
|
||||
|
||||
class FakeHost {
|
||||
serverUp = false;
|
||||
readonly calls: string[] = [];
|
||||
readonly starts: ServerStart[] = [];
|
||||
/** Every tmux -D outside systemd-run, whether or not a server already runs. */
|
||||
readonly looseStartAttempts: string[] = [];
|
||||
/** Every tmux argv run as systemd-run's scoped command (directly or inside its sh -c). */
|
||||
readonly scopedTmux: string[][] = [];
|
||||
constructor(public mode: Mode, private readonly socket: string) {}
|
||||
|
||||
readonly exec: ExecFn = async (cmd: string) => {
|
||||
this.calls.push(cmd);
|
||||
let output = "";
|
||||
let failure: Error | undefined;
|
||||
let skip = false;
|
||||
for (const segment of splitCommands(cmd)) {
|
||||
if (!skip) {
|
||||
try { output = this.simple(cmd, segment); failure = undefined; } catch (error) { output = ""; failure = error as Error; }
|
||||
if (segment.end === "&") { output = ""; failure = undefined; }
|
||||
}
|
||||
skip = segment.end === "&&" ? failure !== undefined : segment.end === "||" ? failure === undefined : false;
|
||||
}
|
||||
if (failure) throw failure;
|
||||
return output;
|
||||
};
|
||||
|
||||
private simple(cmd: string, segment: Segment): string {
|
||||
const parsed = parseSimple(segment.text);
|
||||
const { name, args } = program(parsed.argv);
|
||||
const background = segment.end === "&";
|
||||
if (name === "systemd-run") return this.systemdRun(cmd, args, background && parsed.stdoutAway && parsed.stderrAway);
|
||||
if (name === "systemctl") {
|
||||
if (this.mode === "no-manager") throw execError(cmd, REAL.noManager);
|
||||
if (this.mode === "bus-refused") throw execError(cmd, REAL.busRefused);
|
||||
if (args.includes("is-system-running")) throw execError(cmd, REAL.isSystemRunningUp);
|
||||
return "";
|
||||
}
|
||||
if ((name === "command" && /^-[vV]$/.test(args[0] ?? "")) || name === "which" || name === "type") {
|
||||
// A PATH lookup: POSIX `command -v` prints the path, or nothing with exit 1.
|
||||
const target = args.find(arg => !arg.startsWith("-")) ?? "";
|
||||
if (target === "systemd-run" && this.mode === "systemd-run-missing") throw execError(cmd, { stdout: "", stderr: "", exit: 1 });
|
||||
return /^(systemd-run|systemctl|tmux)$/.test(target) ? `/usr/bin/${target}\n` : "";
|
||||
}
|
||||
if (name === "tmux") return this.tmux(cmd, args, { wrapped: false, user: false, scope: false },
|
||||
background && parsed.stdoutAway && parsed.stderrAway);
|
||||
return "";
|
||||
}
|
||||
|
||||
private systemdRun(cmd: string, args: string[], detached: boolean): string {
|
||||
if (this.mode === "no-manager") throw execError(cmd, REAL.noManager);
|
||||
if (this.mode === "bus-refused") throw execError(cmd, REAL.busRefused);
|
||||
if (this.mode === "systemd-run-missing") throw execError(cmd, REAL.systemdRunMissing);
|
||||
const options: string[] = [];
|
||||
let i = 0;
|
||||
for (; i < args.length; i++) {
|
||||
const word = args[i]!;
|
||||
if (word === "--") { i++; break; }
|
||||
if (!word.startsWith("-")) break;
|
||||
options.push(word);
|
||||
if (SYSTEMD_RUN_VALUE_OPTIONS.has(word)) options.push(args[++i] ?? "");
|
||||
}
|
||||
const user = options.includes("--user");
|
||||
const scope = options.includes("--scope");
|
||||
// Without --user, systemd-run asks the system manager, which an unprivileged daemon may not use.
|
||||
if (!user) throw execError(cmd, SYSTEM_MANAGER_REFUSED);
|
||||
const scoped = program(args.slice(i));
|
||||
const where = { wrapped: true, user, scope };
|
||||
if (scoped.name === "tmux") return this.tmux(cmd, scoped.args, where, detached, true);
|
||||
if (isShell(scoped.name)) {
|
||||
const c = scoped.args.indexOf("-c");
|
||||
const script = c >= 0 ? scoped.args[c + 1] ?? "" : "";
|
||||
let output = "";
|
||||
for (const inner of splitCommands(script)) {
|
||||
const parsed = parseSimple(inner.text);
|
||||
const run = program(parsed.argv);
|
||||
if (run.name !== "tmux") continue;
|
||||
const innerDetached = inner.end === "&" && parsed.stdoutAway && parsed.stderrAway;
|
||||
output = this.tmux(cmd, run.args, where, detached || innerDetached, true);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private tmux(cmd: string, args: string[], where: { wrapped: boolean; user: boolean; scope: boolean },
|
||||
detached: boolean, scoped = false): string {
|
||||
if (scoped) this.scopedTmux.push(args);
|
||||
if (args.includes("has-session")) {
|
||||
const target = args[args.indexOf("-t") + 1] ?? "";
|
||||
throw this.serverUp
|
||||
? execError(cmd, { stdout: "", stderr: `can't find session: ${target}\n`, exit: 1 })
|
||||
: execError(cmd, { stdout: "", stderr: `no server running on ${this.socket}\n`, exit: 1 });
|
||||
}
|
||||
if (startsServer(args)) {
|
||||
if (!scoped && args.includes("-D")) this.looseStartAttempts.push(cmd);
|
||||
if (!this.serverUp) {
|
||||
this.serverUp = true;
|
||||
// `tmux -D` stays in the foreground: exec resolves only once it is detached from exec's pipes.
|
||||
this.starts.push({ ...where, blocking: args.includes("-D") && !detached, command: cmd });
|
||||
}
|
||||
}
|
||||
if (!this.serverUp) throw execError(cmd, { stdout: "", stderr: `no server running on ${this.socket}\n`, exit: 1 });
|
||||
if (args.includes("source-file")) return "%1\n";
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// ---- fixture ----------------------------------------------------------------------------------------------
|
||||
describe("seat tmux server in its own systemd user scope (#2710)", { timeout: 120000 }, () => {
|
||||
const platform = Object.getOwnPropertyDescriptor(process, "platform")!;
|
||||
const savedEnv = { HOME: process.env.HOME, OPENRIG_HOME: process.env.OPENRIG_HOME, RIGGED_HOME: process.env.RIGGED_HOME };
|
||||
let createDaemon: CreateDaemon;
|
||||
let TmuxAdapter: typeof TmuxAdapterClass;
|
||||
let home: string;
|
||||
let dir: string;
|
||||
let marker: string;
|
||||
let sockets: net.Server[] = [];
|
||||
let logs: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
new Database(":memory:").close();
|
||||
// The composition reads OPENRIG_HOME once, at import, and the user's home (skills, settings) at run time:
|
||||
// both point at a private directory before the daemon's modules load, so the host's ~/.openrig never shapes the result.
|
||||
home = fs.mkdtempSync(path.join(os.tmpdir(), "or-sd-home-"));
|
||||
process.env.HOME = home;
|
||||
process.env.OPENRIG_HOME = path.join(home, ".openrig");
|
||||
delete process.env.RIGGED_HOME;
|
||||
({ createDaemon } = await import("../src/startup.js"));
|
||||
({ TmuxAdapter } = await import("../src/adapters/tmux.js"));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
for (const [key, value] of Object.entries(savedEnv)) { if (value === undefined) delete process.env[key]; else process.env[key] = value; }
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "or-sd-"));
|
||||
marker = path.join(dir, "host-systemd-called");
|
||||
// Guard: a systemd-run or systemctl that bypasses the injected exec lands here, never on the host.
|
||||
const bin = path.join(dir, "bin");
|
||||
fs.mkdirSync(bin);
|
||||
for (const name of ["systemd-run", "systemctl"]) {
|
||||
fs.writeFileSync(path.join(bin, name),
|
||||
`#!/bin/sh\nprintf '%s\\n' "${name} $*" >> '${marker}'\necho 'Failed to connect to bus: guard' >&2\nexit 1\n`, { mode: 0o755 });
|
||||
}
|
||||
vi.stubEnv("PATH", `${bin}:${process.env.PATH ?? ""}`);
|
||||
vi.stubEnv("HOME", home);
|
||||
vi.stubEnv("OPENRIG_HOME", path.join(home, ".openrig"));
|
||||
vi.stubEnv("OPENRIG_TMUX_SERVER", "inherit");
|
||||
vi.stubEnv("OPENRIG_TMUX_CONTROL", undefined);
|
||||
vi.stubEnv("TMUX_TMPDIR", path.join(dir, "tmux"));
|
||||
fs.mkdirSync(path.join(dir, "tmux"));
|
||||
vi.stubEnv("TMUX", undefined);
|
||||
vi.stubEnv("OPENRIG_NO_KERNEL", "1");
|
||||
Object.defineProperty(process, "platform", { ...platform, value: "linux" });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
Object.defineProperty(process, "platform", platform);
|
||||
vi.unstubAllEnvs();
|
||||
await Promise.all(sockets.map(server => new Promise(resolve => server.close(resolve))));
|
||||
sockets = [];
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** Every user-manager signal present (runtime dir with live private and bus sockets, bus address, a manager
|
||||
* that launched the daemon) or every one absent. Which signal the daemon reads is its own choice. */
|
||||
async function userManagerEnv(present: boolean): Promise<void> {
|
||||
for (const key of ["XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS", "INVOCATION_ID", "MANAGERPID"]) vi.stubEnv(key, undefined);
|
||||
if (!present) return;
|
||||
const runtime = path.join(dir, "run");
|
||||
fs.mkdirSync(path.join(runtime, "systemd"), { recursive: true, mode: 0o700 });
|
||||
for (const socket of [path.join(runtime, "systemd", "private"), path.join(runtime, "bus")]) {
|
||||
const server = net.createServer(connection => connection.destroy());
|
||||
await new Promise<void>((resolve, reject) => { server.once("error", reject); server.listen(socket, resolve); });
|
||||
sockets.push(server);
|
||||
}
|
||||
vi.stubEnv("XDG_RUNTIME_DIR", runtime);
|
||||
vi.stubEnv("DBUS_SESSION_BUS_ADDRESS", `unix:path=${path.join(runtime, "bus")}`);
|
||||
vi.stubEnv("INVOCATION_ID", "0123456789abcdef0123456789abcdef");
|
||||
vi.stubEnv("MANAGERPID", String(process.pid));
|
||||
}
|
||||
|
||||
/** Records every console line written while `act` runs. */
|
||||
async function observe(host: FakeHost, act: () => Promise<void>): Promise<void> {
|
||||
host.serverUp = false;
|
||||
host.calls.length = 0;
|
||||
host.starts.length = 0;
|
||||
host.looseStartAttempts.length = 0;
|
||||
host.scopedTmux.length = 0;
|
||||
logs = [];
|
||||
for (const level of ["warn", "log", "error", "info"] as const) {
|
||||
vi.spyOn(console, level).mockImplementation((...args: unknown[]) => { logs.push(args.map(String).join(" ")); });
|
||||
}
|
||||
try { await act(); } finally { vi.restoreAllMocks(); }
|
||||
}
|
||||
|
||||
async function withDaemon(mode: Mode, managerSignals: boolean,
|
||||
act: (daemon: Daemon, host: FakeHost) => Promise<void>): Promise<void> {
|
||||
await userManagerEnv(managerSignals);
|
||||
const host = new FakeHost(mode, path.join(dir, "tmux", `tmux-${process.getuid?.() ?? 0}`, "default"));
|
||||
const daemon = await createDaemon({ dbPath: ":memory:", cmuxFactory, tmuxExec: host.exec });
|
||||
try {
|
||||
// Only what the action does is observed: composition's own reads are not under test.
|
||||
await observe(host, () => act(daemon, host));
|
||||
} finally {
|
||||
daemon.eventLoopMonitor.stop();
|
||||
daemon.db.close();
|
||||
}
|
||||
}
|
||||
|
||||
const loose = (command: string): ServerStart => ({ wrapped: false, user: false, scope: false, blocking: false, command });
|
||||
|
||||
it("capture: the fixture is the real host's answers", () => {
|
||||
expect(REAL.isSystemRunningUp).toEqual({ stdout: "degraded\n", stderr: "", exit: 1 });
|
||||
expect(REAL.noManager.stderr).toBe("Failed to connect to bus: No medium found\n");
|
||||
expect(REAL.busRefused.stderr).toBe("Failed to connect to bus: Connection refused\n");
|
||||
expect(REAL.systemdRunMissing).toMatchObject({ stderr: "/bin/sh: 1: systemd-run: not found\n", exit: 127 });
|
||||
expect(TODAY_START.startsWith(`${REAL.serverCmdline} `)).toBe(true);
|
||||
});
|
||||
|
||||
// G1
|
||||
it("with a user manager (is-system-running says degraded, exit 1), the server starts inside systemd-run --user --scope", async () => {
|
||||
await withDaemon("manager", true, async (daemon, host) => {
|
||||
const result = await daemon.deps.tmuxAdapter.startServer();
|
||||
expect(result).toMatchObject({ ok: true });
|
||||
expect(host.starts, host.calls.join("\n")).toHaveLength(1);
|
||||
expect(host.starts[0], host.calls.join("\n")).toMatchObject({ wrapped: true, user: true, scope: true, blocking: false });
|
||||
expect(host.looseStartAttempts, "a tmux -D outside the scope").toEqual([]);
|
||||
expect(fs.existsSync(marker), "systemd was called outside the daemon's exec").toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// G1b
|
||||
it("with a user manager, a launch that starts the server itself (start-server ; source-file) starts it in the scope too", async () => {
|
||||
vi.stubEnv("OPENRIG_TERMINAL_INHERIT_TMUX_ENVIRONMENT", "1");
|
||||
await withDaemon("manager", true, async (daemon, host) => {
|
||||
const adapter = daemon.deps.tmuxAdapter;
|
||||
// The lifecycle lease is not under test here: only how the first launch reaches a server.
|
||||
adapter.deliveryGuard = undefined;
|
||||
const result = await adapter.createSession("seat-2710", undefined, { OPENRIG_NODE_ID: "node-2710" });
|
||||
expect(result.ok, JSON.stringify(result)).toBe(true);
|
||||
expect(host.starts, host.calls.join("\n")).toHaveLength(1);
|
||||
expect(host.starts[0], host.calls.join("\n")).toMatchObject({ wrapped: true, user: true, scope: true, blocking: false });
|
||||
expect(fs.existsSync(marker), "systemd was called outside the daemon's exec").toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// G1c
|
||||
it("with a user manager and a daemon on its own tmux socket (-S), the server in the scope is started on that socket", async () => {
|
||||
await userManagerEnv(true);
|
||||
const socket = path.join(dir, "tmux", "openrig-isolated");
|
||||
const host = new FakeHost("manager", socket);
|
||||
// The adapter as startup builds it for terminal.tmux_server=isolated (createDaemon selects no server when
|
||||
// the exec is injected, so the selection is passed the way startup.ts passes tmuxSelection.executionArgs).
|
||||
const adapter = new TmuxAdapter(host.exec, undefined, undefined, ["-S", socket]);
|
||||
await observe(host, async () => {
|
||||
const result = await adapter.startServer();
|
||||
expect(result).toMatchObject({ ok: true });
|
||||
expect(host.starts, host.calls.join("\n")).toHaveLength(1);
|
||||
expect(host.starts[0], host.calls.join("\n")).toMatchObject({ wrapped: true, user: true, scope: true, blocking: false });
|
||||
const started = host.scopedTmux.filter(args => args.includes("-D"));
|
||||
expect(started, host.calls.join("\n")).toHaveLength(1);
|
||||
const args = started[0]!;
|
||||
const s = args.indexOf("-S");
|
||||
expect(s >= 0 && args[s + 1] === socket && s < args.indexOf("-D"), `tmux ${args.join(" ")}`).toBe(true);
|
||||
expect(fs.existsSync(marker), "systemd was called outside the daemon's exec").toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// G2
|
||||
it("without a user manager (Failed to connect to bus: No medium found), the server starts as today, with a log line", async () => {
|
||||
await withDaemon("no-manager", false, async (daemon, host) => {
|
||||
const result = await daemon.deps.tmuxAdapter.startServer();
|
||||
expect(result).toMatchObject({ ok: true });
|
||||
expect(host.starts, host.calls.join("\n")).toEqual([loose(TODAY_START)]);
|
||||
expect(logs.length, "no line logged for the fallback").toBeGreaterThanOrEqual(1);
|
||||
expect(fs.existsSync(marker), "systemd was called outside the daemon's exec").toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// G3
|
||||
it("when the manager is advertised but systemd-run fails (Connection refused), the server starts as today and the log gives the reason", async () => {
|
||||
await withDaemon("bus-refused", true, async (daemon, host) => {
|
||||
const result = await daemon.deps.tmuxAdapter.startServer();
|
||||
expect(result).toMatchObject({ ok: true });
|
||||
expect(host.starts, host.calls.join("\n")).toEqual([loose(TODAY_START)]);
|
||||
expect(logs.filter(line => line.includes("Connection refused")), logs.join("\n")).not.toEqual([]);
|
||||
expect(fs.existsSync(marker), "systemd was called outside the daemon's exec").toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// G3b
|
||||
it("when systemd-run is not installed (exit 127), the server starts as today and the log gives the reason", async () => {
|
||||
await withDaemon("systemd-run-missing", true, async (daemon, host) => {
|
||||
const result = await daemon.deps.tmuxAdapter.startServer();
|
||||
expect(result).toMatchObject({ ok: true });
|
||||
expect(host.starts, host.calls.join("\n")).toEqual([loose(TODAY_START)]);
|
||||
const reason = logs.filter(line => /systemd-run/.test(line) && /not found|127|ENOENT|missing|not installed|unavailable/i.test(line));
|
||||
expect(reason, logs.join("\n")).not.toEqual([]);
|
||||
expect(fs.existsSync(marker), "systemd was called outside the daemon's exec").toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// G4
|
||||
it("on macOS (no systemd-run on the host), the server starts as today", async () => {
|
||||
Object.defineProperty(process, "platform", { ...platform, value: "darwin" });
|
||||
await withDaemon("systemd-run-missing", false, async (daemon, host) => {
|
||||
const result = await daemon.deps.tmuxAdapter.startServer();
|
||||
expect(result).toMatchObject({ ok: true });
|
||||
expect(host.starts, host.calls.join("\n")).toEqual([loose(TODAY_START)]);
|
||||
expect(fs.existsSync(marker), "systemd was called outside the daemon's exec").toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -143,12 +143,8 @@ describe("workflow routes (PL-004 Phase D)", () => {
|
||||
it("POST /instantiate resolves a seeded spec BY NAME (AC-3 reachability), not just a literal path", async () => {
|
||||
// Seed the spec into the cache the way the starter-spec-loader does at seed
|
||||
// time: a readThrough caches `routes-fixture` by name with its sourcePath.
|
||||
const seed = await app.request("/api/workflow/validate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ specPath }),
|
||||
});
|
||||
expect(seed.status).toBe(200);
|
||||
// (POST /validate no longer seeds: it is read-only since #2726.)
|
||||
runtime.specCache.readThrough(specPath);
|
||||
// Instantiate by the discovered NAME (no path).
|
||||
const res = await app.request("/api/workflow/instantiate", {
|
||||
method: "POST",
|
||||
|
||||
@@ -85,7 +85,8 @@ describe("POST /api/workflow/specs/unregister (#2635)", () => {
|
||||
const register = (dir: string, file: string, id: string, version: string) => {
|
||||
const path = join(dir, file);
|
||||
writeFileSync(path, spec(id, version));
|
||||
runtime.validate(path);
|
||||
// Register the way instantiate and the folder scan do; validate is read-only since #2726.
|
||||
runtime.specCache.readThrough(path);
|
||||
return path;
|
||||
};
|
||||
const unregister = async (body: Record<string, unknown>) => {
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
// #2726: `rig workflow validate` / POST /api/workflow/validate must be READ-ONLY.
|
||||
// It validates the file in memory with the same checks it runs today and never
|
||||
// writes workflow_specs: no INSERT of a new id@version, no UPDATE of a
|
||||
// registered name@version (from another path, or from the same path after the
|
||||
// file was edited in place). instantiate (and `rig workflow
|
||||
// run`, which posts to the same /instantiate route) and the workflows-folder
|
||||
// scan keep registering specs as today.
|
||||
//
|
||||
// Golden fixtures under fixtures/workflow-validate-readonly/ are a real capture
|
||||
// of rig 0.7.9 (isolated daemon, 2026-11-08): the spec files the capture used
|
||||
// and the validate output it printed, with the temp root as "<TMP>".
|
||||
//
|
||||
// The test sets state up through the entry points that register today
|
||||
// (specCache.readThrough, scanWorkflowSpecFolder); what it asserts is only the
|
||||
// HTTP routes and the workflow_specs rows.
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type Database from "better-sqlite3";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { OutboxHandler } from "../src/domain/outbox-handler.js";
|
||||
import { QueueRepository } from "../src/domain/queue-repository.js";
|
||||
import { WorkflowRuntime } from "../src/domain/workflow-runtime.js";
|
||||
import { scanWorkflowSpecFolder } from "../src/domain/spec-library-workflow-scanner.js";
|
||||
import { workflowRoutes } from "../src/routes/workflow.js";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
|
||||
|
||||
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), "fixtures", "workflow-validate-readonly");
|
||||
const fixture = (name: string): string => join(FIXTURES, name);
|
||||
const realJson = (name: string): unknown => JSON.parse(readFileSync(fixture(name), "utf-8"));
|
||||
|
||||
const REGISTERED = "wf-validate-ro";
|
||||
const NEW_ID = "wf-validate-ro-new";
|
||||
|
||||
function buildApp(runtime: WorkflowRuntime, bus: EventBus): Hono {
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("eventBus" as never, bus);
|
||||
c.set("workflowRuntime" as never, runtime);
|
||||
await next();
|
||||
});
|
||||
app.route("/api/workflow", workflowRoutes());
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("#2726 workflow validate is read-only", () => {
|
||||
let db: Database.Database;
|
||||
let bus: EventBus;
|
||||
let runtime: WorkflowRuntime;
|
||||
let app: Hono;
|
||||
let tmp: string;
|
||||
// Same layout the real capture used: <TMP>/specs-a/spec.workflow.yaml,
|
||||
// <TMP>/specs-b/spec.workflow.yaml (same id@version, other bytes),
|
||||
// <TMP>/specs-b/new.workflow.yaml (a new id@version).
|
||||
let specA: string;
|
||||
let specB: string;
|
||||
let specNew: string;
|
||||
|
||||
const post = (path: string, body: unknown) =>
|
||||
app.request(`/api/workflow${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const validate = (specPath: string) => post("/validate", { specPath });
|
||||
const instantiate = (specPath: string) =>
|
||||
post("/instantiate", { specPath, rootObjective: "validate read-only", createdBySession: "ops@rig" });
|
||||
/** The whole registry, every column, in a stable order. */
|
||||
const registry = () => db.prepare(`SELECT * FROM workflow_specs ORDER BY name, version, spec_id`).all();
|
||||
const rowsNamed = (name: string) =>
|
||||
db.prepare(`SELECT * FROM workflow_specs WHERE name = ?`).all(name) as Array<Record<string, unknown>>;
|
||||
const specsList = async () => {
|
||||
const res = await app.request("/api/workflow/specs");
|
||||
expect(res.status).toBe(200);
|
||||
return ((await res.json()) as { specs: Array<{ name: string; version: string; sourcePath: string }> }).specs;
|
||||
};
|
||||
const normalized = (body: unknown): unknown => JSON.parse(JSON.stringify(body).split(tmp).join("<TMP>"));
|
||||
|
||||
beforeEach(() => {
|
||||
db = createDb();
|
||||
migrate(db, ALL_MIGRATIONS); // the shipped schema, like the real daemon
|
||||
bus = new EventBus(db);
|
||||
db.prepare(`INSERT INTO rigs (id, name) VALUES ('r-1', 'rig')`).run();
|
||||
const queueRepo = new QueueRepository(db, bus, { validateRig: () => true });
|
||||
queueRepo.attachOutbox(new OutboxHandler(db));
|
||||
runtime = new WorkflowRuntime({ db, eventBus: bus, queueRepo });
|
||||
app = buildApp(runtime, bus);
|
||||
tmp = mkdtempSync(join(tmpdir(), "wf-validate-ro-"));
|
||||
mkdirSync(join(tmp, "specs-a"));
|
||||
mkdirSync(join(tmp, "specs-b"));
|
||||
specA = join(tmp, "specs-a", "spec.workflow.yaml");
|
||||
specB = join(tmp, "specs-b", "spec.workflow.yaml");
|
||||
specNew = join(tmp, "specs-b", "new.workflow.yaml");
|
||||
copyFileSync(fixture("spec-a.workflow.yaml"), specA);
|
||||
copyFileSync(fixture("spec-b.workflow.yaml"), specB);
|
||||
copyFileSync(fixture("spec-b.new.workflow.yaml"), specNew);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("fixtures are the real capture: B has the same id@version as A with other bytes", () => {
|
||||
const a = readFileSync(specA, "utf-8");
|
||||
const b = readFileSync(specB, "utf-8");
|
||||
expect(b).not.toBe(a);
|
||||
expect(a).toContain(`id: ${REGISTERED}`);
|
||||
expect(b).toContain(`id: ${REGISTERED}`);
|
||||
expect(readFileSync(specNew, "utf-8")).toContain(`id: ${NEW_ID}`);
|
||||
});
|
||||
|
||||
// ---- golden: no write ---------------------------------------------------
|
||||
|
||||
it("validating a draft with a registered name@version from another path and other bytes leaves the row untouched", async () => {
|
||||
runtime.specCache.readThrough(specA); // registered the way instantiate/scan register
|
||||
const before = registry();
|
||||
const [rowBefore] = rowsNamed(REGISTERED);
|
||||
expect(rowBefore.source_path).toBe(specA);
|
||||
|
||||
const res = await validate(specB);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
// Same output as today (real capture, rig 0.7.9).
|
||||
expect(normalized(await res.json())).toEqual(realJson("real-validate-b-same-id-other-bytes.json"));
|
||||
// The registered row keeps its content and its source_path.
|
||||
const [rowAfter] = rowsNamed(REGISTERED);
|
||||
expect(rowAfter.source_path).toBe(specA);
|
||||
expect(rowAfter.source_hash).toBe(rowBefore.source_hash);
|
||||
expect(rowAfter.spec_json).toBe(rowBefore.spec_json);
|
||||
expect(registry()).toEqual(before);
|
||||
});
|
||||
|
||||
it("validating the registered file after it was edited in place (same path, other bytes) leaves the row untouched", async () => {
|
||||
runtime.specCache.readThrough(specB); // registered from specs-b, as in the real capture
|
||||
const before = registry();
|
||||
copyFileSync(specA, specB); // same path, same id@version, other bytes
|
||||
|
||||
const res = await validate(specB);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(normalized(await res.json())).toEqual(realJson("real-validate-same-path-edited.json"));
|
||||
// No UPDATE of source_hash / spec_json / cached_at: the row stays as it was.
|
||||
expect(registry()).toEqual(before);
|
||||
});
|
||||
|
||||
it("validating a new id@version inserts nothing (rig workflow specs does not change)", async () => {
|
||||
runtime.specCache.readThrough(specA);
|
||||
const before = registry();
|
||||
const listBefore = await specsList();
|
||||
|
||||
const res = await validate(specNew);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(normalized(await res.json())).toEqual(realJson("real-validate-new.json"));
|
||||
expect(rowsNamed(NEW_ID)).toEqual([]);
|
||||
expect(registry()).toEqual(before);
|
||||
expect(await specsList()).toEqual(listBefore);
|
||||
});
|
||||
|
||||
it("validating into an empty registry writes nothing, and the output is the real one", async () => {
|
||||
const res = await validate(specA);
|
||||
expect(res.status).toBe(200);
|
||||
expect(normalized(await res.json())).toEqual(realJson("real-validate-a.json"));
|
||||
expect(registry()).toEqual([]);
|
||||
});
|
||||
|
||||
it("an invalid but parseable spec is reported and still not written", async () => {
|
||||
const bad = join(tmp, "specs-b", "invalid-validator.workflow.yaml");
|
||||
copyFileSync(fixture("invalid-validator.workflow.yaml"), bad);
|
||||
const res = await validate(bad);
|
||||
expect(res.status).toBe(200);
|
||||
expect(((await res.json()) as { ok: boolean }).ok).toBe(false);
|
||||
expect(registry()).toEqual([]);
|
||||
});
|
||||
|
||||
// ---- golden: same errors as today ---------------------------------------
|
||||
|
||||
it("a missing file is still 404 spec_file_missing", async () => {
|
||||
const res = await validate(join(tmp, "specs-b", "missing.workflow.yaml"));
|
||||
expect(res.status).toBe(404);
|
||||
expect(((await res.json()) as { error: string }).error).toBe("spec_file_missing");
|
||||
});
|
||||
|
||||
it("an unknown top-level key is still rejected by the parser with the same error", async () => {
|
||||
const bad = join(tmp, "specs-b", "invalid-unknown-key.workflow.yaml");
|
||||
copyFileSync(fixture("invalid-unknown-key.workflow.yaml"), bad);
|
||||
const res = await validate(bad);
|
||||
expect({ status: res.status, error: ((await res.json()) as { error: string }).error }).toEqual(
|
||||
{ status: 400, error: "spec_unknown_key" }, // frozen from main 220e27a9a
|
||||
);
|
||||
});
|
||||
|
||||
it("validator errors are still reported with the same issues", async () => {
|
||||
const bad = join(tmp, "specs-b", "invalid-validator.workflow.yaml");
|
||||
copyFileSync(fixture("invalid-validator.workflow.yaml"), bad);
|
||||
const res = await validate(bad);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { ok: boolean; issues: Array<{ code: string; field?: string; severity: string }> };
|
||||
expect(body.ok).toBe(false);
|
||||
expect(body.issues.map((i) => ({ code: i.code, field: i.field, severity: i.severity }))).toEqual(
|
||||
[
|
||||
// frozen from main 220e27a9a
|
||||
{ code: "entry_role_mismatch", field: "workflow.entry.role", severity: "error" },
|
||||
{ code: "step_actor_role_not_declared", field: "workflow.steps[0].actor_role", severity: "error" },
|
||||
{ code: "role_no_live_preferred_target", field: "workflow.roles.producer.preferred_targets", severity: "warning" },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
// ---- golden: regression (instantiate / run / scan still register) --------
|
||||
|
||||
it("instantiate (and run, same route) by path registers a new spec as today", async () => {
|
||||
const res = await instantiate(specNew);
|
||||
expect(res.status).toBe(201);
|
||||
const rows = rowsNamed(NEW_ID);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].source_path).toBe(specNew);
|
||||
expect((await specsList()).map((s) => s.name)).toContain(NEW_ID);
|
||||
});
|
||||
|
||||
it("instantiate from another path with other bytes updates the registered row as today", async () => {
|
||||
runtime.specCache.readThrough(specA);
|
||||
const hashA = rowsNamed(REGISTERED)[0].source_hash;
|
||||
const res = await instantiate(specB);
|
||||
expect(res.status).toBe(201);
|
||||
const rows = rowsNamed(REGISTERED);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].source_path).toBe(specB);
|
||||
expect(rows[0].source_hash).not.toBe(hashA);
|
||||
});
|
||||
|
||||
it("the workflows-folder scan registers specs as today", () => {
|
||||
const folder = join(tmp, "workspace", "workflows");
|
||||
mkdirSync(folder, { recursive: true });
|
||||
copyFileSync(specA, join(folder, "spec.workflow.yaml"));
|
||||
const result = scanWorkflowSpecFolder({ db, cache: runtime.specCache, folder, builtinDir: null });
|
||||
expect(result.valid).toBe(1);
|
||||
expect(rowsNamed(REGISTERED).map((r) => r.source_path)).toEqual([join(folder, "spec.workflow.yaml")]);
|
||||
});
|
||||
|
||||
// ---- maintainer's (b): only-validated spec outside the workflows folder ---
|
||||
|
||||
it("(b) a spec only validated from outside the workflows folder is not listed and not found by bare name; its path still works", async () => {
|
||||
const folder = join(tmp, "workspace", "workflows");
|
||||
mkdirSync(folder, { recursive: true });
|
||||
copyFileSync(specA, join(folder, "spec.workflow.yaml"));
|
||||
scanWorkflowSpecFolder({ db, cache: runtime.specCache, folder, builtinDir: null });
|
||||
|
||||
const v = await validate(specNew);
|
||||
expect(v.status).toBe(200);
|
||||
expect(((await v.json()) as { ok: boolean }).ok).toBe(true);
|
||||
|
||||
const names = (await specsList()).map((s) => s.name);
|
||||
expect(names).toContain(REGISTERED);
|
||||
expect(names).not.toContain(NEW_ID);
|
||||
|
||||
const byName = await instantiate(NEW_ID);
|
||||
expect(byName.status).toBe(404);
|
||||
expect(((await byName.json()) as { error: string }).error).toBe("spec_file_missing");
|
||||
|
||||
const byPath = await instantiate(specNew);
|
||||
expect(byPath.status).toBe(201);
|
||||
expect(rowsNamed(NEW_ID).map((r) => r.source_path)).toEqual([specNew]);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"release": "0.7.9",
|
||||
"archiveSha256": "668b9305ac38ecf919253930a7cba5c0535c2d30a5cbc0b11076e5999d31953c",
|
||||
"release": "0.7.10",
|
||||
"archiveSha256": "c55aff7e7c7d1429e6d12ee89540961005bcb1efd6e7017753071155b1952732",
|
||||
"plugins": {
|
||||
"openrig-core": {
|
||||
"version": "0.1.25",
|
||||
"sha256": "f1838261dbce4ef900bffa1c233276fe2f8eb317e78c0352a1bb66c9cd46f7a0"
|
||||
"version": "0.1.26",
|
||||
"sha256": "23ed778d65d7feeff7e22b29538627973678241d1676a8d41253371d8ff4ec65"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user