mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-11 17:32:22 -10:00
Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdaa8717bd | ||
|
|
b68742c3c2 | ||
|
|
80cfb7d01a | ||
|
|
f1a06c4f1b | ||
|
|
a7b8a27eb8 | ||
|
|
be2690179d | ||
|
|
a71ce16dd7 | ||
|
|
0834a16949 | ||
|
|
f1668d6907 | ||
|
|
89999f6ebf | ||
|
|
f3a3b1711b | ||
|
|
7886582f9d | ||
|
|
cf6d633667 | ||
|
|
8dfacd2604 | ||
|
|
4adc8872f2 | ||
|
|
94241e6c45 | ||
|
|
dfd4b6dbf4 | ||
|
|
c3abaaf00d | ||
|
|
8ac23f19e6 | ||
|
|
99c6ff5a08 | ||
|
|
d7d29b9d39 | ||
|
|
98260ad3aa | ||
|
|
4c8813b4b6 | ||
|
|
4aa38abf22 | ||
|
|
03850f2b6f | ||
|
|
04980d9f45 | ||
|
|
ceae26774e | ||
|
|
adb5555f08 | ||
|
|
aa75c28f58 | ||
|
|
86c7af6a76 | ||
|
|
898a342bd2 | ||
|
|
5f72f3934c | ||
|
|
16e8b7fb31 | ||
|
|
2be19e641d | ||
|
|
01b80517a9 | ||
|
|
a95065c0da | ||
|
|
81d80a1f2b | ||
|
|
8f1dbb8b05 | ||
|
|
d9292d5b04 | ||
|
|
9d096d2145 | ||
|
|
c818bfe1a5 | ||
|
|
a58db3ac33 | ||
|
|
9887add016 | ||
|
|
382b3b036a | ||
|
|
d382e5b317 | ||
|
|
b8e7318480 | ||
|
|
25d4e711fc | ||
|
|
3a53a07a50 |
No files matched your search
+569
@@ -8,6 +8,575 @@ deprecations, and behavioral changes. Breaking changes are called out explicitly
|
||||
|
||||
---
|
||||
|
||||
## [0.7.12]
|
||||
|
||||
OpenRig 0.7.12 is a patch for people running their own agent teams, cut from
|
||||
main `16e8b7fb`. When you answer your team, by typing, clicking or qualifying
|
||||
an answer, the answer now goes to an agent that reads it with the question and
|
||||
decides. Answers go to the configured receiving agent or kernel operator when
|
||||
available; otherwise they wait. Setup offers to register you as the team's
|
||||
person. `rig down` no longer stops a terminal it
|
||||
can't show belongs to the seat. There are fixes across delivery, Codex,
|
||||
restore, the queue, Slack and speed, many contributed by people using
|
||||
OpenRig. Two repairs aren't in this release: see Known limits.
|
||||
|
||||
### Before you upgrade
|
||||
|
||||
- **`rig down` is more careful.** It stops only a tmux session that carries the
|
||||
seat's own `OPENRIG_NODE_ID`, on the server and session it read. A terminal
|
||||
OpenRig didn't create, a mismarked one or a replaced one is left running, and
|
||||
`rig down` exits 2 naming it and how to stop it yourself. A seat joined with
|
||||
`rig attach --self` is in that group. ([#2962](https://github.com/mvschwarz/openrig/pull/2962))
|
||||
- **openrig-core is 0.1.28.** A Codex session kept running across the upgrade
|
||||
can ask for hook approval until it's relaunched.
|
||||
|
||||
### Answering your team
|
||||
|
||||
- Answers nothing else reads, such as a host pairing request or a mode's
|
||||
scheduled end, use the configured receiving agent or the kernel operator.
|
||||
Answers wait when no receiver is available. ([#2923](https://github.com/mvschwarz/openrig/pull/2923))
|
||||
- Pairing answers go to the receiving agent, and you can decide directly in the
|
||||
TUI's pairing settings. A pairing request says who it went to and who reads
|
||||
the answer. ([#2931](https://github.com/mvschwarz/openrig/pull/2931), [#2935](https://github.com/mvschwarz/openrig/pull/2935))
|
||||
- A runner's permission answer goes to the receiving agent, which decides while
|
||||
the runner waits, so a qualified answer such as "only if it's the test file"
|
||||
is read before anything acts. ([#2934](https://github.com/mvschwarz/openrig/pull/2934), [#2938](https://github.com/mvschwarz/openrig/pull/2938), [#2950](https://github.com/mvschwarz/openrig/pull/2950), [#2966](https://github.com/mvschwarz/openrig/pull/2966))
|
||||
- Setup offers to register you as the team's person, and the empty-registry
|
||||
hint is right. A team you start from your own assistant fits your own logins,
|
||||
with a review before anything is committed. ([#2924](https://github.com/mvschwarz/openrig/pull/2924), [#2905](https://github.com/mvschwarz/openrig/pull/2905))
|
||||
|
||||
### Harnesses and connections
|
||||
|
||||
- A community harness can declare `protocol: pi-rpc`, presets can carry launch
|
||||
wrappers, and a seat can switch between Pi and another runtime. The
|
||||
community-maintained nodecode runner is listed in the reference.
|
||||
([#2870](https://github.com/mvschwarz/openrig/pull/2870), [#2759](https://github.com/mvschwarz/openrig/pull/2759), [#2669](https://github.com/mvschwarz/openrig/pull/2669), [#2859](https://github.com/mvschwarz/openrig/pull/2859))
|
||||
- A relay connector that can't accept incoming connections can pull pending
|
||||
messages for a person instead of being pushed to: set
|
||||
`OPENRIG_RELAY_MODE=pull` in the relay secrets file. Push stays the default.
|
||||
A person's gateway binding can be unset. ([#2844](https://github.com/mvschwarz/openrig/pull/2844), [#2812](https://github.com/mvschwarz/openrig/pull/2812))
|
||||
- `rig terminal open <view> --provider herdr-app` opens a Rig view inside the
|
||||
herdr-app desktop window. ([#2744](https://github.com/mvschwarz/openrig/pull/2744), [#2956](https://github.com/mvschwarz/openrig/pull/2956), [#2981](https://github.com/mvschwarz/openrig/pull/2981))
|
||||
|
||||
### Queue, scope and seats
|
||||
|
||||
- Queue content can be erased for closed-work retention and account deletion,
|
||||
and a retried create can't bring erased work back. ([#2837](https://github.com/mvschwarz/openrig/pull/2837), [#2843](https://github.com/mvschwarz/openrig/pull/2843))
|
||||
- Each send records what's known about its delivery in one
|
||||
`transport.delivery_evidence` event: what was admitted, what reached the
|
||||
input, what the screen check showed, and for which occupant. ([#2937](https://github.com/mvschwarz/openrig/pull/2937), [#2943](https://github.com/mvschwarz/openrig/pull/2943))
|
||||
- `rig queue create` advises on an unresolved scope, slice lists show observed
|
||||
queue counts, a proof policy can require every listed judge, the execution
|
||||
ladder shows each slice's recorded status, `rig queue show` names a handed-off
|
||||
row's successor, and a custom blocker ID explains the wait.
|
||||
([#2901](https://github.com/mvschwarz/openrig/pull/2901), [#2902](https://github.com/mvschwarz/openrig/pull/2902), [#2867](https://github.com/mvschwarz/openrig/pull/2867), [#2872](https://github.com/mvschwarz/openrig/pull/2872), [#2874](https://github.com/mvschwarz/openrig/pull/2874), [#2983](https://github.com/mvschwarz/openrig/pull/2983))
|
||||
- Every seat's managed guidance points to its skills, help and project context.
|
||||
`rig seat status` shows when guidance was written relative to the session's
|
||||
recorded moments, and what
|
||||
the seat's refocus hook recorded. A rebuilt seat's briefing lists older recaps
|
||||
as optional history. ([#2918](https://github.com/mvschwarz/openrig/pull/2918), [#2920](https://github.com/mvschwarz/openrig/pull/2920), [#2916](https://github.com/mvschwarz/openrig/pull/2916), [#2876](https://github.com/mvschwarz/openrig/pull/2876))
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Delivery and drafts:** Claude seats with a custom status line read idle and
|
||||
verify sends; a fresh idle hook doesn't type into a current Claude dialog;
|
||||
Claude's context hint and drawn cursor are read correctly; a deliberate
|
||||
override is delivered when the input can't be read; update notices beside the
|
||||
paste hint are recognized. ([#2807](https://github.com/mvschwarz/openrig/pull/2807), [#2813](https://github.com/mvschwarz/openrig/pull/2813), [#2947](https://github.com/mvschwarz/openrig/pull/2947), [#2942](https://github.com/mvschwarz/openrig/pull/2942), [#2933](https://github.com/mvschwarz/openrig/pull/2933), [#2800](https://github.com/mvschwarz/openrig/pull/2800), [#2786](https://github.com/mvschwarz/openrig/pull/2786))
|
||||
- **Codex:** when you answer a Codex choice prompt, a message waiting on it is
|
||||
delivered instead of escalated; a working counter is compared only within one pane process; hook
|
||||
trust records are written under smol-toml 1.9, so launches no longer stop at
|
||||
"Hooks need review". ([#2952](https://github.com/mvschwarz/openrig/pull/2952), [#2980](https://github.com/mvschwarz/openrig/pull/2980), [#2804](https://github.com/mvschwarz/openrig/pull/2804))
|
||||
- **Restore, start and handover:** owed restore input is delivered after a
|
||||
bounded wait on unknown readiness; compaction uses the draft defaults and
|
||||
reports repeated unsent preparation; `rig daemon start` archives a start
|
||||
reservation left from an earlier boot; the kernel restart guidance describes
|
||||
the existing recovery; an earlier occupant can't replace a Claude seat's
|
||||
resume target; token switches keep the Claude launch; rebuild continuity and
|
||||
Codex launch choices are explained.
|
||||
([#2818](https://github.com/mvschwarz/openrig/pull/2818), [#2799](https://github.com/mvschwarz/openrig/pull/2799), [#2797](https://github.com/mvschwarz/openrig/pull/2797), [#2915](https://github.com/mvschwarz/openrig/pull/2915), [#2965](https://github.com/mvschwarz/openrig/pull/2965), [#2796](https://github.com/mvschwarz/openrig/pull/2796), [#2853](https://github.com/mvschwarz/openrig/pull/2853), [#2906](https://github.com/mvschwarz/openrig/pull/2906))
|
||||
- **Queue and recovery:** a polling hosted member (an outside assistant on the
|
||||
team; experimental and off by default) gets one standing notice; a hosted
|
||||
member's ring that times out reads unknown, and durable recovery doesn't
|
||||
repeat that recorded wake (other recovery paths can still ring it again);
|
||||
unroutable items wake as soon as the seat is reachable; the stuck sweep counts
|
||||
a finding once; an escalation keeps its owner through a handoff; findings route to the
|
||||
seat's own fallback; a park on an outside condition with no wake says so;
|
||||
`rig workflow validate` is read-only.
|
||||
([#2864](https://github.com/mvschwarz/openrig/pull/2864), [#2957](https://github.com/mvschwarz/openrig/pull/2957), [#2790](https://github.com/mvschwarz/openrig/pull/2790), [#2824](https://github.com/mvschwarz/openrig/pull/2824), [#2946](https://github.com/mvschwarz/openrig/pull/2946), [#2865](https://github.com/mvschwarz/openrig/pull/2865), [#2851](https://github.com/mvschwarz/openrig/pull/2851), [#2852](https://github.com/mvschwarz/openrig/pull/2852), [#2817](https://github.com/mvschwarz/openrig/pull/2817), [#2763](https://github.com/mvschwarz/openrig/pull/2763))
|
||||
- **Slack and people:** a Slack reply that can't be read says the message "may
|
||||
have posted"; threads are recovered when needed; an ask settles after its
|
||||
original message is deleted; eligible asks post when quiet hours can't be
|
||||
read, marked when OpenRig can't fully check them; a held ask whose person has
|
||||
no connector closes as no-connector; update delivery is described without
|
||||
assuming Slack. ([#2909](https://github.com/mvschwarz/openrig/pull/2909), [#2788](https://github.com/mvschwarz/openrig/pull/2788), [#2792](https://github.com/mvschwarz/openrig/pull/2792), [#2814](https://github.com/mvschwarz/openrig/pull/2814), [#2805](https://github.com/mvschwarz/openrig/pull/2805), [#2815](https://github.com/mvschwarz/openrig/pull/2815), [#2927](https://github.com/mvschwarz/openrig/pull/2927))
|
||||
- **CLI and config:** `rig context recap-write` writes where every reader looks;
|
||||
config accepts two keys the daemon already resolves; `rig whoami` says the
|
||||
team is still being built during `rig up`; rigs with `@` in the name find
|
||||
attention; a seat waiting on a person names its question; the installer
|
||||
reports PATH gaps with shell hints; daemon credentials stay out of the tmux
|
||||
server's environment. ([#2914](https://github.com/mvschwarz/openrig/pull/2914), [#2969](https://github.com/mvschwarz/openrig/pull/2969), [#2955](https://github.com/mvschwarz/openrig/pull/2955), [#2949](https://github.com/mvschwarz/openrig/pull/2949), [#2891](https://github.com/mvschwarz/openrig/pull/2891), [#2888](https://github.com/mvschwarz/openrig/pull/2888), [#2883](https://github.com/mvschwarz/openrig/pull/2883), [#2850](https://github.com/mvschwarz/openrig/pull/2850))
|
||||
- **Pi and OMP:** typed edits submit in order on the affected Node releases
|
||||
(22.21.x to 22.22.0, 24.9.0 to 24.11.1, 25.0.0 to 25.1.0) instead of appending an
|
||||
insertion at the end. ([#2847](https://github.com/mvschwarz/openrig/pull/2847))
|
||||
- **Terminals:** a Herdr grid whose blank filler cells have no label is confirmed,
|
||||
instead of reading every seat unconfirmed. ([#2849](https://github.com/mvschwarz/openrig/pull/2849))
|
||||
- **Speed:** queue listings, `rig queue show` and `rig parked` stop re-reading
|
||||
each item;
|
||||
the stuck sweep yields by elapsed time; Markdown section ends are found in one
|
||||
pass. ([#2825](https://github.com/mvschwarz/openrig/pull/2825), [#2926](https://github.com/mvschwarz/openrig/pull/2926), [#2833](https://github.com/mvschwarz/openrig/pull/2833))
|
||||
|
||||
### Behaviour that changed
|
||||
|
||||
- **Your answer goes to an agent.** Through 0.7.11 some answers acted on the
|
||||
request directly, and some had no receiver when no operator was set. Now these
|
||||
answers use the configured receiving agent or the kernel operator, with the
|
||||
question included. Answers wait when no receiver is available.
|
||||
([#2923](https://github.com/mvschwarz/openrig/pull/2923), [#2931](https://github.com/mvschwarz/openrig/pull/2931), [#2934](https://github.com/mvschwarz/openrig/pull/2934))
|
||||
- **`rig down`** leaves terminals it can't show belong to the seat; see Before
|
||||
you upgrade. ([#2962](https://github.com/mvschwarz/openrig/pull/2962))
|
||||
- **Codex "Hooks need review" with smol-toml 1.9** no longer needs a manual
|
||||
workaround. ([#2804](https://github.com/mvschwarz/openrig/pull/2804))
|
||||
- **A daemon start reservation left after a reboot** is archived by
|
||||
`rig daemon start`; don't clear it by hand. ([#2915](https://github.com/mvschwarz/openrig/pull/2915))
|
||||
|
||||
### Known limits
|
||||
|
||||
- **Not in this release:** the daemon can pause for up to about a second while
|
||||
reading proof on large mission trees ([#2970](https://github.com/mvschwarz/openrig/pull/2970)), and delivery to a Claude seat
|
||||
showing Claude's auto-update failure footer can stall because that footer
|
||||
isn't recognized ([#2948](https://github.com/mvschwarz/openrig/pull/2948)).
|
||||
- **`rig down`:** a terminal it leaves running stays running until you stop it.
|
||||
It doesn't keep a stored host or boot identity, doesn't guard against another
|
||||
program running as you, and a daemon restart between losing the tmux server
|
||||
and the stop wasn't exercised. For an adopted session, such as a seat joined
|
||||
with `rig attach --self`, `rig seat stop` still suggests `rig down`, which
|
||||
leaves that terminal running too. Release it with `rig unclaim <session>`
|
||||
(that leaves its process running), then close it in its own terminal. ([#2962](https://github.com/mvschwarz/openrig/pull/2962))
|
||||
- **Codex working counter:** a capture that doesn't name its pane keeps the old
|
||||
comparison, a restart that keeps both the pane and its process id isn't seen,
|
||||
and a leftover live frame can count once until it expires. ([#2980](https://github.com/mvschwarz/openrig/pull/2980))
|
||||
- **Custom blocker IDs** explain the wait, but their progress gives no event
|
||||
wake; the timer remains. ([#2983](https://github.com/mvschwarz/openrig/pull/2983))
|
||||
- **Delivery evidence** records what was known, not that the agent read the
|
||||
message; where the input ended up can read unknown. ([#2937](https://github.com/mvschwarz/openrig/pull/2937), [#2943](https://github.com/mvschwarz/openrig/pull/2943))
|
||||
- **Runner permission answers:** when a person answers a runner's permission
|
||||
request, the answer is kept, and an explicit decision works. In 0.7.12 the
|
||||
runner's confirmation updates fail to record, so no receipt or Feed
|
||||
confirmation appears, and the decision can reach the runner on its next read
|
||||
rather than at once (up to about 21 seconds in our test). To settle a request
|
||||
directly, run `rig seat decide-permission <request> --option <id>`. Pairing
|
||||
answers are separate, and their receipts are kept. Tested on one native daemon
|
||||
with the TUI and a runner driven by a stand-in agent. ([#2934](https://github.com/mvschwarz/openrig/pull/2934))
|
||||
- **herdr-app views** are one-shot; following isn't claimed. **Pull relay**
|
||||
needs a connector that implements pull. ([#2744](https://github.com/mvschwarz/openrig/pull/2744), [#2844](https://github.com/mvschwarz/openrig/pull/2844))
|
||||
- **From 0.7.11:** its known limits below still apply, except Codex hook setup
|
||||
with smol-toml 1.9.x ([#2720](https://github.com/mvschwarz/openrig/issues/2720)), fixed by [#2804](https://github.com/mvschwarz/openrig/pull/2804). 0.7.12 changes openrig-core
|
||||
again, so the Codex hook note there applies to this upgrade too. They weren't
|
||||
re-checked for this release unless named here.
|
||||
|
||||
### Credits
|
||||
|
||||
Thanks to @billbenetti, @oodadoudou, @DeryFerd, @Aummadour, @seanmcn, @w4rc0n,
|
||||
@ziadeh, @vanducng, @smota, @sn1thy, @dajiaohuang and @Vaishnavi220506 for
|
||||
merged contributions, and to @KM199, co-author of [#2947](https://github.com/mvschwarz/openrig/pull/2947) and [#2952](https://github.com/mvschwarz/openrig/pull/2952), which finish
|
||||
their [#2513](https://github.com/mvschwarz/openrig/pull/2513) and [#2515](https://github.com/mvschwarz/openrig/pull/2515).
|
||||
Thanks also for the reports behind these fixes: @nassersala ([#2955](https://github.com/mvschwarz/openrig/pull/2955)), @djuntgen
|
||||
([#2804](https://github.com/mvschwarz/openrig/pull/2804)), @todd-dhango ([#2851](https://github.com/mvschwarz/openrig/pull/2851)), @Kayhusk ([#2796](https://github.com/mvschwarz/openrig/pull/2796)), @pr0xc3nt4ur1 ([#2849](https://github.com/mvschwarz/openrig/pull/2849) and [#2850](https://github.com/mvschwarz/openrig/pull/2850)), @amsayfi
|
||||
([#2776](https://github.com/mvschwarz/openrig/pull/2776)), @oodadoudou ([#2847](https://github.com/mvschwarz/openrig/pull/2847)) and @CaptObvious ([#2933](https://github.com/mvschwarz/openrig/pull/2933) and [#2942](https://github.com/mvschwarz/openrig/pull/2942)).
|
||||
|
||||
## [0.7.11]
|
||||
|
||||
OpenRig 0.7.11 is a patch for people running their own agent teams, cut from
|
||||
main `b40bc71c`. Its biggest change: an ordinary send or work notice now goes
|
||||
into a person's typed draft by default instead of being refused (see Behaviour
|
||||
that changed). A person can now work with their team from their own coding
|
||||
agent through the new `openrig` plugin or from an ACP app such as Zed or T3
|
||||
Code, and, as an experiment that's off by default, answer the team's questions
|
||||
from an assistant connected through a grant service. A prepared managed compaction waits for an idle moment instead
|
||||
of asking again, and a restore stopped at a prompt clears itself. On Linux, a
|
||||
daemon run as a systemd user service no longer takes its seats down when its
|
||||
unit stops or restarts, and writable terminal tiles reflow text when the screen resizes. There
|
||||
are fixes across the queue, workflows, launch and speed under load, many
|
||||
contributed or reported by people using OpenRig. The OpenShell worker's
|
||||
maintained cleanup now works on OpenShell 0.1.6, but the worker still isn't end
|
||||
to end: see Known limits.
|
||||
|
||||
### Before you upgrade
|
||||
|
||||
- **Back up your database first.** Five new migrations run at the daemon's
|
||||
first start: 150 (node launch wrapper), 151 (Pi account switches), 152
|
||||
(workflow instance spec), 153 (events retention marks) and 154 (workflow
|
||||
instance root objective). 0.7.10 had none. With the daemon stopped, copy the
|
||||
file `$OPENRIG_DB` names. Going back to 0.7.10 after these migrations isn't
|
||||
established.
|
||||
- **Restart the daemon after installing:**
|
||||
```bash
|
||||
npm install -g @openrig/cli@0.7.11
|
||||
rig daemon stop && rig daemon start
|
||||
```
|
||||
- **Linux, with the daemon run as a systemd user service:** the seats move into
|
||||
their own scope only when their tmux server next starts fresh. Until then the
|
||||
server stays in the daemon's unit, so stopping or restarting that unit still
|
||||
takes the seats down, as before. ([#2749](https://github.com/mvschwarz/openrig/pull/2749))
|
||||
- **The openrig-core plugin moves from 0.1.26 to 0.1.27.**
|
||||
Its activity relay and refocus hooks changed, as did the refocusing,
|
||||
openrig-user and openrig-skills skills. Replaced files are kept under
|
||||
`$OPENRIG_HOME/backups/managed-refresh/<id>/`; reapply local edits you still
|
||||
need from there. ([#2756](https://github.com/mvschwarz/openrig/pull/2756), [#2743](https://github.com/mvschwarz/openrig/pull/2743), [#2717](https://github.com/mvschwarz/openrig/pull/2717), [#2735](https://github.com/mvschwarz/openrig/pull/2735), [#2657](https://github.com/mvschwarz/openrig/pull/2657), [#2647](https://github.com/mvschwarz/openrig/pull/2647), [#2619](https://github.com/mvschwarz/openrig/pull/2619))
|
||||
- **Sends now join a person's draft** (`transport.draft_delivery` = `deliver`).
|
||||
To keep the old refusal, run `rig config set transport.draft_delivery refuse`. ([#2645](https://github.com/mvschwarz/openrig/pull/2645))
|
||||
- **A Pi seat that declares an absolute `PI_CODING_AGENT_DIR`** in its `env:`
|
||||
now uses it as its account home and needs a readable `auth.json` there, or
|
||||
the launch stops. Prepare one, or remove the declaration to keep the managed
|
||||
seat login. ([#2630](https://github.com/mvschwarz/openrig/pull/2630))
|
||||
|
||||
### Sends and drafts
|
||||
|
||||
- **A send now goes into a person's typed draft.** Under the new
|
||||
`transport.draft_delivery` setting's default, `deliver`, an ordinary
|
||||
`rig send` or work notice to a seat whose prompt holds a person's typed draft
|
||||
pastes the message at the cursor and submits the combined input as one
|
||||
prompt, and the send's result says whether submission was confirmed.
|
||||
`refuse` (also `OPENRIG_TRANSPORT_DRAFT_DELIVERY` in the daemon's
|
||||
environment) restores the old refusal where OpenRig detects the draft, from
|
||||
the next send with no restart. Menus, permission prompts, a seat's typing
|
||||
guard, managed compaction and `--wait-for-idle` still refuse or wait under
|
||||
either value. ([#2645](https://github.com/mvschwarz/openrig/pull/2645))
|
||||
- **A draft only the input box shows is caught:** an ordinary Claude Code or
|
||||
Codex send reads the box once before pasting (three tmux calls, within
|
||||
500 ms). Under `refuse` the send is refused before any input; under `deliver`
|
||||
the result says it joined the draft. ([#2653](https://github.com/mvschwarz/openrig/pull/2653))
|
||||
- **Claude seats showing a statusLine or the background task panel** read as a
|
||||
normal composer under the draft-aware typing guard, so an empty box gets the
|
||||
delivery instead of `draft_input_unknown`. ([#2703](https://github.com/mvschwarz/openrig/pull/2703))
|
||||
- **The bare `✔ Update installed · Restart to update` row** of Claude Code
|
||||
2.1.319 in a narrow pane is recognised, so an idle seat's empty prompt reads
|
||||
empty and wakes go through under the draft-aware guard. Checked against the
|
||||
row as reported, not a live pane. ([#2662](https://github.com/mvschwarz/openrig/pull/2662))
|
||||
- **A full held-message quota no longer drops a sender's later wakes:** the
|
||||
sender's newest held message retires its earlier ones, listed as `coalesced`
|
||||
in `rig seat held-messages <seat>` and readable with `--id`. ([#2701](https://github.com/mvschwarz/openrig/pull/2701))
|
||||
- **Seats stopped while they owe work** don't get a burst of undeliverable
|
||||
wakes at each daemon start; the start-up log says `waiting=N (not replayed: …)`. ([#2620](https://github.com/mvschwarz/openrig/pull/2620))
|
||||
|
||||
### Compaction, restore and handover
|
||||
|
||||
- **A prepared managed compaction waits for its window.** When the restore map
|
||||
is complete but `/compact` couldn't go in (a draft, a tmux mode, no idle
|
||||
moment), the attempt is held as `prepared` and sends `/compact` at the next
|
||||
idle moment instead of asking the seat to prepare again about every 11
|
||||
minutes. `rig compact --state` shows the hold and `--cancel` disarms it. A
|
||||
busy seat isn't asked again after an ended attempt, across a restart too. ([#2698](https://github.com/mvschwarz/openrig/pull/2698), [#2716](https://github.com/mvschwarz/openrig/pull/2716))
|
||||
- **The final `/compact` waits out the preparation's own fresh activity:** its
|
||||
guarded send now allows the 15-second hook freshness window plus the 8-second
|
||||
capture budget (23 seconds, against 9), so a seat that's already idle, such
|
||||
as one out of context, gets `/compact` instead of a refusal. A busy seat can
|
||||
wait up to about 14 seconds longer; the attempt's deadline still limits it. ([#2764](https://github.com/mvschwarz/openrig/pull/2764))
|
||||
- **A restore stopped at a prompt clears itself** (such as Claude's resume
|
||||
selection) once the seat runs the conversation it was restoring, using the
|
||||
check `rig seat clear-attention` runs. The docs cover the manual route. ([#2711](https://github.com/mvschwarz/openrig/pull/2711), [#2674](https://github.com/mvschwarz/openrig/pull/2674))
|
||||
- **A queue wake refused at a prompt no longer goes in right after "Compacted"**
|
||||
while the compaction still owes its turn boundary and restore request. It
|
||||
goes in at the seat's next idle moment after that step clears, as a recovered
|
||||
notification. ([#2666](https://github.com/mvschwarz/openrig/pull/2666))
|
||||
- **A Codex helper inside a Claude seat** (such as `codex exec` from its Bash
|
||||
tool) no longer becomes the seat's resume token, so a restart resumes the
|
||||
Claude conversation instead of failing with `no_conversation_found`. ([#2740](https://github.com/mvschwarz/openrig/pull/2740))
|
||||
- **A Claude rebuild handover checks its priming packet:** if it's still
|
||||
staged, the handover reports the submission unconfirmed and says to submit
|
||||
the existing packet once, never to resend or rerun. ([#2650](https://github.com/mvschwarz/openrig/pull/2650))
|
||||
- **Restoring a Claude seat's activity hooks** finds the agent's own source from
|
||||
its saved launch recipe, not a same-name agent in the rig's folder. ([#2617](https://github.com/mvschwarz/openrig/pull/2617))
|
||||
- **`on_restore: follow_source`** on a spec's `resources.skills[]` entry
|
||||
refreshes that skill from its source when a Claude or Codex seat is restored,
|
||||
keeping its conversation; `keep_saved` stays the default. ([#2626](https://github.com/mvschwarz/openrig/pull/2626))
|
||||
|
||||
### Your own coding agent
|
||||
|
||||
- **The `openrig` plugin,** shipped beside openrig-core, lets a person work with
|
||||
their local team from their own Claude Code or Codex: hand it work, follow
|
||||
it, answer the team's questions about it, and read the result. Claude Code:
|
||||
`claude plugin marketplace add "$(npm root -g)/@openrig/cli/daemon/assets/plugins/openrig"`,
|
||||
then `claude plugin install openrig@openrig-app`. Codex: the same
|
||||
`codex plugin marketplace add` path, `codex plugin add openrig@openrig-app`, then
|
||||
`codex mcp add openrig --env OPENRIG_SESSION_NAME=<address> -- rig mcp serve --front-door`.
|
||||
The plugin's README has the steps. ([#2647](https://github.com/mvschwarz/openrig/pull/2647))
|
||||
- **VS Code, Copilot CLI and Cursor** install it too, with no seat; the README
|
||||
and `rig_whoami` say to add your own `rig mcp serve --front-door` server with
|
||||
`OPENRIG_SESSION_NAME` set and turn the plugin's off. A seatless app can still
|
||||
create a task, send and post, as `unknown-mcp-<8 hex>`. ([#2683](https://github.com/mvschwarz/openrig/pull/2683), [#2709](https://github.com/mvschwarz/openrig/pull/2709), [#2718](https://github.com/mvschwarz/openrig/pull/2718), [#2657](https://github.com/mvschwarz/openrig/pull/2657))
|
||||
- **Answers are recorded exactly as the person typed them.** ([#2733](https://github.com/mvschwarz/openrig/pull/2733))
|
||||
- **Marketplace files at the repository root** let Claude Code's
|
||||
`/plugin marketplace add mvschwarz/openrig`, and directories that take a public
|
||||
Git repo, install it from GitHub (marketplace `openrig-app`). ([#2719](https://github.com/mvschwarz/openrig/pull/2719))
|
||||
|
||||
### ACP apps: `rig acp serve`
|
||||
|
||||
- **OpenRig as an agent in ACP apps:** `rig acp serve --to <member@rig>` runs
|
||||
on stdio for an app that launches a custom agent (Zed's `agent_servers`, T3
|
||||
Code). A prompt becomes a queue row, each row the work reaches shows as a
|
||||
step, and the closing resolution is the reply. Nothing runs until an app
|
||||
starts it, and it talks to the daemon `rig` uses. ([#2648](https://github.com/mvschwarz/openrig/pull/2648), [#2714](https://github.com/mvschwarz/openrig/pull/2714))
|
||||
- **The team's questions reach the app** while a turn is open, as a permission
|
||||
request or a one-choice form, and the choice is the person's answer. Sessions
|
||||
can be listed and reopened, and a question left unanswered there is named as
|
||||
still open in the TUI and Slack. ([#2651](https://github.com/mvschwarz/openrig/pull/2651), [#2708](https://github.com/mvschwarz/openrig/pull/2708), [#2686](https://github.com/mvschwarz/openrig/pull/2686), [#2712](https://github.com/mvschwarz/openrig/pull/2712))
|
||||
- **The `@openrig/acp` launcher** (`openrig-acp`) runs the matching
|
||||
`@openrig/cli`'s `rig acp serve` for the ACP registry's `npx` launch, with a
|
||||
new `acp.to` setting for the receiver. `npm run pack:release` packs it beside
|
||||
the CLI at the same version. ([#2655](https://github.com/mvschwarz/openrig/pull/2655), [#2715](https://github.com/mvschwarz/openrig/pull/2715))
|
||||
|
||||
### People and connected assistants
|
||||
|
||||
- Denying or dropping a host pairing request in Steering now gives the requesting machine no access; in 0.7.9 and 0.7.10, that action could return the daemon access token. ([#2884](https://github.com/mvschwarz/openrig/pull/2884))
|
||||
- **Pairing answers now reach the target's kernel operator.** On a 0.7.11 target with a receiving kernel operator, typed words and Approve or Deny choices in the TUI, Steering or Slack are replies for that agent to read. Receiving a reply neither grants nor denies access. The operator acts on clear intent with `rig host approve <code>` or `rig host deny <code>` and asks back when the answer is unclear. The commands also work from a plain terminal on the target; they use its operator bearer (`OPENRIG_AUTH_BEARER_TOKEN`, or `--bearer <token>`). ([#2900](https://github.com/mvschwarz/openrig/pull/2900))
|
||||
- **An Approve click on an operator-owned request waits for the operator to act.** A stopped operator keeps ownership; the answer waits in its queue. A stopped or unknown running state is named with the terminal route, without claiming the answer was read. If there is no operator, it cannot receive answers, or OpenRig cannot determine one, the request names that case and uses the agent-free path: Approve and Deny decide directly, and replies lead to another ask rather than denial. File-only replies and the literal `[file reply]` have no words stored on the agent-free ask; they still do not deny it.
|
||||
- **TUI answers use the daemon's operator bearer when configured.** Ordinary terminal calls keep their separate terminal credential. A missing or wrong action credential leaves the answer unrecorded and shows what to set; this does not weaken the daemon's authentication.
|
||||
- **Receipt, clarification and outcome are separate steps.** Receipts, agent-free re-asks and outcomes inferred from clicks or closes need the requesting machine to keep polling. An explicit `rig host approve` or `rig host deny` attempts its outcome update directly. An expiry timer closes the ask even if polling stopped; an unanswered expired ask gets no further update. Requests and decisions live in daemon memory, so a restart drops an open request. Approval still must be collected before expiry. This target-side change does not change older targets.
|
||||
|
||||
- **Assistants connected through a grant service** (experimental, unsupported,
|
||||
off by default: you serve `rig mcp serve --http --grant-service <url>` behind
|
||||
your own TLS tunnel, and an outside service grants each connection):
|
||||
`rig_question_list` and `rig_question_answer` let a registered person answer
|
||||
the team's questions as their own; `--task-destination <member@rig>` names
|
||||
the receiver for `rig_queue_create`, and a bare seat name resolves. ([#2735](https://github.com/mvschwarz/openrig/pull/2735), [#2638](https://github.com/mvschwarz/openrig/pull/2638), [#2750](https://github.com/mvschwarz/openrig/pull/2750))
|
||||
- **The `relay` gateway connector** sends questions, updates and notices to an
|
||||
operator-owned bridge over loopback HTTP and takes replies back. It's off
|
||||
unless you add a relay binding, and the bridge, with its reach to a messaging
|
||||
platform, is yours (`docs/reference/gateway-relay.md`). ([#2624](https://github.com/mvschwarz/openrig/pull/2624))
|
||||
- **The web UI's For You feed** answers a local ask's questions on its card. ([#2612](https://github.com/mvschwarz/openrig/pull/2612))
|
||||
|
||||
### Queue, workflows and status
|
||||
|
||||
- **One `unclaimed-backlog` notice** replaces a finding per row for a seat that
|
||||
reads without claiming and has no escalation route or delegation parent. ([#2752](https://github.com/mvschwarz/openrig/pull/2752))
|
||||
- **Claims read `working`, not stalled,** for up to 60 minutes while a Claude
|
||||
owner has a live background shell, and 20 while a managed restore is owed. ([#2675](https://github.com/mvschwarz/openrig/pull/2675))
|
||||
- **`rig queue show`, `list`, `transitions`, `update` and `block` take
|
||||
`--host <id>`,** as `create` and `handoff` did; `claim` doesn't. ([#2678](https://github.com/mvschwarz/openrig/pull/2678))
|
||||
- **Cross-host handoffs** that override a claim wake the claimer, and a close
|
||||
to an unverifiable successor names `rig queue handoff`. ([#2734](https://github.com/mvschwarz/openrig/pull/2734), [#2621](https://github.com/mvschwarz/openrig/pull/2621))
|
||||
- **Code results:** an `openrig.code-result/v1` manifest as `evidenceRef` gives
|
||||
`rig_artifact_read` and the new `rig queue artifact` the commit pair to diff. ([#2659](https://github.com/mvschwarz/openrig/pull/2659))
|
||||
- **Workflows:** a run keeps its own spec when another pod's copy reuses the id
|
||||
and version; the root objective is kept and printed as `objective:`;
|
||||
`rig workflow unregister` removes versions; `trace --full` returns the whole
|
||||
trail. ([#2696](https://github.com/mvschwarz/openrig/pull/2696), [#2738](https://github.com/mvschwarz/openrig/pull/2738), [#2676](https://github.com/mvschwarz/openrig/pull/2676), [#2667](https://github.com/mvschwarz/openrig/pull/2667))
|
||||
- **Activity:** a helper harness inside a seat no longer reports its turns as
|
||||
the seat's, and a resumed seat no longer reads a false model divergence. ([#2743](https://github.com/mvschwarz/openrig/pull/2743), [#2664](https://github.com/mvschwarz/openrig/pull/2664))
|
||||
- **Also:** refocus traces projects outside `workspace.root`; a timed-out
|
||||
`rig stream emit` names a retry that can't duplicate; `rig context
|
||||
reconstruction inspect` and `view` read an exact revision from local stores.
|
||||
([#2717](https://github.com/mvschwarz/openrig/pull/2717), [#2619](https://github.com/mvschwarz/openrig/pull/2619), [#2207](https://github.com/mvschwarz/openrig/pull/2207))
|
||||
|
||||
### Launch, install and setup
|
||||
|
||||
- **On Linux, a daemon run as a systemd user service starts the seats' tmux
|
||||
server in its own user scope** (`openrig-tmux-<id>.scope`, with
|
||||
`OOMPolicy=continue` from systemd 253), so stopping or restarting the
|
||||
daemon's unit no longer ends the seats, and their memory no longer counts
|
||||
against it. A daemon started from a login session, or a failed `systemd-run`,
|
||||
starts the server as before with one log line giving the reason; macOS and
|
||||
Windows are unchanged. ([#2749](https://github.com/mvschwarz/openrig/pull/2749))
|
||||
- **A Claude seat whose launch passes `--settings` JSON written with spaces**
|
||||
(such as `{"env": {…}}` from a proxy alias) can be verified again: OpenRig
|
||||
reads the value whole, so it finds the seat's conversation. ([#2665](https://github.com/mvschwarz/openrig/pull/2665))
|
||||
- **A Claude launcher script that execs the native installer's symlink**
|
||||
becomes ready after a launch or rebuild, and `--no-chrome` is recognised. ([#2751](https://github.com/mvschwarz/openrig/pull/2751))
|
||||
- **`launch_wrapper`,** an argv prefix whose launcher prepares the environment
|
||||
and execs the agent, works for local Claude Code, Pi and Codex seats; a
|
||||
wrapped seat never starts unwrapped. ([#2746](https://github.com/mvschwarz/openrig/pull/2746), [#2609](https://github.com/mvschwarz/openrig/pull/2609))
|
||||
- **`rig provider switch --seat <pi seat> --to-account <name> --account-home <dir> --reason "<why>"`**
|
||||
moves a Pi seat to another account through a confirmed successor, keeping its
|
||||
model, effort and wrapper; `--dry-run` shows the plan. A Pi seat whose spec
|
||||
declares its account home starts, resumes and switches accounts again: on the
|
||||
first 0.7.11 candidate it didn't start, because the staged command began with
|
||||
shell setup that `exec` can't run. ([#2630](https://github.com/mvschwarz/openrig/pull/2630), [#2782](https://github.com/mvschwarz/openrig/pull/2782))
|
||||
- **Claude seats that load openrig-core themselves** stop loading its skills
|
||||
twice: the next launch removes OpenRig's unchanged copies. ([#2660](https://github.com/mvschwarz/openrig/pull/2660))
|
||||
- **Also:** a short permission-hook policy name; `rig attach --self` outside
|
||||
tmux adds the rig; `rig host pair --timeout` holds the request that long;
|
||||
`rig config set` names a masking variable; daemon start and stop treat a
|
||||
probe the OS blocked as unknown; a bare piped `rig` skips the compile cache.
|
||||
([#2640](https://github.com/mvschwarz/openrig/pull/2640), [#2641](https://github.com/mvschwarz/openrig/pull/2641), [#2692](https://github.com/mvschwarz/openrig/pull/2692), [#2547](https://github.com/mvschwarz/openrig/pull/2547), [#2502](https://github.com/mvschwarz/openrig/pull/2502), [#2643](https://github.com/mvschwarz/openrig/pull/2643))
|
||||
|
||||
### Terminals and views
|
||||
|
||||
- **Writable tiles reflow with the screen.** They attach as in 0.7.10: the screen
|
||||
in use sizes the seat, and text reflows on resize. They stay attached until
|
||||
detached or closed. Read-only tiles keep showing the seat without attaching,
|
||||
typing into it or resizing it. Fixed web geometry is opt-in through
|
||||
`terminal.fixed_geometry` (off by default).
|
||||
([#2868](https://github.com/mvschwarz/openrig/pull/2868), [#2757](https://github.com/mvschwarz/openrig/pull/2757), [#2821](https://github.com/mvschwarz/openrig/pull/2821))
|
||||
- **Herdr follow views** work on macOS too (protocol 22), open a labelled
|
||||
snapshot where live following isn't supported, and show a cleaner strip. ([#2663](https://github.com/mvschwarz/openrig/pull/2663), [#2706](https://github.com/mvschwarz/openrig/pull/2706))
|
||||
- **Closing the herdr-core terminal that started OpenRig** no longer ends the
|
||||
daemon or its seats (per herdr-core's documented behaviour; a real close
|
||||
wasn't exercised). ([#2737](https://github.com/mvschwarz/openrig/pull/2737))
|
||||
- **Also:** terminal snapshots keep colours; a reattach during a broker
|
||||
teardown works; a home under a symlinked parent records its real path;
|
||||
`rig doctor` says why tmux control mode is off. ([#2654](https://github.com/mvschwarz/openrig/pull/2654), [#2671](https://github.com/mvschwarz/openrig/pull/2671), [#2633](https://github.com/mvschwarz/openrig/pull/2633), [#2707](https://github.com/mvschwarz/openrig/pull/2707))
|
||||
|
||||
### Speed under load
|
||||
|
||||
- **The stuck sweep** batches its reads and yields every 50 rows: on a copy of
|
||||
a busy 340 MB database, a pass went from 10.2–11.4 s to 5.1–5.5 s and the
|
||||
longest stall from about 10 s to 0.57 s. ([#2649](https://github.com/mvschwarz/openrig/pull/2649))
|
||||
- **`view.changed` events are no longer stored,** so the `events` table grows
|
||||
about a third as fast on the busy host measured, and opt-in
|
||||
`retention.events_days` prunes eight history-only event types. ([#2695](https://github.com/mvschwarz/openrig/pull/2695), [#2699](https://github.com/mvschwarz/openrig/pull/2699))
|
||||
- **The web UI** loads the newest 100 events instead of the whole log. ([#2704](https://github.com/mvschwarz/openrig/pull/2704))
|
||||
- **`daemon.seat_poll_interval_ms`** (1000 to 2000) trades activity freshness
|
||||
for daemon CPU on large fleets. ([#2688](https://github.com/mvschwarz/openrig/pull/2688))
|
||||
- **Also:** with `OPENRIG_TMUX_CONTROL=1`, short sends and `set-option` use the
|
||||
control client; the idle-shell check reads the plain process table; skill
|
||||
catalog Git reads time out after five seconds. ([#2636](https://github.com/mvschwarz/openrig/pull/2636), [#2690](https://github.com/mvschwarz/openrig/pull/2690), [#2713](https://github.com/mvschwarz/openrig/pull/2713))
|
||||
|
||||
### Docs and tests
|
||||
|
||||
Daemon HTTP clients and bearer tokens ([#2652](https://github.com/mvschwarz/openrig/pull/2652)), the 0.7.10 capability map on
|
||||
main ([#2736](https://github.com/mvschwarz/openrig/pull/2736)), a neutral TUI demo snapshot ([#2724](https://github.com/mvschwarz/openrig/pull/2724)), a web UI editor timer
|
||||
([#2689](https://github.com/mvschwarz/openrig/pull/2689)), and test and CI fixes ([#2753](https://github.com/mvschwarz/openrig/pull/2753), [#2681](https://github.com/mvschwarz/openrig/pull/2681), [#2685](https://github.com/mvschwarz/openrig/pull/2685), [#2673](https://github.com/mvschwarz/openrig/pull/2673), [#2628](https://github.com/mvschwarz/openrig/pull/2628)).
|
||||
|
||||
### Behaviour that changed
|
||||
|
||||
- **Sends and work notices that were refused with `prompt_draft` now go
|
||||
through by default, joined to the person's draft** (above).
|
||||
`rig config set transport.draft_delivery refuse` restores the old refusal
|
||||
where OpenRig detects the draft, from the next send with no restart; under
|
||||
`refuse`, a draft only the input box shows now refuses too. ([#2645](https://github.com/mvschwarz/openrig/pull/2645), [#2653](https://github.com/mvschwarz/openrig/pull/2653))
|
||||
- **A Pi seat with an absolute `PI_CODING_AGENT_DIR`** uses it as its account
|
||||
home on launch, restart, resume and restore; no `auth.json`, no launch. ([#2630](https://github.com/mvschwarz/openrig/pull/2630))
|
||||
- **`/api/events` no longer emits `view.changed`,** live or replayed, so new
|
||||
ones leave the web UI's log, activity feed and For You and the historical
|
||||
telemetry export; the view streams still carry them. ([#2695](https://github.com/mvschwarz/openrig/pull/2695))
|
||||
- **The stuck sweep** files one backlog notice where it filed one per row,
|
||||
skips `stalled-after-claim` inside the two windows above, re-checks each
|
||||
rule just before filing, and skips overlapping ticks. ([#2752](https://github.com/mvschwarz/openrig/pull/2752), [#2675](https://github.com/mvschwarz/openrig/pull/2675), [#2649](https://github.com/mvschwarz/openrig/pull/2649))
|
||||
- **Managed compaction:** a prepared attempt stays `waiting`, and a manual
|
||||
`rig compact` over it reads `already_in_progress`. ([#2698](https://github.com/mvschwarz/openrig/pull/2698), [#2716](https://github.com/mvschwarz/openrig/pull/2716))
|
||||
- **The daemon and seats no longer inherit `HC_SCOPE`, `HC_TERMINAL_ID` or
|
||||
`HC_TERMINAL_SOCKET`;** one already running keeps them until restarted. ([#2737](https://github.com/mvschwarz/openrig/pull/2737))
|
||||
- **On Linux, the seats' tmux server runs in its own systemd user scope** when
|
||||
the daemon is a user service (above): the daemon unit's stop, restart and
|
||||
memory accounting no longer cover the seats. ([#2749](https://github.com/mvschwarz/openrig/pull/2749))
|
||||
- **Read-only terminal tiles** show the seat without attaching and end with
|
||||
tmux's own error if they cannot read it. Writable tiles use the ordinary
|
||||
sizing attach; detaching ends that viewer, while the agent keeps running.
|
||||
([#2757](https://github.com/mvschwarz/openrig/pull/2757), [#2868](https://github.com/mvschwarz/openrig/pull/2868))
|
||||
- **Also:** each sender keeps one live held message per seat; daemon start
|
||||
doesn't re-send a failed wake to a seat with no receiver; restores record
|
||||
`restore.outcome_reconciled` themselves; `rig terminal open --follow` returns
|
||||
`follow_unsupported` with a snapshot. ([#2701](https://github.com/mvschwarz/openrig/pull/2701), [#2620](https://github.com/mvschwarz/openrig/pull/2620), [#2711](https://github.com/mvschwarz/openrig/pull/2711), [#2663](https://github.com/mvschwarz/openrig/pull/2663))
|
||||
- **MCP and ACP:** an empty or placeholder seat value means no seat; the
|
||||
seatless `rig_whoami` text changed; `--front-door` is `front-door/v2`;
|
||||
grant-service callers' `destination` is optional; `rig acp serve` gives
|
||||
form-taking apps a form, leads step titles with the latest note, and asks
|
||||
nothing after a cancel. ([#2657](https://github.com/mvschwarz/openrig/pull/2657), [#2709](https://github.com/mvschwarz/openrig/pull/2709), [#2718](https://github.com/mvschwarz/openrig/pull/2718), [#2733](https://github.com/mvschwarz/openrig/pull/2733), [#2638](https://github.com/mvschwarz/openrig/pull/2638), [#2750](https://github.com/mvschwarz/openrig/pull/2750), [#2708](https://github.com/mvschwarz/openrig/pull/2708), [#2686](https://github.com/mvschwarz/openrig/pull/2686), [#2655](https://github.com/mvschwarz/openrig/pull/2655))
|
||||
- **CLI:** `rig attach --self` outside tmux records `<name>@<rig>`;
|
||||
`rig host pair` keeps a request for its `--timeout`; `rig config set` and
|
||||
`reset` name environment overrides; `rig daemon start` and `stop` refuse when
|
||||
a blocked probe leaves identity or liveness unknown; `--host` receipts print
|
||||
a runnable command. ([#2641](https://github.com/mvschwarz/openrig/pull/2641), [#2692](https://github.com/mvschwarz/openrig/pull/2692), [#2547](https://github.com/mvschwarz/openrig/pull/2547), [#2502](https://github.com/mvschwarz/openrig/pull/2502), [#2678](https://github.com/mvschwarz/openrig/pull/2678))
|
||||
- **Seat launches:** unchanged OpenRig copies of openrig-core's skills are
|
||||
removed, with warnings; the permission hook reads the installed policy lists
|
||||
on each call; model divergence reads pending until the new occupant's record.
|
||||
([#2660](https://github.com/mvschwarz/openrig/pull/2660), [#2640](https://github.com/mvschwarz/openrig/pull/2640), [#2664](https://github.com/mvschwarz/openrig/pull/2664))
|
||||
|
||||
### Known limits
|
||||
|
||||
- **OpenShell:** on OpenShell 0.1.6 the maintained cleanup (`delete-owned`)
|
||||
now works for a custom image, and recreate and an exact resume passed in our
|
||||
native run. A restored guest still receives its startup text again, so strict
|
||||
recall after a restore isn't met, and the worker isn't end to end. The CA fix
|
||||
holds on 0.1.6. Never give the guest the daemon's main API port (normally
|
||||
7433). After a same-generation reconciled launch, run
|
||||
`rig environment connect` yourself. ([#2755](https://github.com/mvschwarz/openrig/pull/2755))
|
||||
- **Drafts:** detection isn't complete. Under `refuse`, an unreadable input box
|
||||
lets the send go ahead, so an undetected draft goes with the message; under
|
||||
`deliver`, a result may not mention a draft it joined. ([#2645](https://github.com/mvschwarz/openrig/pull/2645), [#2653](https://github.com/mvschwarz/openrig/pull/2653))
|
||||
- **A post-compaction step can still hold** when the box reads as typed, under
|
||||
draft-aware delivery or the typing guard, or after a lost `/compact`. Run
|
||||
`rig compact <seat> --cancel`, then send a restore request naming the newest
|
||||
restore map, marker and packet. A queue wake refused at a prompt now waits for
|
||||
that step, but recovery at daemon start and a new handoff's wake don't check
|
||||
for it yet. ([#2575](https://github.com/mvschwarz/openrig/issues/2575), [#2666](https://github.com/mvschwarz/openrig/pull/2666))
|
||||
- **Draft-aware delivery to Claude:** a message held as `draft_input_unknown`
|
||||
still isn't retried. Whether managed compaction now gets past the banner
|
||||
(`target_activity_unknown`) wasn't retested. ([#2605](https://github.com/mvschwarz/openrig/issues/2605))
|
||||
- **Claude provider switch:** the failure reported in [#2341](https://github.com/mvschwarz/openrig/issues/2341) ("The probe pane
|
||||
returned to a shell instead of staying inside the runtime") isn't shown fixed.
|
||||
- **`rig exec`:** checked with Claude Code and Codex through their ACP
|
||||
adapters. A cancel is acknowledged, not settled, and says nothing about
|
||||
further spend. Cancel with `rig exec cancel`, not `rig acp cancel`.
|
||||
- **ACP apps:** checked in T3 Code (Beta) 0.1.7 and with a scripted client; the
|
||||
unanswered-question notice hasn't run in a live app, nor the note-first step
|
||||
title in Zed. A cancel stops waiting; the work carries on. ([#2648](https://github.com/mvschwarz/openrig/pull/2648), [#2686](https://github.com/mvschwarz/openrig/pull/2686), [#2712](https://github.com/mvschwarz/openrig/pull/2712))
|
||||
- **The `openrig` plugin** hasn't run in Claude Desktop, the Codex app or
|
||||
Copilot CLI. A seatless app can't claim, update, hand off or answer
|
||||
questions, and everyone using the app on a machine acts as its seat. ([#2647](https://github.com/mvschwarz/openrig/pull/2647), [#2657](https://github.com/mvschwarz/openrig/pull/2657), [#2683](https://github.com/mvschwarz/openrig/pull/2683))
|
||||
- **Grant-service assistants** still aren't woken: an item stays queued until
|
||||
its receiver claims it. **Relay:** "Accepted by bridge" means the bridge has
|
||||
it, not the person, and a failed send replays only at a restart. ([#2735](https://github.com/mvschwarz/openrig/pull/2735), [#2624](https://github.com/mvschwarz/openrig/pull/2624))
|
||||
- **Event retention** can't be undone, and the database file doesn't shrink. ([#2699](https://github.com/mvschwarz/openrig/pull/2699))
|
||||
- **`keep_saved` skills:** a skill whose source changes between `rig down` and
|
||||
the restore comes back missing, named in the restore's warnings; an edited seat
|
||||
copy is kept. ([#2626](https://github.com/mvschwarz/openrig/pull/2626))
|
||||
- **Read-only terminal tiles** each run one tmux command a second while
|
||||
showing. The earlier eight-tile showing measurement used about 6–8% of one
|
||||
core on an Apple Silicon Mac mini, against about 0.2% for attached 0.7.10
|
||||
tiles. This polling cost no longer applies to writable tiles. ([#2757](https://github.com/mvschwarz/openrig/pull/2757))
|
||||
- **Early writable-tile input:** as in 0.7.10, Enter or Ctrl-C typed while a
|
||||
new tile is still attaching can close that viewer. The agent keeps running;
|
||||
reopen the view. Once attached, tmux handles input and paste directly.
|
||||
([#2868](https://github.com/mvschwarz/openrig/pull/2868))
|
||||
- **Kept writable tiles from 0.7.8, 0.7.9 or pre-reflow 0.7.11** keep their old
|
||||
client. Manually refresh them: close only their owned viewer and reopen with
|
||||
`rig terminal open`. For a `--follow` view, use `--no-follow` first, then open
|
||||
a fresh view. This does not touch the agent or its conversation; it is not an
|
||||
automatic migration. Existing 0.7.10 plain and read-only tiles stay as they are.
|
||||
([#2868](https://github.com/mvschwarz/openrig/pull/2868))
|
||||
- **Claude `launch_wrapper`** hasn't run with zsh, on macOS arm64 or with a real
|
||||
installed Claude; an alias or shell function named `claude` fails closed. ([#2746](https://github.com/mvschwarz/openrig/pull/2746))
|
||||
- **Going back to 0.7.10:** besides the migrations, an older permission hook
|
||||
gives named-policy Claude sessions no decision until relaunched. ([#2640](https://github.com/mvschwarz/openrig/pull/2640))
|
||||
- **Codex hooks:** a session kept running across an upgrade can show "Hook
|
||||
failed — plugin contents changed; hook approval required" until relaunched,
|
||||
and 0.7.11 changes openrig-core again. `rig seat restart --keep-conversation`
|
||||
refuses a Codex seat launched with developer instructions (`settings_drift`).
|
||||
- **Codex hook setup:** with smol-toml 1.9.x and an existing `hooks.json`,
|
||||
OpenRig can leave its activity hooks and trust records unchanged. The range
|
||||
(`^1.8.0`) still permits 1.9.x; the repository lockfile uses 1.8.0. ([#2720](https://github.com/mvschwarz/openrig/issues/2720))
|
||||
- **Claude Code 2.1.319 starts in auto mode by default,** so a new Claude seat
|
||||
may act without asking; manual mode restores asking.
|
||||
- **Also:** permission requests in seat status show only the latest, in memory
|
||||
for five minutes, and suggested rules can contain sensitive values; Slack
|
||||
private channels need the documented, untested route, direct messages aren't
|
||||
supported and people reads haven't run live; a Codex snapshot restore writes
|
||||
OpenRig's guidance blocks into a tracked `AGENTS.md` (check `git status`); a
|
||||
scheduled reminder that fails before input isn't retried; SSH host status
|
||||
reads `unknown` behind ProxyJump or ProxyCommand; advisories don't show on a
|
||||
native resume; seats handed over before 0.7.9 refuse stop, restart and
|
||||
clean; after a project-local install, plain `rig` isn't found in the TUI's
|
||||
own shell; with Claude Code 2.1.319, `rig up` can wrongly say "Startup prompt
|
||||
still staged … press Enter"; two npm installs into one folder at once once
|
||||
left a partial install, cause unknown.
|
||||
- **Fixed or changed from 0.7.10's known issues:** the bare update banner
|
||||
([#2662](https://github.com/mvschwarz/openrig/pull/2662)) and the Claude launcher-script readiness timeout with `--no-chrome`
|
||||
([#2751](https://github.com/mvschwarz/openrig/pull/2751)). 0.7.10's OpenShell limit is superseded by the one above.
|
||||
- **The earlier limits below that 0.7.11 doesn't fix or change still apply.**
|
||||
|
||||
### Credits
|
||||
|
||||
Thanks to @billbenetti, @seanmcn, @smota, @Aummadour, @dajiaohuang, @w4rc0n,
|
||||
@Mohsintl, @twotnguyen, @ziadeh and @Nikhi00718 for merged contributions.
|
||||
Thanks to @billbenetti for the reports behind [#2752](https://github.com/mvschwarz/openrig/pull/2752), [#2660](https://github.com/mvschwarz/openrig/pull/2660), [#2738](https://github.com/mvschwarz/openrig/pull/2738), [#2749](https://github.com/mvschwarz/openrig/pull/2749), [#2666](https://github.com/mvschwarz/openrig/pull/2666) and [#2665](https://github.com/mvschwarz/openrig/pull/2665), to
|
||||
@pr0xc3nt4ur1 for the report behind [#2740](https://github.com/mvschwarz/openrig/pull/2740) and [#2743](https://github.com/mvschwarz/openrig/pull/2743), to @oneandonlydean for
|
||||
the process chain behind [#2751](https://github.com/mvschwarz/openrig/pull/2751), and to @niklasbaldi for the report and cause
|
||||
behind [#2654](https://github.com/mvschwarz/openrig/pull/2654).
|
||||
Thanks also to @ziadeh for the reports behind [#2622](https://github.com/mvschwarz/openrig/issues/2622) and [#2486](https://github.com/mvschwarz/openrig/issues/2486), and to @mlc-factory-bot for the reports behind [#2605](https://github.com/mvschwarz/openrig/issues/2605) and [#2607](https://github.com/mvschwarz/openrig/issues/2607).
|
||||
|
||||
## [0.7.10]
|
||||
|
||||
Published 2026-11-08 as `@openrig/[email protected]`, built from the commit tagged
|
||||
|
||||
@@ -328,7 +328,7 @@ OpenRig is a multi-agent harness — it manages the system that coding agents fo
|
||||
- **Connect** Slack through an app you create in your own workspace; the experimental `rig slack manifest` prints that app's manifest ([setup guide](docs/reference/slack-app-setup.md))
|
||||
- **Evolve** running topologies with `rig grow`, `rig shrink`, `rig launch`, `rig remove`
|
||||
|
||||
Every agent runs in a tmux session you can attach to, inspect, and work with directly.
|
||||
Every terminal seat runs in a tmux session you can attach to, inspect, and work with directly. [Hosted members](docs/reference/hosted-members.md) join through OpenRig's MCP server and have no pane.
|
||||
|
||||
## Teams
|
||||
|
||||
|
||||
+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.10
|
||||
## Released in 0.7.11
|
||||
|
||||
[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.
|
||||
[0.7.11 is published](https://github.com/mvschwarz/openrig/releases/tag/v0.7.11): OpenRig 0.7.11 is a patch for people running their own agent teams. An ordinary send or work notice now goes into a person's typed draft by default instead of being refused (see Behaviour that changed). See its release notes for what shipped, known issues, upgrade instructions and contributor credits.
|
||||
|
||||
## How to help
|
||||
|
||||
|
||||
@@ -175,8 +175,14 @@ The standing detector in `queue-stuck-sweep.ts` creates findings through the
|
||||
queue repository, with `evidenceRef: rig queue show <source-qitem-id>` pointing
|
||||
to the underlying durable work row. Unclaimed and undelivered findings go to
|
||||
the destination's orchestrator when one is derivable, otherwise to the
|
||||
destination itself, and that seat may be dead. Overdue claims go to their
|
||||
destination. Repeated detections reuse the existing finding without changing
|
||||
destination itself, and that seat may be dead. A wake that failed (`failed:…`)
|
||||
at a recorded agent seat with no `delegates_to` parent or `escalates_to` route is
|
||||
the exception: its finding tries the row's creator, then the operator agent
|
||||
(`agents.operator_session`), skipping an unset, confirmed-missing, self or
|
||||
unaccepted address, a bound terminal seat, and OpenRig's own machinery
|
||||
(`daemon@kernel` or a `@system` actor), so the finding's own wake isn't sent to the seat that just failed to
|
||||
take one. If neither can be used, it stays with the original seat and says why.
|
||||
Overdue claims go to their destination. Repeated detections reuse the existing finding without changing
|
||||
its route; when the source condition resolves, the sweep closes its own finding.
|
||||
Previously, falling out of a reporting query could itself close a delivery or
|
||||
unclaimed finding. An unconfirmed retry, a fresh human-notification post window,
|
||||
|
||||
@@ -32,13 +32,12 @@ Two independent naming axes meet in this layer. They must not be conflated or
|
||||
blanket-replaced: `rigged` → `rig` is a per-occurrence question, not a global
|
||||
substitution.
|
||||
|
||||
**Axis 1 — MCP tool names are `rig_*`.** `packages/cli/src/mcp-server.ts`
|
||||
registers **24** tools, all named `rig_*`
|
||||
(`grep -c 'server.tool(' packages/cli/src/mcp-server.ts`), including
|
||||
`rig_rig_nodes` (`mcp-server.ts:290`), `rig_send` (`:307`),
|
||||
`rig_capture` (`:341`), `rig_chatroom_send` (`:366`) and `rig_chatroom_watch`
|
||||
(`:402`), the five queue tools `rig_queue_create`, `_show`, `_list`, `_claim`
|
||||
and `_update`, and `rig_telemetry_events`. No `rigged_*` name other than the tmux keys below remains in product
|
||||
**Axis 1 — MCP tool names are `rig_*`.** Every tool
|
||||
`packages/cli/src/mcp-server.ts` registers is named `rig_*`, among them
|
||||
`rig_rig_nodes`, `rig_send`, `rig_capture`, `rig_chatroom_send`,
|
||||
`rig_chatroom_watch`, the `rig_queue_*` tools and `rig_telemetry_events`.
|
||||
Count them with `grep -c 'server.registerTool(' packages/cli/src/mcp-server.ts`;
|
||||
each registration depends on the caller's access, so a caller can see fewer. No `rigged_*` name other than the tmux keys below remains in product
|
||||
source: **0** hits
|
||||
(`git grep -n 'rigged_' -- ':(glob)packages/*/src/**' | grep -v -c '@rigged_'`).
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@ last-updated: 2026-11-09
|
||||
|
||||
# CLI Reference — Registered Commands and Options
|
||||
|
||||
Verified against source commit `e1dedbf531b6db9961b4c77c0a74564e58274a50`.
|
||||
Verified against the source commit in this page's `last-verified-against-source`
|
||||
frontmatter.
|
||||
The inventory below comes from the actual Commander tree returned by
|
||||
[`createProgram()`](../../packages/cli/src/index.ts), not a grep of command
|
||||
strings or an installed CLI from a different commit.
|
||||
|
||||
@@ -75,20 +75,20 @@ in the rig's `attentionCount` and the TUI's attention totals.
|
||||
After a full restore of a Claude or Codex seat, answering the prompt is enough. When
|
||||
the seat's activity next turns to running or idle, or when the whole restore completes
|
||||
after the seat already reads running or idle, the daemon checks the live seat: its tmux
|
||||
session is alive, and the pane runs the agent resuming the conversation the seat
|
||||
records. When the check passes, the restore outcome becomes `operator_recovered`, so
|
||||
the restore no longer makes the seat need attention. When it fails, the restore outcome
|
||||
stays and the seat still needs attention, which is true when the pane runs another
|
||||
conversation. A check that started before a newer restore of the seat doesn't settle
|
||||
the newer outcome.
|
||||
session is alive, and the agent in its pane was started to resume the conversation the
|
||||
seat records. When the check passes, the restore outcome becomes `operator_recovered`, so
|
||||
the restore no longer makes the seat need attention. When the check fails, the restore
|
||||
outcome stays and the seat still needs attention. The check uses launch evidence; it
|
||||
does not prove which conversation is open now.
|
||||
|
||||
`rig seat clear-attention <seat>` runs the same check on demand. When it fails, the
|
||||
command says which check failed, and `--reason` doesn't bypass it. If
|
||||
the seat records the wrong conversation and you know the right one, set it with
|
||||
`rig seat set-resume-token <seat> --token-stdin --reason <text>`, then run
|
||||
`clear-attention` again. After a restore of only some seats, or a community
|
||||
harness's assisted startup, `--reason "<what you saw>"` can acknowledge the
|
||||
outcome instead.
|
||||
command says which check failed, and `--reason` doesn't bypass it. If the seat records
|
||||
the wrong conversation and the agent in its pane was started with the right one, set it
|
||||
with `rig seat set-resume-token <seat> --token-stdin --reason <text>`, then run
|
||||
`clear-attention` again; a token the running agent wasn't started with fails the same
|
||||
check. After a restore of only some seats, or a community harness's assisted startup,
|
||||
`--reason "<what you saw>"` can acknowledge the outcome instead, once any startup text
|
||||
left in the input box is submitted (not sent again) and the seat has responded.
|
||||
|
||||
## Two Categories of Startup
|
||||
|
||||
@@ -278,7 +278,10 @@ with an old Claude header or input frame above it and Claude already running. A
|
||||
input frame also keeps the check pending. If the current screen or process cannot be read, or the process identity is unconfirmed, the handover keeps
|
||||
waiting within the readiness window and fails if that window expires. Old screen text or the `claude` process label
|
||||
alone cannot complete this check. A healthy agent can therefore remain unconfirmed while those observations are
|
||||
unavailable; inspect its pane before deciding how to recover.
|
||||
unavailable; inspect its pane before deciding how to recover. When the launch was already sent, the failure's
|
||||
guidance names that pane and the result carries `replacementStarted` and `successorPane`: the previous agent was
|
||||
already ended, the new one may be working there without being registered as the seat's occupant, and another
|
||||
handover or `rig seat launch <seat> --fresh --stop --reason <why>` ends it if it goes ahead.
|
||||
|
||||
**Claude submission check.** For each startup message to a Claude seat, OpenRig makes a bounded check that the prompt
|
||||
was submitted. When the capture can't tell, it records the submission as unverified and carries on. When it sees the
|
||||
|
||||
@@ -84,11 +84,23 @@ so its rate-limit hook is attributed to that seat too. This is appropriate when
|
||||
the helper uses the same login, since the quota belongs to the account. A
|
||||
helper on a different login is attributed the same way, so its rate limit can
|
||||
hold pending handoffs to every seat in that seat's Claude pool for one retry
|
||||
interval, even when the seat's own login still has room. This attribution does
|
||||
not bind the hook to Claude's own native session.
|
||||
interval, even when the seat's own login still has room. A reset time the
|
||||
helper's message states is not used: OpenRig keeps a stated reset only when the
|
||||
hook's Claude `session_id` is the seat's own resume token, so a helper never
|
||||
holds the pool past that one interval.
|
||||
|
||||
The event carries no usage percentage, account identity or reset timestamp.
|
||||
It marks the Claude provider pool as limited and preserves a current
|
||||
The event carries no usage percentage or account identity. When the seat's own
|
||||
Claude states when the limit resets (for example `resets Nov 10, 4pm
|
||||
(America/Sao_Paulo)`, `resets in 2h 30m`, or the older `usage limit
|
||||
reached|<epoch>`), the hook relay reads that time on the seat's machine and
|
||||
forwards only the resulting timestamp (`resetsAt`), never the message text. It
|
||||
reads the reset only after the typed `rate_limit` failure; the text never
|
||||
decides that a seat is limited. A reset it can't read completely (an unknown or
|
||||
truncated time zone, an unsupported unit, a date that doesn't exist such as Feb
|
||||
30) or that is in the past or more than eight days away gives no reset. The
|
||||
daemon applies the same eight-day bound to any sender.
|
||||
|
||||
The event marks the Claude provider pool as limited and preserves a current
|
||||
`agentActivity.reason` of `usage_limit` over a cached idle reading. It does not
|
||||
count as a request for human input; a newer permission prompt still takes
|
||||
precedence in the seat's activity display. Conflicting seat windows retain the
|
||||
@@ -99,13 +111,15 @@ Pending work handled by the wake ladder uses the existing local Claude pool
|
||||
declare different Claude config directories. If reset windows conflict within
|
||||
a proposed shared pool, only the seats reporting exhaustion are held; another
|
||||
seat's pending handoffs can still retry. An exhausted status line window with
|
||||
a known reset supplies the timer. Otherwise a typed failure uses
|
||||
a known reset supplies the timer, and so does a reset the seat's own typed
|
||||
failure stated (`provider-reset`). Otherwise a typed failure uses
|
||||
`queue.wake_retry_interval_seconds` (default 300) from the event time, plus the
|
||||
existing wake jitter, and records `config-fallback`. That is a retry time, not a
|
||||
claim that the provider's quota will reset then. The timer releases the shared
|
||||
blocker once and retains the existing durable wake delivery behavior.
|
||||
|
||||
Authentication, billing and overloaded errors are not classified as usage
|
||||
limits. Claude limit banners are not scraped, and older runtimes or failures
|
||||
that do not emit this hook still depend on status line data. Without either
|
||||
limits. Claude limit banners are not scraped from the terminal, and older
|
||||
runtimes or failures that do not emit this hook still depend on status line
|
||||
data. Without either
|
||||
source, provider usage remains unknown.
|
||||
@@ -21,7 +21,7 @@ it never reads an unsupported capability as idle, working, ready or verified.
|
||||
| Activity | partly | Read from the screen only; there are no activity hooks. Work and attention the screen shows are kept, but a screen that looks idle reads `unknown`, never idle, because a quiet prompt can be an approval or a pause. A [runner preset](#runner-presets-runner-true)'s own reports decide it instead. |
|
||||
| Attention | partly | Read from the screen: a permission question or numbered choice that OpenRig recognizes reads as needing attention, and `rig send` won't type into it. A prompt it doesn't recognize reads `unknown`. |
|
||||
| Approval | no | Approvals stay in the harness's own pane. OpenRig relays none, so a person answers them there. A [runner preset](#runner-presets-runner-true) is the exception: its runner submits the agent's permission requests to OpenRig, which asks a person, and the default is permit (section 7 of the [harness and runner contract](harness-runner-contract.md)). |
|
||||
| Cancel | no | OpenRig has no verb that cancels a running turn. `rig seat stop` and `rig down` end the seat's tmux session; anything the harness runs outside its pane, such as a gateway, is its own, and the preset's `limits` should say what keeps running. Restarting in place and switching harness are refused. |
|
||||
| Cancel | no | OpenRig has no verb that cancels a running turn. A [runner preset](#runner-presets-runner-true) whose runner follows the contract takes `rig send <seat> "/abort" --raw` as its cancel, but not while the seat needs input, and only as a request (see [Cancel](harness-runner-contract.md#6-cancel-requested-isnt-the-same-as-done)). To a plain preset, `/abort` is ordinary input. Say in the preset's `limits` whether the harness has a cancel and how to use it. `rig seat stop` and `rig down` end the seat's tmux session; anything the harness runs outside its pane, such as a gateway, is its own, and the preset's `limits` should say what keeps running. Restarting in place and switching harness are refused. |
|
||||
| Transport | yes | The seat is a tmux pane like any other, so `rig capture`, `rig send` and `--host` work as they do for any seat. |
|
||||
|
||||
**Not placed:** guidance, skills and startup files (a launch warns that they weren't delivered), and the model: set it in the harness's own
|
||||
|
||||
@@ -44,7 +44,7 @@ in the case named:
|
||||
| `routeTimings` | With `eventLoop`. |
|
||||
| `slowOperations` | When the slow-operation recorder runs. |
|
||||
| `bind` | The bind plan: `mode` (`explicit` or `default`), `hosts` (the addresses bound), `tailscaleDetected`, and `ignoredRoutingHost` when a routing value was ignored. |
|
||||
| `stuckSweep`, `wakeLadder` | The heartbeats of the stuck sweep and the wake ladder, when they run. |
|
||||
| `stuckSweep`, `wakeLadder` | The heartbeats of the stuck sweep and the wake ladder, when they run. In `stuckSweep`, `findingsRouted` counts the findings created since the daemon started (an open finding a later pass finds again is not counted again), `findingsOpen` is how many finding rows were still open after the last pass, and `lastOutcome` is `findings` when the last pass created, found again or closed one. |
|
||||
|
||||
## Bearer tokens
|
||||
|
||||
|
||||
@@ -866,6 +866,9 @@ What it costs:
|
||||
## Stop your teams
|
||||
|
||||
`rig down <rig-name>` ends that rig's agent sessions and work in progress.
|
||||
A terminal OpenRig didn't start, such as a seat joined with `rig attach --self`,
|
||||
is left running: `rig down` names it with the commands to stop it by hand, and
|
||||
exits 2.
|
||||
Read `rig ps --nodes --rig <rig-name>` first so you can see who will stop;
|
||||
`rig down` also prints the rig's recorded agent sessions and their activity
|
||||
before stopping them.
|
||||
@@ -895,7 +898,7 @@ the lifecycle API without booting a new kernel, then stop the remaining rigs.
|
||||
| --- | --- |
|
||||
| Tool missing or login fails | Use the specific setup/auth hint; recheck that executable in the launch shell. Do not send work to an unready seat. |
|
||||
| "Startup attention" or "Startup details" names a prompt | Answer the login, trust or bypass prompt in that seat's terminal, then run the `rig seat continue <seat>` it shows. |
|
||||
| A restored seat still counts as needing attention after its prompt was answered | Its restore outcome is still `attention_required`, and answering the prompt or running `rig seat continue` doesn't change it. Once the agent is up in its pane, run `rig seat clear-attention <seat>`; see [After a restored seat's prompt](agent-startup-guide.md#after-a-restored-seats-prompt). |
|
||||
| A restored seat still counts as needing attention after its prompt was answered | After a full restore of a Claude or Codex seat, the daemon clears this by itself once the seat reads running or idle and passes its check. If it still counts, check its `restoreOutcome` in `rig ps --nodes --json` (the RESTORE column): when it is `attention_required`, run `rig seat clear-attention <seat>`, which says which check failed; answering the prompt again or running `rig seat continue` doesn't change it. For a partial restore, submit any startup text left in the input box without sending it again, and wait for the agent to respond before clearing attention. See [After a restored seat's prompt](agent-startup-guide.md#after-a-restored-seats-prompt). |
|
||||
| "Readiness timeout after 30s — harness did not become interactive" | The harness took longer than the readiness window. Raise it for the next launch with `rig config set runtime.readiness_timeout_seconds <1-600>`, then relaunch that seat. |
|
||||
| "Claude resume did not finish within Ns; Claude may still be starting, so the launch was kept" | A restored Claude seat was not interactive when the wait ended. Its pane and resume token are kept. Look at the pane; once Claude is up, run `rig seat clear-attention <seat>`. The wait is about `runtime.readiness_timeout_seconds` in total for each seat (default 30 s), counting the first 3.2 s, and the time adds up seat by seat in a restore. Raising the setting for slow fresh launches lengthens it. If `rig up` or `rig launch` times out at 120 s, that is not a rollback: check `rig ps` before running it again. |
|
||||
| Daemon is healthy, kernel is still starting | Read `rig status` and `rig ps --nodes --rig kernel`; kernel readiness is separate. |
|
||||
|
||||
@@ -241,8 +241,15 @@ permission or input handling in existing built-in runtimes or require every poss
|
||||
|
||||
- **On `/abort` or Ctrl-C:** send the agent's cancel (for ACP, the `session/cancel` notification, which has no reply),
|
||||
and record when you sent it.
|
||||
- A person cancels by typing `/abort` in the seat's pane. A `/abort` sent with `rig send` arrives inside a From/To
|
||||
envelope, so it's an ordinary prompt.
|
||||
- **Sending it:** a person types `/abort` in the seat's pane. From another seat or a script,
|
||||
`rig send <seat> "/abort" --raw` pastes exactly `/abort`, with no From/To envelope, and presses Enter, so a runner
|
||||
that follows section 5 gets the control. 0.7.8's native runs typed it in the pane; a raw send hasn't been run
|
||||
natively. An ordinary `rig send` wraps its text in a From/To envelope, so there `/abort` is an ordinary prompt.
|
||||
- **Not while the seat needs input:** a raw send is still refused while the seat reads needs input
|
||||
(`target_needs_input`), for example while the runner holds a permission request for a person (section 7). Type
|
||||
`/abort` in the pane then.
|
||||
- **A sent `/abort` is a request.** A typed control can't name the turn it was meant for: the runner cancels whatever
|
||||
turn is running when it reads it. Whether a turn was cancelled is the runner's `Stop` / `cancelled` below.
|
||||
- **Report "cancelled" only when the agent says so:** for ACP, when `session/prompt` returns `stopReason: "cancelled"`.
|
||||
Then post `Stop` / `cancelled`.
|
||||
- **Whether the provider stopped work or spend is unknown.** Don't claim it.
|
||||
|
||||
+19
-15
@@ -54,22 +54,22 @@ What an open does, in order:
|
||||
1. Each viewer is recorded first (`$OPENRIG_HOME/private/herdr-app/clients/`), then started by herdr-core outside
|
||||
every tab (`hc run --bg --no-parent`, labelled `openrig.*`). A local viewer runs the view's tile for its seat, as in
|
||||
[terminal sessions](terminal-sessions.md): it shows the seat. An interactive cell starts with the read-only tile and
|
||||
takes no input until herdr-app has shown the view (step 3); then its writable tile takes over, and the first thing
|
||||
you type attaches it. A read-only cell only shows. A seat on another host opens as its own ssh attachment, writable
|
||||
takes no input until herdr-app has shown the view (step 3); then its writable tile takes over and attaches to the
|
||||
seat with plain tmux. A read-only cell only shows. A seat on another host opens as its own ssh attachment, writable
|
||||
or read-only as that view composes it. If the seat's session ends, the viewer says so and waits; it never leaves a
|
||||
shell in the cell.
|
||||
2. One atomic write of `layouts/rig.json`: your current layout, with OpenRig's stack beside your tab block. Your own
|
||||
layout file is never written. A `rig.json` OpenRig didn't write is never overwritten.
|
||||
3. The Rig layout is shown, the view is selected once herdr-app confirms it shows the new file, and the keyboard
|
||||
goes to the first live cell. Once herdr-app has acknowledged the layout with the view, OpenRig writes the gate file
|
||||
of each of the view's interactive cells, on every page (including pages the strip hasn't shown yet, since the strip
|
||||
moves between pages on its own), and each cell takes input a fraction of a second later (it checks every 0.2 s).
|
||||
Anything that reached a cell before that, typed or written to its terminal (`hc send`), is dropped, not delivered
|
||||
late. The open reports a view herdr-app
|
||||
hasn't confirmed as unconfirmed, not opened; its viewers keep running and it stays in the Rig layout, and its cells
|
||||
take input once herdr-app shows it. OpenRig keeps watching for 10 minutes, across a daemon restart; if herdr-app
|
||||
hasn't shown it by then, each interactive cell says so, takes no input, and names the command that opens the view
|
||||
again (`rig terminal open <view> --provider herdr-app`).
|
||||
of each local interactive cell, on every page (including pages the strip hasn't shown yet, since the strip moves
|
||||
between pages on its own). These cells take input a fraction of a second later (they check every 0.2 s). Anything
|
||||
that reached a local interactive cell before that, typed or written to its terminal (`hc send`), is dropped, not
|
||||
delivered late. Remote cells run their ssh attachments from the start and don't wait for this gate. The open
|
||||
reports a view herdr-app hasn't confirmed as unconfirmed, not opened; its viewers keep running and it stays in the
|
||||
Rig layout, and its local interactive cells take input once herdr-app shows it. OpenRig keeps watching for 10
|
||||
minutes, across a daemon restart; if herdr-app hasn't shown it by then, each local interactive cell says so, takes
|
||||
no input, and names the command that opens the view again (`rig terminal open <view> --provider herdr-app`).
|
||||
|
||||
Other layouts and other herdr-app windows don't show Rig views. Each window (app home) has its own.
|
||||
|
||||
@@ -79,11 +79,15 @@ Other layouts and other herdr-app windows don't show Rig views. Each window (app
|
||||
- Entering a view again (a click in the strip, or herdr-app restarting with it shown) returns the keyboard to the cell
|
||||
you were last in there, else the first live cell.
|
||||
- **Your tabs** gives the keyboard back to your own pane.
|
||||
- OpenRig's own rewrites (another view opening or closing, a cell ending) never move the keyboard, whether you are in
|
||||
a view or in your tabs.
|
||||
- Typing in an interactive cell, once the view is shown, attaches its tile to the seat; after 30 seconds without a key
|
||||
it goes back to showing it. When the seat's session is replaced (a handover, a fresh launch, a restart), the tile follows the same seat to
|
||||
its new session, as every OpenRig tile does.
|
||||
- Apart from an open, OpenRig's own rewrites (another view closing, a cell ending) leave the keyboard where it is,
|
||||
whether you are in a view or in your tabs, with one exception: when the cell you were last in ends while its view is
|
||||
shown, the keyboard goes to that view's first live cell, if one is left.
|
||||
- Once the view is shown, an interactive cell for a local seat attaches its tile to the seat with plain tmux, as a
|
||||
writable tile does ([terminal sessions](terminal-sessions.md#replacing-a-seats-session)). Until then it only shows
|
||||
the seat, and anything typed in it is dropped. A cell for a seat on another host is its ssh attachment from the
|
||||
start, so an interactive one takes input as soon as it runs. When the seat's session is replaced (a handover, a fresh
|
||||
launch, a restart), the tile follows the same seat to its new session, except an ssh tile on an isolated local tmux
|
||||
server ([terminal sessions](terminal-sessions.md#replacing-a-seats-session)).
|
||||
|
||||
## What OpenRig keeps in the app home
|
||||
|
||||
|
||||
@@ -78,14 +78,17 @@ entry. The entry holds secret names, never values:
|
||||
- It's a pointer, not the row's body: the member reads the row under its grant.
|
||||
- A receiver should tolerate a repeated `qitem_id`. A ring accepted just before the daemon stops, before it's recorded,
|
||||
is rung again on recovery, the same at-least-once window a terminal wake has.
|
||||
- It waits up to 10 seconds and doesn't follow redirects.
|
||||
- It waits up to 10 seconds and doesn't follow redirects. A ring with no answer by then may still have reached the
|
||||
trigger, so it reads unknown, not failed, and durable recovery doesn't repeat that recorded wake (other recovery paths
|
||||
can still ring it again). The member sees the work when it next checks its rows.
|
||||
- **What the trigger should do:** start a run that lists the member's rows (`rig_queue_list` with `owned`) and claims its work.
|
||||
|
||||
## What the record says, and doesn't
|
||||
|
||||
These facts stay separate on the row:
|
||||
- **Wake:** with a trigger, the row's wake result is `wake-accepted:webhook (<url_ref>) answered <status>` when the trigger took the
|
||||
ring, or `failed:webhook …`. Accepted means the tool's trigger answered, not that the member read the row.
|
||||
ring, `indeterminate:webhook …` when it didn't answer in time, or `failed:webhook …`. Accepted means the tool's trigger
|
||||
answered, not that the member read the row.
|
||||
- With no trigger, the result is `unroutable`. The member finds work by reading its own inbox and picks a row up by claiming
|
||||
it, so a row gets no finding of its own. Work it leaves unclaimed past the stuck sweep's unclaimed age
|
||||
(`queue.stuck_sweep_unclaimed_age_minutes`, 60 by default) is listed in one standing notice per member. The notice goes to
|
||||
|
||||
@@ -296,3 +296,31 @@ Unknown: not said yet.
|
||||
delivery guarantee.
|
||||
- **Transcript bytes as "usage".** `context-usage-threshold` measures an agent's transcript, not an account's
|
||||
allowance. For an allowance, see "A usage warning" above.
|
||||
|
||||
## Recurring reminders for agents
|
||||
|
||||
An agent-to-agent reminder can repeat until stopped. This is separate from
|
||||
[a person's one-shot watch](#setting-a-watch), which uses `rig queue block --wake-after`.
|
||||
|
||||
Save this as `periodic-reminder.yaml`, replacing the example session names with your own:
|
||||
|
||||
```yaml
|
||||
policy: periodic-reminder
|
||||
target:
|
||||
session: worker@my-team
|
||||
message: "Finish the current action, then check the pending work."
|
||||
```
|
||||
|
||||
Register it with all five required flags:
|
||||
|
||||
```bash
|
||||
rig watchdog register --spec periodic-reminder.yaml --policy periodic-reminder \
|
||||
--target-session worker@my-team --interval-seconds 600 \
|
||||
--registered-by coordinator@my-team
|
||||
```
|
||||
|
||||
At registration, `--policy` selects the policy; the YAML `policy` field is not the
|
||||
selector. YAML `target.session` takes precedence over `--target-session`, so keep them
|
||||
aligned. If YAML omits the target, the CLI target is the fallback. Put a non-empty
|
||||
`message` in YAML: `--message` is a shortcut only for `context-usage-threshold`.
|
||||
The first reminder is due after the interval; this example repeats every 600 seconds.
|
||||
@@ -0,0 +1,114 @@
|
||||
# Network connector: a local seat as a hub participant
|
||||
|
||||
Source of truth: `packages/daemon/src/domain/network/` (`network-bindings.ts`, `network-connector.ts`, `hub-client.ts`),
|
||||
`packages/daemon/src/routes/network.ts`, `packages/cli/src/commands/network.ts`.
|
||||
|
||||
A hub is another OpenRig queue that its participants reach over MCP, such as a daemon served by `rig mcp serve --http`
|
||||
with one grant per participant. The network connector puts one selected local seat on a hub as one of its participants.
|
||||
The connector opens no port: your daemon makes every connection outward, and the hub never connects to your machine. Work the hub
|
||||
sends that participant runs on your seat, under your accounts, and the result goes back to the hub item.
|
||||
|
||||
## Connect
|
||||
|
||||
```bash
|
||||
rig network connect <seat> --hub <url> --as <participant> --token-file <path>
|
||||
```
|
||||
|
||||
- `<participant>` is the participant's address in the hub (`member@rig`), and the token file holds its bearer.
|
||||
- The binding follows the seat's node, so a relaunched or renamed seat keeps it.
|
||||
- The token is stored at `<OpenRig home>/network-secrets/<binding>/credential.json` (a 0700 folder and a 0600 file). It
|
||||
is never returned by the API or shown in status. A token in the hub URL is refused.
|
||||
- A plain-http hub URL needs `--allow-http` (see Limits).
|
||||
- One active binding per local seat, and one per hub participant.
|
||||
- On a daemon with an operator bearer configured, `connect` and `disconnect` send `OPENRIG_AUTH_BEARER_TOKEN`, as the
|
||||
`rig host` writes do.
|
||||
|
||||
## What the connector does
|
||||
|
||||
The daemon runs the connector for every active binding. It normally polls every 30 seconds
|
||||
(`OPENRIG_NETWORK_POLL_SECONDS`, at least 5); a failing hub, backoff or a backlog can delay it. Each binding's pass has
|
||||
a deadline of 60 seconds, so one slow hub doesn't hold the others. With no active binding it makes no network call.
|
||||
|
||||
1. It lists the participant's own items on the hub, claims each pending one, and creates a local queue item for the
|
||||
seat's current address. The local item comes from OpenRig itself, and it says where the work came from first: its
|
||||
summary starts "From <requester> via network hub", and its body opens with the requester, the hub and the hub item,
|
||||
before the requester's own words.
|
||||
2. It reports each stage on the hub item, kept apart:
|
||||
- **Queued:** the hub claim means the connector holds the item. The note says "Not picked up yet."
|
||||
- **Picked up:** once the seat claims the local item.
|
||||
- **The result:** once the local item closes, following any local handoffs to the item that finished. The hub item
|
||||
closes the way its queue closes one: done with the resolution and the closure reason (`no-follow-on`, `canceled`
|
||||
or `denied`), or failed when the local item failed. A close with no resolution sends the closer's own note on that
|
||||
close, or "Closed without a stated result." No other note on the item is sent.
|
||||
|
||||
To answer, close the local item with your result:
|
||||
|
||||
```bash
|
||||
rig queue update <local item> --state done --closure-reason no-follow-on --resolution "<your answer>"
|
||||
```
|
||||
|
||||
## Retries, restarts and unknown outcomes
|
||||
|
||||
- Each hub item maps to one local item, under an id derived from the seat's node, the hub, the participant and the hub
|
||||
item. A lost answer, a retry, a daemon restart, or disconnecting and connecting the same seat to the same hub and
|
||||
participant again lands on that same item, so it isn't queued twice. A new binding reports what the earlier one
|
||||
took.
|
||||
- If the local record of an item is gone, or it was handed on without one successor here, the hub item says its outcome
|
||||
is unknown, and it is never run again.
|
||||
- If someone else closed the hub item first (its requester canceled it, say), the local result stays here, the connector
|
||||
doesn't count it as delivered, and `rig network status` (and its `--json`) shows it under the binding as closed
|
||||
elsewhere. The connector checks the hub item just before closing it. It does not write over a close it has already
|
||||
seen, and reads the item back after a close attempt. A hub item someone there handed on counts as closed elsewhere
|
||||
too.
|
||||
- The derived id makes the queue item unique, not the work's outside effects. Anything your agent did outside OpenRig
|
||||
before a crash is neither undone nor deduplicated.
|
||||
|
||||
## Status
|
||||
|
||||
```bash
|
||||
rig network status [--json]
|
||||
```
|
||||
|
||||
Each binding shows its seat, participant, hub, state, and its last contact or last error. `--json` adds the
|
||||
connector's view: each binding's next pass, backoff and last pass.
|
||||
|
||||
## When things go wrong
|
||||
|
||||
- **The seat has no session:** its hub items stay pending and unclaimed, and the last error says why. They're taken
|
||||
once the seat has a session again. A stopped seat still has its session, so it still takes items; they wait for it.
|
||||
- **The hub can't be reached, or a pass runs past its deadline:** the binding stays active, the error is recorded, and
|
||||
retries back off, doubling up to 5 minutes. A failure on this machine is recorded as local, not as the hub's.
|
||||
- **The hub refuses the credential** (HTTP 401): the binding is marked revoked and its credential removed. No new work
|
||||
comes in; local items and results stay.
|
||||
|
||||
## Disconnect
|
||||
|
||||
```bash
|
||||
rig network disconnect <binding-or-seat> [--reason "<why>"]
|
||||
```
|
||||
|
||||
The binding ends here, so no new work comes in through it, and its credential is removed. Local items and results
|
||||
stay. The hub still lists the participant until it's removed there too.
|
||||
|
||||
If the credential can't be removed, the binding still ends, and the error names the
|
||||
`rig network disconnect <binding id>` to run again. The daemon also removes credentials whose binding isn't active
|
||||
each time it starts.
|
||||
|
||||
## Limits
|
||||
|
||||
- The connector polls, so new work normally waits up to one interval before it reaches the seat, and longer when the
|
||||
hub fails or a backlog builds.
|
||||
- If someone else closes a hub item between the connector's check and its close, the hub may keep either outcome, or
|
||||
combine the later state with the earlier resolution. The connector counts its result as delivered only when the
|
||||
readback matches its state, closure reason and resolution; otherwise a closed item is reported as closed elsewhere.
|
||||
An atomic close-if-open needs the hub to offer a conditional close.
|
||||
- After a disconnect or a revocation, work already taken keeps running here, but its pickup and result no longer reach
|
||||
the hub item, which stays at its last reported stage.
|
||||
- **Plain http needs your choice.** `rig network connect` refuses an `http:` hub URL until you add `--allow-http`, because
|
||||
the participant's token and its work then cross the network unencrypted, where they can be read. Use the hub's
|
||||
`https:` URL where it has one. The choice is recorded on that binding, so the connector doesn't ask again, and
|
||||
`rig network status` (and its `--json`) marks the binding unencrypted. OpenRig doesn't judge the address: a private or
|
||||
tailnet address doesn't prove the connection is protected.
|
||||
- **Redirects:** the connector follows a hub's redirect only within its own origin, or to its `https:` form on the same
|
||||
host. Any other redirect stops the connection with its target named, and the token isn't sent there. Connect the
|
||||
binding to the hub's own URL.
|
||||
@@ -1088,6 +1088,20 @@ and returns `edge_not_found` (HTTP 404, exit 1), so a repeated removal reports n
|
||||
Rig names resolve only among unarchived rigs, and an ambiguous name requires an
|
||||
explicit ID. These commands target the local daemon, as `rig add` does.
|
||||
|
||||
Managed Claude compaction also uses the delegation parent for two diagnostic notices: repeated
|
||||
unsent preparation attempts and repeated restore-delivery failures. If the seat has no recorded
|
||||
parent session (including when it is outside the recorded topology), these notices go to the
|
||||
configured registered human operator, or the default registered person when no operator is set.
|
||||
If no registered person can be selected, OpenRig creates no notice rather than inventing a
|
||||
destination: `rig compact <seat> --state` reports a restore-delivery notice failure, while a
|
||||
preparation notice is tried again on later polls. A parent is the source of a `delegates_to` edge
|
||||
to the seat; declare one when an agent should receive these notices. With multiple parents, the
|
||||
parent session with the latest creation time is selected; `escalates_to` is not used for these
|
||||
notices. The restore-delivery notice to a person omits local paths and
|
||||
points to `rig compact <seat> --state` on the host; the preparation notice includes its state-file
|
||||
evidence reference and attempt details. Treat these notices and any chat relay accordingly. Existing
|
||||
notices retain their destination when the topology changes.
|
||||
|
||||
A `delegates_to` edge also decides routing. A seat's stuck-row findings and recovery go to its most
|
||||
recently started parent. A person's top-level Slack message in the rig's mapped channel goes to the rig's
|
||||
representative, its one delegation root, unless that channel names an `inbound` seat; with no single root it
|
||||
|
||||
@@ -74,7 +74,11 @@ 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 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.
|
||||
In-place handover and restart keep showing the same session. The exception is
|
||||
an SSH tile when the local tmux server is isolated: it runs
|
||||
`rig terminal attach` on the seat's host (`--read-only` for a read-only tile),
|
||||
which attaches with tmux once (`tmux attach -r` for a read-only tile). It
|
||||
doesn't follow a replaced session or a restarted tmux server; reopen the view.
|
||||
|
||||
Writable tiles attach immediately, as in 0.7.10. Their visible terminal sizes
|
||||
the agent's PTY under tmux's normal client sizing policy, and resizing the
|
||||
@@ -362,20 +366,25 @@ labels are not that provenance. Other providers/platforms remain unsupported
|
||||
for ensure-viewer until qualified; ordinary open retains its existing behavior.
|
||||
|
||||
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
|
||||
left part of each line. So a read-only 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 read-only 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 has no scrollback of its
|
||||
own. (A read-only SSH tile on an isolated local server attaches read-only
|
||||
instead; see [Replacing a seat's session](#replacing-a-seats-session).) A
|
||||
writable tile attaches with plain tmux when it opens (see
|
||||
[Replacing a seat's session](#replacing-a-seats-session)): keys and paste go
|
||||
through tmux as in any attached client, tmux's prefix works (prefix then `[` to
|
||||
scroll back, for example), and the tile sizes the window while it's the active
|
||||
client. 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 are
|
||||
`tmux attach -t` for a writable tile and `tmux attach -r -t` for a read-only
|
||||
one, with a local seat's tmux server (`-L <name>` or `-S <path>`) when it
|
||||
isn't tmux's default socket. For a seat on another host they run over `ssh -t`, and
|
||||
when the local tmux server is isolated, as `rig terminal attach` on that host.
|
||||
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
|
||||
|
||||
@@ -109,7 +109,10 @@ To move a team that runs on an older server:
|
||||
shares your default server, which runs the new version only after every session
|
||||
on it, yours included, has ended.
|
||||
3. Stop each rig deliberately with `rig down <rig>`. Its seats' sessions end, and a
|
||||
turn in progress stops. Queue rows, rigs and seats stay as they are.
|
||||
turn in progress stops. Queue rows, rigs and seats stay as they are. A session
|
||||
OpenRig didn't start, such as a seat joined with `rig attach --self`, is left
|
||||
running and `rig down` exits 2; stop it with the commands it prints, or it stays on
|
||||
the old server.
|
||||
4. Restart the daemon with `OPENRIG_TMUX_CONTROL=1` in its environment.
|
||||
`rig terminal status --json` should show `migrationPending: false`. If it's still
|
||||
`true`, `blockingSessions` names the sessions still on the old server.
|
||||
@@ -164,8 +167,8 @@ or removing its state when isolation or a deferred migration pins it. Ordinary
|
||||
an old advisory socket is not used to redirect it. Cleanup kills only registered
|
||||
session names on the selected server.
|
||||
|
||||
Dedicated selection requires POSIX tmux. On Windows, psmux retains its existing
|
||||
server behavior; requesting `isolated` produces a status note and a pending choice.
|
||||
Dedicated selection requires POSIX tmux. On native Windows (not a supported platform;
|
||||
the [README](../../README.md) covers WSL2), psmux retains its existing server behavior; requesting `isolated` produces a status note and a pending choice.
|
||||
|
||||
## Two instances on one machine
|
||||
|
||||
|
||||
Generated
+1
@@ -7341,6 +7341,7 @@
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.0",
|
||||
"@hono/node-ws": "^1.3.1",
|
||||
"@modelcontextprotocol/sdk": "^1.32.2",
|
||||
"better-sqlite3": "^13.0.3",
|
||||
"hono": "^4.7.0",
|
||||
"smol-toml": "^1.8.0",
|
||||
|
||||
@@ -79,6 +79,8 @@ Keys:
|
||||
ui.timezone TUI IANA timezone (default America/Los_Angeles; reopen TUI after changing)
|
||||
ui.enabled serve the web UI and its terminal WebSocket (default off; restart the daemon)
|
||||
ui.preview.* refresh_interval_seconds, max_pins, default_lines
|
||||
ui.terminal.max_live_terminals
|
||||
cap on simultaneously-live web-client terminals (effective default 2)
|
||||
recovery.* auto_drive_provider_prompts, provider_auth_env_allowlist
|
||||
agents.* advisor_session, operator_session
|
||||
feed.subscriptions.* action_required, approvals, shipped, progress, audit_log
|
||||
@@ -101,7 +103,7 @@ Keys:
|
||||
permission prompts still reach a person,
|
||||
choice prompts follow this setting; unavailable human delivery stays a visible row;
|
||||
an unreadable registry does not suppress eligibility; delivery still needs a ready route)
|
||||
retention.* enabled, transitions_days, watchdog_days,
|
||||
retention.* enabled, transitions_days, watchdog_days, usage_samples_days,
|
||||
watchdog_keep_per_job, batch_size
|
||||
retention.events_days delete the history-only event types (views, snapshots, watchdog firings, queue events) older
|
||||
than N days; 0 keeps them (default). Removed events no longer replay from /api/events, leave
|
||||
|
||||
@@ -24,9 +24,8 @@ import { existsSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { hostname as osHostname, userInfo } from "node:os";
|
||||
import { connect } from "node:net";
|
||||
import { execFile } from "node:child_process";
|
||||
import { getOpenRigHome } from "../openrig-compat.js";
|
||||
import { addHostEntry, updateHostEntry, removeHostEntry, defaultHostRegistryPath, loadHostRegistry, validateHostRegistry, hostDisplayTarget, resolveHost, resolveRemoteBearer, bearerAuthHeaders, type HostEntry, type HttpHostEntry, type SshHostEntry } from "../host-registry.js";
|
||||
import { addHostEntry, updateHostEntry, removeHostEntry, defaultHostRegistryPath, loadHostRegistry, validateHostRegistry, hostDisplayTarget, resolveHost, resolveRemoteBearer, bearerAuthHeaders, type HostEntry, type HttpHostEntry } from "../host-registry.js";
|
||||
import { runCrossHostCommand, type CrossHostResult } from "../cross-host-executor.js";
|
||||
import { recordHostObservation, loadHostBindings, describeBindingConflict, defaultHostBindingsPath, type HostObservationOutcome } from "../host-bindings.js";
|
||||
import { DaemonClient, operatorAuthHeaders } from "../client.js";
|
||||
@@ -168,32 +167,13 @@ function defaultDoctorDeps(): DoctorDeps {
|
||||
};
|
||||
}
|
||||
|
||||
/** Where ssh would connect for this host, from `ssh -G`, which reads ssh config without connecting.
|
||||
* Null when ssh can't resolve the target or a ProxyJump/ProxyCommand sits in between, since no
|
||||
* direct dial stands for that transport. */
|
||||
export function resolveSshEndpoint(host: SshHostEntry, timeoutMs = 1500): Promise<{ hostname: string; port: number } | null> {
|
||||
const args = [...(host.user ? ["-l", host.user] : []), "-G", host.target];
|
||||
return new Promise((resolve) => {
|
||||
execFile("ssh", args, { timeout: timeoutMs }, (error, stdout) => {
|
||||
if (error) return resolve(null);
|
||||
const conf = new Map(stdout.split("\n").map((line) => {
|
||||
const i = line.indexOf(" ");
|
||||
return [line.slice(0, i).toLowerCase(), line.slice(i + 1).trim()] as const;
|
||||
}));
|
||||
const proxied = ["proxyjump", "proxycommand"].some((k) => (conf.get(k) ?? "none") !== "none");
|
||||
const hostname = conf.get("hostname");
|
||||
const port = Number(conf.get("port"));
|
||||
resolve(hostname && Number.isInteger(port) && !proxied ? { hostname, port } : null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** FR-1 doctor legs — each failing step maps to a DISTINCT actionable error:
|
||||
* "SSH works but daemon is down" ≠ "daemon works but registry is wrong" ≠
|
||||
* "remote rig binary missing/old". */
|
||||
/** OPR.0.4.6.MH1 FR-3 — the coarse ls status word: a BOUNDED tcp dial
|
||||
* against the host's transport endpoint (ssh: the host and port ssh itself
|
||||
* resolves, see resolveSshEndpoint; http: the URL's host:port). Coarse by
|
||||
* against an HTTP host's URL host:port; passive SSH entries report
|
||||
* `unknown` without running `ssh` or opening a raw TCP socket (explicit SSH
|
||||
* verification lives in `rig host doctor`). Coarse by
|
||||
* design (the kubectl STATUS / docker context ls precedent): reachable |
|
||||
* unreachable | unknown — never a hang (hard timeout), never a guess (dial
|
||||
* failure = unreachable, probe error/unparseable endpoint = unknown). */
|
||||
@@ -203,17 +183,12 @@ export async function probeHostStatus(
|
||||
timeoutMs = 1500,
|
||||
): Promise<"reachable" | "unreachable" | "unknown"> {
|
||||
try {
|
||||
let target: string;
|
||||
let port: number;
|
||||
if (host.transport === "ssh") {
|
||||
const endpoint = await resolveSshEndpoint(host, timeoutMs);
|
||||
if (!endpoint) return "unknown";
|
||||
({ hostname: target, port } = endpoint);
|
||||
} else {
|
||||
const u = new URL(host.url);
|
||||
target = u.hostname.replace(/^\[|\]$/g, "");
|
||||
port = u.port ? Number(u.port) : u.protocol === "https:" ? 443 : 80;
|
||||
return "unknown";
|
||||
}
|
||||
const u = new URL(host.url);
|
||||
const target = u.hostname.replace(/^\[|\]$/g, "");
|
||||
const port = u.port ? Number(u.port) : u.protocol === "https:" ? 443 : 80;
|
||||
const r = await tcpProbe(target, port, timeoutMs);
|
||||
return r === "open" ? "reachable" : r === "closed" ? "unreachable" : "unknown";
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Command } from "commander";
|
||||
import { lstatSync, readFileSync } from "node:fs";
|
||||
import { DaemonClient, operatorAuthHeaders } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl, daemonStatusGuard, type LifecycleDeps } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
|
||||
export interface NetworkDeps {
|
||||
lifecycleDeps: LifecycleDeps;
|
||||
clientFactory: (url: string) => Pick<DaemonClient, "get" | "post">;
|
||||
readTokenFile?: (path: string) => string;
|
||||
}
|
||||
|
||||
interface Binding {
|
||||
id: string; localSession: string; hubUrl: string; hubSeat: string; state: string; stateReason: string | null;
|
||||
lastContactAt: string | null; lastError: string | null; unencrypted?: boolean;
|
||||
/** Hub items someone else closed before this binding's result reached them. */
|
||||
closedElsewhere?: Array<{ hubItem: string; localItem: string }>;
|
||||
}
|
||||
|
||||
/** The participant's token, read from the file the account gave the person. A file others can read is used, with a note. */
|
||||
function defaultReadTokenFile(path: string): string {
|
||||
const stat = lstatSync(path);
|
||||
if (!stat.isFile()) throw new Error(`${path} isn't a regular file.`);
|
||||
if ((stat.mode & 0o077) !== 0) console.error(`Note: ${path} can be read by other users of this machine; OpenRig keeps its own private copy.`);
|
||||
return readFileSync(path, "utf8").trim();
|
||||
}
|
||||
|
||||
function line(b: Binding): string {
|
||||
const contact = b.lastContactAt ? `last contact ${b.lastContactAt}` : "no contact yet";
|
||||
const state = b.state === "active" ? contact : `${b.state}${b.stateReason ? ` (${b.stateReason})` : ""}`;
|
||||
const plain = b.unencrypted ? " UNENCRYPTED (http, allowed for this binding)" : "";
|
||||
const conflicts = (b.closedElsewhere ?? []).map((c) => `\n hub item ${c.hubItem} was closed elsewhere before its result arrived; the result stays on ${c.localItem}`).join("");
|
||||
return `${b.id} ${b.localSession} -> ${b.hubSeat} at ${b.hubUrl}${plain} ${state}${b.lastError && b.state === "active" ? ` last error: ${b.lastError}` : ""}${conflicts}`;
|
||||
}
|
||||
|
||||
/** `rig network`: connect a selected local seat to a participant of a remote hub (an account's network rig, or
|
||||
* another OpenRig hub), see where each connection stands, and disconnect one. */
|
||||
export function networkCommand(depsOverride?: NetworkDeps): Command {
|
||||
const cmd = new Command("network")
|
||||
.description("Connect selected local seats to a remote hub, see their status, and disconnect them");
|
||||
const getDeps = (): NetworkDeps => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
|
||||
|
||||
const client = async () => {
|
||||
const deps = getDeps();
|
||||
const daemon = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (!daemonStatusGuard(daemon)) return null;
|
||||
return deps.clientFactory(getDaemonUrl(daemon));
|
||||
};
|
||||
const finish = (status: number, data: unknown, json: boolean | undefined, ok: (data: Record<string, unknown>) => string) => {
|
||||
if (status >= 400) {
|
||||
const error = data as { error?: string; message?: string };
|
||||
console.error(json ? JSON.stringify(data) : `${error.message ?? "The daemon refused this"} (${error.error ?? status})`);
|
||||
process.exitCode = status >= 500 ? 2 : 1;
|
||||
return;
|
||||
}
|
||||
console.log(json ? JSON.stringify(data, null, 2) : ok(data as Record<string, unknown>));
|
||||
};
|
||||
|
||||
cmd.command("connect")
|
||||
.description("Connect a local seat to a hub participant, using that participant's token")
|
||||
.argument("<seat>", "The local seat to connect (its address, member@rig)")
|
||||
.requiredOption("--hub <url>", "The hub's MCP URL")
|
||||
.requiredOption("--as <participant>", "The participant's address in the hub (member@rig)")
|
||||
.requiredOption("--token-file <path>", "A file holding the participant's token")
|
||||
.option("--allow-http", "Accept a plain-http hub for this binding: its token and work cross the network unencrypted")
|
||||
.option("--json", "Print JSON")
|
||||
.action(async (seat: string, opts: { hub: string; as: string; tokenFile: string; allowHttp?: boolean; json?: boolean }) => {
|
||||
let token: string;
|
||||
try { token = (getDeps().readTokenFile ?? defaultReadTokenFile)(opts.tokenFile); } catch (error) {
|
||||
console.error(`Can't read the token file: ${(error as Error).message}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const daemon = await client();
|
||||
if (!daemon) return;
|
||||
const res = await daemon.post<Record<string, unknown>>("/api/network/bindings", { session: seat, hubUrl: opts.hub, hubSeat: opts.as, token, allowHttp: opts.allowHttp === true }, { headers: operatorAuthHeaders() });
|
||||
finish(res.status, res.data, opts.json, (b) => `Connected ${line(b as unknown as Binding)}\nWork for ${opts.as} reaches ${seat} once the connector runs; rig network status shows its last contact.`);
|
||||
});
|
||||
|
||||
cmd.command("status")
|
||||
.description("List the connections and where each stands")
|
||||
.option("--json", "Print JSON")
|
||||
.action(async (opts: { json?: boolean }) => {
|
||||
const daemon = await client();
|
||||
if (!daemon) return;
|
||||
const res = await daemon.get<{ bindings: Binding[] }>("/api/network/bindings");
|
||||
finish(res.status, res.data, opts.json, (d) => {
|
||||
const bindings = (d.bindings ?? []) as Binding[];
|
||||
return bindings.length ? bindings.map(line).join("\n") : "No seats are connected to a hub.";
|
||||
});
|
||||
});
|
||||
|
||||
cmd.command("disconnect")
|
||||
.description("Disconnect a seat: no new work comes in through it; its local work and results stay")
|
||||
.argument("<binding-or-seat>", "The binding id, or the connected seat's address")
|
||||
.option("--reason <text>", "Why, for the record")
|
||||
.option("--json", "Print JSON")
|
||||
.action(async (ref: string, opts: { reason?: string; json?: boolean }) => {
|
||||
const daemon = await client();
|
||||
if (!daemon) return;
|
||||
const res = await daemon.post<Record<string, unknown>>(`/api/network/bindings/${encodeURIComponent(ref)}/disconnect`, { reason: opts.reason }, { headers: operatorAuthHeaders() });
|
||||
finish(res.status, res.data, opts.json, (b) => `Disconnected ${line(b as unknown as Binding)}\nRemove the participant on the hub too, so the account stops listing it.`);
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -1493,9 +1493,10 @@ Examples:
|
||||
.addHelpText("after", `
|
||||
Siblings are untouched. A dead seat is refused (use rig seat clean). An adopted
|
||||
session is refused: to end only it, run rig unclaim <session> (it releases the
|
||||
session and leaves its process running), then end that process in its own
|
||||
terminal. rig down <rig> ends every seat in its rig, so it is the route only
|
||||
for a one-seat rig.
|
||||
session and leaves its process running), then end that process and the
|
||||
underlying tmux session, including any remaining shell. Closing only its
|
||||
viewing window leaves the tmux session running. rig down <rig> leaves a
|
||||
session OpenRig didn't start running.
|
||||
A seat handed over (fresh, fork or rebuild) before OpenRig 0.7.9 still reads
|
||||
adopted and is refused too, until its next launched handover or managed launch;
|
||||
the refusal says so. Stopping ends that agent's session and any work in progress:
|
||||
|
||||
@@ -85,7 +85,7 @@ export function watchdogCommand(depsOverride?: WatchdogDeps): Command {
|
||||
"Context transcripts measured 113K–153K tokens/MB. The margin is the protection " +
|
||||
"because prompt-bound consumers act only at turn boundaries",
|
||||
)
|
||||
.option("--spec <path>", "Path to YAML spec file (optional for context-usage-threshold)")
|
||||
.option("--spec <path>", "YAML spec file (required except for context-usage-threshold)")
|
||||
.requiredOption("--policy <policy>", "Policy name (one of: periodic-reminder, artifact-pool-ready, edge-artifact-required, workflow-keepalive, idle-gate-qitem, context-usage-threshold)")
|
||||
.requiredOption("--target-session <session>", "Canonical <member>@<rig> target")
|
||||
.requiredOption("--interval-seconds <n>", "Evaluation interval (positive integer)")
|
||||
@@ -100,7 +100,27 @@ export function watchdogCommand(depsOverride?: WatchdogDeps): Command {
|
||||
.option("--active-wake-interval-seconds <n>", "(pool-ready-specific) wake-up cadence when actionable artifacts exist")
|
||||
.option("--scan-interval-seconds <n>", "(pool-ready-specific) artifact pool scan cadence")
|
||||
.option("--json", "JSON output for agents")
|
||||
.addHelpText("after", "\nTranscript density varies by session shape; do not tune a context threshold near the context wall.\n\nThe returned job id can be attached to a deliberate park with: rig queue block <qitemId> --on <blocker> --continuation <what-resumes> --wake-watchdog <jobId>")
|
||||
.addHelpText("after", `
|
||||
Recurring reminder for an agent (not a person's one-shot queue watch):
|
||||
Save periodic-reminder.yaml:
|
||||
policy: periodic-reminder
|
||||
target:
|
||||
session: worker@my-team
|
||||
message: "Finish the current action, then check the pending work."
|
||||
|
||||
rig watchdog register --spec periodic-reminder.yaml --policy periodic-reminder \\
|
||||
--target-session worker@my-team --interval-seconds 600 \\
|
||||
--registered-by coordinator@my-team
|
||||
|
||||
All five flags above are required for periodic-reminder. At registration,
|
||||
--policy selects the policy; a YAML policy field is not the selector.
|
||||
YAML target.session takes precedence over --target-session; keep them aligned.
|
||||
Put the reminder message in YAML. --message is only a context-usage-threshold shortcut.
|
||||
For a person's one-shot watch, use rig queue block --wake-after instead.
|
||||
|
||||
Transcript density varies by session shape; do not tune a context threshold near the context wall.
|
||||
|
||||
The returned job id can be attached to a deliberate park with: rig queue block <qitemId> --on <blocker> --continuation <what-resumes> --wake-watchdog <jobId>`)
|
||||
.action(async (opts: {
|
||||
spec?: string;
|
||||
policy: string;
|
||||
|
||||
@@ -64,6 +64,8 @@ interface WhoamiResult {
|
||||
resolvedBy: string;
|
||||
identity: WhoamiIdentity & Record<string, unknown>;
|
||||
peers: WhoamiPeer[];
|
||||
/** #2871: present only while `rig up` is still creating the team. */
|
||||
rosterState?: "building";
|
||||
edges: { outgoing: WhoamiEdge[]; incoming: WhoamiEdge[] };
|
||||
transcript: { enabled: boolean; path: string | null; tailCommand: string | null };
|
||||
contextUsage?: {
|
||||
@@ -103,6 +105,8 @@ function projectCompactWhoami(data: Record<string, unknown>, bounded?: BoundedPe
|
||||
}),
|
||||
// KEEP: openrig-user SKILL.md documents peersNote as a required recovery field.
|
||||
peersNote: bounded ? bounded.note : data["peersNote"],
|
||||
// #2871: while `rig up` is still creating the team, say so, as the daemon does.
|
||||
...(data["rosterState"] !== undefined ? { rosterState: data["rosterState"] } : {}),
|
||||
...(bounded ? { peersOmitted: bounded.omitted } : {}),
|
||||
// edges already carry only kind + to/from {logicalId, sessionName}.
|
||||
edges: data["edges"],
|
||||
@@ -143,9 +147,14 @@ export function boundPeers(data: Record<string, unknown>, bound: number, allPeer
|
||||
const ordered = [0, 1, 2].flatMap((t) => peers.filter((peer) => tier(peer) === t));
|
||||
const shown = ordered.slice(0, bound);
|
||||
const omitted = peers.length - shown.length;
|
||||
const note = `peers = ${shown.length} of this rig's ${peers.length} (roster excluding self; edge peers first, then your pod, then roster order; `
|
||||
// #2871: while `rig up` is still building the team, the daemon's note opens with that; keep it first, and don't call
|
||||
// the edges complete.
|
||||
const daemonNote = typeof data["peersNote"] === "string" ? data["peersNote"] : "";
|
||||
const building = data["rosterState"] === "building";
|
||||
const opening = building && daemonNote.includes("peers = ") ? daemonNote.slice(0, daemonNote.indexOf("peers = ")) : "";
|
||||
const note = `${opening}peers = ${shown.length} of this rig's ${peers.length} (roster excluding self; edge peers first, then your pod, then roster order; `
|
||||
+ `bound output.whoami_peers=${bound}); ${omitted} not shown; all: ${allPeersCommand}; `
|
||||
+ "edges = directional relationships (always complete); use `rig ps --nodes` for node inventory including self + live state";
|
||||
+ `edges = directional relationships (${building ? "the ones written so far" : "always complete"}); use \`rig ps --nodes\` for node inventory including self + live state`;
|
||||
return { peers: shown, omitted, note };
|
||||
}
|
||||
|
||||
@@ -415,6 +424,10 @@ left out. --all-peers lists every one (today's compact output).`)
|
||||
console.log(`Transport: ${id.attachmentType === "external_cli" ? "external_cli (outbound only)" : id.attachmentType}`);
|
||||
console.log(`Resolved: via ${data.resolvedBy.replace(/_/g, " ")}`);
|
||||
|
||||
if (data.rosterState === "building") {
|
||||
console.log("");
|
||||
console.log("Team: still being built by `rig up`: the peers and edges below are the ones created so far, and more may come. Run `rig whoami` again once it finishes.");
|
||||
}
|
||||
if (data.peers.length > 0) {
|
||||
console.log("");
|
||||
// OPR.99.0.6.1: name the contract on the header so peers cannot be
|
||||
|
||||
@@ -84,6 +84,7 @@ export interface RiggedConfig {
|
||||
/** Serve the web UI and its terminal WebSocket. Off by default; the daemon reads it at start. */
|
||||
enabled: boolean;
|
||||
timezone: string;
|
||||
terminal: { maxLiveTerminals: number };
|
||||
preview: {
|
||||
refreshIntervalSeconds: number;
|
||||
maxPins: number;
|
||||
@@ -218,6 +219,7 @@ export interface RiggedConfig {
|
||||
enabled: boolean;
|
||||
transitionsDays: number;
|
||||
watchdogDays: number;
|
||||
usageSamplesDays: number;
|
||||
watchdogKeepPerJob: number;
|
||||
streamDays: number;
|
||||
streamMaxItems: number;
|
||||
@@ -306,6 +308,10 @@ const DEFAULTS = {
|
||||
ui: {
|
||||
enabled: false,
|
||||
timezone: "America/Los_Angeles",
|
||||
// The daemon store leaves its own resolver value unset for this key and the web
|
||||
// client falls back to 2 (settings-browser names that fallback), so the CLI reports
|
||||
// the same effective default rather than inventing a third answer.
|
||||
terminal: { maxLiveTerminals: 2 },
|
||||
preview: {
|
||||
refreshIntervalSeconds: 3,
|
||||
maxPins: 4,
|
||||
@@ -416,6 +422,8 @@ const DEFAULTS = {
|
||||
enabled: true,
|
||||
transitionsDays: 30,
|
||||
watchdogDays: 14,
|
||||
// Usage samples age out after this many days; the daemon's own default.
|
||||
usageSamplesDays: 14,
|
||||
watchdogKeepPerJob: 50,
|
||||
streamDays: 30,
|
||||
streamMaxItems: 20000,
|
||||
@@ -499,6 +507,9 @@ export const VALID_KEYS = [
|
||||
"ui.preview.default_lines",
|
||||
"ui.timezone",
|
||||
"ui.enabled",
|
||||
// The legacy web client's cap on simultaneously-live terminals. The daemon store
|
||||
// resolves it (SETTINGS_VALID_KEYS), so `rig config` must accept it too.
|
||||
"ui.terminal.max_live_terminals",
|
||||
"recovery.auto_drive_provider_prompts",
|
||||
"recovery.provider_auth_env_allowlist",
|
||||
// V1 Phase 4 SC-29 exception — allowlist-only additions.
|
||||
@@ -568,6 +579,8 @@ export const VALID_KEYS = [
|
||||
"retention.enabled",
|
||||
"retention.transitions_days",
|
||||
"retention.watchdog_days",
|
||||
// How long the daemon keeps usage samples; lockstep with the daemon twin.
|
||||
"retention.usage_samples_days",
|
||||
"retention.watchdog_keep_per_job",
|
||||
// Slice 22 outcome 9 — stream retention: unreferenced items older than N days or beyond the newest N.
|
||||
"retention.stream_days",
|
||||
@@ -643,6 +656,7 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
|
||||
"ui.preview.max_pins": { primary: "OPENRIG_UI_PREVIEW_MAX_PINS" },
|
||||
"ui.timezone": { primary: "OPENRIG_UI_TIMEZONE" },
|
||||
"ui.enabled": { primary: "OPENRIG_UI_ENABLED" },
|
||||
"ui.terminal.max_live_terminals": { primary: "OPENRIG_UI_TERMINAL_MAX_LIVE_TERMINALS" },
|
||||
"ui.preview.default_lines": { primary: "OPENRIG_UI_PREVIEW_DEFAULT_LINES" },
|
||||
"recovery.auto_drive_provider_prompts": { primary: "OPENRIG_RECOVERY_AUTO_DRIVE_PROVIDER_PROMPTS" },
|
||||
"recovery.provider_auth_env_allowlist": { primary: "OPENRIG_RECOVERY_PROVIDER_AUTH_ENV_ALLOWLIST" },
|
||||
@@ -699,6 +713,7 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
|
||||
"retention.enabled": { primary: "OPENRIG_RETENTION_ENABLED" },
|
||||
"retention.transitions_days": { primary: "OPENRIG_RETENTION_TRANSITIONS_DAYS" },
|
||||
"retention.watchdog_days": { primary: "OPENRIG_RETENTION_WATCHDOG_DAYS" },
|
||||
"retention.usage_samples_days": { primary: "OPENRIG_RETENTION_USAGE_SAMPLES_DAYS" },
|
||||
"retention.watchdog_keep_per_job": { primary: "OPENRIG_RETENTION_WATCHDOG_KEEP_PER_JOB" },
|
||||
"retention.stream_days": { primary: "OPENRIG_RETENTION_STREAM_DAYS" },
|
||||
"retention.stream_max_items": { primary: "OPENRIG_RETENTION_STREAM_MAX_ITEMS" },
|
||||
@@ -758,6 +773,7 @@ const KEY_TO_PATH: Record<ValidKey, string[]> = {
|
||||
"ui.preview.max_pins": ["ui", "preview", "maxPins"],
|
||||
"ui.timezone": ["ui", "timezone"],
|
||||
"ui.enabled": ["ui", "enabled"],
|
||||
"ui.terminal.max_live_terminals": ["ui", "terminal", "maxLiveTerminals"],
|
||||
"ui.preview.default_lines": ["ui", "preview", "defaultLines"],
|
||||
"recovery.auto_drive_provider_prompts": ["recovery", "autoDriveProviderPrompts"],
|
||||
"recovery.provider_auth_env_allowlist": ["recovery", "providerAuthEnvAllowlist"],
|
||||
@@ -808,6 +824,7 @@ const KEY_TO_PATH: Record<ValidKey, string[]> = {
|
||||
"retention.enabled": ["retention", "enabled"],
|
||||
"retention.transitions_days": ["retention", "transitionsDays"],
|
||||
"retention.watchdog_days": ["retention", "watchdogDays"],
|
||||
"retention.usage_samples_days": ["retention", "usageSamplesDays"],
|
||||
"retention.watchdog_keep_per_job": ["retention", "watchdogKeepPerJob"],
|
||||
"retention.stream_days": ["retention", "streamDays"],
|
||||
"retention.stream_max_items": ["retention", "streamMaxItems"],
|
||||
@@ -1088,6 +1105,7 @@ const KEY_CONSTRAINTS: Partial<Record<ValidKey, (raw: string, coerced: string |
|
||||
new Intl.DateTimeFormat("en-US", { timeZone: value });
|
||||
} catch { throw new Error("Invalid ui.timezone: use an IANA timezone such as America/Los_Angeles or Europe/London"); }
|
||||
},
|
||||
"ui.terminal.max_live_terminals": positiveIntegerConstraint("ui.terminal.max_live_terminals"),
|
||||
"health.context_pressure.warning_percent": percentageConstraint("health.context_pressure.warning_percent"),
|
||||
"health.context_pressure.critical_percent": percentageConstraint("health.context_pressure.critical_percent"),
|
||||
"policies.idle_gate_qitem.scan_interval_seconds": positiveIntegerConstraint("policies.idle_gate_qitem.scan_interval_seconds"),
|
||||
@@ -1174,6 +1192,12 @@ const KEY_CONSTRAINTS: Partial<Record<ValidKey, (raw: string, coerced: string |
|
||||
throw new Error(`Invalid value for retention.watchdog_days: must be an integer >= 1, got "${raw}"`);
|
||||
}
|
||||
},
|
||||
// Same bound and message as the daemon twin's KEY_CONSTRAINTS entry.
|
||||
"retention.usage_samples_days": (raw, coerced) => {
|
||||
if (!/^-?\d+$/.test((raw ?? "").trim()) || typeof coerced !== "number" || !Number.isInteger(coerced) || coerced < 1) {
|
||||
throw new Error(`Invalid value for retention.usage_samples_days: must be an integer >= 1, got "${raw}"`);
|
||||
}
|
||||
},
|
||||
"retention.watchdog_keep_per_job": (raw, coerced) => {
|
||||
if (!/^-?\d+$/.test((raw ?? "").trim()) || typeof coerced !== "number" || !Number.isInteger(coerced) || coerced < 0) {
|
||||
throw new Error(`Invalid value for retention.watchdog_keep_per_job: must be an integer >= 0, got "${raw}"`);
|
||||
@@ -1397,6 +1421,7 @@ export class ConfigStore {
|
||||
ui: {
|
||||
enabled: v("ui.enabled") as boolean,
|
||||
timezone: v("ui.timezone") as string,
|
||||
terminal: { maxLiveTerminals: v("ui.terminal.max_live_terminals") as number },
|
||||
preview: {
|
||||
refreshIntervalSeconds: v("ui.preview.refresh_interval_seconds") as number,
|
||||
maxPins: v("ui.preview.max_pins") as number,
|
||||
@@ -1487,6 +1512,7 @@ export class ConfigStore {
|
||||
enabled: v("retention.enabled") as boolean,
|
||||
transitionsDays: v("retention.transitions_days") as number,
|
||||
watchdogDays: v("retention.watchdog_days") as number,
|
||||
usageSamplesDays: v("retention.usage_samples_days") as number,
|
||||
watchdogKeepPerJob: v("retention.watchdog_keep_per_job") as number,
|
||||
streamDays: v("retention.stream_days") as number,
|
||||
streamMaxItems: v("retention.stream_max_items") as number,
|
||||
|
||||
@@ -101,7 +101,12 @@ export async function runCrossHostCommand(
|
||||
? `OPENRIG_SESSION_NAME=${shellQuote(opts.originTriple)} ${quotedArgv}`
|
||||
: quotedArgv;
|
||||
const remoteCommandLine = `sh -lc ${shellQuote(innerCommand)}`;
|
||||
const fullArgs = [...sshOpts, sshHost.target, remoteCommandLine];
|
||||
// SEP-1: `--` ends ssh's own option parsing, so a target that begins with `-` reaches ssh as the
|
||||
// destination instead of being read as one more option. That misreading is the harm: `-V`, for
|
||||
// instance, is ssh's own version flag, parsed and exited 0 before any connection, which this
|
||||
// executor would count as a success with no remote command run. With the separator, ssh rejects
|
||||
// the name itself.
|
||||
const fullArgs = [...sshOpts, "--", sshHost.target, remoteCommandLine];
|
||||
|
||||
const spawn = opts.spawn ?? (nodeSpawn as unknown as SpawnFn);
|
||||
const child = spawn("ssh", fullArgs);
|
||||
|
||||
@@ -91,6 +91,7 @@ import { compactPlanCommand, type CompactPlanDeps } from "./commands/compact-pla
|
||||
import { compactCommand, type CompactDeps } from "./commands/compact.js";
|
||||
import { heartbeatCommand, type HeartbeatDeps } from "./commands/heartbeat.js";
|
||||
import { seatCommand, handoverCommand, type SeatDeps } from "./commands/seat.js";
|
||||
import { networkCommand, type NetworkDeps } from "./commands/network.js";
|
||||
import { rigModeCommand, type RigModeDeps } from "./commands/rig-mode.js";
|
||||
import { policyCommand } from "./commands/policy.js";
|
||||
import { startupProofCommand, type StartupProofDeps } from "./commands/startup-proof.js";
|
||||
@@ -168,6 +169,7 @@ export interface ProgramDeps {
|
||||
compactDeps?: CompactDeps;
|
||||
heartbeatDeps?: HeartbeatDeps;
|
||||
seatDeps?: SeatDeps;
|
||||
networkDeps?: NetworkDeps;
|
||||
rigModeDeps?: RigModeDeps;
|
||||
startupProofDeps?: StartupProofDeps;
|
||||
healthDeps?: HealthDeps;
|
||||
@@ -269,6 +271,7 @@ export function createProgram(depsOverride?: ProgramDeps): Command {
|
||||
program.addCommand(heartbeatCommand(depsOverride?.heartbeatDeps));
|
||||
program.addCommand(seatCommand(depsOverride?.seatDeps));
|
||||
program.addCommand(handoverCommand(depsOverride?.seatDeps));
|
||||
program.addCommand(networkCommand(depsOverride?.networkDeps));
|
||||
program.addCommand(startupProofCommand(depsOverride?.startupProofDeps));
|
||||
// release-0.3.2 slice 12 — rig scope CLI primitive.
|
||||
program.addCommand(scopeCommand());
|
||||
|
||||
@@ -102,6 +102,7 @@ export const COMMAND_LOADERS: ReadonlyMap<string, Load> = new Map<string, Load>(
|
||||
["heartbeat", async () => (await import("./commands/heartbeat.js")).heartbeatCommand()],
|
||||
["seat", async () => (await import("./commands/seat.js")).seatCommand()],
|
||||
["handover", async () => (await import("./commands/seat.js")).handoverCommand()],
|
||||
["network", async () => (await import("./commands/network.js")).networkCommand()],
|
||||
["startup-proof", async () => (await import("./commands/startup-proof.js")).startupProofCommand()],
|
||||
["scope", async () => (await import("./commands/scope.js")).scopeCommand()],
|
||||
["proof", async () => (await import("./commands/proof.js")).proofCommand()],
|
||||
|
||||
@@ -31,7 +31,7 @@ export async function checkSeatAttention(client: DaemonClient, seats: readonly s
|
||||
const check: AttentionCheck = { attention: [], unknown: [] };
|
||||
const byRig = new Map<string, Set<string>>();
|
||||
for (const seat of seats) {
|
||||
const at = seat.lastIndexOf("@");
|
||||
const at = seat.indexOf("@");
|
||||
if (at <= 0 || at === seat.length - 1) { check.unknown.push({ seat, why: "not a seat@rig name" }); continue; }
|
||||
const rig = seat.slice(at + 1);
|
||||
if (!byRig.has(rig)) byRig.set(rig, new Set());
|
||||
|
||||
@@ -149,6 +149,9 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
|
||||
"files.allowlist", "progress.scan_roots",
|
||||
"ui.preview.refresh_interval_seconds", "ui.preview.max_pins", "ui.preview.default_lines", "ui.timezone",
|
||||
"ui.enabled",
|
||||
// The legacy web client's cap on simultaneously-live terminals; the daemon store
|
||||
// resolves it, so `rig config` must accept it too.
|
||||
"ui.terminal.max_live_terminals",
|
||||
"recovery.auto_drive_provider_prompts",
|
||||
"recovery.provider_auth_env_allowlist",
|
||||
// V1 attempt-3 Phase 4 — Advisor/Operator placeholders.
|
||||
@@ -213,6 +216,7 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
|
||||
"retention.enabled",
|
||||
"retention.transitions_days",
|
||||
"retention.watchdog_days",
|
||||
"retention.usage_samples_days", // how long usage samples are kept
|
||||
"retention.watchdog_keep_per_job",
|
||||
"retention.stream_days", // slice 22 outcome 9: stream retention
|
||||
"retention.stream_max_items",
|
||||
@@ -887,6 +891,44 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
|
||||
expect(() => store.set("ui.preview.refresh_interval_seconds", "soon")).toThrow(/expected a number/);
|
||||
});
|
||||
|
||||
// Both keys below were resolved by the daemon store and served by `GET /api/config`,
|
||||
// while `rig config get|set` rejected them as "Unknown config key". Their siblings in
|
||||
// the same families — every other ui.* and retention.* key — were already settable, so
|
||||
// this was drift between the two lockstep lists, not a deliberate CLI-only subset.
|
||||
it("ui.terminal.max_live_terminals is settable, numeric and a positive integer", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
store.set("ui.terminal.max_live_terminals", "3");
|
||||
expect(store.get("ui.terminal.max_live_terminals")).toBe(3);
|
||||
expect(JSON.parse(readFileSync(configPath, "utf-8")).ui.terminal.maxLiveTerminals).toBe(3);
|
||||
for (const raw of ["0", "-1", "2.5", "2abc"]) {
|
||||
expect(() => store.set("ui.terminal.max_live_terminals", raw), `must reject ${raw}`).toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it("retention.usage_samples_days defaults to 14 and keeps an integer >= 1", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
expect(store.resolveWithSource("retention.usage_samples_days"))
|
||||
.toMatchObject({ value: 14, source: "default", defaultValue: 14 });
|
||||
store.set("retention.usage_samples_days", "30");
|
||||
expect(store.get("retention.usage_samples_days")).toBe(30);
|
||||
for (const raw of ["0", "-1", "1.5"]) {
|
||||
expect(() => store.set("retention.usage_samples_days", raw), `must reject ${raw}`).toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it("both recovered keys honour their OPENRIG_* environment override", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
process.env.OPENRIG_UI_TERMINAL_MAX_LIVE_TERMINALS = "7";
|
||||
process.env.OPENRIG_RETENTION_USAGE_SAMPLES_DAYS = "9";
|
||||
try {
|
||||
expect(store.resolveWithSource("ui.terminal.max_live_terminals")).toMatchObject({ value: 7, source: "env" });
|
||||
expect(store.resolveWithSource("retention.usage_samples_days")).toMatchObject({ value: 9, source: "env" });
|
||||
} finally {
|
||||
delete process.env.OPENRIG_UI_TERMINAL_MAX_LIVE_TERMINALS;
|
||||
delete process.env.OPENRIG_RETENTION_USAGE_SAMPLES_DAYS;
|
||||
}
|
||||
});
|
||||
|
||||
// Constraint gap (Slice 27 residue): daemon.port had no KEY_CONSTRAINTS
|
||||
// entry, so `rig config set daemon.port 99999` stored 99999 and the next
|
||||
// daemon start died with a raw ERR_SOCKET_BAD_PORT from serve(). The
|
||||
@@ -979,6 +1021,24 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
|
||||
expect(cfg.ui.preview.defaultLines).toBe(50);
|
||||
});
|
||||
|
||||
it("RiggedConfig carries both recovered settings, saved values included", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
const defaults = store.resolve();
|
||||
expect(defaults.ui.terminal.maxLiveTerminals).toBe(2);
|
||||
expect(defaults.retention.usageSamplesDays).toBe(14);
|
||||
store.set("ui.terminal.max_live_terminals", "5");
|
||||
store.set("retention.usage_samples_days", "21");
|
||||
const saved = store.resolve();
|
||||
expect(saved.ui.terminal.maxLiveTerminals).toBe(5);
|
||||
expect(saved.retention.usageSamplesDays).toBe(21);
|
||||
// `rig config --json` prints exactly this object, so a saved value reaches the
|
||||
// structured output and not only `rig config get`.
|
||||
expect(JSON.parse(JSON.stringify(saved))).toMatchObject({
|
||||
ui: { terminal: { maxLiveTerminals: 5 } },
|
||||
retention: { usageSamplesDays: 21 },
|
||||
});
|
||||
});
|
||||
|
||||
it("recovery.auto_drive_provider_prompts defaults false and coerces booleans", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
expect(store.get("recovery.auto_drive_provider_prompts")).toBe(false);
|
||||
|
||||
@@ -156,8 +156,10 @@ describe("runCrossHostCommand", () => {
|
||||
expect(capture.command).toBe("ssh");
|
||||
// D13 supersession: the remote command now runs under `sh -lc` (login-shell PATH
|
||||
// resolution) — the quoted argv rides inside one further quoting layer.
|
||||
// SEP-1: `--` ends ssh's own option parsing before the target.
|
||||
expect(capture.args).toEqual([
|
||||
"-o", "ConnectTimeout=10",
|
||||
"--",
|
||||
"vm-test.local",
|
||||
"sh -lc ''\\''rig'\\'' '\\''send'\\'' '\\''dev-impl@rig'\\'' '\\''hello world'\\'' '\\''--verify'\\'''",
|
||||
]);
|
||||
@@ -202,6 +204,29 @@ describe("runCrossHostCommand", () => {
|
||||
expect(capture.args?.[1]).toBe("ops");
|
||||
});
|
||||
|
||||
it("SEP-1 — `--` separates the options from the target, whatever shape the target has", async () => {
|
||||
const capture: { command?: string; args?: readonly string[] } = {};
|
||||
const spawn = mockSpawnFor({ exitCode: 0, capture });
|
||||
await runCrossHostCommand({ id: "vm-test", transport: "ssh", target: "-oProxyCommand=touch /tmp/nothing" }, ["rig", "ps"], { spawn });
|
||||
const args = capture.args ?? [];
|
||||
const separator = args.indexOf("--");
|
||||
expect(separator).toBeGreaterThan(0);
|
||||
// The whole target reaches ssh as one argument after the separator, so an option-shaped
|
||||
// destination is a host ssh rejects rather than an option it obeys.
|
||||
expect(args[separator + 1]).toBe("-oProxyCommand=touch /tmp/nothing");
|
||||
// Everything we pass as an option stays before it.
|
||||
expect(separator).toBeGreaterThan(args.indexOf("-o"));
|
||||
expect(separator).toBeLessThan(args.length - 1);
|
||||
});
|
||||
|
||||
it("SEP-1 — the separator sits after -l <user> too", async () => {
|
||||
const capture: { command?: string; args?: readonly string[] } = {};
|
||||
const spawn = mockSpawnFor({ exitCode: 0, capture });
|
||||
await runCrossHostCommand(HOST_WITH_USER, ["rig", "ps"], { spawn });
|
||||
const args = capture.args ?? [];
|
||||
expect(args.slice(0, args.indexOf("--"))).toEqual(["-l", "ops", "-o", "ConnectTimeout=10"]);
|
||||
});
|
||||
|
||||
it("happy path returns stdout/stderr verbatim", async () => {
|
||||
const spawn = mockSpawnFor({
|
||||
exitCode: 0,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// The opt-in wake loop in the openrig-user skill, run exactly as shipped: extracted from the skill, executed by sh,
|
||||
// calling the built `rig` CLI against the daemon's real queue routes over HTTP. It must wake on new owned work,
|
||||
// survive failed polls, and end at its deadline.
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { describe, it, type TestContext } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -30,62 +30,48 @@ function shippedLoop(): string {
|
||||
return match[1]!;
|
||||
}
|
||||
|
||||
describe.skipIf(process.platform === "win32")("the skill's opt-in wake loop", () => {
|
||||
it("has a built CLI to run the loop with", () => {
|
||||
// Not a silent skip: the loop runs against this checkout's built `rig`.
|
||||
expect(fs.existsSync(CLI), `build the CLI first (npm run build): ${fileURLToPath(CLI)} is missing`).toBe(true);
|
||||
async function fixture(onTestFinished: TestContext["onTestFinished"]) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "wake-loop-"));
|
||||
let db: Database.Database | undefined;
|
||||
let server: ServerType | undefined;
|
||||
onTestFinished(async () => {
|
||||
try {
|
||||
if (server) await new Promise<void>((resolve) => server!.close(() => resolve()));
|
||||
} finally {
|
||||
try { db?.close(); }
|
||||
finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
}
|
||||
});
|
||||
|
||||
let db: Database.Database;
|
||||
let server: ServerType;
|
||||
let url: string;
|
||||
let root: string;
|
||||
let failLists = 0;
|
||||
let failListsAfter = Infinity; // every list request after this many fails, as if the daemon stopped
|
||||
let listCalls = 0;
|
||||
let queue: QueueRepository;
|
||||
|
||||
beforeEach(async () => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), "wake-loop-"));
|
||||
fs.mkdirSync(path.join(root, "bin"));
|
||||
// The agent's `rig`: this checkout's built CLI.
|
||||
fs.writeFileSync(path.join(root, "bin", "rig"), `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(fileURLToPath(CLI))} "$@"\n`, { mode: 0o755 });
|
||||
db = createDb(":memory:");
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
queue = new QueueRepository(db, new EventBus(db), { validateRig: () => true });
|
||||
const inert = async () => ({ ok: false, error: "inert fixture" });
|
||||
const { app } = createTestApp(db, {
|
||||
appDeps: {
|
||||
sessionTransport: { resolveSessions: async () => ({ ok: false, code: "not_found", error: "inert fixture" }), send: inert, capture: inert, broadcast: inert } as never,
|
||||
queueRepo: queue,
|
||||
},
|
||||
});
|
||||
failLists = 0;
|
||||
failListsAfter = Infinity;
|
||||
listCalls = 0;
|
||||
server = await new Promise<ServerType>((resolve) => {
|
||||
const s = serve({
|
||||
fetch: (request: Request) => {
|
||||
if (new URL(request.url).pathname === "/api/queue/list") {
|
||||
listCalls++;
|
||||
if (failLists > 0 || listCalls > failListsAfter) {
|
||||
if (failLists > 0) failLists--;
|
||||
return new Response(JSON.stringify({ error: "fixture: daemon busy" }), { status: 503 });
|
||||
}
|
||||
fs.mkdirSync(path.join(root, "bin"));
|
||||
// The agent's `rig`: this checkout's built CLI.
|
||||
fs.writeFileSync(path.join(root, "bin", "rig"), `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(fileURLToPath(CLI))} "$@"\n`, { mode: 0o755 });
|
||||
db = createDb(":memory:");
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
const queue = new QueueRepository(db, new EventBus(db), { validateRig: () => true });
|
||||
const inert = async () => ({ ok: false, error: "inert fixture" });
|
||||
const { app } = createTestApp(db, {
|
||||
appDeps: {
|
||||
sessionTransport: { resolveSessions: async () => ({ ok: false, code: "not_found", error: "inert fixture" }), send: inert, capture: inert, broadcast: inert } as never,
|
||||
queueRepo: queue,
|
||||
},
|
||||
});
|
||||
const state = { failLists: 0, failListsAfter: Infinity, listCalls: 0 };
|
||||
server = await new Promise<ServerType>((resolve) => {
|
||||
const s = serve({
|
||||
fetch: (request: Request) => {
|
||||
if (new URL(request.url).pathname === "/api/queue/list") {
|
||||
state.listCalls++;
|
||||
if (state.failLists > 0 || state.listCalls > state.failListsAfter) {
|
||||
if (state.failLists > 0) state.failLists--;
|
||||
return new Response(JSON.stringify({ error: "fixture: daemon busy" }), { status: 503 });
|
||||
}
|
||||
return app.fetch(request);
|
||||
},
|
||||
port: 0, hostname: "127.0.0.1",
|
||||
}, () => resolve(s));
|
||||
});
|
||||
url = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
db.close();
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
return app.fetch(request);
|
||||
},
|
||||
port: 0, hostname: "127.0.0.1",
|
||||
}, () => resolve(s));
|
||||
});
|
||||
const url = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
|
||||
/** Run the shipped loop as a background command would: its own process, this seat's identity, short timings. */
|
||||
function runLoop(max: number, seat: string | null = DESK) {
|
||||
@@ -106,26 +92,38 @@ describe.skipIf(process.platform === "win32")("the skill's opt-in wake loop", ()
|
||||
const assign = (destination: string) => queue.create({
|
||||
sourceSession: "lead@team", destinationSession: destination, body: "Summarise the failing test.", nudge: false,
|
||||
} as never);
|
||||
return { runLoop, assign, queue, state };
|
||||
}
|
||||
|
||||
it("wakes with the new owned item once it is assigned", async () => {
|
||||
describe.skipIf(process.platform === "win32").concurrent("the skill's opt-in wake loop", () => {
|
||||
it("has a built CLI to run the loop with", ({ expect }) => {
|
||||
// Not a silent skip: the loop runs against this checkout's built `rig`.
|
||||
expect(fs.existsSync(CLI), `build the CLI first (npm run build): ${fileURLToPath(CLI)} is missing`).toBe(true);
|
||||
});
|
||||
|
||||
it("wakes with the new owned item once it is assigned", async ({ expect, onTestFinished }) => {
|
||||
const { runLoop, assign } = await fixture(onTestFinished);
|
||||
const waiting = runLoop(30);
|
||||
setTimeout(() => { assign(DESK); }, 2_000);
|
||||
const timer = setTimeout(() => { assign(DESK); }, 2_000);
|
||||
onTestFinished(() => clearTimeout(timer));
|
||||
const result = await waiting;
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toMatch(/^NEW WORK: .*"qitemId"/m);
|
||||
expect(result.seconds).toBeLessThan(15);
|
||||
}, 40_000);
|
||||
|
||||
it("keeps waiting through failed polls and wakes when the daemon answers again", async () => {
|
||||
it("keeps waiting through failed polls and wakes when the daemon answers again", async ({ expect, onTestFinished }) => {
|
||||
const { runLoop, assign, state } = await fixture(onTestFinished);
|
||||
assign(DESK);
|
||||
failLists = 3;
|
||||
state.failLists = 3;
|
||||
const result = await runLoop(30);
|
||||
expect(result.code).toBe(0);
|
||||
expect(listCalls).toBeGreaterThanOrEqual(4);
|
||||
expect(state.listCalls).toBeGreaterThanOrEqual(4);
|
||||
expect(result.stdout).toMatch(/^NEW WORK: /m);
|
||||
}, 40_000);
|
||||
|
||||
it("doesn't wake for owned work that is already claimed", async () => {
|
||||
it("doesn't wake for owned work that is already claimed", async ({ expect, onTestFinished }) => {
|
||||
const { runLoop, assign, queue } = await fixture(onTestFinished);
|
||||
const item = await assign(DESK);
|
||||
queue.claim({ qitemId: item.qitemId, destinationSession: DESK } as never);
|
||||
const result = await runLoop(4);
|
||||
@@ -134,7 +132,8 @@ describe.skipIf(process.platform === "win32")("the skill's opt-in wake loop", ()
|
||||
expect(result.stdout).toContain("No new work before the deadline");
|
||||
}, 40_000);
|
||||
|
||||
it("says it couldn't check, and exits non-zero, when every poll failed", async () => {
|
||||
it("says it couldn't check, and exits non-zero, when every poll failed", async ({ expect, onTestFinished }) => {
|
||||
const { runLoop, assign } = await fixture(onTestFinished);
|
||||
assign(DESK);
|
||||
const result = await runLoop(3, null); // no seat identity: every `rig queue list --owned` fails
|
||||
expect(result.code).not.toBe(0);
|
||||
@@ -143,23 +142,26 @@ describe.skipIf(process.platform === "win32")("the skill's opt-in wake loop", ()
|
||||
expect(result.stdout).toContain("Couldn't check for work: every poll failed.");
|
||||
}, 40_000);
|
||||
|
||||
it("reports when checking stopped working, and exits non-zero, after an early success and failures to the deadline", async () => {
|
||||
failListsAfter = 1; // one empty successful poll, then the daemon stops answering
|
||||
it("reports when checking stopped working, and exits non-zero, after an early success and failures to the deadline", async ({ expect, onTestFinished }) => {
|
||||
const { runLoop, state } = await fixture(onTestFinished);
|
||||
state.failListsAfter = 1; // one empty successful poll, then the daemon stops answering
|
||||
const result = await runLoop(4);
|
||||
expect(result.code).not.toBe(0);
|
||||
expect(result.stdout).not.toContain("No new work");
|
||||
expect(result.stdout).toMatch(/Checking stopped working at \d{2}:\d{2}:\d{2}Z: every poll since then failed\./);
|
||||
}, 40_000);
|
||||
|
||||
it("stays quiet when a failed poll is followed by a successful one", async () => {
|
||||
failLists = 2; // two failures, then the daemon answers again (with no work)
|
||||
it("stays quiet when a failed poll is followed by a successful one", async ({ expect, onTestFinished }) => {
|
||||
const { runLoop, state } = await fixture(onTestFinished);
|
||||
state.failLists = 2; // two failures, then the daemon answers again (with no work)
|
||||
const result = await runLoop(4);
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toContain("No new work before the deadline");
|
||||
expect(result.stdout).not.toContain("Checking stopped working");
|
||||
}, 40_000);
|
||||
|
||||
it("ignores work assigned to another seat and ends at its deadline", async () => {
|
||||
it("ignores work assigned to another seat and ends at its deadline", async ({ expect, onTestFinished }) => {
|
||||
const { runLoop, assign } = await fixture(onTestFinished);
|
||||
assign("impl@team");
|
||||
const result = await runLoop(4);
|
||||
expect(result.code).toBe(0);
|
||||
|
||||
@@ -1,51 +1,144 @@
|
||||
// `rig host ls` status for an ssh host dials where ssh itself would connect (`ssh -G`), so an
|
||||
// ssh-config alias or a non-22 port isn't reported unreachable. A stand-in `ssh` on PATH plays
|
||||
// `ssh -G`; no real ssh runs.
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, writeFileSync, chmodSync, rmSync } from "node:fs";
|
||||
// `rig host list` / `rig host ls` status is tested at process and probe boundaries:
|
||||
// passive SSH rows must not invoke ssh -G or trigger a TCP probe.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { probeHostStatus } from "../src/commands/host.js";
|
||||
import { Command } from "commander";
|
||||
import { hostCommand, type DoctorDeps } from "../src/commands/host.js";
|
||||
|
||||
const { execFileMock, connectMock } = vi.hoisted(() => ({
|
||||
execFileMock: vi.fn(),
|
||||
connectMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:child_process")>();
|
||||
return { ...actual, execFile: execFileMock };
|
||||
});
|
||||
vi.mock("node:net", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:net")>();
|
||||
return { ...actual, connect: connectMock };
|
||||
});
|
||||
|
||||
interface ProbeCall {
|
||||
target: string;
|
||||
port: number;
|
||||
timeoutMs: number;
|
||||
}
|
||||
interface ExecFileCall {
|
||||
command: string;
|
||||
args: string[];
|
||||
timeout: number | undefined;
|
||||
}
|
||||
|
||||
let home: string;
|
||||
let savedHome: string | undefined;
|
||||
let execFileCalls: ExecFileCall[];
|
||||
let log: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
function writeRegistry(hosts: unknown[]): void {
|
||||
writeFileSync(join(home, "hosts.yaml"), JSON.stringify({ hosts }));
|
||||
}
|
||||
|
||||
function deps(tcpProbe: DoctorDeps["tcpProbe"]): DoctorDeps {
|
||||
return {
|
||||
run: async () => { throw new Error("unexpected remote SSH command"); },
|
||||
httpGet: async () => { throw new Error("unexpected HTTP request"); },
|
||||
tcpProbe,
|
||||
};
|
||||
}
|
||||
|
||||
async function listHosts(hosts: unknown[], tcpProbe: DoctorDeps["tcpProbe"], verb: "list" | "ls" = "ls") {
|
||||
writeRegistry(hosts);
|
||||
const program = new Command().exitOverride();
|
||||
program.addCommand(hostCommand(deps(tcpProbe)));
|
||||
await program.parseAsync(["node", "rig", "host", verb, "--json"]);
|
||||
const output = log.mock.calls.map((call) => String(call[0])).find((line) => line.trim().startsWith("["));
|
||||
if (output === undefined) throw new Error("host list did not print its JSON array");
|
||||
return JSON.parse(output) as Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
let dir: string;
|
||||
let savedPath: string | undefined;
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "host-status-ssh-"));
|
||||
writeFileSync(join(dir, "ssh"), `#!/bin/sh
|
||||
case "$*" in
|
||||
*-G*devbox*) printf 'user ops\\nhostname 10.1.2.3\\nport 2222\\n' ;;
|
||||
*-G*jumpy*) printf 'hostname jumpy\\nport 22\\nproxyjump bastion\\n' ;;
|
||||
*) exit 255 ;;
|
||||
esac
|
||||
`);
|
||||
chmodSync(join(dir, "ssh"), 0o755);
|
||||
savedPath = process.env["PATH"];
|
||||
process.env["PATH"] = `${dir}:${savedPath}`;
|
||||
});
|
||||
afterEach(() => {
|
||||
process.env["PATH"] = savedPath;
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
home = mkdtempSync(join(tmpdir(), "host-list-ssh-inert-"));
|
||||
savedHome = process.env["OPENRIG_HOME"];
|
||||
process.env["OPENRIG_HOME"] = home;
|
||||
execFileCalls = [];
|
||||
execFileMock.mockReset();
|
||||
execFileMock.mockImplementation((
|
||||
command: string,
|
||||
args: string[],
|
||||
options: { timeout?: number },
|
||||
callback: (error: Error | null, stdout: string) => void,
|
||||
) => {
|
||||
execFileCalls.push({ command, args: [...args], timeout: options.timeout });
|
||||
queueMicrotask(() => callback(null, ""));
|
||||
});
|
||||
connectMock.mockReset();
|
||||
connectMock.mockImplementation(() => { throw new Error("unexpected direct TCP socket"); });
|
||||
log = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
describe("rig host ls status for an ssh host", () => {
|
||||
it("dials the host and port ssh resolves for an ssh-config alias", async () => {
|
||||
const dials: string[] = [];
|
||||
const status = await probeHostStatus({ id: "devbox", transport: "ssh", target: "devbox" }, async (target, port) => {
|
||||
dials.push(`${target}:${port}`);
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
if (savedHome === undefined) delete process.env["OPENRIG_HOME"];
|
||||
else process.env["OPENRIG_HOME"] = savedHome;
|
||||
});
|
||||
|
||||
describe("rig host list/ls passive status", () => {
|
||||
it.each(["list", "ls"] as const)("reports an SSH host unknown without launching a process or probing TCP through host %s", async (verb) => {
|
||||
const probes: ProbeCall[] = [];
|
||||
const tcpProbe: DoctorDeps["tcpProbe"] = async (target, port, timeoutMs) => {
|
||||
probes.push({ target, port, timeoutMs });
|
||||
return "open";
|
||||
};
|
||||
|
||||
const rows = await listHosts([{ id: "devbox", transport: "ssh", target: "devbox", user: "ops" }], tcpProbe, verb);
|
||||
|
||||
expect({ status: rows[0]?.status, execFileCalls, probes, directSockets: connectMock.mock.calls }).toEqual({
|
||||
status: "unknown",
|
||||
execFileCalls: [],
|
||||
probes: [],
|
||||
directSockets: [],
|
||||
});
|
||||
expect(status).toBe("reachable");
|
||||
expect(dials).toEqual(["10.1.2.3:2222"]);
|
||||
});
|
||||
|
||||
it.each([["a ProxyJump host", "jumpy"], ["a target ssh can't resolve", "broken"]])(
|
||||
"reads unknown for %s, since a direct dial can't stand for the transport", async (_label, target) => {
|
||||
const dials: string[] = [];
|
||||
const status = await probeHostStatus({ id: target, transport: "ssh", target }, async (t, port) => {
|
||||
dials.push(`${t}:${port}`);
|
||||
return "closed";
|
||||
});
|
||||
expect(status).toBe("unknown");
|
||||
expect(dials).toEqual([]);
|
||||
it.each([
|
||||
{ label: "HTTP default port", url: "http://example.test", target: "example.test", port: 80 },
|
||||
{ label: "HTTP explicit port", url: "http://example.test:7433", target: "example.test", port: 7433 },
|
||||
{ label: "HTTPS default port", url: "https://example.test", target: "example.test", port: 443 },
|
||||
{ label: "bracketed IPv6 HTTPS", url: "https://[2001:db8::7]:9443", target: "2001:db8::7", port: 9443 },
|
||||
])("preserves CLI $label status probing through its injected seam", async ({ url, target, port }) => {
|
||||
const probes: ProbeCall[] = [];
|
||||
const tcpProbe: DoctorDeps["tcpProbe"] = async (host, servicePort, timeoutMs) => {
|
||||
probes.push({ target: host, port: servicePort, timeoutMs });
|
||||
return "open";
|
||||
};
|
||||
|
||||
const rows = await listHosts([{ id: "web", transport: "http", url }], tcpProbe);
|
||||
|
||||
expect({ status: rows[0]?.status, probes, execFileCalls, directSockets: connectMock.mock.calls }).toEqual({
|
||||
status: "reachable",
|
||||
probes: [{ target, port, timeoutMs: 1500 }],
|
||||
execFileCalls: [],
|
||||
directSockets: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("maps an HTTP closed-probe control to unreachable", async () => {
|
||||
const probes: ProbeCall[] = [];
|
||||
const tcpProbe: DoctorDeps["tcpProbe"] = async (target, port, timeoutMs) => {
|
||||
probes.push({ target, port, timeoutMs });
|
||||
return "closed";
|
||||
};
|
||||
|
||||
const rows = await listHosts([{ id: "web", transport: "http", url: "http://example.test:7433" }], tcpProbe);
|
||||
|
||||
expect({ status: rows[0]?.status, probes, directSockets: connectMock.mock.calls }).toEqual({
|
||||
status: "unreachable",
|
||||
probes: [{ target: "example.test", port: 7433, timeoutMs: 1500 }],
|
||||
directSockets: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,11 +14,13 @@ import { configCommand } from "../src/commands/config.js";
|
||||
describe("rig host add/list", () => {
|
||||
let dir: string;
|
||||
let savedHome: string | undefined;
|
||||
let tcpProbeCalls: Array<{ target: string; port: number; timeoutMs: number }> = [];
|
||||
|
||||
beforeEach(() => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "hostcmd-"));
|
||||
savedHome = process.env.OPENRIG_HOME;
|
||||
process.env.OPENRIG_HOME = dir;
|
||||
tcpProbeCalls = [];
|
||||
});
|
||||
afterEach(() => {
|
||||
if (savedHome === undefined) delete process.env.OPENRIG_HOME;
|
||||
@@ -43,7 +45,15 @@ describe("rig host add/list", () => {
|
||||
function run(argv: string[]) {
|
||||
const prog = new Command();
|
||||
prog.exitOverride();
|
||||
prog.addCommand(hostCommand());
|
||||
prog.addCommand(hostCommand({
|
||||
run: async () => { throw new Error("unexpected cross-host command in add/list test"); },
|
||||
httpGet: async () => { throw new Error("unexpected HTTP GET in add/list test"); },
|
||||
httpPost: async () => { throw new Error("unexpected HTTP POST in add/list test"); },
|
||||
tcpProbe: async (target, port, timeoutMs) => {
|
||||
tcpProbeCalls.push({ target, port, timeoutMs });
|
||||
return "unknown";
|
||||
},
|
||||
}));
|
||||
return prog.parseAsync(["node", "rig", "host", ...argv]);
|
||||
}
|
||||
|
||||
@@ -97,11 +107,17 @@ describe("rig host add/list", () => {
|
||||
const added = await capture(() => run(["add", "--id", "f1", "--transport", "http", "--url", "http://100.64.0.9:7433", "--bearer-env", "FACTORY_TOKEN", "--json"]));
|
||||
expect(JSON.parse(added.out.join("\n"))).toMatchObject({ ok: true, entry: { id: "f1" } });
|
||||
expect(added.err).toEqual([]);
|
||||
const beforeTextList = tcpProbeCalls.length;
|
||||
const { out } = await capture(() => run(["list"]));
|
||||
expect(tcpProbeCalls.length - beforeTextList).toBe(1);
|
||||
const text = out.join("\n");
|
||||
expect(text).toContain("env:FACTORY_TOKEN");
|
||||
expect(text).toContain("unknown");
|
||||
expect(text).not.toContain("super-secret-bearer-value");
|
||||
const beforeJsonList = tcpProbeCalls.length;
|
||||
const { out: jsonOut } = await capture(() => run(["list", "--json"]));
|
||||
expect(tcpProbeCalls.length - beforeJsonList).toBe(1);
|
||||
expect(JSON.parse(jsonOut.join(""))).toMatchObject([{ id: "f1", status: "unknown" }]);
|
||||
expect(jsonOut.join("")).toContain("FACTORY_TOKEN");
|
||||
expect(jsonOut.join("")).not.toContain("super-secret-bearer-value");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
// `rig network connect | status | disconnect` against the daemon's real network routes.
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { Command } from "commander";
|
||||
import { Hono } from "hono";
|
||||
import Database from "better-sqlite3";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { networkCommand, type NetworkDeps } from "../src/commands/network.js";
|
||||
import { STATE_FILE, type LifecycleDeps } from "../src/daemon-lifecycle.js";
|
||||
import { ALL_MIGRATIONS } from "../../daemon/src/db/all-migrations.js";
|
||||
import { migrate } from "../../daemon/src/db/migrate.js";
|
||||
import { RigRepository } from "../../daemon/src/domain/rig-repository.js";
|
||||
import { networkRoutes } from "../../daemon/src/routes/network.js";
|
||||
|
||||
let db: Database.Database;
|
||||
let home: string;
|
||||
let app: Hono;
|
||||
let rigs: RigRepository;
|
||||
const routesWith = (bearerToken: string | null) => {
|
||||
const next = new Hono();
|
||||
next.use("*", async (c, n) => { c.set("rigRepo" as never, rigs as never); await n(); });
|
||||
next.route("/api/network", networkRoutes({ home, bearerToken }));
|
||||
return next;
|
||||
};
|
||||
beforeEach(() => {
|
||||
db = new Database(":memory:");
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
rigs = new RigRepository(db);
|
||||
const node = rigs.addNode(rigs.createRig("lab").id, "impl", { runtime: "claude-code" });
|
||||
db.prepare("INSERT INTO sessions (id, node_id, session_name, status) VALUES ('s1', ?, 'dev-impl@lab', 'running')").run(node.id);
|
||||
home = mkdtempSync(join(tmpdir(), "openrig-network-cli-"));
|
||||
app = routesWith(null);
|
||||
});
|
||||
afterEach(() => { db.close(); rmSync(home, { recursive: true, force: true }); vi.restoreAllMocks(); vi.unstubAllEnvs(); process.exitCode = undefined; });
|
||||
|
||||
function cli(tokenFile: Record<string, string> = {}) {
|
||||
const lifecycleDeps: LifecycleDeps = {
|
||||
spawn: vi.fn(), fetch: vi.fn(async () => ({ ok: true })), kill: vi.fn(),
|
||||
readFile: (p) => p === STATE_FILE ? JSON.stringify({ pid: 123, port: 19731, db: "/fixture/test.sqlite", startedAt: new Date().toISOString() }) : null,
|
||||
writeFile: vi.fn(), removeFile: vi.fn(), exists: (p) => p === STATE_FILE, mkdirp: vi.fn(), openForAppend: () => 3, isProcessAlive: () => true,
|
||||
} as LifecycleDeps;
|
||||
const call = async (method: "GET" | "POST", url: string, body?: unknown, headers: Record<string, string> = {}) => {
|
||||
const res = await app.request(url, { method, headers: { "Content-Type": "application/json", ...headers }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
|
||||
return { status: res.status, data: await res.json() };
|
||||
};
|
||||
const deps: NetworkDeps = {
|
||||
lifecycleDeps,
|
||||
clientFactory: () => ({ get: (url: string) => call("GET", url), post: (url: string, body: unknown, options?: { headers?: Record<string, string> }) => call("POST", url, body, options?.headers) }) as never,
|
||||
readTokenFile: (path) => { if (!(path in tokenFile)) throw new Error("no such file"); return tokenFile[path]!; },
|
||||
};
|
||||
return new Command().exitOverride().addCommand(networkCommand(deps));
|
||||
}
|
||||
|
||||
it("connects a seat with its token file, shows it in status without the token, and disconnects it", async () => {
|
||||
const out = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
await cli({ "/tok": "tok-abc" }).parseAsync(["node", "rig", "network", "connect", "dev-impl@lab", "--hub", "https://hub.example/mcp", "--as", "alex@team", "--token-file", "/tok"]);
|
||||
expect(String(out.mock.calls.at(-1)?.[0])).toMatch(/^Connected netb-[0-9a-f]{20} dev-impl@lab -> alex@team at https:\/\/hub\.example\/mcp no contact yet/);
|
||||
|
||||
await cli().parseAsync(["node", "rig", "network", "status", "--json"]);
|
||||
const status = String(out.mock.calls.at(-1)?.[0]);
|
||||
expect(JSON.parse(status).bindings).toEqual([expect.objectContaining({ localSession: "dev-impl@lab", hubSeat: "alex@team", state: "active" })]);
|
||||
expect(status).not.toContain("tok-abc");
|
||||
|
||||
await cli().parseAsync(["node", "rig", "network", "disconnect", "dev-impl@lab", "--reason", "done"]);
|
||||
expect(String(out.mock.calls.at(-1)?.[0])).toMatch(/^Disconnected netb-[0-9a-f]{20} dev-impl@lab -> alex@team .*disconnected \(done\)/);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it("names the daemon's refusal and exits 1, and an unreadable token file sends nothing", async () => {
|
||||
const err = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
await cli({ "/tok": "tok-abc" }).parseAsync(["node", "rig", "network", "connect", "nobody@lab", "--hub", "https://hub.example/mcp", "--as", "alex@team", "--token-file", "/tok"]);
|
||||
expect(String(err.mock.calls.at(-1)?.[0])).toBe("No local seat is named 'nobody@lab'. (seat_not_found)");
|
||||
expect(process.exitCode).toBe(1);
|
||||
process.exitCode = undefined;
|
||||
await cli().parseAsync(["node", "rig", "network", "connect", "dev-impl@lab", "--hub", "https://hub.example/mcp", "--as", "alex@team", "--token-file", "/missing"]);
|
||||
expect(String(err.mock.calls.at(-1)?.[0])).toBe("Can't read the token file: no such file");
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect((await app.request("/api/network/bindings").then((r) => r.json()) as { bindings: unknown[] }).bindings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("a plain-http hub needs --allow-http: refused with the remedy, then connected and shown unencrypted in status and JSON", async () => {
|
||||
const out = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const err = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const args = ["node", "rig", "network", "connect", "dev-impl@lab", "--hub", "http://192.0.2.7:7433/mcp", "--as", "alex@team", "--token-file", "/tok"];
|
||||
await cli({ "/tok": "tok-abc" }).parseAsync(args);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(String(err.mock.calls.at(-1)?.[0])).toMatch(/plain http, .*unencrypted.*--allow-http to accept that for this binding\. \(http_not_allowed\)$/);
|
||||
process.exitCode = undefined;
|
||||
|
||||
await cli({ "/tok": "tok-abc" }).parseAsync([...args, "--allow-http"]);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
expect(String(out.mock.calls.at(-1)?.[0])).toMatch(/^Connected netb-[0-9a-f]{20} dev-impl@lab -> alex@team at http:\/\/192\.0\.2\.7:7433\/mcp UNENCRYPTED \(http, allowed for this binding\)/);
|
||||
await cli().parseAsync(["node", "rig", "network", "status"]);
|
||||
expect(String(out.mock.calls.at(-1)?.[0])).toContain("UNENCRYPTED (http, allowed for this binding)");
|
||||
await cli().parseAsync(["node", "rig", "network", "status", "--json"]);
|
||||
const json = String(out.mock.calls.at(-1)?.[0]);
|
||||
expect(JSON.parse(json).bindings).toEqual([expect.objectContaining({ hubUrl: "http://192.0.2.7:7433/mcp", allowHttp: true, unencrypted: true })]);
|
||||
expect(json).not.toContain("tok-abc");
|
||||
});
|
||||
|
||||
it("status shows a hub item closed elsewhere under its binding, in text and JSON", async () => {
|
||||
const out = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
await cli({ "/tok": "tok-abc" }).parseAsync(["node", "rig", "network", "connect", "dev-impl@lab", "--hub", "https://hub.example/mcp", "--as", "alex@team", "--token-file", "/tok"]);
|
||||
const bindingId = String(out.mock.calls.at(-1)?.[0]).match(/netb-[0-9a-f]{20}/)![0];
|
||||
db.prepare(`INSERT INTO network_receipts (local_qitem_id, binding_id, hub_qitem_id, created_at, settled_at, outcome, last_error)
|
||||
VALUES ('qitem-net-0001', ?, 'hub-9', '2026-11-11T19:00:00Z', '2026-11-11T19:01:00Z', 'closed_on_hub', 'closed elsewhere (done, canceled)')`).run(bindingId);
|
||||
await cli().parseAsync(["node", "rig", "network", "status"]);
|
||||
expect(String(out.mock.calls.at(-1)?.[0])).toContain("\n hub item hub-9 was closed elsewhere before its result arrived; the result stays on qitem-net-0001");
|
||||
await cli().parseAsync(["node", "rig", "network", "status", "--json"]);
|
||||
expect(JSON.parse(String(out.mock.calls.at(-1)?.[0])).bindings[0].closedElsewhere).toEqual([{ hubItem: "hub-9", localItem: "qitem-net-0001", note: "closed elsewhere (done, canceled)" }]);
|
||||
});
|
||||
|
||||
it("sends the operator bearer on its writes, so a daemon with one configured takes them", async () => {
|
||||
app = routesWith("op-secret");
|
||||
const out = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const err = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const connectArgs = ["node", "rig", "network", "connect", "dev-impl@lab", "--hub", "https://hub.example/mcp", "--as", "alex@team", "--token-file", "/tok"];
|
||||
vi.stubEnv("OPENRIG_AUTH_BEARER_TOKEN", "wrong");
|
||||
await cli({ "/tok": "tok-abc" }).parseAsync(connectArgs);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(String(err.mock.calls.at(-1)?.[0])).toContain("bearer token does not match");
|
||||
process.exitCode = undefined;
|
||||
vi.stubEnv("OPENRIG_AUTH_BEARER_TOKEN", "op-secret");
|
||||
await cli({ "/tok": "tok-abc" }).parseAsync(connectArgs);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
expect(String(out.mock.calls.at(-1)?.[0])).toMatch(/^Connected netb-/);
|
||||
await cli().parseAsync(["node", "rig", "network", "disconnect", "dev-impl@lab"]);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
expect(String(out.mock.calls.at(-1)?.[0])).toMatch(/^Disconnected netb-/);
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
// The outbound connector against a real hub: the daemon's routes behind `rig mcp serve --http` with a grant per
|
||||
// participant, reached by the connector's own MCP client. A second, local queue takes the work, and what happens there
|
||||
// comes back to the hub item: queued, picked up, the result, and a revoked grant ending the binding.
|
||||
import { afterEach, beforeEach, expect, it } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { serve, type ServerType } from "@hono/node-server";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createTestApp } from "../../daemon/test/helpers/test-app.js";
|
||||
import { createDb } from "../../daemon/src/db/connection.js";
|
||||
import { migrate } from "../../daemon/src/db/migrate.js";
|
||||
import { ALL_MIGRATIONS } from "../../daemon/src/db/all-migrations.js";
|
||||
import { EventBus } from "../../daemon/src/domain/event-bus.js";
|
||||
import { QueueRepository } from "../../daemon/src/domain/queue-repository.js";
|
||||
import { OutboxHandler } from "../../daemon/src/domain/outbox-handler.js";
|
||||
import { RigRepository } from "../../daemon/src/domain/rig-repository.js";
|
||||
import { NetworkBindingStore } from "../../daemon/src/domain/network/network-bindings.js";
|
||||
import { networkClosedElsewhere, runNetworkBindingPass, type NetworkConnectorDeps } from "../../daemon/src/domain/network/network-connector.js";
|
||||
import { addGrant, revokeGrant, startMcpHttpServer } from "../src/mcp-http.js";
|
||||
|
||||
let hubDb: Database.Database;
|
||||
let localDb: Database.Database;
|
||||
let hubHome: string;
|
||||
let localHome: string;
|
||||
let hubRepo: QueueRepository;
|
||||
let daemon: ServerType;
|
||||
let hubUrl: string;
|
||||
let tokenFile: string;
|
||||
const closers: Array<() => Promise<void>> = [];
|
||||
const saved = { home: process.env.OPENRIG_HOME, session: process.env.OPENRIG_SESSION_NAME };
|
||||
|
||||
const queue = (db: Database.Database) => {
|
||||
const repo = new QueueRepository(db, new EventBus(db), { validateRig: () => true, loadHumanRegistry: () => ({ ok: true as const, entities: [] }) });
|
||||
repo.attachOutbox(new OutboxHandler(db));
|
||||
return repo;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
hubDb = createDb(":memory:");
|
||||
migrate(hubDb, ALL_MIGRATIONS);
|
||||
hubHome = fs.mkdtempSync(path.join(os.tmpdir(), "network-hub-"));
|
||||
localHome = fs.mkdtempSync(path.join(os.tmpdir(), "network-local-"));
|
||||
process.env.OPENRIG_HOME = hubHome;
|
||||
process.env.OPENRIG_SESSION_NAME = "env-seat@elsewhere";
|
||||
tokenFile = path.join(hubHome, "mcp-http-tokens.json");
|
||||
hubRepo = queue(hubDb);
|
||||
const inert = async () => ({ ok: false, error: "inert fixture" });
|
||||
const { app } = createTestApp(hubDb, { appDeps: {
|
||||
sessionTransport: { resolveSessions: async () => ({ ok: false, code: "not_found", error: "inert fixture" }), send: inert, capture: inert, broadcast: inert } as never,
|
||||
queueRepo: hubRepo,
|
||||
} });
|
||||
daemon = await new Promise<ServerType>((resolve) => { const s = serve({ fetch: app.fetch, port: 0, hostname: "127.0.0.1" }, () => resolve(s)); });
|
||||
const served = await startMcpHttpServer({ daemonUrl: `http://127.0.0.1:${(daemon.address() as AddressInfo).port}`, host: "127.0.0.1", port: 0, tokenFile });
|
||||
closers.push(() => served.close());
|
||||
hubUrl = served.url;
|
||||
|
||||
localDb = createDb(":memory:");
|
||||
migrate(localDb, ALL_MIGRATIONS);
|
||||
const rigs = new RigRepository(localDb);
|
||||
const node = rigs.addNode(rigs.createRig("lab").id, "impl", { runtime: "claude-code" });
|
||||
localDb.prepare("INSERT INTO sessions (id, node_id, session_name, status) VALUES ('s1', ?, 'dev-impl@lab', 'running')").run(node.id);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const close of closers.splice(0).reverse()) await close();
|
||||
await new Promise<void>((resolve) => daemon.close(() => resolve()));
|
||||
hubDb.close();
|
||||
localDb.close();
|
||||
fs.rmSync(hubHome, { recursive: true, force: true });
|
||||
fs.rmSync(localHome, { recursive: true, force: true });
|
||||
if (saved.home === undefined) delete process.env.OPENRIG_HOME; else process.env.OPENRIG_HOME = saved.home;
|
||||
if (saved.session === undefined) delete process.env.OPENRIG_SESSION_NAME; else process.env.OPENRIG_SESSION_NAME = saved.session;
|
||||
});
|
||||
|
||||
it("takes the participant's item over the wire, carries pickup and the result back, and a revoked grant ends the binding", async () => {
|
||||
const token = await addGrant(tokenFile, { name: "alex", seat: "alex@team", rigs: ["team"], roots: [], workspace: false, owner: false });
|
||||
// Longer than a list preview, with its end marked, so a shortened body would show.
|
||||
const body = `Summarize the release notes. ${"Context. ".repeat(400)}END-OF-BODY`;
|
||||
const task = await hubRepo.create({ sourceSession: "lead@team", destinationSession: "alex@team", summary: "Summarize the notes", body, nudge: false } as never);
|
||||
|
||||
const localRepo = queue(localDb);
|
||||
const bindings = new NetworkBindingStore(localDb, localHome);
|
||||
// The test hub listens on loopback http, so the binding records the person's --allow-http choice.
|
||||
const binding = bindings.create({ session: "dev-impl@lab", hubUrl, hubSeat: "alex@team", token, allowHttp: true });
|
||||
const deps: NetworkConnectorDeps = { db: localDb, queueRepo: localRepo, bindings, home: localHome };
|
||||
const pass = () => runNetworkBindingPass(deps, bindings.get(binding.id)!);
|
||||
const notes = () => hubRepo.listTransitions(task.qitemId).map((t) => t.transitionNote).filter(Boolean);
|
||||
|
||||
const first = await pass();
|
||||
expect(first.error).toBeUndefined();
|
||||
expect(first).toMatchObject({ taken: 1 });
|
||||
const [local] = localRepo.list({ limit: 50 });
|
||||
expect(local).toMatchObject({ sourceSession: "daemon@kernel", destinationSession: "dev-impl@lab", summary: "From lead@team via network hub: Summarize the notes", state: "pending" });
|
||||
expect(local!.body!.split("\n")[0]).toBe(`From lead@team through the network hub ${hubUrl}, as alex@team (hub item ${task.qitemId}).`);
|
||||
expect(local!.body).toContain(body);
|
||||
expect(hubRepo.getById(task.qitemId)!.state).toBe("in-progress");
|
||||
expect(notes().at(-1)).toBe(`Queued for the connected local seat as ${local!.qitemId}. Not picked up yet.`);
|
||||
expect(hubRepo.listTransitions(task.qitemId).find((t) => t.state === "in-progress")?.actorSession).toMatch(/^alex@team/);
|
||||
|
||||
expect(await pass()).toMatchObject({ taken: 0, pickupsSent: 0 });
|
||||
expect(localRepo.list({ limit: 50 })).toHaveLength(1);
|
||||
|
||||
localRepo.claim({ qitemId: local!.qitemId, destinationSession: "dev-impl@lab" });
|
||||
expect(await pass()).toMatchObject({ pickupsSent: 1 });
|
||||
expect(notes().at(-1)).toMatch(/^Picked up by the local seat at /);
|
||||
expect(hubRepo.getById(task.qitemId)!.state).toBe("in-progress");
|
||||
|
||||
await localRepo.update({ qitemId: local!.qitemId, actorSession: "dev-impl@lab", state: "done", closureReason: "no-follow-on", resolution: "Three changes; none breaking." });
|
||||
expect(await pass()).toMatchObject({ resultsSent: 1 });
|
||||
expect(hubRepo.getById(task.qitemId)).toMatchObject({ state: "done", resolution: "Three changes; none breaking." });
|
||||
expect(bindings.get(binding.id)!.lastError).toBeNull();
|
||||
|
||||
// A failure and a cancel close the hub item the way its queue takes them: failed, and done with closure reason canceled.
|
||||
const failing = await hubRepo.create({ sourceSession: "lead@team", destinationSession: "alex@team", summary: "Build it", body: "Build the release.", nudge: false } as never);
|
||||
const dropped = await hubRepo.create({ sourceSession: "lead@team", destinationSession: "alex@team", summary: "Skip it", body: "Maybe not.", nudge: false } as never);
|
||||
expect(await pass()).toMatchObject({ taken: 2 });
|
||||
const byHub = (id: string) => localRepo.list({ limit: 50 }).find((i) => (i.tags ?? []).includes(`network-hub-item:${id}`))!;
|
||||
await localRepo.update({ qitemId: byHub(failing.qitemId).qitemId, actorSession: "dev-impl@lab", state: "failed", transitionNote: "The build broke." });
|
||||
await localRepo.update({ qitemId: byHub(dropped.qitemId).qitemId, actorSession: "dev-impl@lab", state: "done", closureReason: "canceled", resolution: "Not needed." });
|
||||
expect(await pass()).toMatchObject({ resultsSent: 2 });
|
||||
expect(hubRepo.getById(failing.qitemId)).toMatchObject({ state: "failed", resolution: "The build broke." });
|
||||
expect(hubRepo.getById(dropped.qitemId)).toMatchObject({ state: "done", closureReason: "canceled", resolution: "Not needed." });
|
||||
|
||||
// Two items the requester fails on the hub while they're queued here. Our failed and done results then aren't ours to
|
||||
// deliver: the hub items keep their failure, and the receipts say closed elsewhere.
|
||||
const theirs = [
|
||||
await hubRepo.create({ sourceSession: "lead@team", destinationSession: "alex@team", summary: "One", body: "First.", nudge: false } as never),
|
||||
await hubRepo.create({ sourceSession: "lead@team", destinationSession: "alex@team", summary: "Two", body: "Second.", nudge: false } as never),
|
||||
];
|
||||
expect(await pass()).toMatchObject({ taken: 2 });
|
||||
for (const item of theirs) await hubRepo.update({ qitemId: item.qitemId, actorSession: "lead@team", state: "failed", resolution: "Dropped by the requester." } as never);
|
||||
await localRepo.update({ qitemId: byHub(theirs[0]!.qitemId).qitemId, actorSession: "dev-impl@lab", state: "failed", transitionNote: "Could not finish." });
|
||||
await localRepo.update({ qitemId: byHub(theirs[1]!.qitemId).qitemId, actorSession: "dev-impl@lab", state: "done", closureReason: "no-follow-on", resolution: "Finished it." });
|
||||
expect(await pass()).toMatchObject({ resultsSent: 0 });
|
||||
for (const item of theirs) expect(hubRepo.getById(item.qitemId)).toMatchObject({ state: "failed", resolution: "Dropped by the requester." });
|
||||
expect(localDb.prepare("SELECT DISTINCT outcome FROM network_receipts WHERE hub_qitem_id IN (?, ?)").all(theirs[0]!.qitemId, theirs[1]!.qitemId)).toEqual([{ outcome: "closed_on_hub" }]);
|
||||
|
||||
// An operator on the hub hands the original on to another seat while it's queued here. It's closed to the connector:
|
||||
// nothing is written to it, the original and its successor are unchanged, status shows it, and it isn't retried.
|
||||
const handed = await hubRepo.create({ sourceSession: "lead@team", destinationSession: "alex@team", summary: "Three", body: "Third.", nudge: false } as never);
|
||||
expect(await pass()).toMatchObject({ taken: 1 });
|
||||
const { created: successor } = await hubRepo.handoff({ qitemId: handed.qitemId, fromSession: "lead@team", toSession: "other@team", nudge: false });
|
||||
const before = hubRepo.listTransitions(handed.qitemId).length;
|
||||
await localRepo.update({ qitemId: byHub(handed.qitemId).qitemId, actorSession: "dev-impl@lab", state: "done", closureReason: "no-follow-on", resolution: "Done here." });
|
||||
expect(await pass()).toMatchObject({ resultsSent: 0 });
|
||||
expect(hubRepo.getById(handed.qitemId)!.state).toBe("handed-off");
|
||||
expect(hubRepo.listTransitions(handed.qitemId)).toHaveLength(before);
|
||||
expect(hubRepo.getById(successor.qitemId)).toMatchObject({ state: "pending", destinationSession: "other@team" });
|
||||
expect(networkClosedElsewhere(localDb, binding.id)).toContainEqual(expect.objectContaining({ hubItem: handed.qitemId, localItem: byHub(handed.qitemId).qitemId,
|
||||
note: expect.stringContaining("closed elsewhere (handed-off") }));
|
||||
await pass();
|
||||
expect(hubRepo.listTransitions(handed.qitemId)).toHaveLength(before);
|
||||
|
||||
await revokeGrant(tokenFile, "alex");
|
||||
expect(await pass()).toMatchObject({ error: "revoked" });
|
||||
expect(bindings.get(binding.id)!.state).toBe("revoked");
|
||||
});
|
||||
@@ -76,7 +76,7 @@ describe("SystemPreflight", () => {
|
||||
it("Node version below 22 → ready: false with guidance", async () => {
|
||||
Object.defineProperty(process, "version", { value: "v18.19.0", writable: true });
|
||||
const pf = createPreflight();
|
||||
const result = await pf.run();
|
||||
const result = await pf.run({ port: 0 });
|
||||
expect(result.ready).toBe(false);
|
||||
const nodeCheck = result.checks.find((c) => c.name === "node_version");
|
||||
expect(nodeCheck!.ok).toBe(false);
|
||||
@@ -104,7 +104,7 @@ describe("SystemPreflight", () => {
|
||||
// Test 3
|
||||
it("tmux missing → ready: false with guidance", async () => {
|
||||
const pf = createPreflight({ tmuxMode: "missing" });
|
||||
const result = await pf.run();
|
||||
const result = await pf.run({ port: 0 });
|
||||
expect(result.ready).toBe(false);
|
||||
const tmuxCheck = result.checks.find((c) => c.name === "tmux");
|
||||
expect(tmuxCheck!.ok).toBe(false);
|
||||
@@ -114,7 +114,7 @@ describe("SystemPreflight", () => {
|
||||
|
||||
it("tmux installed but default control socket unhealthy → ready: false with guidance", async () => {
|
||||
const pf = createPreflight({ tmuxMode: "unhealthy" });
|
||||
const result = await pf.run();
|
||||
const result = await pf.run({ port: 0 });
|
||||
|
||||
expect(result.ready).toBe(false);
|
||||
const tmuxCheck = result.checks.find((c) => c.name === "tmux");
|
||||
@@ -135,7 +135,7 @@ describe("SystemPreflight", () => {
|
||||
getDaemonStatus: makeStatus(),
|
||||
riggedHome: join(readonlyDir, "nested"),
|
||||
});
|
||||
const result = await pf.run();
|
||||
const result = await pf.run({ port: 0 });
|
||||
expect(result.ready).toBe(false);
|
||||
const homeCheck = result.checks.find((c) => c.name === "writable_home");
|
||||
expect(homeCheck!.ok).toBe(false);
|
||||
@@ -187,9 +187,18 @@ describe("SystemPreflight", () => {
|
||||
const pf = createPreflight({
|
||||
daemonStatus: { state: "running", port: 7433, host: "127.0.0.1", healthy: true },
|
||||
});
|
||||
const result = await pf.run({ port: 7433, host: "127.0.0.1" });
|
||||
const portCheck = result.checks.find((c) => c.name === "port_available");
|
||||
expect(portCheck!.ok).toBe(true);
|
||||
// A regressed skip would fail at construction before it could reach any socket.
|
||||
const constructor = vi.spyOn(net, "Socket").mockImplementation(() => {
|
||||
throw new Error("same-endpoint preflight must not construct a probe socket");
|
||||
});
|
||||
try {
|
||||
const result = await pf.run({ port: 7433, host: "127.0.0.1" });
|
||||
const portCheck = result.checks.find((c) => c.name === "port_available");
|
||||
expect(portCheck).toMatchObject({ ok: true });
|
||||
expect(constructor).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
constructor.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
// Test 7
|
||||
@@ -198,18 +207,33 @@ describe("SystemPreflight", () => {
|
||||
const pf = createPreflight({
|
||||
daemonStatus: { state: "running", port: 7433, host: "127.0.0.1", healthy: true },
|
||||
});
|
||||
// Use a port that's actually available, but the skip rule should not apply
|
||||
const result = await pf.run({ port: 7433, host: "10.0.0.5" });
|
||||
const portCheck = result.checks.find((c) => c.name === "port_available");
|
||||
// Should have attempted the check (not skipped)
|
||||
expect(portCheck).toBeDefined();
|
||||
// A deterministic refusal proves the requested endpoint without dialing it.
|
||||
const socket = Object.assign(new EventEmitter(), {
|
||||
setTimeout: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
connect: vi.fn((_port: number, _host: string) => {
|
||||
queueMicrotask(() => socket.emit("error", Object.assign(new Error("connection refused"), { code: "ECONNREFUSED" })));
|
||||
}),
|
||||
});
|
||||
const constructor = vi.spyOn(net, "Socket").mockImplementation(() => socket as unknown as net.Socket);
|
||||
try {
|
||||
const result = await pf.run({ port: 7433, host: "10.0.0.5" });
|
||||
const portCheck = result.checks.find((c) => c.name === "port_available");
|
||||
expect(portCheck).toMatchObject({ ok: true });
|
||||
expect(constructor).toHaveBeenCalledTimes(1);
|
||||
expect(socket.connect).toHaveBeenCalledTimes(1);
|
||||
expect(socket.connect).toHaveBeenCalledWith(7433, "10.0.0.5");
|
||||
expect(socket.destroy).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
constructor.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
// Test 8
|
||||
it("multiple failures reported together (additive)", async () => {
|
||||
Object.defineProperty(process, "version", { value: "v18.19.0", writable: true });
|
||||
const pf = createPreflight({ tmuxMode: "missing" });
|
||||
const result = await pf.run();
|
||||
const result = await pf.run({ port: 0 });
|
||||
expect(result.ready).toBe(false);
|
||||
const failedChecks = result.checks.filter((c) => !c.ok);
|
||||
expect(failedChecks.length).toBeGreaterThanOrEqual(2);
|
||||
@@ -343,9 +367,12 @@ describe("Preflight CLI", () => {
|
||||
it("rig preflight with failure prints three-part error and exits 1", async () => {
|
||||
const homeDir = join(tmpDir, ".openrig");
|
||||
mkdirSync(homeDir, { recursive: true });
|
||||
const configPath = join(tmpDir, "config.json");
|
||||
const { writeFileSync } = await import("node:fs");
|
||||
writeFileSync(configPath, JSON.stringify({ daemon: { port: 0 } }));
|
||||
const cmd = preflightCommand({
|
||||
exec: makeExec("missing"),
|
||||
configPath: join(tmpDir, "config.json"),
|
||||
configPath,
|
||||
riggedHome: homeDir,
|
||||
getDaemonStatus: makeStatus(),
|
||||
});
|
||||
|
||||
@@ -98,6 +98,18 @@ it("a seat that isn't in this daemon's inventory is unknown, never 'fine'", asyn
|
||||
{ seat: "x@elsewhere", why: "no rig named elsewhere on this daemon" }]);
|
||||
});
|
||||
|
||||
it("finds attention for a seat whose rig name contains @", async () => {
|
||||
const { client: c } = client({
|
||||
"/api/ps": { status: 200, data: [{ rigId: "r1", rigName: "team@east" }] },
|
||||
"/api/rigs/r1/nodes": { status: 200, data: [
|
||||
{ canonicalSessionName: "lead@team@east", needsAttention: true, startupStatus: "attention_required", latestError: LOGIN },
|
||||
] },
|
||||
});
|
||||
expect(await checkSeatAttention(c, ["lead@team@east"])).toEqual({
|
||||
attention: [{ seat: "lead@team@east", reason: LOGIN, waitingOnPerson: false }], unknown: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("an unreadable kernel status is reported, not read as no held seat", async () => {
|
||||
const { client: c } = client({ "/api/kernel/status": { status: 503, data: { error: "kernel_boot_tracker_unavailable" } } });
|
||||
expect(await checkKernelAttention(c)).toEqual({ attention: [],
|
||||
|
||||
@@ -214,6 +214,30 @@ describe("rig whoami --json: bounded peers (output.compact, output.whoami_peers)
|
||||
for (const p of BIG_PEERS) expect(out).toContain(p.logicalId);
|
||||
});
|
||||
|
||||
// #2871: while `rig up` is still building the team, every form of the answer keeps saying so.
|
||||
it("a team still being built: rosterState and the daemon's opening survive bounding, and edges aren't called complete", async () => {
|
||||
const BUILDING = "rosterState = building: `rig up` is still creating this team's seats and edges, so the peers and edges "
|
||||
+ "below are only the ones created so far and more may come; run `rig whoami` again once `rig up` finishes. ";
|
||||
served = { ...response(BIG_PEERS), rosterState: "building", peersNote: BUILDING + DAEMON_NOTE };
|
||||
// Switch off: the daemon's own note and state.
|
||||
let out = JSON.parse(await run("--json"));
|
||||
expect(out.rosterState).toBe("building");
|
||||
expect(out.peersNote).toBe(BUILDING + DAEMON_NOTE);
|
||||
// Switch on: the bounded note opens the same way, and names the edges as the ones written so far.
|
||||
vi.stubEnv("OPENRIG_OUTPUT_COMPACT", "1");
|
||||
out = JSON.parse(await run("--json"));
|
||||
expect(out.rosterState).toBe("building");
|
||||
expect(out.peersOmitted).toBe(15);
|
||||
expect(out.peersNote).toBe(
|
||||
BUILDING
|
||||
+ "peers = 8 of this rig's 23 (roster excluding self; edge peers first, then your pod, then roster order; "
|
||||
+ "bound output.whoami_peers=8); 15 not shown; all: rig whoami --json --all-peers --node-id 'node-1'; "
|
||||
+ "edges = directional relationships (the ones written so far); use `rig ps --nodes` for node inventory including self + live state",
|
||||
);
|
||||
// Plain text says it too.
|
||||
expect(await run()).toContain("Team: still being built by `rig up`");
|
||||
});
|
||||
|
||||
it("the full-list command names the identity the call used: --session, quoted", () => {
|
||||
const bounded = boundPeers(response(BIG_PEERS), 2, "rig whoami --json --all-peers --session 'dev-impl@big'");
|
||||
expect(bounded.note).toContain("all: rig whoami --json --all-peers --session 'dev-impl@big';");
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"url": "https://github.com/mvschwarz"
|
||||
},
|
||||
"homepage": "https://github.com/mvschwarz/openrig-plugins",
|
||||
"repository": "github:mvschwarz/openrig-plugins",
|
||||
"repository": "https://github.com/mvschwarz/openrig-plugins",
|
||||
"license": "Apache-2.0",
|
||||
"keywords": ["openrig", "agent", "topology", "rig", "continuity", "coordination", "compaction", "handoff"],
|
||||
"skills": "./skills",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"url": "https://github.com/mvschwarz"
|
||||
},
|
||||
"homepage": "https://github.com/mvschwarz/openrig-plugins",
|
||||
"repository": "github:mvschwarz/openrig-plugins",
|
||||
"repository": "https://github.com/mvschwarz/openrig-plugins",
|
||||
"license": "Apache-2.0",
|
||||
"keywords": ["openrig", "agent", "topology", "rig", "continuity", "coordination", "compaction", "handoff"],
|
||||
"skills": "./skills",
|
||||
|
||||
@@ -50,6 +50,12 @@ This plugin ships dual-manifest packaging — both `.claude-plugin/` and `.codex
|
||||
no-ops. The plugin supplies refocus and skills. Disabling an inline activity hook is not bypassed by the plugin.
|
||||
- **Seat scope:** the commands require both `OPENRIG_SEAT_PROJECT_DIR` and the executable `OPENRIG_NODE`. Personal and
|
||||
unselected sessions are no-ops. A nested Codex inheriting those seat variables still acts as that seat.
|
||||
- **Refocus output:** the Codex prompt handler keeps its unchanged declaration and default 2,500-token budget
|
||||
(10,000 UTF-8 bytes), preserving its native trust identity. Larger packets
|
||||
send a prefix and an explicit shortening notice for the current attempt. After a claimed, successful render/write
|
||||
and state save, the refocus record lists that attempt's omitted tail byte count and full packet's `.context.txt`
|
||||
path, or says the full text could not be saved. A trace/content failure or unavailable final state guard/write can
|
||||
leave older metadata or no record. The metadata describes the last successfully recorded attempt, not model receipt.
|
||||
|
||||
Generated Claude settings commands for the activity relay and context collector use the daemon's absolute Node
|
||||
executable when its name is recognized (`node` or `node.exe`); other names keep the legacy bare `node` command and
|
||||
|
||||
@@ -144,10 +144,134 @@ function buildOpenRigPayload(providerPayload, env = process.env, now = () => new
|
||||
...promptBoundaryFields(hookEvent, providerPayload),
|
||||
occurredAt: now().toISOString(),
|
||||
relayRoute: argv.includes("--from-plugin") ? "plugin" : "managed",
|
||||
...limitResetField(runtime, hookEvent, subtype, providerPayload, now),
|
||||
...conversationFields(providerPayload, env),
|
||||
};
|
||||
}
|
||||
|
||||
// #99: a Claude usage limit says when it resets only in its rendered text ("You've hit your weekly limit ·
|
||||
// resets Nov 10, 4pm (America/Sao_Paulo)"). The typed error still decides THAT the seat is limited; the text
|
||||
// is read here, on the seat's machine and in its time zone, only for WHEN, and only the instant leaves the
|
||||
// relay: never the text itself.
|
||||
function limitResetField(runtime, hookEvent, subtype, providerPayload, now) {
|
||||
if (runtime !== "claude-code" || hookEvent !== "StopFailure" || subtype !== "rate_limit") return {};
|
||||
for (const value of [providerPayload.last_assistant_message, providerPayload.error_details]) {
|
||||
if (typeof value !== "string") continue;
|
||||
const resetsAt = claudeLimitResetAt(value.slice(0, 2000), now());
|
||||
if (resetsAt) return { resetsAt };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
const RESET_MAX_MS = 8 * 24 * 3600 * 1000;
|
||||
const MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
|
||||
const RESET_EPOCH_RE = /usage limit reached\|(\d{10})\b/i;
|
||||
// Each unit must be a whole word ("2 months" is not "2 m"), and a "resets in" fragment is read only this way.
|
||||
const RESET_IN_LEAD_RE = /\bresets?\s+in\b/i;
|
||||
const RESET_IN_RE = /\bresets?\s+in\s+(?:(\d+)\s*(?:d|days?)\b\s*)?(?:(\d+)\s*(?:h|hrs?|hours?)\b\s*)?(?:(\d+)\s*(?:m|mins?|minutes?)\b)?/i;
|
||||
// A zone, when the text opens one, must be closed: "4pm (Not/AZone" is truncated, not local time.
|
||||
const RESET_AT_RE = /\bresets?\s+(?:at\s+|on\s+)?(?:([a-z]{3})[a-z]*\.?\s+(\d{1,2}),?\s+(?:at\s+)?)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b(?:\s*(\()([^)\n]{0,64})(\))?)?/i;
|
||||
|
||||
/** The reset instant a Claude usage-limit message states, as an ISO string, or null when it states none
|
||||
* this can read. A wall-clock time without a zone is the seat's local time, which is what Claude renders. */
|
||||
function claudeLimitResetAt(text, nowDate) {
|
||||
const nowMs = nowDate.getTime();
|
||||
if (!Number.isFinite(nowMs) || typeof text !== "string") return null;
|
||||
let resetMs = null;
|
||||
const epoch = RESET_EPOCH_RE.exec(text);
|
||||
if (epoch) {
|
||||
resetMs = Number(epoch[1]) * 1000;
|
||||
} else if (RESET_IN_LEAD_RE.test(text)) {
|
||||
const relative = RESET_IN_RE.exec(text);
|
||||
// The whole fragment must be read: nothing numeric may follow what was, across a separator either
|
||||
// ("in 2h 30x", "in 2h, 30x" and "in 2h and 30x" are not two hours).
|
||||
if (!relative || !(relative[1] || relative[2] || relative[3])
|
||||
|| /^(?:\s|[,;&+]|and\b)*\d/i.test(text.slice(relative.index + relative[0].length))) return null;
|
||||
resetMs = nowMs + ((Number(relative[1] || 0) * 24 + Number(relative[2] || 0)) * 60 + Number(relative[3] || 0)) * 60_000;
|
||||
} else {
|
||||
const wall = RESET_AT_RE.exec(text);
|
||||
if (wall) resetMs = wallClockReset(wall, nowMs);
|
||||
}
|
||||
if (resetMs === null || !Number.isFinite(resetMs) || resetMs <= nowMs || resetMs > nowMs + RESET_MAX_MS) return null;
|
||||
return new Date(resetMs).toISOString();
|
||||
}
|
||||
|
||||
function wallClockReset(match, nowMs) {
|
||||
const [, monthName, day, hourText, minuteText, meridiem, zoneOpen, zoneText, zoneClose] = match;
|
||||
if (zoneOpen && (!zoneClose || !zoneText.trim())) return null; // a truncated or empty zone: no reset
|
||||
const hour12 = Number(hourText);
|
||||
const minute = Number(minuteText || 0);
|
||||
if (hour12 < 1 || hour12 > 12 || minute > 59) return null;
|
||||
const hour = (hour12 % 12) + (meridiem.toLowerCase() === "pm" ? 12 : 0);
|
||||
const zone = zoneText ? zoneText.trim() : null;
|
||||
let toUtc;
|
||||
try {
|
||||
toUtc = zone ? zonedClock(zone) : localClock();
|
||||
} catch {
|
||||
return null; // a zone this runtime doesn't know: no reset rather than a guessed one
|
||||
}
|
||||
const today = toUtc.partsAt(nowMs);
|
||||
if (monthName) {
|
||||
const month = MONTHS.indexOf(monthName.toLowerCase());
|
||||
if (month < 0) return null;
|
||||
for (const year of [today.year, today.year + 1]) {
|
||||
if (!isCalendarDay(year, month, Number(day))) continue; // "Feb 30" names no day; never roll it into March
|
||||
const at = toUtc.instant(year, month, Number(day), hour, minute);
|
||||
if (Number.isNaN(at)) return null;
|
||||
if (at > nowMs) return at;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const at = toUtc.instant(today.year, today.month, today.day, hour, minute);
|
||||
if (Number.isNaN(at)) return null; // ambiguous today: never advance a day on a guess
|
||||
return at > nowMs ? at : toUtc.instant(today.year, today.month, today.day + 1, hour, minute);
|
||||
}
|
||||
|
||||
function isCalendarDay(year, month, day) {
|
||||
return Number.isInteger(day) && day >= 1 && day <= new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
|
||||
}
|
||||
|
||||
function localClock() {
|
||||
return wallClock(
|
||||
(ms) => { const d = new Date(ms); return { year: d.getFullYear(), month: d.getMonth(), day: d.getDate(), hour: d.getHours(), minute: d.getMinutes() }; },
|
||||
(ms) => -new Date(ms).getTimezoneOffset() * 60_000,
|
||||
);
|
||||
}
|
||||
|
||||
function zonedClock(timeZone) {
|
||||
const format = new Intl.DateTimeFormat("en-US", { timeZone, hourCycle: "h23", year: "numeric", month: "numeric",
|
||||
day: "numeric", hour: "numeric", minute: "numeric", second: "numeric" });
|
||||
const partsAt = (ms) => {
|
||||
const parts = Object.fromEntries(format.formatToParts(new Date(ms)).map((part) => [part.type, Number(part.value)]));
|
||||
return { year: parts.year, month: parts.month - 1, day: parts.day, hour: parts.hour, minute: parts.minute, second: parts.second };
|
||||
};
|
||||
const offsetAt = (ms) => {
|
||||
const p = partsAt(ms);
|
||||
return Date.UTC(p.year, p.month, p.day, p.hour, p.minute, p.second) - Math.floor(ms / 1000) * 1000;
|
||||
};
|
||||
return wallClock(partsAt, offsetAt);
|
||||
}
|
||||
|
||||
/** A clock whose instant() is the one moment a wall time names, or NaN when it names none (skipped by a DST jump) or
|
||||
* two (repeated by a DST fall-back): the message doesn't say which, so no reset rather than a guess a day off. */
|
||||
function wallClock(partsAt, offsetAt) {
|
||||
return {
|
||||
partsAt,
|
||||
instant: (year, month, day, hour, minute) => {
|
||||
const asUtc = Date.UTC(year, month, day, hour, minute);
|
||||
const want = new Date(asUtc); // normalizes a day past the month's end (tomorrow)
|
||||
const matches = new Set();
|
||||
for (const probe of [asUtc - 14 * 3600_000, asUtc, asUtc + 14 * 3600_000]) {
|
||||
const candidate = asUtc - offsetAt(probe);
|
||||
const p = partsAt(candidate);
|
||||
if (p.year === want.getUTCFullYear() && p.month === want.getUTCMonth() && p.day === want.getUTCDate()
|
||||
&& p.hour === want.getUTCHours() && p.minute === want.getUTCMinutes()) matches.add(candidate);
|
||||
}
|
||||
return matches.size === 1 ? [...matches][0] : NaN;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// OPR.0.4.3.28 B1+B3 — resolve the ingest base URL + token without depending on
|
||||
// the operator seeding OPENRIG_URL/OPENRIG_ACTIVITY_HOOK_TOKEN into the shell:
|
||||
// 1. env OPENRIG_URL / token (unchanged fast path).
|
||||
@@ -390,6 +514,7 @@ if (require.main === module) {
|
||||
|
||||
module.exports = {
|
||||
buildOpenRigPayload,
|
||||
claudeLimitResetAt,
|
||||
claimFirstLaunchHook,
|
||||
buildSessionIdentityPayload,
|
||||
parseJson,
|
||||
|
||||
@@ -630,10 +630,30 @@ function runsForThisSeatsProject(env = process.env) {
|
||||
"",
|
||||
...body,
|
||||
]).join("\n");
|
||||
let additionalContext = payload;
|
||||
let contextOutput;
|
||||
if (runtime() === "codex") {
|
||||
// Keep the handler declaration unchanged; Codex's default is 2,500 tokens, ceil(UTF-8 bytes / 4).
|
||||
const limitTokens = 2_500;
|
||||
const bytes = Buffer.from(payload);
|
||||
contextOutput = { status: "full", limitTokens, originalBytes: bytes.length, outputBytes: bytes.length, omittedBytes: 0 };
|
||||
if (bytes.length > limitTokens * 4) {
|
||||
let fullPath = `${stateFile}.${crypto.randomUUID()}.context.txt`;
|
||||
try { fs.writeFileSync(fullPath, payload, { flag: "wx", mode: 0o600 }); }
|
||||
catch { fullPath = null; }
|
||||
const location = fullPath ? `Full text saved to: ${fullPath}` : "Full text could not be saved.";
|
||||
// Reserve the notice before taking the prefix; streaming decode drops an incomplete final UTF-8 character.
|
||||
const prefix = new TextDecoder().decode(bytes.subarray(0, Math.max(0, limitTokens * 4 - Buffer.byteLength(location) - 256)), { stream: true });
|
||||
const prefixBytes = Buffer.byteLength(prefix);
|
||||
const omittedBytes = bytes.length - prefixBytes;
|
||||
additionalContext = `${prefix}\n\n[REFOCUS SHORTENED: omitted ${omittedBytes} trailing UTF-8 bytes from the assembled trace, role and standing instructions. ${location}]`;
|
||||
contextOutput = { status: "shortened", limitTokens, originalBytes: bytes.length, outputBytes: Buffer.byteLength(additionalContext), prefixBytes, omittedBytes, fullPath };
|
||||
}
|
||||
}
|
||||
const output = JSON.stringify({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: "UserPromptSubmit",
|
||||
additionalContext: payload,
|
||||
additionalContext,
|
||||
},
|
||||
});
|
||||
process.stdout.write(output, (error) => {
|
||||
@@ -661,6 +681,9 @@ function runsForThisSeatsProject(env = process.env) {
|
||||
if (size > 0 && latest.lastReset?.at === state.lastReset?.at) latest.lastBytes = size;
|
||||
latest.firedAt = new Date().toISOString();
|
||||
latest.firedOn = event;
|
||||
// Writer completion, not a model-context receipt. A shortened output names its omitted tail and full-text file.
|
||||
if (contextOutput) latest.contextOutput = contextOutput;
|
||||
else delete latest.contextOutput;
|
||||
if (samePending) {
|
||||
delete latest.pendingOn;
|
||||
delete latest.pendingAt;
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
@@ -7,7 +7,7 @@
|
||||
"url": "https://github.com/mvschwarz"
|
||||
},
|
||||
"homepage": "https://github.com/mvschwarz/openrig",
|
||||
"repository": "github:mvschwarz/openrig",
|
||||
"repository": "https://github.com/mvschwarz/openrig",
|
||||
"license": "Apache-2.0",
|
||||
"keywords": ["openrig", "agents", "team", "delegation", "mcp"],
|
||||
"skills": "./skills",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"url": "https://github.com/mvschwarz"
|
||||
},
|
||||
"homepage": "https://github.com/mvschwarz/openrig",
|
||||
"repository": "github:mvschwarz/openrig",
|
||||
"repository": "https://github.com/mvschwarz/openrig",
|
||||
"license": "Apache-2.0",
|
||||
"keywords": [
|
||||
"openrig",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"url": "https://github.com/mvschwarz"
|
||||
},
|
||||
"homepage": "https://github.com/mvschwarz/openrig",
|
||||
"repository": "github:mvschwarz/openrig",
|
||||
"repository": "https://github.com/mvschwarz/openrig",
|
||||
"license": "Apache-2.0",
|
||||
"keywords": ["openrig", "agents", "team", "delegation", "mcp"]
|
||||
}
|
||||
@@ -11,7 +11,7 @@ not know exists.** So the point of what follows is not skill. It is recognition:
|
||||
lands, something rhymes, and you go check instead of building.
|
||||
|
||||
Ninety-two top-level verbs ship (capability canon refreshed through
|
||||
`capability-delta-v0.7.11`). This marker describes the pack's teaching, not publication or live
|
||||
`capability-delta-v0.7.12`). This marker describes the pack's teaching, not publication or live
|
||||
adoption. Read this once for shape, and let it make you suspicious that a thing already exists.
|
||||
What changed in the installed version, and what to do about it, is one read away:
|
||||
`rig context get reference/whats-new.md`.
|
||||
@@ -62,6 +62,8 @@ one command away. Your memory of the fleet is a claim about the past.
|
||||
arguments) and Claude's bounded suggested rules, which can contain paths, patterns or sensitive
|
||||
values. It's evidence, not an applied permission: it applies and approves nothing, and it's held
|
||||
in memory for five minutes and lost on a daemon restart (`docs/reference/seat-delivery.md`).
|
||||
It also shows when guidance was written relative to the session's recorded moments, and what
|
||||
the seat's refocus hook recorded; every seat's managed guidance points to its skills, help and project context.
|
||||
- **`rig view list` / `rig view show <lens>`** — named lenses over coordination state: use
|
||||
`view show escalations` for owner attention, `view show pickup` for claimed-row state, and
|
||||
`view show execution` for done/now/next and current authored planning guidance with its
|
||||
@@ -104,6 +106,10 @@ one command away. Your memory of the fleet is a claim about the past.
|
||||
before querying the database yourself. If `retention.events_days` is set (default `0` keeps
|
||||
everything), the history-only event types (views, snapshots, watchdog firings, queue events) older
|
||||
than that are pruned, and an older window reports them as a `retained_sequence_gap`.
|
||||
Closed work's content and metadata can also be erased on a schedule
|
||||
(`retention.closed_content_days`, `retention.closed_metadata_days`; `0`, the default, keeps them),
|
||||
with an optional `retention.erasure_ledger`, for a retention policy or an account deletion. It's
|
||||
opt-in, and a retried create can't bring erased work back.
|
||||
- **Check the exit code.** Many commands used to print nothing and exit 0 when a daemon read
|
||||
failed; now they exit 1 or 2. Empty output from a failed read is not empty state.
|
||||
|
||||
@@ -142,6 +148,9 @@ unseen — which makes messages the one delivery channel that never gets skipped
|
||||
A seat's typing guard and `--wait-for-idle` retain their checks. Managed compaction follows the
|
||||
ordinary draft default and explicit operator protections; it separately reconciles its own
|
||||
uncertain `/compact` write before sending restore input. Draft detection isn't complete.
|
||||
**Each send records one `transport.delivery_evidence` event**: what was admitted, what reached
|
||||
the input, what the screen check showed, and for which occupant. Read it as what was known about
|
||||
the delivery, not proof the agent read it; where the input landed can read unknown.
|
||||
- **`rig send --raw`** — send exact text or keystrokes without the From/To envelope;
|
||||
the interactive-prompt guard still applies. To deliberately drive an interactive prompt,
|
||||
**`--dangerously-interact --reason "<why>"`** is the explicit override and implies raw text.
|
||||
@@ -307,6 +316,8 @@ unseen — which makes messages the one delivery channel that never gets skipped
|
||||
**Open terminals ▸** opens the inspected plan through the same desktop launcher, herdr or plain
|
||||
tmux, so the TUI must run on the daemon's desktop; when no window was attempted it shows the
|
||||
preview and attach commands. Help or a side trip returns to the same preview.
|
||||
`--provider herdr-app` opens the view inside the herdr-app desktop window as an equal grid with a
|
||||
title per cell; it's a one-shot view (following isn't claimed), so check the cells yourself.
|
||||
- **`rig walk <seat> --through <files> --pace <n>`** — deliver context pieces through
|
||||
file-backed terminal paste. When the current native generation record resolves, the complete
|
||||
piece and its matching Claude/Codex turn closure must arrive before pacing onward, including
|
||||
@@ -374,6 +385,9 @@ your context, your compaction and your replacement — and it is the only thing
|
||||
another host, as `create` and `handoff` do; `claim` doesn't, since only the destination seat
|
||||
claims, on its own host. `rig queue artifact <qitemId>` (and MCP `rig_artifact_read`) returns the
|
||||
exact commit pair from an `openrig.code-result/v1` manifest set as the row's `evidenceRef`.
|
||||
It names a handed-off row's successor. `rig queue create` advises when a row's scope doesn't
|
||||
resolve, and a custom blocker ID explains the wait in the waiting views (its progress gives no
|
||||
event wake; the timer remains).
|
||||
- **`rig queue claim` / `unclaim`** — is this mine and running, or still pending where two agents
|
||||
might double-work it. Put it down honestly when it is not yours.
|
||||
- **`rig queue update`** — record what happened in a form the rest of the system can act on. A
|
||||
@@ -655,6 +669,8 @@ not have to tear one down to change it, and you rarely have to start from nothin
|
||||
so capture the pane first; for an unattended seat use the Gateway preset, within what it declares.
|
||||
A Hermes gateway runs per Hermes home, so one per seat with the example launcher; its preset
|
||||
doesn't declare `resume`.
|
||||
A harness can declare `protocol: pi-rpc`, a preset can carry a launch wrapper, and a seat can
|
||||
switch between Pi and another runtime.
|
||||
- **`rig down`** — stop a rig's seats and take it out of the running set. It ends the agents'
|
||||
sessions and any work in progress; don't run it on a live team unless the person asked. In
|
||||
human mode it first prints one line with each agent's last known activity, or “unknown”: a
|
||||
@@ -666,6 +682,11 @@ not have to tear one down to change it, and you rarely have to start from nothin
|
||||
edited skills-line block, edited team skill files, and settings or a collector that don't match
|
||||
what OpenRig wrote or that another seat shares by a link. Any other guidance block goes even if
|
||||
someone edited it.
|
||||
**Since 0.7.12 it stops only a tmux session that carries the seat's own `OPENRIG_NODE_ID`**, on the
|
||||
server and session it read. A terminal OpenRig didn't create, a mismarked or replaced one, or a seat
|
||||
joined with `rig attach --self` is left running, and `rig down` exits 2 naming it and the manual way
|
||||
to stop it. Teardown is incomplete for those terminals: if one is yours, look at it and stop it
|
||||
yourself, then run `rig down` again.
|
||||
- **`rig launch <rig> [seat]`** — one seat is down; start just that one, without disturbing the
|
||||
rest. It takes the rig's name or ID; a name shared by more than one active rig stops and lists their IDs. Add `--plan` to preview it first, locally or with `--host`; a plan is never sent to a
|
||||
daemon older than 0.5.9, which would ignore it and launch. A seat whose agent exited to a shell in
|
||||
@@ -767,6 +788,8 @@ version, from checking the environment to a useful report to the OpenRig team.
|
||||
database another live daemon already holds (the default, `warn`, only warns), and
|
||||
`rig daemon start --allow-db-conflict` allows one deliberate start. A start still in progress reads
|
||||
`Daemon starting: launch reserved by pid N since T; not listening yet` in `rig daemon status`, not stopped.
|
||||
A start reservation left from an earlier boot is archived by the next start; don't clear it by
|
||||
hand.
|
||||
- **A busy daemon isn't a down one.** The CLI refuses only when the daemon is confirmed down
|
||||
(stopped, stale, a refused probe), its probe is blocked, or it's a sibling home; a busy daemon
|
||||
gets the real request. A write that then times out says its outcome is **unknown**: check its
|
||||
@@ -1239,6 +1262,14 @@ reaching them is ordinary work rather than an escalation.
|
||||
person can settle an ask that was asked of someone else, recorded as themselves beside the person
|
||||
asked. The first committed answer wins; a later one gets 409 `qitem_already_terminal` and records
|
||||
nothing. A park addressed to a registered person can be resolved.
|
||||
- **A person's answer goes to an agent.** Answers nothing else reads (a host pairing request, a
|
||||
mode's scheduled end) use the configured receiving agent or the kernel operator. Pairing and
|
||||
runner permission answers go to the receiving agent with the question for it to decide;
|
||||
qualified words ("only if it's the test file") need that judgment. Answers wait when no receiver
|
||||
is available: don't describe them as read or decided. A pairing request names its receiver, and
|
||||
a person can decide directly in the TUI's pairing settings. A relay connector that can't accept
|
||||
incoming connections can pull instead (`OPENRIG_RELAY_MODE=pull` in the relay secrets file); push
|
||||
stays the default, and a person's connector binding can be unset.
|
||||
- **`rig gateway channel add <id> --display-name <name> --binding
|
||||
slack:<connectorRef>:<secretsRef>:<channelId>`** — a Slack shared channel as one participant,
|
||||
`<id>@external` (Slack only). Anyone writing there reaches the team, registered or not. Each row
|
||||
@@ -1435,6 +1466,8 @@ your circumstances is configuration, and the ones that are not, another agent ca
|
||||
The choice is saved as `provider.selected`, and the kernel starts seats only for those providers
|
||||
(empty, the default, means every signed-in one). `--router-line yes|no` decides whether OpenRig
|
||||
adds its one line to shared guidance files (`skills.router_line.enabled`).
|
||||
Setup offers to register the person when none is registered, and a team started from the
|
||||
person's own assistant fits their own logins, with a review before anything is committed.
|
||||
- **`rig usage series`** — what a seat's token curve has looked like over time: climbing steadily,
|
||||
reset, or stopped reporting entirely. The last one is a signal, not a gap.
|
||||
- **`rig usage top`** — which of this machine's seats used the most tokens over a window (`--window
|
||||
|
||||
@@ -107,6 +107,7 @@
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.0",
|
||||
"@hono/node-ws": "^1.3.1",
|
||||
"@modelcontextprotocol/sdk": "^1.32.2",
|
||||
"better-sqlite3": "^13.0.3",
|
||||
"hono": "^4.7.0",
|
||||
"smol-toml": "^1.8.0",
|
||||
|
||||
@@ -94,6 +94,11 @@ pools and missing edge artifacts.
|
||||
|
||||
## Active surface
|
||||
|
||||
For a worked `periodic-reminder` YAML spec, full registration command and required
|
||||
flags, read `rig context get reference/keeping-a-watch.md#recurring-reminders-for-agents`.
|
||||
This is an agent's recurring reminder. A person's watch uses the one-shot queue
|
||||
pattern in `rig context get reference/keeping-a-watch.md#setting-a-watch`.
|
||||
|
||||
Daemon `rig watchdog` is the active host coordination surface for new
|
||||
work. History records only loud evaluations (`sent` / `terminal`);
|
||||
quiet skip reasons are NOT recorded — POC parity so agents are not
|
||||
|
||||
@@ -44,10 +44,13 @@ machine and have things happen.
|
||||
SQLite. The daemon's kernel-boot path short-circuits
|
||||
`already-managed` — no re-instantiation, no fresh agents. Member
|
||||
tmux sessions survive a daemon-restart-only and the reconciler
|
||||
marks them healthy. A HOST reboot drops tmux entirely; the
|
||||
reconciler then marks the kernel's member sessions detached, and
|
||||
the operator pod owns the agent-restart workflow ("bring my
|
||||
sessions back online") per "Incomplete setup and restart":
|
||||
marks them healthy. A HOST reboot drops tmux entirely. After a host
|
||||
reboot, the next `rig daemon start` attempts to restore the kernel
|
||||
when startup reconciliation newly marks at least one seat detached
|
||||
and every kernel seat is detached and still bound. A deliberately
|
||||
stopped kernel, or one whose seats were all already recorded as
|
||||
detached before that start, stays down until
|
||||
`rig up kernel --existing`. See "Incomplete setup and restart":
|
||||
`rig context get reference/getting-started.md#incomplete-setup-and-restart`.
|
||||
Other rigs are NOT auto-instantiated by the daemon at any point —
|
||||
they require explicit operator-initiated `rig up` / `rig restore`
|
||||
|
||||
@@ -11,9 +11,12 @@ summary: >
|
||||
subsequent daemon-restarts the rig record persists in SQLite and the
|
||||
daemon's kernel-boot path short-circuits already-managed without
|
||||
re-creating the rig. Member tmux sessions survive daemon-restart-only;
|
||||
a HOST reboot drops tmux and the reconciler marks sessions detached,
|
||||
at which point the operator pod owns the agent-restart workflow
|
||||
through the current operator workflow. Dual-runtime variant; Claude Code +
|
||||
a HOST reboot drops tmux. The next daemon start attempts to restore the
|
||||
kernel when startup reconciliation newly marks at least one seat
|
||||
detached and every kernel seat is detached and still bound; a
|
||||
deliberately stopped kernel, or one whose seats were all already
|
||||
recorded as detached, stays down until rig up kernel --existing.
|
||||
Dual-runtime variant; Claude Code +
|
||||
Codex both authenticated.
|
||||
|
||||
culture_file: culture/CULTURE.md
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
import fs from "node:fs";
|
||||
import nodePath from "node:path";
|
||||
import readline from "node:readline";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { PassThrough, Writable } from "node:stream";
|
||||
import stringWidth from "string-width";
|
||||
import { StringDecoder } from "node:string_decoder";
|
||||
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
||||
import { pathToFileURL } from "node:url";
|
||||
@@ -71,14 +72,306 @@ export function preparePiAccount(agentDir: string, accountHome: string | undefin
|
||||
// Use Node's line editor in raw mode, with only paste framing handled here.
|
||||
export const MAX_PI_INPUT_BYTES = 1024 * 1024;
|
||||
|
||||
// ── The compose region's physical layout ──────────────────────────────────────────
|
||||
// One layout model for painting, erasing and the caret: cells fill terminal rows
|
||||
// greedily, a wide character that no longer fits wraps whole to the next row, and
|
||||
// an explicit newline starts one. Every row the compositor paints or erases, and
|
||||
// the caret it places, derives from here — measured with the daemon's string-width.
|
||||
|
||||
/** Where a compose region sits on screen, and where its caret lands. */
|
||||
export interface ComposeLayout { rows: number; caretRow: number; caretColumn: number; }
|
||||
|
||||
const SEGMENTER = (() => {
|
||||
try {
|
||||
return new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
/** Grapheme clusters: what a terminal lays out. A ZWJ family emoji is one cluster
|
||||
* taking two cells where its code points would sum to six; a combining mark adds none. */
|
||||
export function* graphemesOf(text: string): Generator<string> {
|
||||
if (SEGMENTER) {
|
||||
for (const part of SEGMENTER.segment(text)) yield part.segment;
|
||||
} else {
|
||||
for (const character of text) yield character;
|
||||
}
|
||||
}
|
||||
|
||||
const TAB_WIDTH = 8;
|
||||
|
||||
export function composeLayout(text: string, width: number, caret = text.length): ComposeLayout {
|
||||
if (!text) return { rows: 0, caretRow: 0, caretColumn: 0 };
|
||||
if (width <= 0) return { rows: text.split("\n").length, caretRow: 0, caretColumn: 0 };
|
||||
let row = 0;
|
||||
let column = 0;
|
||||
let caretRow = 0;
|
||||
let caretColumn = 0;
|
||||
let index = 0;
|
||||
const place = () => { caretRow = row; caretColumn = column; };
|
||||
for (const segment of graphemesOf(text)) {
|
||||
if (index === caret) place();
|
||||
if (segment === "\n") {
|
||||
row += 1;
|
||||
column = 0;
|
||||
} else if (segment === "\t") {
|
||||
// A tab advances to the next ordinary tab stop, clamped to the last column.
|
||||
column = Math.min((Math.floor(column / TAB_WIDTH) + 1) * TAB_WIDTH, width - 1);
|
||||
} else {
|
||||
const cells = Math.max(0, stringWidth(segment));
|
||||
if (column + cells > width) {
|
||||
row += 1;
|
||||
column = 0;
|
||||
}
|
||||
column += cells;
|
||||
}
|
||||
index += segment.length;
|
||||
}
|
||||
if (index === caret) place();
|
||||
return { rows: row + 1, caretRow, caretColumn };
|
||||
}
|
||||
|
||||
// ── Pane live region (compose + status) ────────────────────────────────────────
|
||||
// A seat pane is a plain terminal: one bottom live region holds the operator's draft
|
||||
// and the runner's status line, and is erased and repainted around every transcript
|
||||
// write. Nothing else may write at the cursor, or a draft freezes into scrollback and
|
||||
// streamed text fuses with it mid-word. When the terminal's width is unknown the
|
||||
// compositor places nothing — the pane falls back to plain output, and the draft,
|
||||
// though still editable, is not drawn.
|
||||
|
||||
/** The live region a pane host repaints around transcript writes. */
|
||||
export interface PaneCompositor {
|
||||
/** Bind the line editor the compose line renders from, and where its cursor sits. */
|
||||
attach(read: () => string, redraw: () => void, caretUp?: () => number, readCursor?: () => number): void;
|
||||
/** Unbind the editor; any held streamed tail is flushed and the live region is dropped. */
|
||||
detach(): void;
|
||||
/** A keystroke changed the draft: repaint the live region from readline's state. A
|
||||
* draft that just went away flushes a held streamed tail first, as plain output would. */
|
||||
refresh(): void;
|
||||
/** Streamed model text: verbatim while no live region exists; once a draft or a
|
||||
* status line is on the bottom, complete lines go above the region and an
|
||||
* unfinished line is held, so no live line is split or frozen into scrollback. */
|
||||
transcript(text: string): void;
|
||||
/** One complete line of transcript. */
|
||||
line(text: string): void;
|
||||
/** A keypress is about to echo: close an open transcript line first, or the echo
|
||||
* and the next streamed deltas would land on top of it. */
|
||||
beforeInput(): void;
|
||||
/** Pin the latest diagnostic on the status line. The transcript copy is the caller's;
|
||||
* this pin is never the only copy. */
|
||||
diagnostic(text: string): void;
|
||||
/** Drop a pinned status line. */
|
||||
clearStatus(): void;
|
||||
/** Before the submit commits: drop the draft line so no partial copy scrolls, and
|
||||
* finish any held streamed line; the durable copy is the commit line below. */
|
||||
beforeSubmit(): void;
|
||||
/** Repaint the live region in place. */
|
||||
repaint(): void;
|
||||
}
|
||||
|
||||
export function createPaneCompositor(out: NodeJS.WriteStream): PaneCompositor {
|
||||
let status: string | null = null;
|
||||
/** The status text actually on screen: clipped when it was painted. A resize must
|
||||
* size the region from this, not from the longer text it was pinned with. */
|
||||
let paintedStatus: string | null = null;
|
||||
let readInput: (() => string) | null = null;
|
||||
let redrawLine: (() => void) | null = null;
|
||||
let held = "";
|
||||
let openLine = false;
|
||||
let screenRows = 0;
|
||||
let readCaretUp: (() => number) | null = null;
|
||||
let readCaretIndex: (() => number) | null = null;
|
||||
const widthKnown = () => typeof out.columns === "number" && out.columns > 0;
|
||||
const composeText = () => (readInput && redrawLine ? readInput() : "");
|
||||
const composeRows = () => {
|
||||
const text = composeText();
|
||||
return text && widthKnown() ? composeLayout(text, out.columns).rows : 0;
|
||||
};
|
||||
const clip = (text: string) => {
|
||||
const flat = text.replace(/[\r\n\t]+/g, " ").trimEnd();
|
||||
if (!widthKnown() || stringWidth(flat) < out.columns) return flat;
|
||||
let used = 0;
|
||||
let clipped = "";
|
||||
for (const segment of graphemesOf(flat)) {
|
||||
const cells = Math.max(0, stringWidth(segment));
|
||||
if (used + cells > out.columns - 1) break;
|
||||
clipped += segment;
|
||||
used += cells;
|
||||
}
|
||||
return `${clipped}\u2026`;
|
||||
};
|
||||
const eraseLines = (lines: number) => {
|
||||
let sequence = "";
|
||||
for (let index = 0; index < lines; index += 1) sequence += index === 0 ? "\r\u001b[2K" : "\u001b[1A\r\u001b[2K";
|
||||
if (sequence) out.write(sequence);
|
||||
};
|
||||
/** Rows this compositor has on screen, recorded when it painted them. Readline never writes
|
||||
* to the terminal, so nothing else can change these rows behind the compositor's back —
|
||||
* except a reflow, which the resize handler owns. */
|
||||
const paint = () => {
|
||||
paintedStatus = status !== null ? clip(status) : null;
|
||||
if (paintedStatus !== null) {
|
||||
out.write(paintedStatus);
|
||||
out.write("\n");
|
||||
}
|
||||
const rows = composeRows();
|
||||
if (rows) redrawLine?.();
|
||||
screenRows = (status !== null ? 1 : 0) + (rows || (status !== null ? 1 : 0));
|
||||
// Only a painted region closes an open transcript line. With nothing painted the
|
||||
// cursor still sits on the line a flush left open, and the next keystroke belongs
|
||||
// to that line's context, not to a fresh one.
|
||||
if (screenRows > 0) openLine = false;
|
||||
};
|
||||
const eraseScreen = () => {
|
||||
// Nothing painted: there is no region to erase, and stepping down from a stale
|
||||
// caret count would move the cursor off the transcript line it is still on.
|
||||
if (screenRows === 0) return;
|
||||
const down = readCaretUp ? readCaretUp() : 0;
|
||||
if (down > 0) out.write(`\u001b[${down}B`);
|
||||
eraseLines(screenRows);
|
||||
screenRows = 0;
|
||||
};
|
||||
const flushHeld = () => {
|
||||
if (!held) return;
|
||||
out.write(held.endsWith("\n") ? held : `${held}\n`);
|
||||
held = "";
|
||||
openLine = false;
|
||||
};
|
||||
const writeVerbatim = (text: string) => {
|
||||
out.write(text);
|
||||
openLine = !text.endsWith("\n");
|
||||
};
|
||||
const writeAbove = (text: string) => {
|
||||
eraseScreen();
|
||||
out.write(text.endsWith("\n") ? text : `${text}\n`);
|
||||
paint();
|
||||
};
|
||||
// A tile attaching or detaching reflows the pane, and the terminal keeps the cursor on
|
||||
// its character. So the old paint now occupies exactly what the new width says it does:
|
||||
// the pinned status wraps to ceil(cells/columns) rows, the draft to its layout rows.
|
||||
// Move down to the region's last row — past the lines below the caret — erase exactly
|
||||
// that many rows, and repaint at the new width. No max(): under-erasing leaves a stale
|
||||
// copy of a wrapped status, over-erasing drops a transcript line off the screen.
|
||||
const onResize = () => {
|
||||
if (!widthKnown()) {
|
||||
screenRows = 0;
|
||||
return;
|
||||
}
|
||||
const draft = composeText();
|
||||
const layout = draft ? composeLayout(draft, out.columns, readCaretIndex ? readCaretIndex() : draft.length) : null;
|
||||
const draftRows = layout ? layout.rows : 0;
|
||||
const statusLines = paintedStatus !== null ? Math.max(1, Math.ceil(stringWidth(paintedStatus) / out.columns)) : 0;
|
||||
const down = layout ? layout.rows - 1 - layout.caretRow : 0;
|
||||
if (down > 0) out.write(`\u001b[${down}B`);
|
||||
// A pinned status without a draft also owns the blank row its trailing newline
|
||||
// left the cursor on — the same row paint() counts into screenRows.
|
||||
eraseLines(statusLines + (draftRows || (status !== null ? 1 : 0)));
|
||||
screenRows = 0;
|
||||
paint();
|
||||
};
|
||||
out.on("resize", onResize);
|
||||
const pane: PaneCompositor = {
|
||||
attach(read, redraw, caretUp, readCursor) {
|
||||
readInput = read;
|
||||
redrawLine = redraw;
|
||||
readCaretUp = caretUp ?? null;
|
||||
readCaretIndex = readCursor ?? null;
|
||||
},
|
||||
detach() {
|
||||
eraseScreen();
|
||||
flushHeld();
|
||||
readInput = null;
|
||||
redrawLine = null;
|
||||
readCaretUp = null;
|
||||
readCaretIndex = null;
|
||||
openLine = false;
|
||||
screenRows = 0;
|
||||
},
|
||||
refresh() {
|
||||
eraseScreen();
|
||||
// With no region left, an unfinished streamed line goes out verbatim so the
|
||||
// next delta continues it on the same row instead of splitting mid-word.
|
||||
if (held && status === null && !composeText()) {
|
||||
writeVerbatim(held);
|
||||
held = "";
|
||||
}
|
||||
paint();
|
||||
},
|
||||
transcript(text) {
|
||||
if (!text) return;
|
||||
if (status === null && composeRows() === 0 && screenRows === 0) {
|
||||
if (held) {
|
||||
writeVerbatim(held);
|
||||
held = "";
|
||||
}
|
||||
writeVerbatim(text);
|
||||
return;
|
||||
}
|
||||
held += text;
|
||||
let newline = held.indexOf("\n");
|
||||
while (newline >= 0) {
|
||||
const line = held.slice(0, newline + 1);
|
||||
held = held.slice(newline + 1);
|
||||
writeAbove(line);
|
||||
newline = held.indexOf("\n");
|
||||
}
|
||||
},
|
||||
line(text) {
|
||||
pane.transcript(`${text}\n`);
|
||||
},
|
||||
beforeInput() {
|
||||
if (!openLine) return;
|
||||
out.write("\n");
|
||||
openLine = false;
|
||||
},
|
||||
diagnostic(text) {
|
||||
// Without a known width there is no region to pin into. Callers already wrote
|
||||
// the transcript copy; printing here would duplicate it.
|
||||
if (!widthKnown()) return;
|
||||
if (openLine) {
|
||||
out.write("\n");
|
||||
openLine = false;
|
||||
}
|
||||
eraseScreen();
|
||||
status = clip(text);
|
||||
paint();
|
||||
},
|
||||
clearStatus() {
|
||||
if (status === null) return;
|
||||
eraseScreen();
|
||||
status = null;
|
||||
paint();
|
||||
},
|
||||
beforeSubmit() {
|
||||
eraseScreen();
|
||||
openLine = false;
|
||||
flushHeld();
|
||||
},
|
||||
repaint() {
|
||||
paint();
|
||||
},
|
||||
};
|
||||
return pane;
|
||||
}
|
||||
|
||||
export function createRunnerInput(
|
||||
input: NodeJS.ReadStream,
|
||||
output: NodeJS.WriteStream,
|
||||
onSubmit: (block: string) => void,
|
||||
pane?: PaneCompositor,
|
||||
): readline.Interface {
|
||||
const reject = () => output.write(
|
||||
"[pi-runner] input rejected: maximum 1048576 UTF-8 bytes; send a smaller message. Ctrl-C clears unfinished input.\n",
|
||||
);
|
||||
const say = (text: string) => {
|
||||
if (pane) {
|
||||
pane.line(text);
|
||||
pane.diagnostic(text);
|
||||
} else {
|
||||
output.write(`${text}\n`);
|
||||
}
|
||||
};
|
||||
const reject = () => {
|
||||
say("[pi-runner] input rejected: maximum 1048576 UTF-8 bytes; send a smaller message. Ctrl-C clears unfinished input.");
|
||||
};
|
||||
const submit = (block: string) => {
|
||||
if (Buffer.byteLength(block) > MAX_PI_INPUT_BYTES) reject();
|
||||
else if (block.trim()) onSubmit(block);
|
||||
@@ -94,14 +387,37 @@ export function createRunnerInput(
|
||||
|| (major === 25 && (minor === 0 || (minor === 1 && patch === 0)));
|
||||
const keys = new PassThrough();
|
||||
const editorInput = batchText ? new PassThrough() : keys;
|
||||
// Readline edits against a discard sink: it must never draw on the terminal, because
|
||||
// _refreshLine does moveCursor(0, -prevRows) + clearScreenDown(), which erases whatever the
|
||||
// compositor put above the draft. The compositor owns every terminal byte instead.
|
||||
const sink = new Writable({ write(_chunk, _encoding, callback) { callback(); } });
|
||||
// Readline wraps its edit against output.columns; carry the terminal's geometry onto the sink.
|
||||
Object.assign(sink, { columns: output.columns, rows: output.rows });
|
||||
let caretUp = 0;
|
||||
const renderCompose = () => {
|
||||
const text = `${editor.line ?? ""}`;
|
||||
if (!text) return;
|
||||
const width = typeof output.columns === "number" && output.columns > 0 ? output.columns : 0;
|
||||
output.write(text);
|
||||
if (!width) return;
|
||||
const cursor = Math.max(0, Math.min(Number(editor.cursor) || 0, text.length));
|
||||
const layout = composeLayout(text, width, cursor);
|
||||
caretUp = layout.rows - 1 - layout.caretRow;
|
||||
if (caretUp > 0) output.write(`\u001b[${caretUp}A`);
|
||||
output.write("\r");
|
||||
if (layout.caretColumn > 0) output.write(`\u001b[${layout.caretColumn}C`);
|
||||
};
|
||||
pane?.attach(() => `${editor.line ?? ""}`, renderCompose, () => caretUp, () => Math.max(0, Math.min(Number(editor.cursor) || 0, `${editor.line ?? ""}`.length)));
|
||||
|
||||
const editor = readline.createInterface({
|
||||
input: editorInput, output, terminal: true, prompt: "", historySize: 0, crlfDelay: Infinity,
|
||||
input: editorInput, output: sink, terminal: true, prompt: "", historySize: 0, crlfDelay: Infinity,
|
||||
});
|
||||
// Node forces dumb-terminal mode without editing if TERM=dumb. When terminal: true
|
||||
// was requested, remove the dumb-mode override so VT100 line editing is preserved.
|
||||
if (Object.prototype.hasOwnProperty.call(editor, "_ttyWrite")) {
|
||||
delete (editor as any)._ttyWrite;
|
||||
}
|
||||
|
||||
const decoder = new StringDecoder("utf8");
|
||||
let pending = "";
|
||||
let paste: string | null = null;
|
||||
@@ -111,7 +427,7 @@ export function createRunnerInput(
|
||||
// Node documents changing rl.line together with rl.cursor. The installed
|
||||
// typings mark them readonly, so assign this pair through one explicit seam.
|
||||
Object.assign(editor, { line, cursor });
|
||||
editor.prompt(true);
|
||||
pane?.refresh();
|
||||
};
|
||||
const clear = () => setLine("", 0);
|
||||
// Node 22.21–22.22.0 (also some 24/25 releases) appends batched text after
|
||||
@@ -137,6 +453,7 @@ export function createRunnerInput(
|
||||
}
|
||||
const consume = (text: string) => {
|
||||
if (paste === null) {
|
||||
pane?.beforeInput();
|
||||
keys.write(text);
|
||||
if (batchText) flushTyped();
|
||||
} else if (!discarded) {
|
||||
@@ -170,7 +487,7 @@ export function createRunnerInput(
|
||||
paste = null;
|
||||
discarded = false;
|
||||
clear();
|
||||
output.write("\n[pi-runner] input cleared\n");
|
||||
say("[pi-runner] input cleared");
|
||||
onSubmit("/abort");
|
||||
} else if (paste === null) {
|
||||
paste = "";
|
||||
@@ -189,6 +506,7 @@ export function createRunnerInput(
|
||||
paste = null;
|
||||
}
|
||||
}
|
||||
pane?.refresh();
|
||||
};
|
||||
const wasRaw = input.isRaw;
|
||||
const end = () => editor.close();
|
||||
@@ -196,7 +514,10 @@ export function createRunnerInput(
|
||||
output.write("\u001b[?2004h");
|
||||
input.on("data", receive);
|
||||
input.once("end", end);
|
||||
editor.on("line", submit);
|
||||
editor.on("line", (line) => {
|
||||
pane?.beforeSubmit();
|
||||
submit(line);
|
||||
});
|
||||
editor.once("close", () => {
|
||||
input.removeListener("data", receive);
|
||||
input.removeListener("end", end);
|
||||
@@ -204,6 +525,7 @@ export function createRunnerInput(
|
||||
input.pause();
|
||||
keys.removeListener("keypress", keypress);
|
||||
keys.destroy();
|
||||
pane?.detach();
|
||||
if (editorInput !== keys) editorInput.destroy();
|
||||
output.write("\u001b[?2004l");
|
||||
});
|
||||
@@ -359,6 +681,11 @@ export interface RunnerIo {
|
||||
mirrorLine(line: string): void;
|
||||
/** Append raw text to the current pane line (streamed deltas). */
|
||||
mirrorAppend(text: string): void;
|
||||
/** Pin a copy of a diagnostic on the pane's status line, when the pane provides one.
|
||||
* The transcript copy is written by the caller; this pin is never the only copy. */
|
||||
diagnostic?(text: string): void;
|
||||
/** Drop a pinned status line at the start of a new agent turn. */
|
||||
clearStatus?(): void;
|
||||
/** POST to the daemon activity endpoint. May resolve to the parsed JSON
|
||||
* response (null on transport failure) so identity delivery can retry. */
|
||||
postActivity(payload: Record<string, unknown>): void | Promise<Record<string, unknown> | null>;
|
||||
@@ -592,7 +919,7 @@ export class RunnerCore {
|
||||
|
||||
/** A frame that can't be reassembled is lost, and said so. If it was a get_entries reply, the cursor stays where it was. */
|
||||
private reportDroppedFrame(reason: string | null | undefined): void {
|
||||
if (reason) this.io.mirrorLine(`[omp-runner] ERROR rpc frame: ${reason}; the chunked frame was dropped`);
|
||||
if (reason) this.diag(`[omp-runner] ERROR rpc frame: ${reason}; the chunked frame was dropped`);
|
||||
}
|
||||
|
||||
private get runtime(): string { return this.opts.runtime ?? "pi"; }
|
||||
@@ -601,7 +928,7 @@ export class RunnerCore {
|
||||
handleUserBlock(block: string): void {
|
||||
if (block === "/abort") {
|
||||
this.io.sendRpc({ type: "abort" });
|
||||
this.io.mirrorLine("[pi-runner] abort sent");
|
||||
this.diag("[pi-runner] abort sent");
|
||||
return;
|
||||
}
|
||||
if (block.startsWith("/followup ")) {
|
||||
@@ -646,7 +973,7 @@ export class RunnerCore {
|
||||
} else {
|
||||
// Advertised but refused: v1 as before, so a reply over 1 MiB still fails, visibly.
|
||||
const detail = typeof record.error === "string" ? record.error : "refused";
|
||||
this.io.mirrorLine(`[omp-runner] RPC protocol v2 not negotiated (${detail}); replies over 1 MiB will fail`);
|
||||
this.diag(`[omp-runner] RPC protocol v2 not negotiated (${detail}); replies over 1 MiB will fail`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -656,7 +983,7 @@ export class RunnerCore {
|
||||
if (record.id === GET_STATE_ID) {
|
||||
this.ready = false;
|
||||
this.writeSidecar({});
|
||||
this.io.mirrorLine(this.runtime === "omp" ? `[omp-runner] ERROR rpc get_state: ${message}` : `${PI_RUNNER_ERROR_MARKER} rpc: ${message}`);
|
||||
this.diag(this.runtime === "omp" ? `[omp-runner] ERROR rpc get_state: ${message}` : `${PI_RUNNER_ERROR_MARKER} rpc: ${message}`);
|
||||
// OMP cannot provide a resume token without get_state; stop it so the
|
||||
// launch fails now instead of waiting out readiness.
|
||||
if (this.runtime === "omp") this.io.stopChild?.();
|
||||
@@ -665,11 +992,11 @@ export class RunnerCore {
|
||||
if (record.id === CATCH_UP_ID && this.runtime === "pi") {
|
||||
// A refused catch-up (a saved cursor this session's history doesn't hold, such as an older runner's UI-event
|
||||
// id) is history sync, not the seat's resume: say so, and take a confirmed cursor from the history's tail.
|
||||
this.io.mirrorLine(`[pi-runner] History catch-up didn't run (${message}). That's history sync, separate from the seat's resume; reading the history cursor again.`);
|
||||
this.diag(`[pi-runner] History catch-up didn't run (${message}). That's history sync, separate from the seat's resume; reading the history cursor again.`);
|
||||
this.io.sendRpc({ type: "get_entries", id: CURSOR_REFRESH_ID });
|
||||
return;
|
||||
}
|
||||
this.io.mirrorLine(`[${this.runtime}-runner] ERROR rpc: ${message}`);
|
||||
this.diag(`[${this.runtime}-runner] ERROR rpc: ${message}`);
|
||||
// Only a rejected input means the seat cannot do the requested work.
|
||||
// A mistimed /abort (or any control command) leaves the seat idle.
|
||||
if (this.runtime === "omp" && typeof record.command === "string" && Object.hasOwn(INPUT_COMMANDS, record.command)) {
|
||||
@@ -687,13 +1014,13 @@ export class RunnerCore {
|
||||
this.ready = this.runtime === "pi" || !!this.sessionFile;
|
||||
if (!this.ready) {
|
||||
if (this.runtime === "omp") {
|
||||
this.io.mirrorLine("[omp-runner] ERROR rpc get_state returned no session file; cannot provide a resume token");
|
||||
this.diag("[omp-runner] ERROR rpc get_state returned no session file; cannot provide a resume token");
|
||||
this.io.stopChild?.();
|
||||
return;
|
||||
}
|
||||
// For custom protocol: pi-rpc community harnesses, reporting no session file
|
||||
// reads as needing attention rather than stopping the child process.
|
||||
this.io.mirrorLine(`[${this.runtime}-runner] ERROR rpc get_state returned no session file; cannot provide a resume token`);
|
||||
this.diag(`[${this.runtime}-runner] ERROR rpc get_state returned no session file; cannot provide a resume token`);
|
||||
this.raiseAttention("runtime_error");
|
||||
return;
|
||||
}
|
||||
@@ -856,6 +1183,13 @@ export class RunnerCore {
|
||||
this.attentionIsLatest = false;
|
||||
}
|
||||
|
||||
/** A diagnostic stays in the transcript — the readiness probe and the operator both read
|
||||
* it there; a pane with a status line pins the latest one as a convenience copy. */
|
||||
private diag(text: string): void {
|
||||
this.io.mirrorLine(text);
|
||||
this.io.diagnostic?.(text);
|
||||
}
|
||||
|
||||
private handleEvent(event: Record<string, unknown>): void {
|
||||
if (event.type === "extension_ui_request") {
|
||||
const method = event.method;
|
||||
@@ -863,7 +1197,7 @@ export class RunnerCore {
|
||||
const detail = [event.title, event.message].filter((text): text is string => typeof text === "string")
|
||||
.map(stripVTControlCharacters).join(" — ").replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, " ").slice(0, 400);
|
||||
if (this.runtime !== "omp" && !dialog) {
|
||||
if (method === "notify") this.io.mirrorLine(`[${this.runtime}-runner] ${detail}`);
|
||||
if (method === "notify") this.diag(`[${this.runtime}-runner] ${detail}`);
|
||||
return; // Pi notifications and other fire-and-forget UI requests expect no answer.
|
||||
}
|
||||
// RPC does not mount an interactive UI. Reply at once; otherwise the
|
||||
@@ -872,7 +1206,7 @@ export class RunnerCore {
|
||||
this.io.sendRpc({ type: "extension_ui_response", id: event.id, cancelled: true });
|
||||
}
|
||||
if (dialog) {
|
||||
this.io.mirrorLine(this.runtime !== "omp"
|
||||
this.diag(this.runtime !== "omp"
|
||||
? `[${this.runtime}-runner] Extension ${method}: ${detail}; cancelled because the managed RPC pane cannot answer it. Operator action required: review the cancelled request and re-send an instruction the extension can complete without a dialog.`
|
||||
: "[omp-runner] Approval/input requested; cancelled because the managed RPC pane cannot answer it. Operator action required: review the denied action and re-send a safe instruction, or change the seat permission policy explicitly.");
|
||||
this.raiseAttention("permission_prompt");
|
||||
@@ -892,7 +1226,10 @@ export class RunnerCore {
|
||||
this.writeSidecar({});
|
||||
}
|
||||
|
||||
if (event.type === "agent_start") this.clearAttention();
|
||||
if (event.type === "agent_start") {
|
||||
this.clearAttention();
|
||||
this.io.clearStatus?.();
|
||||
}
|
||||
if (this.runtime === "omp") {
|
||||
if (event.type === "agent_start" || event.type === "turn_start"
|
||||
|| (event.type === "message_start" && message?.role === "assistant")
|
||||
@@ -936,7 +1273,7 @@ export class RunnerCore {
|
||||
// the repeat lacks it, then reset at the next assistant message/agent
|
||||
// turn, not at agent_end.
|
||||
if (event.type === "message_end" || !this.assistantErrorShown) {
|
||||
this.io.mirrorLine(mapped.errorNotice);
|
||||
this.diag(mapped.errorNotice);
|
||||
}
|
||||
this.assistantErrorShown = true;
|
||||
// Only typed, explicitly transient HTTP failures may release the send
|
||||
@@ -1445,6 +1782,14 @@ export async function main(argv = process.argv.slice(2)): Promise<void> {
|
||||
// The child may close its pipe before the first RPC write completes.
|
||||
child.stdin.on("error", () => { /* spawn/exit handlers own the diagnosis */ });
|
||||
|
||||
const pane = createPaneCompositor(process.stdout);
|
||||
// Diagnostics stay in the transcript — the pane's readiness probe greps it, and people
|
||||
// and agents read the history there; the status line only pins the latest copy.
|
||||
const paneDiag = (text: string) => {
|
||||
pane.line(text);
|
||||
pane.diagnostic(text);
|
||||
};
|
||||
|
||||
const chargeLookup = new OpenRouterChargeLookup({
|
||||
fetchGeneration: openRouterGenerationFetcher(() => childEnv["OPENROUTER_API_KEY"]),
|
||||
postCharges: async (piSessionId, charges) => {
|
||||
@@ -1463,7 +1808,7 @@ export async function main(argv = process.argv.slice(2)): Promise<void> {
|
||||
}, () => false as const).finally(() => clearTimeout(timeout));
|
||||
},
|
||||
schedule: (fn, ms) => { setTimeout(fn, ms).unref(); },
|
||||
notice: (line) => { process.stdout.write(`${line}\n`); },
|
||||
notice: (line) => { paneDiag(line); },
|
||||
});
|
||||
|
||||
// One activity post at a time: the daemon reads its newest record by arrival, so a beat still in flight when a turn
|
||||
@@ -1486,8 +1831,10 @@ export async function main(argv = process.argv.slice(2)): Promise<void> {
|
||||
sendRpc: (cmd) => {
|
||||
try { child.stdin.write(`${JSON.stringify(cmd)}\n`); } catch { /* exit handler reports */ }
|
||||
},
|
||||
mirrorLine: (line) => process.stdout.write(`${line}\n`),
|
||||
mirrorAppend: (text) => process.stdout.write(text),
|
||||
mirrorLine: (line) => pane.line(line),
|
||||
mirrorAppend: (text) => pane.transcript(text),
|
||||
diagnostic: (text) => pane.diagnostic(text),
|
||||
clearStatus: () => pane.clearStatus(),
|
||||
postActivity: (payload) => {
|
||||
if (!endpoint || typeof fetch !== "function") return;
|
||||
const result = postInOrder(payload).catch(() => null);
|
||||
@@ -1568,7 +1915,7 @@ export async function main(argv = process.argv.slice(2)): Promise<void> {
|
||||
let reportedPiCapability = false;
|
||||
readline.createInterface({ input: child.stderr }).on("line", (line) => {
|
||||
if (!line.trim()) return;
|
||||
process.stdout.write(`[${runtime}:err] ${line}\n`);
|
||||
paneDiag(`[${runtime}:err] ${line}`);
|
||||
stderrTail.push(line.length > OMP_STDERR_TAIL_CHARS ? `${line.slice(0, OMP_STDERR_TAIL_CHARS)}...` : line);
|
||||
if (stderrTail.length > OMP_STDERR_TAIL_LINES) stderrTail.shift();
|
||||
if (runtime === "pi" && !core.isReady() && !reportedPiCapability) {
|
||||
@@ -1582,15 +1929,15 @@ export async function main(argv = process.argv.slice(2)): Promise<void> {
|
||||
});
|
||||
if (detail) {
|
||||
reportedPiCapability = true;
|
||||
process.stdout.write(`[pi-runner] ${detail}\n`);
|
||||
paneDiag(`[pi-runner] ${detail}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
const input = createRunnerInput(process.stdin, process.stdout, (block) => core.handleUserBlock(block));
|
||||
const input = createRunnerInput(process.stdin, process.stdout, (block) => core.handleUserBlock(block), pane);
|
||||
|
||||
if (runtime !== "omp") {
|
||||
child.on("error", (err) => {
|
||||
console.error(`${PI_RUNNER_ERROR_MARKER} failed to spawn ${args.launchWrapper?.length ? `launch_wrapper (${spawnFile})` : (args.command ?? "pi")}: ${err.message}`);
|
||||
paneDiag(`${PI_RUNNER_ERROR_MARKER} failed to spawn ${args.launchWrapper?.length ? `launch_wrapper (${spawnFile})` : (args.command ?? "pi")}: ${err.message}`);
|
||||
const code = (err as NodeJS.ErrnoException).code === "ENOENT" ? 127 : 1;
|
||||
core.handlePiExit(code);
|
||||
input.close();
|
||||
@@ -1617,7 +1964,7 @@ export async function main(argv = process.argv.slice(2)): Promise<void> {
|
||||
if (!core.isReady()) {
|
||||
const how = signal ? `signal ${signal}` : `exit code ${code ?? "unknown"}`;
|
||||
const output = stderrTail.length > 0 ? ` Its last output: ${stderrTail.join(" | ")}` : " It printed nothing on stderr.";
|
||||
console.error(transportUp
|
||||
paneDiag(transportUp
|
||||
? "[omp-runner] ERROR OMP did not establish a resumable RPC session. Authenticate this isolated seat using HOME=<seat-root> PI_CODING_AGENT_DIR=<seat-root>/agent omp and /login, or provide its declared model provider key in the OpenRig daemon environment. Default OMP credentials are not shared."
|
||||
: `[omp-runner] ERROR OMP exited during startup, before its RPC transport started (${command}, ${how}).${output}`);
|
||||
}
|
||||
@@ -1626,7 +1973,7 @@ export async function main(argv = process.argv.slice(2)): Promise<void> {
|
||||
process.exitCode = code ?? 1;
|
||||
};
|
||||
child.on("error", (err) => {
|
||||
console.error(`[omp-runner] ERROR failed to spawn omp: ${err.message}`);
|
||||
paneDiag(`[omp-runner] ERROR failed to spawn omp: ${err.message}`);
|
||||
recordExit(null);
|
||||
});
|
||||
// "close" fires once OMP's stdio is drained, so its stderr is in the tail
|
||||
|
||||
@@ -74,17 +74,49 @@ export function daemonTmuxControlStatus(): { requested: boolean } & TmuxControlS
|
||||
return { requested: true, ...(daemonControl.status?.() ?? { state: "off" }) };
|
||||
}
|
||||
|
||||
function shellWord(value: string): string {
|
||||
return /^[A-Za-z0-9_@%+=:,./-]+$/.test(value) ? value : "'" + value.replace(/'/g, "'\"'\"'") + "'";
|
||||
function shellWord(value: string, force = false): string {
|
||||
return !force && /^[A-Za-z0-9_@%+=:,./-]+$/.test(value) ? value : "'" + value.replace(/'/g, "'\"'\"'") + "'";
|
||||
}
|
||||
|
||||
/** `tmux`, with the daemon's server flags when it isn't on the default server. */
|
||||
function tmuxOnDaemonServer(): string {
|
||||
export function tmuxOnDaemonServer(): string {
|
||||
const server = daemonServerArgs.map((arg, i) => (i % 2 === 0 ? arg : shellWord(arg))).join(" ");
|
||||
return `tmux${server ? ` ${server}` : ""}`;
|
||||
}
|
||||
|
||||
/** `tmux attach -t <session>`, with the daemon's server flags when it isn't on the default server. */
|
||||
/** A session name as tmux must receive it.
|
||||
*
|
||||
* Callers hand this a raw session name, not an argument tmux has already escaped, so the name is
|
||||
* encoded exactly once here, for the tmux rules that apply at this layer.
|
||||
*
|
||||
* `cmd_parse_from_arguments` drops a final `;` as a command separator and consumes a backslash
|
||||
* sitting immediately in front of it. One more backslash goes before that separator, whether or
|
||||
* not one is already there: a session named `report;` goes as `report\;` and one named `report\;`
|
||||
* goes as `report\\;`, so the two stay different all the way to tmux.
|
||||
*
|
||||
* `cmd_find_parse_target` reads several spellings as target markers rather than as part of a name:
|
||||
* a leading `=` is the exact-match prefix, a leading `@` a window target, a leading `%` a pane
|
||||
* target, and the words `~` and `{marked}` mean the marked target. One more `=` in front turns
|
||||
* each of those back into a session name, because the marker is consumed and the rest is looked up
|
||||
* as the name. A name starting with `$` is the one that cannot be rescued this way:
|
||||
* `cmd_find_get_session` reads it as a session id before the exact-match flag is consulted. */
|
||||
function tmuxSessionWord(session: string): string {
|
||||
const markedTarget = session === "~" || session === "{marked}";
|
||||
let word = /^[=@%]/.test(session) || markedTarget ? `=${session}` : session;
|
||||
if (word.endsWith(";")) word = `${word.slice(0, -1)}\\;`;
|
||||
return word;
|
||||
}
|
||||
|
||||
/** `tmux attach -t <session>`, with the daemon's server flags when it isn't on the default server.
|
||||
*
|
||||
* The session is encoded for tmux first and then quoted like any other shell word: callers type
|
||||
* this line into a fresh terminal (the CMUX panes) or hand it to a person to run, and a session
|
||||
* name is not guaranteed to be shell-safe — an adopted session's name comes from outside
|
||||
* OpenRig. `shellWord` leaves the ordinary `name@rig` form byte-identical. `=` is the one
|
||||
* character it cannot leave bare: zsh expands a word beginning with `=` to the path of that
|
||||
* command, and with MAGIC_EQUAL_SUBST it can expand one inside a `name=value` word, so any
|
||||
* session word carrying `=` is forced into quotes. A shell passes those quotes through. */
|
||||
export function tmuxAttachCommand(session: string): string {
|
||||
return `${tmuxOnDaemonServer()} attach -t ${session}`;
|
||||
const word = tmuxSessionWord(session);
|
||||
return `${tmuxOnDaemonServer()} attach -t ${shellWord(word, word.includes("="))}`;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { inspectComposerInput } from "../domain/session-transport.js";
|
||||
import { parentSessionEnvKeys, removeParentSessionEnv } from "../domain/parent-session-env.js";
|
||||
import { addSeatRuntimeDir, seatRuntimeDir } from "../domain/seat-runtime-dir.js";
|
||||
import type { TmuxControlExec } from "./tmux-control.js";
|
||||
import { tmuxOnDaemonServer } from "./tmux-server.js";
|
||||
|
||||
export type ExecFn = (cmd: string) => Promise<string>;
|
||||
|
||||
@@ -277,7 +278,9 @@ function parentSessionEnvWarning(target: string, err: unknown): string {
|
||||
/** Shell-quote a string using single quotes (POSIX-safe). */
|
||||
/** One pane's text from a batched capture, with the time its chunk was read (#309 review). `text` is null for a
|
||||
* session that is gone or an empty capture. */
|
||||
export interface PaneCapture { text: string | null; capturedAt: Date; /** The listing lacked the session. */ missing?: true }
|
||||
export interface PaneCapture { text: string | null; capturedAt: Date; /** The listing lacked the session. */ missing?: true;
|
||||
/** The pane and its process that this capture read, from the same tmux call (#2515). */
|
||||
pane?: { id: string; pid: number } }
|
||||
|
||||
/** The most sessions one batched capture chains (#308). Not a byte bound: a chunk whose output overflows exec's buffer
|
||||
* fails and is split in half. */
|
||||
@@ -544,7 +547,7 @@ export class TmuxAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
private async guardedInput(target: string, write: (pane: string, beforeWrite: () => void) => Promise<TmuxResult>, allowAbsent = false, inputKind: "control" | "text" | "keys" | "respawn" = "control"): Promise<TmuxResult> {
|
||||
private async guardedInput(target: string, write: (pane: string, beforeWrite: () => void, nodeId?: string) => Promise<TmuxResult>, allowAbsent = false, inputKind: "control" | "text" | "keys" | "respawn" = "control"): Promise<TmuxResult> {
|
||||
const guard = this.deliveryGuard;
|
||||
if (!guard) return write(target, () => {});
|
||||
try {
|
||||
@@ -594,7 +597,7 @@ export class TmuxAdapter {
|
||||
}
|
||||
// Revalidate registry/occupant after the asynchronous observation. Write
|
||||
// to the immutable pane ID, not a session name which could be recycled.
|
||||
return guard.input(identity, () => write(pane, () => guard.checkInput(identity)));
|
||||
return guard.input(identity, () => write(pane, () => guard.checkInput(identity), bound.nodeId));
|
||||
});
|
||||
} catch (error) {
|
||||
return { ok: false, code: (error as { code?: string }).code ?? "guard_target_unknown", message: String((error as Error).message) };
|
||||
@@ -1308,16 +1311,26 @@ export class TmuxAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
/** With a delivery guard, terminate by immutable tmux session ID; checked callers validate before detach and kill. */
|
||||
/** With a delivery guard, terminate by immutable tmux session ID; checked callers validate before detach and kill.
|
||||
* A seat's session is stopped only when it carries that seat's OpenRig marker, and each effect runs only while that
|
||||
* id still names a session on the server it was read from: a replaced server reuses ids for other terminals. */
|
||||
async killSession(name: string, beforeEffect?: () => void): Promise<TmuxResult> {
|
||||
let stopPhase: StopPhase = "none";
|
||||
if (this.deliveryGuard) {
|
||||
const result = await this.guardedInput(name, async (pane, beforeWrite) => {
|
||||
const stdout = await this.run(["tmux", "display-message", "-p", "-t", pane, "#{session_id}"], `tmux display-message -p -t ${shellQuote(pane)} '#{session_id}'`);
|
||||
const sessionId = stdout.trim();
|
||||
if (!/^\$\d+$/.test(sessionId)) return { ok: false, code: "guard_target_unknown", message: "Cannot establish immutable session identity; no session killed." };
|
||||
const result = await this.guardedInput(name, async (pane, beforeWrite, nodeId) => {
|
||||
// The session id and its server (pid and start time) in one read, so the effects below can be held to both.
|
||||
const stdout = await this.run(["tmux", "display-message", "-p", "-t", pane, "#{session_id}|#{pid}|#{start_time}"],
|
||||
`tmux display-message -p -t ${shellQuote(pane)} '#{session_id}|#{pid}|#{start_time}'`);
|
||||
const [sessionId = "", pid = "", start = ""] = stdout.trim().split("|");
|
||||
if (!/^\$\d+$/.test(sessionId) || !/^\d+$/.test(pid) || !/^\d+$/.test(start)) {
|
||||
return { ok: false, code: "guard_target_unknown", message: "Cannot establish immutable session identity; no session killed." };
|
||||
}
|
||||
if (nodeId) {
|
||||
const unproven = await this.unprovenSeatSession(name, sessionId, nodeId);
|
||||
if (unproven) return unproven;
|
||||
}
|
||||
const kill = async () => {
|
||||
const result = await this.killSessionUnchecked(sessionId, beforeEffect && (() => { beforeWrite(); beforeEffect(); }), phase => { stopPhase = phase; });
|
||||
const result = await this.killSessionUnchecked(sessionId, beforeEffect && (() => { beforeWrite(); beforeEffect(); }), phase => { stopPhase = phase; }, { pid, start });
|
||||
if (result.ok) { this.freshProbes.delete(name); this.freshProbes.delete(pane); this.freshManaged.delete(name); }
|
||||
return result;
|
||||
};
|
||||
@@ -1330,16 +1343,48 @@ export class TmuxAdapter {
|
||||
return this.killSessionUnchecked(name);
|
||||
}
|
||||
|
||||
/** The session a seat's stop would end is the seat's own: every session OpenRig creates for a seat carries its node's
|
||||
* OPENRIG_NODE_ID. One without it (adopted, bound or discovered from outside), or with another seat's, is left
|
||||
* running and named; nothing is detached or killed. */
|
||||
private async unprovenSeatSession(name: string, sessionId: string, nodeId: string): Promise<TmuxResult | null> {
|
||||
let marker: string | null = null;
|
||||
try {
|
||||
const out = (await this.run(["tmux", "show-environment", "-t", sessionId, "OPENRIG_NODE_ID"])).trim();
|
||||
if (out.startsWith("OPENRIG_NODE_ID=")) marker = out.slice("OPENRIG_NODE_ID=".length);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (!/unknown variable/i.test(message)) {
|
||||
return { ok: false, code: "guard_target_unknown", message: `'${name}' was left running: its OpenRig marker couldn't be read (${message.trim()}), so nothing was detached or killed.` };
|
||||
}
|
||||
}
|
||||
if (marker === nodeId) return null;
|
||||
const why = marker === null ? "doesn't carry this seat's OpenRig marker (OPENRIG_NODE_ID)" : `carries another seat's OpenRig marker (OPENRIG_NODE_ID=${marker})`;
|
||||
return { ok: false, code: "seat_target_unproven", message: `'${name}' was left running: its tmux session ${sessionId} ${why}, so it can't be shown `
|
||||
+ "to be this seat's terminal, and nothing was detached or killed. A session OpenRig didn't create, such as an adopted one, has no marker. "
|
||||
// On the daemon's own tmux server: a person's plain `tmux` can reach another server, and a namesake there.
|
||||
+ `If it is this seat's, look first with \`${tmuxOnDaemonServer()} attach -t ${shellQuote(`=${name}`)}\`, then stop it with `
|
||||
+ `\`${tmuxOnDaemonServer()} kill-session -t ${shellQuote(`=${name}`)}\` and run the command again.` };
|
||||
}
|
||||
|
||||
/** Keep effect checks outside command catches so a selection refusal retains its code and phase. */
|
||||
private async killSessionUnchecked(name: string, beforeEffect?: () => void, phase?: (value: StopPhase) => void): Promise<TmuxResult> {
|
||||
private async killSessionUnchecked(name: string, beforeEffect?: () => void, phase?: (value: StopPhase) => void,
|
||||
server?: { pid: string; start: string }): Promise<TmuxResult> {
|
||||
// The guarded path passes the immutable `$N` id, which stays as it is; a bare name is made exact.
|
||||
const session = exactTarget(name, "session");
|
||||
// With its server, an effect runs only while this id is that session on that server: tmux checks and acts in one
|
||||
// command, so a server replaced since the read (whose ids name other terminals) is left alone.
|
||||
const changed = "openrig-stop-target-changed";
|
||||
const effect = (verb: string, flag: string): string[] => server
|
||||
? ["tmux", "if-shell", "-F", "-t", session,
|
||||
`#{&&:#{==:#{session_id},${session}},#{&&:#{==:#{pid},${server.pid}},#{==:#{start_time},${server.start}}}}`,
|
||||
`${verb} ${flag} '${session}'`, `display-message -p ${changed}`]
|
||||
: ["tmux", verb, flag, session];
|
||||
// Detach first so `detach-on-destroy off` cannot switch views onto another session.
|
||||
beforeEffect?.();
|
||||
phase?.("detach_attempted");
|
||||
try {
|
||||
await this.run(["tmux", "detach-client", "-s", session],
|
||||
`tmux detach-client -s ${shellQuote(session)}`);
|
||||
const out = await this.run(effect("detach-client", "-s"), server ? undefined : `tmux detach-client -s ${shellQuote(session)}`);
|
||||
if (out.includes(changed)) return { ok: false, code: "guard_target_changed", message: `${session} is no longer that session on the tmux server it was read from (the server may have been replaced); nothing was detached or killed.` };
|
||||
} catch (err) {
|
||||
// tmux 3.7 says "no current client" when nothing is attached (and for a missing session, which the kill classifies).
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
@@ -1349,8 +1394,8 @@ export class TmuxAdapter {
|
||||
beforeEffect?.();
|
||||
phase?.("kill_attempted");
|
||||
try {
|
||||
await this.run(["tmux", "kill-session", "-t", session],
|
||||
`tmux kill-session -t ${shellQuote(session)}`);
|
||||
const out = await this.run(effect("kill-session", "-t"), server ? undefined : `tmux kill-session -t ${shellQuote(session)}`);
|
||||
if (out.includes(changed)) return { ok: false, code: "guard_target_changed", message: `${session} is no longer that session on the tmux server it was read from (the server may have been replaced); it was detached, but nothing was killed.` };
|
||||
const pane = this.freshProbes.get(name);
|
||||
this.freshProbes.delete(name);
|
||||
if (pane) this.freshProbes.delete(pane);
|
||||
@@ -1781,12 +1826,14 @@ export class TmuxAdapter {
|
||||
const captureChunk = async (chunk: string[]): Promise<void> => {
|
||||
const nonce = randomUUID().replace(/-/g, "");
|
||||
const mark = (k: number) => `__openrig_capture_${nonce}_${k}__`;
|
||||
// The marker line also names the pane and its process that the capture after it reads, in the same call.
|
||||
const header = (k: number) => `${mark(k)} #{pane_id} #{pane_pid}`;
|
||||
const argv = ["tmux", ...chunk.flatMap((t, k) => [
|
||||
...(k > 0 ? [";"] : []),
|
||||
"display-message", "-p", "-t", `=${t}:`, mark(k), ";", "capture-pane", "-p", "-t", `=${t}:`, "-S", `-${lines}`,
|
||||
"display-message", "-p", "-t", `=${t}:`, header(k), ";", "capture-pane", "-p", "-t", `=${t}:`, "-S", `-${lines}`,
|
||||
])];
|
||||
const legacy = "tmux " + chunk.map((t, k) =>
|
||||
`display-message -p -t ${shellQuote(`=${t}:`)} ${shellQuote(mark(k))} \\; capture-pane -p -t ${shellQuote(`=${t}:`)} -S -${lines}`,
|
||||
`display-message -p -t ${shellQuote(`=${t}:`)} ${shellQuote(header(k))} \\; capture-pane -p -t ${shellQuote(`=${t}:`)} -S -${lines}`,
|
||||
).join(" \\; ");
|
||||
let output: string;
|
||||
let capturedAt: Date;
|
||||
@@ -1830,14 +1877,18 @@ export class TmuxAdapter {
|
||||
return; // a single session that failed: read per target by the caller
|
||||
}
|
||||
const segments: Array<string[] | null> = chunk.map(() => null);
|
||||
const panes: Array<PaneCapture["pane"]> = chunk.map(() => undefined);
|
||||
const marker = new RegExp(`^__openrig_capture_${nonce}_(\\d+)__(?: (.*))?$`);
|
||||
let cur = -1;
|
||||
const outLines = output.split("\n");
|
||||
if (outLines.length && outLines[outLines.length - 1] === "") outLines.pop(); // the final newline, not a line
|
||||
for (const line of outLines) {
|
||||
const m = line.startsWith(`__openrig_capture_${nonce}_`) ? /_(\d+)__$/.exec(line) : null;
|
||||
if (m && line === mark(Number(m[1]))) {
|
||||
const m = line.startsWith(`__openrig_capture_${nonce}_`) ? marker.exec(line) : null;
|
||||
if (m && Number(m[1]) < chunk.length) {
|
||||
cur = Number(m[1]);
|
||||
segments[cur] = [];
|
||||
const identity = /^(%\d+) (\d+)$/.exec(m[2] ?? "");
|
||||
panes[cur] = identity ? { id: identity[1]!, pid: Number(identity[2]) } : undefined;
|
||||
} else if (cur >= 0) {
|
||||
segments[cur]!.push(line);
|
||||
}
|
||||
@@ -1847,7 +1898,7 @@ export class TmuxAdapter {
|
||||
if (seg == null) return; // its marker never printed: read per target
|
||||
// capture-pane prints its lines newline-terminated, as the per-target read returns them
|
||||
const text = seg.length ? seg.join("\n") + "\n" : "";
|
||||
out.set(t, { text: text === "" ? null : text, capturedAt });
|
||||
out.set(t, { text: text === "" ? null : text, capturedAt, ...(panes[k] ? { pane: panes[k] } : {}) });
|
||||
});
|
||||
};
|
||||
const unique = [...new Set(targets)];
|
||||
|
||||
@@ -7,6 +7,8 @@ import { queueErasureLinkIndexesSchema } from "./migrations/156_queue_erasure_li
|
||||
import { queueErasureBatchesSchema } from "./migrations/157_queue_erasure_batches.js";
|
||||
import { queueInboxCheckinsSchema } from "./migrations/158_queue_inbox_checkins.js";
|
||||
import { queueItemsHandedOffFromIndexSchema } from "./migrations/159_queue_items_handed_off_from_index.js";
|
||||
import { networkBindingsSchema } from "./migrations/160_network_bindings.js";
|
||||
import { networkReceiptsSchema } from "./migrations/161_network_receipts.js";
|
||||
import { activityGrantsSchema } from "./migrations/143_activity_grants.js";
|
||||
import { environmentBindingsSchema } from "./migrations/144_environment_bindings.js";
|
||||
import { nodePermissionsToSchema } from "./migrations/145_node_permissions_to.js";
|
||||
@@ -321,4 +323,6 @@ export const ALL_MIGRATIONS: Migration[] = [
|
||||
queueErasureBatchesSchema,
|
||||
queueInboxCheckinsSchema,
|
||||
queueItemsHandedOffFromIndexSchema,
|
||||
networkBindingsSchema,
|
||||
networkReceiptsSchema,
|
||||
];
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Migration } from "../migrate.js";
|
||||
|
||||
/** A selected local seat joined to one participant of a remote hub (an account's network rig, reached over MCP). The
|
||||
* participant's credential lives in a private file under the OpenRig home, never in this table. One active binding per
|
||||
* local seat and per hub participant; a disconnected or revoked binding stays as history. `allow_http` records the
|
||||
* person's choice to send the participant's token to a plain-http hub. */
|
||||
export const networkBindingsSchema: Migration = {
|
||||
name: "160_network_bindings.sql",
|
||||
sql: `
|
||||
CREATE TABLE IF NOT EXISTS network_bindings (
|
||||
id TEXT PRIMARY KEY,
|
||||
node_id TEXT NOT NULL,
|
||||
local_session TEXT NOT NULL,
|
||||
hub_url TEXT NOT NULL,
|
||||
hub_seat TEXT NOT NULL,
|
||||
allow_http INTEGER NOT NULL DEFAULT 0 CHECK (allow_http IN (0, 1)),
|
||||
state TEXT NOT NULL CHECK (state IN ('active', 'revoked', 'disconnected')),
|
||||
state_reason TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
last_contact_at TEXT,
|
||||
last_error TEXT
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_network_bindings_active_node ON network_bindings(node_id) WHERE state = 'active';
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_network_bindings_active_participant ON network_bindings(hub_url, hub_seat) WHERE state = 'active';
|
||||
`,
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Migration } from "../migrate.js";
|
||||
|
||||
/** One hub item taken in, and the local item that carries it: what was reported back, and when. It's a receipt, not a
|
||||
* task: the work itself is the local queue item, found by its id, which is derived from the seat's node, the hub, the
|
||||
* participant and the hub item. `binding_id` is the binding that reports it now, and a new binding for the same seat,
|
||||
* hub and participant takes it over. `settled_at` is when nothing more is reported (the result was sent, the outcome is
|
||||
* unknown, or the hub item was closed elsewhere: `outcome` says which); `checked_at` gives every open receipt a turn. */
|
||||
export const networkReceiptsSchema: Migration = {
|
||||
name: "161_network_receipts.sql",
|
||||
sql: `
|
||||
CREATE TABLE IF NOT EXISTS network_receipts (
|
||||
local_qitem_id TEXT PRIMARY KEY,
|
||||
binding_id TEXT NOT NULL,
|
||||
hub_qitem_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
queued_sent_at TEXT,
|
||||
pickup_sent_at TEXT,
|
||||
settled_at TEXT,
|
||||
checked_at TEXT,
|
||||
outcome TEXT,
|
||||
last_error TEXT
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_network_receipts_hub ON network_receipts(binding_id, hub_qitem_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_network_receipts_open ON network_receipts(binding_id) WHERE settled_at IS NULL;
|
||||
`,
|
||||
};
|
||||
@@ -26,6 +26,11 @@ export interface HookActivityInput {
|
||||
sourceSequence?: number | null;
|
||||
/** How often the emitter promises to report while it stays idle (OpenRig's pi runner). */
|
||||
livenessSeconds?: number | null;
|
||||
/** #99: the reset instant a Claude usage limit stated; kept only on that typed failure. */
|
||||
resetsAt?: string | null;
|
||||
/** The provider's own session id the hook reports (Claude's session_id). A stated reset is kept only when this is
|
||||
* the seat's own Claude session (its resume token), so a helper on another login can't park the seat's pool. */
|
||||
sessionId?: string | null;
|
||||
}
|
||||
|
||||
export type RecordHookActivityResult =
|
||||
@@ -113,6 +118,16 @@ export class AgentActivityStore {
|
||||
} catch { return unavailablePermissionStop(); }
|
||||
}
|
||||
|
||||
/** #99: whether a hook's Claude session id is the seat's own Claude session (its current resume token). A helper
|
||||
* Claude started from the seat's shell posts as the seat but runs its own session, possibly on another login. */
|
||||
private isSeatOwnClaudeSession(nodeId: string, hookSessionId: string | null | undefined): boolean {
|
||||
const token = typeof hookSessionId === "string" ? hookSessionId.trim() : "";
|
||||
if (!token) return false;
|
||||
const row = this.db.prepare("SELECT resume_type, resume_token FROM sessions WHERE node_id = ? ORDER BY id DESC LIMIT 1")
|
||||
.get(nodeId) as { resume_type: string | null; resume_token: string | null } | undefined;
|
||||
return row?.resume_type === "claude_id" && row.resume_token?.trim() === token;
|
||||
}
|
||||
|
||||
recordHookEvent(input: HookActivityInput): RecordHookActivityResult {
|
||||
if (!input.sessionName && !input.nodeId) {
|
||||
return {
|
||||
@@ -159,6 +174,10 @@ export class AgentActivityStore {
|
||||
if (input.hookAgentId !== undefined) activity.hookAgentId = input.hookAgentId;
|
||||
if (input.sourceSequence) activity.sourceSequence = input.sourceSequence;
|
||||
if (input.livenessSeconds) activity.livenessSeconds = input.livenessSeconds;
|
||||
if (input.resetsAt && isClaudeRateLimitHook(activity.runtime, input.hookEvent, input.subtype)
|
||||
&& this.isSeatOwnClaudeSession(session.node_id, input.sessionId)) {
|
||||
activity.resetsAt = input.resetsAt;
|
||||
}
|
||||
|
||||
const event = this.eventBus.emit({
|
||||
type: "agent.activity",
|
||||
|
||||
@@ -89,5 +89,6 @@ export function permissionDeciderFor(
|
||||
const kernel = seatState(db, KERNEL_OPERATOR_SESSION);
|
||||
if (kernel.state === "missing" || (kernel.state === "unknown" && !kernel.recorded)) return null;
|
||||
return { session: KERNEL_OPERATOR_SESSION, source: "kernel-operator", ...kernel,
|
||||
note: receiver.session ? `${receiver.session}, the receiving agent, ${receiver.session === requester ? "is the seat asking" : "isn't on this host"}` : undefined };
|
||||
// A setting that's set but ignored keeps saying so, rather than reading as unset.
|
||||
note: receiver.session ? `${receiver.session}, the receiving agent, ${receiver.session === requester ? "is the seat asking" : "isn't on this host"}` : receiver.note };
|
||||
}
|
||||
@@ -110,6 +110,29 @@ export class EventBus {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a writer whose failure the caller handles, in its own savepoint (a transaction outside one). If it throws
|
||||
* anywhere, even after a nested write of its own was released, everything it wrote rolls back, the event tokens it
|
||||
* persisted leave the active envelope too, and the caller's transaction can still commit its fallback. With no
|
||||
* envelope and no transaction open (a direct answer from Slack), it runs in a notify envelope of its own, so its events
|
||||
* reach subscribers only after it commits, and a throw delivers none of them.
|
||||
*/
|
||||
discardOnThrow<T>(writer: () => T): T {
|
||||
const envelope = this.activeEnvelope;
|
||||
if (!envelope && !this.db.inTransaction) return this.withNotifyEnvelope(() => writer());
|
||||
const persisted = envelope ? new Set(envelope.persisted) : null;
|
||||
const registered = envelope ? new Set(envelope.registered) : null;
|
||||
try {
|
||||
return this.db.transaction(writer)();
|
||||
} catch (err) {
|
||||
if (envelope && persisted && registered) {
|
||||
for (const token of [...envelope.persisted]) if (!persisted.has(token)) envelope.persisted.delete(token);
|
||||
for (const token of [...envelope.registered]) if (!registered.has(token)) envelope.registered.delete(token);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one caller-owned synchronous transaction under the W2b notification
|
||||
* envelope. Every token persisted through EventBus during the callback must
|
||||
|
||||
@@ -2,7 +2,7 @@ import { physicalSessionName } from "./session-registry.js";
|
||||
import { resolveNodeFile } from "./scope/node-file.js";
|
||||
import { belongsToProject } from "./workspace/project-catalog.js";
|
||||
import { inspectGraph } from "./workflow-reconciliation.js";
|
||||
import { createProofPolicyRead, readMissionReadiness, readProjectReadiness } from "./proof/judgments.js";
|
||||
import { createProofPolicyRead, readMissionReadiness, readProjectReadinessAsync } from "./proof/judgments.js";
|
||||
import { lifecycleObligations, requiredLifecycleSteps } from "./lifecycle-obligations.js";
|
||||
import { QueueWakeRepository } from "./queue-wake-repository.js";
|
||||
import { isTypedGateBlocker } from "./queue-repository.js";
|
||||
@@ -1361,7 +1361,7 @@ export async function buildExecutionView(deps: ExecutionViewDeps, opts?: { missi
|
||||
...(opts?.project ? { project: opts.project, membership: "exact project:<id> queue tags and lifecycle identity; unscoped rows excluded" } : {}),
|
||||
view: "execution",
|
||||
readiness,
|
||||
project_readiness: missionsRoot ? readProjectReadiness(missionsRoot, readPolicy) : null,
|
||||
project_readiness: missionsRoot ? await readProjectReadinessAsync(missionsRoot, readPolicy) : null,
|
||||
// Authored guidance is carried verbatim in meaning, never parsed into edges or acceptance.
|
||||
planning_guidance: arrangement?.state === "valid" ? arrangement.guidance : [],
|
||||
mission,
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { RosterWake } from "./rosters.js";
|
||||
import { resolveSecret } from "./gateway/slack/secrets.js";
|
||||
import { safeDecodeURIComponent, validateOutboundUrl } from "./mission-control/outbound-url-validator.js";
|
||||
|
||||
/** How long a trigger may take to answer before the ring counts as failed. */
|
||||
/** How long a trigger may take to answer. After that the ring is unconfirmed, not failed: the trigger may have it. */
|
||||
export const HOSTED_WAKE_TIMEOUT_MS = 10_000;
|
||||
|
||||
export interface HostedWakeRing {
|
||||
@@ -66,8 +66,20 @@ export async function ringHostedWake(
|
||||
} catch (err) {
|
||||
// An error's text can carry the URL, so only a fixed classification is recorded: the timeout, or the error code.
|
||||
const timedOut = err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError");
|
||||
if (timedOut) return failed(urlRef, `didn't answer within ${opts.timeoutMs ?? HOSTED_WAKE_TIMEOUT_MS} ms`);
|
||||
// The POST may have reached the trigger and started a run, so the ring is unknown, like an unconfirmed send: it isn't
|
||||
// rung again blindly (see hostedRingMayHaveArrived), and the member's claim is still the pickup.
|
||||
if (timedOut) {
|
||||
return { classified: "indeterminate", nudgeResult: `indeterminate:webhook (${urlRef}) didn't answer within ${opts.timeoutMs ?? HOSTED_WAKE_TIMEOUT_MS} ms, `
|
||||
+ "so it may have the ring; it isn't rung again, and pickup is the member's claim on this row" };
|
||||
}
|
||||
const code = (err as { cause?: { code?: unknown } } | null)?.cause?.code;
|
||||
return failed(urlRef, `couldn't be reached (${typeof code === "string" && /^[A-Z][A-Z0-9_]*$/.test(code) ? code : "network error"})`);
|
||||
}
|
||||
}
|
||||
|
||||
/** A ring the trigger accepted, or one it may have received before it stopped answering. Either can start a run, so
|
||||
* recovery doesn't ring that wake intent again. A ring that never left (no URL, an invalid one, a refused connection)
|
||||
* is neither. */
|
||||
export function hostedRingMayHaveArrived(nudgeResult: string): boolean {
|
||||
return nudgeResult.startsWith("wake-accepted:webhook ") || nudgeResult.startsWith("indeterminate:webhook ");
|
||||
}
|
||||
@@ -818,6 +818,9 @@ export class MissionControlWriteContract {
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
// A runner's permission request isn't closed by an answer: it's handed to the receiving agent, or kept. Say which.
|
||||
const final = after as QueueItem | null;
|
||||
const runnerNotice = !answer.message && final && runnerPermissionOrigin(final) ? runnerAnswerNotice(final) : undefined;
|
||||
return {
|
||||
actionId: actionEntry!.actionId,
|
||||
verb: actionVerb,
|
||||
@@ -827,7 +830,7 @@ export class MissionControlWriteContract {
|
||||
notifyAttempted: false,
|
||||
notifyResult: null,
|
||||
auditedAt: evaluatedAt,
|
||||
answer,
|
||||
answer: runnerNotice ? { ...answer, message: runnerNotice } : answer,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -960,6 +963,18 @@ function verbToClosure(input: MissionControlActionInput): ClosureMapping {
|
||||
}
|
||||
}
|
||||
|
||||
/** What became of a person's answer on a runner's permission request: handed to the receiving agent, or kept (the
|
||||
* person's own receipt says why). Undefined for a request the answer closed (no agent wired here). */
|
||||
function runnerAnswerNotice(ask: QueueItem): string | undefined {
|
||||
if (ask.state === "handed-off" && ask.handedOffTo) {
|
||||
return `Your answer on ${ask.qitemId} went to ${ask.handedOffTo}, the receiving agent, which decides. The runner waits until then; nothing has run. A note to you says the agent's recorded state.`;
|
||||
}
|
||||
if (ask.state === "pending" || ask.state === "in-progress" || ask.state === "blocked") {
|
||||
return `Your answer is kept on ${ask.qitemId}, and the request is still waiting: the tool hasn't run, and nothing was permitted or refused. A note to you says why it wasn't passed to a receiving agent. To decide directly: rig seat decide-permission ${ask.qitemId} --option <id>.`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function snapshotQitem(q: QueueItem): Record<string, unknown> {
|
||||
return {
|
||||
qitemId: q.qitemId,
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// The connector's view of a remote hub: the queue tools an external grant offers its participant (list its own items,
|
||||
// claim, show, update), reached over MCP's Streamable HTTP with that participant's bearer. Nothing here decides what to
|
||||
// do with an item; it only says what the hub answered, and sorts a failure into what the caller must do about it.
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { StreamableHTTPClientTransport, StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
|
||||
export interface HubItem {
|
||||
qitemId: string;
|
||||
sourceSession: string;
|
||||
destinationSession: string;
|
||||
state: string;
|
||||
body?: string | null;
|
||||
summary?: string | null;
|
||||
resolution?: string | null;
|
||||
closureReason?: string | null;
|
||||
}
|
||||
|
||||
/** `revoked`: the hub refused the credential (401), so the binding ends. `unavailable`: the hub or its grant authority
|
||||
* couldn't answer, or the connection failed, so try later. `refused`: the hub answered this one call with an error. */
|
||||
export type HubErrorKind = "revoked" | "unavailable" | "refused";
|
||||
|
||||
export class HubError extends Error {
|
||||
constructor(readonly kind: HubErrorKind, message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export interface HubSession {
|
||||
listOwned(state: "pending" | "in-progress", limit: number): Promise<HubItem[]>;
|
||||
claim(qitemId: string): Promise<void>;
|
||||
show(qitemId: string): Promise<HubItem>;
|
||||
note(qitemId: string, note: string): Promise<void>;
|
||||
/** Close the way the queue closes an item: done with a closure reason (no-follow-on, canceled or denied), or failed. */
|
||||
close(qitemId: string, input: { state: "done" | "failed"; closureReason: string | null; resolution: string }): Promise<void>;
|
||||
end(): Promise<void>;
|
||||
}
|
||||
|
||||
/** `signal` closes the connection when it aborts, which ends any call still waiting on the hub. */
|
||||
export type HubConnect = (hubUrl: string, token: string, signal?: AbortSignal) => Promise<HubSession>;
|
||||
|
||||
/** A failure, sorted. The hub's own MCP endpoint answers 401 for an unknown or revoked credential and 503 when its grant
|
||||
* authority can't answer (`packages/cli/src/mcp-http.ts`). */
|
||||
export function hubFailure(error: unknown): HubError {
|
||||
if (error instanceof HubError) return error;
|
||||
if (error instanceof StreamableHTTPError && error.code === 401) {
|
||||
return new HubError("revoked", "The hub refused this participant's credential: it's unknown there or was revoked.");
|
||||
}
|
||||
if (error instanceof StreamableHTTPError && typeof error.code === "number" && error.code >= 300 && error.code < 400) {
|
||||
return new HubError("unavailable", `The hub redirected the connection somewhere else, and the participant's token isn't sent there (${error.message.slice(0, 300)}). Connect the binding to the hub's own URL.`);
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return new HubError("unavailable", `The hub couldn't be reached: ${message.slice(0, 300)}`);
|
||||
}
|
||||
|
||||
function parsed(result: { isError?: boolean; content?: unknown }): unknown {
|
||||
const content = Array.isArray(result.content) ? result.content as Array<{ type?: string; text?: string }> : [];
|
||||
const text = content.filter((part) => part.type === "text").map((part) => part.text ?? "").join("\n");
|
||||
if (result.isError) throw new HubError("refused", text.slice(0, 500) || "The hub refused this call.");
|
||||
try { return JSON.parse(text); } catch { throw new HubError("refused", "The hub answered with something that isn't JSON."); }
|
||||
}
|
||||
|
||||
const item = (value: unknown): HubItem => {
|
||||
const row = (value ?? {}) as Record<string, unknown>;
|
||||
if (typeof row.qitemId !== "string") throw new HubError("refused", "The hub answered without an item id.");
|
||||
return {
|
||||
qitemId: row.qitemId,
|
||||
sourceSession: String(row.sourceSession ?? ""),
|
||||
destinationSession: String(row.destinationSession ?? ""),
|
||||
state: String(row.state ?? ""),
|
||||
body: typeof row.body === "string" ? row.body : null,
|
||||
summary: typeof row.summary === "string" ? row.summary : null,
|
||||
resolution: typeof row.resolution === "string" ? row.resolution : null,
|
||||
closureReason: typeof row.closureReason === "string" ? row.closureReason : null,
|
||||
};
|
||||
};
|
||||
|
||||
/** Connect to a hub as one participant. The bearer goes only in this transport's Authorization header. The SDK's
|
||||
* same-origin policy, set explicitly, follows a redirect only within the hub's origin, or to its https form on the same
|
||||
* host with default ports, so the bearer follows only those; any other redirect fails with its target named. */
|
||||
export const connectMcpHub: HubConnect = async (hubUrl, token, signal) => {
|
||||
const transport = new StreamableHTTPClientTransport(new URL(hubUrl), {
|
||||
requestInit: { headers: { Authorization: `Bearer ${token}` } },
|
||||
redirectPolicy: "same-origin",
|
||||
});
|
||||
const client = new Client({ name: "openrig-network-connector", version: "1" });
|
||||
const close = () => { void client.close().catch(() => {}); };
|
||||
if (signal?.aborted) throw new HubError("unavailable", "The pass ended before it reached the hub.");
|
||||
signal?.addEventListener("abort", close, { once: true });
|
||||
try {
|
||||
await client.connect(transport);
|
||||
} catch (error) {
|
||||
signal?.removeEventListener("abort", close);
|
||||
await client.close().catch(() => {});
|
||||
throw hubFailure(error);
|
||||
}
|
||||
const call = async (name: string, args: Record<string, unknown>): Promise<unknown> => {
|
||||
try {
|
||||
return parsed(await client.callTool({ name, arguments: args }) as { isError?: boolean; content?: unknown });
|
||||
} catch (error) {
|
||||
throw hubFailure(error);
|
||||
}
|
||||
};
|
||||
return {
|
||||
listOwned: async (state, limit) => {
|
||||
const rows = await call("rig_queue_list", { owned: true, state, limit });
|
||||
if (!Array.isArray(rows)) throw new HubError("refused", "The hub's list wasn't a list.");
|
||||
return rows.map(item);
|
||||
},
|
||||
claim: async (qitemId) => { await call("rig_queue_claim", { qitemId }); },
|
||||
show: async (qitemId) => {
|
||||
const shown = await call("rig_queue_show", { qitemId }) as Record<string, unknown>;
|
||||
return item(shown?.item ?? shown);
|
||||
},
|
||||
note: async (qitemId, note) => { await call("rig_queue_update", { qitemId, note }); },
|
||||
close: async (qitemId, input) => {
|
||||
await call("rig_queue_update", { qitemId, state: input.state, ...(input.closureReason ? { closureReason: input.closureReason } : {}), resolution: input.resolution });
|
||||
},
|
||||
end: async () => { signal?.removeEventListener("abort", close); await client.close().catch(() => {}); },
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,238 @@
|
||||
// A selected local seat joined to one participant of a remote hub: the account's network rig, which this daemon reaches
|
||||
// as an MCP client with that participant's own credential (an external grant). The binding names the local seat by its
|
||||
// durable node id, so a renamed or relaunched seat keeps it; the hub address represents the seat and never renames it.
|
||||
// The credential is a private file under the OpenRig home; this table only says where the binding stands.
|
||||
import type Database from "better-sqlite3";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { closeSync, existsSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { resolveSessionNodeId } from "../queue-owner.js";
|
||||
|
||||
export type NetworkBindingState = "active" | "revoked" | "disconnected";
|
||||
|
||||
export interface NetworkBinding {
|
||||
id: string;
|
||||
nodeId: string;
|
||||
/** The seat's address when it was bound, for display; the node id is what the binding follows. */
|
||||
localSession: string;
|
||||
hubUrl: string;
|
||||
/** The participant's address in the hub (`member@rig`). */
|
||||
hubSeat: string;
|
||||
/** The person chose, for this binding, to send the participant's token to a plain-http hub. */
|
||||
allowHttp: boolean;
|
||||
/** The hub URL is plain http, so the token and the work cross the network unencrypted. */
|
||||
unencrypted: boolean;
|
||||
state: NetworkBindingState;
|
||||
stateReason: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastContactAt: string | null;
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
export class NetworkBindingError extends Error {
|
||||
constructor(readonly code: string, message: string, readonly status: 400 | 404 | 409 | 500 = 400) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
interface Row {
|
||||
id: string; node_id: string; local_session: string; hub_url: string; hub_seat: string; allow_http: number; state: NetworkBindingState;
|
||||
state_reason: string | null; created_at: string; updated_at: string; last_contact_at: string | null; last_error: string | null;
|
||||
}
|
||||
|
||||
const project = (row: Row): NetworkBinding => ({
|
||||
id: row.id, nodeId: row.node_id, localSession: row.local_session, hubUrl: row.hub_url, hubSeat: row.hub_seat,
|
||||
allowHttp: row.allow_http === 1, unencrypted: row.hub_url.startsWith("http:"), state: row.state, stateReason: row.state_reason, createdAt: row.created_at, updatedAt: row.updated_at,
|
||||
lastContactAt: row.last_contact_at, lastError: row.last_error,
|
||||
});
|
||||
|
||||
const MAX_CREDENTIAL_BYTES = 16384;
|
||||
|
||||
/** Where a binding's credential lives: `<home>/network-secrets/<binding id>/credential.json`, private to this user. */
|
||||
export function networkCredentialFile(home: string, bindingId: string): string {
|
||||
if (!/^netb-[0-9a-f]{20}$/.test(bindingId)) throw new NetworkBindingError("invalid_binding_id", `'${bindingId}' isn't a network binding id.`);
|
||||
return join(home, "network-secrets", bindingId, "credential.json");
|
||||
}
|
||||
|
||||
/** The participant's bearer, read from its private file. A file anyone else can read, a link or an oversized file is
|
||||
* refused, with the path named and the value never shown. */
|
||||
export function readNetworkCredential(home: string, bindingId: string): string {
|
||||
const file = networkCredentialFile(home, bindingId);
|
||||
let stat;
|
||||
try { stat = lstatSync(file); } catch {
|
||||
throw new NetworkBindingError("credential_missing", `The credential for ${bindingId} isn't at ${file}. Connect the seat again with its token file.`, 404);
|
||||
}
|
||||
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_CREDENTIAL_BYTES || (stat.mode & 0o077) !== 0) {
|
||||
throw new NetworkBindingError("credential_unsafe", `${file} must be a regular file readable only by this user.`);
|
||||
}
|
||||
// A parse error can quote the file's text, which holds the token: say only which file.
|
||||
let value: { version?: unknown; token?: unknown };
|
||||
try { value = JSON.parse(readFileSync(file, "utf8")) as typeof value; } catch {
|
||||
throw new NetworkBindingError("credential_invalid", `${file} isn't a network credential.`);
|
||||
}
|
||||
if (value === null || typeof value !== "object" || value.version !== 1 || typeof value.token !== "string" || !value.token) {
|
||||
throw new NetworkBindingError("credential_invalid", `${file} isn't a network credential.`);
|
||||
}
|
||||
return value.token;
|
||||
}
|
||||
|
||||
function validHubUrl(raw: unknown, allowHttp: boolean): string {
|
||||
if (typeof raw !== "string" || !raw.trim()) throw new NetworkBindingError("invalid_hub_url", "A hub URL is required.");
|
||||
let url: URL;
|
||||
try { url = new URL(raw.trim()); } catch { throw new NetworkBindingError("invalid_hub_url", `'${raw}' isn't a URL.`); }
|
||||
if (url.protocol !== "https:" && url.protocol !== "http:") throw new NetworkBindingError("invalid_hub_url", "The hub URL must use https (or http).");
|
||||
// A credential in the URL would be stored in plain text in this table and shown in status.
|
||||
if (url.username || url.password) throw new NetworkBindingError("invalid_hub_url", "Put the participant's token in its token file, not in the hub URL.");
|
||||
// Plain http is the person's choice per binding. The scheme is kept as given, and the address isn't judged: a private
|
||||
// or tailnet address doesn't prove the connection is protected.
|
||||
if (url.protocol === "http:" && !allowHttp) {
|
||||
throw new NetworkBindingError("http_not_allowed", `${url.toString()} is plain http, so the participant's token and its work would cross the network unencrypted, where they can be read. Use the hub's https URL, or connect again with --allow-http to accept that for this binding.`);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function validHubSeat(raw: unknown): string {
|
||||
if (typeof raw !== "string" || !/^[^@\s]+@[^@\s]+$/.test(raw.trim())) {
|
||||
throw new NetworkBindingError("invalid_hub_seat", "The hub participant is its address in the hub, member@rig.");
|
||||
}
|
||||
return raw.trim();
|
||||
}
|
||||
|
||||
function validToken(raw: unknown): string {
|
||||
const token = typeof raw === "string" ? raw.trim() : "";
|
||||
if (!token || /\s/.test(token) || Buffer.byteLength(token) > 8192) {
|
||||
throw new NetworkBindingError("invalid_token", "The participant's token must be one non-empty line with no spaces.");
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
export class NetworkBindingStore {
|
||||
constructor(
|
||||
private readonly db: Database.Database,
|
||||
private readonly home: string,
|
||||
private readonly now: () => Date = () => new Date(),
|
||||
private readonly newId: () => string = () => `netb-${randomBytes(10).toString("hex")}`,
|
||||
) {}
|
||||
|
||||
list(): NetworkBinding[] {
|
||||
return (this.db.prepare("SELECT * FROM network_bindings ORDER BY created_at, id").all() as Row[]).map(project);
|
||||
}
|
||||
|
||||
active(): NetworkBinding[] {
|
||||
return (this.db.prepare("SELECT * FROM network_bindings WHERE state = 'active' ORDER BY created_at, id").all() as Row[]).map(project);
|
||||
}
|
||||
|
||||
get(id: string): NetworkBinding | null {
|
||||
const row = this.db.prepare("SELECT * FROM network_bindings WHERE id = ?").get(id) as Row | undefined;
|
||||
return row ? project(row) : null;
|
||||
}
|
||||
|
||||
/** A binding by its id, or the active binding of a local seat named by its address. */
|
||||
find(ref: string): NetworkBinding | null {
|
||||
const byId = this.get(ref);
|
||||
if (byId) return byId;
|
||||
const nodeId = resolveSessionNodeId(this.db, ref);
|
||||
const row = nodeId ? this.db.prepare("SELECT * FROM network_bindings WHERE node_id = ? AND state = 'active'").get(nodeId) as Row | undefined : undefined;
|
||||
return row ? project(row) : null;
|
||||
}
|
||||
|
||||
/** Bind a local seat to a hub participant with that participant's token. The credential is written first, exclusively
|
||||
* and private, then the row. If either step fails, the credential this call created is removed, so a refused bind
|
||||
* leaves none behind; a file that was already at that path is never touched. */
|
||||
create(input: { session: unknown; hubUrl: unknown; hubSeat: unknown; token: unknown; allowHttp?: unknown }): NetworkBinding {
|
||||
const session = typeof input.session === "string" ? input.session.trim() : "";
|
||||
if (!session) throw new NetworkBindingError("invalid_seat", "Name the local seat to connect.");
|
||||
const nodeId = resolveSessionNodeId(this.db, session);
|
||||
if (!nodeId) throw new NetworkBindingError("seat_not_found", `No local seat is named '${session}'.`, 404);
|
||||
const allowHttp = input.allowHttp === true;
|
||||
const hubUrl = validHubUrl(input.hubUrl, allowHttp);
|
||||
const hubSeat = validHubSeat(input.hubSeat);
|
||||
const token = validToken(input.token);
|
||||
const bound = this.db.prepare("SELECT id FROM network_bindings WHERE node_id = ? AND state = 'active'").get(nodeId) as { id: string } | undefined;
|
||||
if (bound) throw new NetworkBindingError("seat_already_connected", `${session} is already connected (${bound.id}). Disconnect it first.`, 409);
|
||||
const taken = this.db.prepare("SELECT id FROM network_bindings WHERE hub_url = ? AND hub_seat = ? AND state = 'active'").get(hubUrl, hubSeat) as { id: string } | undefined;
|
||||
if (taken) throw new NetworkBindingError("participant_already_connected", `${hubSeat} at ${hubUrl} is already connected (${taken.id}).`, 409);
|
||||
|
||||
const id = this.newId();
|
||||
const file = networkCredentialFile(this.home, id);
|
||||
let created = false;
|
||||
let madeFolder: string | undefined;
|
||||
try {
|
||||
madeFolder = mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
||||
const fd = openSync(file, "wx", 0o600);
|
||||
created = true;
|
||||
try { writeFileSync(fd, `${JSON.stringify({ version: 1, token })}\n`); } finally { closeSync(fd); }
|
||||
const at = this.now().toISOString();
|
||||
this.db.prepare(`INSERT INTO network_bindings (id, node_id, local_session, hub_url, hub_seat, allow_http, state, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?)`).run(id, nodeId, session, hubUrl, hubSeat, allowHttp ? 1 : 0, at, at);
|
||||
} catch (error) {
|
||||
// Only what this call made: the file it opened, and this binding's folder if it made it (`mkdirSync` returns the
|
||||
// first folder it made, so any value means this one is new). A folder that was already there keeps its other files.
|
||||
if (created) rmSync(file, { force: true });
|
||||
if (madeFolder !== undefined) rmSync(dirname(file), { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
return this.get(id)!;
|
||||
}
|
||||
|
||||
/** The person ends this binding here. No new work comes in through it; local rows, results and other bindings stay. The
|
||||
* credential is removed, and the hub still lists the participant until it's removed there too. */
|
||||
disconnect(ref: string, reason?: string): NetworkBinding {
|
||||
return this.end(ref, "disconnected", reason?.trim() || "disconnected here");
|
||||
}
|
||||
|
||||
/** The hub refused the credential: it was revoked there. Same effect as a local disconnect. */
|
||||
markRevoked(id: string, reason: string): NetworkBinding {
|
||||
return this.end(id, "revoked", reason);
|
||||
}
|
||||
|
||||
/** The connector reached the hub as this binding. */
|
||||
recordContact(id: string, at: Date = this.now()): void {
|
||||
this.db.prepare("UPDATE network_bindings SET last_contact_at = ?, last_error = NULL, updated_at = ? WHERE id = ?")
|
||||
.run(at.toISOString(), at.toISOString(), id);
|
||||
}
|
||||
|
||||
/** The connector couldn't reach the hub as this binding, or the hub answered with an error; the binding stays active.
|
||||
* Control characters are replaced, since the text can come from the hub and `rig network status` prints it. */
|
||||
recordError(id: string, message: string): void {
|
||||
this.db.prepare("UPDATE network_bindings SET last_error = ?, updated_at = ? WHERE id = ?")
|
||||
.run(message.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").slice(0, 500), this.now().toISOString(), id);
|
||||
}
|
||||
|
||||
/** The binding ends first, so no new work comes in even if the credential can't be removed; removal is tried on every
|
||||
* call, including again on an already ended binding, until its credential is gone. */
|
||||
private end(ref: string, state: "disconnected" | "revoked", reason: string): NetworkBinding {
|
||||
const binding = this.find(ref);
|
||||
if (!binding) throw new NetworkBindingError("binding_not_found", `No network binding or connected seat is named '${ref}'.`, 404);
|
||||
if (binding.state === "active") {
|
||||
this.db.prepare("UPDATE network_bindings SET state = ?, state_reason = ?, updated_at = ? WHERE id = ? AND state = 'active'")
|
||||
.run(state, reason, this.now().toISOString(), binding.id);
|
||||
}
|
||||
try {
|
||||
this.removeCredential(binding.id);
|
||||
} catch (error) {
|
||||
throw new NetworkBindingError("credential_cleanup_failed",
|
||||
`${binding.id} is ${this.get(binding.id)!.state}, but its credential at ${networkCredentialFile(this.home, binding.id)} couldn't be removed (${(error as Error).message}). Run \`rig network disconnect ${binding.id}\` again to retry.`, 500);
|
||||
}
|
||||
return this.get(binding.id)!;
|
||||
}
|
||||
|
||||
/** Removes the credential folders whose binding isn't active: one a stop left between the credential write and its
|
||||
* row, or one whose removal failed when its binding ended. Other names under the folder are left alone. */
|
||||
sweepCredentials(): { removed: string[]; failed: string[] } {
|
||||
const swept = { removed: [] as string[], failed: [] as string[] };
|
||||
let names: string[];
|
||||
try { names = readdirSync(join(this.home, "network-secrets")); } catch { return swept; }
|
||||
for (const name of names.sort()) {
|
||||
if (!/^netb-[0-9a-f]{20}$/.test(name) || this.get(name)?.state === "active") continue;
|
||||
try { this.removeCredential(name); swept.removed.push(name); } catch { swept.failed.push(name); }
|
||||
}
|
||||
return swept;
|
||||
}
|
||||
|
||||
private removeCredential(id: string): void {
|
||||
const file = networkCredentialFile(this.home, id);
|
||||
if (existsSync(dirname(file))) rmSync(dirname(file), { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
// The outbound connector: for each active network binding, reach the hub as that participant, take the items addressed
|
||||
// to it into the bound local seat's queue, and carry what happens locally back to the hub item.
|
||||
//
|
||||
// Honest stages, kept apart on the hub item:
|
||||
// - a hub claim is the connector taking custody of the item, not the agent picking it up;
|
||||
// - "picked up" is reported only once the local item is claimed;
|
||||
// - the result is the local item's own closing outcome.
|
||||
//
|
||||
// Each hub item maps to one local item, under an id derived from what it is: the seat's node, the hub, the participant
|
||||
// and the hub item. The queue's same-id absorption makes every retry, restart or new binding for the same seat, hub and
|
||||
// participant land on that one item, so a lost response never starts a second one. An item whose local record is gone
|
||||
// reads unknown and is never run again.
|
||||
import type Database from "better-sqlite3";
|
||||
import { createHash } from "node:crypto";
|
||||
import { QueueRepositoryError, type QueueItem, type QueueRepository } from "../queue-repository.js";
|
||||
import { NetworkBindingError, readNetworkCredential, type NetworkBinding, type NetworkBindingStore } from "./network-bindings.js";
|
||||
import { connectMcpHub, HubError, type HubConnect, type HubItem, type HubSession } from "./hub-client.js";
|
||||
|
||||
/** OpenRig's own sender: the connector creates the local item for the hub's requester, who is named first in it. */
|
||||
const LOCAL_SOURCE = "daemon@kernel";
|
||||
const PAGE = 25;
|
||||
const MAX_PAGES = 8;
|
||||
/** Receipts looked at per pass, the least recently looked at first, so a long backlog still gives every item a turn. */
|
||||
const REPORT_LIMIT = 50;
|
||||
/** How long one binding's pass may take before its connection is closed, so one slow hub can't hold the others. */
|
||||
const PASS_TIMEOUT_MS = 60_000;
|
||||
|
||||
/** The local item for a hub item, by what it is. A retry, a restart or a new binding for the same seat, hub and
|
||||
* participant lands on the same item. */
|
||||
export function networkLocalItemId(binding: Pick<NetworkBinding, "nodeId" | "hubUrl" | "hubSeat">, hubQitemId: string): string {
|
||||
return `qitem-net-${createHash("sha256").update([binding.nodeId, binding.hubUrl, binding.hubSeat, hubQitemId].join("\n")).digest("hex").slice(0, 20)}`;
|
||||
}
|
||||
|
||||
export interface NetworkConnectorDeps {
|
||||
db: Database.Database;
|
||||
queueRepo: QueueRepository;
|
||||
bindings: NetworkBindingStore;
|
||||
home: string;
|
||||
connect?: HubConnect;
|
||||
now?: () => Date;
|
||||
log?: (line: string) => void;
|
||||
passTimeoutMs?: number;
|
||||
reportLimit?: number;
|
||||
}
|
||||
|
||||
export interface NetworkPassResult {
|
||||
bindingId: string;
|
||||
taken: number;
|
||||
pickupsSent: number;
|
||||
resultsSent: number;
|
||||
unknown: number;
|
||||
/** Why the pass stopped early, if it did. `revoked` ended the binding; `no_session` left the hub's items pending;
|
||||
* `local` is a failure here, not the hub's. */
|
||||
error?: HubError["kind"] | "credential" | "no_session" | "http_not_allowed" | "local";
|
||||
}
|
||||
|
||||
/** The seat's current address: the node's latest session, as the queue's other node lookups read it. */
|
||||
function currentSession(db: Database.Database, nodeId: string): string | null {
|
||||
const row = db.prepare("SELECT session_name FROM sessions WHERE node_id = ? ORDER BY created_at DESC, id DESC LIMIT 1")
|
||||
.get(nodeId) as { session_name: string } | undefined;
|
||||
return row?.session_name ?? null;
|
||||
}
|
||||
|
||||
interface Receipt {
|
||||
local_qitem_id: string; binding_id: string; hub_qitem_id: string; queued_sent_at: string | null; pickup_sent_at: string | null;
|
||||
settled_at: string | null; outcome: string | null;
|
||||
}
|
||||
|
||||
/** The states a local item ends in. A handed-off local item ends at its successor, which is followed. */
|
||||
const FINAL = new Set(["done", "canceled", "denied", "failed"]);
|
||||
/** The states in which the hub item is closed to this connector. A hub item handed on by someone there is closed here:
|
||||
* its successor isn't followed, and it's never written to again. */
|
||||
const HUB_CLOSED = new Set([...FINAL, "handed-off"]);
|
||||
|
||||
/** One pass for one binding, bounded by its deadline and the connector's stop. Never throws: a failure is recorded on
|
||||
* the binding and returned. */
|
||||
export async function runNetworkBindingPass(deps: NetworkConnectorDeps, binding: NetworkBinding,
|
||||
opts: { signal?: AbortSignal } = {}): Promise<NetworkPassResult> {
|
||||
const now = deps.now ?? (() => new Date());
|
||||
const result: NetworkPassResult = { bindingId: binding.id, taken: 0, pickupsSent: 0, resultsSent: 0, unknown: 0 };
|
||||
// The token goes to a plain-http hub only on the person's recorded choice for this binding.
|
||||
if (binding.unencrypted && !binding.allowHttp) {
|
||||
deps.bindings.recordError(binding.id, `${binding.hubUrl} is plain http and this binding has no --allow-http choice, so its token isn't sent. Connect again with the hub's https URL, or with --allow-http.`);
|
||||
return { ...result, error: "http_not_allowed" };
|
||||
}
|
||||
let token: string;
|
||||
try {
|
||||
token = readNetworkCredential(deps.home, binding.id);
|
||||
} catch (error) {
|
||||
deps.bindings.recordError(binding.id, error instanceof NetworkBindingError ? error.message : String(error));
|
||||
return { ...result, error: "credential" };
|
||||
}
|
||||
|
||||
const timeoutMs = deps.passTimeoutMs ?? PASS_TIMEOUT_MS;
|
||||
const deadline = new AbortController();
|
||||
const timer = setTimeout(() => deadline.abort(), timeoutMs);
|
||||
timer.unref?.();
|
||||
const onStop = () => deadline.abort();
|
||||
if (opts.signal?.aborted) deadline.abort();
|
||||
opts.signal?.addEventListener("abort", onStop, { once: true });
|
||||
// Checked before every take, and again just before a local item is made: an ended binding, the pass's deadline or
|
||||
// the connector's stop starts no local work, even when a hub answer arrives late.
|
||||
const live = () => !deadline.signal.aborted && deps.bindings.get(binding.id)?.state === "active";
|
||||
|
||||
let hub: HubSession | null = null;
|
||||
try {
|
||||
hub = await (deps.connect ?? connectMcpHub)(binding.hubUrl, token, deadline.signal);
|
||||
// Work goes to the seat at its current address. With no session there's nobody to give it to, so nothing is taken.
|
||||
const destination = currentSession(deps.db, binding.nodeId);
|
||||
if (destination) {
|
||||
// What this participant already holds (claimed before a restart, or through an earlier binding) comes first.
|
||||
for (const held of await hub.listOwned("in-progress", PAGE * MAX_PAGES)) {
|
||||
if (!live()) break;
|
||||
if (held.destinationSession === binding.hubSeat && await ensureLocal(deps, binding, hub, held, destination, now, live)) result.taken++;
|
||||
}
|
||||
// New work, a page at a time: a claim moves an item out of pending, so each page is new items.
|
||||
for (let page = 0; page < MAX_PAGES && live(); page++) {
|
||||
const pending = await hub.listOwned("pending", PAGE);
|
||||
for (const item of pending) {
|
||||
if (!live()) break;
|
||||
if (item.destinationSession !== binding.hubSeat) continue;
|
||||
try {
|
||||
await hub.claim(item.qitemId);
|
||||
} catch (error) {
|
||||
// Another claimant, or the item closed meanwhile: it isn't this binding's to take.
|
||||
if (error instanceof HubError && error.kind === "refused") continue;
|
||||
throw error;
|
||||
}
|
||||
if (await ensureLocal(deps, binding, hub, item, destination, now, live)) result.taken++;
|
||||
}
|
||||
if (pending.length < PAGE) break;
|
||||
}
|
||||
}
|
||||
// Report back what happened locally, for the items this binding holds. A revoked binding never gets here.
|
||||
if (live()) await reportBack(deps, binding, hub, now, result);
|
||||
deps.bindings.recordContact(binding.id, now());
|
||||
if (!destination) {
|
||||
deps.bindings.recordError(binding.id, `${binding.localSession} has no session here, so its hub items stay pending until it has one.`);
|
||||
return { ...result, error: "no_session" };
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (deadline.signal.aborted) {
|
||||
deps.bindings.recordError(binding.id, opts.signal?.aborted
|
||||
? "The connector stopped during this pass and closed its connection to the hub."
|
||||
: `This pass took longer than ${timeoutMs >= 1000 ? `${Math.round(timeoutMs / 1000)} s` : `${timeoutMs} ms`}, so its connection to the hub was closed. The next pass tries again.`);
|
||||
return { ...result, error: "unavailable" };
|
||||
}
|
||||
// Only the hub session's own failures are the hub's (it reports them as HubError); anything else happened here.
|
||||
if (!(error instanceof HubError)) {
|
||||
deps.bindings.recordError(binding.id, `A local error stopped this pass: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return { ...result, error: "local" };
|
||||
}
|
||||
if (error.kind === "revoked") {
|
||||
try {
|
||||
deps.bindings.markRevoked(binding.id, error.message);
|
||||
} catch (cleanup) {
|
||||
// The binding is revoked either way; only its credential's removal failed, and the error says how to retry.
|
||||
if (!(cleanup instanceof NetworkBindingError) || cleanup.code !== "credential_cleanup_failed") throw cleanup;
|
||||
deps.bindings.recordError(binding.id, cleanup.message);
|
||||
}
|
||||
deps.log?.(`network ${binding.id}: revoked by the hub; no new work comes in through it`);
|
||||
} else {
|
||||
deps.bindings.recordError(binding.id, error.message);
|
||||
}
|
||||
return { ...result, error: error.kind };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
opts.signal?.removeEventListener("abort", onStop);
|
||||
await hub?.end();
|
||||
}
|
||||
}
|
||||
|
||||
/** The local item for a hub item this participant holds: created once, or found again. Returns true when it was new. */
|
||||
async function ensureLocal(deps: NetworkConnectorDeps, binding: NetworkBinding, hub: HubSession, item: HubItem, destination: string,
|
||||
now: () => Date, live: () => boolean): Promise<boolean> {
|
||||
const localId = networkLocalItemId(binding, item.qitemId);
|
||||
const taken = deps.db.prepare("SELECT binding_id FROM network_receipts WHERE local_qitem_id = ?").get(localId) as { binding_id: string } | undefined;
|
||||
if (taken) {
|
||||
// Taken already, perhaps through an earlier binding for this seat, hub and participant: this binding reports it now,
|
||||
// with what was already sent and its outcome, unknown included, kept.
|
||||
if (taken.binding_id !== binding.id) deps.db.prepare("UPDATE network_receipts SET binding_id = ? WHERE local_qitem_id = ?").run(binding.id, localId);
|
||||
return false;
|
||||
}
|
||||
const full = item.body == null ? await hub.show(item.qitemId) : item;
|
||||
if (!live()) return false;
|
||||
let outcome: string | null = null;
|
||||
try {
|
||||
await deps.queueRepo.create({
|
||||
qitemId: localId,
|
||||
sourceSession: LOCAL_SOURCE,
|
||||
destinationSession: destination,
|
||||
// Where it came from is read first: the requester and the hub, before the requester's own words.
|
||||
summary: full.summary ? `From ${item.sourceSession} via network hub: ${full.summary}` : `From ${item.sourceSession} via network hub`,
|
||||
body: [
|
||||
`From ${item.sourceSession} through the network hub ${binding.hubUrl}, as ${binding.hubSeat} (hub item ${item.qitemId}).`,
|
||||
"",
|
||||
full.body ?? "",
|
||||
"",
|
||||
"---",
|
||||
`Answer by closing this item with your result: rig queue update ${localId} --state done --closure-reason no-follow-on --resolution "<your answer>".`,
|
||||
"OpenRig returns the resolution to the hub item.",
|
||||
].join("\n"),
|
||||
tags: [`network-binding:${binding.id}`, `network-hub-item:${item.qitemId}`],
|
||||
});
|
||||
} catch (error) {
|
||||
const code = error instanceof QueueRepositoryError ? error.code : null;
|
||||
// Its id was used before and its record erased: whether it ran is unknown, and it's never run again. The hub hears
|
||||
// that from reportBack, which retries the note until it lands.
|
||||
if (code === "qitem_erased") outcome = "unknown";
|
||||
// The same id with another destination: the seat's address changed between attempts; the item already exists.
|
||||
else if (code !== "qitem_id_reuse" || !deps.queueRepo.getById(localId)) throw error;
|
||||
}
|
||||
const inserted = deps.db.prepare(`INSERT INTO network_receipts (local_qitem_id, binding_id, hub_qitem_id, created_at, outcome)
|
||||
VALUES (?, ?, ?, ?, ?) ON CONFLICT DO NOTHING`).run(localId, binding.id, item.qitemId, now().toISOString(), outcome).changes > 0;
|
||||
// Every stage note, queued included, goes out from reportBack, which marks it sent only once it lands.
|
||||
return inserted && outcome === null;
|
||||
}
|
||||
|
||||
/** The item a local item ends at, following local handoffs: a successor names the item it came from. Null when a
|
||||
* handed-off item has no one successor here (handed to another host, or the record is gone): its outcome is unknown. */
|
||||
function finalItem(repo: QueueRepository, start: QueueItem): QueueItem | null {
|
||||
let current = start;
|
||||
for (let hop = 0; hop < 20 && current.state === "handed-off"; hop++) {
|
||||
const next = repo.list({ handedOffFrom: current.qitemId, limit: 2 });
|
||||
if (next.length !== 1) return null;
|
||||
current = next[0]!;
|
||||
}
|
||||
return current.state === "handed-off" ? null : current;
|
||||
}
|
||||
|
||||
/** What the hub hears as the result: the resolution, else the closer's own note on the close itself. The close is read
|
||||
* with `closingTransition`, since later notes on a closed item repeat its state. Other notes on the item can be
|
||||
* OpenRig's or another seat's, and name local seats, so they're never sent. */
|
||||
function closingWords(repo: QueueRepository, item: QueueItem): string {
|
||||
if (item.resolution) return item.resolution;
|
||||
const close = repo.closingTransition(item.qitemId, item.state);
|
||||
return close?.transitionNote && close.actorSession === item.destinationSession ? close.transitionNote : "Closed without a stated result.";
|
||||
}
|
||||
|
||||
/** The hub items a binding's result never reached because someone else closed them first, for status. */
|
||||
export function networkClosedElsewhere(db: Database.Database, bindingId: string): Array<{ hubItem: string; localItem: string; note: string | null }> {
|
||||
return (db.prepare("SELECT hub_qitem_id, local_qitem_id, last_error FROM network_receipts WHERE binding_id = ? AND outcome = 'closed_on_hub' ORDER BY settled_at, local_qitem_id")
|
||||
.all(bindingId) as Array<{ hub_qitem_id: string; local_qitem_id: string; last_error: string | null }>)
|
||||
.map((row) => ({ hubItem: row.hub_qitem_id, localItem: row.local_qitem_id, note: row.last_error }));
|
||||
}
|
||||
|
||||
async function reportBack(deps: NetworkConnectorDeps, binding: NetworkBinding, hub: HubSession, now: () => Date, result: NetworkPassResult): Promise<void> {
|
||||
const open = deps.db.prepare(`SELECT * FROM network_receipts WHERE binding_id = ? AND settled_at IS NULL
|
||||
ORDER BY COALESCE(checked_at, ''), created_at LIMIT ?`).all(binding.id, deps.reportLimit ?? REPORT_LIMIT) as Receipt[];
|
||||
const mark = (column: "queued_sent_at" | "pickup_sent_at" | "settled_at", receipt: Receipt, outcome?: string, lastError: string | null = null) =>
|
||||
deps.db.prepare(`UPDATE network_receipts SET ${column} = ?, outcome = COALESCE(?, outcome), last_error = ? WHERE local_qitem_id = ?`)
|
||||
.run(now().toISOString(), outcome ?? null, lastError, receipt.local_qitem_id);
|
||||
for (const receipt of open) {
|
||||
deps.db.prepare("UPDATE network_receipts SET checked_at = ? WHERE local_qitem_id = ?").run(now().toISOString(), receipt.local_qitem_id);
|
||||
try {
|
||||
const local = deps.queueRepo.getById(receipt.local_qitem_id);
|
||||
const unknown = async (why: string) => {
|
||||
await hub.note(receipt.hub_qitem_id, why);
|
||||
mark("settled_at", receipt, "unknown");
|
||||
result.unknown++;
|
||||
};
|
||||
if (!local || receipt.outcome === "unknown") {
|
||||
await unknown(`This item's local record (${receipt.local_qitem_id}) is gone, so whether it ran is unknown. It won't be run again.`);
|
||||
continue;
|
||||
}
|
||||
if (!receipt.queued_sent_at) {
|
||||
await hub.note(receipt.hub_qitem_id, `Queued for the connected local seat as ${local.qitemId}. Not picked up yet.`);
|
||||
mark("queued_sent_at", receipt);
|
||||
}
|
||||
if (!receipt.pickup_sent_at && local.claimedAt) {
|
||||
await hub.note(receipt.hub_qitem_id, `Picked up by the local seat at ${local.claimedAt}.`);
|
||||
mark("pickup_sent_at", receipt);
|
||||
result.pickupsSent++;
|
||||
}
|
||||
const end = local.state === "handed-off" ? finalItem(deps.queueRepo, local) : local;
|
||||
if (!end) {
|
||||
await unknown(`This item was handed on from ${local.qitemId} without one successor here, so its outcome is unknown. It won't be run again.`);
|
||||
continue;
|
||||
}
|
||||
if (!FINAL.has(end.state)) continue;
|
||||
// The hub item closes the way the queue closes one: done with the local closure reason, or failed. A refusal, a
|
||||
// cancel or a failure never reads as success.
|
||||
const reason = end.state === "canceled" || end.closureReason === "canceled" ? "canceled"
|
||||
: end.state === "denied" || end.closureReason === "denied" ? "denied" : "no-follow-on";
|
||||
const state = end.state === "failed" ? "failed" as const : "done" as const;
|
||||
const closureReason = state === "failed" ? null : reason;
|
||||
const words = closingWords(deps.queueRepo, end);
|
||||
// A final check before the close: a hub item already closed, by our own close whose answer was lost or by someone
|
||||
// else, gets no write, so another actor's failure or cancel never becomes our done.
|
||||
let shown = await hub.show(receipt.hub_qitem_id);
|
||||
if (!HUB_CLOSED.has(shown.state)) {
|
||||
try {
|
||||
await hub.close(receipt.hub_qitem_id, { state, closureReason, resolution: words });
|
||||
} catch (error) {
|
||||
// Refused: closed by someone else between the check and the close. The readback says how.
|
||||
if (!(error instanceof HubError) || error.kind !== "refused") throw error;
|
||||
}
|
||||
shown = await hub.show(receipt.hub_qitem_id);
|
||||
if (!HUB_CLOSED.has(shown.state)) {
|
||||
deps.db.prepare("UPDATE network_receipts SET last_error = ? WHERE local_qitem_id = ?").run("The close didn't take: the hub item still reads open. Trying again.", receipt.local_qitem_id);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// It counts as our result only when the hub item shows our state, closure reason and resolution. Otherwise the
|
||||
// local result stays here, and status shows the hub item as closed elsewhere.
|
||||
if (shown.state !== state || shown.resolution !== words || (shown.closureReason ?? null) !== closureReason) {
|
||||
mark("settled_at", receipt, "closed_on_hub", `The hub item was closed elsewhere (${shown.state}${shown.closureReason ? `, ${shown.closureReason}` : ""}) before this result reached it. The result stays on ${local.qitemId}.`);
|
||||
deps.log?.(`network ${binding.id}: hub item ${receipt.hub_qitem_id} was closed elsewhere; its local result stays on ${local.qitemId}`);
|
||||
continue;
|
||||
}
|
||||
mark("settled_at", receipt, end.state);
|
||||
result.resultsSent++;
|
||||
} catch (error) {
|
||||
if (!(error instanceof HubError) || error.kind !== "refused") throw error;
|
||||
deps.db.prepare("UPDATE network_receipts SET last_error = ? WHERE local_qitem_id = ?").run(error.message.slice(0, 500), receipt.local_qitem_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface NetworkConnectorSnapshot {
|
||||
running: boolean;
|
||||
intervalMs: number;
|
||||
bindings: Array<{ id: string; nextPassAt: string | null; backoffMs: number; lastPass: NetworkPassResult | null }>;
|
||||
}
|
||||
|
||||
/** The connector the daemon runs: one pass per active binding when it's due, one round at a time, off the event loop's
|
||||
* hot path (the hub calls are async network I/O). Each pass is bounded by its deadline, and stop closes a pass in
|
||||
* flight. A binding that fails backs off, doubling to a cap, with jitter. With no active binding it makes no network
|
||||
* call at all. */
|
||||
export class NetworkConnectorService {
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
private inFlight: Promise<void> | null = null;
|
||||
private stopped = false;
|
||||
private stopping: AbortController | null = null;
|
||||
private readonly due = new Map<string, { at: number; backoffMs: number; last: NetworkPassResult | null }>();
|
||||
readonly intervalMs: number;
|
||||
private readonly maxBackoffMs: number;
|
||||
|
||||
constructor(private readonly deps: NetworkConnectorDeps, opts: { intervalMs?: number; maxBackoffMs?: number; random?: () => number } = {}) {
|
||||
this.intervalMs = opts.intervalMs ?? 30_000;
|
||||
this.maxBackoffMs = Math.max(this.intervalMs, opts.maxBackoffMs ?? 300_000);
|
||||
this.random = opts.random ?? Math.random;
|
||||
}
|
||||
private readonly random: () => number;
|
||||
|
||||
start(): void {
|
||||
this.stopped = false;
|
||||
this.stopping = new AbortController();
|
||||
// A credential whose binding isn't active has nothing left to do here: one a stop left before its row, or one
|
||||
// whose removal failed when its binding ended.
|
||||
const swept = this.deps.bindings.sweepCredentials();
|
||||
if (swept.removed.length) this.deps.log?.(`network connector: removed the credentials of ended bindings ${swept.removed.join(", ")}`);
|
||||
if (swept.failed.length) this.deps.log?.(`network connector: couldn't remove the credentials of ended bindings ${swept.failed.join(", ")}`);
|
||||
this.schedule(0);
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.stopped = true;
|
||||
this.stopping?.abort();
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
await this.inFlight;
|
||||
}
|
||||
|
||||
/** One round: a pass for each active binding that's due. Exposed for tests and status. */
|
||||
async round(): Promise<void> {
|
||||
if (this.inFlight) return this.inFlight;
|
||||
this.inFlight = (async () => {
|
||||
const at = (this.deps.now ?? (() => new Date()))().getTime();
|
||||
const active = this.deps.bindings.active();
|
||||
for (const id of [...this.due.keys()]) if (!active.some((b) => b.id === id)) this.due.delete(id);
|
||||
for (const binding of active) {
|
||||
if (this.stopped) break;
|
||||
const due = this.due.get(binding.id);
|
||||
if (due && due.at > at) continue;
|
||||
const pass = await runNetworkBindingPass(this.deps, binding, { signal: this.stopping?.signal });
|
||||
const failing = pass.error === "unavailable" || pass.error === "refused" || pass.error === "credential" || pass.error === "local";
|
||||
const backoffMs = failing ? Math.min(this.maxBackoffMs, (due?.backoffMs ?? this.intervalMs) * 2) : this.intervalMs;
|
||||
const jitter = 1 + (this.random() * 0.4 - 0.2);
|
||||
this.due.set(binding.id, { at: at + Math.round(backoffMs * jitter), backoffMs, last: pass });
|
||||
}
|
||||
})();
|
||||
try { await this.inFlight; } finally { this.inFlight = null; }
|
||||
}
|
||||
|
||||
snapshot(): NetworkConnectorSnapshot {
|
||||
return {
|
||||
running: !this.stopped,
|
||||
intervalMs: this.intervalMs,
|
||||
bindings: [...this.due.entries()].map(([id, d]) => ({ id, nextPassAt: new Date(d.at).toISOString(), backoffMs: d.backoffMs, lastPass: d.last })),
|
||||
};
|
||||
}
|
||||
|
||||
private schedule(ms: number): void {
|
||||
if (this.stopped) return;
|
||||
this.timer = setTimeout(() => {
|
||||
void this.round().catch((error) => this.deps.log?.(`network connector round failed: ${(error as Error).message}`))
|
||||
.finally(() => this.schedule(Math.min(this.intervalMs, 5_000)));
|
||||
}, ms);
|
||||
this.timer.unref?.();
|
||||
}
|
||||
}
|
||||
|
||||
export { HubError };
|
||||
@@ -1508,7 +1508,7 @@ export function hookOrPaneActivity(
|
||||
// beats hook-arrival age) and makes ACTIVITY real on the DEFAULT path for hook-less / Codex /
|
||||
// just-finished-a-turn seats: the exact fleet-blindness this fix targets. Structural discrimination
|
||||
// is STRUCTURAL (spinner shapes / esc-to-interrupt / idle prompt), never a verb allowlist.
|
||||
if (structural && structural.state !== "unknown") {
|
||||
if (structural && (structural.state !== "unknown" || structural.reason === "unrecognized_client_version")) {
|
||||
return {
|
||||
source: "pane",
|
||||
activity: {
|
||||
@@ -1617,8 +1617,9 @@ export async function attachAgentActivity(
|
||||
*
|
||||
* needs_input > running > MOTION > idle / unknown
|
||||
*
|
||||
* A current, target-bound complete idle frame is the narrow exception to motion below.
|
||||
* A generic footer or old idle capture is not that evidence; D2 remains unchanged.
|
||||
* A current, target-bound complete idle frame is the narrow idle exception to motion below.
|
||||
* An explicitly unsupported client version also retains unknown rather than inferring work from redraws.
|
||||
* A generic footer or old idle capture is not completed-idle evidence; D2 remains unchanged.
|
||||
* Live motion otherwise UPGRADES a verdict of `idle` or `unknown`; it never touches `needs_input` or an
|
||||
* already-`running` verdict, and it never MANUFACTURES `idle`. That asymmetry is the whole
|
||||
* safety argument, and it is what each half of the defect needs:
|
||||
@@ -1644,7 +1645,7 @@ export async function attachAgentActivity(
|
||||
* signal improving. Silence therefore leaves the prior verdict exactly as it was.
|
||||
*/
|
||||
const withMotion = (activity: AgentActivity, currentIdleFrame = false): AgentActivity => {
|
||||
if (!motionRunning || (currentIdleFrame && activity.state === "idle")) return activity;
|
||||
if (!motionRunning || activity.reason === "unrecognized_client_version" || (currentIdleFrame && activity.state === "idle")) return activity;
|
||||
if (activity.state !== "idle" && activity.state !== "unknown") return activity;
|
||||
return {
|
||||
state: "running",
|
||||
@@ -1682,7 +1683,8 @@ export async function attachAgentActivity(
|
||||
|
||||
// An idle hook alone still yields to motion (D2); when a current complete idle frame exists, report
|
||||
// that frame's provenance instead.
|
||||
const resolved = hookOrPaneActivity(hookActivity, structural, motionRunning && currentIdleFrame, entry.runtime);
|
||||
const resolved = hookOrPaneActivity(hookActivity, structural,
|
||||
motionRunning && (currentIdleFrame || structural?.reason === "unrecognized_client_version"), entry.runtime);
|
||||
if (resolved?.source === "hook") return { ...entry, agentActivity: withMotion(resolved.activity) };
|
||||
if (resolved?.source === "limit") return { ...entry, agentActivity: resolved.activity };
|
||||
if (resolved?.source === "pane") return { ...entry, agentActivity: withMotion(resolved.activity, currentIdleFrame) };
|
||||
|
||||
@@ -389,6 +389,22 @@ function declaredNodeId(dir: string): string | null {
|
||||
* from them); it defaults to the ordinary filesystem reader and changes nothing about the evaluation. */
|
||||
export function readProjectReadiness(missionsRoot: string, readPolicy: ProofPolicyRead = defaultPolicyRead, root?: string, io: ScopeFsDeps = proofFs, observe?: (file: string) => void) {
|
||||
const missions = fs.readdirSync(missionsRoot).filter(n => fs.statSync(path.join(missionsRoot, n)).isDirectory()).map(name => ({ name, ...readMissionReadiness(contained(missionsRoot, path.join(missionsRoot, name)), readPolicy, root, io, observe) }));
|
||||
return projectReadiness(missions);
|
||||
}
|
||||
|
||||
/** The same semantic read, yielding between missions for daemon requests and source-watch refreshes.
|
||||
* A single mission (including its evidence) is still synchronous; this is not a hard time bound or atomic snapshot. */
|
||||
export async function readProjectReadinessAsync(missionsRoot: string, readPolicy: ProofPolicyRead = defaultPolicyRead, root?: string, io: ScopeFsDeps = proofFs, observe?: (file: string) => void) {
|
||||
const names = fs.readdirSync(missionsRoot).filter(n => fs.statSync(path.join(missionsRoot, n)).isDirectory());
|
||||
const missions: Array<MissionReadiness & { name: string }> = [];
|
||||
for (const name of names) {
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
missions.push({ name, ...readMissionReadiness(contained(missionsRoot, path.join(missionsRoot, name)), readPolicy, root, io, observe) });
|
||||
}
|
||||
return projectReadiness(missions);
|
||||
}
|
||||
|
||||
function projectReadiness(missions: Array<MissionReadiness & { name: string }>) {
|
||||
const active = missions.filter(m => ["active", "release-candidate"].includes(m.historicalStatus ?? ""));
|
||||
return { revision: hash(active), state: active.length && active.every(m => m.state === "ready") ? "ready" : active.some(m => m.state === "unknown") ? "unknown" : "not-ready", missions, basis: "Active mission readiness; distinct outcome judgment and publication remain separate" };
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import * as path from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import type { EventBus } from "../event-bus.js";
|
||||
import type { ScopeFsDeps } from "../scope/scope-view-projection.js";
|
||||
import { createProofPolicyRead, proofFs, readProjectReadiness, resolveProjectRoot } from "./judgments.js";
|
||||
import { createProofPolicyRead, proofFs, readProjectReadinessAsync, resolveProjectRoot } from "./judgments.js";
|
||||
|
||||
export interface ProofSourceWatch {
|
||||
close(): void;
|
||||
@@ -76,16 +76,15 @@ function shallowWatches(missionsRoot: string, realRoot: string, top: string, pro
|
||||
* including absent nearer ones, and accepted evidence elsewhere in the project) is watched at its parent directory,
|
||||
* not recursively, so sibling checkouts and build trees are never subscribed to.
|
||||
*/
|
||||
export function watchProofSources(missionsRoot: string, invalidate: () => void, bus: EventBus, scopeChanged?: () => void): ProofSourceWatch {
|
||||
export async function watchProofSources(missionsRoot: string, invalidate: () => void, bus: EventBus, scopeChanged?: () => void): Promise<ProofSourceWatch> {
|
||||
missionsRoot = path.resolve(missionsRoot);
|
||||
let projectRoot = path.dirname(missionsRoot);
|
||||
try { projectRoot = resolveProjectRoot(missionsRoot) ?? projectRoot; } catch { /* Legacy roots keep the missions root's parent. */ }
|
||||
const top = path.dirname(path.resolve(projectRoot));
|
||||
// The authority boundary in real terms: what the readers' containment check accepts.
|
||||
const project = canonical(projectRoot) ?? path.resolve(projectRoot);
|
||||
// ponytail: bounded local workspaces use a full semantic read after a file burst.
|
||||
// Keep this workload measured; subtree indexing is warranted only when that bound is exceeded.
|
||||
const basis = () => {
|
||||
// Keep the full semantic read, but let queue/send requests run between missions.
|
||||
const basis = async () => {
|
||||
const read = new Set<string>();
|
||||
const note = (p: string) => { read.add(p); return p; };
|
||||
const io: ScopeFsDeps = {
|
||||
@@ -95,7 +94,8 @@ export function watchProofSources(missionsRoot: string, invalidate: () => void,
|
||||
listDir: (p) => proofFs.listDir(note(p)),
|
||||
isDirectory: (p) => proofFs.isDirectory(note(p)),
|
||||
};
|
||||
const hash = createHash("sha256").update(JSON.stringify(readProjectReadiness(missionsRoot, createProofPolicyRead(), undefined, io, note).missions.map(m => [m.name, m.revision]))).digest("hex");
|
||||
const result = await readProjectReadinessAsync(missionsRoot, createProofPolicyRead(), undefined, io, note);
|
||||
const hash = createHash("sha256").update(JSON.stringify(result.missions.map(m => [m.name, m.revision]))).digest("hex");
|
||||
// A file read through a link inside the project depends on its real location too: observe both.
|
||||
for (const file of [...read]) {
|
||||
const real = canonical(file);
|
||||
@@ -107,6 +107,7 @@ export function watchProofSources(missionsRoot: string, invalidate: () => void,
|
||||
let closed = false, failed = false, covered = false, readable = false;
|
||||
let revision = "unavailable", timer: NodeJS.Timeout | undefined;
|
||||
let scopeDirty = false;
|
||||
let reading = true, dirty = false;
|
||||
const notifyScope = () => { try { scopeChanged?.(); } catch { /* A later event or explicit refresh retries the view read. */ } };
|
||||
let wanted: Shallow = new Map();
|
||||
const shallow = new Map<string, fs.FSWatcher>();
|
||||
@@ -163,25 +164,32 @@ export function watchProofSources(missionsRoot: string, invalidate: () => void,
|
||||
/** Read the sources and keep the subscriptions in step with what was read, even when the result is unchanged.
|
||||
* Coverage is certified only by a read whose every dependency was already watched before it; if the dependencies
|
||||
* keep moving past a small budget, the sources are reported unobserved rather than read forever. */
|
||||
const evaluate = (): string => {
|
||||
let read = basis();
|
||||
const evaluate = async (): Promise<{ hash: string; covered: boolean }> => {
|
||||
let read = await basis();
|
||||
for (let i = 0; ; i++) {
|
||||
if (closed) return { hash: revision, covered: false };
|
||||
// A root whose real location is unknown or outside the project has no anchors there: never certified.
|
||||
if (!resubscribe(read.watches)) { covered = recursiveReal !== null && within(recursiveReal, project); return read.hash; }
|
||||
if (i === 2) { covered = false; return read.hash; }
|
||||
read = basis();
|
||||
// Coverage belongs to this candidate, just like the hash; neither escapes a discarded read.
|
||||
if (!resubscribe(read.watches)) return { hash: read.hash, covered: recursiveReal !== null && within(recursiveReal, project) };
|
||||
if (i === 2) return { hash: read.hash, covered: false };
|
||||
read = await basis();
|
||||
}
|
||||
};
|
||||
const observed = () => !closed && !failed && covered && readable;
|
||||
const schedule = () => {
|
||||
if (timer || closed) return;
|
||||
timer = setTimeout(() => {
|
||||
dirty = true;
|
||||
if (timer || reading || closed) return;
|
||||
timer = setTimeout(async () => {
|
||||
timer = undefined;
|
||||
if (closed) return;
|
||||
reading = true; dirty = false;
|
||||
try {
|
||||
if (scopeDirty) { scopeDirty = false; notifyScope(); }
|
||||
const before = observed();
|
||||
const next = evaluate();
|
||||
const candidate = await evaluate();
|
||||
if (closed || dirty) return;
|
||||
const next = candidate.hash;
|
||||
covered = candidate.covered && !failed;
|
||||
readable = true;
|
||||
// Deduplicate on what clients see: the revision and whether the sources are observed. Losing or regaining
|
||||
// coverage is news even when the proof result itself did not change.
|
||||
@@ -190,12 +198,16 @@ export function watchProofSources(missionsRoot: string, invalidate: () => void,
|
||||
invalidate();
|
||||
notify(observed() ? revision : "unavailable");
|
||||
} catch (e) {
|
||||
if (closed) return;
|
||||
// Never report the sources as watched after a failed read or a watch that could not be attached.
|
||||
readable = false;
|
||||
if (e instanceof WatchFailure) covered = false;
|
||||
revision = "unavailable";
|
||||
invalidate();
|
||||
notify("unavailable");
|
||||
} finally {
|
||||
reading = false;
|
||||
if (dirty && !closed) schedule();
|
||||
}
|
||||
}, 40);
|
||||
timer.unref();
|
||||
@@ -203,7 +215,10 @@ export function watchProofSources(missionsRoot: string, invalidate: () => void,
|
||||
try {
|
||||
recursiveReal = canonical(missionsRoot);
|
||||
recursive = watchDir(missionsRoot, true);
|
||||
try { revision = evaluate(); readable = true; } catch (e) {
|
||||
try {
|
||||
const candidate = await evaluate();
|
||||
if (!dirty) { revision = candidate.hash; covered = candidate.covered && !failed; readable = true; }
|
||||
} catch (e) {
|
||||
if (e instanceof WatchFailure) throw e;
|
||||
// Direct reads name unavailable inputs; watching may recover them.
|
||||
covered = true;
|
||||
@@ -214,6 +229,9 @@ export function watchProofSources(missionsRoot: string, invalidate: () => void,
|
||||
for (const watcher of shallow.values()) watcher.close();
|
||||
invalidate(); notify("unavailable"); notifyScope();
|
||||
return { close() {}, observation: () => ({ state: "unavailable", revision }) };
|
||||
} finally {
|
||||
reading = false;
|
||||
if (dirty && !closed) schedule();
|
||||
}
|
||||
return {
|
||||
observation: () => ({ state: observed() ? "watching" : "unavailable", revision }),
|
||||
|
||||
@@ -219,6 +219,8 @@ export type ReactiveEventInput = {
|
||||
// Required: a reactive event without a freshness bound could never pass BR-2, and a momentary
|
||||
// event must always carry when it expires. Staleness itself stays the shared predicate's job.
|
||||
staleAfter: string;
|
||||
/** #99: when the provider's own failure says the limit resets. Absent, the pool falls back to the bounded retry. */
|
||||
resetsAt?: string;
|
||||
} & ({ provider: ProviderKind; accountRef: string; seatSession?: never }
|
||||
| { provider: "claude"; seatSession: string; accountRef?: never });
|
||||
|
||||
@@ -236,6 +238,7 @@ export function reactiveEventSignal(input: ReactiveEventInput): ProviderSignal {
|
||||
// At-limit exhaustion is an actionable switch trigger; stream/stop errors are advisory.
|
||||
automationUse: input.kind === "at_limit" ? "allow_switch_decision" : "advisory_only",
|
||||
// No usedPercent — a reactive event is exhaustion evidence, not a remaining meter.
|
||||
...(input.resetsAt !== undefined ? { resetsAt: input.resetsAt } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -319,8 +322,8 @@ export function deriveUsageLimitPools(input: {
|
||||
const statedResets = pool.signals
|
||||
.map((signal) => Date.parse(signal.resetsAt ?? ""))
|
||||
.filter((value) => Number.isFinite(value) && value > nowMs);
|
||||
// A typed Claude failure has no reset timestamp. Use the existing bounded retry interval,
|
||||
// as for Codex events; a Claude meter missing its reset still cannot invent one.
|
||||
// A typed Claude failure carries a reset only when Claude's own message stated one (#99). Without it, use
|
||||
// the existing bounded retry interval, as for Codex events; a Claude meter missing its reset still cannot invent one.
|
||||
const fallbackResets = pool.signals
|
||||
.filter((signal) => pool.provider === "codex" || (signal.sourceClass === "provider_event" && signal.authority === "reactive_error"))
|
||||
.map((signal) => Date.parse(signal.asOf) + input.fallbackSeconds * 1000)
|
||||
|
||||
@@ -4,6 +4,9 @@ import type { CodexAuthMetadata } from "./codex-auth-reader.js";
|
||||
import { reactiveEventSignal, type ReactiveEventKind } from "./provider-signals.js";
|
||||
import type { ProviderSignal } from "./provider-types.js";
|
||||
|
||||
/** #99: the farthest a stated Claude reset may sit after its failure: the relay's own cap (activity-relay.cjs). */
|
||||
const CLAUDE_STATED_RESET_MAX_MS = 8 * 24 * 3600 * 1000;
|
||||
|
||||
interface ReactiveSeat {
|
||||
seatSession: string;
|
||||
runtime: string;
|
||||
@@ -98,9 +101,13 @@ export function collectReactiveEventSignals(deps: ReactiveTapDeps): ReactiveTapR
|
||||
|| isVerificationReason(event.reason) || event.stale) continue;
|
||||
const eventAtMs = Date.parse(event.eventAt ?? "");
|
||||
if (!Number.isFinite(eventAtMs) || eventAtMs > nowMs || nowMs >= eventAtMs + deps.freshnessMs) continue;
|
||||
// #99: the reset Claude's message stated, when the relay could read one, after the failure and within the relay's
|
||||
// eight-day cap. The cap is enforced here too, so no other sender can park a pool longer than the relay could.
|
||||
const resetMs = Date.parse(event.resetsAt ?? "");
|
||||
signals.push(reactiveEventSignal({
|
||||
provider: "claude", seatSession: seat.seatSession, kind: "at_limit",
|
||||
asOf: event.eventAt!, staleAfter: new Date(eventAtMs + deps.freshnessMs).toISOString(),
|
||||
...(Number.isFinite(resetMs) && resetMs > eventAtMs && resetMs <= eventAtMs + CLAUDE_STATED_RESET_MAX_MS ? { resetsAt: new Date(resetMs).toISOString() } : {}),
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/** Typed non-queue gates take precedence over custom queue IDs. */
|
||||
export const TYPED_GATE_BLOCKER_PREFIXES = ["fold:", "auth:", "external:"] as const;
|
||||
|
||||
export function typedGateBlockerPrefix(value: string): string | null {
|
||||
return TYPED_GATE_BLOCKER_PREFIXES.find((prefix) => value.startsWith(prefix)) ?? null;
|
||||
}
|
||||
|
||||
export function isTypedGateBlocker(value: unknown): boolean {
|
||||
if (typeof value !== "string") return false;
|
||||
const prefix = typedGateBlockerPrefix(value);
|
||||
return prefix != null && value.slice(prefix.length).trim().length > 0;
|
||||
}
|
||||
@@ -72,6 +72,8 @@ import type { QueueWakeFacts } from "./draft-aware-delivery.js";
|
||||
import { derivePickup, type PickupReceipt } from "./queue-pickup.js";
|
||||
import { withRecoveryIndex } from "./queue-recovery.js";
|
||||
import { lastMeaningfulTransitionMany, meaningfulFor, readWaitingView, WaitingStatements, type WaitingView, type WaitingActivityReader, type WaitingReads } from "./queue-waiting.js";
|
||||
import { isTypedGateBlocker, typedGateBlockerPrefix } from "./queue-blocker.js";
|
||||
export { TYPED_GATE_BLOCKER_PREFIXES, isTypedGateBlocker, typedGateBlockerPrefix } from "./queue-blocker.js";
|
||||
import { isOpenRigSender, OPENRIG_SENDER, wrapPaneEnvelope } from "../lib/pane-envelope.js";
|
||||
import { isBoundTerminalSeat } from "./terminal-seat.js";
|
||||
import { renderQueueHandoffNudge } from "./queue-nudge-text.js";
|
||||
@@ -84,7 +86,7 @@ import { classifyDestination, type DestinationClass } from "./gateway/destinatio
|
||||
import { join } from "node:path";
|
||||
import { SettingsStore } from "./user-settings/settings-store.js";
|
||||
import { hostedWakeFor, isHostedMemberAt, type RosterWake } from "./rosters.js";
|
||||
import { ringHostedWake } from "./hosted-wake.js";
|
||||
import { hostedRingMayHaveArrived, ringHostedWake } from "./hosted-wake.js";
|
||||
import { effectiveGateRoles } from "./gate-predicate.js";
|
||||
import {
|
||||
computeClosureRequiredAt,
|
||||
@@ -180,22 +182,8 @@ const AUTO_UNPARK_BLOCKER_TAG_PREFIX = "queue:auto-unpark:blocker-ref:";
|
||||
/** The actor on the transition routeToFallback writes; the row's own history is how a later reroute finds the line it wrote. */
|
||||
const FALLBACK_ACTOR = "system:queue-fallback";
|
||||
|
||||
/** 0.5.1-53 Atom 1a — typed non-qitem gate blocker prefixes. A park may be gated on a fold / auth /
|
||||
* external condition that is NOT a qitem and NOT a human seat; these prefixes make such a gate a
|
||||
* first-class, compact-visible, downstream-classifiable blocker (the ruling detail rides a transition). */
|
||||
export const TYPED_GATE_BLOCKER_PREFIXES = ["fold:", "auth:", "external:"] as const;
|
||||
|
||||
/** The actor a terminal act is recorded as when it names no responder: explicitly unattributed, never a person. */
|
||||
export const UNATTRIBUTED_ACTOR = "human@host";
|
||||
export function typedGateBlockerPrefix(value: string): string | null {
|
||||
return TYPED_GATE_BLOCKER_PREFIXES.find((p) => value.startsWith(p)) ?? null;
|
||||
}
|
||||
/** A well-formed typed gate blocker: a recognized prefix AND a non-empty gate body. */
|
||||
export function isTypedGateBlocker(value: unknown): boolean {
|
||||
if (typeof value !== "string") return false;
|
||||
const p = typedGateBlockerPrefix(value);
|
||||
return p != null && value.slice(p.length).trim().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* #165: a claim is the destination acting on the row, which is stronger evidence than
|
||||
@@ -1677,8 +1665,8 @@ export class QueueRepository {
|
||||
// The level can change while this send waits for the seat's lease. Under the lease, each member is admitted again with
|
||||
// its own source and urgency; only those with the first member's outcome go with this send, and the others go back
|
||||
// to their state before this claim (never marked delivered, held or failed) for their own delivery.
|
||||
// A hosted member's trigger already accepted the ring for exactly these intents (tagged below): recovery doesn't ring
|
||||
// it again, since each ring can start a run. Any other wake rings: a new intent, or one claimed but never sent.
|
||||
// A hosted member's trigger already accepted the ring for exactly these intents, or may have before it stopped
|
||||
// answering (tagged below): recovery doesn't ring it again, since each ring can start a run. Any other wake rings: a new intent, or one claimed but never sent.
|
||||
if (group.every((entry) => entry.tags?.includes(HOSTED_WAKE_ACCEPTED_TAG))) {
|
||||
for (const entry of group) this.outbox.finalizeDelivery(entry.outboxId, "indeterminate");
|
||||
return "indeterminate";
|
||||
@@ -1719,8 +1707,8 @@ export class QueueRepository {
|
||||
const wakeEvent = this.db.transaction(() => {
|
||||
this.recordNudgeAttempt(qitemId, nudgeResult);
|
||||
this.outbox!.finalizeDelivery(intent.outboxId, finalState);
|
||||
// The acceptance belongs to this intent, so recovery can tell this wake already rang.
|
||||
if (outcome.nudgeResult.startsWith("wake-accepted:")) {
|
||||
// The acceptance (or a ring that may have arrived) belongs to this intent, so recovery can tell this wake already rang.
|
||||
if (hostedRingMayHaveArrived(outcome.nudgeResult)) {
|
||||
this.db.prepare(`UPDATE outbox_entries SET tags = json_insert(COALESCE(tags, '[]'), '$[#]', ?)
|
||||
WHERE outbox_id = ? AND NOT EXISTS (SELECT 1 FROM json_each(COALESCE(outbox_entries.tags, '[]')) WHERE value = ?)`)
|
||||
.run(HOSTED_WAKE_ACCEPTED_TAG, intent.outboxId, HOSTED_WAKE_ACCEPTED_TAG);
|
||||
@@ -1794,11 +1782,12 @@ export class QueueRepository {
|
||||
/** Recover current notifications once per pass. A snapshot horizon and seek cursor
|
||||
* prevent new arrivals or persistent failures from making this pass unbounded. `stopRequested` is checked before
|
||||
* each delivery: a stopped pass leaves the notifications it has not attempted as they are, for the next start.
|
||||
* A notification already tried for a seat that still has no session or transport would only fail again, so it isn't
|
||||
* claimed, alone or in a group: it stays as it is, its row stays owed, and the pass reports it as waiting. The
|
||||
* receiver is read at each claim, so a seat that starts during the pass gets its later notifications. */
|
||||
* A notification already tried for a seat that still has no session or transport, or that is a bound terminal seat,
|
||||
* would only fail again, so it isn't claimed, alone or in a group: it stays as it is, its row stays owed, and the
|
||||
* pass reports it as waiting. The receiver is read at each claim, so a seat that starts during the pass gets its
|
||||
* later notifications. */
|
||||
async drainPendingWakeIntents(opts?: { stopRequested?: () => boolean }): Promise<{ delivered: number; indeterminate: number; failed: number; retained: number;
|
||||
/** Present only when a notification waits for a seat with no session or transport. */
|
||||
/** Present only when a notification waits for a seat with no receiver (no session or transport, or a terminal). */
|
||||
waiting?: { notifications: number; seats: string[] } }> {
|
||||
const tally: { delivered: number; indeterminate: number; failed: number; retained: number;
|
||||
waiting?: { notifications: number; seats: string[] } } = { delivered: 0, indeterminate: 0, failed: 0, retained: 0 };
|
||||
@@ -1852,16 +1841,20 @@ export class QueueRepository {
|
||||
return result();
|
||||
}
|
||||
|
||||
/** Positive evidence only that a wake can't land now: no transport can hold the address, or the newest session
|
||||
* under the name the transport sends to has stopped (exited, or detached because its tmux session is gone). A
|
||||
* canonical address resolves to its current physical session first. Anything unknown may have a receiver. */
|
||||
/** Positive evidence only that a wake can't land now: no transport can hold the address, the address is a bound
|
||||
* terminal seat (a shell or a TUI: performWakeSend types nothing there, so a retry can only fail the same way and
|
||||
* move the row's last nudge time, which the stuck sweep reads as new evidence; #2939), or the newest session under
|
||||
* the name the transport sends to has stopped (exited, or detached because its tmux session is gone). A canonical
|
||||
* address resolves to its current physical session first. Anything unknown may have a receiver. */
|
||||
private wakeHasNoReceiver(destinationSession: string): boolean {
|
||||
try {
|
||||
const destClass = this.classifyDestinationOf(destinationSession).class;
|
||||
if (destClass === "unroutable") return true;
|
||||
if (destClass !== "pane-bound") return false;
|
||||
const physical = physicalSessionName(this.db, destinationSession);
|
||||
if (isBoundTerminalSeat(this.db, destinationSession) || isBoundTerminalSeat(this.db, physical)) return true;
|
||||
const newest = this.db.prepare("SELECT status FROM sessions WHERE session_name = ? ORDER BY id DESC LIMIT 1")
|
||||
.get(physicalSessionName(this.db, destinationSession)) as { status: string } | undefined;
|
||||
.get(physical) as { status: string } | undefined;
|
||||
return newest?.status === "exited" || newest?.status === "detached";
|
||||
} catch {
|
||||
return false;
|
||||
@@ -4252,7 +4245,9 @@ export class QueueRepository {
|
||||
: answer && typeof answer === "object" && "text" in answer ? `wrote "${String(answer.text).trim()}"`
|
||||
: "answered without a choice or words";
|
||||
const person = input.actorSession;
|
||||
try { this.handoffNow({
|
||||
// A handoff that fails after persisting its events rolls them back with its savepoint: discardOnThrow takes their
|
||||
// tokens out of the caller's notify envelope too, so the kept answer below still commits.
|
||||
try { this.eventBus.discardOnThrow(() => this.handoffNow({
|
||||
qitemId: item.qitemId, fromSession: person, toSession: decider,
|
||||
summary: `Decide: ${item.summary ?? "a runner's permission request"} (${person} answered)`,
|
||||
transitionNote: `runner-permission-answer-passed: ${person}'s answer goes to ${decider}, the receiving agent, to decide while the runner waits`,
|
||||
@@ -4268,7 +4263,7 @@ export class QueueRepository {
|
||||
"Act on a clear answer at once: a clear yes or an allow click is the allow option, a clear no or a reject click the reject option. Don't ask them to confirm a clear answer. If their words are unclear or conditional, ask them back, naming this request, then decide.",
|
||||
"Until it's decided (by you, or by anyone with that command), the runner keeps waiting, with no timeout that refuses it; if its turn ends first, nothing is sent.",
|
||||
].join("\n"),
|
||||
}); } catch (error) {
|
||||
})); } catch (error) {
|
||||
// Kept, and committed with the answer's own write (a throw here would roll it back): the request stays open, and
|
||||
// the person is told it couldn't be passed on. Never a default permit.
|
||||
console.error(`[queue] a runner permission answer couldn't be handed to ${decider}; it's kept and the request stays open`, error);
|
||||
@@ -4277,7 +4272,9 @@ export class QueueRepository {
|
||||
// The person learns who decides and in what recorded state, so a delay is shown: one update per request.
|
||||
const toldId = `qitem-runner-permission-passed-${item.qitemId.replace(/^qitem-runner-permission-/, "").replace(/^qitem-/, "")}`;
|
||||
setImmediate(() => {
|
||||
// The request the person answered is what they'd judge (convention C3: a note to a person names its evidence).
|
||||
void this.create({ qitemId: toldId, sourceSession: root.sourceSession, destinationSession: person, humanIntent: "update",
|
||||
evidenceRef: `rig queue show ${root.qitemId}`,
|
||||
summary: `Your answer went to ${decider} to decide; ${root.sourceSession} is waiting`,
|
||||
body: [`Your answer to ${root.sourceSession}'s permission request ${root.qitemId} went to ${decider}, which decides it while ${root.sourceSession} waits. Nothing runs until it decides.`,
|
||||
...(chosen?.describe ? [`Receiving agent: ${chosen.describe}`] : []),
|
||||
@@ -4300,7 +4297,11 @@ export class QueueRepository {
|
||||
if (item.state === "done" || item.state === "failed") {
|
||||
this.db.prepare("UPDATE queue_items SET human_answers = ? WHERE qitem_id = ?").run(JSON.stringify(answers), item.qitemId);
|
||||
this.transitionLog.append({ qitemId: item.qitemId, state: item.state, actorSession: input.actorSession, transitionNote: input.transitionNote });
|
||||
return { persistedEvents: [] as PersistedEvent[] };
|
||||
// The row's state doesn't change, but its decision does: a runner waiting on this request reads it now, and
|
||||
// Steering refreshes.
|
||||
return { persistedEvents: [this.eventBus.persistWithinTransaction({ type: "queue.updated", qitemId: item.qitemId,
|
||||
fromState: item.state, toState: item.state, closureReason: null, closureTarget: null,
|
||||
actorSession: input.actorSession, summary: item.summary ?? null })] };
|
||||
}
|
||||
if (item.state !== "pending" && item.state !== "in-progress" && item.state !== "blocked") return null;
|
||||
this.db.prepare("UPDATE queue_items SET human_answers = ? WHERE qitem_id = ?").run(JSON.stringify(answers), item.qitemId);
|
||||
@@ -4324,6 +4325,7 @@ export class QueueRepository {
|
||||
// After the answer's own write settles: one receipt per request (its id is the request's), told to who answered.
|
||||
setImmediate(() => {
|
||||
void this.create({ qitemId: receiptId, sourceSession: requester, destinationSession: person, humanIntent: "update",
|
||||
evidenceRef: `rig queue show ${item.qitemId}`,
|
||||
summary: `Your answer to ${requester}'s permission request is kept; the tool hasn't run`,
|
||||
body: [`Your answer to ${requester}'s permission request ${item.qitemId} is kept, and the request is still waiting: ${unreachable ? `it couldn't be passed to ${unreachable}, the receiving agent, just now (answer again to retry)` : `no receiving agent on this host can read it before the tool runs (none is set, or the only one is ${requester} itself)`}. The tool hasn't run, and nothing was permitted or refused.`,
|
||||
`To decide now: rig seat decide-permission ${item.qitemId} --option <id>${options.length ? ` (options: ${options.map((o) => `${o.id}, ${o.label}`).join("; ")})` : ""}. Or set up a receiving agent that isn't ${requester}: rig config set agents.operator_session <seat>@<rig>.`,
|
||||
@@ -5839,7 +5841,7 @@ export class QueueRepository {
|
||||
* their qitem blockers' last meaningful transitions too. */
|
||||
private listingReads(rows: QueueItemRow[], includeWaiting: boolean): ListingReads {
|
||||
const ids = rows.map((r) => r.qitem_id);
|
||||
if (includeWaiting) for (const r of rows) if (r.blocked_on?.startsWith("qitem-")) ids.push(r.blocked_on);
|
||||
if (includeWaiting) for (const r of rows) if (r.blocked_on) ids.push(r.blocked_on);
|
||||
// Each owner's activity once per listing: a row and its waiting view (and every other row to that owner) read the
|
||||
// same seat state. ownerActivity reads a failure as null, so one session's failure stays that session's.
|
||||
const activities = new Map<string, ReturnType<WaitingActivityReader>>();
|
||||
|
||||
@@ -58,6 +58,12 @@ const NO_WAKE_ROUTE_RULES = [
|
||||
"the operator agent (agents.operator_session)",
|
||||
] as const;
|
||||
|
||||
/** How a failed wake's finding at a seat with no declared route names the rule that chose its recipient, in the order tried. */
|
||||
const FAILED_WAKE_ROUTE_RULES = [
|
||||
"the row's creator",
|
||||
"the operator agent (agents.operator_session)",
|
||||
] as const;
|
||||
|
||||
// S01 ladder marker vocabulary (the seam contract). S01 writes these transition-note
|
||||
// prefixes; the sweep derives "live ladder" from the latest marker. An attempt/rung is
|
||||
// LIVE, an exhausted marker hands the row back, and a later attempt starts a live cycle
|
||||
@@ -122,7 +128,12 @@ export interface StuckSweepStatusSnapshot {
|
||||
lastOutcome: "clean" | "findings" | "failed" | null;
|
||||
lastError: string | null;
|
||||
consecutiveFailures: number;
|
||||
/** Findings newly created (routed to a seat) since the daemon started. An open finding a later pass finds again
|
||||
* is not routed again, so it is not counted again (#2939). */
|
||||
findingsRouted: number;
|
||||
/** Finding rows still open after the last pass that finished (found again, suppressed or newly created): a gauge,
|
||||
* replaced every pass, never accumulated. A pass that throws keeps the previous value. */
|
||||
findingsOpen: number;
|
||||
/** Start of the pass in flight, or null between passes: a pass that never ends shows here, not only as a stale
|
||||
* lastSweepAt. */
|
||||
runningSince: string | null;
|
||||
@@ -131,7 +142,7 @@ export interface StuckSweepStatusSnapshot {
|
||||
}
|
||||
|
||||
export interface StuckSweepStatus {
|
||||
record(outcome: "clean" | "findings" | "failed", detail?: { error?: string; findings?: number }): void;
|
||||
record(outcome: "clean" | "findings" | "failed", detail?: { error?: string; created?: number; open?: number }): void;
|
||||
/** The scheduler's one-pass-at-a-time state, mirrored for /healthz. */
|
||||
pass(state: { runningSince: string | null; skippedTicks: number }): void;
|
||||
snapshot(): StuckSweepStatusSnapshot;
|
||||
@@ -146,6 +157,7 @@ export function createStuckSweepStatus(): StuckSweepStatus {
|
||||
lastError: null,
|
||||
consecutiveFailures: 0,
|
||||
findingsRouted: 0,
|
||||
findingsOpen: 0,
|
||||
runningSince: null,
|
||||
skippedTicks: 0,
|
||||
};
|
||||
@@ -155,7 +167,8 @@ export function createStuckSweepStatus(): StuckSweepStatus {
|
||||
state.lastOutcome = outcome;
|
||||
state.lastError = outcome === "failed" ? (detail?.error ?? "unknown error") : null;
|
||||
state.consecutiveFailures = outcome === "failed" ? state.consecutiveFailures + 1 : 0;
|
||||
if (detail?.findings) state.findingsRouted += detail.findings;
|
||||
if (detail?.created) state.findingsRouted += detail.created;
|
||||
if (detail?.open !== undefined) state.findingsOpen = detail.open;
|
||||
},
|
||||
pass(pass) {
|
||||
state.runningSince = pass.runningSince;
|
||||
@@ -313,6 +326,12 @@ function defaultIsRegisteredHost(log: (line: string) => void): (hostId: string)
|
||||
* Use the closing transition, including archived history; later same-state notes
|
||||
* and delivery receipts do not transfer responsibility. A newer cancellation
|
||||
* without closure metadata still supersedes an older closing actor. */
|
||||
/** OpenRig's own machinery (the daemon, or a `@system` actor such as this sweep): it creates and closes rows, but it isn't
|
||||
* a seat that can act on a finding. */
|
||||
function isMachinerySession(session: string): boolean {
|
||||
return session === "daemon@kernel" || session.endsWith("@system");
|
||||
}
|
||||
|
||||
function custodyFindingRoute(db: Database.Database, queueRepo: QueueRepository, row: QueueItem): string {
|
||||
// Unknown identity cannot establish a new owner. The caller retains this row's
|
||||
// established destination if the registry needed for classification is unavailable.
|
||||
@@ -331,7 +350,7 @@ function custodyFindingRoute(db: Database.Database, queueRepo: QueueRepository,
|
||||
ORDER BY transition_id DESC LIMIT 1`,
|
||||
).get(row.qitemId, row.qitemId) as { actor_session: string } | undefined;
|
||||
for (const session of [closure?.actor_session, row.sourceSession]) {
|
||||
if (!session || session === "daemon@kernel" || session.endsWith("@system")) continue;
|
||||
if (!session || isMachinerySession(session)) continue;
|
||||
if (!queueRepo.isHumanDestination(session) && queueRepo.acceptsDestination(session)) {
|
||||
return session;
|
||||
}
|
||||
@@ -640,6 +659,15 @@ function backlogRowsOf(body: string): Set<string> | null {
|
||||
}
|
||||
}
|
||||
|
||||
/** What a pass hands the status: the findings it created (the routed total grows only by these), and the finding
|
||||
* rows still open after it. Open is read from the queue, not from the pass's actions: a finding kept open without an
|
||||
* action (a live ladder suppresses its reporting, a grace holds it) is still open. */
|
||||
function findingCounts(db: Database.Database, findings: StuckSweepFindingAction[]): { created: number; open: number } {
|
||||
const open = db.prepare(`SELECT COUNT(*) AS n FROM queue_items WHERE state IN ('pending', 'in-progress', 'blocked')
|
||||
AND tags LIKE ?`).get(`%"${STUCK_SWEEP_FINDING_TAG}"%`) as { n: number };
|
||||
return { created: findings.filter((f) => f.action === "created").length, open: open.n };
|
||||
}
|
||||
|
||||
/**
|
||||
* One sweep pass. Instance-wide (no rig scope — the loop is the net for every rig the
|
||||
* daemon carries). Never throws: a sweep that cannot run reports outcome=failed loudly
|
||||
@@ -668,7 +696,10 @@ export async function runStuckSweep(deps: StuckSweepDeps): Promise<StuckSweepRes
|
||||
// Every yield and every awaited write lets other code run: indexes read before it are dropped, and `othersRan`
|
||||
// tells a list read before it that its rows need a fresh read.
|
||||
let othersRan = 0;
|
||||
const othersMayHaveRun = (): void => { othersRan++; recoveries.invalidate(); successors?.invalidate(); };
|
||||
/** Each seat's agent-seat reading (agentSeatNode, below), dropped with the indexes: another writer can declare a
|
||||
* seat's delegation parent while the pass yields or awaits a write. */
|
||||
const agentSeats = new Map<string, { logicalId: string; rig: string } | null>();
|
||||
const othersMayHaveRun = (): void => { othersRan++; recoveries.invalidate(); successors?.invalidate(); agentSeats.clear(); };
|
||||
// A successor index whose build took at least half of a stretch that yielded by elapsed time is not rebuilt for
|
||||
// the rest of the pass: the yield drops it, the next stretch would pay the build again and end the same way after a
|
||||
// few rows, so the rows would pay about one build each instead of one per STUCK_SWEEP_YIELD_EVERY_ROWS. Those rows
|
||||
@@ -1258,8 +1289,6 @@ export async function runStuckSweep(deps: StuckSweepDeps): Promise<StuckSweepRes
|
||||
c.why += `. This finding goes to ${recipient.seat}, ${NO_WAKE_ROUTE_RULES[recipient.chosen]}`;
|
||||
c.route = recipient.seat;
|
||||
};
|
||||
/** The two re-routes, in order, for one candidate. */
|
||||
const routeCandidate = (c: Candidate): void => { routeAroundMissing(c); routeAroundNoWake(c); };
|
||||
for (const c of candidates) {
|
||||
if (dueToYield()) await yieldLoop();
|
||||
routeAroundMissing(c);
|
||||
@@ -1283,10 +1312,9 @@ export async function runStuckSweep(deps: StuckSweepDeps): Promise<StuckSweepRes
|
||||
// (below). A finding per row stays for a person's address or a terminal seat, for a declared route that failed (its
|
||||
// finding names why, and a declared delegation parent with no session is one), and for a row that already has a
|
||||
// recovery.
|
||||
const agentSeats = new Map<string, { logicalId: string; rig: string } | null>();
|
||||
/** An agent seat's node: one whose runtime isn't a terminal, with no declared delegation parent, at an address that
|
||||
* isn't a person's. An address the human registry can't classify counts as a person's. null for anything else. Read
|
||||
* once per seat per pass. */
|
||||
* once per seat until other code may have run (agentSeats). */
|
||||
const agentSeatNode = (session: string): { logicalId: string; rig: string } | null => {
|
||||
if (agentSeats.has(session)) return agentSeats.get(session)!;
|
||||
const nodeId = resolveSessionNodeId(deps.db, session);
|
||||
@@ -1303,6 +1331,30 @@ export async function runStuckSweep(deps: StuckSweepDeps): Promise<StuckSweepRes
|
||||
agentSeats.set(session, agent);
|
||||
return agent;
|
||||
};
|
||||
// A wake that failed at a live agent seat with no delegation parent or escalates_to made its finding that same seat's,
|
||||
// so the finding's own wake went to the seat that had just failed to take one, onto whatever was in its input box.
|
||||
// The row's creator can act on it (resend, reroute or ask), as the waiting and parked-owner findings already route;
|
||||
// for a seat that sent itself the work, a row OpenRig's own machinery created, or a creator that is a bound terminal
|
||||
// seat (nothing there can claim it, as for the no-wake rule), the operator agent can. A declared
|
||||
// route that failed keeps its fallback, and a finding the two re-routes above moved keeps its recipient. A wake that
|
||||
// found no route never reached the seat, so a seat launched since keeps its own finding, whose wake is the one it missed.
|
||||
const routeAroundFailedWake = (c: Candidate): void => {
|
||||
if (c.kind !== "undelivered-wake" || !startsWithCI(c.row.lastNudgeResult, "failed:") || c.noWake
|
||||
|| c.route !== c.row.destinationSession
|
||||
|| routeNotes.has(c.row.qitemId) || agentSeatNode(c.route) === null) return;
|
||||
const choices = [c.row.sourceSession, (deps.resolveRecoveryAgentSeat ?? resolveRecoveryAgentSeat)()];
|
||||
const chosen = choices.findIndex((seat) => !!seat && seat !== c.route && !isMachinerySession(seat)
|
||||
&& !sessionMissing(seat) && deps.queueRepo.acceptsDestination(seat) && !isBoundTerminalSeat(deps.db, seat));
|
||||
if (chosen < 0) {
|
||||
c.why += `; ${c.route} declares no delegation parent or escalates_to, and its creator and the operator agent (agents.operator_session) are each unset, missing, this seat, OpenRig's own machinery, a terminal seat or not an address this queue takes, so this finding stays addressed to it`;
|
||||
return;
|
||||
}
|
||||
c.why += `; ${c.route} declares no delegation parent or escalates_to, so this finding goes to ${choices[chosen]}, ${FAILED_WAKE_ROUTE_RULES[chosen]}`;
|
||||
c.route = choices[chosen]!;
|
||||
};
|
||||
/** The three re-routes, in order, for one candidate. The third reads no earlier pass: a failed wake's finding is
|
||||
* routed only here, on the row as it is right before the finding is created. */
|
||||
const routeCandidate = (c: Candidate): void => { routeAroundMissing(c); routeAroundNoWake(c); routeAroundFailedWake(c); };
|
||||
const selfRoutedUnclaimed = (c: Candidate): boolean => c.kind === "unclaimed-obligation"
|
||||
&& c.route === c.row.destinationSession && !routeNotes.has(c.row.qitemId) && agentSeatNode(c.route) !== null;
|
||||
// A hosted member that polls for its work (no wake trigger) can't hear a finding, so each unclaimed row went to a
|
||||
@@ -1621,11 +1673,11 @@ export async function runStuckSweep(deps: StuckSweepDeps): Promise<StuckSweepRes
|
||||
if (failedCreates.length > 0) {
|
||||
// Not a clean or complete pass: say so, while keeping the findings that were made.
|
||||
const error = failedCreates.join("; ");
|
||||
status?.record("failed", { error, findings: findings.filter((f) => f.action !== "closed").length });
|
||||
status?.record("failed", { error, ...findingCounts(deps.db, findings) });
|
||||
return { outcome: "failed", findings, error };
|
||||
}
|
||||
const outcome = findings.length > 0 ? "findings" : "clean";
|
||||
status?.record(outcome, { findings: findings.filter((f) => f.action !== "closed").length });
|
||||
status?.record(outcome, findingCounts(deps.db, findings));
|
||||
return { outcome, findings };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { resolveEscalationOwner, hasConsumedEscalationHop } from "./escalation-owner.js";
|
||||
import { isTypedGateBlocker } from "./queue-blocker.js";
|
||||
import type Database from "better-sqlite3";
|
||||
import { resolvePickupThresholdMinutes } from "./queue-pickup.js";
|
||||
import { SettingsStore } from "./user-settings/settings-store.js";
|
||||
@@ -142,7 +143,7 @@ export function readWaitingView(db: Database.Database, id: string, readActivity?
|
||||
const row = held && held.qitem_id === id ? held : statements.statement(`SELECT qitem_id, source_session, destination_session, state, blocked_on, closure_required_at, tags
|
||||
FROM queue_items WHERE qitem_id = ?`).get(id) as WaitingRow | undefined;
|
||||
if (!row) return null;
|
||||
const blocker = row.blocked_on?.startsWith("qitem-") ? statements.statement(
|
||||
const blocker = row.blocked_on && !isTypedGateBlocker(row.blocked_on) ? statements.statement(
|
||||
"SELECT destination_session, state FROM queue_items WHERE qitem_id = ?",
|
||||
).get(row.blocked_on) as { destination_session: string; state: string } | undefined : undefined;
|
||||
let recoveryOwner: string | null = null;
|
||||
@@ -151,7 +152,7 @@ export function readWaitingView(db: Database.Database, id: string, readActivity?
|
||||
const subject = row.blocked_on ? blocker?.destination_session ?? row.blocked_on : row.destination_session;
|
||||
let observed: ReturnType<WaitingActivityReader> = null;
|
||||
try { observed = readActivity?.(subject) ?? null; } catch { /* unavailable remains unknown */ }
|
||||
const meaningful = meaningfulFor(db, row.blocked_on?.startsWith("qitem-") ? row.blocked_on : id, reads);
|
||||
const meaningful = meaningfulFor(db, blocker ? row.blocked_on! : id, reads);
|
||||
const sweepInterval = sweepSeconds();
|
||||
const view: WaitingView = {
|
||||
obligation: id, owner: row.destination_session, state: row.state, actionableSince: row.state === "pending" ? pendingSince(db, id, statements) : null,
|
||||
@@ -178,10 +179,11 @@ export function readWaitingView(db: Database.Database, id: string, readActivity?
|
||||
// and the wake-now marker is due now (#860).
|
||||
const timerDue = timer ? nextDueAt({ policy: timer.policy, specYaml: timer.spec_yaml, registeredAt: timer.registered_at,
|
||||
intervalSeconds: timer.interval_seconds, scanIntervalSeconds: timer.scan_interval_seconds, lastEvaluationAt: timer.last_evaluation_at }) : null;
|
||||
const liveBlocker = blocker && ["pending", "in-progress", "blocked"].includes(blocker.state);
|
||||
view.nextBackstop = timer ? {
|
||||
owner: row.destination_session, mechanism: `watchdog:${timer.job_id}`, intervalSeconds: timer.interval_seconds,
|
||||
dueAt: new Date(timerDue ?? Date.now()).toISOString(),
|
||||
} :{ owner: blocker?.destination_session ?? row.destination_session, mechanism: blocker ? "blocker-transition / queue-stuck-sweep" : NO_TIMED_BACKSTOP, dueAt: null, intervalSeconds: blocker ? sweepInterval : null };
|
||||
} :{ owner: liveBlocker ? blocker.destination_session : row.destination_session, mechanism: liveBlocker ? "blocker-transition / queue-stuck-sweep" : NO_TIMED_BACKSTOP, dueAt: null, intervalSeconds: liveBlocker ? sweepInterval : null };
|
||||
if (timer) {
|
||||
let notice: { at: string; deliveryStatus: string } | undefined;
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { resolveEscalationOwner, ESCALATION_HOP_TAG, hasConsumedEscalationHop } from "./escalation-owner.js";
|
||||
import { tmuxAttachCommand } from "../adapters/tmux-server.js";
|
||||
import { shellQuote } from "../adapters/shell-quote.js";
|
||||
import type { HookReviewObservation } from "./kernel-hook-review.js";
|
||||
import type { ArbitratedSeatState } from "./activity-taxonomy.js";
|
||||
import { seatRuntimeHomeBySession } from "./seat-runtime-home.js";
|
||||
@@ -1447,7 +1446,7 @@ async function advancePromptRefusals(
|
||||
const route = !deps.resolveOrchestrator
|
||||
? resolveEscalationOwner(deps.db, dest, target => deps.queueRepo.acceptsDestination(target)) : null;
|
||||
const reason = (group.hookReview
|
||||
? `Seat ${dest} is stopped at Codex's "Hooks need review" (hook_trust_gate). Reach that pane on this host with ${tmuxAttachCommand(shellQuote(dest))}. Review the hooks there through Codex's hook review; approve them there only if you choose to. OpenRig does not approve or bypass this review. No input was sent.`
|
||||
? `Seat ${dest} is stopped at Codex's "Hooks need review" (hook_trust_gate). Reach that pane on this host with ${tmuxAttachCommand(dest)}. Review the hooks there through Codex's hook review; approve them there only if you choose to. OpenRig does not approve or bypass this review. No input was sent.`
|
||||
: "interactive prompt blocks outstanding work; no input was sent")
|
||||
+ (route && route.outcome !== "none" && route.outcome !== "parent" ? `; ${route.reason}` : "");
|
||||
// Every refusal at a choice prompt: the alert follows queue.wake_human_rung and holds for other choice alerts. Any other
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type Database from "better-sqlite3";
|
||||
|
||||
// The rigs `rig up` is still building on this daemon: created, with their seats being created and launched one by one
|
||||
// and their edges still to be written. An agent that asks who's on its team in that window is told so, rather than
|
||||
// reading a partial roster as the whole team (#2871). In memory only, like the import guard beside it: after a daemon
|
||||
// restart nothing is marked, so the answer is today's, never a stuck "still being built".
|
||||
const building = new WeakMap<Database.Database, Set<string>>();
|
||||
|
||||
export function markRigBuilding(db: Database.Database, rigId: string): void {
|
||||
let rigs = building.get(db);
|
||||
if (!rigs) building.set(db, (rigs = new Set()));
|
||||
rigs.add(rigId);
|
||||
}
|
||||
|
||||
export function unmarkRigBuilding(db: Database.Database, rigId: string): void {
|
||||
building.get(db)?.delete(rigId);
|
||||
}
|
||||
|
||||
export function rigStillBeingBuilt(db: Database.Database, rigId: string): boolean {
|
||||
return building.get(db)?.has(rigId) ?? false;
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import { LegacyRigSpecCodec as RigSpecCodec } from "./rigspec-codec.js"; // TODO
|
||||
import type { LegacyRigSpec as RigSpec, LegacyRigSpecEdge as RigSpecEdge, InstantiateOutcome, InstantiateResult } from "./types.js"; // TODO: AS-T08b — migrate to pod-aware RigSpec
|
||||
import { resolveLaunchCwd } from "./cwd-resolution.js";
|
||||
import type { RecordCreated } from "./operation-correlation.js";
|
||||
import { markRigBuilding, unmarkRigBuilding } from "./rigs-being-built.js";
|
||||
|
||||
// Only these edge kinds constrain launch order
|
||||
const LAUNCH_DEPENDENCY_KINDS = new Set(["delegates_to", "spawned_by"]);
|
||||
@@ -1412,7 +1413,7 @@ export class PodRigInstantiator {
|
||||
name = typeof parsed?.name === "string" ? parsed.name : null;
|
||||
} catch { /* the import below reports the parse error */ }
|
||||
}
|
||||
if (!name) return this.instantiateOnce(rigSpecYaml, rigRoot, opts);
|
||||
if (!name) return this.instantiateBuilding(rigSpecYaml, rigRoot, opts);
|
||||
let inFlight = importsInFlight.get(this.db);
|
||||
if (!inFlight) importsInFlight.set(this.db, (inFlight = new Set()));
|
||||
if (inFlight.has(name)) {
|
||||
@@ -1420,12 +1421,27 @@ export class PodRigInstantiator {
|
||||
}
|
||||
inFlight.add(name);
|
||||
try {
|
||||
return await this.instantiateOnce(rigSpecYaml, rigRoot, opts);
|
||||
return await this.instantiateBuilding(rigSpecYaml, rigRoot, opts);
|
||||
} finally {
|
||||
inFlight.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
/** {@link instantiateOnce}, with the new rig marked as still being built from its creation until every seat and edge
|
||||
* has been written (#2871), so a seat that asks who's on its team in between is told the team isn't complete yet. */
|
||||
private async instantiateBuilding(rigSpecYaml: string, rigRoot: string, opts?: Parameters<PodRigInstantiator["instantiate"]>[2]) {
|
||||
const marked: string[] = [];
|
||||
const recordCreated: RecordCreated = (rigId, nodeId) => {
|
||||
if (nodeId === undefined) { markRigBuilding(this.db, rigId); marked.push(rigId); }
|
||||
opts?.recordCreated?.(rigId, nodeId);
|
||||
};
|
||||
try {
|
||||
return await this.instantiateOnce(rigSpecYaml, rigRoot, { ...opts, recordCreated });
|
||||
} finally {
|
||||
for (const rigId of marked) unmarkRigBuilding(this.db, rigId);
|
||||
}
|
||||
}
|
||||
|
||||
private async instantiateOnce(rigSpecYaml: string, rigRoot: string, opts?: { nonInterruptive?: boolean; cwdOverride?: string; env?: SeatEnv; installRoot?: string; force?: boolean; recordCreated?: RecordCreated; prelaunchHook?: (rigId: string, replacedRigIds: readonly string[]) => Promise<{ ok: true; rollback?: () => Promise<void> } | { ok: false; code: string; message: string; retainRig?: boolean }> }): Promise<InstantiateOutcome> {
|
||||
// 1. Parse + validate
|
||||
let rigSpec: PodRigSpec;
|
||||
|
||||
@@ -331,8 +331,15 @@ export class RunnerPermissions {
|
||||
const changed = new Promise<void>((resolve) => { release = resolve; });
|
||||
let liveId = askId; // after a route or handoff, the colleague's row is where the answer lands
|
||||
const unsubscribe = this.deps.eventBus.subscribe((event) => {
|
||||
const changedId = (event as { type?: string; qitemId?: string }).type === "queue.updated" ? (event as { qitemId?: string }).qitemId : undefined;
|
||||
if (changedId === askId || changedId === liveId) release();
|
||||
const { type, qitemId } = event as { type?: string; qitemId?: string };
|
||||
// A handoff while this read waits (the person's answer going to the receiving agent) moves the request to a new
|
||||
// row: follow it, so the decision recorded there wakes this read rather than its next one.
|
||||
if (type === "queue.handed_off" && (qitemId === askId || qitemId === liveId)) {
|
||||
const asked = this.deps.queueRepo.getById(askId);
|
||||
liveId = (asked ? this.liveEnd(asked)?.qitemId : undefined) ?? liveId;
|
||||
return;
|
||||
}
|
||||
if (type === "queue.updated" && (qitemId === askId || qitemId === liveId)) release();
|
||||
});
|
||||
try {
|
||||
const now = this.withWords(askId, this.read(seat, askId));
|
||||
@@ -366,7 +373,7 @@ export class RunnerPermissions {
|
||||
const words = typed && typeof typed === "object" && typed.kind === "typed-reply" ? String(typed.text ?? "").trim() || null : null;
|
||||
const recorded = this.deps.queueRepo.recordPermissionDecision({ qitemId: live.qitemId, actorSession: decider, questionId: QUESTION_ID, optionId,
|
||||
transitionNote: `runner-permission-decided by ${decider}, an agent, not the person: ${optionId}${reason ? `, because ${clip(reason, MAX_AGENT_TEXT)}` : ""}${words ? `; the person wrote "${words}"` : typeof typed === "string" ? `; the person chose ${typed}` : ""}` });
|
||||
if (!recorded) return { ok: false, code: "not_recorded", message: `The decision couldn't be recorded on ${live.qitemId} (its row is ${live.state}). Nothing was decided; the request is still waiting.` };
|
||||
if (!recorded) return { ok: false, code: "not_recorded", message: `The decision couldn't be recorded on ${live.qitemId} (its row is ${live.state}). Nothing was decided. Read the request again.` };
|
||||
return this.read(null, askId);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,9 +15,10 @@ import type {
|
||||
import { COMMUNITY_HARNESS_RUNG_INVENTORY, COMMUNITY_RUNNER_RUNG_INVENTORY, EVIDENCE_RUNG_RANK, runtimeRungInventory } from "./activity-taxonomy.js";
|
||||
import { recordedRunner, sessionCommunityHarnessSelect, sessionCommunityRunnerSelect } from "./session-registry.js";
|
||||
import { hookPrecedes } from "./agent-activity-store.js";
|
||||
import type { SeatStructuralActivityService } from "./seat-structural-activity-service.js";
|
||||
import type { SeatStructuralActivityService, StructuralObservation } from "./seat-structural-activity-service.js";
|
||||
import { DEFAULT_STRUCTURAL_STALE_MS } from "./seat-structural-activity-service.js";
|
||||
import { SettingsStore } from "./user-settings/settings-store.js";
|
||||
import { SeatIdentityStore } from "./seat-identity-store.js";
|
||||
|
||||
/** Default polling cadence: 1Hz. The default silence window is 3s, so
|
||||
* 1Hz polling gives at-most ~1s freshness lag on the cached observation (a slower cadence from
|
||||
@@ -222,14 +223,15 @@ export class SeatActivityService {
|
||||
const seat = this.ladder.get(seatNodeId);
|
||||
let codexFrameWorking = false;
|
||||
let codexFrameProofAt: string | undefined;
|
||||
const frame = seat?.inventory?.runtime === "codex" && this.structuralActivity
|
||||
? this.structuralActivity.getStructuralActivity(paneId) : null;
|
||||
if (seat?.inventory?.runtime === "codex" && this.structuralActivity) {
|
||||
const frame = this.structuralActivity.getStructuralActivity(paneId);
|
||||
const capturedAt = frame ? Date.parse(frame.observedAt) : NaN;
|
||||
const frameStarted = frame?.codexWorkingFrame ? Date.parse(frame.codexWorkingFrame.captureStartedAt) : NaN;
|
||||
const proofAt = frame?.codexWorkingFrame ? Date.parse(frame.codexWorkingFrame.proofAt) : NaN;
|
||||
const previousStarted = frame?.codexWorkingFrame?.previousCaptureStartedAt;
|
||||
codexFrameWorking = frame !== null && frame?.state !== "attention" && Number.isFinite(capturedAt)
|
||||
&& Number.isFinite(frameStarted) && frameStarted >= seat.inventorySinceMs
|
||||
&& Number.isFinite(frameStarted) && this.codexFrameQualifies(seat, frame!.codexWorkingFrame!.captureStartedAt, frame!.pane, "if-known")
|
||||
&& (previousStarted === undefined || Date.parse(previousStarted) >= seat.inventorySinceMs)
|
||||
&& Number.isFinite(proofAt) && proofAt <= capturedAt && proofAt <= observedAt.getTime()
|
||||
&& observedAt.getTime() - proofAt < DEFAULT_STRUCTURAL_STALE_MS && frameStarted <= proofAt;
|
||||
@@ -245,6 +247,23 @@ export class SeatActivityService {
|
||||
observedAt: paneOverridesQuietWindow ? codexFrameProofAt! : record.lastObservedAt,
|
||||
activity: isActiveWithinWindow || codexFrameWorking ? "working" : "idle-at-prompt",
|
||||
});
|
||||
// #2515: a Codex choice prompt can be answered on screen with no hook after it. The same structural frame, under
|
||||
// the working frame's boundary (captured after this seat's inventory began, and fresh), says whether a choice is
|
||||
// showing (1) or the composer is complete and empty (0). Any other frame (a draft, work, an unrecognised screen)
|
||||
// withdraws this source, so the prompt reads unknown again. A tracked tool approval keeps its own path.
|
||||
if (seat?.inventory?.runtime === "codex" && this.structuralActivity) {
|
||||
const prompt = frame && this.codexFrameQualifies(seat, frame.captureStartedAt, frame.pane, "required")
|
||||
? codexPromptObservation(frame, paneId) : null;
|
||||
if (prompt) {
|
||||
seat.structuralPrompt = { freshMs: Date.parse(frame!.checkedAt ?? frame!.observedAt),
|
||||
startedMs: Date.parse(frame!.captureStartedAt!), pane: frame!.pane! };
|
||||
this.reportEvidence({ seatNodeId, sessionName: paneId, rung: "needs-input-chrome", sourceId: CODEX_STRUCTURAL_PROMPT_SOURCE,
|
||||
seq, observedAt: prompt.observedAt, needsInput: prompt.needsInput });
|
||||
} else if (seat.sources.delete(CODEX_STRUCTURAL_PROMPT_SOURCE)) {
|
||||
seat.structuralPrompt = undefined;
|
||||
this.arbitrate(seat);
|
||||
}
|
||||
}
|
||||
if (this.selfReportReader && seat?.inventory?.rungs.some((r) => r.rung === "self-report")) {
|
||||
const evd = this.selfReportReader(paneId, seatNodeId);
|
||||
if (evd) this.reportEvidence(evd); // null = unreadable ⇒ the rung simply stales
|
||||
@@ -416,6 +435,7 @@ export class SeatActivityService {
|
||||
// auto-declared from their runtime's inventory (claude authoritative standing,
|
||||
// codex hooks-at-trial, generic sampling floor) — production-complete without
|
||||
// touching the launch machinery.
|
||||
const identities = new SeatIdentityStore(db);
|
||||
for (const r of tmuxRows) {
|
||||
const known = this.ladder.get(r.node_id);
|
||||
const inventory = runtimeRungInventory(r.runtime, r.community_harness, recordedRunner(r.community_runner));
|
||||
@@ -426,6 +446,15 @@ export class SeatActivityService {
|
||||
this.declareRungInventory({ seatNodeId: r.node_id, sessionName: r.session_name }, inventory);
|
||||
this.restoreDurableHook({ seatNodeId: r.node_id, sessionName: r.session_name }, inventory, r.runtime);
|
||||
}
|
||||
if (r.runtime === "codex") {
|
||||
// The occupant as OpenRig records it, from the identity reconciler's last verdict for this session: the
|
||||
// binding's pane it checked, and the pane process it verified as this session's Codex there. It follows
|
||||
// OpenRig's own in-place restart. No verdict (or a defensive read of a store without one) leaves both unknown.
|
||||
const verdict = identities.getForNode(r.node_id);
|
||||
const current = verdict?.sessionName === r.session_name ? verdict : null;
|
||||
this.recordCodexOccupant(r.node_id, { paneId: current?.evidence.registeredPane ?? null,
|
||||
pid: current?.verdict === "verified" && current.evidence.registeredPane ? current.evidence.observedPanePid ?? null : null });
|
||||
}
|
||||
}
|
||||
|
||||
// One tmux call reads every session's window activity for this tick; only with no batch at all does each seat
|
||||
@@ -495,12 +524,22 @@ export class SeatActivityService {
|
||||
this.sessionToSeat.set(binding.sessionName, binding.seatNodeId);
|
||||
seat.inventory = inventory;
|
||||
seat.inventorySinceMs = this.now().getTime();
|
||||
// #2515: a new inventory is a new occupant boundary, so the copied prompt evidence from before it goes too.
|
||||
seat.sources.delete(CODEX_STRUCTURAL_PROMPT_SOURCE);
|
||||
seat.structuralPrompt = undefined;
|
||||
seat.trust.clear();
|
||||
for (const decl of inventory.rungs) seat.trust.set(decl.rung, decl.initialTrust);
|
||||
seat.promotion.clear();
|
||||
this.arbitrate(seat);
|
||||
}
|
||||
|
||||
/** A Codex seat's occupant as OpenRig records it (#2515): its binding's pane and its verified pane process, or null
|
||||
* where unknown. The sweep refreshes it every tick; the next arbitration applies it. */
|
||||
recordCodexOccupant(seatNodeId: string, occupant: { paneId: string | null; pid: number | null }): void {
|
||||
const seat = this.ladder.get(seatNodeId);
|
||||
if (seat) seat.occupant = occupant;
|
||||
}
|
||||
|
||||
/** Adapters push rung evidence (hooks, self-report, chrome, sampling). Per-source
|
||||
* monotonic seq: stale or reordered reports are dropped — a late lower-seq event
|
||||
* never revives an idle seat (the SubagentStop class at the service layer). */
|
||||
@@ -875,8 +914,36 @@ export class SeatActivityService {
|
||||
* instantly. Chosen against our 1Hz/3s cadence: the 3s silence window already
|
||||
* absorbs sub-3s lulls; the 2-tick arbitration debounce absorbs the
|
||||
* window-boundary flap. */
|
||||
/** #2500's boundary for a Codex structural frame, shared by the working frame and the prompt evidence: captured after
|
||||
* this seat's current inventory began, and, when the capture names its pane, from the occupant's own pane and process
|
||||
* (`recordCodexOccupant`). A respawn, a reused pane id or another pane under the same name differs from them.
|
||||
* "required" refuses a frame that doesn't name its pane, or a seat whose process OpenRig hasn't verified. */
|
||||
private codexFrameQualifies(seat: SeatLadderState, startedAt: string | undefined, pane: StructuralObservation["pane"],
|
||||
identity: "required" | "if-known"): boolean {
|
||||
const started = startedAt ? Date.parse(startedAt) : NaN;
|
||||
if (!Number.isFinite(started) || started < seat.inventorySinceMs) return false;
|
||||
if (!pane) return identity !== "required";
|
||||
return this.fromCodexOccupant(seat, pane, identity);
|
||||
}
|
||||
|
||||
private fromCodexOccupant(seat: SeatLadderState, pane: { id: string; pid: number }, identity: "required" | "if-known"): boolean {
|
||||
const occupant = seat.occupant;
|
||||
if (occupant?.paneId && occupant.paneId !== pane.id) return false;
|
||||
if (occupant?.pid != null) return occupant.pid === pane.pid;
|
||||
return identity !== "required";
|
||||
}
|
||||
|
||||
private arbitrate(seat: SeatLadderState): void {
|
||||
this.checkContradiction(seat);
|
||||
// #2515: the copied Codex prompt evidence holds only while its frame would: fresh, from this inventory, and from the
|
||||
// occupant's pane and process. Read here, so a getter, a failed window read, a new inventory or a new occupant sees
|
||||
// it expire too.
|
||||
const prompt = seat.structuralPrompt;
|
||||
if (prompt && (this.now().getTime() - prompt.freshMs >= DEFAULT_STRUCTURAL_STALE_MS || prompt.startedMs < seat.inventorySinceMs
|
||||
|| !this.fromCodexOccupant(seat, prompt.pane, "required"))) {
|
||||
seat.sources.delete(CODEX_STRUCTURAL_PROMPT_SOURCE);
|
||||
seat.structuralPrompt = undefined;
|
||||
}
|
||||
const candidate = this.rawCandidate(seat);
|
||||
let nextActivity: ActivityValue = candidate?.activity ?? "unknown";
|
||||
let decidedBy: EvidenceRungId | null = candidate?.rung ?? null;
|
||||
@@ -998,6 +1065,21 @@ interface NeedsInputShape {
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
const CODEX_STRUCTURAL_PROMPT_SOURCE = "codex:structural-prompt";
|
||||
|
||||
/** What a qualified Codex structural frame says about a prompt: a choice or permission prompt on screen (1), or a
|
||||
* complete empty composer (0), observed at the capture's start. Null for anything else. */
|
||||
function codexPromptObservation(frame: StructuralObservation, sessionName: string):
|
||||
{ observedAt: string; needsInput: NeedsInputShape } | null {
|
||||
if (frame.state === "attention" && (frame.reason === "selection_prompt" || frame.reason === "permission_prompt")) {
|
||||
return { observedAt: frame.captureStartedAt!, needsInput: { count: 1, reason: frame.reason } };
|
||||
}
|
||||
if (frame.idleFrame?.runtime === "codex" && frame.idleFrame.sessionName === sessionName) {
|
||||
return { observedAt: frame.idleFrame.captureStartedAt, needsInput: { count: 0, reason: null } };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** One relay route's consecutive agreements toward promoting a trial rung. */
|
||||
interface PromotionRun {
|
||||
agreements: number;
|
||||
@@ -1015,6 +1097,10 @@ interface SeatLadderState {
|
||||
retired: boolean;
|
||||
inventory: AdapterRungInventory | null;
|
||||
inventorySinceMs: number;
|
||||
/** The Codex occupant's pane and process as OpenRig records them, refreshed by the sweep (#2515). */
|
||||
occupant?: { paneId: string | null; pid: number | null };
|
||||
/** What keeps the copied Codex prompt evidence valid where it's read: its frame's freshness, capture start and pane. */
|
||||
structuralPrompt?: { freshMs: number; startedMs: number; pane: { id: string; pid: number } };
|
||||
sources: Map<string, { latest: ActivityEvidence; latestActivity: ActivityEvidence | null }>;
|
||||
trust: Map<EvidenceRungId, RungTrust>;
|
||||
promotion: Map<EvidenceRungId, Map<EvidenceRoute | "untagged", PromotionRun>>;
|
||||
|
||||
@@ -12,6 +12,7 @@ import { SessionAddressConflict, type SessionRegistry } from "./session-registry
|
||||
import type { DiscoveryRepository } from "./discovery-repository.js";
|
||||
import type { EventBus } from "./event-bus.js";
|
||||
import type { TmuxAdapter } from "../adapters/tmux.js";
|
||||
import { shellQuote } from "../adapters/shell-quote.js";
|
||||
import type { TmuxOptionDefaultsApplier } from "./tmux-option-defaults.js";
|
||||
import { SeatStatusService, type SeatStatus, type SeatStatusResult } from "./seat-status-service.js";
|
||||
import { SeatHandoverPlanner, parseHandoverSource, SEAT_HANDOVER_SOURCE_CAPABILITIES, type SeatHandoverPlan, type SeatHandoverSource } from "./seat-handover-planner.js";
|
||||
@@ -118,7 +119,9 @@ export type SeatHandoverResult =
|
||||
| { ok: false; code: "missing_reason" | "invalid_source" | "successor_creation_not_implemented" | "source_not_supported" | "resume_token_unavailable" | "fork_source_not_found"; message: string; guidance: string }
|
||||
| { ok: false; code: "address_conflict" | "current_occupant_required" | "discovered_not_active" | "successor_tmux_absent" | "successor_already_managed" | "successor_is_current" | "runtime_mismatch"; message: string; guidance: string }
|
||||
| { ok: false; code: "discovered_not_found"; message: string; guidance: string }
|
||||
| { ok: false; code: "tmux_probe_failed" | "handover_commit_failed" | "successor_create_failed" | "context_delivery_failed"; message: string; guidance: string }
|
||||
| { ok: false; code: "tmux_probe_failed" | "handover_commit_failed" | "context_delivery_failed"; message: string; guidance: string }
|
||||
// #2985: once the successor was launched in the respawned pane, the failure says so and names the pane.
|
||||
| { ok: false; code: "successor_create_failed"; message: string; guidance: string; replacementStarted?: true; successorPane?: string }
|
||||
| Extract<SeatStatusResult, { ok: false }>;
|
||||
|
||||
type ClaudeRestorePacketObservation =
|
||||
@@ -588,6 +591,24 @@ export class SeatHandoverService {
|
||||
},
|
||||
});
|
||||
if (!launch.ok) {
|
||||
// #2985: the harness was launched into the respawned pane and only readiness failed. The previous occupant is
|
||||
// already ended and the successor may come up (or already be working) there, but the handover never commits,
|
||||
// so its generation is never registered. Retrying the handover would end it, so the guidance says what may be
|
||||
// in the pane instead of "retry". It claims only what holds for every runtime (a Codex successor's hooks are
|
||||
// not held back by generation_uncommitted, which is Claude-only).
|
||||
if (launch.step === "start_agent" && launch.replacementStarted && launch.successorLaunched) {
|
||||
const pane = launch.tmuxPane ?? "unknown";
|
||||
const session = latestSession.session_name;
|
||||
const quoted = shellQuote(session);
|
||||
return {
|
||||
ok: false,
|
||||
code: "successor_create_failed",
|
||||
message: `Handover failed at step "${launch.step}": ${launch.message}`,
|
||||
replacementStarted: true,
|
||||
...(launch.tmuxPane ? { successorPane: launch.tmuxPane } : {}),
|
||||
guidance: `The handover was not committed: the seat's registry binding is unchanged and OpenRig has not registered the successor as the seat's occupant. The previous occupant was already ended, and a successor was launched in pane "${pane}" of ${session}; it may still be starting there or already working. Look at the pane before acting: rig capture ${quoted}. Another handover, or rig seat launch ${quoted} --fresh --stop --reason "<why>", ends that successor if it goes ahead. Shell-looking output alone does not confirm that the successor has stopped; check the launch and process state before another handover.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
code: "successor_create_failed",
|
||||
@@ -833,12 +854,12 @@ export class SeatHandoverService {
|
||||
}
|
||||
// #981: a composer-launched successor respawns into the seat's own pane, whose discovery row the
|
||||
// previous handover claimed for this same node, and a rescan keeps that claim (#237). That claim is
|
||||
// this seat's own, so commit re-claims it. A row claimed by any other node, or a discovered-source
|
||||
// successor, still needs an active, unclaimed record.
|
||||
// this seat's own, so commit re-claims it. #2861: a deleted owner or a terminal owner in an archived
|
||||
// earlier rig can also leave that pane claimed. Other owners and discovered-source successors still refuse.
|
||||
const ownPaneClaim = input.reportedSource.mode !== "discovered"
|
||||
&& discovered.status === "claimed"
|
||||
&& discovered.claimedNodeId === input.node.id
|
||||
&& discovered.tmuxSession === input.latestSession.session_name;
|
||||
&& discovered.tmuxSession === input.latestSession.session_name
|
||||
&& (discovered.claimedNodeId === input.node.id || this.isStalePaneClaimOwner(discovered.claimedNodeId));
|
||||
if (discovered.status !== "active" && !ownPaneClaim) {
|
||||
return fail({
|
||||
ok: false,
|
||||
@@ -1256,6 +1277,15 @@ export class SeatHandoverService {
|
||||
return findOtherSessionOwner(this.db, tmuxSession, targetNodeId, { ignoreArchived });
|
||||
}
|
||||
|
||||
private isStalePaneClaimOwner(nodeId: string | null): boolean {
|
||||
if (nodeId === null) return true; // ON DELETE SET NULL retains the claim after its owner is removed.
|
||||
return this.db.prepare(`
|
||||
SELECT 1 FROM nodes n JOIN rigs r ON r.id = n.rig_id
|
||||
WHERE n.id = ? AND r.archived_at IS NOT NULL
|
||||
AND (SELECT status FROM sessions WHERE node_id = n.id ORDER BY id DESC LIMIT 1) IN ('superseded', 'detached', 'exited')
|
||||
`).pluck().get(nodeId) === 1;
|
||||
}
|
||||
|
||||
private commit(input: {
|
||||
seatRef: string;
|
||||
status: SeatStatus;
|
||||
@@ -1277,6 +1307,15 @@ export class SeatHandoverService {
|
||||
}): SeatHandoverResult {
|
||||
const handoverAt = this.now().toISOString();
|
||||
const tx = this.db.transaction(() => {
|
||||
// The presence probe awaited after validation. Reclaim only the same still-stale foreign claim,
|
||||
// inside the transaction that assigns it, so a resumed owner or a new claimant cannot be overwritten.
|
||||
if (input.discovered.status === "claimed" && input.discovered.claimedNodeId !== input.node.id) {
|
||||
const current = this.discoveryRepo.getDiscoveredSession(input.discovered.id);
|
||||
if (current?.status !== "claimed" || current.claimedNodeId !== input.discovered.claimedNodeId
|
||||
|| !this.isStalePaneClaimOwner(current.claimedNodeId)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const rows = this.db.prepare(
|
||||
"SELECT id FROM sessions WHERE node_id = ? AND status NOT IN ('superseded', 'detached', 'exited') ORDER BY id"
|
||||
).all(input.node.id) as Array<{ id: string }>;
|
||||
@@ -1392,7 +1431,16 @@ export class SeatHandoverService {
|
||||
|
||||
let committed: { newSessionId: string; previousSessionIdsSuperseded: string[]; event: PersistedEvent; provenanceRecordWritten: boolean };
|
||||
try {
|
||||
committed = tx();
|
||||
const outcome = tx();
|
||||
if (outcome === null) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "discovered_not_active",
|
||||
message: `Discovery record "${input.discovered.id}" no longer has a stale owner claim.`,
|
||||
guidance: "Inspect the current discovery owner and successor state before retrying.",
|
||||
};
|
||||
}
|
||||
committed = outcome;
|
||||
// S19 ruling 01530 — the SOLE narrow call: after the commit lands, the activity
|
||||
// oracle sees the swap as its own event, keyed by the durable node id, identified
|
||||
// by the successor tenure (never the retiree's). In-memory, post-commit, optional.
|
||||
|
||||
@@ -72,32 +72,30 @@ import { resolveSeatKind, seatProcessOwner } from "./seat-kinds.js";
|
||||
const SEAT_LOOKUP_GUIDANCE = "List seats with: rig ps --nodes";
|
||||
|
||||
/**
|
||||
* The supported routes for a session registered as adopted (origin=claimed). `rig unclaim` releases
|
||||
* it and leaves its process running, so ending that process in its own terminal ends only this seat.
|
||||
* `rig down` ends it with the rest of its rig, so it is the route only when the rig has one seat;
|
||||
* otherwise it is named for what it ends. Only the guidance names them; the refusal stands.
|
||||
* `seats` is the rig's node count when known. A seat handed over before 0.7.9 gets
|
||||
* earlierHandoverRefusal instead. A seat joined with `rig attach` as an external CLI has no tmux
|
||||
* session that `rig down` stops.
|
||||
* The supported route for a session registered as adopted (origin=claimed): `rig unclaim` releases
|
||||
* it and leaves its process running, so ending that process and its tmux session ends only this seat. Closing only
|
||||
* a viewing window detaches from the tmux session and leaves it running.
|
||||
* `rig down` stops only a session carrying the seat's OpenRig marker, which OpenRig sets on the sessions
|
||||
* it starts and an adopted, bound or self-attached session lacks, so it isn't a route here and the
|
||||
* guidance says why. Only the guidance names the route; the refusal stands. A seat handed over before
|
||||
* 0.7.9 gets earlierHandoverRefusal instead. A seat joined with `rig attach` as an external CLI has no
|
||||
* tmux session that `rig down` stops.
|
||||
*/
|
||||
export function claimedSessionRoutes(rigName: string, sessionName: string, seats: number | null, externalCli = false): string {
|
||||
export function claimedSessionRoutes(rigName: string, sessionName: string, externalCli = false): string {
|
||||
const session = shellQuote(sessionName);
|
||||
const release = `To release it from OpenRig and leave its process running, run rig unclaim ${session}.`;
|
||||
if (externalCli) return `This seat was joined with rig attach as an external CLI, so rig down leaves it running. ${release} End the process in its own terminal.`;
|
||||
if (seats === 1) return `To end the session, run ${rigDownRoute(rigName)}. ${release}`;
|
||||
return `To end only this session, run rig unclaim ${session} (it releases the session from OpenRig and leaves its process running), `
|
||||
+ `then end that process in its own terminal. ${rigDownScope(rigName, seats)}`;
|
||||
return `To end this session, run rig unclaim ${session} (it releases the session from OpenRig and leaves its process running), `
|
||||
+ `then end that process and the underlying tmux session, including any remaining shell, so no session keeps the seat's name. `
|
||||
+ `${VIEWER_LEAVES_IT} ${rigDownLeavesIt(rigName)}`;
|
||||
}
|
||||
|
||||
/** `rig down <rig>` as the route for a one-seat rig, and how the rig comes back. */
|
||||
function rigDownRoute(rigName: string): string {
|
||||
const rig = shellQuote(rigName);
|
||||
return `rig down ${rig} (this rig has one seat, so it stops only this session; rig up ${rig} --existing brings the rig back)`;
|
||||
}
|
||||
/** Closing a viewer detaches; the tmux session, its shell and the seat's name stay. */
|
||||
const VIEWER_LEAVES_IT = "Closing only its viewing window leaves the tmux session running.";
|
||||
|
||||
/** What `rig down <rig>` ends in a rig with more than one seat, or an unknown count. */
|
||||
function rigDownScope(rigName: string, seats: number | null): string {
|
||||
return `rig down ${shellQuote(rigName)} ends ${seats !== null && seats > 1 ? `all ${seats} seats` : "every seat"} in this rig.`;
|
||||
/** Why `rig down <rig>` isn't the route for an adopted session. */
|
||||
function rigDownLeavesIt(rigName: string): string {
|
||||
return `rig down ${shellQuote(rigName)} leaves a session OpenRig didn't start running.`;
|
||||
}
|
||||
|
||||
/** Terminal session statuses — rows the clean verb must NOT touch (they already
|
||||
@@ -1388,6 +1386,8 @@ export class SeatLifecycleService {
|
||||
ok: false,
|
||||
code: "unmanaged_session_collision",
|
||||
message: `Canonical tmux session "${canonicalSessionName}" exists but is not owned by this seat's current managed rows; refusing to overwrite it.`,
|
||||
guidance: "If you released this session with rig unclaim, end that underlying tmux session, including any remaining shell, "
|
||||
+ "then launch again. Closing only its viewing window doesn't end the session.",
|
||||
};
|
||||
}
|
||||
if (!stop) {
|
||||
@@ -1427,13 +1427,14 @@ export class SeatLifecycleService {
|
||||
const rows = this.nonTerminalSessions(nodeId);
|
||||
const claimed = rows.find(row => row.origin === "claimed");
|
||||
if (claimed) {
|
||||
const seats = this.rigRepo.getRig(seat.rigId)?.nodes.length ?? null;
|
||||
const externalCli = seatProcessOwner(resolveSeatKind(this.sessionRegistry.getBindingForNode(nodeId)?.attachmentType)) === "external";
|
||||
return {
|
||||
ok: false, code: "claimed_session",
|
||||
message: `Seat "${seatRef}" has an adopted/operator-owned occupant; fresh launch refuses even with --stop.`,
|
||||
guidance: `Release it with rig unclaim ${shellQuote(claimed.session_name)} (its process keeps running), end that process yourself, then launch fresh.`
|
||||
+ (externalCli ? "" : seats === 1 ? ` Or end it with ${rigDownRoute(seat.rigName)}.` : ` ${rigDownScope(seat.rigName, seats)}`),
|
||||
guidance: `Release it with rig unclaim ${shellQuote(claimed.session_name)} (its process keeps running), `
|
||||
+ (externalCli
|
||||
? "end that process and close its terminal, then launch fresh."
|
||||
: `end that process and the underlying tmux session, including any remaining shell, then launch fresh. ${VIEWER_LEAVES_IT} ${rigDownLeavesIt(seat.rigName)}`),
|
||||
};
|
||||
}
|
||||
const binding = this.sessionRegistry.getBindingForNode(nodeId);
|
||||
@@ -2292,7 +2293,7 @@ export class SeatLifecycleService {
|
||||
|
||||
/** The claimed-session routes for this seat's rig (see claimedSessionRoutes). */
|
||||
private claimedRoutes(seat: SeatDescriptor, sessionName: string): string {
|
||||
return claimedSessionRoutes(seat.rigName, sessionName, this.rigRepo.getRig(seat.rigId)?.nodes.length ?? null,
|
||||
return claimedSessionRoutes(seat.rigName, sessionName,
|
||||
seatProcessOwner(resolveSeatKind(this.sessionRegistry.getBindingForNode(seat.nodeId)?.attachmentType)) === "external");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type Database from "better-sqlite3";
|
||||
import type { PaneCapture, TmuxAdapter } from "../adapters/tmux.js";
|
||||
import { communityHarnessPaneActivity, classifyPaneActivity, codexWorkingElapsed, isCodexLiveWorkingFrame, readSeatBeforePaste, type PaneActivityClassification } from "./session-transport.js";
|
||||
import { communityHarnessPaneActivity, classifyPaneActivity, codexWorkingElapsed, isCodexLiveWorkingFrame, namesAnotherClientVersion, readSeatBeforePaste, type PaneActivityClassification } from "./session-transport.js";
|
||||
import { communityHarnessOf } from "./built-in-runtimes.js";
|
||||
import { detectCodexLimitBanner, type CodexLimitBanner } from "./provider/codex-limit-banner.js";
|
||||
import { IDLE_FULL_CAPTURE_MS, windowSnapshot } from "./transcript-rotation.js";
|
||||
@@ -14,6 +14,11 @@ export interface StructuralObservation {
|
||||
reason: string;
|
||||
evidence: string | null;
|
||||
observedAt: string;
|
||||
/** A lower bound taken before the capture's read, when known: a reader may use the frame only for the occupant
|
||||
* present by then. */
|
||||
captureStartedAt?: string;
|
||||
/** The pane and its process this capture read, when the batched capture reported them (#2515). */
|
||||
pane?: { id: string; pid: number };
|
||||
/** A complete idle frame read for this capture's target, not a generic footer or hook. */
|
||||
idleFrame?: { sessionName: string; runtime: "claude-code" | "codex"; captureStartedAt: string };
|
||||
/** A Codex working observation is usable only for the occupant present when capture began. */
|
||||
@@ -103,12 +108,14 @@ export class SeatStructuralActivityService {
|
||||
async pollSeat(sessionName: string, prefetched?: Map<string, PaneCapture> | null, runtime?: string | null, communityHarness?: string | null, prefetchedStartedAt?: Date): Promise<StructuralObservation | null> {
|
||||
let content: string | null;
|
||||
let observedAt: Date | null = null;
|
||||
let pane: StructuralObservation["pane"];
|
||||
// Completion can lag the bytes: use a lower bound taken before the actual read.
|
||||
const captureStartedAt = prefetched?.has(sessionName) ? prefetchedStartedAt : this.now();
|
||||
if (prefetched?.has(sessionName)) {
|
||||
const capture = prefetched.get(sessionName)!;
|
||||
content = capture.text;
|
||||
observedAt = capture.capturedAt; // the capture's own time, never the end of the sweep (#309 review)
|
||||
pane = capture.pane;
|
||||
} else {
|
||||
try {
|
||||
content = await this.tmuxAdapter.capturePaneContent(sessionName, this.captureLines);
|
||||
@@ -122,8 +129,13 @@ export class SeatStructuralActivityService {
|
||||
return null;
|
||||
}
|
||||
const c = communityHarnessPaneActivity(classifyPaneActivity(content), communityHarness, content);
|
||||
// Redraws from an unrecorded Codex layout cannot establish work or completed idle.
|
||||
// Keep positive work and attention visible; native hooks are resolved separately by the activity fold.
|
||||
const unrecognizedVersion = runtime === "codex" && c.state !== "attention" && c.state !== "agent_active" && namesAnotherClientVersion(content, "codex");
|
||||
const at = observedAt ?? this.now();
|
||||
const previous = this.latestBySession.get(sessionName);
|
||||
const latest = this.latestBySession.get(sessionName);
|
||||
// A counter or proof read from another pane or process says nothing about this one, so compare only within one.
|
||||
const previous = latest?.pane && pane && (latest.pane.id !== pane.id || latest.pane.pid !== pane.pid) ? undefined : latest;
|
||||
const seconds = runtime === "codex" && captureStartedAt && c.state !== "attention" ? codexWorkingElapsed(content) : null;
|
||||
const previousAge = previous ? at.getTime() - Date.parse(previous.observedAt) : NaN;
|
||||
const advanced = seconds !== null && previous?.codexWorkingClock && seconds > previous.codexWorkingClock.seconds
|
||||
@@ -137,10 +149,12 @@ export class SeatStructuralActivityService {
|
||||
...(advanced ? { previousCaptureStartedAt: previous!.codexWorkingClock!.captureStartedAt } : {}) }
|
||||
: priorProof;
|
||||
const obs: StructuralObservation = {
|
||||
state: c.state,
|
||||
reason: c.reason,
|
||||
state: unrecognizedVersion ? "unknown" : c.state,
|
||||
reason: unrecognizedVersion ? "unrecognized_client_version" : c.reason,
|
||||
evidence: c.evidence,
|
||||
observedAt: at.toISOString(),
|
||||
...(captureStartedAt ? { captureStartedAt: captureStartedAt.toISOString() } : {}),
|
||||
...(pane ? { pane } : {}),
|
||||
...(captureStartedAt && seconds !== null ? { codexWorkingClock: { seconds, captureStartedAt: captureStartedAt.toISOString() } } : {}),
|
||||
...(workingFrame ? { codexWorkingFrame: workingFrame } : {}),
|
||||
...(captureStartedAt && c.state === "agent_idle" && (runtime === "claude-code" || runtime === "codex")
|
||||
@@ -148,7 +162,7 @@ export class SeatStructuralActivityService {
|
||||
? { idleFrame: { sessionName, runtime, captureStartedAt: captureStartedAt.toISOString() } } : {}),
|
||||
};
|
||||
this.latestBySession.set(sessionName, obs);
|
||||
if (previous?.state !== obs.state || previous?.reason !== obs.reason) this.activityChanged?.(sessionName);
|
||||
if (latest?.state !== obs.state || latest?.reason !== obs.reason) this.activityChanged?.(sessionName);
|
||||
// #763: a Codex usage-limit banner is invisible to the hook vocabulary,
|
||||
// so the reactive tap never fires for it. Report what the seat shows, on
|
||||
// transition only (the recorder skips an already-reported banner). A poll
|
||||
@@ -156,7 +170,7 @@ export class SeatStructuralActivityService {
|
||||
// Null/failed, blank and unrecognized captures cannot establish recovery.
|
||||
if (runtime === "codex" && this.bannerEmitter) {
|
||||
const banner = detectCodexLimitBanner(content);
|
||||
if (banner || (content.trim().length > 0 && c.state !== "unknown")) {
|
||||
if (banner || (content.trim().length > 0 && obs.state !== "unknown")) {
|
||||
this.bannerEmitter(sessionName, banner, at);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,7 +280,8 @@ function findCurrentClaudeQuestion(paneContent: string): string | null {
|
||||
// Claude can leave these noninteractive warnings BELOW the input box and mode bar.
|
||||
// Recognize the complete input block, never a warning or historical prompt alone.
|
||||
const CLAUDE_STATUS_WARNINGS = [
|
||||
/^✘ Auto-update failed: no write permission to npm prefix · Run claude doctor$/,
|
||||
/^✘ Auto-update failed(?:: [^·\r\n]+)? · Run claude doctor$/,
|
||||
/^✘ Auto-update failed · Try claude doctor or (?:npm i -g @anthropic-ai\/claude-code|cd ~\/\.claude\/local && npm update @anthropic-ai\/claude-code)$/,
|
||||
/^tmux focus-events off · add 'set -g focus-events on' to ~\/\.tmux\.conf and re…$/,
|
||||
/^You've used (?:\d|[1-9]\d)% of your weekly limit · resets \d{1,2}(?::\d{2})?(?:am|pm) \(UTC\)$/,
|
||||
// #1641: Claude 2.1.293's update banner, whatever installer it names.
|
||||
@@ -1834,8 +1835,9 @@ const SEAT_IDLE_SHAPES: {
|
||||
},
|
||||
},
|
||||
codex: {
|
||||
// The hint row under the status line of an empty box (product-path-idle.J, idle).
|
||||
hints: [/^\? for shortcuts$/, /^\? for shortcuts\s+⚠ \d+ warnings? · f2 to view$/],
|
||||
// Empty-box hints: 0.160 product-path-idle.J/idle, and the captured 0.167 completed frame.
|
||||
hints: [/^\? for shortcuts$/, /^\? for shortcuts\s+⚠ \d+ warnings? · f2 to view$/,
|
||||
/^← for agents · \? for shortcuts\s+⚠ \d+ warnings? · f2 to view$/],
|
||||
// The last non-blank row above the empty box: the completion row Codex adds when a turn completes
|
||||
// (after-enter-15s.J; its grammar is CODEX_COMPLETION_ROW above), or the greeting under the session header
|
||||
// before any turn (idle, product-path-idle.J, cleared-without-enter.J). A running or streaming turn draws rows
|
||||
@@ -1908,7 +1910,7 @@ const VISIBLE_CLIENT_VERSION: Record<"claude-code" | "codex", RegExp[]> = {
|
||||
};
|
||||
|
||||
/** Whether a frame visibly names a client version other than the recorded one (see RECORDED_CLIENT_VERSION). */
|
||||
function namesAnotherClientVersion(pane: string, runtime: "claude-code" | "codex"): boolean {
|
||||
export function namesAnotherClientVersion(pane: string, runtime: "claude-code" | "codex"): boolean {
|
||||
return VISIBLE_CLIENT_VERSION[runtime].some((pattern) =>
|
||||
[...pane.matchAll(pattern)].some((match) => match[1] !== RECORDED_CLIENT_VERSION[runtime]));
|
||||
}
|
||||
|
||||
@@ -811,19 +811,22 @@ export class SliceIndexer {
|
||||
// --- internals ---
|
||||
|
||||
private readSliceLocations(): SliceLocation[] {
|
||||
return this.walkSliceLocations().filter((location) => !this.missionId || location.missionId === this.missionId);
|
||||
return this.walkSliceLocations(false, [], undefined, this.missionId || undefined);
|
||||
}
|
||||
|
||||
/** Every slice location under the roots, whatever mission this indexer is scoped to.
|
||||
* Directory names are deduplicated (first wins) unless `physical` asks for every folder. */
|
||||
/** Every slice location under the roots. A requested mission is filtered before
|
||||
* directory-name deduplication; otherwise the first matching name wins unless
|
||||
* `physical` asks for every folder. */
|
||||
private walkSliceLocations(
|
||||
physical = false,
|
||||
additionalRoots: readonly string[] = [],
|
||||
onReadError?: () => void,
|
||||
missionId?: string,
|
||||
): SliceLocation[] {
|
||||
const locations: SliceLocation[] = [];
|
||||
const seen = new Set<string>();
|
||||
const addLocation = (location: SliceLocation) => {
|
||||
if (missionId !== undefined && location.missionId !== missionId) return;
|
||||
if (!physical) {
|
||||
if (seen.has(location.name)) return;
|
||||
seen.add(location.name);
|
||||
@@ -1085,7 +1088,7 @@ export class SliceIndexer {
|
||||
const status = this.mapStatus(frontmatter["status"] as string | undefined);
|
||||
const railItem = this.extractRailItem(frontmatter, missionId);
|
||||
const qitemIds = this.matchQitems(name, served);
|
||||
const proofPacket = this.findProofPacket(name);
|
||||
const proofPacket = this.findProofPacket(name, this.missionId ? missionId : null);
|
||||
const lastActivityAt = this.computeLastActivity(slicePath, qitemIds, proofPacket);
|
||||
|
||||
return {
|
||||
@@ -1115,7 +1118,7 @@ export class SliceIndexer {
|
||||
const status = this.mapStatus(frontmatter["status"] as string | undefined);
|
||||
const railItem = this.extractRailItem(frontmatter, missionId);
|
||||
const qitemIds = this.matchQitems(name, served);
|
||||
const proofPacket = this.findProofPacket(name);
|
||||
const proofPacket = this.findProofPacket(name, this.missionId ? missionId : null);
|
||||
const lastActivityAt = this.computeLastActivity(slicePath, qitemIds, proofPacket);
|
||||
const commitRefs = this.extractCommitRefs(frontmatter);
|
||||
const files = this.listSliceFiles(slicePath);
|
||||
@@ -1502,7 +1505,34 @@ export class SliceIndexer {
|
||||
return this.membershipIndex;
|
||||
}
|
||||
|
||||
private findProofPacket(sliceName: string): SliceProofPacket | null {
|
||||
private discoverMissionIds(): Set<string> {
|
||||
const missionIds = new Set<string>();
|
||||
for (const root of this.sliceRoots()) {
|
||||
let entries: fs.Dirent[];
|
||||
try { entries = fs.readdirSync(root, { withFileTypes: true }); }
|
||||
catch { continue; }
|
||||
for (const entry of entries) {
|
||||
if ((!entry.isDirectory() && !entry.isSymbolicLink()) || entry.name.startsWith(".")) continue;
|
||||
// Project readers treat every non-dot directory or symlink entry as a mission;
|
||||
// ownership needs only its name and never follows a sibling link.
|
||||
missionIds.add(entry.name);
|
||||
}
|
||||
}
|
||||
return missionIds;
|
||||
}
|
||||
|
||||
private proofDirectoryMissionOwner(dirName: string, missionIds: ReadonlySet<string>): string | null {
|
||||
let owner: string | null = null;
|
||||
for (const candidate of missionIds) {
|
||||
if (!dirName.startsWith(candidate)) continue;
|
||||
const separator = dirName[candidate.length];
|
||||
if (separator !== "-" && separator !== "." && separator !== "_") continue;
|
||||
if (!owner || candidate.length > owner.length) owner = candidate;
|
||||
}
|
||||
return owner;
|
||||
}
|
||||
|
||||
private findProofPacket(sliceName: string, missionId: string | null): SliceProofPacket | null {
|
||||
if (!this.dogfoodEvidenceRoot) return null;
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
@@ -1511,19 +1541,26 @@ export class SliceIndexer {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Match strategy: token-based. Tokenize slice name on `-`, drop
|
||||
// trailing `vN` version tokens (real proof dirs typically don't
|
||||
// include the version suffix), then a dir matches if every remaining
|
||||
// token appears as a hyphen-bounded substring of the dir name. This
|
||||
// handles real-world dir-naming where phase indicators land at the
|
||||
// front (e.g. `pl005-phase-a-mission-control-queue-observability-...`)
|
||||
// even though the slice folder uses suffix form
|
||||
// (`mission-control-queue-observability-phase-a`). Latest mtime wins.
|
||||
// Qualified proof directories use the observed `<mission>-<slice>` form:
|
||||
// the literal mission ID must lead the name at a separator boundary. If
|
||||
// known mission IDs overlap, the longest prefix owns the directory. This
|
||||
// is a fixture-supported rule, not a historically documented contract.
|
||||
// Unqualified reads retain their legacy slice-only token match. Once a
|
||||
// qualified directory is attributed, slice tokens match against the
|
||||
// remainder after that mission prefix and separator (`vN` handling unchanged);
|
||||
// latest mtime remains the tie-break among same-mission matches.
|
||||
let knownMissionIds: Set<string> | null = null;
|
||||
if (missionId) {
|
||||
knownMissionIds = this.discoverMissionIds();
|
||||
knownMissionIds.add(missionId);
|
||||
}
|
||||
const sliceTokens = sliceName.split("-").filter((t) => t.length > 0 && !/^v\d+$/.test(t));
|
||||
const matches: { dirent: fs.Dirent; mtime: number }[] = [];
|
||||
for (const dirent of entries) {
|
||||
if (!dirent.isDirectory()) continue;
|
||||
const dirTokenSet = new Set(dirent.name.split(/[-._]/).filter((t) => t.length > 0));
|
||||
if (knownMissionIds && this.proofDirectoryMissionOwner(dirent.name, knownMissionIds) !== missionId) continue;
|
||||
const tokenSource = knownMissionIds && missionId ? dirent.name.slice(missionId.length + 1) : dirent.name;
|
||||
const dirTokenSet = new Set(tokenSource.split(/[-._]/).filter((t) => t.length > 0));
|
||||
const allTokensPresent = sliceTokens.every((t) => dirTokenSet.has(t));
|
||||
if (!allTokensPresent) continue;
|
||||
try {
|
||||
|
||||
@@ -24,7 +24,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import type Database from "better-sqlite3";
|
||||
import type { WorkflowSpec, WorkflowExitKind, WorkflowAgentHarness, WorkflowGateSpec } from "./workflow-types.js";
|
||||
import { RETAINED_STATUS, type WorkflowSpecCache } from "./workflow-spec-cache.js";
|
||||
import { getFailedSpecRead, RETAINED_STATUS, type WorkflowSpecCache } from "./workflow-spec-cache.js";
|
||||
import type { EventBus } from "./event-bus.js";
|
||||
|
||||
export interface SpecLibraryWorkflowEntry {
|
||||
@@ -377,12 +377,13 @@ function isUnderDir(childPath: string, parentDir: string): boolean {
|
||||
// disappear since the last scan get removed via removeBySourcePath
|
||||
// (OQ-4 decision; deletion + audit log).
|
||||
//
|
||||
// OQ-3 timestamp check: a file is skipped when both its mtime/ctime are <=
|
||||
// the cache row's cached_at. A file can be restored or copied with an older
|
||||
// mtime, so ctime keeps the stat-only fast path honest. Files in the same
|
||||
// timestamp second still use the content hash, and other newer files are
|
||||
// re-parsed via the cache (which itself hashes the content and returns the
|
||||
// prior row when the hash matches).
|
||||
// OQ-3 timestamp check: a file is skipped when its mtime/ctime precede the
|
||||
// persisted cache observation. This stat-only hit assumes filesystem timestamps
|
||||
// and the daemon clock are comparable and that edits advance mtime or ctime. A
|
||||
// restored/copied file can have an older mtime, so ctime is included. Files in
|
||||
// the same timestamp second still use the content hash; other newer files are
|
||||
// re-parsed via the cache (which itself hashes the content and returns the prior
|
||||
// row when the hash matches).
|
||||
|
||||
export interface ScanWorkflowSpecFolderOpts {
|
||||
/** SQLite handle (used for direct lookups). */
|
||||
@@ -403,6 +404,8 @@ export interface ScanWorkflowSpecFolderOpts {
|
||||
eventBus?: EventBus;
|
||||
/** A user file with the same bytes and identity as an existing built-in row. */
|
||||
onBuiltinAlias?: (name: string, version: string, sourcePath: string, sourceHash: string) => void;
|
||||
/** A valid source whose hash was just read or whose persisted stat observation is safely aged. */
|
||||
onVerifiedSource?: (sourcePath: string, sourceHash: string) => void;
|
||||
}
|
||||
|
||||
export interface ScanWorkflowSpecFolderResult {
|
||||
@@ -468,14 +471,15 @@ export function scanWorkflowSpecFolder(
|
||||
// A path can hold several cached versions plus a diagnostic row (#503); the most
|
||||
// recently written row is the one that reflects the file's last scan.
|
||||
const cachedAt = opts.db
|
||||
.prepare(`SELECT cached_at, source_hash FROM workflow_specs WHERE source_path = ? AND status != '${RETAINED_STATUS}' ORDER BY cached_at DESC, rowid DESC LIMIT 1`)
|
||||
.get(filePath) as { cached_at: string; source_hash: string } | undefined;
|
||||
.prepare(`SELECT cached_at, source_hash, status FROM workflow_specs WHERE source_path = ? AND status != '${RETAINED_STATUS}' ORDER BY cached_at DESC, rowid DESC LIMIT 1`)
|
||||
.get(filePath) as { cached_at: string; source_hash: string; status: string } | undefined;
|
||||
if (cachedAt) {
|
||||
const cachedAtMs = Date.parse(cachedAt.cached_at);
|
||||
const cachedAtSec = Math.floor(cachedAtMs / 1000);
|
||||
const changeSec = Math.floor(changeMs / 1000);
|
||||
if (Number.isFinite(cachedAtMs) && Number.isFinite(changeMs)) {
|
||||
if (cachedAtSec > changeSec) {
|
||||
if (cachedAt.status === "valid") opts.onVerifiedSource?.(filePath, cachedAt.source_hash);
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -489,6 +493,7 @@ export function scanWorkflowSpecFolder(
|
||||
unchanged = createHash("sha256").update(readFileSync(filePath, "utf-8")).digest("hex") === cachedAt.source_hash;
|
||||
} catch { /* The existing readThrough path records the diagnostic. */ }
|
||||
if (unchanged) {
|
||||
if (cachedAt.status === "valid") opts.onVerifiedSource?.(filePath, cachedAt.source_hash);
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -505,25 +510,29 @@ export function scanWorkflowSpecFolder(
|
||||
&& isUnderDir(cached.sourcePath, opts.builtinDir)) {
|
||||
opts.onBuiltinAlias(cached.name, cached.version, filePath, cached.sourceHash);
|
||||
}
|
||||
if (cached.sourcePath === filePath) opts.onVerifiedSource?.(filePath, cached.sourceHash);
|
||||
result.valid += 1;
|
||||
} catch (err) {
|
||||
// Diagnostic: hash the raw content best-effort so we can detect
|
||||
// edits that fix the error (mtime alone is fine; this is for
|
||||
// bookkeeping). Use the empty string when content is unreadable.
|
||||
// Prefer the digest and timestamp captured by the failed read itself. A second path read here can observe a
|
||||
// repair that raced parse handling and falsely describe those replacement bytes as the bytes that failed.
|
||||
// Errors without captured text (missing/unreadable files) retain the previous best-effort path read.
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
let sourceHash = "";
|
||||
try {
|
||||
sourceHash = createHash("sha256").update(readFileSync(filePath, "utf-8")).digest("hex");
|
||||
} catch {
|
||||
// unreadable / disappeared between stat and read; treat as
|
||||
// empty hash so the next scan re-evaluates
|
||||
sourceHash = "";
|
||||
const failedRead = getFailedSpecRead(err);
|
||||
let sourceHash = failedRead?.sourceHash ?? "";
|
||||
if (!failedRead) {
|
||||
try {
|
||||
sourceHash = createHash("sha256").update(readFileSync(filePath, "utf-8")).digest("hex");
|
||||
} catch {
|
||||
// unreadable / disappeared between stat and read; treat as empty so the next scan re-evaluates
|
||||
sourceHash = "";
|
||||
}
|
||||
}
|
||||
try {
|
||||
opts.cache.writeDiagnostic({
|
||||
sourcePath: filePath,
|
||||
sourceHash,
|
||||
errorMessage: message,
|
||||
...(failedRead ? { cachedAt: failedRead.cachedAt } : {}),
|
||||
});
|
||||
} catch (writeErr) {
|
||||
// One file's diagnostic must not end the scan of the rest of the folder (#511).
|
||||
|
||||
@@ -116,6 +116,11 @@ export type SuccessorLaunchResult =
|
||||
message: string;
|
||||
/** True once the predecessor process has been replaced in its pane. */
|
||||
replacementStarted: boolean;
|
||||
/** The pane the successor was being started in, once it is resolved (start_agent failures). */
|
||||
tmuxPane?: string;
|
||||
/** True when the harness launch was sent into the pane and only readiness failed (#2985): the successor may
|
||||
* still come up there, and it is not adopted. */
|
||||
successorLaunched?: boolean;
|
||||
};
|
||||
|
||||
export class SuccessorSessionLauncher {
|
||||
@@ -293,11 +298,14 @@ export class SuccessorSessionLauncher {
|
||||
// 3. Route the successor through REAL runtime startup (launchHarness send-keys + readiness) so it
|
||||
// becomes a LIVE, READY agent in the reused pane BEFORE it can be committed. UNWIND INVARIANT:
|
||||
// on ANY launch/readiness failure we do NOT killSession the preserved seat — that would destroy
|
||||
// the retiree's recoverable state. We return the structured failure and leave the re-wakeable
|
||||
// shell in the pane; commit never runs, so the binding is not repointed.
|
||||
// the retiree's recoverable state. We return the structured failure; commit never runs, so the binding is
|
||||
// not repointed. When the harness launch was already sent and only readiness failed, the pane is NOT left as
|
||||
// a bare shell: the successor may still come up there, unadopted (#2985). The failure says so, so the caller
|
||||
// can tell the operator that a retry would end it.
|
||||
const started = await this.startAgent(input.node, departingSession, pane.id, cwd, input.forkSource, input.occupantGeneration);
|
||||
if (!started.ok) {
|
||||
return { ok: false, code: started.code, step: "start_agent", message: started.message, replacementStarted: true };
|
||||
return { ok: false, code: started.code, step: "start_agent", message: started.message, replacementStarted: true,
|
||||
tmuxPane: pane.id, ...(started.launched ? { successorLaunched: true } : {}) };
|
||||
}
|
||||
|
||||
const discovered = this.discoveryRepo.upsertDiscoveredSession({
|
||||
@@ -386,7 +394,7 @@ export class SuccessorSessionLauncher {
|
||||
forkSource?: ForkSource,
|
||||
launchGeneration?: string | null,
|
||||
staged?: StagedLaunch,
|
||||
): Promise<{ ok: true; resumeToken?: string; resumeType?: string; appliedLaunch?: AppliedLaunchObservation; notes?: string[] } | { ok: false; code: string; message: string }> {
|
||||
): Promise<{ ok: true; resumeToken?: string; resumeType?: string; appliedLaunch?: AppliedLaunchObservation; notes?: string[] } | { ok: false; code: string; message: string; launched?: true }> {
|
||||
const adapter = node.runtime ? this.runtimeAdapters[node.runtime] : undefined;
|
||||
if (!adapter) {
|
||||
return {
|
||||
@@ -472,9 +480,9 @@ export class SuccessorSessionLauncher {
|
||||
// createSession already succeeded (adapter/socket/permission errors rethrow).
|
||||
// A thrown exception must NOT reject createSuccessor before its kill/unwind
|
||||
// runs (that would LEAK the unmanaged successor + surface an unstructured 500).
|
||||
// Catch it here and return a STRUCTURED ok:false so the caller kills the
|
||||
// just-created session (killBestEffort) and fails loudly at step=start_agent —
|
||||
// exactly like a returned readiness failure.
|
||||
// Catch it here and return a STRUCTURED ok:false so the caller fails loudly at
|
||||
// step=start_agent, exactly like a returned readiness failure. Nothing kills the
|
||||
// pane (unwind invariant above): the launched successor may still be running there.
|
||||
let readiness: ReadinessResult;
|
||||
try {
|
||||
readiness = await this.waitForReady(adapter, binding);
|
||||
@@ -483,6 +491,7 @@ export class SuccessorSessionLauncher {
|
||||
ok: false,
|
||||
code: "successor_readiness_failed",
|
||||
message: `Successor readiness probe threw: ${err instanceof Error ? err.message : String(err)}`,
|
||||
launched: true,
|
||||
};
|
||||
}
|
||||
if (!readiness.ready) {
|
||||
@@ -490,6 +499,7 @@ export class SuccessorSessionLauncher {
|
||||
ok: false,
|
||||
code: isAttentionRequiredReadinessCode(readiness.code) ? "successor_attention_required" : "successor_not_ready",
|
||||
message: `Successor did not become a ready agent: ${readiness.reason ?? "readiness timeout"}`,
|
||||
launched: true,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
// A view opens as:
|
||||
// 1. journal (each viewer with its op nonce, before anything starts);
|
||||
// 2. viewers started by herdr-core, outside every tab (`hc run --bg --no-parent`, openrig.* labels), each the
|
||||
// view's own tile for its seat (an interactive one mirrors the seat and attaches only while someone types in
|
||||
// it; a read-only one only shows it), each waiting after its tile ends instead of exiting (an exiting viewer
|
||||
// view's own tile for its seat (an interactive local one shows the seat read-only until its gate opens, then
|
||||
// attaches with plain tmux; a read-only one only shows it), each waiting after its tile ends instead of exiting (an exiting viewer
|
||||
// makes herdr collapse the layout), and never `--keep` (that would leave a shell behind in the cell);
|
||||
// 3. ONE atomic write of layouts/rig.json: the person's layout with OpenRig's stack beside their tabs, each view an
|
||||
// equal, padded grid with a title row per cell;
|
||||
|
||||
@@ -651,6 +651,9 @@ export interface AgentActivity {
|
||||
sourceSequence?: number | null;
|
||||
/** How often the emitter promises to report while idle; an idle report older than two of these no longer holds. */
|
||||
livenessSeconds?: number | null;
|
||||
/** #99: when a Claude usage limit (StopFailure/rate_limit) says it resets, read by the relay from Claude's own
|
||||
* message. Only the instant is kept, never the message. */
|
||||
resetsAt?: string | null;
|
||||
rawEvent?: string | null;
|
||||
rawSubtype?: string | null;
|
||||
runtime?: string | null;
|
||||
|
||||
@@ -8,11 +8,12 @@ export interface WakeRecovery {
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
/** Notifications the pass didn't replay because their seats have no session or transport, named once. */
|
||||
/** Notifications the pass didn't replay because their seats have no receiver (no session or transport, or a bound
|
||||
* terminal seat, which no wake can reach), named once. */
|
||||
function waitingNote(waiting?: { notifications: number; seats: string[] }): string {
|
||||
if (!waiting) return "";
|
||||
const named = waiting.seats.slice(0, 10).join(", ") + (waiting.seats.length > 10 ? `, and ${waiting.seats.length - 10} more` : "");
|
||||
return ` waiting=${waiting.notifications} (not replayed: no session or transport for ${named})`;
|
||||
return ` waiting=${waiting.notifications} (not replayed: no receiver (no session or transport, or a terminal seat) for ${named})`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { RigRepository } from "./rig-repository.js";
|
||||
import type { SessionRegistry } from "./session-registry.js";
|
||||
import type { TranscriptStore } from "./transcript-store.js";
|
||||
import { resolveWorkspaceContext, type WhoamiWorkspaceBlock } from "./workspace/workspace-resolver.js";
|
||||
import { rigStillBeingBuilt } from "./rigs-being-built.js";
|
||||
|
||||
export interface WhoamiResult {
|
||||
resolvedBy: "node_id" | "session_name";
|
||||
@@ -43,6 +44,9 @@ export interface WhoamiResult {
|
||||
* Additive note only — `peers[]` name and shape are unchanged.
|
||||
*/
|
||||
peersNote: string;
|
||||
/** Present only while `rig up` is still creating this rig's seats and edges (#2871): the peers and edges are the ones
|
||||
* created so far, and more may come. Absent once the rig is built, and for every rig not being built now. */
|
||||
rosterState?: "building";
|
||||
edges: {
|
||||
outgoing: Array<{ kind: string; to: { logicalId: string; sessionName: string | null } }>;
|
||||
incoming: Array<{ kind: string; from: { logicalId: string; sessionName: string | null } }>;
|
||||
@@ -141,6 +145,11 @@ interface WhoamiDeps {
|
||||
contextUsageStore?: import("./context-usage-store.js").ContextUsageStore;
|
||||
}
|
||||
|
||||
/** Said first in `peersNote` while the rig is still being built, so a seat doesn't plan around a teammate that hasn't been
|
||||
* created yet. Exported for the CLI's bounded note. */
|
||||
export const ROSTER_BUILDING_NOTE = "rosterState = building: `rig up` is still creating this team's seats and edges, so the peers and edges "
|
||||
+ "below are only the ones created so far and more may come; run `rig whoami` again once `rig up` finishes. ";
|
||||
|
||||
export class WhoamiService {
|
||||
private db: Database.Database;
|
||||
private rigRepo: RigRepository;
|
||||
@@ -356,11 +365,14 @@ export class WhoamiService {
|
||||
envOverride: query.targetRepoOverride ?? process.env["OPENRIG_TARGET_REPO"] ?? null,
|
||||
});
|
||||
|
||||
const building = rigStillBeingBuilt(this.db, nodeRow.rig_id);
|
||||
return {
|
||||
resolvedBy,
|
||||
identity,
|
||||
peers,
|
||||
peersNote: "peers = this rig's roster excluding self (no edge filter); edges = directional relationships; use `rig ps --nodes` for node inventory including self + live state",
|
||||
...(building ? { rosterState: "building" as const } : {}),
|
||||
peersNote: (building ? ROSTER_BUILDING_NOTE : "")
|
||||
+ "peers = this rig's roster excluding self (no edge filter); edges = directional relationships; use `rig ps --nodes` for node inventory including self + live state",
|
||||
edges: { outgoing, incoming },
|
||||
transcript,
|
||||
commands: { sendExamples, captureExamples },
|
||||
|
||||
@@ -76,6 +76,20 @@ export class WorkflowSpecError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
interface FailedSpecRead {
|
||||
sourceHash: string;
|
||||
cachedAt: string;
|
||||
}
|
||||
|
||||
// Keep capture evidence out of the original exception and out of process-global path state. The scanner can retrieve it
|
||||
// while handling this exact parse error, then persist it in the diagnostic row; WeakMap lifetime follows the exception.
|
||||
const failedSpecReads = new WeakMap<object, FailedSpecRead>();
|
||||
|
||||
export function getFailedSpecRead(error: unknown): FailedSpecRead | null {
|
||||
if ((typeof error !== "object" || error === null) && typeof error !== "function") return null;
|
||||
return failedSpecReads.get(error as object) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* OPR.0.4.6.WF1 FR-7 — THE closed keysets, EXPORTED as named
|
||||
* constants: parseWorkflowSpec is the ONLY seam that sees raw keys
|
||||
@@ -643,7 +657,10 @@ export class WorkflowSpecCache {
|
||||
* not previously cached).
|
||||
*/
|
||||
readThrough(sourcePath: string): WorkflowSpecRow {
|
||||
const { spec, sourceHash } = readSpecFile(sourcePath);
|
||||
// Stamp before opening the file. If an edit races the read, a later stat must not be hidden by a timestamp
|
||||
// assigned after that edit and after parse-error handling.
|
||||
const capturedAt = this.now().toISOString();
|
||||
const { spec, sourceHash } = readSpecFile(sourcePath, capturedAt);
|
||||
const named = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM workflow_specs WHERE name = ? AND version = ?`,
|
||||
@@ -671,11 +688,18 @@ export class WorkflowSpecCache {
|
||||
.run(sourcePath, existing?.spec_id ?? "").changes
|
||||
: 0;
|
||||
if (existing && existing.status !== "error" && existing.status !== RETAINED_STATUS && !moved && existing.source_hash === sourceHash) {
|
||||
// A repair back to the cached bytes records this scan, so the next scan skips the file.
|
||||
if (cleared > 0) {
|
||||
const repairedAt = this.now().toISOString();
|
||||
this.db.prepare(`UPDATE workflow_specs SET cached_at = ? WHERE spec_id = ?`).run(repairedAt, existing.spec_id);
|
||||
existing.cached_at = repairedAt;
|
||||
// A read verifies the timestamp only for its own source path, even when another copy has matching bytes. Advance only forward:
|
||||
// this pre-read stamp is safe for stat-only skips, while a backwards clock must not replace a newer observation.
|
||||
const existingCachedAtMs = Date.parse(existing.cached_at);
|
||||
const capturedAtMs = Date.parse(capturedAt);
|
||||
const shouldAdvanceObservation = existing.source_path === sourcePath && Number.isFinite(capturedAtMs) && (
|
||||
Number.isFinite(existingCachedAtMs)
|
||||
? existingCachedAtMs < capturedAtMs
|
||||
: cleared > 0
|
||||
);
|
||||
if (shouldAdvanceObservation) {
|
||||
this.db.prepare(`UPDATE workflow_specs SET cached_at = ? WHERE spec_id = ?`).run(capturedAt, existing.spec_id);
|
||||
existing.cached_at = capturedAt;
|
||||
}
|
||||
// readThrough is file-authoritative: return the freshly parsed
|
||||
// file spec so validation sees non-column metadata such as
|
||||
@@ -691,7 +715,7 @@ export class WorkflowSpecCache {
|
||||
}
|
||||
return rowToWorkflowSpec(existing, spec);
|
||||
}
|
||||
const cachedAt = this.now().toISOString();
|
||||
const cachedAt = capturedAt;
|
||||
const purpose = spec.objective ?? null;
|
||||
const targetRig = spec.target?.rig ?? null;
|
||||
const rolesJson = JSON.stringify(spec.roles);
|
||||
@@ -940,8 +964,10 @@ export class WorkflowSpecCache {
|
||||
sourcePath: string;
|
||||
sourceHash: string;
|
||||
errorMessage: string;
|
||||
/** Capture time from the read that failed, when available. */
|
||||
cachedAt?: string;
|
||||
}): void {
|
||||
const cachedAt = this.now().toISOString();
|
||||
const cachedAt = opts.cachedAt ?? this.now().toISOString();
|
||||
const existing = this.db
|
||||
.prepare(`SELECT spec_id FROM workflow_specs WHERE source_path = ? AND status = 'error'`)
|
||||
.get(opts.sourcePath) as { spec_id: string } | undefined;
|
||||
@@ -1116,7 +1142,7 @@ function warnLegacyRehydrationOnce(name: string, version: string): void {
|
||||
);
|
||||
}
|
||||
|
||||
function readSpecFile(sourcePath: string): { spec: WorkflowSpec; sourceHash: string } {
|
||||
function readSpecFile(sourcePath: string, cachedAt?: string): { spec: WorkflowSpec; sourceHash: string } {
|
||||
if (!existsSync(sourcePath)) {
|
||||
throw new WorkflowSpecError(
|
||||
"spec_file_missing",
|
||||
@@ -1126,7 +1152,14 @@ function readSpecFile(sourcePath: string): { spec: WorkflowSpec; sourceHash: str
|
||||
}
|
||||
const raw = readFileSync(sourcePath, "utf-8");
|
||||
const sourceHash = createHash("sha256").update(raw).digest("hex");
|
||||
return { spec: parseWorkflowSpec(raw, sourcePath), sourceHash };
|
||||
try {
|
||||
return { spec: parseWorkflowSpec(raw, sourcePath), sourceHash };
|
||||
} catch (error) {
|
||||
if (cachedAt && ((typeof error === "object" && error !== null) || typeof error === "function")) {
|
||||
failedSpecReads.set(error as object, { sourceHash, cachedAt });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function rowToWorkflowSpec(row: SpecRow, parsedSpec?: WorkflowSpec): WorkflowSpecRow {
|
||||
|
||||
@@ -384,6 +384,7 @@ export async function startServer(port?: number) {
|
||||
// tree post-bind so the HTTP surface is ready before the first
|
||||
// tick (matches contextMonitor pattern).
|
||||
deps.watchdogScheduler?.start();
|
||||
deps.networkConnector?.start();
|
||||
deps.sessionTransport?.guardedDelivery?.recover();
|
||||
// S10 — start the gateway subsystem's NETWORK services (replay, outbound poll,
|
||||
// Socket Mode inbound) post-bind, contextMonitor pattern: the composed wire is
|
||||
@@ -452,6 +453,7 @@ export async function startServer(port?: number) {
|
||||
["proof-source-watch", () => deps.proofSourceWatch?.close()],
|
||||
["health-diagnosis", () => deps.healthDiagnosis?.stop()],
|
||||
["watchdog", () => deps.watchdogScheduler?.stop()],
|
||||
["network-connector", () => deps.networkConnector?.stop()],
|
||||
["stream-intake", () => deps.streamIntakeCoordinator?.stop()],
|
||||
["draft-delivery", () => deps.sessionTransport?.guardedDelivery?.stop()],
|
||||
["seat-activity", () => deps.seatActivityService?.stop()],
|
||||
|
||||
@@ -116,6 +116,16 @@ export function hookRelayRoute(value: unknown): EvidenceRoute | undefined {
|
||||
const hookEvidenceSeq = new Map<string, number>();
|
||||
// What each seat's runner has said about its turns, for deciding whether a working beat counts (the runner contract §4).
|
||||
const workingLiveness = new WorkingLivenessGate();
|
||||
/** #99: the reset instant the relay read from a Claude usage limit, as a canonical ISO timestamp, or null. The store
|
||||
* keeps it only on the typed StopFailure/rate_limit row; the provider lane decides whether it is still ahead. */
|
||||
function limitResetsAt(body: Record<string, unknown>): string | null {
|
||||
if (typeof body.resetsAt !== "string" || body.resetsAt.length > 40) return null;
|
||||
const ms = Date.parse(body.resetsAt);
|
||||
// The relay's eight-day horizon, enforced at ingress too: no sender can park a pool for longer than the relay could.
|
||||
return Number.isFinite(ms) && ms <= Date.now() + LIMIT_RESET_MAX_MS ? new Date(ms).toISOString() : null;
|
||||
}
|
||||
const LIMIT_RESET_MAX_MS = 8 * 24 * 3600 * 1000;
|
||||
|
||||
function sourceSequence(body: Record<string, unknown>): number | null {
|
||||
return typeof body.sequence === "number" && Number.isSafeInteger(body.sequence) && body.sequence > 0 ? body.sequence : null;
|
||||
}
|
||||
@@ -727,6 +737,8 @@ function recordHookActivity(c: Context, store: AgentActivityStore, body: Record<
|
||||
livenessSeconds: beat ? workingLivenessSeconds(body.livenessSeconds)
|
||||
: typeof body.livenessSeconds === "number" && Number.isSafeInteger(body.livenessSeconds)
|
||||
&& body.livenessSeconds > 0 && body.livenessSeconds <= 3600 ? body.livenessSeconds : null,
|
||||
resetsAt: limitResetsAt(body),
|
||||
sessionId: stringOrNull(body.sessionId),
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
|
||||
@@ -50,11 +50,10 @@ import { mkdirSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { randomUUID, randomInt } from "node:crypto";
|
||||
import { connect } from "node:net";
|
||||
import { execFile } from "node:child_process";
|
||||
import { authBearerTokenMiddleware } from "../middleware/auth-bearer-token.js";
|
||||
import { getOpenRigHome } from "../openrig-compat.js";
|
||||
import { addHostEntry, updateHostEntry, removeHostEntry } from "../domain/hosts/hosts-registry-writer.js";
|
||||
import { defaultHostRegistryPath, loadHostRegistry, validateHostRegistry, type HostEntry, type SshHostEntry } from "../domain/hosts/hosts-registry-reader.js";
|
||||
import { defaultHostRegistryPath, loadHostRegistry, validateHostRegistry, type HostEntry } from "../domain/hosts/hosts-registry-reader.js";
|
||||
import type { SettingsStore } from "../domain/user-settings/settings-store.js";
|
||||
import { UNATTRIBUTED_ACTOR, type QueueItem, type QueueRepository } from "../domain/queue-repository.js";
|
||||
import { KERNEL_OPERATOR_SESSION, resolveDefaultReceiver } from "../domain/default-receiver.js";
|
||||
@@ -332,39 +331,16 @@ export function hostsRoutes(opts?: { bearerToken?: string | null; humanRegistry?
|
||||
// POINTERS by construction) + `selected` + a bounded coarse `status`.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// Where ssh would connect for this host, from `ssh -G` (reads ssh config, no connection); null when
|
||||
// ssh can't resolve it or a ProxyJump/ProxyCommand sits in between. Twin of the CLI's resolveSshEndpoint.
|
||||
function resolveSshEndpoint(host: SshHostEntry, timeoutMs: number): Promise<{ hostname: string; port: number } | null> {
|
||||
const args = [...(host.user ? ["-l", host.user] : []), "-G", host.target];
|
||||
return new Promise((resolve) => {
|
||||
execFile("ssh", args, { timeout: timeoutMs }, (error, stdout) => {
|
||||
if (error) return resolve(null);
|
||||
const conf = new Map(stdout.split("\n").map((line) => {
|
||||
const i = line.indexOf(" ");
|
||||
return [line.slice(0, i).toLowerCase(), line.slice(i + 1).trim()] as const;
|
||||
}));
|
||||
const proxied = ["proxyjump", "proxycommand"].some((k) => (conf.get(k) ?? "none") !== "none");
|
||||
const hostname = conf.get("hostname");
|
||||
const port = Number(conf.get("port"));
|
||||
resolve(hostname && Number.isInteger(port) && !proxied ? { hostname, port } : null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function probeHost(host: HostEntry, timeoutMs = 1500): Promise<"reachable" | "unreachable" | "unknown"> {
|
||||
try {
|
||||
let target: string;
|
||||
let port: number;
|
||||
if (host.transport === "ssh") {
|
||||
const endpoint = await resolveSshEndpoint(host, timeoutMs);
|
||||
if (!endpoint) return "unknown";
|
||||
({ hostname: target, port } = endpoint);
|
||||
} else {
|
||||
const u = new URL(host.url);
|
||||
// WHATWG keeps the brackets on an IPv6 hostname; net.connect wants the bare address.
|
||||
target = u.hostname.replace(/^\[(.*)\]$/, "$1");
|
||||
port = u.port ? Number(u.port) : u.protocol === "https:" ? 443 : 80;
|
||||
// Passive SSH rows have no authority to read SSH config or probe sshd.
|
||||
return "unknown";
|
||||
}
|
||||
const u = new URL(host.url);
|
||||
// WHATWG keeps the brackets on an IPv6 hostname; net.connect wants the bare address.
|
||||
const target = u.hostname.replace(/^\[(.*)\]$/, "$1");
|
||||
const port = u.port ? Number(u.port) : u.protocol === "https:" ? 443 : 80;
|
||||
return new Promise((resolve) => {
|
||||
const sock = connect({ host: target, port, timeout: timeoutMs });
|
||||
sock.on("connect", () => { sock.destroy(); resolve("reachable"); });
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Hono, type Context } from "hono";
|
||||
import type { RigRepository } from "../domain/rig-repository.js";
|
||||
import { NetworkBindingError, NetworkBindingStore } from "../domain/network/network-bindings.js";
|
||||
import { networkClosedElsewhere } from "../domain/network/network-connector.js";
|
||||
import { authBearerTokenMiddleware } from "../middleware/auth-bearer-token.js";
|
||||
import { OPENRIG_HOME } from "../openrig-compat.js";
|
||||
|
||||
/** A local seat's connections to a remote hub (`rig network`). Reads list every binding with its state and last contact;
|
||||
* the token is accepted on connect and never returned. Writes are gated like the hosts routes: enforced when an operator
|
||||
* bearer is configured, passed through on a loopback or tailnet-trusted daemon. */
|
||||
export function networkRoutes(opts?: { bearerToken?: string | null; home?: string }): Hono {
|
||||
const router = new Hono();
|
||||
const requireAuth = authBearerTokenMiddleware({ expectedToken: opts?.bearerToken ?? null });
|
||||
router.use("/bindings", async (c, next) => (c.req.method === "GET" ? next() : requireAuth(c, next)));
|
||||
router.use("/bindings/:id/disconnect", requireAuth);
|
||||
|
||||
const store = (c: Context) => new NetworkBindingStore((c.get("rigRepo" as never) as RigRepository).db, opts?.home ?? OPENRIG_HOME);
|
||||
const refused = (c: Context, error: unknown) => {
|
||||
if (error instanceof NetworkBindingError) return c.json({ error: error.code, message: error.message }, error.status);
|
||||
throw error;
|
||||
};
|
||||
|
||||
// The connector's own view (next pass, backoff, last pass) when it runs in this daemon; null otherwise.
|
||||
router.get("/bindings", (c) => {
|
||||
const connector = c.get("networkConnector" as never) as { snapshot(): unknown } | undefined;
|
||||
const db = (c.get("rigRepo" as never) as RigRepository).db;
|
||||
// Hub items someone else closed before a binding's result reached them: a conflict the person sees.
|
||||
const bindings = store(c).list().map((b) => ({ ...b, closedElsewhere: networkClosedElsewhere(db, b.id) }));
|
||||
return c.json({ bindings, connector: connector?.snapshot() ?? null });
|
||||
});
|
||||
|
||||
router.post("/bindings", async (c) => {
|
||||
let body: Record<string, unknown>;
|
||||
try { body = await c.req.json(); } catch { return c.json({ error: "invalid_body", message: "The body must be JSON." }, 400); }
|
||||
try {
|
||||
return c.json(store(c).create({ session: body.session, hubUrl: body.hubUrl, hubSeat: body.hubSeat, token: body.token, allowHttp: body.allowHttp }), 201);
|
||||
} catch (error) { return refused(c, error); }
|
||||
});
|
||||
|
||||
router.post("/bindings/:id/disconnect", async (c) => {
|
||||
let body: Record<string, unknown> = {};
|
||||
try { body = await c.req.json(); } catch { /* no reason given */ }
|
||||
try {
|
||||
return c.json(store(c).disconnect(c.req.param("id"), typeof body.reason === "string" ? body.reason : undefined));
|
||||
} catch (error) { return refused(c, error); }
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
Loaded 100 of 184 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user