mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-11 11:41:38 -10:00
Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
049637a44a | ||
|
|
8990e1cb6e | ||
|
|
837186fd9d | ||
|
|
aaf986bf88 |
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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -1092,6 +1093,7 @@ for input and receipt examples, rollback, and snapshot behavior.
|
||||
| `rig seat set-permissions <seat>` | — | `--mode <mode>`<br>`--rules <file>`<br>`--codex-permissions <file>`<br>`--codex-profile <name>`<br>`--reason <text>` **required**<br>`--operator <address>`<br>`--json` |
|
||||
| `rig seat set-effort <seat>` | — | `--effort <level>`<br>`--clear`<br>`--reason <text>` **required**<br>`--operator <address>`<br>`--json` |
|
||||
| `rig seat set-permissions-to <seat>` | — | `--to <person>`<br>`--clear`<br>`--deny`<br>`--reason <text>` **required**<br>`--operator <address>`<br>`--json` |
|
||||
| `rig seat decide-permission <request>` | — | `--option <id>` **required**<br>`--reason <text>`<br>`--json` |
|
||||
| `rig seat effort <seat>` | — | `--json` |
|
||||
| `rig seat set-model <seat>` | — | `--model <id>` **required**<br>`--reason <text>` **required**<br>`--operator <address>`<br>`--json` |
|
||||
| `rig seat set-runtime <seat>` | — | `--runtime <runtime>`<br>`--reason <text>`<br>`--model <id>`<br>`--effort <level>`<br>`--codex-config-profile <name>`<br>`--clear-account-home`<br>`--account-home <path>`<br>`--token-file <path>`<br>`--account-label <label>`<br>`--permission-mode <mode>`<br>`--confirm-timeout <seconds>`<br>`--dry-run`<br>`--confirm <switch-id>`<br>`--note <text>`<br>`--recover`<br>`--json` |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -91,7 +91,8 @@ never picks whichever seat happens to be running. The TUI shows the effective ag
|
||||
under Settings > Agents & runtime ("Default receiving agent"). It never replaces the default person.
|
||||
|
||||
For Slack, `rig slack setup --inbound-destination default-receiver` (the default for a new setup) sends unrouted
|
||||
messages to the default receiving agent, read when the connector starts. A destination already saved in the connector
|
||||
messages to the default receiving agent, read when the connector starts. With no receiving agent on the host, they go to
|
||||
`operator-agent@kernel` for compatibility, as before. A destination already saved in the connector
|
||||
config, `operator-agent@kernel` included, is kept as chosen; pass `default-receiver` to switch it.
|
||||
|
||||
## In the TUI: whose queue, and who acts
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -298,9 +305,31 @@ that runs without asking gains no approval step. Ordinary conversation and `rig
|
||||
| replies with just a no ("no", "deny", "reject", "stop" …), or denies it in the Feed | the agent's reject (`reject_once`, else `reject_always`), or, with no reject option, cancelled. A person's no is never a grant |
|
||||
| routes or hands it to another person | the ask waits for that person, who gets the same options; their answer counts as if they'd been asked. The handoff itself never permits |
|
||||
| routes or hands it to an agent seat | that seat gets no options. The request waits until that row is closed, by the seat or a person. Closed as done without a choice (including a drop), or as failed, is no answer, so it's permitted. Closed as denied (the Feed's deny, `--closure-reason denied`, or the state `denied`) is a no. A row set to the state `canceled` cancels the request, and a further handoff is followed; a person it reaches gets the same options. Or the request waits until the agent's turn ends |
|
||||
| closes it without choosing: drop, approve, or other typed words | no answer, so the default applies: **permitted** with the agent's allow, and OpenRig records the permit (below) |
|
||||
| closes it with no answer at all (a drop) | no answer, so the default applies: **permitted** with the agent's allow, and OpenRig records the permit (below) |
|
||||
| answers in other words that nobody decides on | after 0.7.11, **the request waits**: the words are kept, never the default permit, until an explicit decision (`rig seat decide-permission`) or the turn's end |
|
||||
| doesn't answer | the request **stays pending and visible** (the seat reads needs input) until the agent's turn ends. It's never timed out into a grant. |
|
||||
|
||||
**After 0.7.11, a person's answer goes to an agent that decides, or waits:**
|
||||
- **Who decides:** this host's receiving agent (`agents.operator_session`, else the kernel operator
|
||||
`operator-agent@kernel`), unless it's the runner that asked (the request's first row, however it was handed on) or
|
||||
a seat this host doesn't have; then the kernel operator, unless it's the runner that asked.
|
||||
- **What happens to the answer:** a click, a plain yes or no, other words, or Steering's Approve or Deny on the request
|
||||
(a yes or a no) is kept on the row it was given on. The request is handed to that agent with the person's whole
|
||||
answer, the question and the options, and the person is told once who decides and in what recorded state.
|
||||
- **The runner keeps reading `pending`.** The agent decides with `rig seat decide-permission <askId> --option <id>
|
||||
--reason "…"` (`POST <base>/api/activity/permission-requests/<askId>/decide` with `{"optionId", "reason"}`).
|
||||
- It accepts only an option the agent offered.
|
||||
- It records an agent's decision: who decided, why, and the person's words.
|
||||
- A request that's no longer pending comes back as it is, and nothing is written.
|
||||
- **A kept answer counts only through an explicit decision.** If the row is closed without one (an agent's or a
|
||||
person's ordinary close), the answer stays kept and the request waits; `rig seat decide-permission` still settles it.
|
||||
A refusing close (denied) is still a no.
|
||||
- **With no agent that can decide,** any answer is kept, the request waits, and the person is told once that the tool
|
||||
hasn't run and how to decide directly (`rig seat decide-permission`) or set up a receiving agent. A handoff that fails
|
||||
leaves the request waiting the same way.
|
||||
- **No timeout refuses a request.** If the turn ends first, nothing is sent.
|
||||
- **The default permit stays** for a request nobody can be asked about (above), and one closed with no answer at all.
|
||||
|
||||
- **An answer reaches only the live request it was asked for,** once. A request from an earlier occupant reads
|
||||
`stale`, and one withdrawn at the turn's end reads `withdrawn`. Neither can take a later answer.
|
||||
- **Text typed into the runner's pane, an agent's own text, and `rig send` are never an answer.** While a request waits
|
||||
@@ -345,7 +374,9 @@ that runs without asking gains no approval step. Ordinary conversation and `rig
|
||||
`no_allow_offered` when it was closed without a choice and the agent offered no allow option), `withdrawn` or
|
||||
`stale`.
|
||||
- **From 0.7.9, `said`,** with `permitted` or `refused` (`no_allow_offered`), is the person's own words, when they
|
||||
answered in words that were neither a choice nor a plain yes or no. It never changes the outcome.
|
||||
answered in words that were neither a choice nor a plain yes or no. It never changes the outcome. After 0.7.11, words are never
|
||||
answered with the default permit (they keep the request waiting), so `said` no longer arrives. A runner that handles
|
||||
it keeps working.
|
||||
- **A seat grant reads and withdraws only its own launch's requests.** Once its occupant is replaced, the grant is
|
||||
refused (`409 activity_grant_stale`), even during a read's wait.
|
||||
3. **Deliver** `{"outcome": {"outcome": "selected", "optionId": "<optionId>"}}` to the agent once, and only while that
|
||||
|
||||
+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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -172,7 +172,9 @@ function defaultDoctorDeps(): DoctorDeps {
|
||||
* 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];
|
||||
// `--` before the target: a destination that begins with `-` is fed to ssh as the host, not
|
||||
// read as an option. ssh answers an invalid hostname on its own.
|
||||
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);
|
||||
|
||||
@@ -986,6 +986,47 @@ Examples:
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("decide-permission")
|
||||
.argument("<request>", "The permission request's id (qitem-runner-permission-…), as the row handed to you names it")
|
||||
.requiredOption("--option <id>", "One of the options the agent offered, by its id")
|
||||
.option("--reason <text>", "Why, recorded with the decision (say what the person meant)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.description("Decide a runner seat's permission request that its person answered: one of the agent's offered options")
|
||||
.addHelpText("after", `
|
||||
When a person answers a runner seat's permission request, with a click or in their own
|
||||
words, the request goes to this host's receiving agent to decide while the runner waits.
|
||||
This records that agent's decision: one of the options the agent offered, which the runner
|
||||
then gives its agent. It's recorded as an agent's decision, not the person's, with the
|
||||
deciding seat and the reason. A request that's already decided, withdrawn or stale is
|
||||
reported as it is, and nothing is written. See docs/reference/harness-runner-contract.md,
|
||||
section 7.
|
||||
Example:
|
||||
rig seat decide-permission qitem-runner-permission-0123abcd --option allow --reason "they said go ahead"`)
|
||||
.action(async (request: string, opts: { option: string; reason?: string; json?: boolean }) => {
|
||||
type Decided = { ok?: boolean; state?: string; optionId?: string; code?: string; error?: string };
|
||||
let res: { status: number; data: Decided };
|
||||
try {
|
||||
res = await new DaemonClient().post<Decided>(`/api/activity/permission-requests/${encodeURIComponent(request.trim())}/decide`,
|
||||
{ optionId: opts.option.trim(), reason: opts.reason ?? "" });
|
||||
} catch (err) {
|
||||
console.error((err as Error).message);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (opts.json) console.log(JSON.stringify(res.data));
|
||||
if (res.status !== 200 || !res.data?.ok) {
|
||||
if (!opts.json) console.error(res.data?.error ?? `the decision failed (HTTP ${res.status})`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (opts.json) return;
|
||||
const state = res.data.state;
|
||||
console.log(state === "answered" ? `Decided: ${res.data.optionId}. The runner gives it to its agent at its next read.`
|
||||
: state === "refused" ? "Decided: refused. The runner gives its agent the refusal at its next read."
|
||||
: `Nothing was decided: the request is ${state}.`);
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("effort")
|
||||
.argument("<seat>", "Canonical session name or logical seat ref")
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';");
|
||||
|
||||
@@ -50,12 +50,6 @@ 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
|
||||
|
||||
@@ -64,6 +64,20 @@ function conversationFields(providerPayload, env) {
|
||||
};
|
||||
}
|
||||
|
||||
// The prompt the harness received at UserPromptSubmit, as a fingerprint and byte length, with Claude's own prompt id
|
||||
// when it sends one: enough for the daemon to match it to a message OpenRig wrote, without the prompt leaving this
|
||||
// process. Any other event, or a prompt that isn't a string, adds nothing.
|
||||
function promptBoundaryFields(hookEvent, providerPayload) {
|
||||
if (hookEvent !== "UserPromptSubmit" || typeof providerPayload.prompt !== "string") return {};
|
||||
const bytes = Buffer.from(providerPayload.prompt, "utf8");
|
||||
const nativePromptId = firstString(providerPayload.prompt_id);
|
||||
return {
|
||||
promptSha256: crypto.createHash("sha256").update(bytes).digest("hex"),
|
||||
promptBytes: bytes.length,
|
||||
...(nativePromptId && /^[A-Za-z0-9_-]{1,128}$/.test(nativePromptId) ? { nativePromptId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// The route this run reports by, so the daemon can count one event once per route: "plugin" for the plugin's hook
|
||||
// (started with --from-plugin), "managed" for the settings or managed hook copy.
|
||||
function buildOpenRigPayload(providerPayload, env = process.env, now = () => new Date(), argv = process.argv) {
|
||||
@@ -127,6 +141,7 @@ function buildOpenRigPayload(providerPayload, env = process.env, now = () => new
|
||||
&& (providerPayload.agent_id == null || firstString(providerPayload.agent_id))
|
||||
? { hookAgentId: firstString(providerPayload.agent_id) } : {}),
|
||||
turnId: firstString(providerPayload.turn_id, providerPayload.turnId),
|
||||
...promptBoundaryFields(hookEvent, providerPayload),
|
||||
occurredAt: now().toISOString(),
|
||||
relayRoute: argv.includes("--from-plugin") ? "plugin" : "managed",
|
||||
...conversationFields(providerPayload, env),
|
||||
|
||||
@@ -630,30 +630,10 @@ 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,
|
||||
additionalContext: payload,
|
||||
},
|
||||
});
|
||||
process.stdout.write(output, (error) => {
|
||||
@@ -681,9 +661,6 @@ 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;
|
||||
|
||||
@@ -146,7 +146,8 @@ person needs in their project or home folder and name that path.
|
||||
A declared name is a claim, not a seat's own identity: for a team that acts only on seat identity, the attached
|
||||
shell above is the route.
|
||||
- **Who answers OpenRig's questions:** read `rig gateway participants` (its `default:` line) before the handoff.
|
||||
- A default person is there: say requests and asks go to them, and don't offer again.
|
||||
- A default person is there: say requests and asks that name nobody go to them (a pairing request or an ask can
|
||||
name someone else), and don't offer again.
|
||||
- Several people with no default: ask which should get requests nobody is named for (`rig gateway default <id>`),
|
||||
or leave it. Never pick one yourself.
|
||||
- Nobody is registered: offer it in a sentence or two. OpenRig's questions, such as another machine asking to pair,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)];
|
||||
|
||||
@@ -72,3 +72,23 @@ export function describeDefaultReceiver(receiver: DefaultReceiver): string {
|
||||
: "its seat couldn't be read: answers may wait";
|
||||
return `${note}${receiver.session}, ${from}; ${state}.`;
|
||||
}
|
||||
|
||||
/** Who decides a runner's permission request once its person has answered, while the runner waits on it: the receiving
|
||||
* agent, unless that's the requesting seat itself (blocked in the very turn that asked); else the kernel operator, when
|
||||
* its seat is on this host and isn't the requester; else nobody, and OpenRig's default applies. Never the requester, and
|
||||
* never a seat picked because it happens to be running. */
|
||||
export function permissionDeciderFor(
|
||||
db: Database.Database,
|
||||
requester: string,
|
||||
configured: () => string | null | { session: string | null; problem?: string } = readReceivingAgentSetting,
|
||||
): DefaultReceiver | null {
|
||||
const receiver = resolveDefaultReceiver(db, configured);
|
||||
// A seat this host doesn't have can't read anything: it isn't a decider (a stopped one is, and its delay is told).
|
||||
if (receiver.session && receiver.session !== requester && receiver.state !== "missing") return receiver;
|
||||
if (requester === KERNEL_OPERATOR_SESSION) return null;
|
||||
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,
|
||||
// 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 };
|
||||
}
|
||||
@@ -79,7 +79,7 @@ export class DraftAwareDelivery {
|
||||
// delivery needs an idle seat: submitting it starts a turn, so that delivery is held as before.
|
||||
await this.guard.beforeInput(session, false, undefined, opts.waitForIdleMs === undefined && !opts.preserveInput ? "recognize" : undefined);
|
||||
result = await this.deliver(session, text, { ...opts,
|
||||
onInputEffect: phase => { inputWritten = true; opts.onInputEffect?.(phase); },
|
||||
onInputEffect: (phase, written) => { inputWritten = true; opts.onInputEffect?.(phase, written); },
|
||||
beforeWrite: () => {
|
||||
if (opts.queueWake && !this.wakeApplicable?.(entries)) throw new DeliveryGuardError("draft_wake_superseded", "The queue wake is no longer applicable; no further input was written.");
|
||||
opts.beforeWrite?.();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ");
|
||||
}
|
||||
@@ -218,6 +218,19 @@ export class MissionControlWriteContract {
|
||||
const acting = this.asActor(input, runnerPermissionOrigin(source) && this.queueRepo.answersAsk(source, input.actorSession) ? null : asked);
|
||||
input = acting.input;
|
||||
|
||||
// A runner's permission ask approved or denied from the Feed or Steering is the person's answer, as a click on its
|
||||
// options is: a yes or a no that goes the same way, to the receiving agent to decide, or straight to the request
|
||||
// where no agent can.
|
||||
if ((input.verb === "approve" || input.verb === "deny") && source.state === "pending" && runnerPermissionOrigin(source)) {
|
||||
const verb = input.verb;
|
||||
return this.recordAnswer(input, snapshotQitem(source), verb === "approve" ? "yes" : "no", { outcome: "answered", unanswered: [] }, null, {
|
||||
primaryUser: input.authority === "primary-user",
|
||||
allowAlreadyDone: false,
|
||||
onUnclosed: () => new MissionControlWriteContractError("answer_not_closed",
|
||||
`The ${verb} on ${input.qitemId} wasn't recorded as an answer: it changed meanwhile, or couldn't be passed on. Read it again.`, { qitemId: input.qitemId }),
|
||||
});
|
||||
}
|
||||
|
||||
if ((input.verb === "route" || input.verb === "handoff") && !input.destinationSession) {
|
||||
throw new MissionControlWriteContractError(
|
||||
"destination_required",
|
||||
@@ -725,7 +738,9 @@ export class MissionControlWriteContract {
|
||||
onUnclosed: () =>
|
||||
new MissionControlWriteContractError(
|
||||
"answer_not_closed",
|
||||
`The answer went back to ${ask.sourceSession} as ${replyId}, but qitem ${input.qitemId} didn't close: it changed meanwhile.`,
|
||||
runnerPermissionOrigin(ask)
|
||||
? `Your answer is kept on ${input.qitemId}, but it couldn't be passed to its receiving agent just now, and nothing ran. Answer again, or decide directly: rig seat decide-permission ${input.qitemId} --option <id>.`
|
||||
: `The answer went back to ${ask.sourceSession} as ${replyId}, but qitem ${input.qitemId} didn't close: it changed meanwhile.`,
|
||||
{ qitemId: input.qitemId, replyQitemId: replyId },
|
||||
),
|
||||
},
|
||||
@@ -756,6 +771,10 @@ export class MissionControlWriteContract {
|
||||
},
|
||||
): MissionControlActionResult {
|
||||
const evaluatedAt = this.now().toISOString();
|
||||
// A runner's permission ask approved or denied from the Feed or Steering goes through the answer path, and the
|
||||
// audit keeps what the person did: their verb, and their reason when they gave one.
|
||||
const actionVerb: MissionControlVerb = input.verb === "approve" || input.verb === "deny" ? input.verb : "answer";
|
||||
const auditReason = actionVerb !== "answer" && input.reason ? input.reason : reason;
|
||||
let after: QueueItem | null = null;
|
||||
let actionEntry: ReturnType<MissionControlActionLog["record"]> | null = null;
|
||||
try {
|
||||
@@ -773,13 +792,13 @@ export class MissionControlWriteContract {
|
||||
}
|
||||
after = this.queueRepo.getById(input.qitemId);
|
||||
actionEntry = this.actionLog.record({
|
||||
actionVerb: "answer",
|
||||
actionVerb,
|
||||
qitemId: input.qitemId,
|
||||
actorSession: input.actorSession,
|
||||
actedAt: evaluatedAt,
|
||||
beforeState: before,
|
||||
afterState: after ? snapshotQitem(after) : null,
|
||||
reason,
|
||||
reason: auditReason,
|
||||
auditNotes: input.auditNotes ?? null,
|
||||
identityProvenance: input.identityProvenance ?? null,
|
||||
});
|
||||
@@ -787,7 +806,7 @@ export class MissionControlWriteContract {
|
||||
this.eventBus.persistWithinTransaction({
|
||||
type: "mission_control.action_executed",
|
||||
actionId: actionEntry.actionId,
|
||||
actionVerb: "answer",
|
||||
actionVerb,
|
||||
qitemId: input.qitemId,
|
||||
actorSession: input.actorSession,
|
||||
}),
|
||||
@@ -799,16 +818,19 @@ 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: "answer",
|
||||
verb: actionVerb,
|
||||
qitemId: input.qitemId,
|
||||
closedQitem: after,
|
||||
createdQitemId,
|
||||
notifyAttempted: false,
|
||||
notifyResult: null,
|
||||
auditedAt: evaluatedAt,
|
||||
answer,
|
||||
answer: runnerNotice ? { ...answer, message: runnerNotice } : answer,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -941,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.`;
|
||||
}
|
||||
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,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
|
||||
@@ -1110,6 +1098,7 @@ export class QueueRepository {
|
||||
private readonly hostedWakeFn: (destination: string) => RosterWake | null;
|
||||
/** Whether the rosters list an address as a hosted member, trigger or not. */
|
||||
private readonly hostedMemberFn: (destination: string) => boolean;
|
||||
private readonly permissionDecider?: (ask: QueueItem, requester: string) => { session: string; describe?: string } | null;
|
||||
private readonly hostedWakeFetch: typeof fetch | undefined;
|
||||
/** When this process last wrote each session's inbox check-in, so a seat that polls often writes at most once a minute. */
|
||||
private readonly inboxCheckInWrittenAt = new Map<string, number>();
|
||||
@@ -1163,6 +1152,10 @@ export class QueueRepository {
|
||||
hostedWake?: (destination: string) => RosterWake | null;
|
||||
isHostedMember?: (destination: string) => boolean;
|
||||
hostedWakeFetch?: typeof fetch;
|
||||
/** Who decides a runner's permission ask after its person answers, while the runner waits (startup wires the
|
||||
* receiving-agent order, `permissionDeciderFor`). Absent, or null for an ask: the ask closes with the answer, as
|
||||
* before, and the runner reads it there. */
|
||||
permissionDecider?: (ask: QueueItem, requester: string) => { session: string; describe?: string } | null;
|
||||
}
|
||||
) {
|
||||
this.db = db;
|
||||
@@ -1181,6 +1174,7 @@ export class QueueRepository {
|
||||
this.sharedChannelsFn = opts?.sharedChannels ?? (() => sharedChannelAddresses());
|
||||
this.hostedWakeFn = opts?.hostedWake ?? defaultHostedWake;
|
||||
this.hostedMemberFn = opts?.isHostedMember ?? defaultIsHostedMember;
|
||||
this.permissionDecider = opts?.permissionDecider;
|
||||
this.hostedWakeFetch = opts?.hostedWakeFetch;
|
||||
this.hasTargetRepoColumn = detectQueueColumn(db, "target_repo");
|
||||
this.hasSummaryColumn = detectQueueColumn(db, "summary");
|
||||
@@ -1671,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";
|
||||
@@ -1713,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);
|
||||
@@ -1788,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 };
|
||||
@@ -1846,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;
|
||||
@@ -2550,6 +2549,12 @@ export class QueueRepository {
|
||||
* with `handed_off_from` recording the chain. One atomic transaction.
|
||||
*/
|
||||
async handoff(input: QueueHandoffInput): Promise<{ closed: QueueItem; created: QueueItem }> {
|
||||
return this.handoffNow(input);
|
||||
}
|
||||
|
||||
/** {@link handoff}'s whole act, synchronously: its transaction commits before this returns, and the wake is delivered
|
||||
* after commit without being awaited, so a synchronous caller (an answer's close path) can hand a row on. */
|
||||
private handoffNow(input: QueueHandoffInput): { closed: QueueItem; created: QueueItem } {
|
||||
const source = this.getById(input.qitemId);
|
||||
if (!source) {
|
||||
throw new QueueRepositoryError(
|
||||
@@ -2712,8 +2717,10 @@ export class QueueRepository {
|
||||
});
|
||||
|
||||
txn();
|
||||
// Inside a caller's notify envelope (an answer's close path), the events join it; otherwise they're notified now.
|
||||
for (const e of events) {
|
||||
this.eventBus.notifySubscribers(e.payload as import("./types.js").PersistedEvent);
|
||||
const event = e.payload as import("./types.js").PersistedEvent;
|
||||
if (!this.eventBus.registerPersistedWithinActiveEnvelope(event)) this.eventBus.notifySubscribers(event);
|
||||
}
|
||||
|
||||
// Closure and successor are persisted. Delivery is separate and recoverable
|
||||
@@ -4183,6 +4190,8 @@ export class QueueRepository {
|
||||
* button set is already final; its resolve continuation must not overwrite those answers.
|
||||
*/
|
||||
resolveDirectHumanReply(input: { qitemId: string; actorSession: string; decision: string; primaryUser?: boolean }): boolean {
|
||||
const passed = this.passRunnerAnswerToDecider(input);
|
||||
if (passed !== null) return passed;
|
||||
let mine = true;
|
||||
const result = this.db.transaction(() => {
|
||||
const item = this.getById(input.qitemId);
|
||||
@@ -4208,6 +4217,121 @@ export class QueueRepository {
|
||||
return mine;
|
||||
}
|
||||
|
||||
/** A runner's permission ask its person answered, where an agent can decide while the runner waits on it: their answer
|
||||
* (a choice, or their words) stays on the ask, and the ask is handed to that agent to decide, never closed with it. The
|
||||
* runner reads pending until the agent decides (`rig seat decide-permission`), or the person uses a direct control.
|
||||
* Null when this doesn't apply (no decider, or another person's answer settles it), and the ask closes as before. */
|
||||
private passRunnerAnswerToDecider(input: { qitemId: string; actorSession: string; decision: string; primaryUser?: boolean }): boolean | null {
|
||||
const item = this.getById(input.qitemId);
|
||||
if (!this.permissionDecider || item?.state !== "pending" || !runnerPermissionOrigin(item) || item.humanIntent === "update"
|
||||
|| (!this.answersAsk(item, input.actorSession) && !input.primaryUser) || !this.isPersonAddress(item.destinationSession)
|
||||
|| this.committedAnswerFromAnother(item.qitemId, input.actorSession)) return null;
|
||||
// The requester is the runner seat that asked: the first row of the request, whoever handed it on since.
|
||||
let root: QueueItem = item;
|
||||
for (let hops = 0; root.handedOffFrom && hops < 10; hops++) {
|
||||
const before = this.getById(root.handedOffFrom);
|
||||
if (!before || !runnerPermissionOrigin(before)) break;
|
||||
root = before;
|
||||
}
|
||||
let chosen: { session: string; describe?: string } | null;
|
||||
try { chosen = this.permissionDecider(item, root.sourceSession); } catch { chosen = null; }
|
||||
const decider = chosen?.session ?? null;
|
||||
if (!decider || !this.validateRig(decider)) return this.keepRunnerAnswer(item, input.actorSession, input.decision, root.sourceSession);
|
||||
this.db.transaction(() => { this.placeTypedReply(item, input.decision); })();
|
||||
const answered = this.getById(item.qitemId) ?? item;
|
||||
const question = (this.permissionQuestionsFor(answered) ?? [])[0];
|
||||
const answer = question ? answered.humanAnswers?.[question.id] : undefined;
|
||||
const said = typeof answer === "string" ? `chose "${question?.options.find((o) => o.id === answer)?.label ?? answer}" (option ${answer})`
|
||||
: answer && typeof answer === "object" && "text" in answer ? `wrote "${String(answer.text).trim()}"`
|
||||
: "answered without a choice or words";
|
||||
const person = input.actorSession;
|
||||
// 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`,
|
||||
body: [
|
||||
`${person} answered ${item.sourceSession}'s permission request ${item.qitemId}: they ${said}. Its runner is waiting on your decision: nothing is permitted or refused until you decide.`,
|
||||
"",
|
||||
`- **Question:** ${question?.question ?? "Allow this tool call?"}`,
|
||||
"- **Options:**",
|
||||
...(question?.options ?? []).map((o) => ` - ${o.label} (${o.id})`),
|
||||
"",
|
||||
`Their whole answer is recorded on the request: rig queue show ${answered.qitemId} --full.`,
|
||||
`Decide with: rig seat decide-permission ${item.qitemId} --option <id> --reason "<why>"`,
|
||||
"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) {
|
||||
// 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);
|
||||
return this.keepRunnerAnswer(item, person, input.decision, root.sourceSession, decider);
|
||||
}
|
||||
// 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(() => {
|
||||
void this.create({ qitemId: toldId, sourceSession: root.sourceSession, destinationSession: person, humanIntent: "update",
|
||||
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}`] : []),
|
||||
`To decide directly instead: rig seat decide-permission ${root.qitemId} --option <id>.`].join("\n") })
|
||||
.catch((error) => console.error(`[queue] the update for ${item.qitemId}'s decider wasn't written`, error));
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/** An agent's decision on a runner's permission request, on the row that holds it now: the chosen option under its
|
||||
* question, and the row closed done, in one transaction. False when the row isn't open any more. */
|
||||
recordPermissionDecision(input: { qitemId: string; actorSession: string; questionId: string; optionId: string; transitionNote: string }): boolean {
|
||||
const result = this.db.transaction(() => {
|
||||
const item = this.getById(input.qitemId);
|
||||
if (!item) return null;
|
||||
const answers: HumanAnswers = { ...item.humanAnswers, [input.questionId]: input.optionId };
|
||||
// A row closed without a decision (an agent's or a person's ordinary close): the decision is recorded on it, with
|
||||
// its own transition, over any kept answer (which the transition names); the runner's next read takes it.
|
||||
// (The caller read it first: a row already decided explicitly, withdrawn or stale never reaches here.)
|
||||
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 });
|
||||
// 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);
|
||||
return this.updateInTransactionalContext({ qitemId: item.qitemId, actorSession: input.actorSession, state: "done",
|
||||
closureReason: "no-follow-on", transitionNote: input.transitionNote });
|
||||
})();
|
||||
if (!result) return false;
|
||||
for (const event of result.persistedEvents) {
|
||||
if (!this.eventBus.registerPersistedWithinActiveEnvelope(event)) this.eventBus.notifySubscribers(event);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** No agent here can read a runner permission answer before the tool runs (none is set, or the only one is the seat
|
||||
* asking): the person's answer, a click or words, is kept on the ask, the request stays pending, and the person is
|
||||
* told once that the tool hasn't run and how to decide directly. Never a default permit, never read as a refusal. */
|
||||
private keepRunnerAnswer(item: QueueItem, person: string, decision: string, requester: string, unreachable?: string): boolean {
|
||||
this.db.transaction(() => { this.placeTypedReply(item, decision); })();
|
||||
const options = (this.permissionQuestionsFor(item) ?? [])[0]?.options ?? [];
|
||||
const receiptId = `qitem-runner-permission-kept-${item.qitemId.replace(/^qitem-runner-permission-/, "")}`;
|
||||
// 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",
|
||||
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>.`,
|
||||
"If the agent's turn ends first, the tool call doesn't happen, and nothing is sent later."].join("\n") })
|
||||
.catch((error) => console.error(`[queue] the kept-answer receipt for ${item.qitemId} wasn't written`, error));
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Another person's committed return to this ask: its shared reply (for a park's question, `episodeKey`'s). It's the
|
||||
* answer, so a different person's later attempt settles with it rather than their own. Null when there's none, or it's
|
||||
* this actor's. */
|
||||
@@ -5714,7 +5838,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>>();
|
||||
|
||||
@@ -116,8 +116,10 @@ export const PRUNABLE_EVENT_TYPES = [
|
||||
"queue.claimed",
|
||||
"queue.unclaimed",
|
||||
"queue.handed_off",
|
||||
// One delivery-evidence record per send; kept only as long as the other high-volume event types.
|
||||
// One delivery-evidence record per send, and one prompt observation per prompt hook; kept only as long as the other
|
||||
// high-volume event types.
|
||||
"transport.delivery_evidence",
|
||||
"transport.prompt_observation",
|
||||
] as const;
|
||||
|
||||
export interface RetentionOptions {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -237,7 +237,7 @@ export class RunnerPermissions {
|
||||
"**The options the agent offered:**",
|
||||
...offered,
|
||||
"",
|
||||
"Your choice goes to this one request, once. A reply of just yes or no counts as the agent's allow or reject. Any other words choose nothing: OpenRig's default permits the request when the agent offered an allow option, and a runner that forwards your words passes them to the agent only after its turn. Closing this without choosing counts as no answer, and then OpenRig's default applies: the request is permitted. If the agent's turn ends first, your answer isn't sent.",
|
||||
"Your answer, a choice or your own words, goes to this host's receiving agent, which decides this one request while the agent waits: it acts on a clear answer at once, and asks you back when your words aren't clear. Where no agent can decide here, your answer is kept and the request waits, and nothing runs: decide directly with rig seat decide-permission, or set up a receiving agent. Closing this without answering counts as no answer, and then OpenRig's default applies: the request is permitted. If the agent's turn ends first, your answer isn't sent.",
|
||||
].join("\n"),
|
||||
});
|
||||
return { ok: true, askId, created: true };
|
||||
@@ -266,15 +266,31 @@ export class RunnerPermissions {
|
||||
return { ok: true, state: withdrawn ? "withdrawn" : "stale" };
|
||||
}
|
||||
if (!askIsCurrent || !callerMatches) return { ok: true, state: "stale" };
|
||||
const answer = item.humanAnswers?.[QUESTION_ID];
|
||||
const offered = item.humanQuestions?.find((question) => question.id === QUESTION_ID)?.options.map((option) => option.id) ?? [];
|
||||
if (typeof answer === "string" && offered.includes(answer)) return { ok: true, state: "answered", optionId: answer };
|
||||
// A plain no in words, or a deny from the Feed, is the agent's reject, and never a grant; a plain yes is its allow.
|
||||
// A request withdrawn after its row closed without a decision: it ended, so nothing decides it now.
|
||||
if (this.withdrawnAfterClose(item)) return { ok: true, state: "withdrawn" };
|
||||
// The person's answer, from the live row or back along the rows the request was handed through.
|
||||
const answer = this.personAnswer(item);
|
||||
// The options travel with the ask: a row handed to an agent to decide carries none of its own.
|
||||
const offered = this.deps.queueRepo.permissionQuestionsFor(item)?.find((question) => question.id === QUESTION_ID)?.options.map((option) => option.id) ?? [];
|
||||
const allowId = tagValue(item, ALLOW_TAG), rejectId = tagValue(item, REJECT_TAG);
|
||||
const said = item.closureReason === "denied" || item.state === "denied" ? "no" : typedYesOrNo(answer);
|
||||
if (said === "no") return rejectId ? { ok: true, state: "answered", optionId: rejectId } : { ok: true, state: "refused", reason: "said_no" };
|
||||
if (said === "yes" && allowId) return { ok: true, state: "answered", optionId: allowId };
|
||||
// Closed without an option is no answer, so OpenRig's default applies: permit.
|
||||
// A refusing close (denied) is the agent's reject, and never a grant.
|
||||
if (item.closureReason === "denied" || item.state === "denied") {
|
||||
return rejectId ? { ok: true, state: "answered", optionId: rejectId } : { ok: true, state: "refused", reason: "said_no" };
|
||||
}
|
||||
if (answer != null) {
|
||||
// An answer counts only through an explicit decision (`rig seat decide-permission`, or a person's own direct reply
|
||||
// where no agent is wired to decide). An ordinary close without one leaves it waiting: never a direct action from
|
||||
// a kept click or plain yes or no, and never the default permit over the person's answer.
|
||||
if (!this.explicitlyDecided(item)) return { ok: true, state: "pending" };
|
||||
if (typeof answer === "string" && offered.includes(answer)) return { ok: true, state: "answered", optionId: answer };
|
||||
// A plain no in words is the agent's reject, and never a grant; a plain yes is its allow.
|
||||
const said = typedYesOrNo(answer);
|
||||
if (said === "no") return rejectId ? { ok: true, state: "answered", optionId: rejectId } : { ok: true, state: "refused", reason: "said_no" };
|
||||
if (said === "yes" && allowId) return { ok: true, state: "answered", optionId: allowId };
|
||||
// Other words nobody decided on are kept: the request waits for an explicit decision, or the turn's end.
|
||||
return { ok: true, state: "pending" };
|
||||
}
|
||||
// Closed with no answer at all is no answer, so OpenRig's default applies: permit.
|
||||
if (!allowId) return { ok: true, state: "refused", reason: "no_allow_offered" };
|
||||
this.recordClosedAsk(asked, item, origin.nodeId, allowId);
|
||||
return { ok: true, state: "permitted", optionId: allowId };
|
||||
@@ -332,6 +348,28 @@ export class RunnerPermissions {
|
||||
}
|
||||
}
|
||||
|
||||
/** An agent's decision on a request its person answered (`rig seat decide-permission`): one of the options the agent
|
||||
* offered, recorded on the row that holds the request now, with who decided and why. A request that's no longer
|
||||
* pending (decided, withdrawn or stale) returns as it is and nothing is written, so deciding twice changes nothing. */
|
||||
decide(askId: string, optionId: string, decider: string, reason: string): ReadResult | { ok: false; code: "invalid_option" | "not_recorded"; message: string } {
|
||||
const now = this.read(null, askId);
|
||||
if (!now.ok || now.state !== "pending") return now;
|
||||
const asked = this.deps.queueRepo.getById(askId)!;
|
||||
const live = this.liveEnd(asked);
|
||||
if (!live) return now; // handed somewhere this host can't follow: nothing here to decide on
|
||||
const question = this.deps.queueRepo.permissionQuestionsFor(live)?.find((q) => q.id === QUESTION_ID);
|
||||
if (!question?.options.some((option) => option.id === optionId)) {
|
||||
return { ok: false, code: "invalid_option", message: `${optionId} isn't one of the options the agent offered: ${(question?.options ?? []).map((o) => o.id).join(", ") || "none recorded"}. Nothing was decided.` };
|
||||
}
|
||||
// The person's words as they wrote them, a plain yes or no included (the record says what they said).
|
||||
const typed = this.personAnswer(live) as { kind?: unknown; text?: unknown } | string | undefined;
|
||||
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. Read the request again.` };
|
||||
return this.read(null, askId);
|
||||
}
|
||||
|
||||
withdraw(seat: { nodeId: string; generation?: string } | null, askId: string, reason: string): WithdrawResult {
|
||||
if (!WITHDRAW_REASONS.has(reason)) {
|
||||
return { ok: false, code: "invalid_permission_request", message: "reason must be turn_cancelled, turn_ended or exited." };
|
||||
@@ -340,8 +378,11 @@ export class RunnerPermissions {
|
||||
if (!now.ok || now.state !== "pending") return now;
|
||||
const asked = this.deps.queueRepo.getById(askId)!;
|
||||
const live = this.liveEnd(asked);
|
||||
// A handoff this host can't follow leaves its rows as they are; the runner just stops waiting.
|
||||
if (live) this.close(live, WITHDRAWN_NOTE, reason);
|
||||
// A handoff this host can't follow leaves its rows as they are; the runner just stops waiting. A row closed without a
|
||||
// decision (its answer kept, so the request read pending) can't be canceled: the withdrawal is recorded on it instead.
|
||||
if (live && (live.state === "pending" || live.state === "in-progress" || live.state === "blocked")) this.close(live, WITHDRAWN_NOTE, reason);
|
||||
else if (live) this.deps.queueRepo.update({ qitemId: live.qitemId, actorSession: live.sourceSession, state: live.state,
|
||||
transitionNote: `${WITHDRAWN_NOTE} reason=${reason}: the runner's request ended, so any answer or decision here is not sent.` });
|
||||
return { ok: true, state: "withdrawn" };
|
||||
}
|
||||
|
||||
@@ -350,10 +391,44 @@ export class RunnerPermissions {
|
||||
private withWords(askId: string, result: ReadResult): ReadResult {
|
||||
if (!result.ok || !(result.state === "permitted" || (result.state === "refused" && result.reason === "no_allow_offered"))) return result;
|
||||
const asked = this.deps.queueRepo.getById(askId);
|
||||
const said = typedWords((asked ? this.liveEnd(asked) : null)?.humanAnswers?.[QUESTION_ID]);
|
||||
const live = asked ? this.liveEnd(asked) : null;
|
||||
const said = typedWords(live ? this.personAnswer(live) : undefined);
|
||||
return said ? { ...result, said } : result;
|
||||
}
|
||||
|
||||
/** Whether the runner's request was withdrawn after this row closed (recorded on the row, since it can't be canceled). */
|
||||
private withdrawnAfterClose(item: QueueItem): boolean {
|
||||
const transitions = this.deps.queueRepo.listTransitions(item.qitemId);
|
||||
for (let i = transitions.length - 1; i >= 0 && transitions[i]!.state === item.state; i--) {
|
||||
if (transitions[i]!.transitionNote?.startsWith(WITHDRAWN_NOTE)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Whether the row's close is an explicit decision: `rig seat decide-permission`'s, recorded on the row (also after an
|
||||
* agent's ordinary close), or a person's own direct reply where no agent is wired to decide. */
|
||||
private explicitlyDecided(item: QueueItem): boolean {
|
||||
const transitions = this.deps.queueRepo.listTransitions(item.qitemId);
|
||||
for (let i = transitions.length - 1; i >= 0 && transitions[i]!.state === item.state; i--) {
|
||||
const t = transitions[i]!;
|
||||
if (t.transitionNote?.startsWith("runner-permission-decided") || t.ownerNotificationKind === "human-decision-resolved") return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The answer the request holds: the live row's own, else the nearest one back along the rows it was handed through
|
||||
* (a person answered there, and the row was handed on to decide), so a person's no is never lost to a later close. */
|
||||
private personAnswer(item: QueueItem): unknown {
|
||||
let current: QueueItem | null = item;
|
||||
for (let hops = 0; current && hops <= MAX_HANDOFF_HOPS; hops++) {
|
||||
const answer = current.humanAnswers?.[QUESTION_ID];
|
||||
if (answer != null) return answer;
|
||||
current = current.handedOffFrom ? this.deps.queueRepo.getById(current.handedOffFrom) : null;
|
||||
if (current && !runnerPermissionOrigin(current)) return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** A person who routes or hands the ask to a colleague means the colleague decides: follow each handoff (a handed-off
|
||||
* row, or one completed with closure handed_off_to) along its own edge, to the row handed from it at the handed-to
|
||||
* address. Null when that can't be followed on this host. */
|
||||
|
||||
@@ -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>>;
|
||||
|
||||
@@ -72,32 +72,25 @@ 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
|
||||
* The supported route 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.
|
||||
* `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 in its own terminal. ${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)`;
|
||||
}
|
||||
|
||||
/** 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
|
||||
@@ -1427,13 +1420,12 @@ 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)}`),
|
||||
+ (externalCli ? "" : ` ${rigDownLeavesIt(seat.rigName)}`),
|
||||
};
|
||||
}
|
||||
const binding = this.sessionRegistry.getBindingForNode(nodeId);
|
||||
@@ -2292,7 +2284,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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -123,7 +130,9 @@ export class SeatStructuralActivityService {
|
||||
}
|
||||
const c = communityHarnessPaneActivity(classifyPaneActivity(content), communityHarness, content);
|
||||
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
|
||||
@@ -141,6 +150,8 @@ export class SeatStructuralActivityService {
|
||||
reason: 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 +159,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
|
||||
|
||||
@@ -6,7 +6,7 @@ import { currentClaudeThemeMenu } from "./claude-theme-menu.js";
|
||||
import { currentClaudeResumePicker } from "./native-resume-probe.js";
|
||||
import stringWidth from "string-width";
|
||||
import { CODEX_LIVE_WORKING_FRAME_PATTERN, CODEX_TURN_STATUS_PATTERN } from "./codex-pane-patterns.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { CLAUDE_NOT_STATUS_LINE_ROW, COLLAPSED_PASTE, composerRegion, isComposerFooter, isClaudePasteHint, inspectStartupStagedText, QUEUED_PLACEHOLDER, startupSubmissionEvidence, type StartupSubmissionEvidence } from "./startup-submission-evidence.js";
|
||||
import {
|
||||
composerContainsOwnedText,
|
||||
@@ -158,6 +158,14 @@ export interface PaneActivityClassification {
|
||||
|
||||
/** Refusal codes from the checks that run before Enter reaches the adapter (the seat's input guard, the expected
|
||||
* conversation and managed compaction): a written send that stopped with one left its text without Enter. */
|
||||
/** The written text's fingerprint, byte length and paste time, to match a harness's own prompt hook against; nulls when
|
||||
* nothing was pasted. The text itself is never recorded. */
|
||||
function wireFingerprint(wire: { text: string; at: string } | null): { wireSha256: string | null; wireBytes: number | null; writtenAt: string | null } {
|
||||
if (wire === null) return { wireSha256: null, wireBytes: null, writtenAt: null };
|
||||
const bytes = Buffer.from(wire.text, "utf8");
|
||||
return { wireSha256: createHash("sha256").update(bytes).digest("hex"), wireBytes: bytes.length, writtenAt: wire.at };
|
||||
}
|
||||
|
||||
function enterWithheld(reason: string | undefined): boolean {
|
||||
return typeof reason === "string" && (reason.startsWith("expected_identity_") || reason.startsWith("draft_") || reason === "managed_compaction_pending");
|
||||
}
|
||||
@@ -1974,8 +1982,9 @@ export interface SendOpts {
|
||||
* decision), with how the seat's level would treat each input now. It returns the members that still go with this send:
|
||||
* their text and outbox ids. Never accepted from HTTP. */
|
||||
recheckMembers?: (holds: (input: AdmissionInput) => boolean, held: boolean) => { text: string; outboxIds: string[] };
|
||||
/** Internal observation of a completed input effect; never a permission callback. */
|
||||
onInputEffect?: (phase: "pasted" | "submitted") => void;
|
||||
/** Internal observation of a completed input effect; never a permission callback. A paste reports the exact text it
|
||||
* wrote, for delivery evidence. */
|
||||
onInputEffect?: (phase: "pasted" | "submitted", written?: { text: string }) => void;
|
||||
/** Internal managed lifecycle prerequisite; never accepted from HTTP send options. */
|
||||
beforeWrite?: () => void;
|
||||
/** Explicit internal composer-preservation request. Managed compaction instead uses ordinary draft-delivery defaults.
|
||||
@@ -2684,8 +2693,14 @@ export class SessionTransport {
|
||||
private async sendWithoutCompactionNotice(sessionName: string, text: string, opts?: SendOpts): Promise<SendResult> {
|
||||
// The seat and occupant this send is for, frozen before it runs: a later handover doesn't relabel its evidence.
|
||||
const seat = this.evidenceSeat(sessionName);
|
||||
const result = await this.sendMarkingUrgent(sessionName, text, opts);
|
||||
this.recordDeliveryEvidence(sessionName, seat, opts, result);
|
||||
// The text the paste actually wrote (a terminal seat's exact text, a combined wake), not the text asked for.
|
||||
let wire: { text: string; at: string } | null = null;
|
||||
const observed: SendOpts | undefined = seat ? { ...opts, onInputEffect: (phase, written) => {
|
||||
if (phase === "pasted" && written) wire = { text: written.text, at: this.now().toISOString() };
|
||||
opts?.onInputEffect?.(phase, written);
|
||||
} } : opts;
|
||||
const result = await this.sendMarkingUrgent(sessionName, text, observed);
|
||||
this.recordDeliveryEvidence(sessionName, seat, opts, result, wire);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -2705,7 +2720,8 @@ export class SessionTransport {
|
||||
/** One additive record of what this send's result says, beside it. Best effort: a record that
|
||||
* can't be written never changes the result, holds the send or resends anything. A fact the result doesn't give is
|
||||
* unknown. Only an outbox row that exists counts as admitted. */
|
||||
private recordDeliveryEvidence(sessionName: string, seat: ReturnType<SessionTransport["evidenceSeat"]>, opts: SendOpts | undefined, result: SendResult): void {
|
||||
private recordDeliveryEvidence(sessionName: string, seat: ReturnType<SessionTransport["evidenceSeat"]>, opts: SendOpts | undefined, result: SendResult,
|
||||
wire: { text: string; at: string } | null): void {
|
||||
if (!this.eventBus || !seat) return;
|
||||
try {
|
||||
const outboxIds = opts?.committedOutboxIds ?? result.outboxIds ?? [];
|
||||
@@ -2719,6 +2735,7 @@ export class SessionTransport {
|
||||
pane: seat.pane, occupantGeneration: seat.occupant, messageId: opts?.deliveryId ?? outboxIds[0] ?? attemptId, attemptId,
|
||||
admitted: admissionUnknown ? "unknown" : admitted.length > 0 ? { outboxIds: admitted } : "none", written: writtenFact(result),
|
||||
terminalObserved: { outcome: result.outcome ?? null, verified: result.verified ?? null }, inputBoundary: "unknown", source: "send_result",
|
||||
...wireFingerprint(wire),
|
||||
});
|
||||
} catch (err) {
|
||||
if (!this.evidenceFailureLogged) {
|
||||
@@ -3723,7 +3740,7 @@ export class SessionTransport {
|
||||
...(waitMode || policyRefusal ? { sent: false, ...waitEvidence } : {}),
|
||||
});
|
||||
}
|
||||
try { opts?.onInputEffect?.("pasted"); } catch { /* Observation cannot change a completed paste. */ }
|
||||
try { opts?.onInputEffect?.("pasted", { text }); } catch { /* Observation cannot change a completed paste. */ }
|
||||
|
||||
// From the successful paste to Enter the text may sit in the seat's input. Any throw in this span is
|
||||
// tagged inputWritten, so no caller reports it as unsent; a step added here later stays covered.
|
||||
@@ -4165,9 +4182,11 @@ export class SessionTransport {
|
||||
hookActivity.reason !== "permission_request_pending" &&
|
||||
hookActivity.reason !== "permission_prompt_cleared"
|
||||
) {
|
||||
// Fresh hook (within the 15s send window): authoritative for any state.
|
||||
// Fresh hook (within the 15s send window): authoritative for any state, except a current Claude
|
||||
// dialog. Opening a picker or a permission selector after Stop fires no new hook, so a fresh idle
|
||||
// hook gets the same current-dialog check as a fresh running one.
|
||||
if (this.hookFreshForSend(hookActivity, now)) {
|
||||
if (hookActivity.state === "running" && input.runtime === "claude-code") {
|
||||
if ((hookActivity.state === "running" || hookActivity.state === "idle") && input.runtime === "claude-code") {
|
||||
const paneVeto = await probeSessionActivity({
|
||||
communityHarness, communityHarnessSendProbe: true,
|
||||
composerSnapshot,
|
||||
@@ -4180,7 +4199,9 @@ export class SessionTransport {
|
||||
captureObserver: privateInput ? undefined : this.captureObserver,
|
||||
binding,
|
||||
});
|
||||
if (paneVeto.state === "needs_input") return paneVeto;
|
||||
// After an idle hook only a dialog refuses: a draft or other input stays the delivery guard's.
|
||||
if (paneVeto.state === "needs_input" && (hookActivity.state === "running"
|
||||
|| paneVeto.reason === "selection_prompt" || paneVeto.reason === "permission_prompt")) return paneVeto;
|
||||
}
|
||||
return hookActivity;
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -189,14 +189,22 @@ export type RigEvent =
|
||||
| { type: "agent.activity"; rigId: string; nodeId: string; sessionName: string; runtime: string | null; activity: AgentActivity }
|
||||
/** What one send's own result says was admitted, written and seen on screen, for the seat and
|
||||
* occupant it was sent to. Additive: it sits beside the send's result and never rewrites it. `written` is what the
|
||||
* result shows reached the input; `terminalObserved` is today's screen check, verbatim; `inputBoundary` is unknown
|
||||
* until a harness hook is correlated. Never acceptance, a claim or completion. It names no node, so a node's own event
|
||||
* result shows reached the input; `terminalObserved` is today's screen check, verbatim; `inputBoundary` is unknown:
|
||||
* no harness here reports a prompt's origin, so no hook attributes one. Never acceptance, a claim or completion. It names no node, so a node's own event
|
||||
* reads never see it: the session and occupant generation name the seat. */
|
||||
| { type: "transport.delivery_evidence"; sessionName: string; seatKind: "tmux" | "external_cli" | "unknown";
|
||||
pane: string | null; occupantGeneration: string | null; messageId: string; attemptId: string;
|
||||
admitted: DeliveryEvidenceAdmitted;
|
||||
written: "submitted" | "submit_only" | "text_only" | "text_submit_unknown" | "uncertain" | "none" | "unknown";
|
||||
terminalObserved: DeliveryEvidenceObserved; inputBoundary: "unknown"; source: "send_result" }
|
||||
terminalObserved: DeliveryEvidenceObserved; inputBoundary: "unknown"; source: "send_result";
|
||||
wireSha256: string | null; wireBytes: number | null; writtenAt: string | null }
|
||||
/** A harness's own prompt hook, as it reported: a fingerprint and byte length of the prompt it received, its own session
|
||||
* and prompt or turn id, and the generation the hook ran as. A raw observation, never a message's attribution: neither
|
||||
* harness reports a prompt's origin, so which message (if any) it was isn't established, and each delivery record's
|
||||
* input boundary stays unknown. It names no node. */
|
||||
| { type: "transport.prompt_observation"; sessionName: string; occupantGeneration: string | null; runtime: string | null;
|
||||
source: "UserPromptSubmit"; nativeSessionId: string | null; nativePromptId: string | null; nativeTurnId: string | null;
|
||||
promptSha256: string; promptBytes: number; observedAt: string | null }
|
||||
// OPR.0.4.1.10 — audit record for a --dangerously-interact send that drove a target's interactive
|
||||
// prompt / permission block. overrideReason = caller's --reason; detectedReason/evidenceSource =
|
||||
// what the classifier saw (kept distinct so permission_prompt vs selection_prompt vs unknown stays visible).
|
||||
|
||||
@@ -4,7 +4,7 @@ import { join, isAbsolute } from "node:path";
|
||||
import { getOpenRigHome } from "../../openrig-compat.js";
|
||||
import { SETTINGS_VALID_KEYS, type SettingsStore, type ResolvedSetting } from "./settings-store.js";
|
||||
import { connectionsProjection, readConnectionConfiguration } from "../gateway/connections-projection.js";
|
||||
import { DEFAULT_CONFIG, INHERIT_INBOUND_DESTINATION } from "../gateway/slack/config.js";
|
||||
import { DEFAULT_CONFIG, INHERIT_INBOUND_DESTINATION, LEGACY_INBOUND_DESTINATION } from "../gateway/slack/config.js";
|
||||
import { projectionPath } from "../gateway/human-registry.js";
|
||||
import { loadHostRegistry } from "../hosts/hosts-registry-reader.js";
|
||||
import { DEFAULT_HEALTH_POLICY, validateHealthPolicy } from "../health-policy.js";
|
||||
@@ -144,9 +144,14 @@ export function settingsBrowser(store: SettingsStore, gateway: Record<string, un
|
||||
// Derived, not a setting: the agent that receives answers nothing else reads, from agents.operator_session or the
|
||||
// kernel operator. Shown with where it came from and its state; changed with rig config set agents.operator_session.
|
||||
if (receiver) {
|
||||
// The description names the session and can quote the setting: when either is withheld, so is the description.
|
||||
const unsafe = [receiver.session, resolved?.["agents.operator_session"]?.value]
|
||||
.some((v) => typeof v === "string" && v !== "" && safeValue(v, "identity", redact).value === null);
|
||||
add("agents.default_receiver", "general", receiver.session ?? "", undefined,
|
||||
receiver.source === "setting" ? resolved?.["agents.operator_session"]?.source ?? "unreported" : "default", "identity",
|
||||
"Displayed daemon instance", describeDefaultReceiver(receiver));
|
||||
"Displayed daemon instance", unsafe
|
||||
? "Withheld: agents.operator_session holds credential-bearing or unsafe text, so the receiving agent isn't described here. Inspect it at its source."
|
||||
: describeDefaultReceiver(receiver));
|
||||
}
|
||||
if (resolved) {
|
||||
for (const host of store.listFeedHostSubscriptions()) {
|
||||
@@ -171,7 +176,13 @@ export function settingsBrowser(store: SettingsStore, gateway: Record<string, un
|
||||
const unchosen = key === "inboundDestination" && (!cfg || cfg[key] === INHERIT_INBOUND_DESTINATION);
|
||||
add("slack." + key, "slack", cfg ? flatten(cfg[key]) : null, flatten(defs[key]),
|
||||
cfg ? read.fields.includes(key) ? "file" : "default" : "unavailable", kind, undefined,
|
||||
unchosen ? "Not chosen: when the connector starts, unrouted messages go to the default receiving agent (Agents & runtime)." : undefined);
|
||||
unchosen ? receiver?.session
|
||||
? "Not chosen: when the connector starts, unrouted messages go to the default receiving agent (Agents & runtime)."
|
||||
// A receiver that couldn't be read (or wasn't resolved here) is unknown, not absent.
|
||||
: receiver && receiver.state !== "unknown"
|
||||
? `Not chosen: this host has no receiving agent (Agents & runtime), so when the connector starts, unrouted messages go to ${LEGACY_INBOUND_DESTINATION} for compatibility, as before.`
|
||||
: `Not chosen: OpenRig couldn't tell whether this host has a receiving agent (Agents & runtime). If it still can't when the connector starts, unrouted messages go to ${LEGACY_INBOUND_DESTINATION} for compatibility, as before.`
|
||||
: undefined);
|
||||
}
|
||||
// #192: the channel map as one read-only line, "match: channel" per entry.
|
||||
add("slack.channelMap", "slack", read.cfg ? c.configuration!.channelMap.map((e) => `${e.match ?? "withheld"}: ${e.channel ?? "withheld"}`).join(", ") : 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 {
|
||||
|
||||
@@ -733,6 +733,7 @@ function recordHookActivity(c: Context, store: AgentActivityStore, body: Record<
|
||||
const status = result.code === "missing_session_identity" ? 400 : 404;
|
||||
return { refused: c.json({ ok: false, code: result.code, error: result.error }, status) };
|
||||
}
|
||||
recordPromptObservation(c, body, (result.event as { sessionName?: string } | undefined)?.sessionName ?? stringOrNull(body.sessionName));
|
||||
|
||||
// S19 A4 — feed the ONE oracle through the adapter seam: the recorded (store-
|
||||
// normalized) event becomes ladder evidence on the lifecycle-hooks rung. The store
|
||||
@@ -815,6 +816,33 @@ for (const [router, installTokenAccepted] of [[activityRoutes, true], [guestActi
|
||||
router.post("/permission-requests/:askId/withdraw", (c) => withdrawPermission(c, installTokenAccepted));
|
||||
}
|
||||
|
||||
// The receiving agent's decision on a request its person answered (`rig seat decide-permission`): on this host's main
|
||||
// routes only. The record names the deciding seat (X-OpenRig-Session), which is attribution, not authentication.
|
||||
activityRoutes.post("/permission-requests/:askId/decide", async (c) => {
|
||||
const store = c.get("agentActivityStore" as never) as AgentActivityStore | undefined;
|
||||
const queueRepo = c.get("queueRepo" as never) as QueueRepository | undefined;
|
||||
const eventBus = c.get("eventBus" as never) as EventBus | undefined;
|
||||
if (!store || !queueRepo || !eventBus) {
|
||||
return c.json({ ok: false, code: "permission_requests_unconfigured", error: "This daemon doesn't take runner permission requests." }, 503);
|
||||
}
|
||||
let body: Record<string, unknown>;
|
||||
try { body = await c.req.json() as Record<string, unknown>; } catch {
|
||||
return c.json({ ok: false, code: "invalid_json", error: "Request body must be JSON." }, 400);
|
||||
}
|
||||
const optionId = typeof body.optionId === "string" ? body.optionId.trim() : "";
|
||||
if (!optionId) return c.json({ ok: false, code: "invalid_option", error: "Name the option to decide: optionId." }, 400);
|
||||
const registry = (c.get("sessionRegistry" as never) as SessionRegistry | undefined) ?? new SessionRegistry(store.db);
|
||||
const rigRepo = (c.get("rigRepo" as never) as RigRepository | undefined) ?? new RigRepository(store.db);
|
||||
const permissions = new RunnerPermissions({ db: store.db, queueRepo, eventBus, humanRegistry: () => queueRepo.humanRegistry(),
|
||||
ownerOf: (nodeId) => rigRepo.getNodePermissionsTo(nodeId),
|
||||
currentGeneration: (nodeId) => registry.currentOccupantTenure(nodeId)?.generationUuid ?? null,
|
||||
stream: c.get("streamStore" as never) as StreamStore | undefined });
|
||||
const decider = transportSenderSession(c) ?? "someone outside a seat";
|
||||
const result = permissions.decide(c.req.param("askId") ?? "", optionId, decider, typeof body.reason === "string" ? body.reason.trim() : "");
|
||||
if (!result.ok) return c.json({ ok: false, code: result.code, error: result.message }, result.code === "not_found" ? 404 : result.code === "not_recorded" ? 409 : 400);
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
type PermissionContext = { store: AgentActivityStore; grant: ActivityGrant | null; registry: SessionRegistry; permissions: RunnerPermissions };
|
||||
|
||||
function permissionContext(c: Context, installTokenAccepted: boolean): PermissionContext | { refused: Response } {
|
||||
@@ -1073,6 +1101,28 @@ activityRoutes.get("/parked", (c) => {
|
||||
});
|
||||
});
|
||||
|
||||
/** A prompt hook's own observation, recorded as it was reported: a fingerprint and byte length of the prompt the harness
|
||||
* received, its native session and prompt or turn id, and the generation the hook ran as. It's raw evidence only: neither
|
||||
* harness reports where a prompt came from (a message OpenRig wrote, a person's own text, a background turn), so it's never
|
||||
* joined to a message here. A missing or malformed fingerprint records nothing; a bad id or time is left out. The hook
|
||||
* itself is recorded and answered either way. */
|
||||
function recordPromptObservation(c: Context, body: Record<string, unknown>, sessionName: string | null): void {
|
||||
if (body.hookEvent !== "UserPromptSubmit") return;
|
||||
const eventBus = c.get("eventBus" as never) as EventBus | undefined;
|
||||
const promptSha256 = typeof body.promptSha256 === "string" && /^[0-9a-f]{64}$/.test(body.promptSha256) ? body.promptSha256 : null;
|
||||
const promptBytes = typeof body.promptBytes === "number" && Number.isSafeInteger(body.promptBytes) && body.promptBytes >= 0 ? body.promptBytes : null;
|
||||
if (!eventBus || !sessionName || !promptSha256 || promptBytes === null) return;
|
||||
const nativeId = (value: unknown) => typeof value === "string" && /^[A-Za-z0-9_.:-]{1,128}$/.test(value) ? value : null;
|
||||
// An instant, not a string to compare: a time that doesn't parse is left out.
|
||||
const at = typeof body.occurredAt === "string" ? Date.parse(body.occurredAt) : Number.NaN;
|
||||
try {
|
||||
eventBus.emit({ type: "transport.prompt_observation", sessionName, occupantGeneration: stringOrNull(body.generation),
|
||||
runtime: stringOrNull(body.runtime), source: "UserPromptSubmit", nativeSessionId: nativeId(body.sessionId),
|
||||
nativePromptId: nativeId(body.nativePromptId), nativeTurnId: nativeId(body.turnId), promptSha256, promptBytes,
|
||||
observedAt: Number.isFinite(at) ? new Date(at).toISOString() : null });
|
||||
} catch { /* Best effort: the hook is recorded either way. */ }
|
||||
}
|
||||
|
||||
function stringOrNull(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ const REPLAY_PAGE = 500;
|
||||
|
||||
/** Event types this stream doesn't carry, live or replayed: one-per-send diagnostics the log keeps for their own readers,
|
||||
* which would otherwise fill a viewer's newest-N replay and its cache. */
|
||||
const OMITTED_TYPES: readonly string[] = ["transport.delivery_evidence"];
|
||||
const OMITTED_TYPES: readonly string[] = ["transport.delivery_evidence", "transport.prompt_observation"];
|
||||
|
||||
function getEventBus(c: { get: (key: string) => unknown }): EventBus {
|
||||
return c.get("eventBus" as never) as EventBus;
|
||||
|
||||
@@ -335,7 +335,9 @@ export function hostsRoutes(opts?: { bearerToken?: string | null; humanRegistry?
|
||||
// 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];
|
||||
// `--` before the target, the same separator the CLI twin and the executor pass: a
|
||||
// destination that begins with `-` is read as the host, not as an option.
|
||||
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);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { EventBus } from "../domain/event-bus.js";
|
||||
import { listProjects, selectedProject, projectMission, workSource, projectReadResponse } from "../domain/workspace/project-read.js";
|
||||
import { listProjects, selectedProject, projectMission, workSource, insideProject, projectReadResponse } from "../domain/workspace/project-read.js";
|
||||
import { inferCatalogProject, ProjectReadError } from "../domain/workspace/project-catalog.js";
|
||||
// Slice Story View v0 — HTTP routes.
|
||||
//
|
||||
@@ -166,15 +166,20 @@ export function slicesRoutes(): Hono {
|
||||
const abs = deps.projector.resolveProofAssetPath(slice.proofPacket, relPath);
|
||||
if (!abs) return c.json({ error: "proof_asset_not_found" }, 404);
|
||||
|
||||
let servedPath = abs;
|
||||
if (selected.qualified) {
|
||||
try { servedPath = insideProject(selected.projectRoot, abs); }
|
||||
catch (err) { return projectReadResponse(err); }
|
||||
}
|
||||
const contentType = inferContentType(abs);
|
||||
return fileAssetResponse(abs, contentType, c.req.header("Range"));
|
||||
return fileAssetResponse(servedPath, contentType, c.req.header("Range"));
|
||||
});
|
||||
|
||||
// 3) Doc serving for the Docs tab — markdown content of a single file
|
||||
// inside the slice folder. Path-traversal guarded by the projector.
|
||||
app.get("/:name/doc/*", (c) => {
|
||||
const name = c.req.param("name");
|
||||
const selected = selectSliceDeps(c, name);
|
||||
const selected = selectSliceDeps(c, name, { includeEvidenceRoot: false });
|
||||
if ("response" in selected) return selected.response;
|
||||
const { deps } = selected;
|
||||
const fullPath = c.req.path;
|
||||
@@ -205,10 +210,39 @@ export function slicesRoutes(): Hono {
|
||||
return app;
|
||||
}
|
||||
|
||||
const UNAVAILABLE_EVIDENCE_ROOT_CODES = new Set([
|
||||
"ENOENT",
|
||||
"EACCES",
|
||||
"EPERM",
|
||||
"ENOTDIR",
|
||||
"ELOOP",
|
||||
"EIO",
|
||||
"ENAMETOOLONG",
|
||||
"ESTALE",
|
||||
]);
|
||||
|
||||
export function qualifiedEvidenceRoot(projectRoot: string, missionsRoot: string): string | null {
|
||||
const candidate = path.join(path.dirname(missionsRoot), "dogfood-evidence");
|
||||
const relative = path.relative(path.resolve(projectRoot), path.resolve(candidate));
|
||||
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return null;
|
||||
try {
|
||||
const resolved = insideProject(projectRoot, candidate);
|
||||
fs.accessSync(resolved, fs.constants.R_OK | fs.constants.X_OK);
|
||||
fs.readdirSync(resolved);
|
||||
return resolved;
|
||||
} catch (err) {
|
||||
if (err instanceof ProjectReadError && err.code === "project_path_escape") return null;
|
||||
const code = (err as NodeJS.ErrnoException | null | undefined)?.code;
|
||||
if (typeof code === "string" && UNAVAILABLE_EVIDENCE_ROOT_CODES.has(code)) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function selectSliceDeps(
|
||||
c: Context,
|
||||
name: string,
|
||||
): { deps: SlicesRoutesDeps; qualified: boolean } | { response: Response } {
|
||||
opts: { includeEvidenceRoot?: boolean } = {},
|
||||
): { deps: SlicesRoutesDeps; qualified: false } | { deps: SlicesRoutesDeps; qualified: true; projectRoot: string } | { response: Response } {
|
||||
let deps = getDeps(c);
|
||||
if (!deps) return { response: c.json({ error: "slices_indexer_unavailable" }, 503) };
|
||||
try {
|
||||
@@ -218,9 +252,15 @@ function selectSliceDeps(
|
||||
if (!mission || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(name)) return { response: c.json({ error: "exact_mission_and_slice_required" }, 400) };
|
||||
const dir = projectMission(project, mission);
|
||||
workSource(project.root, path.join(dir, "slices", name));
|
||||
const indexer = new SliceIndexer({ db: deps.indexer.db, slicesRoot: project.missionsRoot, dogfoodEvidenceRoot: null, projectId: project.id, missionId: mission });
|
||||
const indexer = new SliceIndexer({
|
||||
db: deps.indexer.db,
|
||||
slicesRoot: project.missionsRoot,
|
||||
dogfoodEvidenceRoot: (opts.includeEvidenceRoot ?? true) ? qualifiedEvidenceRoot(project.root, project.missionsRoot) : null,
|
||||
projectId: project.id,
|
||||
missionId: mission,
|
||||
});
|
||||
deps = { indexer, projector: deps.projector.withIndexer(indexer) };
|
||||
return { deps, qualified: true };
|
||||
return { deps, qualified: true, projectRoot: project.root };
|
||||
} catch (err) { return { response: projectReadResponse(err) }; }
|
||||
}
|
||||
|
||||
|
||||
@@ -46,10 +46,12 @@ export function specLibraryRoutes(): Hono {
|
||||
const folder = c.get("workflowsFolderDir" as never) as string | undefined;
|
||||
const eventBus = c.get("eventBus" as never) as EventBus | undefined;
|
||||
const aliases: Array<{ name: string; version: string; sourcePath: string; sourceHash: string }> = [];
|
||||
const verifiedSourceHashes = new Map<string, string>();
|
||||
if (cache && folder) {
|
||||
try {
|
||||
scanWorkflowSpecFolder({ db, cache, folder, builtinDir: builtinDir ?? null, eventBus,
|
||||
onBuiltinAlias: (name, version, sourcePath, sourceHash) => aliases.push({ name, version, sourcePath, sourceHash }) });
|
||||
onBuiltinAlias: (name, version, sourcePath, sourceHash) => aliases.push({ name, version, sourcePath, sourceHash }),
|
||||
onVerifiedSource: (sourcePath, sourceHash) => verifiedSourceHashes.set(sourcePath, sourceHash) });
|
||||
} catch { /* best-effort — folder scan failure must not break Library list */ }
|
||||
}
|
||||
// Re-scan workflow specs on each list request — cheap (single
|
||||
@@ -70,9 +72,11 @@ export function specLibraryRoutes(): Hono {
|
||||
// Keep a source-specific link valid when an identical copy is edited and
|
||||
// becomes the cache's own user-file row. The normal row is listed once.
|
||||
if (folder && cache) {
|
||||
const sourceHashes = new Map<string, string>();
|
||||
// Reuse the scanner's persisted/stat-validated hash for ordinary user files. Alias copies intentionally keep
|
||||
// their separate two-file byte check above because they have no persisted row of their own.
|
||||
const sourceHashes = verifiedSourceHashes;
|
||||
for (const entry of [...workflowEntries]) {
|
||||
if (entry.sourceType !== "user_file" || entry.status === "error" || dirname(entry.sourcePath) !== folder) continue;
|
||||
if (entry.id.startsWith("workflow-source:") || entry.sourceType !== "user_file" || entry.status === "error" || dirname(entry.sourcePath) !== folder) continue;
|
||||
try {
|
||||
let sourceHash = sourceHashes.get(entry.sourcePath);
|
||||
if (sourceHash === undefined) {
|
||||
|
||||
@@ -132,6 +132,7 @@ import { SlowOpRecorder, type SlowOperationInstrumentation } from "./domain/slow
|
||||
import { configureSyncSiteRecorder } from "./domain/sync-site-wrap.js";
|
||||
import { RequestPhaseObserver } from "./domain/request-phase-observer.js";
|
||||
import { QueueRepository, isBlockerLive } from "./domain/queue-repository.js";
|
||||
import { describeDefaultReceiver, permissionDeciderFor } from "./domain/default-receiver.js";
|
||||
import { createWorkflowFrontierPredicate } from "./domain/workflow-frontier-guard.js";
|
||||
import { InboxHandler } from "./domain/inbox-handler.js";
|
||||
import { OutboxHandler } from "./domain/outbox-handler.js";
|
||||
@@ -478,6 +479,11 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
resolveSessionAddresses: (sessionName) => sessionRegistry.sessionAddresses(sessionName),
|
||||
// #2575: a prompt-clear wake retry waits for a managed compaction's owed steps, as parked-owner reminders do.
|
||||
hasPendingPostCompactStage: (sessionName) => deps.compactionEnforcer?.hasPendingPostCompactStage(sessionName) ?? false,
|
||||
// A runner's permission ask its person answered goes to the receiving agent to decide, never to the requester itself.
|
||||
permissionDecider: (_ask, requester) => {
|
||||
const decider = permissionDeciderFor(db, requester);
|
||||
return decider?.session ? { session: decider.session, describe: describeDefaultReceiver(decider) } : null;
|
||||
},
|
||||
});
|
||||
// W1 (transactional closure): ONE OutboxHandler shared between the deps slot
|
||||
// (sender-side audit surface) and the queue repo's durable wake-intent
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createRequire } from "node:module";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import nodePath from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const relay = require("../assets/plugins/openrig-core/hooks/scripts/activity-relay.cjs") as {
|
||||
@@ -94,3 +95,24 @@ describe("activity-relay occupant generation carry (W2a producer)", () => {
|
||||
expect(payload).toHaveProperty("generation", null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("activity-relay prompt fingerprint at UserPromptSubmit", () => {
|
||||
const identity = { OPENRIG_SESSION_NAME: "dev-impl@r", OPENRIG_RUNTIME: "claude-code", OPENRIG_OCCUPANT_GENERATION: "gen-1" };
|
||||
const at = () => new Date("2026-11-11T03:00:00.000Z");
|
||||
|
||||
it("carries the prompt's fingerprint, byte length and Claude's prompt id, never the prompt", () => {
|
||||
const payload = relay.buildOpenRigPayload({ hook_event_name: "UserPromptSubmit", prompt: "héllo, world", prompt_id: "p-1", session_id: "s-1" }, identity, at)!;
|
||||
expect(payload).toMatchObject({
|
||||
hookEvent: "UserPromptSubmit", generation: "gen-1", nativePromptId: "p-1", promptBytes: Buffer.byteLength("héllo, world"),
|
||||
promptSha256: createHash("sha256").update("héllo, world").digest("hex"),
|
||||
});
|
||||
expect(JSON.stringify(payload)).not.toContain("world");
|
||||
});
|
||||
|
||||
it("adds nothing for another event, a prompt that isn't a string, or a malformed prompt id", () => {
|
||||
expect(relay.buildOpenRigPayload({ hook_event_name: "Stop", prompt: "x" }, identity, at)).not.toHaveProperty("promptSha256");
|
||||
expect(relay.buildOpenRigPayload({ hook_event_name: "UserPromptSubmit", prompt: 7 }, identity, at)).not.toHaveProperty("promptSha256");
|
||||
expect(relay.buildOpenRigPayload({ hook_event_name: "UserPromptSubmit", prompt: "x", prompt_id: "not ok/../" }, identity, at))
|
||||
.not.toHaveProperty("nativePromptId");
|
||||
});
|
||||
});
|
||||
@@ -221,9 +221,17 @@ describe("auth-bearer-token middleware (PL-005 Phase B)", () => {
|
||||
|
||||
// Scenario 7: DNS-fails hostname, no bearer → THROW
|
||||
it("(7) DNS-unresolvable hostname with no bearer → THROW (HG-4)", async () => {
|
||||
await expect(
|
||||
assertBindAuthInvariant({ host: "this-host-does-not-exist.invalid", bearerToken: null }),
|
||||
).rejects.toThrow(AuthBearerTokenStartupError);
|
||||
const lookup = vi.spyOn(dns.promises, "lookup").mockRejectedValue(
|
||||
Object.assign(new Error("getaddrinfo ENOTFOUND this-host-does-not-exist.invalid"), { code: "ENOTFOUND" }),
|
||||
);
|
||||
try {
|
||||
await expect(
|
||||
assertBindAuthInvariant({ host: "this-host-does-not-exist.invalid", bearerToken: null }),
|
||||
).rejects.toThrow(AuthBearerTokenStartupError);
|
||||
expect(lookup).toHaveBeenCalledWith("this-host-does-not-exist.invalid", { all: false });
|
||||
} finally {
|
||||
lookup.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
// Existing-behavior preservation
|
||||
|
||||
@@ -25,6 +25,10 @@ describe("seat-stop.v1 checked stop", () => {
|
||||
let onDetach: (() => void) | undefined;
|
||||
let onKill: (() => void) | undefined;
|
||||
let killError: Error | undefined;
|
||||
let named: string | undefined;
|
||||
// A stop's effect, held inside tmux to the session id and server it read.
|
||||
const held = (inner: string) => "tmux if-shell -F -t '$7' '#{&&:#{==:#{session_id},$7},#{&&:#{==:#{pid},4242},#{==:#{start_time},1700000000}}}' "
|
||||
+ `'${inner.replace(/'/g, "'\"'\"'")}' 'display-message -p openrig-stop-target-changed'`;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createFullTestDb();
|
||||
@@ -34,13 +38,19 @@ describe("seat-stop.v1 checked stop", () => {
|
||||
registry = new SessionRegistry(db);
|
||||
bus = new EventBus(db);
|
||||
commands = [];
|
||||
named = undefined;
|
||||
onProbe = onPanes = onDetach = onKill = undefined;
|
||||
killError = undefined;
|
||||
adapter = new TmuxAdapter(async command => {
|
||||
commands.push(command);
|
||||
if (command.includes("has-session")) { onProbe?.(); return ""; }
|
||||
if (command.includes("list-panes")) { onPanes?.(); return "%1|0|/project|120|40|1"; }
|
||||
if (command.includes("display-message")) return "$7";
|
||||
if (command.includes("list-panes")) { onPanes?.(); named = /=([^:']+):/.exec(command)?.[1] ?? named; return "%1|0|/project|120|40|1"; }
|
||||
// The named session carries its own seat's OpenRig marker.
|
||||
if (command.startsWith("tmux show-environment")) {
|
||||
const bound = db.prepare("SELECT node_id FROM bindings WHERE tmux_session = ?").get(named) as { node_id: string } | undefined;
|
||||
return `OPENRIG_NODE_ID=${bound?.node_id ?? ""}`;
|
||||
}
|
||||
if (command.startsWith("tmux display-message")) return "$7|4242|1700000000";
|
||||
if (command.includes("detach-client")) { onDetach?.(); return ""; }
|
||||
if (command.includes("kill-session")) { onKill?.(); if (killError) throw killError; return ""; }
|
||||
throw new Error(`unexpected tmux command: ${command}`);
|
||||
@@ -129,7 +139,7 @@ describe("seat-stop.v1 checked stop", () => {
|
||||
const result = await stop(a.name, expected(a.name));
|
||||
expect(result).toMatchObject({ ok: true, sessionName: a.name, sessionId: a.session.id });
|
||||
expect(commands.filter(command => command.includes("detach-client") || command.includes("kill-session"))).toEqual([
|
||||
"tmux detach-client -s '$7'", "tmux kill-session -t '$7'",
|
||||
held("detach-client -s '$7'"), held("kill-session -t '$7'"),
|
||||
]);
|
||||
expect(status(a.session.id)).toBe("exited");
|
||||
expect(registry.getBindingForNode(a.node.id)).toBeNull();
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { SessionTransport } from "../src/domain/session-transport.js";
|
||||
import { AgentActivityStore } from "../src/domain/agent-activity-store.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
import { createFullTestDb } from "./helpers/test-app.js";
|
||||
import type { TmuxAdapter } from "../src/adapters/tmux.js";
|
||||
|
||||
// A person can open a Claude dialog (a permission selector, a picker) in the seconds after the seat's Stop hook, and
|
||||
// opening it fires no new hook. An ordinary send then mustn't type into it.
|
||||
const border = "────────────────────────────────────────────";
|
||||
const framed = (prompt: string) => ["● Done.", border, prompt, border, "⏵⏵ accept edits on (shift+tab to cycle) · ← for agents"].join("\n");
|
||||
const permission = "Do you want to proceed?\n❯ 1. Yes\n 2. No\nEnter to select · ↑/↓ to navigate · Esc to cancel";
|
||||
|
||||
async function sendAfterHook(event: "Stop" | "UserPromptSubmit", pane: string) {
|
||||
const db = createFullTestDb();
|
||||
try {
|
||||
const repo = new RigRepository(db), registry = new SessionRegistry(db), rig = repo.createRig("idle-dialog");
|
||||
const node = repo.addNode(rig.id, "worker.a", { runtime: "claude-code", role: "worker" });
|
||||
const name = "worker-a@idle-dialog";
|
||||
const session = registry.registerSession(node.id, name);
|
||||
registry.updateStatus(session.id, "running");
|
||||
registry.updateBinding(node.id, { tmuxSession: name });
|
||||
const generation = registry.currentOccupantTenure(node.id)!.generationUuid;
|
||||
const now = new Date("2026-11-11T03:40:00Z");
|
||||
const store = new AgentActivityStore({ db, eventBus: new EventBus(db), now: () => now,
|
||||
resolveOccupantGeneration: (id) => registry.currentOccupantTenure(id)?.generationUuid ?? null,
|
||||
isRegisteredOccupantGeneration: (id, g) => Boolean(db.prepare(
|
||||
"SELECT 1 FROM occupant_tenures WHERE node_id=? AND generation_uuid=?").get(id, g)),
|
||||
});
|
||||
expect(store.recordHookEvent({ runtime: "claude-code", sessionName: name, hookEvent: event, generation,
|
||||
occurredAt: new Date(now.getTime() - 1_000).toISOString() }).ok).toBe(true);
|
||||
const paste = vi.fn(async () => ({ ok: true as const })), keys = vi.fn(async () => ({ ok: true as const }));
|
||||
const capture = vi.fn(async () => pane);
|
||||
const adapter = { hasSession: async () => true, probeSession: async () => ({ state: "present" as const }),
|
||||
getPaneCommand: async () => "claude", getPanePid: async () => null, listPanes: async () => [],
|
||||
hasSessionEnv: async () => false, capturePaneContent: capture, sendText: paste, sendKeys: keys,
|
||||
} as unknown as TmuxAdapter;
|
||||
const transport = new SessionTransport({ db, rigRepo: repo, sessionRegistry: registry, tmuxAdapter: adapter,
|
||||
agentActivityStore: store, now: () => now, sleep: async () => undefined, activityEndpointFile: () => null });
|
||||
const result = await transport.send(name, "Ordinary synthetic peer message");
|
||||
return { result, paste: paste.mock.calls.length, keys: keys.mock.calls.length, captures: capture.mock.calls.length };
|
||||
} finally { db.close(); }
|
||||
}
|
||||
|
||||
describe("a fresh idle hook doesn't let a send type into a current Claude dialog", () => {
|
||||
it("a permission selector opened after Stop refuses the send, with nothing typed or pressed", async () => {
|
||||
const sent = await sendAfterHook("Stop", permission);
|
||||
expect(sent.result).toMatchObject({ ok: false, reason: "target_needs_input" });
|
||||
expect(sent.paste).toBe(0);
|
||||
expect(sent.keys).toBe(0);
|
||||
expect(sent.captures).toBe(1);
|
||||
});
|
||||
|
||||
it("an empty composer after Stop still sends", async () => {
|
||||
const sent = await sendAfterHook("Stop", framed("❯ "));
|
||||
expect(sent.result.ok).toBe(true);
|
||||
expect(sent.paste).toBe(1);
|
||||
});
|
||||
|
||||
it("a draft after Stop isn't a dialog: it still sends, as before (drafts are the delivery guard's)", async () => {
|
||||
const sent = await sendAfterHook("Stop", framed("❯ unfinished message"));
|
||||
expect(sent.result.ok).toBe(true);
|
||||
expect(sent.paste).toBe(1);
|
||||
});
|
||||
|
||||
it("a fresh running hook keeps its own check: the selector refuses there too", async () => {
|
||||
const sent = await sendAfterHook("UserPromptSubmit", permission);
|
||||
expect(sent.result).toMatchObject({ ok: false, reason: "target_needs_input" });
|
||||
expect(sent.paste).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -160,14 +160,16 @@ describe("ordinary transport under current and historical questions", () => {
|
||||
const staleWaiting = hook.name === "send_stale_permission" || hook.name === "display_stale_permission";
|
||||
const currentQuestion = ["picker160", "picker100", "picker12", "picker13"].includes(shape)
|
||||
|| shape === "footer_in_multiline_draft";
|
||||
const freshRunningNeedsInput = hook.name === "fresh_running" && currentQuestion;
|
||||
const sent = hook.name === "fresh_permission" || freshRunningNeedsInput ? false
|
||||
// A fresh running or idle hook reads the pane once for a current dialog: a picker opened after Stop fires no hook.
|
||||
const freshDialogCheck = hook.name === "fresh_running" || hook.name === "fresh_idle";
|
||||
const freshCurrentQuestion = freshDialogCheck && currentQuestion;
|
||||
const sent = hook.name === "fresh_permission" || freshCurrentQuestion ? false
|
||||
: hook.name.startsWith("fresh_") ? true : state !== "attention" && !(staleWaiting && state === "unknown");
|
||||
expect(result.ok).toBe(sent);
|
||||
expect(paste).toHaveBeenCalledTimes(sent ? 1 : 0);
|
||||
expect(enter).toHaveBeenCalledTimes(sent ? 1 : 0);
|
||||
if (!sent) expect(result.reason).toBe("target_needs_input");
|
||||
expect(capture).toHaveBeenCalledTimes(hook.name === "fresh_running" ? 1 : hook.name.startsWith("fresh_") ? 0 : 1);
|
||||
expect(capture).toHaveBeenCalledTimes(freshDialogCheck ? 1 : hook.name.startsWith("fresh_") ? 0 : 1);
|
||||
} finally { db.close(); }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -57,15 +57,6 @@ it("a fresh install has all six exact plugin hooks trusted before the first seat
|
||||
expect(Object.values(states).every((state: any) => state.trusted_content_hash === codexPluginContentHash(shipped))).toBe(true);
|
||||
expect(f.calls.map(a=>a.slice(0,2))).toEqual([["plugin","marketplace"],["plugin","add"]]);
|
||||
});
|
||||
it("refocus keeps Codex's default budget and main's declaration hash", () => {
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(shipped, "hooks/codex.json"), "utf8"));
|
||||
const handler = manifest.hooks.UserPromptSubmit[1].hooks[0];
|
||||
const { computeCodexHookTrust } = createRequire(import.meta.url)(path.join(shipped, "hooks/scripts/codex-hook-trust.cjs"));
|
||||
const identity = computeCodexHookTrust("UserPromptSubmit", { ...handler, timeoutSec: handler.timeout,
|
||||
keySource: "openrig-core@openrig:hooks/codex.json", groupIndex: 1, handlerIndex: 0 });
|
||||
expect(identity.hash).toBe("sha256:c648714b3a02675ba4ea029b8e62dbcd77569afc317ad4f1bc3e8cbfbe7b34ee");
|
||||
expect(handler.additionalContextLimit).toBeUndefined();
|
||||
});
|
||||
it.each(['[plugins."openrig-core@openrig"]\nenabled = false\n','[plugins._default]\nenabled = false\n'])("a disabled plugin stays disabled on restart and upgrade: %s", async config => {
|
||||
const f = fixture(config); await f.adapter().ensureCodexCorePlugin(); await f.adapter().ensureCodexCorePlugin();
|
||||
expect(fs.readFileSync(f.file,"utf8")).toBe(config); expect(f.calls).toEqual([]);
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { SeatActivityService } from "../src/domain/seat-activity-service.js";
|
||||
import { SeatStructuralActivityService } from "../src/domain/seat-structural-activity-service.js";
|
||||
import { CODEX_ACTIVITY_RUNG_INVENTORY } from "../src/domain/activity-taxonomy.js";
|
||||
import { classifyPromptAfterRefusal } from "../src/domain/queue-wake-ladder.js";
|
||||
|
||||
// Recorded Codex screens: two native choice prompts (directory trust on 0.156.1, the update prompt on 0.160.0), a complete
|
||||
// empty composer and a staged draft (0.160.0).
|
||||
const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8");
|
||||
const TRUST = read("./fixtures/codex-trust-0.156.1.txt");
|
||||
const UPDATE = read("./fixtures/codex-update-0.160.0.txt");
|
||||
const IDLE = read("./fixtures/input-box/codex-0.160.0/idle.txt");
|
||||
const DRAFT = read("./fixtures/input-box/codex-0.160.0/staged-short.J.txt");
|
||||
// #2500's recorded working frame, as codex-working-queue-oracle.test.ts builds it.
|
||||
const RECORDED_WORKING = read("./fixtures/input-box/codex-0.160.0/after-enter-0.4s.J.txt");
|
||||
const WORKING = RECORDED_WORKING.slice(RECORDED_WORKING.indexOf("• Working"))
|
||||
.replace(/^• Working.*$/m, "◦ Working已(1h 56m 32s • esc to interrupt · 3 background terminals running")
|
||||
.replace(/^ GPT-.*$/m, " GPT-6.1 Terra high · D:\\workspace");
|
||||
type Pane = { id: string; pid: number } | undefined;
|
||||
type Occupant = { paneId: string | null; pid: number | null };
|
||||
|
||||
function fixture(first: string) {
|
||||
let time = Date.parse("2026-11-11T06:00:00.000Z"), screen = first, pane: Pane = { id: "%5", pid: 4100 };
|
||||
let windowRead: number | null = time / 1000 - 60;
|
||||
const session = "worker@fixture", node = "node-fixture";
|
||||
const structural = new SeatStructuralActivityService({ capturePaneContent: async () => screen } as never, () => new Date(time));
|
||||
const oracle = new SeatActivityService({ tmux: { readPaneLastActivity: async () => windowRead },
|
||||
defaultWindowSeconds: 3, now: () => new Date(time), structuralActivity: structural } as never);
|
||||
oracle.declareRungInventory({ seatNodeId: node, sessionName: session }, CODEX_ACTIVITY_RUNG_INVENTORY);
|
||||
/** The occupant as OpenRig records it (the binding's pane, the process the identity reconciler verified); the sweep
|
||||
* refreshes it every tick. */
|
||||
const occupant = (next: Occupant) => oracle.recordCodexOccupant?.(node, next);
|
||||
occupant({ paneId: "%5", pid: 4100 });
|
||||
/** The sweep's batched capture: the text with the pane and process tmux reported for it in the same call. */
|
||||
const capture = async () => structural.pollSeat(session, new Map([[session, { text: screen, capturedAt: new Date(time), ...(pane ? { pane } : {}) }]]),
|
||||
"codex", null, new Date(time));
|
||||
const tick = async (ms = 1000) => {
|
||||
time += ms;
|
||||
windowRead = time / 1000 - 60;
|
||||
await capture();
|
||||
await oracle.pollSeat(session);
|
||||
};
|
||||
return { oracle, node, session, structural, tick, capture, at: () => new Date(time).toISOString(),
|
||||
show: (next: string) => { screen = next; }, pane: (next: Pane) => { pane = next; }, occupant, advance: (ms: number) => { time += ms; },
|
||||
failWindowRead: () => { windowRead = null; },
|
||||
state: () => oracle.getSeatState(node)!,
|
||||
redeclare: () => oracle.declareRungInventory({ seatNodeId: node, sessionName: session }, CODEX_ACTIVITY_RUNG_INVENTORY),
|
||||
verdict: (refusedAt: string) => classifyPromptAfterRefusal(oracle.getSeatState(node), refusedAt) };
|
||||
}
|
||||
|
||||
describe("a Codex choice prompt read from the structural frame (#2515)", () => {
|
||||
it.each([["directory trust (0.156.1)", TRUST], ["update (0.160.0)", UPDATE]])(
|
||||
"a native %s choice reads as needs-input; once answered, an empty composer reads as a clear observed after the refusal", async (_name, choice) => {
|
||||
const f = fixture(choice);
|
||||
await f.tick();
|
||||
expect(f.state()).toMatchObject({ needsInput: { count: 1, reason: "selection_prompt" }, needsInputEvidence: { rung: "needs-input-chrome" } });
|
||||
const refusedAt = f.at();
|
||||
expect(f.verdict(refusedAt)).toBe("blocked");
|
||||
f.show(IDLE);
|
||||
await f.tick();
|
||||
expect(f.state()).toMatchObject({ needsInput: { count: 0, reason: null }, needsInputEvidence: { rung: "needs-input-chrome" } });
|
||||
expect(f.verdict(refusedAt)).toBe("clear");
|
||||
// The clear is observed at the capture's start: a refusal after it isn't cleared by it.
|
||||
expect(f.verdict(new Date(Date.parse(f.at()) + 1).toISOString())).toBe("unknown");
|
||||
});
|
||||
|
||||
it.each([["a staged draft", DRAFT], ["an unrecognised screen", "some provider output\nwith no composer"]])(
|
||||
"%s after the choice never clears: the prompt reads unknown again", async (_name, screen) => {
|
||||
const f = fixture(TRUST);
|
||||
await f.tick();
|
||||
const refusedAt = f.at();
|
||||
f.show(screen);
|
||||
await f.tick();
|
||||
expect(f.state().needsInputEvidence).toBeNull();
|
||||
expect(f.verdict(refusedAt)).toBe("unknown");
|
||||
});
|
||||
|
||||
it("a frame captured before the seat's current inventory isn't used (it may be another occupant's screen)", async () => {
|
||||
const f = fixture(IDLE);
|
||||
await f.capture();
|
||||
f.advance(500);
|
||||
// A new occupant's inventory is declared after that capture; no capture has happened since.
|
||||
f.redeclare();
|
||||
const refusedAt = new Date(Date.parse(f.at()) - 1000).toISOString();
|
||||
await f.oracle.pollSeat(f.session); // the sampler reads the cached frame; no new capture
|
||||
expect(f.state().needsInputEvidence).toBeNull();
|
||||
expect(f.verdict(refusedAt)).toBe("unknown");
|
||||
// The next capture is the current occupant's, and counts.
|
||||
await f.tick();
|
||||
expect(f.verdict(refusedAt)).toBe("clear");
|
||||
});
|
||||
|
||||
it("a tracked tool approval keeps its own path: an empty composer doesn't clear a pending tracked request", async () => {
|
||||
const f = fixture(IDLE);
|
||||
f.oracle.reportEvidence({ seatNodeId: f.node, sessionName: f.session, rung: "lifecycle-hooks", sourceId: "codex:hooks",
|
||||
seq: 1, observedAt: f.at(), activity: "working", permissionRequest: { id: "tracked-approval" } } as never);
|
||||
const refusedAt = f.at();
|
||||
await f.tick();
|
||||
// The structural zero is ignored while the request is tracked; its own probe decides it.
|
||||
expect(f.state().needsInputEvidence?.rung ?? null).not.toBe("needs-input-chrome");
|
||||
expect(f.verdict(refusedAt)).not.toBe("clear");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the copied prompt evidence expires where it's read (#2952 R1-F1)", () => {
|
||||
const clearedAfterRefusal = async () => {
|
||||
const f = fixture(TRUST);
|
||||
await f.tick();
|
||||
const refusedAt = f.at();
|
||||
f.show(IDLE);
|
||||
await f.tick();
|
||||
expect(f.verdict(refusedAt)).toBe("clear");
|
||||
return { f, refusedAt };
|
||||
};
|
||||
|
||||
it("on a getter alone, past the structural window with no capture", async () => {
|
||||
const { f, refusedAt } = await clearedAfterRefusal();
|
||||
f.advance(6000);
|
||||
expect(f.verdict(refusedAt)).toBe("unknown");
|
||||
expect(f.state().needsInputEvidence).toBeNull();
|
||||
});
|
||||
|
||||
it("when the window read fails, past the structural window", async () => {
|
||||
const { f, refusedAt } = await clearedAfterRefusal();
|
||||
f.advance(6000);
|
||||
f.failWindowRead();
|
||||
await f.oracle.pollSeat(f.session);
|
||||
expect(f.verdict(refusedAt)).toBe("unknown");
|
||||
});
|
||||
|
||||
it("when the seat's inventory is declared again, without any new capture", async () => {
|
||||
const { f, refusedAt } = await clearedAfterRefusal();
|
||||
f.redeclare();
|
||||
expect(f.verdict(refusedAt)).toBe("unknown");
|
||||
});
|
||||
|
||||
it("a fresh clear stays positive while its frame is current", async () => {
|
||||
const { f, refusedAt } = await clearedAfterRefusal();
|
||||
f.advance(2000);
|
||||
expect(f.verdict(refusedAt)).toBe("clear");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the frame must come from the occupant's own pane and process (#2952 R1-F2)", () => {
|
||||
it.each([
|
||||
["a same-name successor (a new process in the pane)", { id: "%5", pid: 4200 }],
|
||||
["a reused pane id held by another process", { id: "%5", pid: 9999 }],
|
||||
["another pane under the same session name", { id: "%9", pid: 4100 }],
|
||||
])("%s is refused: the prompt reads unknown, never clear", async (_name, other) => {
|
||||
const f = fixture(TRUST);
|
||||
await f.tick(); // the occupant is %5 / 4100
|
||||
const refusedAt = f.at();
|
||||
f.show(IDLE);
|
||||
f.pane(other);
|
||||
await f.tick();
|
||||
expect(f.state().needsInputEvidence).toBeNull();
|
||||
expect(f.verdict(refusedAt)).toBe("unknown");
|
||||
// Once OpenRig records that pane and process as the occupant (a managed swap's binding, or the reconciler verifying
|
||||
// the process), its frames count.
|
||||
f.occupant({ paneId: other.id, pid: other.pid });
|
||||
await f.tick();
|
||||
expect(f.verdict(refusedAt)).toBe("clear");
|
||||
});
|
||||
|
||||
it("a capture that doesn't name its pane (the per-seat fallback read) gives no prompt evidence", async () => {
|
||||
const f = fixture(IDLE);
|
||||
f.pane(undefined);
|
||||
await f.tick();
|
||||
expect(f.state().needsInputEvidence).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the identity is the occupant's as OpenRig records it, not the first frame's (#2952 round 2)", () => {
|
||||
it("a replacement before the seat's first frame is refused, so it can't clear an earlier refusal (R2-F1)", async () => {
|
||||
const f = fixture(IDLE);
|
||||
const refusedAt = f.at();
|
||||
f.pane({ id: "%5", pid: 4200 }); // replaced behind the same name before any frame was read
|
||||
await f.tick();
|
||||
expect(f.state().needsInputEvidence).toBeNull();
|
||||
expect(f.verdict(refusedAt)).toBe("unknown");
|
||||
});
|
||||
|
||||
it("a process OpenRig hasn't verified gives no prompt evidence", async () => {
|
||||
const f = fixture(IDLE);
|
||||
f.occupant({ paneId: "%5", pid: null });
|
||||
const refusedAt = f.at();
|
||||
await f.tick();
|
||||
expect(f.state().needsInputEvidence).toBeNull();
|
||||
expect(f.verdict(refusedAt)).toBe("unknown");
|
||||
});
|
||||
|
||||
it("OpenRig's in-place restart (same pane, new process): once the reconciler verifies the process, the prompt clear works", async () => {
|
||||
const f = fixture(TRUST);
|
||||
await f.tick();
|
||||
const refusedAt = f.at();
|
||||
f.pane({ id: "%5", pid: 4300 });
|
||||
f.show(IDLE);
|
||||
await f.tick();
|
||||
// Until the identity reconciler verifies the new process, its frames aren't the occupant's.
|
||||
expect(f.verdict(refusedAt)).toBe("unknown");
|
||||
f.occupant({ paneId: "%5", pid: 4300 });
|
||||
await f.tick();
|
||||
expect(f.verdict(refusedAt)).toBe("clear");
|
||||
});
|
||||
|
||||
it("OpenRig's in-place restart keeps #2500's working frame once the reconciler verifies the process", async () => {
|
||||
const f = fixture(WORKING);
|
||||
await f.tick();
|
||||
expect(f.state()).toMatchObject({ activity: "working", decidedBy: "window-sampling" });
|
||||
f.pane({ id: "%5", pid: 4300 });
|
||||
f.occupant({ paneId: "%5", pid: 4300 });
|
||||
await f.tick();
|
||||
await f.tick();
|
||||
expect(f.state()).toMatchObject({ activity: "working", decidedBy: "window-sampling" });
|
||||
});
|
||||
});
|
||||
@@ -221,6 +221,38 @@ it("does not renew an adjacent frame using a predecessor's counter", async () =>
|
||||
expect(oracle.getSeatState(node)?.activity).toBe("idle-at-prompt");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "the same counter", predecessorAdjacent: true, step: 0 },
|
||||
{ name: "an advanced counter", predecessorAdjacent: false, step: 1 },
|
||||
])("does not credit a restarted pane process with $name from the process before it", async ({ predecessorAdjacent, step }) => {
|
||||
let time = Date.parse("2026-11-04T20:00:00.000Z"), elapsed = 32, pid = 4100;
|
||||
const session = "worker@fixture", node = "node-fixture";
|
||||
const structural = new SeatStructuralActivityService({ capturePaneContent: async () => null } as never, () => new Date(time));
|
||||
const capture = (adjacent: boolean) => {
|
||||
const at = new Date(time);
|
||||
const frame = screen.replace("32s • esc to interrupt", `${elapsed}s • esc to interrupt`);
|
||||
const text = adjacent ? frame : frame.replace("\n› Ask Codex", "\n• Edited files\n\n› Ask Codex");
|
||||
return structural.pollSeat(session, new Map([[session, { text, capturedAt: at, pane: { id: "%5", pid } }]]), "codex", null, at);
|
||||
};
|
||||
const oracle = new SeatActivityService({ tmux: { readPaneLastActivity: async () => time / 1000 - 60 },
|
||||
defaultWindowSeconds: 3, now: () => new Date(time), structuralActivity: structural } as never);
|
||||
oracle.declareRungInventory({ seatNodeId: node, sessionName: session }, CODEX_ACTIVITY_RUNG_INVENTORY);
|
||||
oracle.recordCodexOccupant(node, { paneId: "%5", pid });
|
||||
await capture(predecessorAdjacent);
|
||||
// An in-place restart: OpenRig verifies the new process in the same pane, and the inventory is unchanged.
|
||||
time += 1000; elapsed += step; pid = 4200;
|
||||
oracle.recordCodexOccupant(node, { paneId: "%5", pid });
|
||||
const replaced = await capture(false);
|
||||
await oracle.pollSeat(session);
|
||||
expect(oracle.getSeatState(node)?.activity).toBe("idle-at-prompt");
|
||||
expect(replaced?.codexWorkingFrame).toBeUndefined();
|
||||
// The new process's own advancing counter still counts.
|
||||
time += 1000; elapsed++;
|
||||
await capture(false);
|
||||
await oracle.pollSeat(session);
|
||||
expect(oracle.getSeatState(node)?.activity).toBe("working");
|
||||
});
|
||||
|
||||
it("does not treat counter readings separated by a capture outage as continuous activity", async () => {
|
||||
let time = Date.parse("2026-11-04T20:00:00.000Z"), elapsed = 32;
|
||||
const session = "worker@fixture", node = "node-fixture";
|
||||
|
||||
@@ -329,8 +329,17 @@ exec "$@"
|
||||
writePreset("free-harness.yaml", ["format: 1", "name: free-harness", `command: ${JSON.stringify(`node ${shellQuote(appScript)}`)}`, "maintainer: Example Maintainer", ""].join("\n"));
|
||||
await sleep(4_000);
|
||||
await check();
|
||||
// A relaunch doesn't start the same-named app in its place.
|
||||
expect((await rig(["down", "free-rig", "--json", "--force"])).code).toBe(0);
|
||||
// A relaunch doesn't start the same-named app in its place. The self-attached session is the person's own terminal,
|
||||
// which OpenRig didn't create, so rig down leaves it running and says so (exit 2) instead of stopping a terminal it can't
|
||||
// show is the seat's.
|
||||
const down = await rig(["down", "free-rig", "--json", "--force"]);
|
||||
expect(down.code, JSON.stringify(down)).toBe(2);
|
||||
expect(JSON.parse(down.stdout).errors).toEqual([expect.stringMatching(
|
||||
/^Kill failed for session '[^']+': '[^']+' was left running: its tmux session \$\d+ doesn't carry this seat's OpenRig marker/)]);
|
||||
// The manual path it names: the person stops their own terminal on the daemon's server, then runs rig down again.
|
||||
execFileSync("tmux", ["-S", scaffold.tmuxSocketPath, "kill-session", "-t", "=free-seat"], { env: scaffold.env as NodeJS.ProcessEnv });
|
||||
const again = await rig(["down", "free-rig", "--json", "--force"]);
|
||||
expect(JSON.parse(again.stdout).errors, JSON.stringify(again)).toEqual([]);
|
||||
// A deliberate fresh launch of the seat gets main's refusal (a self-attached seat has no startup context, checked
|
||||
// before any adapter lookup) and starts nothing.
|
||||
const seatLaunch = await rig(["seat", "launch", "work.free@free-rig", "--fresh", "--reason", "relaunch a free-text seat", "--json"], 60_000);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
|
||||
import { describeDefaultReceiver, KERNEL_OPERATOR_SESSION, resolveDefaultReceiver } from "../src/domain/default-receiver.js";
|
||||
import { describeDefaultReceiver, KERNEL_OPERATOR_SESSION, permissionDeciderFor, resolveDefaultReceiver } from "../src/domain/default-receiver.js";
|
||||
import { readReceivingAgentSetting } from "../src/domain/queue-owner.js";
|
||||
import { effectiveInboundDestination, inboundDestinationNote, INHERIT_INBOUND_DESTINATION, LEGACY_INBOUND_DESTINATION, loadConfig, saveConfig, configPathFor } from "../src/domain/gateway/slack/config.js";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
@@ -145,4 +145,48 @@ describe("the settings view", () => {
|
||||
.toBe("Not chosen: when the connector starts, unrouted messages go to the default receiving agent (Agents & runtime).");
|
||||
expect(settingsBrowser(store, null, home).entries.some((entry) => entry.key === "agents.default_receiver")).toBe(false);
|
||||
});
|
||||
|
||||
it("withholds the receiving agent's description when the setting holds unsafe text; with no receiver, Slack's line says where unrouted messages go", () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "default-receiver-browser-"));
|
||||
closes.push(() => rmSync(home, { recursive: true, force: true }));
|
||||
const store = new SettingsStore(join(home, "config.json"));
|
||||
const unsafe = "token=synthetic-example-value";
|
||||
store.set("agents.operator_session", unsafe);
|
||||
const view = settingsBrowser(store, null, home, { session: null, source: "none", state: "missing",
|
||||
note: `agents.operator_session is "${unsafe}", which isn't an agent's session, so it's ignored` });
|
||||
const row = view.entries.find((entry) => entry.key === "agents.default_receiver")!;
|
||||
expect(row.application).toBe("Withheld: agents.operator_session holds credential-bearing or unsafe text, so the receiving agent isn't described here. Inspect it at its source.");
|
||||
expect(JSON.stringify(view)).not.toContain("synthetic-example-value");
|
||||
expect(view.entries.find((entry) => entry.key === "slack.inboundDestination")?.application)
|
||||
.toBe(`Not chosen: this host has no receiving agent (Agents & runtime), so when the connector starts, unrouted messages go to ${LEGACY_INBOUND_DESTINATION} for compatibility, as before.`);
|
||||
// A receiver that couldn't be read, or none resolved for this view, is unknown, not absent.
|
||||
const unknown = `Not chosen: OpenRig couldn't tell whether this host has a receiving agent (Agents & runtime). If it still can't when the connector starts, unrouted messages go to ${LEGACY_INBOUND_DESTINATION} for compatibility, as before.`;
|
||||
for (const receiver of [{ session: null, source: "none" as const, state: "unknown" as const }, null]) {
|
||||
expect(settingsBrowser(store, null, home, receiver).entries.find((entry) => entry.key === "slack.inboundDestination")?.application).toBe(unknown);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("who decides a runner's answered permission request", () => {
|
||||
it("is the receiving agent, never the requesting seat itself; then the kernel operator when it isn't the requester; else nobody", () => {
|
||||
const both = db([[KERNEL_OPERATOR_SESSION, "running"], ["lead@team", "running"]]);
|
||||
expect(permissionDeciderFor(both, "runner@team", () => "lead@team")?.session).toBe("lead@team");
|
||||
// The chosen agent is the blocked requester: the kernel operator decides instead.
|
||||
expect(permissionDeciderFor(both, "lead@team", () => "lead@team")?.session).toBe(KERNEL_OPERATOR_SESSION);
|
||||
// No agent chosen: the kernel operator, unless it's the requester.
|
||||
expect(permissionDeciderFor(both, "runner@team", () => null)?.session).toBe(KERNEL_OPERATOR_SESSION);
|
||||
expect(permissionDeciderFor(both, KERNEL_OPERATOR_SESSION, () => null)).toBeNull();
|
||||
// A stopped kernel operator stays responsible; a host without one has nobody, and OpenRig's default applies.
|
||||
expect(permissionDeciderFor(db([[KERNEL_OPERATOR_SESSION, "exited"]]), "runner@team", () => null)).toMatchObject({ session: KERNEL_OPERATOR_SESSION, state: "stopped" });
|
||||
expect(permissionDeciderFor(db([]), "runner@team", () => null)).toBeNull();
|
||||
expect(permissionDeciderFor(db([]), "lead@team", () => "lead@team")).toBeNull();
|
||||
// A chosen seat this host doesn't have can't decide: the kernel operator, when here, else nobody.
|
||||
expect(permissionDeciderFor(db([]), "runner@team", () => "ghost@team")).toBeNull();
|
||||
expect(permissionDeciderFor(both, "runner@team", () => "ghost@team")).toMatchObject({ session: KERNEL_OPERATOR_SESSION, note: "ghost@team, the receiving agent, isn't on this host" });
|
||||
// A setting that's set but ignored keeps its note when the kernel operator decides: it never reads as unset.
|
||||
const ignored = { session: null, problem: 'agents.operator_session is "alex@external", which isn\'t an agent\'s session, so it\'s ignored' };
|
||||
const kernelDecides = permissionDeciderFor(both, "runner@team", () => ignored)!;
|
||||
expect(kernelDecides).toMatchObject({ session: KERNEL_OPERATOR_SESSION, note: ignored.problem });
|
||||
expect(describeDefaultReceiver(kernelDecides)).not.toContain("unset");
|
||||
});
|
||||
});
|
||||
@@ -264,7 +264,7 @@ describe("SSE events route", () => {
|
||||
|
||||
const evidence = (i: number) => eventBus.emit({ type: "transport.delivery_evidence", sessionName: "dev-impl@my-rig", seatKind: "tmux", pane: null,
|
||||
occupantGeneration: null, messageId: `m${i}`, attemptId: `a${i}`, admitted: "none", written: "submitted",
|
||||
terminalObserved: { outcome: null, verified: null }, inputBoundary: "unknown", source: "send_result" }).seq;
|
||||
terminalObserved: { outcome: null, verified: null }, inputBoundary: "unknown", source: "send_result", wireSha256: null, wireBytes: null, writtenAt: null }).seq;
|
||||
|
||||
it("a fresh replay=<n> skips per-send delivery evidence, so sends after the newest real events never empty a new feed", async () => {
|
||||
const rig = rigRepo.createRig("r01");
|
||||
@@ -275,11 +275,17 @@ describe("SSE events route", () => {
|
||||
expect(ids(await readSSEEvents(await app.request("/api/events?replay=2"), 3))).toEqual(seqs);
|
||||
});
|
||||
|
||||
it("live delivery evidence isn't written to the stream, so a viewer's cache never holds it, and a reconnect's replay skips it", async () => {
|
||||
it("live delivery evidence and prompt observations aren't written to the stream, so a viewer's cache never holds them, and a reconnect's replay skips them", async () => {
|
||||
const rig = rigRepo.createRig("r01");
|
||||
const [first] = emitMany(rig.id, 1);
|
||||
const live = await app.request("/api/events?replay=0");
|
||||
setTimeout(() => { evidence(0); eventBus.emit({ type: "rig.created", rigId: rig.id }); }, 10);
|
||||
setTimeout(() => {
|
||||
evidence(0);
|
||||
eventBus.emit({ type: "transport.prompt_observation", sessionName: "dev-impl@my-rig", occupantGeneration: "g", runtime: "claude-code",
|
||||
source: "UserPromptSubmit", nativeSessionId: null, nativePromptId: null, nativeTurnId: null, promptSha256: "a".repeat(64), promptBytes: 1,
|
||||
observedAt: new Date().toISOString() });
|
||||
eventBus.emit({ type: "rig.created", rigId: rig.id });
|
||||
}, 10);
|
||||
expect((await readSSEEvents(live, 2)).map((e) => JSON.parse(e.data).type)).toEqual(["rig.created"]);
|
||||
const again = await app.request("/api/events?replay=0", { headers: { "Last-Event-ID": String(first) } });
|
||||
expect((await readSSEEvents(again, 3)).map((e) => JSON.parse(e.data).type)).toEqual(["rig.created"]);
|
||||
|
||||
+65
-7
@@ -1,8 +1,10 @@
|
||||
/* Synthetic interactive provider: no provider network calls; credentials are compared only to fixture constants.
|
||||
* A real executable lets native process/binding checks run without mocking their observations. */
|
||||
#include <errno.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
#include <signal.h>
|
||||
|
||||
@@ -15,32 +17,88 @@ static void stopped(int signalNumber) {
|
||||
}
|
||||
|
||||
static const char *flag(int argc, char **argv, const char *name, const char *fallback) {
|
||||
for (int i = 1; i + 1 < argc; i++) if (!strcmp(argv[i], name)) return argv[i + 1];
|
||||
size_t len = strlen(name);
|
||||
for (int i = 1; i < argc; i++) {
|
||||
if (!strcmp(argv[i], name) && i + 1 < argc) return argv[i + 1];
|
||||
if (!strncmp(argv[i], name, len) && argv[i][len] == '=') return argv[i] + len + 1;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
static int matches(const char *name, const char *value) {
|
||||
const char *v = getenv(name); return v && !strcmp(v, value);
|
||||
}
|
||||
static int valid_session_id(const char *s) {
|
||||
size_t len = s ? strlen(s) : 0;
|
||||
if (len == 0 || len > 64) return 0;
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
unsigned char c = (unsigned char)s[i];
|
||||
if (!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')
|
||||
|| c == '.' || c == '_' || c == '-')) return 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
static int fput_json_string(FILE *out, const char *s) {
|
||||
if (fputc('"', out) == EOF) return -1;
|
||||
for (const unsigned char *p = (const unsigned char *)s; *p; p++) {
|
||||
if (*p == '"' || *p == '\\') {
|
||||
if (fprintf(out, "\\%c", *p) < 0) return -1;
|
||||
} else if (*p < 0x20) {
|
||||
if (fprintf(out, "\\u%04x", (unsigned int)*p) < 0) return -1;
|
||||
} else if (fputc(*p, out) == EOF) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
return fputc('"', out) == EOF ? -1 : 0;
|
||||
}
|
||||
static int write_session_record(const char *home, pid_t pid, const char *seatName, const char *sessionId) {
|
||||
char sessionsDir[4096], tmpPath[4096], finalPath[4096];
|
||||
int n1 = snprintf(sessionsDir, sizeof(sessionsDir), "%s/sessions", home);
|
||||
int n2 = snprintf(tmpPath, sizeof(tmpPath), "%s/sessions/.%d.json.tmp", home, (int)pid);
|
||||
int n3 = snprintf(finalPath, sizeof(finalPath), "%s/sessions/%d.json", home, (int)pid);
|
||||
if (n1 < 0 || (size_t)n1 >= sizeof(sessionsDir)
|
||||
|| n2 < 0 || (size_t)n2 >= sizeof(tmpPath)
|
||||
|| n3 < 0 || (size_t)n3 >= sizeof(finalPath)) return -1;
|
||||
if (mkdir(sessionsDir, 0700) != 0 && errno != EEXIST) return -1;
|
||||
FILE *out = fopen(tmpPath, "w");
|
||||
if (!out) return -1;
|
||||
int ok = fputs("{\"name\":", out) != EOF
|
||||
&& fput_json_string(out, seatName) == 0
|
||||
&& fputs(",\"sessionId\":", out) != EOF
|
||||
&& fput_json_string(out, sessionId) == 0
|
||||
&& fputs("}\n", out) != EOF
|
||||
&& fflush(out) == 0;
|
||||
if (fclose(out) != 0 || !ok) { unlink(tmpPath); return -1; }
|
||||
if (rename(tmpPath, finalPath) != 0) { unlink(tmpPath); return -1; }
|
||||
return 0;
|
||||
}
|
||||
int main(int argc, char **argv) {
|
||||
setvbuf(stdout, NULL, _IONBF, 0);
|
||||
if (argc > 1 && !strcmp(argv[1], "--version")) { puts("2.1.139 (Claude Code)"); return 0; }
|
||||
if (argc > 1 && !strcmp(argv[1], "--help")) {
|
||||
puts("Usage: claude [options]\n--permission-mode <mode> (choices: \"acceptEdits\", \"default\", \"auto\")"); return 0;
|
||||
}
|
||||
const char *id = flag(argc, argv, "--resume", flag(argc, argv, "--session-id", "missing"));
|
||||
const char *explicitId = flag(argc, argv, "--resume", flag(argc, argv, "--session-id", NULL));
|
||||
const char *id = explicitId ? explicitId : "missing";
|
||||
const char *argName = flag(argc, argv, "--name", NULL);
|
||||
const char *seatName = (argName && argName[0]) ? argName : NULL;
|
||||
const char *model = flag(argc, argv, "--model", "default");
|
||||
const char *permission = flag(argc, argv, "--permission-mode", "default");
|
||||
const char *home = getenv("CLAUDE_CONFIG_DIR"), *rig = getenv("OPENRIG_HOME");
|
||||
if (!home || !rig || strchr(id, '/') || strlen(id) > 64) return 2;
|
||||
snprintf(removeKeyFlag, sizeof(removeKeyFlag), "%s/remove-key-on-stop-%d", rig, getpid());
|
||||
snprintf(apiKeyFile, sizeof(apiKeyFile), "%s/../api-key", rig);
|
||||
if (!home || home[0] != '/' || !rig || rig[0] != '/' || strchr(id, '/') || strlen(id) > 64) return 2;
|
||||
int r1 = snprintf(removeKeyFlag, sizeof(removeKeyFlag), "%s/remove-key-on-stop-%d", rig, getpid());
|
||||
int r2 = snprintf(apiKeyFile, sizeof(apiKeyFile), "%s/../api-key", rig);
|
||||
if (r1 < 0 || (size_t)r1 >= sizeof(removeKeyFlag) || r2 < 0 || (size_t)r2 >= sizeof(apiKeyFile)) return 2;
|
||||
signal(SIGTERM, stopped);
|
||||
signal(SIGHUP, stopped);
|
||||
const char *auth = matches("ANTHROPIC_API_KEY", "synthetic-api") ? "api"
|
||||
: matches("CLAUDE_CODE_OAUTH_TOKEN", "synthetic-plan") ? "plan" : "other";
|
||||
char journal[4096], memoryFile[4096], memory[512] = "", line[4096];
|
||||
snprintf(journal, sizeof(journal), "%s/provider-launches.jsonl", rig);
|
||||
snprintf(memoryFile, sizeof(memoryFile), "%s/fixture-conversation-%s", home, id);
|
||||
int j1 = snprintf(journal, sizeof(journal), "%s/provider-launches.jsonl", rig);
|
||||
int m1 = snprintf(memoryFile, sizeof(memoryFile), "%s/fixture-conversation-%s", home, id);
|
||||
if (j1 < 0 || (size_t)j1 >= sizeof(journal) || m1 < 0 || (size_t)m1 >= sizeof(memoryFile)) return 2;
|
||||
if (seatName && valid_session_id(explicitId)) {
|
||||
if (strlen(seatName) > 512 || write_session_record(home, getpid(), seatName, explicitId) != 0) return 7;
|
||||
}
|
||||
FILE *f = fopen(memoryFile, "r"); if (f) { fgets(memory, sizeof(memory), f); fclose(f); }
|
||||
f = fopen(journal, "a"); if (!f) return 3;
|
||||
fprintf(f, "{\"pid\":%d,\"conversation\":\"%s\",\"model\":\"%s\",\"permission\":\"%s\",\"auth\":\"%s\",\"remembered\":%s}\n",
|
||||
|
||||
+3
-2
@@ -35,9 +35,10 @@ try {
|
||||
const seats = new Map();
|
||||
for (const member of ["a", "b", "c", "sibling"]) {
|
||||
const name = `${member}@shrink-fixture`;
|
||||
await native(["new-session", "-d", "-s", name, "sleep 120"]);
|
||||
const pane = (await native(["list-panes", "-t", name, "-F", "#{pane_id}"])).trim();
|
||||
const node = rigRepo.addNode(rig.id, member, { runtime: "terminal", ...(member === "sibling" ? {} : { podId: pod.id }) });
|
||||
// As OpenRig launches a seat: its session carries the node's OPENRIG_NODE_ID, which a guarded stop requires.
|
||||
await native(["new-session", "-d", "-s", name, "-e", `OPENRIG_NODE_ID=${node.id}`, "sleep 120"]);
|
||||
const pane = (await native(["list-panes", "-t", name, "-F", "#{pane_id}"])).trim();
|
||||
const session = sessionRegistry.registerSession(node.id, name);
|
||||
sessionRegistry.updateStatus(session.id, "running");
|
||||
sessionRegistry.updateBinding(node.id, { tmuxSession: name, tmuxPane: pane });
|
||||
|
||||
@@ -4,15 +4,18 @@
|
||||
// Harness adapted from review50-r2's PR #1001 review (real hook route, SQLite store, activity service, transport,
|
||||
// parked-owner policy, queue and ladder; only terminal I/O and the final orchestrator delivery are inert).
|
||||
import { it, expect, vi, afterEach } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { Hono } from "hono";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createFullTestDb } from "./helpers/test-app.js";
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
import { SeatIdentityStore } from "../src/domain/seat-identity-store.js";
|
||||
import { AgentActivityStore } from "../src/domain/agent-activity-store.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { SessionTransport } from "../src/domain/session-transport.js";
|
||||
import { SeatActivityService } from "../src/domain/seat-activity-service.js";
|
||||
import { SeatStructuralActivityService } from "../src/domain/seat-structural-activity-service.js";
|
||||
import { activityRoutes } from "../src/routes/activity.js";
|
||||
import { QueueRepository } from "../src/domain/queue-repository.js";
|
||||
import { makeParkedOwnerConsumerPolicy, makeRigAnchor, PARKED_OWNER_POLICY_NAME, REFUSED_PREFIX } from "../src/domain/policies/parked-owner-consumer.js";
|
||||
@@ -22,7 +25,8 @@ import { startWakeLadderScheduler } from "../src/index.js";
|
||||
const databases: Database.Database[] = [];
|
||||
afterEach(() => { for (const db of databases.splice(0)) db.close(); });
|
||||
|
||||
function fixture(runtime = "claude-code", permissionPromptReader?: (sessionName: string) => Promise<boolean | null>, pane = "unrecognized provider content") {
|
||||
function fixture(runtime = "claude-code", permissionPromptReader?: (sessionName: string) => Promise<boolean | null>, pane = "unrecognized provider content",
|
||||
withStructural = false) {
|
||||
const db = createFullTestDb(); databases.push(db);
|
||||
const rigRepo = new RigRepository(db), sessionRegistry = new SessionRegistry(db), bus = new EventBus(db);
|
||||
const rig = rigRepo.createRig("r1001"), node = rigRepo.addNode(rig.id, "worker.a", { role: "worker", runtime });
|
||||
@@ -36,13 +40,16 @@ function fixture(runtime = "claude-code", permissionPromptReader?: (sessionName:
|
||||
isRegisteredOccupantGeneration: (id, g) => Boolean(db.prepare("SELECT 1 FROM occupant_tenures WHERE node_id=? AND generation_uuid=?").get(id, g)),
|
||||
});
|
||||
const paste = vi.fn(async () => ({ ok: true as const })), enter = vi.fn(async () => ({ ok: true as const }));
|
||||
const screen = { value: pane };
|
||||
const tmux = { hasSession: async () => true, probeSession: async () => ({ state: "present" as const }),
|
||||
getPaneCommand: async () => runtime === "codex" ? "codex" : "claude", getPanePid: async () => null,
|
||||
listPanes: async () => [], hasSessionEnv: async () => false, capturePaneContent: async () => pane,
|
||||
listPanes: async () => [], hasSessionEnv: async () => false, capturePaneContent: async () => screen.value,
|
||||
sendText: paste, sendKeys: enter, readPaneLastActivity: async () => now().getTime() / 1000 - 120,
|
||||
};
|
||||
// The structural capture over the same pane, when a cell needs the oracle to read it (#2515).
|
||||
const structural = withStructural ? new SeatStructuralActivityService(tmux as never, now) : undefined;
|
||||
const newOracle = () => new SeatActivityService({ tmux: tmux as never, defaultWindowSeconds: 3, now,
|
||||
permissionPromptReader,
|
||||
permissionPromptReader, structuralActivity: structural,
|
||||
permissionPromptChanged: permissionPromptReader ? (sessionName, confirmed, permissionRuntime) => {
|
||||
const latest = store.getLatestForNode({ sessionName });
|
||||
store.recordHookEvent({ sessionName, runtime: permissionRuntime, generation: latest?.generation,
|
||||
@@ -70,7 +77,7 @@ function fixture(runtime = "claude-code", permissionPromptReader?: (sessionName:
|
||||
getSeatState: (nodeId) => oracle.getSeatState(nodeId),
|
||||
getLatestHook: (sessionName) => store.getLatestForNode({ sessionName }) });
|
||||
return { db, bus, node, name, store, hook, transport, paste, enter, clock, readPromptState,
|
||||
oracle: () => oracle, reconstruct: () => { oracle = newOracle(); },
|
||||
oracle: () => oracle, reconstruct: () => { oracle = newOracle(); }, structural, show: (next: string) => { screen.value = next; },
|
||||
changeGeneration: (g: string | null) => { currentGeneration = g; } };
|
||||
}
|
||||
|
||||
@@ -302,3 +309,75 @@ it("the production scheduler keeps a current Claude prompt alert when an older S
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
// #2515: a native Codex choice that the person answers on screen, with no hook after it. The structural frame observes the
|
||||
// choice and then the empty composer, so the refused wake isn't escalated, and the next wake is sent once.
|
||||
const codexScreen = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8");
|
||||
it("a Codex wake refused at a native choice the person then answers: no escalation, and the next wake is sent once", async () => {
|
||||
const f = fixture("codex", undefined, codexScreen("./fixtures/codex-trust-0.156.1.txt"), true);
|
||||
// The occupant as OpenRig records it: the binding's pane, and the pane process the identity reconciler verified.
|
||||
new SessionRegistry(f.db).updateBinding(f.node.id, { tmuxPane: "%5" });
|
||||
new SeatIdentityStore(f.db).upsert({ nodeId: f.node.id, verdict: "verified", evidenceSource: null, reason: null,
|
||||
evidence: { registeredPane: "%5", observedPanePid: 4100, observedPid: 4101, observedCommand: "codex", matchedLayer: 1 },
|
||||
sessionName: f.name, observedAt: f.clock.value.toISOString() });
|
||||
// The sweep's batched capture: the screen with the pane and process tmux reported for it.
|
||||
const screen = { value: codexScreen("./fixtures/codex-trust-0.156.1.txt") };
|
||||
const observe = async () => {
|
||||
await f.structural!.pollSeat(f.name, new Map([[f.name, { text: screen.value, capturedAt: f.clock.value, pane: { id: "%5", pid: 4100 } }]]),
|
||||
"codex", null, f.clock.value);
|
||||
await f.oracle().pollAllRunningTmuxSeats(f.db);
|
||||
};
|
||||
await observe();
|
||||
expect(f.oracle().getSeatState(f.node.id)?.needsInput).toMatchObject({ count: 1, reason: "selection_prompt" });
|
||||
const repo = new QueueRepository(f.db, f.bus, { validateRig: () => true });
|
||||
const row = await repo.create({ sourceSession: "planner@r1001", destinationSession: f.name,
|
||||
body: "finish synthetic assigned work", summary: "synthetic obligation", nudge: false });
|
||||
repo.claim({ qitemId: row.qitemId, destinationSession: f.name });
|
||||
const history: Array<Record<string, unknown>> = [];
|
||||
const policy = makeParkedOwnerConsumerPolicy({
|
||||
diagnoseRig: () => ({ seats: [{ sessionName: f.name, parked: true,
|
||||
activity: { value: "idle-at-prompt", needsInput: { count: 0, reason: null } },
|
||||
obligations: { items: [{ qitemId: row.qitemId, state: "in-progress", summary: row.summary }], held: [] } }] }),
|
||||
history: { listForJob: () => history as never, countForJob: () => history.length },
|
||||
rows: { listTransitions: (id) => repo.listTransitions(id),
|
||||
appendNote: (id, note) => { repo.update({ qitemId: id, actorSession: "watchdog@system", transitionNote: note }); return { ok: true }; },
|
||||
recordNudgeResult: (id, result) => repo.recordNudgeAttempt(id, result), listOpenIds: () => [row.qitemId],
|
||||
recoveryOwnsWake: (id) => queueRecoveryOwnsWake(f.db, repo.getById(id)),
|
||||
},
|
||||
});
|
||||
const job = { jobId: "fixture-job", policy: PARKED_OWNER_POLICY_NAME, target: { session: makeRigAnchor("r1001") }, context: {} };
|
||||
const wake = await policy.evaluate(job as never);
|
||||
expect(wake.action).toBe("send");
|
||||
const sent = await f.transport.send(f.name, "synthetic assigned-work wake");
|
||||
expect(sent).toMatchObject({ ok: false, reason: "target_needs_input" });
|
||||
history.push({ historyId: "history", jobId: job.jobId, evaluatedAt: new Date().toISOString(), outcome: "sent", skipReason: null,
|
||||
deliveryTargetSession: f.name, deliveryStatus: "failed", deliveryMessage: "synthetic wake",
|
||||
evaluationNotes: { ...(wake as { notes?: Record<string, unknown> }).notes, deliveryReason: sent.error } });
|
||||
await policy.evaluate(job as never);
|
||||
const refused = repo.listTransitions(row.qitemId).find((t) => t.transitionNote?.startsWith(REFUSED_PREFIX));
|
||||
expect(refused?.transitionNote).toContain("(selection_prompt)");
|
||||
expect(f.readPromptState(f.name, refused!.ts)).toBe("blocked"); // still open: nothing is typed into it
|
||||
// The person answers the choice; no hook follows.
|
||||
f.show(codexScreen("./fixtures/input-box/codex-0.160.0/idle.txt"));
|
||||
screen.value = codexScreen("./fixtures/input-box/codex-0.160.0/idle.txt");
|
||||
f.clock.value = new Date(Date.now() + 2_000);
|
||||
await observe();
|
||||
expect(f.readPromptState(f.name, refused!.ts)).toBe("clear");
|
||||
const targets: string[] = [];
|
||||
const alerts = () => repo.list({ state: ["pending", "in-progress", "blocked"], limit: 1000 })
|
||||
.filter((r) => r.tags?.includes("wake-prompt-refusal"));
|
||||
for (const ageMs of [2_000, 3_600_000]) {
|
||||
f.clock.value = new Date(Date.now() + ageMs);
|
||||
await observe();
|
||||
await runWakeLadderTick({ db: f.db, queueRepo: repo, now: f.clock.value,
|
||||
resolveOrchestrator: () => "lead@r1001", readPromptState: f.readPromptState, retryIntervalSeconds: 1,
|
||||
attemptWake: async (_id, target) => { targets.push(target); return "verified"; }, log: () => {} });
|
||||
}
|
||||
expect(targets).not.toContain("lead@r1001");
|
||||
expect(alerts()).toHaveLength(0);
|
||||
expect(f.paste).not.toHaveBeenCalled();
|
||||
// The next wake goes through normal delivery, once.
|
||||
expect((await policy.evaluate(job as never)).action).toBe("send");
|
||||
expect((await f.transport.send(f.name, "synthetic assigned-work wake")).ok).toBe(true);
|
||||
expect(f.paste).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -65,7 +65,7 @@ describe("a hosted member with a webhook trigger is rung when work lands for it"
|
||||
expect(outcome.nudgeResult).not.toContain(url);
|
||||
});
|
||||
|
||||
it("records a failed ring for an error, a redirect it doesn't follow, a missing secret and a trigger that doesn't answer", async () => {
|
||||
it("records a failed ring for an error, a redirect it doesn't follow and a missing secret", async () => {
|
||||
reply = { status: 500 };
|
||||
expect(await ringHostedWake(ring, wake, { env: env() })).toEqual({ classified: "failed", nudgeResult: "failed:webhook (GROK_WAKE_URL) answered 500" });
|
||||
|
||||
@@ -77,9 +77,15 @@ describe("a hosted member with a webhook trigger is rung when work lands for it"
|
||||
expect((await ringHostedWake(ring, wake, { env: { GROK_WAKE_URL: url } })).nudgeResult)
|
||||
.toBe("failed:webhook (GROK_WAKE_URL) not rung: its Authorization value (GROK_WAKE_AUTH) isn't set");
|
||||
expect(received).toHaveLength(2);
|
||||
});
|
||||
|
||||
// The ring reached a trigger that then didn't answer in time, so it may have started a run: unknown, like an unconfirmed
|
||||
// send, never failed.
|
||||
it("a trigger that doesn't answer in time reads unconfirmed, not failed", async () => {
|
||||
reply = "hang";
|
||||
expect((await ringHostedWake(ring, wake, { env: env(), timeoutMs: 100 })).nudgeResult).toBe("failed:webhook (GROK_WAKE_URL) didn't answer within 100 ms");
|
||||
expect(await ringHostedWake(ring, wake, { env: env(), timeoutMs: 100 })).toEqual({ classified: "indeterminate",
|
||||
nudgeResult: "indeterminate:webhook (GROK_WAKE_URL) didn't answer within 100 ms, so it may have the ring; it isn't rung again, and pickup is the member's claim on this row" });
|
||||
expect(received).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("through the queue: work for the member rings its trigger from the roster's secrets file, never its terminal, and a claim stays a separate fact", async () => {
|
||||
@@ -126,7 +132,7 @@ describe("a hosted member with a webhook trigger is rung when work lands for it"
|
||||
// Real topology and a repository whose trigger comes from a secrets file, as on a running daemon.
|
||||
// A repository over the same database stands in for the daemon after a restart; `transport: false` for one that
|
||||
// stops before sending.
|
||||
function hostedRepo(db: Database.Database, envLines: string, declared: RosterWake["webhook"] = wake.webhook, opts: { transport?: boolean } = {}) {
|
||||
function hostedRepo(db: Database.Database, envLines: string, declared: RosterWake["webhook"] = wake.webhook, opts: { transport?: boolean; fetchImpl?: typeof fetch } = {}) {
|
||||
db.prepare("INSERT OR IGNORE INTO rigs (id, name) VALUES ('rig-lab', 'lab')").run();
|
||||
db.prepare("INSERT OR IGNORE INTO nodes (id, rig_id, logical_id, runtime, cwd) VALUES ('node-lead', 'rig-lab', 'lead', 'claude-code', '/tmp')").run();
|
||||
const secrets = join(dir, "grok.env");
|
||||
@@ -136,6 +142,7 @@ describe("a hosted member with a webhook trigger is rung when work lands for it"
|
||||
loadHumanRegistry: () => ({ ok: true, entities: [] }),
|
||||
...(opts.transport === false ? {} : { transport: { send: async () => ({ ok: true, verified: true }) } }),
|
||||
hostedWake: (destination) => destination === "grok@lab" ? { webhook: { ...declared, secrets_env_file: secrets } } : null,
|
||||
...(opts.fetchImpl ? { hostedWakeFetch: opts.fetchImpl } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -184,6 +191,56 @@ describe("a hosted member with a webhook trigger is rung when work lands for it"
|
||||
}
|
||||
});
|
||||
|
||||
// A trigger that timed out may already be running the work, so startup recovery doesn't ring it again; a refused
|
||||
// connection never left, so recovery still retries it. A timed-out ring nobody claims is still surfaced to a person.
|
||||
const ringDouble = (first: "timeout" | "refused") => {
|
||||
const rings: string[] = [];
|
||||
let next: "timeout" | "refused" | "accept" = first;
|
||||
const fetchImpl = (async () => {
|
||||
rings.push(next);
|
||||
if (next === "timeout") throw Object.assign(new Error("The operation was aborted due to timeout"), { name: "TimeoutError" });
|
||||
if (next === "refused") throw Object.assign(new TypeError("fetch failed"), { cause: { code: "ECONNREFUSED" } });
|
||||
return new Response(null, { status: 202 });
|
||||
}) as typeof fetch;
|
||||
return { rings, fetchImpl, accept: () => { next = "accept"; } };
|
||||
};
|
||||
const firstResult = (first: "timeout" | "refused") => first === "timeout"
|
||||
? /^indeterminate:webhook \(GROK_WAKE_URL\) didn't answer within 10000 ms, so it may have the ring/
|
||||
: /^failed:webhook \(GROK_WAKE_URL\) couldn't be reached \(ECONNREFUSED\)/;
|
||||
const envLines = () => `GROK_WAKE_URL=${url}\nGROK_WAKE_AUTH=Bearer test-only-value\n`;
|
||||
|
||||
it("startup recovery doesn't ring a ring that timed out again; a refused one it rings again", async () => {
|
||||
for (const first of ["timeout", "refused"] as const) {
|
||||
const { rings, fetchImpl, accept } = ringDouble(first);
|
||||
const db = new Database(":memory:");
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
const repo = hostedRepo(db, envLines(), wake.webhook, { fetchImpl });
|
||||
repo.attachOutbox(new OutboxHandler(db));
|
||||
const row = await repo.create({ sourceSession: "lead@lab", destinationSession: "grok@lab", body: "work", summary: "Work" });
|
||||
await vi.waitFor(() => expect(repo.getById(row.qitemId)!.lastNudgeWireResult).toMatch(firstResult(first)));
|
||||
accept();
|
||||
await repo.drainPendingWakeIntents();
|
||||
expect(rings).toEqual(first === "timeout" ? ["timeout"] : ["refused", "accept"]);
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("a ring that timed out and was never claimed still reaches the member's person through the stuck sweep", async () => {
|
||||
const { fetchImpl } = ringDouble("timeout");
|
||||
const db = new Database(":memory:");
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
const repo = hostedRepo(db, envLines(), wake.webhook, { fetchImpl });
|
||||
const row = await repo.create({ sourceSession: "lead@lab", destinationSession: "grok@lab", body: "work", summary: "Work" });
|
||||
expect(repo.getById(row.qitemId)!.lastNudgeWireResult).toMatch(firstResult("timeout"));
|
||||
await runStuckSweep({ db, queueRepo: repo, status: createStuckSweepStatus(), resolveOrchestrator: () => null, unclaimedAgeMinutes: 60,
|
||||
now: new Date(Date.now() + 2 * 3_600_000), log: () => {}, isRegisteredHost: () => false,
|
||||
resolveOperatorSeat: () => "person@external", resolveRecoveryAgentSeat: () => null });
|
||||
const finding = repo.list({ limit: 50 }).find((item) => (item.tags ?? []).includes(STUCK_SWEEP_FINDING_TAG));
|
||||
expect(finding).toMatchObject({ destinationSession: "person@external" });
|
||||
expect(finding!.body).toContain(row.qitemId);
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("a new wake for the same row rings: parked on a blocker that completes, the member is rung again, and recovery adds none", async () => {
|
||||
const db = new Database(":memory:");
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
|
||||
@@ -71,6 +71,16 @@ const SHARED_FIXTURES: Array<{ label: string; parsed: unknown; ok: boolean }> =
|
||||
{ label: "invalid: reserved hostId 'kernel'", parsed: { hosts: [{ id: "vm-a", transport: "ssh", target: "a", hostId: "kernel" }] }, ok: false },
|
||||
{ label: "invalid: reserved hostId 'host'", parsed: { hosts: [{ id: "vm-a", transport: "ssh", target: "a", hostId: "host" }] }, ok: false },
|
||||
{ label: "invalid: reserved hostId 'external'", parsed: { hosts: [{ id: "vm-a", transport: "http", url: "http://x", hostId: "external" }] }, ok: false },
|
||||
// The ssh target and the optional user are argv elements, not shell words, so whitespace
|
||||
// never splits a destination, and a target that begins with `-` is handled by the `--` the
|
||||
// callers pass before it rather than by a registry refusal. The registry refuses neither;
|
||||
// these fixtures pin that in both twins, next to the ordinary shapes that must keep working.
|
||||
{ label: "valid: ssh target with whitespace (argv keeps it whole)", parsed: { hosts: [{ id: "x", transport: "ssh", target: "two words" }] }, ok: true },
|
||||
{ label: "valid: ssh user with a space in the login name (OpenSSH accepts it)", parsed: { hosts: [{ id: "x", transport: "ssh", target: "vm-a", user: "Jane Doe" }] }, ok: true },
|
||||
{ label: "valid: option-shaped target passes validation; `--` handles it at the call site", parsed: { hosts: [{ id: "x", transport: "ssh", target: "-oProxyCommand=touch /tmp/nothing" }] }, ok: true },
|
||||
{ label: "valid: ssh target as user@host", parsed: { hosts: [{ id: "x", transport: "ssh", target: "[email protected]" }] }, ok: true },
|
||||
{ label: "valid: ssh target as an IPv6 literal", parsed: { hosts: [{ id: "x", transport: "ssh", target: "admin@[::1]" }] }, ok: true },
|
||||
{ label: "valid: ssh host alias with a user", parsed: { hosts: [{ id: "x", transport: "ssh", target: "box", user: "admin" }] }, ok: true },
|
||||
];
|
||||
|
||||
describe("hosts-registry reader — CLI/daemon validator parity (shared fixtures)", () => {
|
||||
|
||||
@@ -13,6 +13,8 @@ import { NodeLauncher } from "../src/domain/node-launcher.js";
|
||||
import { StartupOrchestrator } from "../src/domain/startup-orchestrator.js";
|
||||
import { reconcileSkillLoadout } from "../src/domain/skill-catalog.js";
|
||||
import { PodRigInstantiator } from "../src/domain/rigspec-instantiator.js";
|
||||
import { WhoamiService } from "../src/domain/whoami-service.js";
|
||||
import { TranscriptStore } from "../src/domain/transcript-store.js";
|
||||
import { BootstrapOrchestrator } from "../src/domain/bootstrap-orchestrator.js";
|
||||
import { ServiceOrchestrator } from "../src/domain/service-orchestrator.js";
|
||||
import { ComposeServicesAdapter } from "../src/adapters/compose-services-adapter.js";
|
||||
@@ -108,6 +110,34 @@ describe("PodRigInstantiator", () => {
|
||||
return { db, rigRepo, podRepo, sessionRegistry, eventBus, inst, adapter, codexAdapter, tmux };
|
||||
}
|
||||
|
||||
// #2871: a seat that asks who's on its team while `rig up` is still building it is told so; once built, the answer is today's.
|
||||
it("whoami says the team is still being built during instantiate, and answers as before once it's built", async () => {
|
||||
const files = { [`${RIG_ROOT}/agents/impl/agent.yaml`]: agentYaml("impl"), [`${RIG_ROOT}/agents/qa/agent.yaml`]: agentYaml("qa") };
|
||||
const { db, rigRepo, sessionRegistry, inst } = setup(files);
|
||||
const whoami = new WhoamiService({ db, rigRepo, sessionRegistry, transcriptStore: new TranscriptStore({ transcriptsRoot: "/tmp/transcripts", enabled: true }) });
|
||||
const spec = makeRigSpec({ pods: [{ id: "dev", label: "Dev", members: [
|
||||
{ id: "impl", agentRef: "local:agents/impl", profile: "default", runtime: "claude-code", cwd: "." },
|
||||
{ id: "qa", agentRef: "local:agents/qa", profile: "default", runtime: "claude-code", cwd: "." },
|
||||
], edges: [{ from: "impl", to: "qa", kind: "delegates_to" }] }] });
|
||||
const early: Array<ReturnType<WhoamiService["resolve"]>> = [];
|
||||
let firstNode: string | undefined;
|
||||
const result = await inst.instantiate(RigSpecCodec.serialize(spec), RIG_ROOT, { recordCreated: (_rigId, nodeId) => {
|
||||
if (!nodeId) return;
|
||||
firstNode ??= nodeId;
|
||||
early.push(whoami.resolve({ nodeId: firstNode }));
|
||||
} });
|
||||
expect(result.ok).toBe(true);
|
||||
// Mid-launch: the first seat sees only what exists so far, and is told the team isn't complete.
|
||||
expect(early[0]).toMatchObject({ rosterState: "building", peers: [], edges: { outgoing: [], incoming: [] } });
|
||||
expect(early[0]!.peersNote).toMatch(/^rosterState = building: `rig up` is still creating this team's seats and edges/);
|
||||
// Built: the full roster and edges, with no building state and today's note.
|
||||
const done = whoami.resolve({ nodeId: firstNode! });
|
||||
expect(done).not.toHaveProperty("rosterState");
|
||||
expect(done.peersNote).toBe("peers = this rig's roster excluding self (no edge filter); edges = directional relationships; use `rig ps --nodes` for node inventory including self + live state");
|
||||
expect(done.peers.map((peer) => peer.logicalId)).toEqual(["dev.qa"]);
|
||||
expect(done.edges.outgoing).toMatchObject([{ kind: "delegates_to", to: { logicalId: "dev.qa" } }]);
|
||||
});
|
||||
|
||||
// T1: valid rig instantiates pods + nodes + edges
|
||||
it("instantiates pods + nodes correctly", async () => {
|
||||
const { db, inst } = setup();
|
||||
|
||||
@@ -12,7 +12,6 @@ import type { PolicyJob } from "../src/domain/policies/types.js";
|
||||
import { WatchdogHistoryLog, type WatchdogHistoryEntry } from "../src/domain/watchdog-history-log.js";
|
||||
import { WatchdogJobsRepository } from "../src/domain/watchdog-jobs-repository.js";
|
||||
import { tmuxAttachCommand } from "../src/adapters/tmux-server.js";
|
||||
import { shellQuote } from "../src/adapters/shell-quote.js";
|
||||
import { makeKernelHookReviewReader } from "../src/domain/kernel-hook-review.js";
|
||||
import { verifyCodexPaneProcess, type NativeProcessRow } from "../src/domain/native-process-lineage.js";
|
||||
import { runWakeLadderTick, queueRecoveryOwnsWake, classifyPromptAfterRefusal } from "../src/domain/queue-wake-ladder.js";
|
||||
@@ -481,7 +480,7 @@ describe("prompt-blocked outstanding work reaches the configured route", () => {
|
||||
await hookTick(port);
|
||||
const updated = repo.getById(alert.qitemId)!;
|
||||
expect(updated.summary).toContain("Hooks need review");
|
||||
expect(updated.summary).toContain(tmuxAttachCommand(shellQuote(seat)));
|
||||
expect(updated.summary).toContain(tmuxAttachCommand(seat));
|
||||
expect(notes(alert.qitemId).filter(n => n.startsWith("hook-review observed:"))).toHaveLength(1);
|
||||
expect(notes(alert.qitemId).join("\n")).toContain("through Codex's hook review");
|
||||
expect(updated.body).toBe(before.body); // retain the original delivered content and receipt
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// A harness's prompt hook is recorded as its own raw observation, never as a message's attribution: no harness reports a
|
||||
// prompt's origin, so a delivery record's input boundary stays unknown even when the texts match.
|
||||
import { afterEach, describe, it, expect } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import { createFullTestDb } from "./helpers/test-app.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { activityRoutes } from "../src/routes/activity.js";
|
||||
|
||||
const TOKEN = "test-token";
|
||||
const SESSION = "dev-impl@r";
|
||||
const SHA = "a".repeat(64);
|
||||
const closeDbs: Array<() => void> = [];
|
||||
afterEach(() => { for (const close of closeDbs.splice(0)) close(); });
|
||||
|
||||
function makeApp() {
|
||||
const db = createFullTestDb();
|
||||
closeDbs.push(() => db.close());
|
||||
const bus = new EventBus(db);
|
||||
const store = { db, recordHookEvent: () => ({ ok: true as const, activity: { state: "running", reason: "prompt", evidenceSource: "hook", sampledAt: new Date().toISOString(), evidence: null, runtime: "claude-code" }, event: {} }) };
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("agentActivityStore" as never, store as never);
|
||||
c.set("activityHookToken" as never, TOKEN as never);
|
||||
c.set("eventBus" as never, bus as never);
|
||||
await next();
|
||||
});
|
||||
app.route("/api/activity", activityRoutes);
|
||||
const post = (fields: Record<string, unknown>) => app.request("/api/activity/hooks", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", authorization: `Bearer ${TOKEN}` },
|
||||
body: JSON.stringify({ runtime: "claude-code", sessionName: SESSION, hookEvent: "UserPromptSubmit", generation: "gen-1",
|
||||
occurredAt: "2026-11-11T03:00:01.000Z", promptSha256: SHA, promptBytes: 5, ...fields }),
|
||||
});
|
||||
const rows = () => (db.prepare("SELECT type, node_id, payload FROM events ORDER BY seq").all() as Array<{ type: string; node_id: string | null; payload: string }>)
|
||||
.map((row) => ({ type: row.type, nodeId: row.node_id, ...JSON.parse(row.payload) }));
|
||||
return { bus, post, rows };
|
||||
}
|
||||
|
||||
describe("the prompt hook's raw observation", () => {
|
||||
it("records what the hook reported, with no node, and attributes no message even when a write's text matches", async () => {
|
||||
const { bus, post, rows } = makeApp();
|
||||
bus.emit({ type: "transport.delivery_evidence", sessionName: SESSION, seatKind: "tmux", pane: "%1", occupantGeneration: "gen-1",
|
||||
messageId: "m1", attemptId: "a1", admitted: "none", written: "submitted", terminalObserved: { outcome: null, verified: null },
|
||||
inputBoundary: "unknown", source: "send_result", wireSha256: SHA, wireBytes: 5, writtenAt: "2026-11-11T03:00:00.000Z" });
|
||||
const res = await post({ sessionId: "native-session-1", nativePromptId: "p-1", turnId: "t-1" });
|
||||
expect(res.status).toBe(200);
|
||||
expect(rows().map((row) => row.type)).toEqual(["transport.delivery_evidence", "transport.prompt_observation"]);
|
||||
expect(rows()[1]).toEqual({ type: "transport.prompt_observation", nodeId: null, sessionName: SESSION, occupantGeneration: "gen-1",
|
||||
runtime: "claude-code", source: "UserPromptSubmit", nativeSessionId: "native-session-1", nativePromptId: "p-1", nativeTurnId: "t-1",
|
||||
promptSha256: SHA, promptBytes: 5, observedAt: "2026-11-11T03:00:01.000Z" });
|
||||
expect(rows()[0]).toMatchObject({ messageId: "m1", inputBoundary: "unknown" });
|
||||
});
|
||||
|
||||
it("records nothing without a valid fingerprint and length, leaves out a bad id, reads a time as an instant, and answers the hook each time", async () => {
|
||||
const { post, rows } = makeApp();
|
||||
for (const fields of [{ promptSha256: "not-a-sha" }, { promptBytes: -1 }, { promptBytes: 1.5 }, { promptSha256: undefined }]) {
|
||||
expect((await post(fields)).status).toBe(200);
|
||||
}
|
||||
expect(rows()).toEqual([]);
|
||||
expect((await post({ nativePromptId: "bad id/..", occurredAt: "not a time", generation: null })).status).toBe(200);
|
||||
expect((await post({ occurredAt: "2026-11-11T05:00:01+02:00" })).status).toBe(200);
|
||||
expect(rows()).toMatchObject([
|
||||
{ type: "transport.prompt_observation", nativePromptId: null, observedAt: null, occupantGeneration: null },
|
||||
{ type: "transport.prompt_observation", observedAt: "2026-11-11T03:00:01.000Z" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,438 @@
|
||||
import { execFileSync, spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { once } from "node:events";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { TmuxAdapter } from "../src/adapters/tmux.js";
|
||||
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { CheckpointStore } from "../src/domain/checkpoint-store.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { readProcessTable, type ProcessTableRow } from "../src/domain/process-table.js";
|
||||
import { ResumeMetadataRefresher } from "../src/domain/resume-metadata-refresher.js";
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import { RigTeardownOrchestrator } from "../src/domain/rig-teardown.js";
|
||||
import { seatRuntimeHome } from "../src/domain/seat-runtime-home.js";
|
||||
import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
import { SnapshotCapture } from "../src/domain/snapshot-capture.js";
|
||||
import { SnapshotRepository } from "../src/domain/snapshot-repository.js";
|
||||
|
||||
interface ProviderLaunchRecord {
|
||||
pid: number;
|
||||
conversation: string;
|
||||
model: string;
|
||||
permission: string;
|
||||
auth: string;
|
||||
remembered: boolean;
|
||||
}
|
||||
|
||||
describe.skipIf(process.platform === "win32")("provider-switch synthetic Claude fixture shutdown capture contract", () => {
|
||||
let binRoot: string;
|
||||
let claudeFixtureBinary: string;
|
||||
let root: string;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
let rigRepo: RigRepository;
|
||||
let registry: SessionRegistry;
|
||||
let eventBus: EventBus;
|
||||
let snapshotRepo: SnapshotRepository;
|
||||
let capture: SnapshotCapture;
|
||||
const cleanupCallbacks: Array<() => Promise<void>> = [];
|
||||
|
||||
beforeAll(() => {
|
||||
binRoot = fs.mkdtempSync(path.join(os.tmpdir(), "provider-switch-claude-bin-"));
|
||||
claudeFixtureBinary = path.join(binRoot, "claude");
|
||||
const fixtureSource = fileURLToPath(new URL("fixtures/provider-switch-claude.c", import.meta.url));
|
||||
const shippedRelay = fileURLToPath(
|
||||
new URL("../assets/plugins/openrig-core/hooks/scripts/activity-relay.cjs", import.meta.url),
|
||||
);
|
||||
execFileSync("cc", [
|
||||
fixtureSource,
|
||||
"-o",
|
||||
claudeFixtureBinary,
|
||||
`-DFIXTURE_NODE=${JSON.stringify(process.execPath)}`,
|
||||
`-DFIXTURE_RELAY=${JSON.stringify(shippedRelay)}`,
|
||||
]);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (binRoot) fs.rmSync(binRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), "provider-switch-shutdown-"));
|
||||
db = createDb();
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
rigRepo = new RigRepository(db);
|
||||
registry = new SessionRegistry(db);
|
||||
eventBus = new EventBus(db);
|
||||
snapshotRepo = new SnapshotRepository(db);
|
||||
capture = new SnapshotCapture({
|
||||
db,
|
||||
rigRepo,
|
||||
sessionRegistry: registry,
|
||||
eventBus,
|
||||
snapshotRepo,
|
||||
checkpointStore: new CheckpointStore(db),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
while (cleanupCallbacks.length > 0) {
|
||||
const stop = cleanupCallbacks.pop()!;
|
||||
await stop();
|
||||
}
|
||||
db.close();
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function waitForPrompt(child: ChildProcessWithoutNullStreams): Promise<void> {
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error(`synthetic provider fixture timed out waiting for prompt (stdout=${JSON.stringify(stdout)}, stderr=${JSON.stringify(stderr)})`));
|
||||
}, 10_000);
|
||||
const onData = (chunk: Buffer) => {
|
||||
stdout += chunk.toString("utf8");
|
||||
if (stdout.includes("❯ ")) {
|
||||
cleanup();
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
const onStderr = (chunk: Buffer) => {
|
||||
stderr += chunk.toString("utf8");
|
||||
};
|
||||
const onError = (err: Error) => {
|
||||
cleanup();
|
||||
reject(err);
|
||||
};
|
||||
const onExit = (code: number | null, signal: NodeJS.Signals | null) => {
|
||||
cleanup();
|
||||
reject(new Error(`synthetic provider fixture exited before prompt (code=${String(code)}, signal=${String(signal)}, stderr=${JSON.stringify(stderr)})`));
|
||||
};
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer);
|
||||
child.stdout.off("data", onData);
|
||||
child.stderr.off("data", onStderr);
|
||||
child.off("error", onError);
|
||||
child.off("exit", onExit);
|
||||
};
|
||||
child.stdout.on("data", onData);
|
||||
child.stderr.on("data", onStderr);
|
||||
child.once("error", onError);
|
||||
child.once("exit", onExit);
|
||||
});
|
||||
}
|
||||
|
||||
async function launchProviderSwitchFixtureSeat(options: {
|
||||
storedToken: string;
|
||||
provenance: "operator" | "scrape" | "hook";
|
||||
args: string[];
|
||||
extraEnv: Record<string, string>;
|
||||
seatName?: string;
|
||||
}) {
|
||||
const workDir = path.join(root, "work");
|
||||
const homeDir = path.join(root, "home");
|
||||
const openrigHome = path.join(root, "openrig-home");
|
||||
const daemonConfigDir = path.join(root, "daemon-claude");
|
||||
const selectedConfigDir = path.join(root, "account");
|
||||
const scratchTmpDir = path.join(root, "tmp");
|
||||
for (const dir of [workDir, homeDir, openrigHome, daemonConfigDir, selectedConfigDir, scratchTmpDir]) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
const rig = rigRepo.createRig("provider-switch-fixture");
|
||||
const node = rigRepo.addNode(rig.id, "work.worker", {
|
||||
runtime: "claude-code",
|
||||
cwd: workDir,
|
||||
model: "fixture-model",
|
||||
restorePolicy: "resume_if_possible",
|
||||
});
|
||||
db.prepare("UPDATE nodes SET account_home = ? WHERE id = ?").run(selectedConfigDir, node.id);
|
||||
|
||||
const seatName = options.seatName ?? "r00-provider-switch-fixture-work_worker";
|
||||
const session = registry.registerSession(node.id, seatName);
|
||||
registry.updateStatus(session.id, "running");
|
||||
registry.updateBinding(node.id, { tmuxSession: seatName, tmuxPane: "%1" });
|
||||
registry.updateResumeToken(session.id, "claude_id", options.storedToken, options.provenance);
|
||||
|
||||
const child = spawn(claudeFixtureBinary, options.args, {
|
||||
cwd: workDir,
|
||||
env: {
|
||||
PATH: "/usr/bin:/bin",
|
||||
HOME: homeDir,
|
||||
TMPDIR: scratchTmpDir,
|
||||
OPENRIG_HOME: openrigHome,
|
||||
CLAUDE_CONFIG_DIR: selectedConfigDir,
|
||||
OPENRIG_NODE_ID: node.id,
|
||||
OPENRIG_RUNTIME: "claude-code",
|
||||
...options.extraEnv,
|
||||
},
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
const stopChild = async () => {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
const exited = once(child, "close");
|
||||
child.kill("SIGTERM");
|
||||
const killTimer = setTimeout(() => {
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
||||
}, 2_000);
|
||||
try {
|
||||
await exited;
|
||||
} finally {
|
||||
clearTimeout(killTimer);
|
||||
}
|
||||
};
|
||||
cleanupCallbacks.push(stopChild);
|
||||
|
||||
await waitForPrompt(child);
|
||||
const childPid = child.pid!;
|
||||
|
||||
const panePid = process.pid;
|
||||
const readForegroundRows = async (): Promise<ProcessTableRow[]> => {
|
||||
const table = await readProcessTable();
|
||||
const selfRow = table.find((row) => row.pid === panePid);
|
||||
const liveRow = table.find((row) => row.pid === childPid);
|
||||
if (!selfRow || !liveRow) return [];
|
||||
return [
|
||||
{
|
||||
pid: selfRow.pid,
|
||||
ppid: selfRow.ppid,
|
||||
pgid: panePid,
|
||||
tpgid: liveRow.pid,
|
||||
executableName: "bash",
|
||||
startedAt: selfRow.startedAt,
|
||||
command: "bash",
|
||||
},
|
||||
{
|
||||
...liveRow,
|
||||
ppid: panePid,
|
||||
pgid: liveRow.pid,
|
||||
tpgid: liveRow.pid,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const initialSample = await readForegroundRows();
|
||||
const initialLiveRow = initialSample.find((row) => row.pid === childPid);
|
||||
expect(initialLiveRow, "expected live compiled provider-switch-claude OS process in process table").toBeDefined();
|
||||
expect(Number.isFinite(Date.parse(initialLiveRow!.startedAt))).toBe(true);
|
||||
|
||||
const listClaudeProcesses = vi.fn(readForegroundRows);
|
||||
const probeClaudeResume = vi.fn(async () => "inconclusive" as const);
|
||||
const tmux = {
|
||||
getPanePid: vi.fn(async () => panePid),
|
||||
killSession: vi.fn(async () => {
|
||||
await stopChild();
|
||||
return { ok: true as const };
|
||||
}),
|
||||
} as unknown as TmuxAdapter;
|
||||
|
||||
const refresher = new ResumeMetadataRefresher({
|
||||
sessionRegistry: registry,
|
||||
tmuxAdapter: tmux,
|
||||
homeDir,
|
||||
claudeConfigDir: daemonConfigDir,
|
||||
seatHome: (nodeId, runtime) => seatRuntimeHome(db, nodeId, runtime).home,
|
||||
listClaudeProcesses,
|
||||
probeClaudeResume,
|
||||
});
|
||||
const teardown = new RigTeardownOrchestrator({
|
||||
db,
|
||||
rigRepo,
|
||||
sessionRegistry: registry,
|
||||
eventBus,
|
||||
snapshotCapture: capture,
|
||||
tmuxAdapter: tmux,
|
||||
resumeMetadataRefresher: refresher,
|
||||
});
|
||||
|
||||
const readLaunches = (): ProviderLaunchRecord[] => {
|
||||
const journalPath = path.join(openrigHome, "provider-launches.jsonl");
|
||||
return fs
|
||||
.readFileSync(journalPath, "utf8")
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line) as ProviderLaunchRecord);
|
||||
};
|
||||
|
||||
return {
|
||||
rig,
|
||||
node,
|
||||
session,
|
||||
seatName,
|
||||
workDir,
|
||||
openrigHome,
|
||||
daemonConfigDir,
|
||||
selectedConfigDir,
|
||||
childPid,
|
||||
initialLiveRow: initialLiveRow!,
|
||||
pidSessionFile: path.join(selectedConfigDir, "sessions", `${childPid}.json`),
|
||||
probeClaudeResume,
|
||||
listClaudeProcesses,
|
||||
tmux,
|
||||
refresher,
|
||||
teardown,
|
||||
readLaunches,
|
||||
};
|
||||
}
|
||||
|
||||
it("writes current-PID session metadata from explicit --name + --resume so shutdown captures without fallback probe", async () => {
|
||||
const conversation = randomUUID();
|
||||
const seatName = "r00-provider-switch-fixture-work_worker";
|
||||
const h = await launchProviderSwitchFixtureSeat({
|
||||
storedToken: conversation,
|
||||
provenance: "operator",
|
||||
seatName,
|
||||
args: ["--resume", conversation, "--name", seatName, "--model", "fixture-model", "--permission-mode", "acceptEdits"],
|
||||
extraEnv: {
|
||||
OPENRIG_SESSION_NAME: seatName,
|
||||
ANTHROPIC_API_KEY: "synthetic-api",
|
||||
},
|
||||
});
|
||||
|
||||
expect(h.readLaunches()).toEqual([
|
||||
{
|
||||
pid: h.childPid,
|
||||
conversation,
|
||||
model: "fixture-model",
|
||||
permission: "acceptEdits",
|
||||
auth: "api",
|
||||
remembered: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await h.teardown.teardown(h.rig.id);
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(result.sessionsKilled).toBe(1);
|
||||
expect(h.listClaudeProcesses).toHaveBeenCalled();
|
||||
|
||||
const recordExists = fs.existsSync(h.pidSessionFile);
|
||||
expect
|
||||
.soft(recordExists, "expected synthetic fixture to write <CLAUDE_CONFIG_DIR>/sessions/<pid>.json for explicit --name + --resume")
|
||||
.toBe(true);
|
||||
if (recordExists) {
|
||||
const stat = fs.statSync(h.pidSessionFile);
|
||||
expect.soft(stat.size).toBeLessThanOrEqual(64 * 1024);
|
||||
expect.soft(stat.mtimeMs).toBeGreaterThanOrEqual(Date.parse(h.initialLiveRow.startedAt));
|
||||
expect.soft(JSON.parse(fs.readFileSync(h.pidSessionFile, "utf8"))).toMatchObject({
|
||||
name: h.seatName,
|
||||
sessionId: conversation,
|
||||
});
|
||||
}
|
||||
|
||||
expect
|
||||
.soft(h.probeClaudeResume, "expected captureClaudeSessionId hit without invoking fallback probeClaudeResume")
|
||||
.not.toHaveBeenCalled();
|
||||
const updated = registry.getSessionsForRig(h.rig.id)[0]!;
|
||||
expect.soft(updated.resumeToken).toBe(conversation);
|
||||
expect.soft(updated.resumeLastProbeStatus).toBe("resumable");
|
||||
});
|
||||
|
||||
it("writes current-PID session metadata from --name + --session-id so shutdown captures conversation without fallback probe", async () => {
|
||||
const initialConversation = randomUUID();
|
||||
const activeConversation = randomUUID();
|
||||
const seatName = "r00-provider-switch-fixture-work_worker";
|
||||
const h = await launchProviderSwitchFixtureSeat({
|
||||
storedToken: initialConversation,
|
||||
provenance: "scrape",
|
||||
seatName,
|
||||
args: ["--session-id", activeConversation, "--name", seatName, "--model", "fixture-model", "--permission-mode", "acceptEdits"],
|
||||
extraEnv: {
|
||||
CLAUDE_CODE_OAUTH_TOKEN: "synthetic-plan",
|
||||
},
|
||||
});
|
||||
|
||||
expect(h.readLaunches()).toEqual([
|
||||
{
|
||||
pid: h.childPid,
|
||||
conversation: activeConversation,
|
||||
model: "fixture-model",
|
||||
permission: "acceptEdits",
|
||||
auth: "plan",
|
||||
remembered: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await h.teardown.teardown(h.rig.id);
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(result.sessionsKilled).toBe(1);
|
||||
expect(h.listClaudeProcesses).toHaveBeenCalled();
|
||||
|
||||
const recordExists = fs.existsSync(h.pidSessionFile);
|
||||
expect
|
||||
.soft(recordExists, "expected synthetic fixture to write <CLAUDE_CONFIG_DIR>/sessions/<pid>.json for --name + --session-id")
|
||||
.toBe(true);
|
||||
if (recordExists) {
|
||||
const stat = fs.statSync(h.pidSessionFile);
|
||||
expect.soft(stat.size).toBeLessThanOrEqual(64 * 1024);
|
||||
expect.soft(stat.mtimeMs).toBeGreaterThanOrEqual(Date.parse(h.initialLiveRow.startedAt));
|
||||
expect.soft(JSON.parse(fs.readFileSync(h.pidSessionFile, "utf8"))).toMatchObject({
|
||||
name: h.seatName,
|
||||
sessionId: activeConversation,
|
||||
});
|
||||
}
|
||||
|
||||
expect
|
||||
.soft(h.probeClaudeResume, "expected captureClaudeSessionId hit without invoking fallback probeClaudeResume")
|
||||
.not.toHaveBeenCalled();
|
||||
const updated = registry.getSessionsForRig(h.rig.id)[0]!;
|
||||
expect.soft(updated.resumeToken).toBe(activeConversation);
|
||||
const snap = snapshotRepo.getSnapshot(result.snapshotId!)!;
|
||||
expect.soft(snap.data.sessions.find((s) => s.id === h.session.id)?.resumeToken).toBe(activeConversation);
|
||||
});
|
||||
|
||||
it("does not emit current-PID session metadata when only OPENRIG_SESSION_NAME is set without explicit --name", async () => {
|
||||
const initialConversation = randomUUID();
|
||||
const resumedConversation = randomUUID();
|
||||
const seatName = "r00-provider-switch-fixture-work_worker";
|
||||
const h = await launchProviderSwitchFixtureSeat({
|
||||
storedToken: initialConversation,
|
||||
provenance: "scrape",
|
||||
seatName,
|
||||
args: ["--resume", resumedConversation, "--model", "fixture-model", "--permission-mode", "acceptEdits"],
|
||||
extraEnv: {
|
||||
OPENRIG_SESSION_NAME: seatName,
|
||||
ANTHROPIC_API_KEY: "synthetic-api",
|
||||
},
|
||||
});
|
||||
|
||||
expect(h.readLaunches()).toEqual([
|
||||
{
|
||||
pid: h.childPid,
|
||||
conversation: resumedConversation,
|
||||
model: "fixture-model",
|
||||
permission: "acceptEdits",
|
||||
auth: "api",
|
||||
remembered: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await h.teardown.teardown(h.rig.id);
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(result.sessionsKilled).toBe(1);
|
||||
expect(h.listClaudeProcesses).toHaveBeenCalled();
|
||||
|
||||
expect
|
||||
.soft(fs.existsSync(h.pidSessionFile), "expected synthetic fixture not to emit <CLAUDE_CONFIG_DIR>/sessions/<pid>.json without explicit --name")
|
||||
.toBe(false);
|
||||
expect
|
||||
.soft(h.probeClaudeResume, "expected launch without explicit --name to fall through to fallback probeClaudeResume")
|
||||
.toHaveBeenCalledTimes(1);
|
||||
expect.soft(h.probeClaudeResume).toHaveBeenCalledWith(h.seatName, initialConversation, h.workDir, h.selectedConfigDir);
|
||||
|
||||
const updated = registry.getSessionsForRig(h.rig.id)[0]!;
|
||||
expect.soft(updated.resumeToken).toBe(initialConversation);
|
||||
expect.soft(updated.resumeLastProbeStatus).toBe("inconclusive");
|
||||
const snap = snapshotRepo.getSnapshot(result.snapshotId!)!;
|
||||
expect.soft(snap.data.sessions.find((s) => s.id === h.session.id)?.resumeToken).toBe(initialConversation);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
// A wake that failed at a live agent seat with no delegation parent or escalates_to got a finding addressed back to that
|
||||
// seat, whose own wake then went to the seat that had just failed to take one. That finding now goes to the row's creator,
|
||||
// or for a seat that sent itself the work, to the operator agent. A declared route keeps its routing and its fallback.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import Database from "better-sqlite3";
|
||||
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { QueueRepository, type QueueItem } from "../src/domain/queue-repository.js";
|
||||
import { runStuckSweep, createStuckSweepStatus, STUCK_SWEEP_FINDING_TAG } from "../src/domain/queue-stuck-sweep.js";
|
||||
|
||||
describe("stuck sweep: a failed wake at a seat with no declared route", () => {
|
||||
let db: Database.Database;
|
||||
let rigs: RigRepository;
|
||||
let queue: QueueRepository;
|
||||
let seq: number;
|
||||
beforeEach(() => {
|
||||
db = new Database(":memory:");
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
rigs = new RigRepository(db);
|
||||
queue = new QueueRepository(db, new EventBus(db), { validateRig: () => true, loadHumanRegistry: () => ({ ok: true, entities: [] }) });
|
||||
seq = 0;
|
||||
});
|
||||
afterEach(() => db.close());
|
||||
|
||||
/** An agent seat; with `running` false it has no session. */
|
||||
function seat(rigName: string, logicalId: string, session: string, running = true) {
|
||||
const rig = rigs.listRigs().find((r) => r.name === rigName) ?? rigs.createRig(rigName);
|
||||
const node = rigs.addNode(rig.id, logicalId, { runtime: "claude-code" });
|
||||
if (running) db.prepare("INSERT INTO sessions (id,node_id,session_name,status) VALUES (?,?,?,?)").run(`session-${++seq}`, node.id, session, "running");
|
||||
return { rig, node, session };
|
||||
}
|
||||
async function sweep(overrides: Record<string, unknown> = {}) {
|
||||
return runStuckSweep({ db, queueRepo: queue, status: createStuckSweepStatus(), unclaimedAgeMinutes: 60, log: () => {},
|
||||
isRegisteredHost: () => false, resolveRecoveryAgentSeat: () => null, resolveOperatorSeat: () => null, ...overrides });
|
||||
}
|
||||
/** A row for `destination` whose wake failed. */
|
||||
async function failedWake(destination: string, source = "sender@lane"): Promise<QueueItem> {
|
||||
const row = await queue.create({ sourceSession: source, destinationSession: destination, body: "Decide the scope", nudge: false });
|
||||
db.prepare("UPDATE queue_items SET last_nudge_attempt = ?, last_nudge_result = ? WHERE qitem_id = ?")
|
||||
.run(new Date().toISOString(), "failed:target_needs_input", row.qitemId);
|
||||
return row;
|
||||
}
|
||||
const findingsFor = (row: QueueItem): QueueItem[] => queue.list({ limit: 500 })
|
||||
.filter((item) => (item.tags ?? []).includes(STUCK_SWEEP_FINDING_TAG) && (item.tags ?? []).includes(`stuck-sweep:undelivered-wake:${row.qitemId}`))
|
||||
.map((item) => queue.getById(item.qitemId)!);
|
||||
|
||||
it("sends the finding to the row's creator, and says why", async () => {
|
||||
seat("front", "door.root", "root@front");
|
||||
const row = await failedWake("root@front");
|
||||
await sweep();
|
||||
const [finding, ...more] = findingsFor(row);
|
||||
expect(more).toHaveLength(0);
|
||||
expect(finding!.destinationSession).toBe("sender@lane");
|
||||
expect(finding!.body).toContain("root@front declares no delegation parent or escalates_to, so this finding goes to sender@lane, the row's creator");
|
||||
});
|
||||
|
||||
it("a seat that sent itself the work gets the operator agent instead", async () => {
|
||||
seat("front", "door.root", "root@front");
|
||||
const row = await failedWake("root@front", "root@front");
|
||||
await sweep({ resolveRecoveryAgentSeat: () => "operator-admin@kernel" });
|
||||
const [finding] = findingsFor(row);
|
||||
expect(finding!.destinationSession).toBe("operator-admin@kernel");
|
||||
expect(finding!.body).toContain("so this finding goes to operator-admin@kernel, the operator agent (agents.operator_session)");
|
||||
});
|
||||
|
||||
it("with no operator agent, a self-sent row's finding stays with the seat and says so", async () => {
|
||||
seat("front", "door.root", "root@front");
|
||||
const row = await failedWake("root@front", "root@front");
|
||||
await sweep();
|
||||
const [finding] = findingsFor(row);
|
||||
expect(finding!.destinationSession).toBe("root@front");
|
||||
expect(finding!.body).toContain("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");
|
||||
});
|
||||
|
||||
// Rows that OpenRig's own machinery creates (stream intake and startup write them as daemon@kernel) have no creator that
|
||||
// can act on a finding.
|
||||
it.each(["daemon@kernel", "stuck-sweep@system"])("a row created by OpenRig's machinery (%s) gets the operator agent", async (machinery) => {
|
||||
seat("front", "door.root", "root@front");
|
||||
const row = await failedWake("root@front", machinery);
|
||||
await sweep({ resolveRecoveryAgentSeat: () => "operator-admin@kernel" });
|
||||
const [finding] = findingsFor(row);
|
||||
expect(finding!.destinationSession).toBe("operator-admin@kernel");
|
||||
expect(finding!.body).toContain("so this finding goes to operator-admin@kernel, the operator agent (agents.operator_session)");
|
||||
});
|
||||
|
||||
it("reads a seat's delegation parent again after the pass writes a finding", async () => {
|
||||
const lead = seat("lane", "work.lead", "lead@lane");
|
||||
const first = await failedWake("lead@lane");
|
||||
const second = await failedWake("lead@lane");
|
||||
// Another writer declares the seat's parent (with no session yet) while the pass awaits its first finding's write.
|
||||
const parent = seat("lane", "coord.owner", "owner@lane", false);
|
||||
const create = queue.create.bind(queue);
|
||||
let declared = false;
|
||||
vi.spyOn(queue, "create").mockImplementation(async (input) => {
|
||||
const created = await create(input);
|
||||
if (!declared && (input.tags ?? []).includes(STUCK_SWEEP_FINDING_TAG)) {
|
||||
declared = true;
|
||||
rigs.addEdge(lead.rig.id, parent.node.id, lead.node.id, "delegates_to");
|
||||
}
|
||||
return created;
|
||||
});
|
||||
await sweep();
|
||||
const routes = [first, second].map((row) => findingsFor(row)[0]!.destinationSession);
|
||||
// The first finding was routed before the parent existed; the second sees it and keeps the parent's fallback.
|
||||
expect(routes.sort()).toEqual(["lead@lane", "sender@lane"]);
|
||||
});
|
||||
|
||||
it("passes over a creator the queue doesn't take as an address", async () => {
|
||||
queue = new QueueRepository(db, new EventBus(db), {
|
||||
validateRig: (destination) => destination !== "sender@ops@elsewhere", loadHumanRegistry: () => ({ ok: true, entities: [] }),
|
||||
});
|
||||
seat("front", "door.root", "root@front");
|
||||
const row = await failedWake("root@front", "sender@ops@elsewhere");
|
||||
await sweep({ resolveRecoveryAgentSeat: () => "operator-admin@kernel" });
|
||||
expect(findingsFor(row)[0]!.destinationSession).toBe("operator-admin@kernel");
|
||||
});
|
||||
|
||||
// A bound terminal seat (a shell or a TUI) can't act on a finding: no wake is typed into it, and nothing there claims
|
||||
// work. The no-wake rule passes it over, and so must this one.
|
||||
it("passes over a creator that is a bound terminal seat", async () => {
|
||||
const { rig } = seat("front", "door.root", "root@front");
|
||||
const logger = rigs.addNode(rig.id, "logger", { runtime: "terminal" });
|
||||
db.prepare("INSERT INTO sessions (id,node_id,session_name,status) VALUES (?,?,?,?)").run(`session-${++seq}`, logger.id, "logger@front", "running");
|
||||
db.prepare("INSERT INTO bindings (id,node_id,attachment_type,tmux_session,tmux_pane) VALUES (?,?,'tmux',?,'%2')").run("bind-logger", logger.id, "logger@front");
|
||||
const row = await failedWake("root@front", "logger@front");
|
||||
await sweep({ resolveRecoveryAgentSeat: () => "operator-admin@kernel" });
|
||||
const [finding] = findingsFor(row);
|
||||
expect(finding!.destinationSession).toBe("operator-admin@kernel");
|
||||
expect(finding!.body).toContain("so this finding goes to operator-admin@kernel, the operator agent (agents.operator_session)");
|
||||
});
|
||||
|
||||
it("a hosted member whose ring failed keeps the no-wake rule's choice when that rule finds nobody", async () => {
|
||||
queue = new QueueRepository(db, new EventBus(db), {
|
||||
validateRig: () => true, loadHumanRegistry: () => ({ ok: true, entities: [] }),
|
||||
hostedWake: (destination) => destination === "grok@front" ? { webhook: { url_ref: "GROK_WAKE_URL" } } : null,
|
||||
});
|
||||
// A recorded seat with a session and no terminal: the sweep can read its topology, and it still can't be typed into.
|
||||
seat("front", "grok", "grok@front");
|
||||
expect(queue.classifyDestinationOf("grok@front").class).toBe("hosted");
|
||||
// A creator with no wake route here: the no-wake rule passes it over, and so must this one.
|
||||
const row = await failedWake("grok@front", "someone@elsewhere");
|
||||
db.prepare("UPDATE queue_items SET last_nudge_result = ? WHERE qitem_id = ?").run("failed:webhook (GROK_WAKE_URL) answered 500", row.qitemId);
|
||||
await sweep();
|
||||
const [finding] = findingsFor(row);
|
||||
expect(finding!.destinationSession).toBe("grok@front");
|
||||
expect(finding!.body).toContain("No person, creator or operator agent that can be reached is available");
|
||||
});
|
||||
|
||||
it("a declared delegation parent keeps the finding where it was, even when the parent has no session", async () => {
|
||||
const lead = seat("lane", "work.lead", "lead@lane");
|
||||
const parent = seat("lane", "coord.owner", "owner@lane", false);
|
||||
rigs.addEdge(lead.rig.id, parent.node.id, lead.node.id, "delegates_to");
|
||||
const row = await failedWake("lead@lane");
|
||||
await sweep();
|
||||
expect(findingsFor(row).map((f) => f.destinationSession)).toEqual(["lead@lane"]);
|
||||
});
|
||||
|
||||
it("a declared escalates_to that can't be used keeps its fallback", async () => {
|
||||
const door = seat("front", "door.root", "root@front");
|
||||
seat("ops", "watch.lead", "lead@ops", false);
|
||||
rigs.addEdge(door.rig.id, door.node.id, "lead@ops", "escalates_to");
|
||||
const row = await failedWake("root@front");
|
||||
await sweep();
|
||||
const [finding] = findingsFor(row);
|
||||
expect(finding!.destinationSession).toBe("root@front");
|
||||
expect(finding!.body).toContain("escalation route: escalates_to unavailable");
|
||||
});
|
||||
});
|
||||
@@ -334,6 +334,29 @@ describe("#514 custody findings for a human decision", () => {
|
||||
|
||||
expect((await sweep()).findings.map((f) => f.action)).toEqual(["refreshed", "refreshed", "refreshed"]);
|
||||
expect(sent.sort()).toEqual(terminalWakes);
|
||||
// #2939: a pass that only finds the same open findings routes nothing new
|
||||
expect(status.snapshot()).toMatchObject({ lastOutcome: "findings", findingsRouted: 3, findingsOpen: 3 });
|
||||
});
|
||||
|
||||
it("counts an open finding once in findingsRouted and reports what is still open as findingsOpen (#2939)", async () => {
|
||||
const row = await ask("unclaimed@r");
|
||||
const past = new Date(Date.now() - 90 * 60_000).toISOString();
|
||||
db.prepare("UPDATE queue_items SET ts_created = ? WHERE qitem_id = ?").run(past, row.qitemId);
|
||||
db.prepare("UPDATE queue_transitions SET ts = ? WHERE qitem_id = ?").run(past, row.qitemId);
|
||||
|
||||
expect((await sweep()).findings.map((f) => f.action)).toEqual(["created"]);
|
||||
expect(status.snapshot()).toMatchObject({ lastOutcome: "findings", findingsRouted: 1, findingsOpen: 1 });
|
||||
for (let pass = 0; pass < 3; pass++) {
|
||||
expect((await sweep()).findings.map((f) => f.action)).toEqual(["refreshed"]);
|
||||
}
|
||||
// the same open finding on every pass: still "findings", but the routed total does not grow
|
||||
expect(status.snapshot()).toMatchObject({ lastOutcome: "findings", findingsRouted: 1, findingsOpen: 1 });
|
||||
|
||||
repo.claim({ qitemId: row.qitemId, destinationSession: "unclaimed@r" });
|
||||
expect((await sweep()).findings.map((f) => f.action)).toEqual(["closed"]);
|
||||
expect(status.snapshot()).toMatchObject({ findingsRouted: 1, findingsOpen: 0 });
|
||||
expect((await sweep()).outcome).toBe("clean");
|
||||
expect(status.snapshot()).toMatchObject({ lastOutcome: "clean", findingsRouted: 1, findingsOpen: 0 });
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -1394,7 +1394,10 @@ describe("S02 standing stuck sweep — both halves, routed findings, quiet-but-o
|
||||
// Issue step 3: the source is excluded from candidates, and the resolution pass used
|
||||
// to read that exclusion as "condition resolved" and close the finding as done /
|
||||
// no-follow-on. Suppression must leave the finding open; delivery evidence closes it.
|
||||
await runSweep();
|
||||
const suppressed = await runSweep();
|
||||
// #2939: the pass reports no action on it, but the finding is still open, and healthz says so
|
||||
expect(suppressed.result.findings).toEqual([]);
|
||||
expect(suppressed.status.snapshot()).toMatchObject({ findingsRouted: 0, findingsOpen: 1 });
|
||||
const after = await findingsFor(row.qitemId);
|
||||
expect(after).toHaveLength(1);
|
||||
expect(repo.getById(after[0]!.qitemId)?.state).toBe("pending");
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// Each case runs the real hook as a harness does (JSON on stdin, killed at the registration's outer bound), with a stub
|
||||
// `rig` and a stub trace script whose delays are set per case. The overlaps are hook processes overlapping; whether a
|
||||
// harness overlaps these events natively is not established here.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it, vi, type TestContext } from "vitest";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -14,19 +14,17 @@ const PLUGIN = resolve(HERE, "../assets/plugins/openrig-core");
|
||||
const HOOK = join(PLUGIN, "hooks/scripts/refocus.cjs");
|
||||
const SEAT = "dev-impl@budget-rig";
|
||||
|
||||
let root: string | undefined;
|
||||
afterEach(() => {
|
||||
// A case that made the state folder read-only may have timed out before putting it back.
|
||||
if (root) try { chmodSync(join(root, "openrig", "refocus"), 0o755); } catch {}
|
||||
if (root) rmSync(root, { recursive: true, force: true });
|
||||
root = undefined;
|
||||
});
|
||||
|
||||
interface Fixture { home: string; bin: string; log: string }
|
||||
interface Fixture { root: string; home: string; bin: string; log: string }
|
||||
|
||||
/** Build isolated hook state and controllable lookup scripts for timing and overlap cases. */
|
||||
function fixture(): Fixture {
|
||||
root = mkdtempSync(join(tmpdir(), "refocus-budget-"));
|
||||
function fixture(onTestFinished: TestContext["onTestFinished"]): Fixture {
|
||||
const root = mkdtempSync(join(tmpdir(), "refocus-budget-"));
|
||||
// Bind cleanup to the owning test, including cases that construct two fixtures.
|
||||
onTestFinished(() => {
|
||||
// A case that made the state folder read-only may have timed out before putting it back.
|
||||
try { chmodSync(join(root, "openrig", "refocus"), 0o755); } catch {}
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
const home = join(root, "openrig");
|
||||
const bin = join(root, "bin");
|
||||
const log = join(root, "calls.log");
|
||||
@@ -55,20 +53,20 @@ echo "TRACE OK: work and topology"
|
||||
`);
|
||||
chmodSync(join(bin, "rig"), 0o755);
|
||||
chmodSync(join(bin, "trace"), 0o755);
|
||||
return { home, bin, log };
|
||||
return { root, home, bin, log };
|
||||
}
|
||||
|
||||
interface Run { killed: boolean; ms: number; stdout: string; stderr: string }
|
||||
|
||||
// Run the hook as the harness does, killed at `outerMs` (both registrations' bound for refocus: 60 s).
|
||||
function hook(f: Fixture, event: string, session: string, extra: Record<string, string> = {}, outerMs = 60_000, promptId?: string): Promise<Run> {
|
||||
const transcript = join(root!, `${session}.jsonl`);
|
||||
const transcript = join(f.root, `${session}.jsonl`);
|
||||
if (!existsSync(transcript)) writeFileSync(transcript, "");
|
||||
const started = Date.now();
|
||||
return new Promise((done) => {
|
||||
const child = spawn("node", [HOOK, "--runtime", "claude"], {
|
||||
env: {
|
||||
PATH: `${f.bin}:${process.env.PATH}`, HOME: root!, OPENRIG_HOME: f.home, OPENRIG_SESSION_NAME: SEAT,
|
||||
PATH: `${f.bin}:${process.env.PATH}`, HOME: f.root, OPENRIG_HOME: f.home, OPENRIG_SESSION_NAME: SEAT,
|
||||
PYTHON: join(f.bin, "trace"), OPENRIG_REFOCUS_WORK_NODE: f.home, OPENRIG_WORKSPACE_ROOT: f.home,
|
||||
OPENRIG_TOPOLOGY_ROOT: f.home, ...extra,
|
||||
},
|
||||
@@ -103,9 +101,11 @@ describe("refocus: the registrations' outer bound", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("refocus: the time budget", () => {
|
||||
it("delivers a slow but successful trace when a longer budget is selected", async () => {
|
||||
const f = fixture();
|
||||
// Each budget case owns its files and child processes. Overlap their real waits;
|
||||
// attempts within a case and the overlapping-event scenarios below stay sequential.
|
||||
describe.concurrent("refocus: the time budget", () => {
|
||||
it("delivers a slow but successful trace when a longer budget is selected", async ({ expect, onTestFinished }) => {
|
||||
const f = fixture(onTestFinished);
|
||||
await hook(f, "PostCompact", "s1");
|
||||
const r = await hook(f, "UserPromptSubmit", "s1", { OPENRIG_REFOCUS_BUDGET_MS: "10000", STUB_TRACE_MS: "2500" });
|
||||
expect(r.killed).toBe(false);
|
||||
@@ -115,8 +115,8 @@ describe("refocus: the time budget", () => {
|
||||
expect(stateOf(f, "s1")["pendingOn"]).toBeUndefined();
|
||||
}, 20_000);
|
||||
|
||||
it("ends a trace that never finishes as a gap inside the selected budget, keeping the refocus due", async () => {
|
||||
const f = fixture();
|
||||
it("ends a trace that never finishes as a gap inside the selected budget, keeping the refocus due", async ({ expect, onTestFinished }) => {
|
||||
const f = fixture(onTestFinished);
|
||||
await hook(f, "PostCompact", "s1");
|
||||
const r = await hook(f, "UserPromptSubmit", "s1", { OPENRIG_REFOCUS_BUDGET_MS: "10000", STUB_TRACE_MS: "30000" });
|
||||
expect(r.killed).toBe(false);
|
||||
@@ -125,9 +125,9 @@ describe("refocus: the time budget", () => {
|
||||
expect(stateOf(f, "s1")).toMatchObject({ pendingOn: "PostCompact" });
|
||||
}, 20_000);
|
||||
|
||||
it("limits a selected budget to 52 s, so reporting and recording the outcome still end inside the 60 s bound", async () => {
|
||||
it("limits a selected budget to 52 s, so reporting and recording the outcome still end inside the 60 s bound", async ({ expect, onTestFinished }) => {
|
||||
// At 52 s the trace's cap is 23.1 s: a 23.5 s trace ends as a gap (at 55 s its cap would have been 24.4 s).
|
||||
const f = fixture();
|
||||
const f = fixture(onTestFinished);
|
||||
await hook(f, "PostCompact", "s1");
|
||||
const r = await hook(f, "UserPromptSubmit", "s1", { OPENRIG_REFOCUS_BUDGET_MS: "120000", STUB_TRACE_MS: "23500" });
|
||||
expect(r.killed).toBe(false);
|
||||
@@ -137,8 +137,8 @@ describe("refocus: the time budget", () => {
|
||||
expect(stateOf(f, "s1")).toMatchObject({ pendingOn: "PostCompact" });
|
||||
}, 40_000);
|
||||
|
||||
it("keeps today's 2 s trace cap when no budget is selected", async () => {
|
||||
const f = fixture();
|
||||
it("keeps today's 2 s trace cap when no budget is selected", async ({ expect, onTestFinished }) => {
|
||||
const f = fixture(onTestFinished);
|
||||
await hook(f, "PostCompact", "s1");
|
||||
const r = await hook(f, "UserPromptSubmit", "s1", { STUB_TRACE_MS: "2500" }, 5_000);
|
||||
expect(r.killed).toBe(false);
|
||||
@@ -147,10 +147,10 @@ describe("refocus: the time budget", () => {
|
||||
expect(stateOf(f, "s1")).toMatchObject({ pendingOn: "PostCompact" });
|
||||
}, 20_000);
|
||||
|
||||
it("with no budget selected, gives main's result: each lookup keeps its 2 s cap, unclipped, inside the 60 s bound", async () => {
|
||||
it("with no budget selected, gives main's result: each lookup keeps its 2 s cap, unclipped, inside the 60 s bound", async ({ expect, onTestFinished }) => {
|
||||
// Lookups each under their own 2 s cap (with room for a slow runner's process start-up) that together pass the 4.5 s lookup budget: on
|
||||
// main's 5 s registration this was killed; now it completes as main's code does.
|
||||
const f = fixture();
|
||||
const f = fixture(onTestFinished);
|
||||
const slow = { STUB_WHOAMI_MS: "1550", STUB_TRACE_MS: "1550", STUB_CONTEXT_MS: "1550", OPENRIG_REFOCUS_CONTENT_REF: "refocus/team.md",
|
||||
OPENRIG_REFOCUS_WORK_NODE: "", OPENRIG_REFOCUS_TREES: "both" };
|
||||
await hook(f, "PostCompact", "s1");
|
||||
@@ -160,7 +160,7 @@ describe("refocus: the time budget", () => {
|
||||
expect(context(r)).toContain("CONFIGURED REFOCUS CONTENT");
|
||||
expect(stateOf(f, "s1")).toMatchObject({ firedOn: "UserPromptSubmit" });
|
||||
// A content-REF seat with a slow whoami gets main's full 2 s trace, not a shorter one (completes on main in ~2.9 s).
|
||||
const g = fixture();
|
||||
const g = fixture(onTestFinished);
|
||||
await hook(g, "PostCompact", "s1");
|
||||
const ref = await hook(g, "UserPromptSubmit", "s1", { STUB_WHOAMI_MS: "1000", STUB_TRACE_MS: "1500", STUB_CONTEXT_MS: "300",
|
||||
OPENRIG_REFOCUS_CONTENT_REF: "refocus/team.md", OPENRIG_REFOCUS_WORK_NODE: "", OPENRIG_REFOCUS_TREES: "both" });
|
||||
@@ -168,10 +168,10 @@ describe("refocus: the time budget", () => {
|
||||
expect(context(ref)).not.toContain("TRACE GAP");
|
||||
}, 40_000);
|
||||
|
||||
it("with no budget selected, still files the issue report after three slow failed attempts that ran past 4.5 s", async () => {
|
||||
it("with no budget selected, still files the issue report after three slow failed attempts that ran past 4.5 s", async ({ expect, onTestFinished }) => {
|
||||
// Default lookups keep their fixed 2 s caps, so a failed default attempt can end around 6 s. Its report gets its
|
||||
// own window after that (the stub answers a report after 200 ms), so the third failure is reported and confirmed.
|
||||
const f = fixture();
|
||||
const f = fixture(onTestFinished);
|
||||
const slow = { STUB_WHOAMI_MS: "1900", STUB_TRACE_MS: "2500", STUB_CONTEXT_MS: "1900", OPENRIG_REFOCUS_CONTENT_REF: "refocus/team.md",
|
||||
OPENRIG_REFOCUS_WORK_NODE: "", OPENRIG_REFOCUS_TREES: "both" };
|
||||
await hook(f, "PostCompact", "s1");
|
||||
@@ -185,9 +185,9 @@ describe("refocus: the time budget", () => {
|
||||
expect(readFileSync(f.log, "utf8").split("\n").filter((l) => l.startsWith("rig stream")).length).toBe(1);
|
||||
}, 60_000);
|
||||
|
||||
it("with a budget selected, ends lookups that together pass it as a reported gap, and records the failure", async () => {
|
||||
it("with a budget selected, ends lookups that together pass it as a reported gap, and records the failure", async ({ expect, onTestFinished }) => {
|
||||
// A 4.6 s budget gives each lookup a ~2 s cap; three of them pass the budget, so the last is clipped.
|
||||
const f = fixture();
|
||||
const f = fixture(onTestFinished);
|
||||
const slow = { OPENRIG_REFOCUS_BUDGET_MS: "4600", STUB_WHOAMI_MS: "1950", STUB_TRACE_MS: "1950", STUB_CONTEXT_MS: "1950",
|
||||
OPENRIG_REFOCUS_CONTENT_REF: "refocus/team.md", OPENRIG_REFOCUS_WORK_NODE: "", OPENRIG_REFOCUS_TREES: "both" };
|
||||
await hook(f, "PostCompact", "s1");
|
||||
@@ -204,13 +204,13 @@ describe("refocus: the time budget", () => {
|
||||
});
|
||||
|
||||
describe("refocus: overlapping events for one occupant", () => {
|
||||
it("preserves a PostCompact that lands while an earlier refocus is being delivered", async () => {
|
||||
const f = fixture();
|
||||
it("preserves a PostCompact that lands while an earlier refocus is being delivered", async ({ expect, onTestFinished }) => {
|
||||
const f = fixture(onTestFinished);
|
||||
await hook(f, "UserPromptSubmit", "s1", { OPENRIG_REFOCUS_NOW: "1" });
|
||||
const file = join(f.home, "refocus", `${SEAT}__s1.json`);
|
||||
writeFileSync(file, JSON.stringify({ ...stateOf(f, "s1"), pendingOn: "Stop", pendingAt: "2026-10-18T17:00:00.000Z" }));
|
||||
const ready = join(root!, "trace-ready");
|
||||
const release = join(root!, "trace-release");
|
||||
const ready = join(f.root, "trace-ready");
|
||||
const release = join(f.root, "trace-release");
|
||||
// Hold delivery after it has read the state; process start-up time cannot choose the interleaving.
|
||||
const delivering = hook(f, "UserPromptSubmit", "s1", {
|
||||
OPENRIG_REFOCUS_BUDGET_MS: "52000", STUB_TRACE_READY: ready, STUB_TRACE_RELEASE: release,
|
||||
@@ -230,11 +230,11 @@ describe("refocus: overlapping events for one occupant", () => {
|
||||
expect(stateOf(f, "s1")["pendingOn"]).toBeUndefined();
|
||||
}, 20_000);
|
||||
|
||||
it("delivers a pending refocus once when two handlers overlap", async () => {
|
||||
const f = fixture();
|
||||
it("delivers a pending refocus once when two handlers overlap", async ({ expect, onTestFinished }) => {
|
||||
const f = fixture(onTestFinished);
|
||||
await hook(f, "PostCompact", "s1");
|
||||
const ready = join(root!, "trace-ready");
|
||||
const release = join(root!, "trace-release");
|
||||
const ready = join(f.root, "trace-ready");
|
||||
const release = join(f.root, "trace-release");
|
||||
const first = hook(f, "UserPromptSubmit", "s1", {
|
||||
OPENRIG_REFOCUS_BUDGET_MS: "10000", STUB_TRACE_READY: ready, STUB_TRACE_RELEASE: release,
|
||||
}, 60_000, "prompt-A");
|
||||
@@ -252,11 +252,11 @@ describe("refocus: overlapping events for one occupant", () => {
|
||||
expect(context(await hook(f, "UserPromptSubmit", "s1"))).toBe("");
|
||||
}, 20_000);
|
||||
|
||||
it("retains a distinct identified on-demand prompt while an earlier prompt is rendering", async () => {
|
||||
const f = fixture();
|
||||
it("retains a distinct identified on-demand prompt while an earlier prompt is rendering", async ({ expect, onTestFinished }) => {
|
||||
const f = fixture(onTestFinished);
|
||||
await hook(f, "PostCompact", "s1");
|
||||
const ready = join(root!, "trace-ready");
|
||||
const release = join(root!, "trace-release");
|
||||
const ready = join(f.root, "trace-ready");
|
||||
const release = join(f.root, "trace-release");
|
||||
const first = hook(f, "UserPromptSubmit", "s1", {
|
||||
OPENRIG_REFOCUS_BUDGET_MS: "10000", STUB_TRACE_READY: ready, STUB_TRACE_RELEASE: release,
|
||||
}, 60_000, "prompt-A");
|
||||
@@ -273,19 +273,19 @@ describe("refocus: overlapping events for one occupant", () => {
|
||||
expect(context(await hook(f, "UserPromptSubmit", "s1", {}, 60_000, "prompt-D"))).toBe("");
|
||||
}, 20_000);
|
||||
|
||||
it("delivers once per identified prompt even if the second handler starts after completion", async () => {
|
||||
const f = fixture();
|
||||
it("delivers once per identified prompt even if the second handler starts after completion", async ({ expect, onTestFinished }) => {
|
||||
const f = fixture(onTestFinished);
|
||||
const env = { OPENRIG_REFOCUS_NOW: "1" };
|
||||
expect(context(await hook(f, "UserPromptSubmit", "s1", env, 60_000, "prompt-1"))).toContain("REFOCUS");
|
||||
expect(context(await hook(f, "UserPromptSubmit", "s1", env, 60_000, "prompt-1"))).toBe("");
|
||||
expect(context(await hook(f, "UserPromptSubmit", "s1", env, 60_000, "prompt-2"))).toContain("REFOCUS");
|
||||
});
|
||||
|
||||
it("recovers pending work after the rendering hook is killed", async () => {
|
||||
const f = fixture();
|
||||
it("recovers pending work after the rendering hook is killed", async ({ expect, onTestFinished }) => {
|
||||
const f = fixture(onTestFinished);
|
||||
await hook(f, "PostCompact", "s1");
|
||||
const ready = join(root!, "trace-ready");
|
||||
const release = join(root!, "trace-release");
|
||||
const ready = join(f.root, "trace-ready");
|
||||
const release = join(f.root, "trace-release");
|
||||
const interrupted = hook(f, "UserPromptSubmit", "s1", {
|
||||
OPENRIG_REFOCUS_BUDGET_MS: "10000", STUB_TRACE_READY: ready, STUB_TRACE_RELEASE: release,
|
||||
}, 1000);
|
||||
@@ -302,8 +302,8 @@ describe("refocus: overlapping events for one occupant", () => {
|
||||
expect(context(await hook(f, "UserPromptSubmit", "s1"))).toBe("");
|
||||
});
|
||||
|
||||
it("uses a shared successor guard after its former owner exits", async () => {
|
||||
const f = fixture();
|
||||
it("uses a shared successor guard after its former owner exits", async ({ expect, onTestFinished }) => {
|
||||
const f = fixture(onTestFinished);
|
||||
await hook(f, "PostCompact", "s1");
|
||||
const guard = join(f.home, "refocus", `${SEAT}__s1.json.guard`);
|
||||
const owner = spawnSync(process.execPath, ["-e", ""]).pid;
|
||||
@@ -316,8 +316,8 @@ describe("refocus: overlapping events for one occupant", () => {
|
||||
expect(readlinkSync(guard)).toBe(token);
|
||||
});
|
||||
|
||||
it("does not take a successor when the old guard was released before the owner exited", async () => {
|
||||
const f = fixture();
|
||||
it("does not take a successor when the old guard was released before the owner exited", async ({ expect, onTestFinished }) => {
|
||||
const f = fixture(onTestFinished);
|
||||
await hook(f, "PostCompact", "s1");
|
||||
const stateFile = join(f.home, "refocus", `${SEAT}__s1.json`);
|
||||
const guard = stateFile + ".guard";
|
||||
@@ -344,8 +344,8 @@ describe("refocus: overlapping events for one occupant", () => {
|
||||
expect(readlinkSync(guard)).toBe(liveToken);
|
||||
});
|
||||
|
||||
it("reports an unreadable guard and leaves the pending compaction available", async () => {
|
||||
const f = fixture();
|
||||
it("reports an unreadable guard and leaves the pending compaction available", async ({ expect, onTestFinished }) => {
|
||||
const f = fixture(onTestFinished);
|
||||
await hook(f, "UserPromptSubmit", "s1");
|
||||
const guard = join(f.home, "refocus", `${SEAT}__s1.json.guard`);
|
||||
mkdirSync(guard);
|
||||
@@ -359,8 +359,8 @@ describe("refocus: overlapping events for one occupant", () => {
|
||||
expect(context(await hook(f, "UserPromptSubmit", "s1"))).toBe("");
|
||||
});
|
||||
|
||||
it("still delivers to each of two occupants of one seat when they overlap", async () => {
|
||||
const f = fixture();
|
||||
it("still delivers to each of two occupants of one seat when they overlap", async ({ expect, onTestFinished }) => {
|
||||
const f = fixture(onTestFinished);
|
||||
await hook(f, "PostCompact", "s1");
|
||||
await hook(f, "PostCompact", "s2");
|
||||
const runs = await Promise.all([
|
||||
@@ -372,8 +372,8 @@ describe("refocus: overlapping events for one occupant", () => {
|
||||
});
|
||||
|
||||
describe("refocus: state left by an earlier candidate", () => {
|
||||
it("ignores a delivery claim and pendingSeq left on disk, so a resumed hook still delivers what is due", async () => {
|
||||
const f = fixture();
|
||||
it("ignores a delivery claim and pendingSeq left on disk, so a resumed hook still delivers what is due", async ({ expect, onTestFinished }) => {
|
||||
const f = fixture(onTestFinished);
|
||||
await hook(f, "PostCompact", "s1");
|
||||
const file = join(f.home, "refocus", `${SEAT}__s1.json`);
|
||||
writeFileSync(file, JSON.stringify({ ...stateOf(f, "s1"), pendingSeq: 3,
|
||||
|
||||
@@ -35,62 +35,6 @@ function run(runtime: string, event: string, overrides: NodeJS.ProcessEnv = {},
|
||||
}
|
||||
const context = (r: ReturnType<typeof run>) => { expect(r.status, r.stderr || String(r.error)).toBe(0); return JSON.parse(r.stdout).hookSpecificOutput.additionalContext as string; };
|
||||
const calls = () => existsSync(env.CALLS!) ? readFileSync(env.CALLS!, "utf8").trim().split("\n") : [];
|
||||
const refocusStatePath = () => join(env.OPENRIG_HOME!, "refocus", `${env.OPENRIG_SESSION_NAME}__test-occupant.json`);
|
||||
|
||||
it.each([6_000, 12_000, 28_000])("Codex refocus output budgets %i standing-content bytes and records any omitted tail", (bytes) => {
|
||||
const content = join(root, "standing.md");
|
||||
writeFileSync(content, `STANDING START\n${"🙂".repeat(bytes / 4)}\nSTANDING END`);
|
||||
run("codex", "PostCompact");
|
||||
const text = context(run("codex", "UserPromptSubmit", { OPENRIG_REFOCUS_CONTENT_FILE: content }));
|
||||
const manifest = JSON.parse(readFileSync(resolve(hook, "../../codex.json"), "utf8"));
|
||||
const handler = manifest.hooks.UserPromptSubmit.flatMap((group: any) => group.hooks)
|
||||
.find((entry: any) => entry.command.includes("refocus.cjs"));
|
||||
expect(Buffer.byteLength(text)).toBeLessThanOrEqual(2_500 * 4);
|
||||
expect(handler.additionalContextLimit).toBeUndefined();
|
||||
const state = JSON.parse(readFileSync(refocusStatePath(), "utf8"));
|
||||
expect(state.firedAt).toEqual(expect.any(String));
|
||||
expect(state.contextOutput.outputBytes).toBe(Buffer.byteLength(text));
|
||||
if (bytes === 6_000) {
|
||||
expect(text).toContain("STANDING END");
|
||||
expect(state.contextOutput).toMatchObject({ status: "full", omittedBytes: 0 });
|
||||
} else {
|
||||
expect(text).toContain("REFOCUS SHORTENED");
|
||||
expect(text).not.toContain("STANDING END");
|
||||
expect(text).not.toContain("\uFFFD");
|
||||
expect(state.contextOutput.status).toBe("shortened");
|
||||
const full = readFileSync(state.contextOutput.fullPath, "utf8");
|
||||
expect(full).toContain("STANDING END");
|
||||
expect(state.contextOutput.originalBytes).toBe(Buffer.byteLength(full));
|
||||
expect(state.contextOutput.omittedBytes).toBe(Buffer.byteLength(full) - state.contextOutput.prefixBytes);
|
||||
expect(text.startsWith(Buffer.from(full).subarray(0, state.contextOutput.prefixBytes).toString("utf8"))).toBe(true);
|
||||
writeFileSync(content, `NEXT STANDING CONTENT\n${"x".repeat(25_000)}`);
|
||||
context(run("codex", "UserPromptSubmit", { OPENRIG_REFOCUS_CONTENT_FILE: content, OPENRIG_REFOCUS_NOW: "1" }));
|
||||
const next = JSON.parse(readFileSync(refocusStatePath(), "utf8"));
|
||||
expect(next.contextOutput.fullPath).not.toBe(state.contextOutput.fullPath);
|
||||
expect(readFileSync(state.contextOutput.fullPath, "utf8")).toBe(full);
|
||||
}
|
||||
expect(run("codex", "UserPromptSubmit").stdout).toBe("");
|
||||
});
|
||||
|
||||
it("Codex refocus output still fires and records shortening when the full-text file cannot be saved", () => {
|
||||
const content = join(root, "standing.md");
|
||||
writeFileSync(content, "x".repeat(25_000));
|
||||
run("codex", "PostCompact");
|
||||
const stateFile = refocusStatePath();
|
||||
const preload = join(root, "fail-context-save.cjs");
|
||||
writeFileSync(preload, `const fs = require("node:fs"), write = fs.writeFileSync;
|
||||
fs.writeFileSync = (file, ...args) => {
|
||||
if (String(file).endsWith(".context.txt")) throw new Error("fixture context save refused");
|
||||
return write(file, ...args);
|
||||
};`);
|
||||
const text = context(run("codex", "UserPromptSubmit", { OPENRIG_REFOCUS_CONTENT_FILE: content }, preload));
|
||||
expect(text).toContain("REFOCUS SHORTENED");
|
||||
expect(text).toContain("Full text could not be saved");
|
||||
const state = JSON.parse(readFileSync(stateFile, "utf8"));
|
||||
expect(state.firedAt).toEqual(expect.any(String));
|
||||
expect(state.contextOutput).toMatchObject({ status: "shortened", fullPath: null });
|
||||
expect(state.contextOutput.omittedBytes).toBeGreaterThan(0);
|
||||
});
|
||||
it.each(["claude", "codex"])("%s carries only A's role after PostCompact and reuses the work response", (runtime) => {
|
||||
expect(run(runtime, "PostCompact").stdout).toBe("");
|
||||
const text = context(run(runtime, "UserPromptSubmit"));
|
||||
|
||||
@@ -50,9 +50,20 @@ class FakeTmux {
|
||||
if (!found) throw new Error(`can't find session: ${target}`);
|
||||
return `${found[1].pane}|0|/tmp|80|24|1\n`;
|
||||
}
|
||||
// The stop's identity read asks for the session id with its server's pid and start time.
|
||||
if (command.startsWith("tmux display-message") && command.includes("#{pid}")) return byTarget() ? `${byTarget()![1].id}|4242|1700000000\n` : "\n";
|
||||
if (command.startsWith("tmux display-message") && command.includes("session_id")) return `${byTarget()?.[1].id ?? ""}\n`;
|
||||
if (command.startsWith("tmux display-message") && command.includes("pane_current_command")) return byTarget() ? "zsh\n" : "";
|
||||
if (command.startsWith("tmux capture-pane")) return byTarget() ? "user@host ~ % \n" : "";
|
||||
if (command.startsWith("tmux if-shell -F") && command.includes("#{==:#{session_id},")) {
|
||||
// A stop held to the session id and server it read: one branch runs, in the same command.
|
||||
const found = byTarget();
|
||||
const wantId = /#\{==:#\{session_id\},(\$\d+)\}/.exec(command)?.[1];
|
||||
const same = found && found[1].id === wantId && command.includes("#{==:#{pid},4242}") && command.includes("#{==:#{start_time},1700000000}");
|
||||
if (!same) return "openrig-stop-target-changed\n";
|
||||
if (command.includes("kill-session")) this.sessions.delete(found[0]);
|
||||
return "";
|
||||
}
|
||||
if (command.startsWith("tmux if-shell -F")) {
|
||||
// Real tmux expands the format against the target session, then runs one branch in the same command.
|
||||
const found = byTarget();
|
||||
|
||||
@@ -27,6 +27,8 @@ import { ClaudeCodeAdapter } from "../src/adapters/claude-code-adapter.js";
|
||||
import { replaceFile } from "../src/adapters/replace-file.js";
|
||||
import { shellQuote } from "../src/adapters/shell-quote.js";
|
||||
import { claudeActivityRelayPath } from "../src/domain/claude-activity-hooks.js";
|
||||
import { SeatDeliveryGuard, resolveGuardTarget } from "../src/domain/seat-delivery-guard.js";
|
||||
import { setDaemonTmuxServer } from "../src/adapters/tmux-server.js";
|
||||
|
||||
|
||||
function mockTmux(killResult?: { ok: boolean; code?: string; message?: string }): TmuxAdapter {
|
||||
@@ -1343,3 +1345,144 @@ describe("RigTeardownOrchestrator", () => {
|
||||
eventBus.persistWithinTransaction = origPersist;
|
||||
});
|
||||
});
|
||||
|
||||
// rig down stops only the seat's own terminal. On the production path (the guard installed, the server chosen by execution
|
||||
// args, every write as argv), a session is stopped only when it carries the seat's OpenRig marker, and each effect runs
|
||||
// only on the server and session id it was read from. Each case runs on its own private tmux server.
|
||||
describe.skipIf(process.platform === "win32")("rig down leaves a terminal it can't show to be the seat's", () => {
|
||||
const NAME = "dev-impl@owned";
|
||||
const exec = promisify(execFile);
|
||||
|
||||
async function harness(hook?: (argv: string[], tmux: (...a: string[]) => Promise<string>) => Promise<void>) {
|
||||
// Short socket path for macOS sockaddr_un, no inherited server, no config.
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "rd-"));
|
||||
const env: NodeJS.ProcessEnv = { PATH: process.env.PATH, HOME: home, TMUX_TMPDIR: home, SHELL: "/bin/sh" };
|
||||
const tmux = async (...args: string[]) => (await exec("tmux", ["-L", "own", "-f", "/dev/null", ...args], { env, timeout: 5000 })).stdout.trim();
|
||||
const adapter = new TmuxAdapter(async () => { throw new Error("unexpected shell execution"); }, undefined, async (argv) => {
|
||||
const out = (await exec(argv[0]!, argv.slice(1), { env, timeout: 5000 })).stdout;
|
||||
await hook?.(argv, tmux);
|
||||
return out;
|
||||
}, ["-L", "own", "-f", "/dev/null"]);
|
||||
const db = createDb();
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
const rigRepo = new RigRepository(db), sessionRegistry = new SessionRegistry(db), eventBus = new EventBus(db);
|
||||
const guard = new SeatDeliveryGuard(db, (target) => resolveGuardTarget(db, target));
|
||||
guard.recoverActivation();
|
||||
adapter.deliveryGuard = guard;
|
||||
const rig = rigRepo.createRig("owned");
|
||||
const node = rigRepo.addNode(rig.id, "dev.impl", { runtime: "claude-code", cwd: home });
|
||||
const bind = async () => {
|
||||
const pane = (await tmux("list-panes", "-t", `=${NAME}:`, "-F", "#{pane_id}")).split("\n")[0]!;
|
||||
const session = sessionRegistry.registerSession(node.id, NAME);
|
||||
sessionRegistry.updateStatus(session.id, "running");
|
||||
sessionRegistry.updateBinding(node.id, { attachmentType: "tmux", tmuxSession: NAME, tmuxPane: pane });
|
||||
return session;
|
||||
};
|
||||
const teardown = () => new RigTeardownOrchestrator({ db, rigRepo, sessionRegistry, eventBus, tmuxAdapter: adapter,
|
||||
snapshotCapture: mockSnapshotCapture(db) }).teardown(rig.id, { snapshot: false });
|
||||
const alive = (name: string) => tmux("has-session", "-t", `=${name}`).then(() => true, () => false);
|
||||
const close = async () => { await tmux("kill-server").catch(() => {}); db.close(); fs.rmSync(home, { recursive: true, force: true }); };
|
||||
return { tmux, node, bind, teardown, alive, close, sessionRegistry, db };
|
||||
}
|
||||
|
||||
// What's left after a refusal: the session row still running and its binding intact, so nothing reads as stopped.
|
||||
function expectLeftRunning(h: Awaited<ReturnType<typeof harness>>, sessionId: string, result: { sessionsKilled: number; errors: string[] }, reason: RegExp) {
|
||||
expect(result.sessionsKilled).toBe(0);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0]).toMatch(new RegExp(`^Kill failed for session '${NAME}': '${NAME}' was left running: its tmux session \\$\\d+ ${reason.source}`));
|
||||
expect(h.sessionRegistry.getBindingForNode(h.node.id)?.tmuxSession).toBe(NAME);
|
||||
expect(h.db.prepare("SELECT status FROM sessions WHERE id=?").get(sessionId)).toEqual({ status: "running" });
|
||||
}
|
||||
|
||||
it("the seat's own session, marked with its node, is stopped as before", async () => {
|
||||
const h = await harness();
|
||||
try {
|
||||
await h.tmux("new-session", "-d", "-s", "keepalive", "sleep 300");
|
||||
await h.tmux("new-session", "-d", "-s", NAME, "-e", `OPENRIG_NODE_ID=${h.node.id}`, "sleep 300");
|
||||
await h.bind();
|
||||
expect(await h.teardown()).toMatchObject({ sessionsKilled: 1, errors: [] });
|
||||
expect(await h.alive(NAME)).toBe(false);
|
||||
expect(await h.alive("keepalive")).toBe(true);
|
||||
expect(h.sessionRegistry.getBindingForNode(h.node.id)).toBeNull();
|
||||
} finally { await h.close(); }
|
||||
});
|
||||
|
||||
it("after the tmux server is replaced, another terminal with the seat's name and its old ids survives", async () => {
|
||||
const h = await harness();
|
||||
try {
|
||||
await h.tmux("new-session", "-d", "-s", NAME, "-e", `OPENRIG_NODE_ID=${h.node.id}`, "sleep 300");
|
||||
const session = await h.bind();
|
||||
expect(h.sessionRegistry.getBindingForNode(h.node.id)?.tmuxPane).toBe("%0");
|
||||
await h.tmux("kill-server");
|
||||
await h.tmux("new-session", "-d", "-s", NAME, "sleep 301");
|
||||
expect(await h.tmux("list-panes", "-t", `=${NAME}:`, "-F", "#{session_id} #{pane_id}")).toBe("$0 %0");
|
||||
expectLeftRunning(h, session.id, await h.teardown(), /doesn't carry this seat's OpenRig marker \(OPENRIG_NODE_ID\)/);
|
||||
expect(await h.alive(NAME)).toBe(true);
|
||||
} finally { await h.close(); }
|
||||
});
|
||||
|
||||
it("a session with the seat's name but no marker (adopted, bound or older) is left running, not stopped", async () => {
|
||||
const h = await harness();
|
||||
// The daemon is on its own server here, so the manual path must name it: a plain `tmux` reaches another server.
|
||||
setDaemonTmuxServer(["-L", "own"]);
|
||||
try {
|
||||
await h.tmux("new-session", "-d", "-s", NAME, "sleep 300");
|
||||
const session = await h.bind();
|
||||
const result = await h.teardown();
|
||||
expectLeftRunning(h, session.id, result, /doesn't carry this seat's OpenRig marker \(OPENRIG_NODE_ID\)/);
|
||||
expect(result.errors[0]).toContain(`If it is this seat's, look first with \`tmux -L own attach -t '=${NAME}'\`, `
|
||||
+ `then stop it with \`tmux -L own kill-session -t '=${NAME}'\` and run the command again.`);
|
||||
expect(await h.alive(NAME)).toBe(true);
|
||||
} finally { setDaemonTmuxServer([]); await h.close(); }
|
||||
});
|
||||
|
||||
it("a session marked with another seat's node is left running", async () => {
|
||||
const h = await harness();
|
||||
try {
|
||||
await h.tmux("new-session", "-d", "-s", NAME, "-e", "OPENRIG_NODE_ID=another-node", "sleep 300");
|
||||
const session = await h.bind();
|
||||
expectLeftRunning(h, session.id, await h.teardown(), /carries another seat's OpenRig marker \(OPENRIG_NODE_ID=another-node\)/);
|
||||
expect(await h.alive(NAME)).toBe(true);
|
||||
} finally { await h.close(); }
|
||||
});
|
||||
|
||||
it("on the same server, a replacement with the seat's name (a different pane) survives", async () => {
|
||||
const h = await harness();
|
||||
try {
|
||||
// The keepalive comes first, so the server stays the same one throughout.
|
||||
await h.tmux("new-session", "-d", "-s", "keepalive", "sleep 300");
|
||||
await h.tmux("new-session", "-d", "-s", NAME, "-e", `OPENRIG_NODE_ID=${h.node.id}`, "sleep 300");
|
||||
const session = await h.bind();
|
||||
await h.tmux("kill-session", "-t", `=${NAME}`);
|
||||
await h.tmux("new-session", "-d", "-s", NAME, "sleep 301");
|
||||
const result = await h.teardown();
|
||||
expect(result).toMatchObject({ sessionsKilled: 0 });
|
||||
expect(result.errors[0]).toMatch(/Managed pane identity unavailable or changed; no input written/);
|
||||
expect(await h.alive(NAME)).toBe(true);
|
||||
expect(h.db.prepare("SELECT status FROM sessions WHERE id=?").get(session.id)).toEqual({ status: "running" });
|
||||
} finally { await h.close(); }
|
||||
});
|
||||
|
||||
it("a server replaced between reading the seat's marker and stopping it leaves the new terminal alone", async () => {
|
||||
let replaced = false;
|
||||
const h = await harness(async (argv, tmux) => {
|
||||
// Just after the marker is read on the seat's server, that server is replaced, and another terminal takes the name and
|
||||
// the old ids.
|
||||
if (replaced || !argv.includes("show-environment")) return;
|
||||
replaced = true;
|
||||
await tmux("kill-server");
|
||||
await tmux("new-session", "-d", "-s", NAME, "sleep 301");
|
||||
});
|
||||
try {
|
||||
await h.tmux("new-session", "-d", "-s", NAME, "-e", `OPENRIG_NODE_ID=${h.node.id}`, "sleep 300");
|
||||
const session = await h.bind();
|
||||
const result = await h.teardown();
|
||||
expect(replaced).toBe(true);
|
||||
expect(result.sessionsKilled).toBe(0);
|
||||
expect(result.errors).toEqual([`Kill failed for session '${NAME}': $0 is no longer that session on the tmux server it was read from `
|
||||
+ "(the server may have been replaced); nothing was detached or killed."]);
|
||||
expect(await h.tmux("list-panes", "-t", `=${NAME}:`, "-F", "#{session_id} #{pane_id}")).toBe("$0 %0");
|
||||
expect(h.db.prepare("SELECT status FROM sessions WHERE id=?").get(session.id)).toEqual({ status: "running" });
|
||||
} finally { await h.close(); }
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
// A runner seat's answered permission path, through the real activity routes, queue, Mission Control answer route,
|
||||
// activity store and seat grant. The chosen person's one offered option reaches only that live request; nothing else
|
||||
// ever reads as an answer. No model, Slack or terminal is involved.
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
@@ -17,9 +17,11 @@ import { missionControlRoutes } from "../src/routes/mission-control.js";
|
||||
import { activityRoutes, guestActivityRoutes } from "../src/routes/activity.js";
|
||||
import { ActivityGrantStore } from "../src/domain/activity-grants.js";
|
||||
import { StreamStore } from "../src/domain/stream-store.js";
|
||||
import { OutboxHandler } from "../src/domain/outbox-handler.js";
|
||||
import { describeDefaultReceiver, permissionDeciderFor } from "../src/domain/default-receiver.js";
|
||||
import { createGuestActivityApp, type AppDeps } from "../src/server.js";
|
||||
|
||||
const PERSON = "mike@external", OTHER_PERSON = "dana@external", SEAT = "dev-runner@perm-rig", INSTALL = "install-token";
|
||||
const PERSON = "mike@external", OTHER_PERSON = "dana@external", SEAT = "dev-runner@perm-rig", INSTALL = "install-token", DECIDER = "lead@perm-rig";
|
||||
const person = (id: string) => ({ entityId: id, class: "human" as const, displayName: id, address: `${id}@external`,
|
||||
connectorBindings: [{ kind: "slack" as const, connectorRef: "primary", secretsRef: "unused", role: "primary" as const, handle: `U${id.toUpperCase()}` }],
|
||||
prefs: { deliveryClass: "A" as const } });
|
||||
@@ -33,7 +35,7 @@ const OPTIONS = [
|
||||
const cleanups: Array<() => void> = [];
|
||||
afterEach(() => { for (const cleanup of cleanups.splice(0)) cleanup(); });
|
||||
|
||||
function fixture(options: { owner?: string | null; guest?: boolean; realGuest?: boolean; people?: string[]; brokenStream?: boolean } = {}) {
|
||||
function fixture(options: { owner?: string | null; guest?: boolean; realGuest?: boolean; people?: string[]; brokenStream?: boolean; decider?: string | null; deciderOf?: (requester: string) => string | null; receiver?: string | null } = {}) {
|
||||
const people = options.people ? { ok: true as const, entities: options.people.map(person) } : registry;
|
||||
const db = createDb();
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
@@ -52,8 +54,20 @@ function fixture(options: { owner?: string | null; guest?: boolean; realGuest?:
|
||||
occupy(runner.id, SEAT);
|
||||
occupy(other.id, "dev-other@perm-rig");
|
||||
const nudges: string[] = [];
|
||||
const requesters: string[] = [];
|
||||
const queueRepo = new QueueRepository(db, bus, { validateRig: () => true, loadHumanRegistry: () => people,
|
||||
transport: { send: async (session) => { nudges.push(session); return { ok: true, verified: true, sent: true }; } } });
|
||||
transport: { send: async (session) => { nudges.push(session); return { ok: true, verified: true, sent: true }; } },
|
||||
// The receiving agent that decides an answered request; absent, the request closes with the person's answer.
|
||||
// The daemon's own order, as startup wires it, over this host's seats and the given agents.operator_session.
|
||||
...(options.receiver !== undefined ? { permissionDecider: (_ask: unknown, requester: string) => {
|
||||
requesters.push(requester);
|
||||
const decider = permissionDeciderFor(db, requester, () => options.receiver ?? null);
|
||||
return decider?.session ? { session: decider.session, describe: describeDefaultReceiver(decider) } : null; } }
|
||||
: options.deciderOf ? { permissionDecider: (_ask: unknown, requester: string) => {
|
||||
requesters.push(requester); const session = options.deciderOf!(requester); return session ? { session } : null; } }
|
||||
: options.decider !== undefined ? { permissionDecider: () => options.decider ? { session: options.decider } : null } : {}) });
|
||||
// A handoff stages its wake durably, as the daemon's queue does with its outbox attached.
|
||||
if (options.decider !== undefined || options.deciderOf || options.receiver !== undefined) queueRepo.attachOutbox(new OutboxHandler(db));
|
||||
const contract = new MissionControlWriteContract({ db, eventBus: bus, queueRepo, actionLog: new MissionControlActionLog(db) });
|
||||
const store = new AgentActivityStore({ db, eventBus: bus,
|
||||
resolveOccupantGeneration: (id) => sessions.currentOccupantTenure(id)?.generationUuid ?? null,
|
||||
@@ -102,12 +116,20 @@ function fixture(options: { owner?: string | null; guest?: boolean; realGuest?:
|
||||
json(await app.request("/api/mission-control/action", { method: "POST", headers: { "Content-Type": "application/json", "X-OpenRig-Session": actor },
|
||||
body: JSON.stringify({ verb: "answer", qitemId: askId, actorSession: actor, ...(choice.optionId ? { questionId: "permission", optionId: choice.optionId } : {}),
|
||||
...(choice.decision ? { decision: choice.decision } : {}) }) }));
|
||||
/** Another Mission Control verb on a row, as a person in Steering: approve, deny, or handoff to someone. */
|
||||
const act = async (qitemId: string, verb: string, extra: Record<string, unknown> = {}, actor = PERSON) =>
|
||||
json(await app.request("/api/mission-control/action", { method: "POST", headers: { "Content-Type": "application/json", "X-OpenRig-Session": actor },
|
||||
body: JSON.stringify({ verb, qitemId, actorSession: actor, ...extra }) }));
|
||||
/** The receiving agent decides a request its person answered (`rig seat decide-permission`). */
|
||||
const decide = async (askId: string, optionId: string, decider = DECIDER, reason = "they said so") =>
|
||||
json(await app.request(`/api/activity/permission-requests/${askId}/decide`, { method: "POST",
|
||||
headers: { "Content-Type": "application/json", "X-OpenRig-Session": decider }, body: JSON.stringify({ optionId, reason }) }));
|
||||
const waitsOnPerson = () => latestHookWaitsOnPerson(store.getLatestForNode({ nodeId: runner.id, sessionName: SEAT }), "synth");
|
||||
const rowsToSeat = () => (db.prepare("SELECT COUNT(*) AS n FROM queue_items WHERE destination_session = ?").get(SEAT) as { n: number }).n;
|
||||
const grants = new ActivityGrantStore(db);
|
||||
/** OpenRig's pull-only records of the permits it decided by default. */
|
||||
const permits = () => stream.list({ hintTag: "runner-permission-permit", limit: 100 });
|
||||
return { db, runner, other, sessions, queueRepo, contract, nudges, generation, submit, read, withdraw, answer, waitsOnPerson, rowsToSeat, grants,
|
||||
return { db, bus, runner, other, sessions, queueRepo, contract, nudges, requesters, generation, submit, read, withdraw, answer, act, decide, waitsOnPerson, rowsToSeat, grants,
|
||||
permits, relaunch: () => occupy(runner.id, SEAT, "handover") };
|
||||
}
|
||||
|
||||
@@ -154,7 +176,7 @@ describe("a runner seat's answered permission path", () => {
|
||||
expect(ask.humanQuestions?.[0]?.options.map((option) => option.id)).toEqual(["allow", "always", "reject"]);
|
||||
expect(ask.body).toContain("Edit src/app.ts");
|
||||
expect(ask.body).toMatch(/Always allow \(allow_always, always\): the agent's harness remembers this choice/);
|
||||
expect(ask.body).toContain("Your choice goes to this one request, once. A reply of just yes or no counts as the agent's allow or reject. Any other words choose nothing: OpenRig's default permits the request when the agent offered an allow option, and a runner that forwards your words passes them to the agent only after its turn. Closing this without choosing counts as no answer, and then OpenRig's default applies: the request is permitted. If the agent's turn ends first, your answer isn't sent.");
|
||||
expect(ask.body).toContain("Your answer, a choice or your own words, goes to this host's receiving agent, which decides this one request while the agent waits: it acts on a clear answer at once, and asks you back when your words aren't clear. Where no agent can decide here, your answer is kept and the request waits, and nothing runs: decide directly with rig seat decide-permission, or set up a receiving agent.");
|
||||
expect(f.waitsOnPerson()).toBe(true);
|
||||
expect(await f.read(String(submitted.body.askId))).toMatchObject({ status: 200, body: { state: "pending" } });
|
||||
expect(await f.answer(String(submitted.body.askId), { optionId: "always" })).toMatchObject({ status: 200 });
|
||||
@@ -224,23 +246,24 @@ describe("a runner seat's answered permission path", () => {
|
||||
it.each([
|
||||
["a typed yes", { decision: "Yes." }, "answered", "allow", undefined],
|
||||
["a typed no", { decision: "no" }, "answered", "reject", undefined],
|
||||
["typed words that are neither", { decision: "what does this edit do?" }, "permitted", "allow", "what does this edit do?"],
|
||||
])("reads %s from the person as the offered option, and no answer as the default", async (_case, choice, state, optionId, said) => {
|
||||
// Words that are neither are kept: nobody decided on them, so the request waits, never the default permit.
|
||||
["typed words that are neither", { decision: "what does this edit do?" }, "pending", undefined, undefined],
|
||||
])("reads %s from the person as the offered option, and keeps other words waiting", async (_case, choice, state, optionId, said) => {
|
||||
const f = fixture();
|
||||
const askId = String((await f.submit()).body.askId);
|
||||
expect(await f.answer(askId, choice)).toMatchObject({ status: 200 });
|
||||
const read = await f.read(askId);
|
||||
expect(read).toMatchObject({ body: { state, optionId } });
|
||||
expect(read).toMatchObject({ body: { state, ...(optionId ? { optionId } : {}) } });
|
||||
// The person's own words reach the runner beside the default, never in place of an option.
|
||||
expect(read.body.said).toBe(said);
|
||||
});
|
||||
|
||||
it("carries the person's words with the refusal when the agent offered no allow option, never reading them as a no", async () => {
|
||||
it("keeps the person's words waiting when the agent offered no allow option, never reading them as a no", async () => {
|
||||
const f = fixture();
|
||||
const askId = String((await f.submit({ options: [{ optionId: "reject", name: "Reject", kind: "reject_once" },
|
||||
{ optionId: "never", name: "Never", kind: "reject_always" }] })).body.askId);
|
||||
expect(await f.answer(askId, { decision: "use the other file instead" })).toMatchObject({ status: 200 });
|
||||
expect((await f.read(askId)).body).toEqual({ ok: true, state: "refused", reason: "no_allow_offered", said: "use the other file instead" });
|
||||
expect((await f.read(askId)).body).toEqual({ ok: true, state: "pending" });
|
||||
});
|
||||
|
||||
it("carries a colleague's words, whole, when the ask was routed to them", async () => {
|
||||
@@ -250,9 +273,9 @@ describe("a runner seat's answered permission path", () => {
|
||||
const colleague = f.queueRepo.list({ limit: 100 }).find((row) => row.destinationSession === OTHER_PERSON && row.handedOffFrom === askId)!;
|
||||
const words = `only the tests folder; ${"and nothing else. ".repeat(200)}`;
|
||||
expect(await f.answer(colleague.qitemId, { decision: words }, OTHER_PERSON)).toMatchObject({ status: 200 });
|
||||
const read = await f.read(askId, 1);
|
||||
expect(read.body).toMatchObject({ state: "permitted", optionId: "allow" });
|
||||
expect(read.body.said).toBe(words.trim());
|
||||
// Nobody decided on the colleague's words: the request waits, and their words are kept whole on their row.
|
||||
expect((await f.read(askId, 1)).body).toEqual({ ok: true, state: "pending" });
|
||||
expect((f.queueRepo.getById(colleague.qitemId)!.humanAnswers?.permission as { text?: string })?.text).toBe(words.trim());
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -608,3 +631,395 @@ describe("a default permit's record", () => {
|
||||
expect(await asked.read(askId)).toMatchObject({ status: 200, body: { state: "permitted", optionId: "allow" } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("an answered request goes to the receiving agent, which decides while the runner waits", () => {
|
||||
it("a person's click is kept and handed to the agent; the runner reads pending until the agent decides, then that option", async () => {
|
||||
const f = fixture({ decider: DECIDER });
|
||||
const askId = String((await f.submit()).body.askId);
|
||||
expect(await f.answer(askId, { optionId: "allow" })).toMatchObject({ status: 200 });
|
||||
expect(f.queueRepo.getById(askId)).toMatchObject({ state: "handed-off", handedOffTo: DECIDER, humanAnswers: { permission: "allow" } });
|
||||
const handed = f.queueRepo.list({ limit: 100 }).find((row) => row.handedOffFrom === askId)!;
|
||||
expect(handed).toMatchObject({ destinationSession: DECIDER, state: "pending" });
|
||||
expect(handed.body).toContain(`${PERSON} answered ${SEAT}'s permission request ${askId}: they chose "Allow" (option allow).`);
|
||||
expect(handed.body).toContain(`Decide with: rig seat decide-permission ${askId} --option <id>`);
|
||||
expect(await f.read(askId)).toMatchObject({ body: { state: "pending" } });
|
||||
expect(await f.decide(askId, "allow")).toMatchObject({ status: 200, body: { ok: true, state: "answered", optionId: "allow" } });
|
||||
expect(await f.read(askId)).toMatchObject({ body: { state: "answered", optionId: "allow" } });
|
||||
const note = f.queueRepo.listTransitions(handed.qitemId).at(-1)?.transitionNote ?? "";
|
||||
expect(note).toContain(`runner-permission-decided by ${DECIDER}, an agent, not the person: allow, because they said so`);
|
||||
// Deciding again changes nothing: the request isn't pending any more.
|
||||
expect(await f.decide(askId, "reject")).toMatchObject({ status: 200, body: { state: "answered", optionId: "allow" } });
|
||||
expect(f.permits()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("a person's words, even a plain yes, go to the agent with the question and options, never permitted by default first", async () => {
|
||||
for (const words of ["yes", "only if it's the test file"]) {
|
||||
const f = fixture({ decider: DECIDER });
|
||||
const askId = String((await f.submit()).body.askId);
|
||||
expect(await f.answer(askId, { decision: words })).toMatchObject({ status: 200 });
|
||||
expect(await f.read(askId)).toMatchObject({ body: { state: "pending" } });
|
||||
const handed = f.queueRepo.list({ limit: 100 }).find((row) => row.handedOffFrom === askId)!;
|
||||
expect(handed.body).toContain(`they wrote "${words}"`);
|
||||
expect(handed.body).toContain("- Reject (reject)");
|
||||
expect(await f.decide(askId, "reject", DECIDER, "they set a condition")).toMatchObject({ body: { state: "answered", optionId: "reject" } });
|
||||
const note = f.queueRepo.listTransitions(handed.qitemId).at(-1)?.transitionNote ?? "";
|
||||
expect(note).toContain(`the person wrote "${words}"`);
|
||||
expect(f.permits()).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("an option the agent didn't offer is refused and nothing is decided; a withdrawn request decides nothing", async () => {
|
||||
const f = fixture({ decider: DECIDER });
|
||||
const askId = String((await f.submit()).body.askId);
|
||||
await f.answer(askId, { optionId: "always" });
|
||||
expect(await f.decide(askId, "sure")).toMatchObject({ status: 400, body: { code: "invalid_option" } });
|
||||
expect(await f.read(askId)).toMatchObject({ body: { state: "pending" } });
|
||||
expect(await f.withdraw(askId, "turn_ended")).toMatchObject({ body: { state: "withdrawn" } });
|
||||
expect(await f.decide(askId, "allow")).toMatchObject({ status: 200, body: { state: "withdrawn" } });
|
||||
expect(f.queueRepo.list({ limit: 100 }).find((row) => row.handedOffFrom === askId)?.humanAnswers?.permission).toBeUndefined();
|
||||
});
|
||||
|
||||
it("an agent that closes the handed row without deciding leaves the person's answer waiting: never a permit, and a direct decision settles it", async () => {
|
||||
for (const choice of [{ decision: "no" }, { optionId: "reject" }] as const) {
|
||||
const f = fixture({ decider: DECIDER });
|
||||
const askId = String((await f.submit()).body.askId);
|
||||
await f.answer(askId, choice);
|
||||
const handed = f.queueRepo.list({ limit: 100 }).find((row) => row.handedOffFrom === askId)!;
|
||||
// The agent's usual close, not decide-permission.
|
||||
await f.queueRepo.update({ qitemId: handed.qitemId, actorSession: DECIDER, state: "done", closureReason: "no-follow-on" });
|
||||
expect(await f.read(askId)).toMatchObject({ body: { state: "pending" } });
|
||||
expect(f.permits()).toHaveLength(0);
|
||||
expect(await f.decide(askId, "reject", PERSON, "decided directly")).toMatchObject({ body: { state: "answered", optionId: "reject" } });
|
||||
}
|
||||
});
|
||||
|
||||
it("with no agent that can decide, any answer is kept and the request waits; the person is told once; the direct decision decides", async () => {
|
||||
for (const choice of [{ decision: "yes" }, { optionId: "allow" }, { decision: "only the test file" }] as const) {
|
||||
const f = fixture({ decider: null });
|
||||
const askId = String((await f.submit()).body.askId);
|
||||
expect(await f.answer(askId, choice)).toMatchObject({ status: 200 });
|
||||
expect(f.queueRepo.getById(askId)).toMatchObject({ state: "pending" });
|
||||
expect(await f.read(askId)).toMatchObject({ body: { state: "pending" } });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const told = f.queueRepo.list({ limit: 100 }).filter((row) => row.destinationSession === PERSON && row.humanIntent === "update");
|
||||
expect(told).toHaveLength(1);
|
||||
expect(told[0]!.body).toContain(`The tool hasn't run, and nothing was permitted or refused.`);
|
||||
expect(told[0]!.body).toContain(`rig seat decide-permission ${askId} --option <id>`);
|
||||
expect(f.permits()).toHaveLength(0);
|
||||
expect(await f.decide(askId, "reject", PERSON, "decided directly")).toMatchObject({ body: { state: "answered", optionId: "reject" } });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("an answered request goes to the receiving agent: the pair's round-0 controls", () => {
|
||||
const handedFrom = (f: ReturnType<typeof fixture>, id: string) => f.queueRepo.list({ limit: 100 }).find((row) => row.handedOffFrom === id);
|
||||
|
||||
it("Steering's Approve or Deny on the request is the person's yes or no: it goes to the agent, or, with none, to the request as before", async () => {
|
||||
const f = fixture({ decider: DECIDER });
|
||||
const askId = String((await f.submit()).body.askId);
|
||||
// The result says what became of the answer (the TUI prints it), never that the request closed.
|
||||
expect(await f.act(askId, "approve")).toMatchObject({ status: 200,
|
||||
body: { answer: { message: `Your answer on ${askId} went to ${DECIDER}, the receiving agent, which decides. The runner waits until then; nothing has run.` } } });
|
||||
expect(f.queueRepo.getById(askId)).toMatchObject({ state: "handed-off", handedOffTo: DECIDER });
|
||||
expect(await f.read(askId)).toMatchObject({ body: { state: "pending" } });
|
||||
expect(handedFrom(f, askId)!.body).toContain(`they wrote "yes"`);
|
||||
// With no agent that can decide, the Approve or Deny is kept as context too: the request waits for a direct decision.
|
||||
for (const verb of ["approve", "deny"] as const) {
|
||||
const g = fixture({ decider: null });
|
||||
const id = String((await g.submit()).body.askId);
|
||||
expect(await g.act(id, verb, { reason: `${verb}: only the test file` })).toMatchObject({ status: 200, body: { verb,
|
||||
answer: { message: expect.stringContaining(`Your answer is kept on ${id}, and the request is still waiting: the tool hasn't run`) } } });
|
||||
expect(await g.read(id)).toMatchObject({ body: { state: "pending" } });
|
||||
// The audit keeps what the person did: their verb, and their reason.
|
||||
expect(g.db.prepare("SELECT action_verb, reason FROM mission_control_actions WHERE qitem_id = ?").all(id))
|
||||
.toEqual([{ action_verb: verb, reason: `${verb}: only the test file` }]);
|
||||
}
|
||||
});
|
||||
|
||||
it("the decider is chosen against the runner that asked, even after a person handed the request to another person", async () => {
|
||||
// The runner seat itself is the receiving agent: it can't decide its own blocked turn, so nobody else here can either.
|
||||
const f = fixture({ deciderOf: (requester) => requester === SEAT ? null : SEAT });
|
||||
const askId = String((await f.submit()).body.askId);
|
||||
expect(await f.act(askId, "handoff", { destinationSession: OTHER_PERSON })).toMatchObject({ status: 200 });
|
||||
const toDana = handedFrom(f, askId)!;
|
||||
expect(await f.answer(toDana.qitemId, { optionId: "reject" }, OTHER_PERSON)).toMatchObject({ status: 200 });
|
||||
expect(f.requesters).toEqual([SEAT]);
|
||||
expect(f.queueRepo.list({ limit: 100 }).some((row) => row.destinationSession === SEAT && row.handedOffFrom)).toBe(false);
|
||||
// Nobody else here can decide: the answer is kept, the request waits, and a direct decision settles it.
|
||||
expect(await f.read(askId)).toMatchObject({ body: { state: "pending" } });
|
||||
expect(await f.decide(askId, "reject", OTHER_PERSON)).toMatchObject({ body: { state: "answered", optionId: "reject" } });
|
||||
});
|
||||
|
||||
it("a handoff to the agent that fails leaves the request open, never a default permit", async () => {
|
||||
const f = fixture({ decider: DECIDER });
|
||||
const askId = String((await f.submit()).body.askId);
|
||||
const spy = vi.spyOn(f.queueRepo as unknown as { handoffNow: () => unknown }, "handoffNow").mockImplementation(() => { throw new Error("staging failed"); });
|
||||
try {
|
||||
expect(await f.answer(askId, { decision: "only the test file" })).toMatchObject({ status: 200 });
|
||||
// Steering's Approve fails the same way, and is kept too.
|
||||
const other = String((await f.submit({ requestId: "8" })).body.askId);
|
||||
expect(await f.act(other, "approve")).toMatchObject({ status: 200 });
|
||||
expect((f.queueRepo.getById(other)!.humanAnswers?.permission as { text?: string })?.text).toBe("yes");
|
||||
} finally { spy.mockRestore(); }
|
||||
// The words are committed on the request, which stays open; the person is told it couldn't be passed on.
|
||||
expect(f.queueRepo.getById(askId)).toMatchObject({ state: "pending" });
|
||||
expect((f.queueRepo.getById(askId)!.humanAnswers?.permission as { text?: string })?.text).toBe("only the test file");
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(f.queueRepo.list({ limit: 100 }).filter((row) => row.destinationSession === PERSON && row.humanIntent === "update")
|
||||
.some((row) => row.body.includes(`it couldn't be passed to ${DECIDER}, the receiving agent, just now (answer again to retry)`))).toBe(true);
|
||||
expect(await f.read(askId)).toMatchObject({ body: { state: "pending" } });
|
||||
expect(f.permits()).toHaveLength(0);
|
||||
// Answering again once it works passes the kept words on.
|
||||
expect(await f.answer(askId, { decision: "only the test file" })).toMatchObject({ status: 200 });
|
||||
expect(f.queueRepo.getById(askId)).toMatchObject({ state: "handed-off", handedOffTo: DECIDER });
|
||||
expect(handedFrom(f, askId)!.body).toContain(`they wrote "only the test file"`);
|
||||
});
|
||||
|
||||
it("a person's no on a row handed to them is kept when the agent it went to closes without deciding (two handoffs)", async () => {
|
||||
const f = fixture({ decider: "lead-two@perm-rig" });
|
||||
const askId = String((await f.submit()).body.askId);
|
||||
await f.act(askId, "handoff", { destinationSession: OTHER_PERSON });
|
||||
const toDana = handedFrom(f, askId)!;
|
||||
await f.answer(toDana.qitemId, { decision: "no" }, OTHER_PERSON);
|
||||
const toAgent = handedFrom(f, toDana.qitemId)!;
|
||||
expect(toAgent.destinationSession).toBe("lead-two@perm-rig");
|
||||
await f.queueRepo.update({ qitemId: toAgent.qitemId, actorSession: "lead-two@perm-rig", state: "done", closureReason: "no-follow-on" });
|
||||
expect(await f.read(askId)).toMatchObject({ body: { state: "pending" } });
|
||||
expect(f.permits()).toHaveLength(0);
|
||||
expect(await f.decide(askId, "reject", OTHER_PERSON)).toMatchObject({ body: { state: "answered", optionId: "reject" } });
|
||||
});
|
||||
|
||||
it("the agent gets the person's whole words, with where they're recorded, however long", async () => {
|
||||
const f = fixture({ decider: DECIDER });
|
||||
const askId = String((await f.submit()).body.askId);
|
||||
const words = `${"go ahead with the edit, it is the release fix we discussed this morning; ".repeat(9)}but only if it touches the test file`;
|
||||
expect(words.length).toBeGreaterThan(500);
|
||||
await f.answer(askId, { decision: words });
|
||||
const handed = handedFrom(f, askId)!;
|
||||
expect(handed.body).toContain(`they wrote "${words}"`);
|
||||
expect(handed.body).toContain(`rig queue show ${askId} --full`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("an answered request: the round-1 controls with the daemon's own decider order", () => {
|
||||
const handedFrom = (f: ReturnType<typeof fixture>, id: string) => f.queueRepo.list({ limit: 100 }).find((row) => row.handedOffFrom === id);
|
||||
const updatesTo = (f: ReturnType<typeof fixture>, person: string) => f.queueRepo.list({ limit: 100 }).filter((row) => row.destinationSession === person && row.humanIntent === "update");
|
||||
|
||||
it("a forwarded request is never decided by the runner that asked, when that runner is the receiving agent: the answer is kept", async () => {
|
||||
const f = fixture({ receiver: SEAT });
|
||||
const askId = String((await f.submit()).body.askId);
|
||||
await f.act(askId, "handoff", { destinationSession: OTHER_PERSON });
|
||||
const toDana = handedFrom(f, askId)!;
|
||||
expect(await f.answer(toDana.qitemId, { decision: "yes" }, OTHER_PERSON)).toMatchObject({ status: 200 });
|
||||
expect(f.requesters).toEqual([SEAT]);
|
||||
expect(handedFrom(f, toDana.qitemId)).toBeUndefined();
|
||||
expect(await f.read(askId)).toMatchObject({ body: { state: "pending" } });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(updatesTo(f, OTHER_PERSON)[0]!.body).toContain(`or the only one is ${SEAT} itself`);
|
||||
});
|
||||
|
||||
it("a chosen receiving agent this host doesn't have can't decide: the answer is kept, and the person is told", async () => {
|
||||
const f = fixture({ receiver: "ghost@perm-rig" });
|
||||
const askId = String((await f.submit()).body.askId);
|
||||
await f.answer(askId, { optionId: "allow" });
|
||||
expect(await f.read(askId)).toMatchObject({ body: { state: "pending" } });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(updatesTo(f, PERSON)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("when the answer goes to an agent, the person is told once who decides and its recorded state", async () => {
|
||||
const f = fixture({ receiver: "dev-other@perm-rig" });
|
||||
const askId = String((await f.submit()).body.askId);
|
||||
await f.answer(askId, { decision: "only the test file" });
|
||||
expect(handedFrom(f, askId)).toMatchObject({ destinationSession: "dev-other@perm-rig" });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const told = updatesTo(f, PERSON);
|
||||
expect(told).toHaveLength(1);
|
||||
expect(told[0]!.body).toContain(`went to dev-other@perm-rig, which decides it while ${SEAT} waits. Nothing runs until it decides.`);
|
||||
expect(told[0]!.body).toContain("Receiving agent: dev-other@perm-rig, chosen in agents.operator_session; its session is recorded as running.");
|
||||
expect(told[0]!.body).toContain(`rig seat decide-permission ${askId} --option <id>`);
|
||||
});
|
||||
|
||||
it("a Reject click on a row handed to a colleague is kept when the agent it went to closes without deciding (two handoffs)", async () => {
|
||||
const f = fixture({ decider: "lead-two@perm-rig" });
|
||||
const askId = String((await f.submit()).body.askId);
|
||||
await f.act(askId, "handoff", { destinationSession: OTHER_PERSON });
|
||||
const toDana = handedFrom(f, askId)!;
|
||||
await f.answer(toDana.qitemId, { optionId: "reject" }, OTHER_PERSON);
|
||||
const toAgent = handedFrom(f, toDana.qitemId)!;
|
||||
await f.queueRepo.update({ qitemId: toAgent.qitemId, actorSession: "lead-two@perm-rig", state: "done", closureReason: "no-follow-on" });
|
||||
expect(await f.read(askId)).toMatchObject({ body: { state: "pending" } });
|
||||
expect(f.permits()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("qualified words the agent closed without deciding stay waiting; a direct decision still settles them", async () => {
|
||||
const f = fixture({ decider: DECIDER });
|
||||
const askId = String((await f.submit()).body.askId);
|
||||
await f.answer(askId, { decision: "only the test file" });
|
||||
const toAgent = handedFrom(f, askId)!;
|
||||
await f.queueRepo.update({ qitemId: toAgent.qitemId, actorSession: DECIDER, state: "done", closureReason: "no-follow-on" });
|
||||
expect(await f.read(askId)).toMatchObject({ body: { state: "pending" } });
|
||||
expect(f.permits()).toHaveLength(0);
|
||||
expect(await f.decide(askId, "allow", PERSON, "the test file only")).toMatchObject({ body: { state: "answered", optionId: "allow" } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("a kept answer counts only through an explicit decision (Root's source finding at c0a5d7b1)", () => {
|
||||
it("with no agent that can decide, a kept click or plain yes followed by an ordinary close still waits: no direct action, no permit", async () => {
|
||||
for (const choice of [{ optionId: "allow" }, { decision: "yes" }, { optionId: "reject" }] as const) {
|
||||
const f = fixture({ decider: null });
|
||||
const askId = String((await f.submit()).body.askId);
|
||||
await f.answer(askId, choice);
|
||||
// An ordinary close by the person or an agent: not a decision.
|
||||
await f.queueRepo.update({ qitemId: askId, actorSession: PERSON, state: "done", closureReason: "no-follow-on" });
|
||||
expect(await f.read(askId)).toMatchObject({ body: { state: "pending" } });
|
||||
expect(f.permits()).toHaveLength(0);
|
||||
// The explicit decision still settles it, recorded on the closed row.
|
||||
expect(await f.decide(askId, "allow", PERSON, "decided directly")).toMatchObject({ body: { state: "answered", optionId: "allow" } });
|
||||
}
|
||||
});
|
||||
|
||||
it("a request closed with no answer at all keeps the default permit", async () => {
|
||||
const f = fixture({ decider: null });
|
||||
const askId = String((await f.submit()).body.askId);
|
||||
await f.queueRepo.update({ qitemId: askId, actorSession: PERSON, state: "done", closureReason: "no-follow-on" });
|
||||
expect(await f.read(askId)).toMatchObject({ body: { state: "permitted", optionId: "allow" } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("an answered request: review-r2's round-1 questions", () => {
|
||||
it("a direct decision settles a request whose row closed as failed, and records the person's whole words", async () => {
|
||||
const f = fixture({ decider: null });
|
||||
const askId = String((await f.submit()).body.askId);
|
||||
const words = `${"careful with this one, it touches the release branch config and the CI cache. ".repeat(8)}only the test file`;
|
||||
await f.answer(askId, { decision: words });
|
||||
await f.queueRepo.update({ qitemId: askId, actorSession: PERSON, state: "failed" });
|
||||
expect(await f.read(askId)).toMatchObject({ body: { state: "pending" } });
|
||||
expect(await f.decide(askId, "allow", PERSON, "only the test file")).toMatchObject({ body: { state: "answered", optionId: "allow" } });
|
||||
const note = f.queueRepo.listTransitions(askId).at(-1)?.transitionNote ?? "";
|
||||
expect(note).toContain(`the person wrote "${words.trim()}"`);
|
||||
});
|
||||
|
||||
it("a withdrawal after the row closed without a decision ends it: it reads withdrawn, and a later decision writes nothing", async () => {
|
||||
const f = fixture({ decider: null });
|
||||
const askId = String((await f.submit()).body.askId);
|
||||
await f.answer(askId, { optionId: "allow" });
|
||||
await f.queueRepo.update({ qitemId: askId, actorSession: PERSON, state: "done", closureReason: "no-follow-on" });
|
||||
expect(await f.read(askId)).toMatchObject({ body: { state: "pending" } });
|
||||
expect(await f.withdraw(askId, "turn_ended")).toMatchObject({ status: 200, body: { state: "withdrawn" } });
|
||||
expect(await f.read(askId)).toMatchObject({ body: { state: "withdrawn" } });
|
||||
expect(await f.decide(askId, "allow")).toMatchObject({ body: { state: "withdrawn" } });
|
||||
expect(f.queueRepo.listTransitions(askId).some((t) => t.transitionNote?.startsWith("runner-permission-decided"))).toBe(false);
|
||||
});
|
||||
|
||||
it("a failed handoff's kept words survive a later ordinary close: the request waits, never a default permit", async () => {
|
||||
const f = fixture({ decider: DECIDER });
|
||||
const askId = String((await f.submit()).body.askId);
|
||||
const spy = vi.spyOn(f.queueRepo as unknown as { handoffNow: () => unknown }, "handoffNow").mockImplementation(() => { throw new Error("staging failed"); });
|
||||
try { await f.answer(askId, { decision: "only the test file" }); } finally { spy.mockRestore(); }
|
||||
await f.queueRepo.update({ qitemId: askId, actorSession: PERSON, state: "done", closureReason: "no-follow-on" });
|
||||
expect((f.queueRepo.getById(askId)!.humanAnswers?.permission as { text?: string })?.text).toBe("only the test file");
|
||||
expect(await f.read(askId)).toMatchObject({ body: { state: "pending" } });
|
||||
expect(f.permits()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("a decision the row can't take says so: nothing is decided, and the request still waits", async () => {
|
||||
const f = fixture({ decider: null });
|
||||
const askId = String((await f.submit()).body.askId);
|
||||
await f.answer(askId, { decision: "only the test file" });
|
||||
const spy = vi.spyOn(f.queueRepo, "recordPermissionDecision").mockReturnValue(false);
|
||||
try {
|
||||
expect(await f.decide(askId, "allow")).toMatchObject({ status: 409, body: { ok: false, code: "not_recorded",
|
||||
error: expect.stringContaining("Nothing was decided. Read the request again.") } });
|
||||
} finally { spy.mockRestore(); }
|
||||
expect(await f.read(askId)).toMatchObject({ body: { state: "pending" } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("an answered request: #2934's follow-ups", () => {
|
||||
it("a decision on a row closed without one persists queue.updated, so a waiting runner and Steering hear it", async () => {
|
||||
const f = fixture({ decider: null });
|
||||
const askId = String((await f.submit()).body.askId);
|
||||
await f.answer(askId, { decision: "only the test file" });
|
||||
await f.queueRepo.update({ qitemId: askId, actorSession: PERSON, state: "done", closureReason: "no-follow-on" });
|
||||
const updates = () => (f.db.prepare("SELECT COUNT(*) AS n FROM events WHERE type = 'queue.updated' AND json_extract(payload, '$.qitemId') = ?").get(askId) as { n: number }).n;
|
||||
const before = updates();
|
||||
expect(await f.decide(askId, "allow")).toMatchObject({ body: { state: "answered", optionId: "allow" } });
|
||||
expect(updates()).toBe(before + 1);
|
||||
});
|
||||
|
||||
it("a handoff that fails after persisting its events still keeps the person's words (the envelope drops the rolled-back tokens)", async () => {
|
||||
const f = fixture({ decider: DECIDER });
|
||||
const askId = String((await f.submit()).body.askId);
|
||||
// The real handoff persists its two events, then fails at its last check: its savepoint rolls them back.
|
||||
const spy = vi.spyOn(f.queueRepo as unknown as { assertTerminalClosureHasIntent: () => void }, "assertTerminalClosureHasIntent")
|
||||
.mockImplementationOnce(() => { throw new Error("closure check failed"); });
|
||||
try {
|
||||
expect(await f.answer(askId, { decision: "only the test file" })).toMatchObject({ status: 200 });
|
||||
} finally { spy.mockRestore(); }
|
||||
expect(f.queueRepo.getById(askId)).toMatchObject({ state: "pending" });
|
||||
expect((f.queueRepo.getById(askId)!.humanAnswers?.permission as { text?: string })?.text).toBe("only the test file");
|
||||
expect(await f.read(askId)).toMatchObject({ body: { state: "pending" } });
|
||||
expect(f.permits()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("a handoff that throws after its own write was released rolls back whole, so the kept answer is the only outcome", async () => {
|
||||
const f = fixture({ decider: DECIDER });
|
||||
const askId = String((await f.submit()).body.askId);
|
||||
// The handoff's own transaction has released its savepoint; the throw comes after it, inside the caller's transaction.
|
||||
const spy = vi.spyOn(f.queueRepo as unknown as { deliverWakeIntentAfterCommit: () => void }, "deliverWakeIntentAfterCommit")
|
||||
.mockImplementationOnce(() => { throw new Error("failed after the handoff was written"); });
|
||||
try {
|
||||
expect(await f.answer(askId, { decision: "only the test file" })).toMatchObject({ status: 200 });
|
||||
} finally { spy.mockRestore(); }
|
||||
// Nothing of the handoff remains: the request wasn't handed on, and no row went to the receiving agent.
|
||||
expect(f.queueRepo.getById(askId)).toMatchObject({ state: "pending" });
|
||||
expect(f.db.prepare("SELECT COUNT(*) AS n FROM queue_items WHERE handed_off_from = ?").get(askId)).toEqual({ n: 0 });
|
||||
expect((f.queueRepo.getById(askId)!.humanAnswers?.permission as { text?: string })?.text).toBe("only the test file");
|
||||
expect(await f.read(askId)).toMatchObject({ body: { state: "pending" } });
|
||||
expect(f.permits()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// The Slack fallback answers a direct runner ask with resolveDirectHumanReply itself (slack-subsystem.ts:179): no notify
|
||||
// envelope and no transaction is open around it.
|
||||
describe("a direct answer with no notify envelope (the Slack fallback)", () => {
|
||||
const watch = (f: ReturnType<typeof fixture>) => {
|
||||
const seen: Array<{ type: string; qitemId: string; to?: string; inTransaction: boolean }> = [];
|
||||
const stop = f.bus.subscribe((event) => {
|
||||
if (event.type === "queue.handed_off") seen.push({ type: event.type, qitemId: event.qitemId, to: event.toSession, inTransaction: f.db.inTransaction });
|
||||
if (event.type === "queue.created") seen.push({ type: event.type, qitemId: event.qitemId, to: event.destinationSession, inTransaction: f.db.inTransaction });
|
||||
});
|
||||
return { seen, stop };
|
||||
};
|
||||
|
||||
it("a handoff that throws after its own write delivers nothing to subscribers, and the answer is kept", async () => {
|
||||
const f = fixture({ decider: DECIDER });
|
||||
const askId = String((await f.submit()).body.askId);
|
||||
const { seen, stop } = watch(f);
|
||||
const spy = vi.spyOn(f.queueRepo as unknown as { deliverWakeIntentAfterCommit: () => void }, "deliverWakeIntentAfterCommit")
|
||||
.mockImplementationOnce(() => { throw new Error("failed after the handoff was written"); });
|
||||
try {
|
||||
f.queueRepo.resolveDirectHumanReply({ qitemId: askId, actorSession: PERSON, decision: "only the test file" });
|
||||
} finally { spy.mockRestore(); stop(); }
|
||||
expect(seen.filter((e) => (e.type === "queue.handed_off" && e.qitemId === askId) || (e.type === "queue.created" && e.to === DECIDER))).toEqual([]);
|
||||
expect(f.queueRepo.getById(askId)).toMatchObject({ state: "pending" });
|
||||
expect(f.db.prepare("SELECT COUNT(*) AS n FROM queue_items WHERE handed_off_from = ?").get(askId)).toEqual({ n: 0 });
|
||||
expect((f.queueRepo.getById(askId)!.humanAnswers?.permission as { text?: string })?.text).toBe("only the test file");
|
||||
expect(f.permits()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("a handoff that succeeds reaches subscribers once, after it commits", async () => {
|
||||
const f = fixture({ decider: DECIDER });
|
||||
const askId = String((await f.submit()).body.askId);
|
||||
const { seen, stop } = watch(f);
|
||||
try {
|
||||
expect(f.queueRepo.resolveDirectHumanReply({ qitemId: askId, actorSession: PERSON, decision: "only the test file" })).toBe(true);
|
||||
} finally { stop(); }
|
||||
const successor = f.db.prepare("SELECT qitem_id FROM queue_items WHERE handed_off_from = ?").get(askId) as { qitem_id: string };
|
||||
expect(seen.filter((e) => e.qitemId === askId || e.qitemId === successor.qitem_id)).toEqual([
|
||||
{ type: "queue.handed_off", qitemId: askId, to: DECIDER, inTransaction: false },
|
||||
{ type: "queue.created", qitemId: successor.qitem_id, to: DECIDER, inTransaction: false },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,8 @@ function fixture(hooks: { file?: () => Promise<void>; exec?: (command: string) =
|
||||
commands.push(command);
|
||||
return hooks.exec ? hooks.exec(command)
|
||||
: command.includes("list-panes") ? "%1|0|/inert|80|24|1\n"
|
||||
: command.startsWith("tmux show-environment") ? "OPENRIG_NODE_ID=a\n"
|
||||
: command.startsWith("tmux display-message") && command.includes("#{pid}") ? "$1|4242|1700000000"
|
||||
: command.includes("#{session_id}") ? "$1" : "";
|
||||
}, { writeFile: async () => { await hooks.file?.(); }, unlink: async () => {}, tmpName: () => "/inert/buffer", bufferName: () => "inert" });
|
||||
const guard = new SeatDeliveryGuard(db, name => resolveGuardTarget(db, name)); tmux.deliveryGuard = guard;
|
||||
@@ -103,7 +105,7 @@ describe("guard-wired teardown", () => {
|
||||
expect(f.sessionRegistry.getBindingForNode("a")?.tmuxPane).toBe("%1");
|
||||
await f.guard.set("a", false, "person", "stop");
|
||||
expect(await f.teardown.teardown("r")).toMatchObject({ sessionsKilled: 1, errors: [] });
|
||||
expect(f.commands).toContain("tmux kill-session -t '$1'");
|
||||
expect(f.commands.some(c => /^tmux if-shell -F -t '\$1' .*#\{==:#\{pid\},4242\}.*kill-session -t .*\$1/.test(c))).toBe(true);
|
||||
expect(f.commands.filter(c => c.includes("list-panes"))).toHaveLength(1);
|
||||
expect(f.db.prepare("SELECT status FROM sessions WHERE id='sa'").get()).toEqual({ status: "exited" });
|
||||
expect(f.sessionRegistry.getBindingForNode("a")).toBeNull(); f.db.close();
|
||||
|
||||
@@ -472,27 +472,27 @@ it("automatic reminder records retained custody and no delivered fire or input",
|
||||
});
|
||||
it("guarded session termination binds the immutable session ID, never recycled name",async()=>{
|
||||
const {db,guard}=fixture();const commands:string[]=[];
|
||||
const tmux=new TmuxAdapter(async command=>{commands.push(command);return command.includes("display-message")?"$123\n":"";});tmux.deliveryGuard=guard;
|
||||
const tmux=new TmuxAdapter(async command=>{commands.push(command);return command.startsWith("tmux show-environment")?"OPENRIG_NODE_ID=a\n":command.startsWith("tmux display-message")?"$123|4242|1700000000\n":"";});tmux.deliveryGuard=guard;
|
||||
vi.spyOn(tmux,"listPanes").mockResolvedValue([{id:"%1"} as never]);
|
||||
expect(await guard.lifecycle(["a"],()=>tmux.killSession("a"))).toEqual({ok:true});
|
||||
expect(commands.at(-1)).toBe("tmux kill-session -t '$123'");db.close();
|
||||
expect(commands.at(-1)).toMatch(/^tmux if-shell -F -t '\$123' '#\{&&:#\{==:#\{session_id\},\$123\},#\{&&:#\{==:#\{pid\},4242\},#\{==:#\{start_time\},1700000000\}\}\}' 'kill-session -t .*\$123.*' 'display-message -p openrig-stop-target-changed'$/);db.close();
|
||||
});
|
||||
|
||||
it("failed first binding compensates only its proven fresh session under the same lifecycle lease",async()=>{
|
||||
const f=queueFixture();f.db.exec("INSERT INTO nodes(id,rig_id,logical_id) VALUES ('c','rig','fresh');");
|
||||
const commands:string[]=[];const tmux=new TmuxAdapter(async command=>{commands.push(command);return command.includes("display-message")?"$44\n":"";});tmux.deliveryGuard=f.guard;
|
||||
const commands:string[]=[];const tmux=new TmuxAdapter(async command=>{commands.push(command);return command.startsWith("tmux show-environment")?"OPENRIG_NODE_ID=c\n":command.startsWith("tmux display-message")?"$44|4242|1700000000\n":"";});tmux.deliveryGuard=f.guard;
|
||||
vi.spyOn(tmux,"listPanes").mockResolvedValue([{id:"%3"} as never]);
|
||||
vi.spyOn(f.bus,"persistWithinTransaction").mockImplementation(()=>{throw Error("injected DB failure");});
|
||||
const launcher=new NodeLauncher({db:f.db,rigRepo:f.rigRepo,sessionRegistry:f.sessionRegistry,eventBus:f.bus,tmuxAdapter:tmux});
|
||||
expect(await launcher.launchNode("rig","fresh")).toMatchObject({ok:false,code:"db_error"});
|
||||
expect(commands.at(-1)).toBe("tmux kill-session -t '$44'");expect(f.guard.target("c").pane).toBeNull();f.db.close();
|
||||
expect(commands.at(-1)).toMatch(/^tmux if-shell -F -t '\$44' .*#\{==:#\{pid\},4242\}.*kill-session -t .*\$44/);expect(f.guard.target("c").pane).toBeNull();f.db.close();
|
||||
});
|
||||
it("private probe termination uses only its positively allocated session identity",async()=>{
|
||||
const {db,guard}=fixture();const commands:string[]=[];
|
||||
const tmux=new TmuxAdapter(async command=>{commands.push(command);return command.includes("display-message")?"$55\n":"";});tmux.deliveryGuard=guard;
|
||||
const tmux=new TmuxAdapter(async command=>{commands.push(command);return command.startsWith("tmux show-environment")?"OPENRIG_NODE_ID=none\n":command.startsWith("tmux display-message")?"$55|4242|1700000000\n":"";});tmux.deliveryGuard=guard;
|
||||
vi.spyOn(tmux,"listPanes").mockResolvedValue([{id:"%55"} as never]);
|
||||
expect(await tmux.createProbeSession("probe")).toEqual({ok:true});expect(await tmux.killSession("probe")).toEqual({ok:true});
|
||||
expect(commands.at(-1)).toBe("tmux kill-session -t '$55'");const count=commands.length;
|
||||
expect(commands.at(-1)).toMatch(/^tmux if-shell -F -t '\$55' .*#\{==:#\{pid\},4242\}.*kill-session -t .*\$55/);const count=commands.length;
|
||||
expect(await tmux.sendKeys("probe",["Enter"])).toMatchObject({ok:false});expect(commands).toHaveLength(count);db.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -493,14 +493,14 @@ describe("SeatLifecycleService.launchFresh", () => {
|
||||
expect(tmux.startServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves the adopted seat name and names the release and rig down routes in plans and execution", async () => {
|
||||
it("preserves the adopted seat name and names the release route in plans and execution", async () => {
|
||||
const seat = seedSeat({ adopted: true });
|
||||
const expected = {
|
||||
code: "claimed_session",
|
||||
message: `Seat "${seat.sessionName}" has an adopted/operator-owned occupant; fresh launch refuses even with --stop.`,
|
||||
// B5: rig unclaim releases it (the process keeps running); rig down ends it with its rig.
|
||||
// B5: rig unclaim releases it (the process keeps running); rig down leaves a session OpenRig didn't start.
|
||||
guidance: "Release it with rig unclaim 'dev-impl@fresh-rig' (its process keeps running), end that process yourself, then launch fresh. "
|
||||
+ "Or end it with rig down 'fresh-rig' (this rig has one seat, so it stops only this session; rig up 'fresh-rig' --existing brings the rig back).",
|
||||
+ "rig down 'fresh-rig' leaves a session OpenRig didn't start running.",
|
||||
};
|
||||
const before = db.serialize();
|
||||
expect(await service.planFresh({ seatRef: seat.sessionName, fresh: true, stop: true }))
|
||||
|
||||
Loaded 100 of 117 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user