mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-11 03:31:38 -10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0df0bd588f | ||
|
|
8b5b0f0438 | ||
|
|
d34a36a24e |
No files matched your search
@@ -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 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.
|
||||
Every agent runs in a tmux session you can attach to, inspect, and work with directly.
|
||||
|
||||
## Teams
|
||||
|
||||
|
||||
@@ -32,12 +32,13 @@ 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_*`.** 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
|
||||
**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
|
||||
source: **0** hits
|
||||
(`git grep -n 'rigged_' -- ':(glob)packages/*/src/**' | grep -v -c '@rigged_'`).
|
||||
|
||||
|
||||
@@ -15,8 +15,7 @@ last-updated: 2026-11-09
|
||||
|
||||
# CLI Reference — Registered Commands and Options
|
||||
|
||||
Verified against the source commit in this page's `last-verified-against-source`
|
||||
frontmatter.
|
||||
Verified against source commit `e1dedbf531b6db9961b4c77c0a74564e58274a50`.
|
||||
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.
|
||||
@@ -1093,7 +1092,6 @@ 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. 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. |
|
||||
| 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. |
|
||||
| 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. 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. |
|
||||
| `stuckSweep`, `wakeLadder` | The heartbeats of the stuck sweep and the wake ladder, when they run. |
|
||||
|
||||
## Bearer tokens
|
||||
|
||||
|
||||
@@ -91,8 +91,7 @@ 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. With no receiving agent on the host, they go to
|
||||
`operator-agent@kernel` for compatibility, as before. A destination already saved in the connector
|
||||
messages to the default receiving agent, read when the connector starts. 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
|
||||
|
||||
@@ -241,15 +241,8 @@ 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.
|
||||
- **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.
|
||||
- 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.
|
||||
- **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.
|
||||
@@ -305,31 +298,9 @@ 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 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 |
|
||||
| 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) |
|
||||
| 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
|
||||
@@ -374,9 +345,7 @@ 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. 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.
|
||||
answered in words that were neither a choice nor a plain yes or no. It never changes the outcome.
|
||||
- **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
|
||||
|
||||
+13
-16
@@ -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 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
|
||||
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
|
||||
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 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`).
|
||||
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`).
|
||||
|
||||
Other layouts and other herdr-app windows don't show Rig views. Each window (app home) has its own.
|
||||
|
||||
@@ -81,12 +81,9 @@ Other layouts and other herdr-app windows don't show Rig views. Each window (app
|
||||
- **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.
|
||||
- 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)).
|
||||
- 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.
|
||||
|
||||
## What OpenRig keeps in the app home
|
||||
|
||||
|
||||
@@ -74,11 +74,7 @@ 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. 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.
|
||||
In-place handover and restart keep showing the same session.
|
||||
|
||||
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
|
||||
@@ -366,25 +362,20 @@ 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 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
|
||||
left part of each line. So a tile shows its seat without attaching to it: about
|
||||
once a second it reads the seat's screen with `tmux capture-pane`, redraws only
|
||||
after tmux records new activity, and lets the tile's own terminal wrap each
|
||||
line. A tile narrower than the agent's window still shows whole lines, and
|
||||
showing a seat never changes its size. A rule drawn across the window is cut to
|
||||
the tile's width. A read-only tile only shows the seat. In a writable tile, the
|
||||
first thing you type or paste attaches the tile and goes through tmux as it
|
||||
would in any attached client: a paste arrives as one paste, and tmux's prefix
|
||||
works (prefix then `[` to scroll back, for example). The tile sizes the window
|
||||
while you use it, as any client you type in does; after 30 seconds without a
|
||||
key it detaches and shows the seat again. A tile that's only showing has no
|
||||
scrollback of its own. If the seat's tmux server restarts, a tile shows the
|
||||
seat again only when the same managed seat returns. The printed manual attach commands
|
||||
keep `-f ignore-size`. Observers of a Herdr terminal do not themselves resize
|
||||
it; a controlling browser can. No cross-version resize guarantee is implied.
|
||||
|
||||
To type into a seat without changing its size, send the text through OpenRig
|
||||
|
||||
@@ -164,8 +164,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 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.
|
||||
Dedicated selection requires POSIX tmux. On Windows, psmux retains its existing
|
||||
server behavior; requesting `isolated` produces a status note and a pending choice.
|
||||
|
||||
## Two instances on one machine
|
||||
|
||||
|
||||
@@ -79,8 +79,6 @@ 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
|
||||
@@ -103,7 +101,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, usage_samples_days,
|
||||
retention.* enabled, transitions_days, watchdog_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
|
||||
|
||||
@@ -986,47 +986,6 @@ 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,8 +64,6 @@ 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?: {
|
||||
@@ -105,8 +103,6 @@ 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"],
|
||||
@@ -147,14 +143,9 @@ 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;
|
||||
// #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; `
|
||||
const note = `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 (${building ? "the ones written so far" : "always complete"}); use \`rig ps --nodes\` for node inventory including self + live state`;
|
||||
+ "edges = directional relationships (always complete); use `rig ps --nodes` for node inventory including self + live state";
|
||||
return { peers: shown, omitted, note };
|
||||
}
|
||||
|
||||
@@ -424,10 +415,6 @@ 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,7 +84,6 @@ 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;
|
||||
@@ -219,7 +218,6 @@ export interface RiggedConfig {
|
||||
enabled: boolean;
|
||||
transitionsDays: number;
|
||||
watchdogDays: number;
|
||||
usageSamplesDays: number;
|
||||
watchdogKeepPerJob: number;
|
||||
streamDays: number;
|
||||
streamMaxItems: number;
|
||||
@@ -308,10 +306,6 @@ 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,
|
||||
@@ -422,8 +416,6 @@ 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,
|
||||
@@ -507,9 +499,6 @@ 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.
|
||||
@@ -579,8 +568,6 @@ 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",
|
||||
@@ -656,7 +643,6 @@ 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" },
|
||||
@@ -713,7 +699,6 @@ 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" },
|
||||
@@ -773,7 +758,6 @@ 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"],
|
||||
@@ -824,7 +808,6 @@ 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"],
|
||||
@@ -1105,7 +1088,6 @@ 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"),
|
||||
@@ -1192,12 +1174,6 @@ 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}"`);
|
||||
@@ -1421,7 +1397,6 @@ 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,
|
||||
@@ -1512,7 +1487,6 @@ 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,
|
||||
|
||||
@@ -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.indexOf("@");
|
||||
const at = seat.lastIndexOf("@");
|
||||
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,9 +149,6 @@ 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.
|
||||
@@ -216,7 +213,6 @@ 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",
|
||||
@@ -891,44 +887,6 @@ 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
|
||||
@@ -1021,24 +979,6 @@ 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);
|
||||
|
||||
@@ -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 { describe, it, type TestContext } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -30,48 +30,62 @@ function shippedLoop(): string {
|
||||
return match[1]!;
|
||||
}
|
||||
|
||||
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 }); }
|
||||
}
|
||||
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);
|
||||
});
|
||||
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);
|
||||
|
||||
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,
|
||||
},
|
||||
port: 0, hostname: "127.0.0.1",
|
||||
}, () => resolve(s));
|
||||
});
|
||||
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 });
|
||||
}
|
||||
}
|
||||
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 });
|
||||
});
|
||||
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) {
|
||||
@@ -92,38 +106,26 @@ async function fixture(onTestFinished: TestContext["onTestFinished"]) {
|
||||
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 };
|
||||
}
|
||||
|
||||
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);
|
||||
it("wakes with the new owned item once it is assigned", async () => {
|
||||
const waiting = runLoop(30);
|
||||
const timer = setTimeout(() => { assign(DESK); }, 2_000);
|
||||
onTestFinished(() => clearTimeout(timer));
|
||||
setTimeout(() => { assign(DESK); }, 2_000);
|
||||
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 ({ expect, onTestFinished }) => {
|
||||
const { runLoop, assign, state } = await fixture(onTestFinished);
|
||||
it("keeps waiting through failed polls and wakes when the daemon answers again", async () => {
|
||||
assign(DESK);
|
||||
state.failLists = 3;
|
||||
failLists = 3;
|
||||
const result = await runLoop(30);
|
||||
expect(result.code).toBe(0);
|
||||
expect(state.listCalls).toBeGreaterThanOrEqual(4);
|
||||
expect(listCalls).toBeGreaterThanOrEqual(4);
|
||||
expect(result.stdout).toMatch(/^NEW WORK: /m);
|
||||
}, 40_000);
|
||||
|
||||
it("doesn't wake for owned work that is already claimed", async ({ expect, onTestFinished }) => {
|
||||
const { runLoop, assign, queue } = await fixture(onTestFinished);
|
||||
it("doesn't wake for owned work that is already claimed", async () => {
|
||||
const item = await assign(DESK);
|
||||
queue.claim({ qitemId: item.qitemId, destinationSession: DESK } as never);
|
||||
const result = await runLoop(4);
|
||||
@@ -132,8 +134,7 @@ describe.skipIf(process.platform === "win32").concurrent("the skill's opt-in wak
|
||||
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 ({ expect, onTestFinished }) => {
|
||||
const { runLoop, assign } = await fixture(onTestFinished);
|
||||
it("says it couldn't check, and exits non-zero, when every poll failed", async () => {
|
||||
assign(DESK);
|
||||
const result = await runLoop(3, null); // no seat identity: every `rig queue list --owned` fails
|
||||
expect(result.code).not.toBe(0);
|
||||
@@ -142,26 +143,23 @@ describe.skipIf(process.platform === "win32").concurrent("the skill's opt-in wak
|
||||
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 ({ expect, onTestFinished }) => {
|
||||
const { runLoop, state } = await fixture(onTestFinished);
|
||||
state.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 () => {
|
||||
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 ({ expect, onTestFinished }) => {
|
||||
const { runLoop, state } = await fixture(onTestFinished);
|
||||
state.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 () => {
|
||||
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 ({ expect, onTestFinished }) => {
|
||||
const { runLoop, assign } = await fixture(onTestFinished);
|
||||
it("ignores work assigned to another seat and ends at its deadline", async () => {
|
||||
assign("impl@team");
|
||||
const result = await runLoop(4);
|
||||
expect(result.code).toBe(0);
|
||||
|
||||
@@ -98,18 +98,6 @@ 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,30 +214,6 @@ 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,6 +50,12 @@ This plugin ships dual-manifest packaging — both `.claude-plugin/` and `.codex
|
||||
no-ops. The plugin supplies refocus and skills. Disabling an inline activity hook is not bypassed by the plugin.
|
||||
- **Seat scope:** the commands require both `OPENRIG_SEAT_PROJECT_DIR` and the executable `OPENRIG_NODE`. Personal and
|
||||
unselected sessions are no-ops. A nested Codex inheriting those seat variables still acts as that seat.
|
||||
- **Refocus output:** the Codex prompt handler keeps its unchanged declaration and default 2,500-token budget
|
||||
(10,000 UTF-8 bytes), preserving its native trust identity. Larger packets
|
||||
send a prefix and an explicit shortening notice for the current attempt. After a claimed, successful render/write
|
||||
and state save, the refocus record lists that attempt's omitted tail byte count and full packet's `.context.txt`
|
||||
path, or says the full text could not be saved. A trace/content failure or unavailable final state guard/write can
|
||||
leave older metadata or no record. The metadata describes the last successfully recorded attempt, not model receipt.
|
||||
|
||||
Generated Claude settings commands for the activity relay and context collector use the daemon's absolute Node
|
||||
executable when its name is recognized (`node` or `node.exe`); other names keep the legacy bare `node` command and
|
||||
|
||||
@@ -64,20 +64,6 @@ 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) {
|
||||
@@ -141,7 +127,6 @@ 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,10 +630,30 @@ function runsForThisSeatsProject(env = process.env) {
|
||||
"",
|
||||
...body,
|
||||
]).join("\n");
|
||||
let additionalContext = payload;
|
||||
let contextOutput;
|
||||
if (runtime() === "codex") {
|
||||
// Keep the handler declaration unchanged; Codex's default is 2,500 tokens, ceil(UTF-8 bytes / 4).
|
||||
const limitTokens = 2_500;
|
||||
const bytes = Buffer.from(payload);
|
||||
contextOutput = { status: "full", limitTokens, originalBytes: bytes.length, outputBytes: bytes.length, omittedBytes: 0 };
|
||||
if (bytes.length > limitTokens * 4) {
|
||||
let fullPath = `${stateFile}.${crypto.randomUUID()}.context.txt`;
|
||||
try { fs.writeFileSync(fullPath, payload, { flag: "wx", mode: 0o600 }); }
|
||||
catch { fullPath = null; }
|
||||
const location = fullPath ? `Full text saved to: ${fullPath}` : "Full text could not be saved.";
|
||||
// Reserve the notice before taking the prefix; streaming decode drops an incomplete final UTF-8 character.
|
||||
const prefix = new TextDecoder().decode(bytes.subarray(0, Math.max(0, limitTokens * 4 - Buffer.byteLength(location) - 256)), { stream: true });
|
||||
const prefixBytes = Buffer.byteLength(prefix);
|
||||
const omittedBytes = bytes.length - prefixBytes;
|
||||
additionalContext = `${prefix}\n\n[REFOCUS SHORTENED: omitted ${omittedBytes} trailing UTF-8 bytes from the assembled trace, role and standing instructions. ${location}]`;
|
||||
contextOutput = { status: "shortened", limitTokens, originalBytes: bytes.length, outputBytes: Buffer.byteLength(additionalContext), prefixBytes, omittedBytes, fullPath };
|
||||
}
|
||||
}
|
||||
const output = JSON.stringify({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: "UserPromptSubmit",
|
||||
additionalContext: payload,
|
||||
additionalContext,
|
||||
},
|
||||
});
|
||||
process.stdout.write(output, (error) => {
|
||||
@@ -661,6 +681,9 @@ function runsForThisSeatsProject(env = process.env) {
|
||||
if (size > 0 && latest.lastReset?.at === state.lastReset?.at) latest.lastBytes = size;
|
||||
latest.firedAt = new Date().toISOString();
|
||||
latest.firedOn = event;
|
||||
// Writer completion, not a model-context receipt. A shortened output names its omitted tail and full-text file.
|
||||
if (contextOutput) latest.contextOutput = contextOutput;
|
||||
else delete latest.contextOutput;
|
||||
if (samePending) {
|
||||
delete latest.pendingOn;
|
||||
delete latest.pendingAt;
|
||||
|
||||
@@ -146,8 +146,7 @@ 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 that name nobody go to them (a pairing request or an ask can
|
||||
name someone else), and don't offer again.
|
||||
- A default person is there: say requests and asks go to them, 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,
|
||||
|
||||
@@ -44,13 +44,10 @@ 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. 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":
|
||||
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":
|
||||
`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,12 +11,9 @@ 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. 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 +
|
||||
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 +
|
||||
Codex both authenticated.
|
||||
|
||||
culture_file: culture/CULTURE.md
|
||||
|
||||
@@ -79,7 +79,7 @@ function shellWord(value: string): string {
|
||||
}
|
||||
|
||||
/** `tmux`, with the daemon's server flags when it isn't on the default server. */
|
||||
export function tmuxOnDaemonServer(): string {
|
||||
function tmuxOnDaemonServer(): string {
|
||||
const server = daemonServerArgs.map((arg, i) => (i % 2 === 0 ? arg : shellWord(arg))).join(" ");
|
||||
return `tmux${server ? ` ${server}` : ""}`;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ 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>;
|
||||
|
||||
@@ -278,9 +277,7 @@ 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;
|
||||
/** The pane and its process that this capture read, from the same tmux call (#2515). */
|
||||
pane?: { id: string; pid: number } }
|
||||
export interface PaneCapture { text: string | null; capturedAt: Date; /** The listing lacked the session. */ missing?: true }
|
||||
|
||||
/** 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. */
|
||||
@@ -547,7 +544,7 @@ export class TmuxAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
private async guardedInput(target: string, write: (pane: string, beforeWrite: () => void, nodeId?: string) => Promise<TmuxResult>, allowAbsent = false, inputKind: "control" | "text" | "keys" | "respawn" = "control"): Promise<TmuxResult> {
|
||||
private async guardedInput(target: string, write: (pane: string, beforeWrite: () => void) => Promise<TmuxResult>, allowAbsent = false, inputKind: "control" | "text" | "keys" | "respawn" = "control"): Promise<TmuxResult> {
|
||||
const guard = this.deliveryGuard;
|
||||
if (!guard) return write(target, () => {});
|
||||
try {
|
||||
@@ -597,7 +594,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), bound.nodeId));
|
||||
return guard.input(identity, () => write(pane, () => guard.checkInput(identity)));
|
||||
});
|
||||
} catch (error) {
|
||||
return { ok: false, code: (error as { code?: string }).code ?? "guard_target_unknown", message: String((error as Error).message) };
|
||||
@@ -1311,26 +1308,16 @@ export class TmuxAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
/** With a delivery guard, terminate by immutable tmux session ID; checked callers validate before detach and kill. */
|
||||
async killSession(name: string, beforeEffect?: () => void): Promise<TmuxResult> {
|
||||
let stopPhase: StopPhase = "none";
|
||||
if (this.deliveryGuard) {
|
||||
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 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 kill = async () => {
|
||||
const result = await this.killSessionUnchecked(sessionId, beforeEffect && (() => { beforeWrite(); beforeEffect(); }), phase => { stopPhase = phase; }, { pid, start });
|
||||
const result = await this.killSessionUnchecked(sessionId, beforeEffect && (() => { beforeWrite(); beforeEffect(); }), phase => { stopPhase = phase; });
|
||||
if (result.ok) { this.freshProbes.delete(name); this.freshProbes.delete(pane); this.freshManaged.delete(name); }
|
||||
return result;
|
||||
};
|
||||
@@ -1343,48 +1330,16 @@ 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,
|
||||
server?: { pid: string; start: string }): Promise<TmuxResult> {
|
||||
private async killSessionUnchecked(name: string, beforeEffect?: () => void, phase?: (value: StopPhase) => void): 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 {
|
||||
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.` };
|
||||
await this.run(["tmux", "detach-client", "-s", session],
|
||||
`tmux detach-client -s ${shellQuote(session)}`);
|
||||
} 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);
|
||||
@@ -1394,8 +1349,8 @@ export class TmuxAdapter {
|
||||
beforeEffect?.();
|
||||
phase?.("kill_attempted");
|
||||
try {
|
||||
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.` };
|
||||
await this.run(["tmux", "kill-session", "-t", session],
|
||||
`tmux kill-session -t ${shellQuote(session)}`);
|
||||
const pane = this.freshProbes.get(name);
|
||||
this.freshProbes.delete(name);
|
||||
if (pane) this.freshProbes.delete(pane);
|
||||
@@ -1826,14 +1781,12 @@ 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}:`, header(k), ";", "capture-pane", "-p", "-t", `=${t}:`, "-S", `-${lines}`,
|
||||
"display-message", "-p", "-t", `=${t}:`, mark(k), ";", "capture-pane", "-p", "-t", `=${t}:`, "-S", `-${lines}`,
|
||||
])];
|
||||
const legacy = "tmux " + chunk.map((t, k) =>
|
||||
`display-message -p -t ${shellQuote(`=${t}:`)} ${shellQuote(header(k))} \\; capture-pane -p -t ${shellQuote(`=${t}:`)} -S -${lines}`,
|
||||
`display-message -p -t ${shellQuote(`=${t}:`)} ${shellQuote(mark(k))} \\; capture-pane -p -t ${shellQuote(`=${t}:`)} -S -${lines}`,
|
||||
).join(" \\; ");
|
||||
let output: string;
|
||||
let capturedAt: Date;
|
||||
@@ -1877,18 +1830,14 @@ 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}_`) ? marker.exec(line) : null;
|
||||
if (m && Number(m[1]) < chunk.length) {
|
||||
const m = line.startsWith(`__openrig_capture_${nonce}_`) ? /_(\d+)__$/.exec(line) : null;
|
||||
if (m && line === mark(Number(m[1]))) {
|
||||
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);
|
||||
}
|
||||
@@ -1898,7 +1847,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, ...(panes[k] ? { pane: panes[k] } : {}) });
|
||||
out.set(t, { text: text === "" ? null : text, capturedAt });
|
||||
});
|
||||
};
|
||||
const unique = [...new Set(targets)];
|
||||
|
||||
@@ -72,23 +72,3 @@ 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, written) => { inputWritten = true; opts.onInputEffect?.(phase, written); },
|
||||
onInputEffect: phase => { inputWritten = true; opts.onInputEffect?.(phase); },
|
||||
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,29 +110,6 @@ 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. After that the ring is unconfirmed, not failed: the trigger may have it. */
|
||||
/** How long a trigger may take to answer before the ring counts as failed. */
|
||||
export const HOSTED_WAKE_TIMEOUT_MS = 10_000;
|
||||
|
||||
export interface HostedWakeRing {
|
||||
@@ -66,20 +66,8 @@ 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");
|
||||
// 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" };
|
||||
}
|
||||
if (timedOut) return failed(urlRef, `didn't answer within ${opts.timeoutMs ?? HOSTED_WAKE_TIMEOUT_MS} ms`);
|
||||
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,19 +218,6 @@ 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",
|
||||
@@ -738,9 +725,7 @@ export class MissionControlWriteContract {
|
||||
onUnclosed: () =>
|
||||
new MissionControlWriteContractError(
|
||||
"answer_not_closed",
|
||||
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.`,
|
||||
`The answer went back to ${ask.sourceSession} as ${replyId}, but qitem ${input.qitemId} didn't close: it changed meanwhile.`,
|
||||
{ qitemId: input.qitemId, replyQitemId: replyId },
|
||||
),
|
||||
},
|
||||
@@ -771,10 +756,6 @@ 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 {
|
||||
@@ -792,13 +773,13 @@ export class MissionControlWriteContract {
|
||||
}
|
||||
after = this.queueRepo.getById(input.qitemId);
|
||||
actionEntry = this.actionLog.record({
|
||||
actionVerb,
|
||||
actionVerb: "answer",
|
||||
qitemId: input.qitemId,
|
||||
actorSession: input.actorSession,
|
||||
actedAt: evaluatedAt,
|
||||
beforeState: before,
|
||||
afterState: after ? snapshotQitem(after) : null,
|
||||
reason: auditReason,
|
||||
reason,
|
||||
auditNotes: input.auditNotes ?? null,
|
||||
identityProvenance: input.identityProvenance ?? null,
|
||||
});
|
||||
@@ -806,7 +787,7 @@ export class MissionControlWriteContract {
|
||||
this.eventBus.persistWithinTransaction({
|
||||
type: "mission_control.action_executed",
|
||||
actionId: actionEntry.actionId,
|
||||
actionVerb,
|
||||
actionVerb: "answer",
|
||||
qitemId: input.qitemId,
|
||||
actorSession: input.actorSession,
|
||||
}),
|
||||
@@ -818,19 +799,16 @@ export class MissionControlWriteContract {
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
// A runner's permission request isn't closed by an answer: it's handed to the receiving agent, or kept. Say which.
|
||||
const final = after as QueueItem | null;
|
||||
const runnerNotice = !answer.message && final && runnerPermissionOrigin(final) ? runnerAnswerNotice(final) : undefined;
|
||||
return {
|
||||
actionId: actionEntry!.actionId,
|
||||
verb: actionVerb,
|
||||
verb: "answer",
|
||||
qitemId: input.qitemId,
|
||||
closedQitem: after,
|
||||
createdQitemId,
|
||||
notifyAttempted: false,
|
||||
notifyResult: null,
|
||||
auditedAt: evaluatedAt,
|
||||
answer: runnerNotice ? { ...answer, message: runnerNotice } : answer,
|
||||
answer,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -963,18 +941,6 @@ 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,
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
/** 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,8 +72,6 @@ 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";
|
||||
@@ -86,7 +84,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 { hostedRingMayHaveArrived, ringHostedWake } from "./hosted-wake.js";
|
||||
import { ringHostedWake } from "./hosted-wake.js";
|
||||
import { effectiveGateRoles } from "./gate-predicate.js";
|
||||
import {
|
||||
computeClosureRequiredAt,
|
||||
@@ -182,8 +180,22 @@ 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
|
||||
@@ -1098,7 +1110,6 @@ 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>();
|
||||
@@ -1152,10 +1163,6 @@ 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;
|
||||
@@ -1174,7 +1181,6 @@ 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");
|
||||
@@ -1665,8 +1671,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, 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.
|
||||
// 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.
|
||||
if (group.every((entry) => entry.tags?.includes(HOSTED_WAKE_ACCEPTED_TAG))) {
|
||||
for (const entry of group) this.outbox.finalizeDelivery(entry.outboxId, "indeterminate");
|
||||
return "indeterminate";
|
||||
@@ -1707,8 +1713,8 @@ export class QueueRepository {
|
||||
const wakeEvent = this.db.transaction(() => {
|
||||
this.recordNudgeAttempt(qitemId, nudgeResult);
|
||||
this.outbox!.finalizeDelivery(intent.outboxId, finalState);
|
||||
// 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)) {
|
||||
// The acceptance belongs to this intent, so recovery can tell this wake already rang.
|
||||
if (outcome.nudgeResult.startsWith("wake-accepted:")) {
|
||||
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);
|
||||
@@ -1782,12 +1788,11 @@ 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, 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. */
|
||||
* 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. */
|
||||
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 receiver (no session or transport, or a terminal). */
|
||||
/** Present only when a notification waits for a seat with no session or transport. */
|
||||
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 };
|
||||
@@ -1841,20 +1846,16 @@ export class QueueRepository {
|
||||
return result();
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
/** 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. */
|
||||
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(physical) as { status: string } | undefined;
|
||||
.get(physicalSessionName(this.db, destinationSession)) as { status: string } | undefined;
|
||||
return newest?.status === "exited" || newest?.status === "detached";
|
||||
} catch {
|
||||
return false;
|
||||
@@ -2549,12 +2550,6 @@ 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(
|
||||
@@ -2717,10 +2712,8 @@ 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) {
|
||||
const event = e.payload as import("./types.js").PersistedEvent;
|
||||
if (!this.eventBus.registerPersistedWithinActiveEnvelope(event)) this.eventBus.notifySubscribers(event);
|
||||
this.eventBus.notifySubscribers(e.payload as import("./types.js").PersistedEvent);
|
||||
}
|
||||
|
||||
// Closure and successor are persisted. Delivery is separate and recoverable
|
||||
@@ -4190,8 +4183,6 @@ 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);
|
||||
@@ -4217,121 +4208,6 @@ 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. */
|
||||
@@ -5838,7 +5714,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) ids.push(r.blocked_on);
|
||||
if (includeWaiting) for (const r of rows) if (r.blocked_on?.startsWith("qitem-")) 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,10 +116,8 @@ export const PRUNABLE_EVENT_TYPES = [
|
||||
"queue.claimed",
|
||||
"queue.unclaimed",
|
||||
"queue.handed_off",
|
||||
// One delivery-evidence record per send, and one prompt observation per prompt hook; kept only as long as the other
|
||||
// high-volume event types.
|
||||
// One delivery-evidence record per send; kept only as long as the other high-volume event types.
|
||||
"transport.delivery_evidence",
|
||||
"transport.prompt_observation",
|
||||
] as const;
|
||||
|
||||
export interface RetentionOptions {
|
||||
|
||||
@@ -122,12 +122,7 @@ 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;
|
||||
@@ -136,7 +131,7 @@ export interface StuckSweepStatusSnapshot {
|
||||
}
|
||||
|
||||
export interface StuckSweepStatus {
|
||||
record(outcome: "clean" | "findings" | "failed", detail?: { error?: string; created?: number; open?: number }): void;
|
||||
record(outcome: "clean" | "findings" | "failed", detail?: { error?: string; findings?: number }): void;
|
||||
/** The scheduler's one-pass-at-a-time state, mirrored for /healthz. */
|
||||
pass(state: { runningSince: string | null; skippedTicks: number }): void;
|
||||
snapshot(): StuckSweepStatusSnapshot;
|
||||
@@ -151,7 +146,6 @@ export function createStuckSweepStatus(): StuckSweepStatus {
|
||||
lastError: null,
|
||||
consecutiveFailures: 0,
|
||||
findingsRouted: 0,
|
||||
findingsOpen: 0,
|
||||
runningSince: null,
|
||||
skippedTicks: 0,
|
||||
};
|
||||
@@ -161,8 +155,7 @@ 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?.created) state.findingsRouted += detail.created;
|
||||
if (detail?.open !== undefined) state.findingsOpen = detail.open;
|
||||
if (detail?.findings) state.findingsRouted += detail.findings;
|
||||
},
|
||||
pass(pass) {
|
||||
state.runningSince = pass.runningSince;
|
||||
@@ -647,15 +640,6 @@ 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
|
||||
@@ -1637,11 +1621,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, ...findingCounts(deps.db, findings) });
|
||||
status?.record("failed", { error, findings: findings.filter((f) => f.action !== "closed").length });
|
||||
return { outcome: "failed", findings, error };
|
||||
}
|
||||
const outcome = findings.length > 0 ? "findings" : "clean";
|
||||
status?.record(outcome, findingCounts(deps.db, findings));
|
||||
status?.record(outcome, { findings: findings.filter((f) => f.action !== "closed").length });
|
||||
return { outcome, findings };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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";
|
||||
@@ -143,7 +142,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 && !isTypedGateBlocker(row.blocked_on) ? statements.statement(
|
||||
const blocker = row.blocked_on?.startsWith("qitem-") ? 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;
|
||||
@@ -152,7 +151,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, blocker ? row.blocked_on! : id, reads);
|
||||
const meaningful = meaningfulFor(db, row.blocked_on?.startsWith("qitem-") ? 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,
|
||||
@@ -179,11 +178,10 @@ 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: liveBlocker ? blocker.destination_session : row.destination_session, mechanism: liveBlocker ? "blocker-transition / queue-stuck-sweep" : NO_TIMED_BACKSTOP, dueAt: null, intervalSeconds: liveBlocker ? sweepInterval : null };
|
||||
} :{ owner: blocker?.destination_session ?? row.destination_session, mechanism: blocker ? "blocker-transition / queue-stuck-sweep" : NO_TIMED_BACKSTOP, dueAt: null, intervalSeconds: blocker ? sweepInterval : null };
|
||||
if (timer) {
|
||||
let notice: { at: string; deliveryStatus: string } | undefined;
|
||||
try {
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
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,7 +21,6 @@ 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"]);
|
||||
@@ -1413,7 +1412,7 @@ export class PodRigInstantiator {
|
||||
name = typeof parsed?.name === "string" ? parsed.name : null;
|
||||
} catch { /* the import below reports the parse error */ }
|
||||
}
|
||||
if (!name) return this.instantiateBuilding(rigSpecYaml, rigRoot, opts);
|
||||
if (!name) return this.instantiateOnce(rigSpecYaml, rigRoot, opts);
|
||||
let inFlight = importsInFlight.get(this.db);
|
||||
if (!inFlight) importsInFlight.set(this.db, (inFlight = new Set()));
|
||||
if (inFlight.has(name)) {
|
||||
@@ -1421,27 +1420,12 @@ export class PodRigInstantiator {
|
||||
}
|
||||
inFlight.add(name);
|
||||
try {
|
||||
return await this.instantiateBuilding(rigSpecYaml, rigRoot, opts);
|
||||
return await this.instantiateOnce(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 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.",
|
||||
"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.",
|
||||
].join("\n"),
|
||||
});
|
||||
return { ok: true, askId, created: true };
|
||||
@@ -266,31 +266,15 @@ export class RunnerPermissions {
|
||||
return { ok: true, state: withdrawn ? "withdrawn" : "stale" };
|
||||
}
|
||||
if (!askIsCurrent || !callerMatches) return { ok: true, state: "stale" };
|
||||
// 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 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.
|
||||
const allowId = tagValue(item, ALLOW_TAG), rejectId = tagValue(item, REJECT_TAG);
|
||||
// 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.
|
||||
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.
|
||||
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 };
|
||||
@@ -348,28 +332,6 @@ 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." };
|
||||
@@ -378,11 +340,8 @@ 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. 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.` });
|
||||
// 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);
|
||||
return { ok: true, state: "withdrawn" };
|
||||
}
|
||||
|
||||
@@ -391,44 +350,10 @@ 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 live = asked ? this.liveEnd(asked) : null;
|
||||
const said = typedWords(live ? this.personAnswer(live) : undefined);
|
||||
const said = typedWords((asked ? this.liveEnd(asked) : null)?.humanAnswers?.[QUESTION_ID]);
|
||||
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,10 +15,9 @@ 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, StructuralObservation } from "./seat-structural-activity-service.js";
|
||||
import type { SeatStructuralActivityService } 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
|
||||
@@ -223,15 +222,14 @@ 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) && this.codexFrameQualifies(seat, frame!.codexWorkingFrame!.captureStartedAt, frame!.pane, "if-known")
|
||||
&& Number.isFinite(frameStarted) && frameStarted >= seat.inventorySinceMs
|
||||
&& (previousStarted === undefined || Date.parse(previousStarted) >= seat.inventorySinceMs)
|
||||
&& Number.isFinite(proofAt) && proofAt <= capturedAt && proofAt <= observedAt.getTime()
|
||||
&& observedAt.getTime() - proofAt < DEFAULT_STRUCTURAL_STALE_MS && frameStarted <= proofAt;
|
||||
@@ -247,23 +245,6 @@ 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
|
||||
@@ -435,7 +416,6 @@ 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));
|
||||
@@ -446,15 +426,6 @@ 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
|
||||
@@ -524,22 +495,12 @@ 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). */
|
||||
@@ -914,36 +875,8 @@ 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;
|
||||
@@ -1065,21 +998,6 @@ 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;
|
||||
@@ -1097,10 +1015,6 @@ 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>>;
|
||||
|
||||
@@ -14,11 +14,6 @@ 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. */
|
||||
@@ -108,14 +103,12 @@ 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);
|
||||
@@ -130,9 +123,7 @@ export class SeatStructuralActivityService {
|
||||
}
|
||||
const c = communityHarnessPaneActivity(classifyPaneActivity(content), communityHarness, content);
|
||||
const at = observedAt ?? this.now();
|
||||
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 previous = this.latestBySession.get(sessionName);
|
||||
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
|
||||
@@ -150,8 +141,6 @@ 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")
|
||||
@@ -159,7 +148,7 @@ export class SeatStructuralActivityService {
|
||||
? { idleFrame: { sessionName, runtime, captureStartedAt: captureStartedAt.toISOString() } } : {}),
|
||||
};
|
||||
this.latestBySession.set(sessionName, obs);
|
||||
if (latest?.state !== obs.state || latest?.reason !== obs.reason) this.activityChanged?.(sessionName);
|
||||
if (previous?.state !== obs.state || previous?.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 { createHash, randomUUID } from "node:crypto";
|
||||
import { 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,14 +158,6 @@ 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");
|
||||
}
|
||||
@@ -1982,9 +1974,8 @@ 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. A paste reports the exact text it
|
||||
* wrote, for delivery evidence. */
|
||||
onInputEffect?: (phase: "pasted" | "submitted", written?: { text: string }) => void;
|
||||
/** Internal observation of a completed input effect; never a permission callback. */
|
||||
onInputEffect?: (phase: "pasted" | "submitted") => 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.
|
||||
@@ -2693,14 +2684,8 @@ 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);
|
||||
// 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);
|
||||
const result = await this.sendMarkingUrgent(sessionName, text, opts);
|
||||
this.recordDeliveryEvidence(sessionName, seat, opts, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -2720,8 +2705,7 @@ 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,
|
||||
wire: { text: string; at: string } | null): void {
|
||||
private recordDeliveryEvidence(sessionName: string, seat: ReturnType<SessionTransport["evidenceSeat"]>, opts: SendOpts | undefined, result: SendResult): void {
|
||||
if (!this.eventBus || !seat) return;
|
||||
try {
|
||||
const outboxIds = opts?.committedOutboxIds ?? result.outboxIds ?? [];
|
||||
@@ -2735,7 +2719,6 @@ 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) {
|
||||
@@ -3740,7 +3723,7 @@ export class SessionTransport {
|
||||
...(waitMode || policyRefusal ? { sent: false, ...waitEvidence } : {}),
|
||||
});
|
||||
}
|
||||
try { opts?.onInputEffect?.("pasted", { text }); } catch { /* Observation cannot change a completed paste. */ }
|
||||
try { opts?.onInputEffect?.("pasted"); } 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.
|
||||
@@ -4182,11 +4165,9 @@ export class SessionTransport {
|
||||
hookActivity.reason !== "permission_request_pending" &&
|
||||
hookActivity.reason !== "permission_prompt_cleared"
|
||||
) {
|
||||
// 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.
|
||||
// Fresh hook (within the 15s send window): authoritative for any state.
|
||||
if (this.hookFreshForSend(hookActivity, now)) {
|
||||
if ((hookActivity.state === "running" || hookActivity.state === "idle") && input.runtime === "claude-code") {
|
||||
if (hookActivity.state === "running" && input.runtime === "claude-code") {
|
||||
const paneVeto = await probeSessionActivity({
|
||||
communityHarness, communityHarnessSendProbe: true,
|
||||
composerSnapshot,
|
||||
@@ -4199,9 +4180,7 @@ export class SessionTransport {
|
||||
captureObserver: privateInput ? undefined : this.captureObserver,
|
||||
binding,
|
||||
});
|
||||
// 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;
|
||||
if (paneVeto.state === "needs_input") return paneVeto;
|
||||
}
|
||||
return hookActivity;
|
||||
}
|
||||
|
||||
@@ -811,22 +811,19 @@ export class SliceIndexer {
|
||||
// --- internals ---
|
||||
|
||||
private readSliceLocations(): SliceLocation[] {
|
||||
return this.walkSliceLocations(false, [], undefined, this.missionId || undefined);
|
||||
return this.walkSliceLocations().filter((location) => !this.missionId || location.missionId === this.missionId);
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
/** 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. */
|
||||
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);
|
||||
@@ -1088,7 +1085,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, this.missionId ? missionId : null);
|
||||
const proofPacket = this.findProofPacket(name);
|
||||
const lastActivityAt = this.computeLastActivity(slicePath, qitemIds, proofPacket);
|
||||
|
||||
return {
|
||||
@@ -1118,7 +1115,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, this.missionId ? missionId : null);
|
||||
const proofPacket = this.findProofPacket(name);
|
||||
const lastActivityAt = this.computeLastActivity(slicePath, qitemIds, proofPacket);
|
||||
const commitRefs = this.extractCommitRefs(frontmatter);
|
||||
const files = this.listSliceFiles(slicePath);
|
||||
@@ -1505,34 +1502,7 @@ export class SliceIndexer {
|
||||
return this.membershipIndex;
|
||||
}
|
||||
|
||||
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 {
|
||||
private findProofPacket(sliceName: string): SliceProofPacket | null {
|
||||
if (!this.dogfoodEvidenceRoot) return null;
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
@@ -1541,26 +1511,19 @@ export class SliceIndexer {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
// 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.
|
||||
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;
|
||||
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 dirTokenSet = new Set(dirent.name.split(/[-._]/).filter((t) => t.length > 0));
|
||||
const allTokensPresent = sliceTokens.every((t) => dirTokenSet.has(t));
|
||||
if (!allTokensPresent) continue;
|
||||
try {
|
||||
|
||||
@@ -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 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
|
||||
// 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
|
||||
// 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,22 +189,14 @@ 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:
|
||||
* 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
|
||||
* 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
|
||||
* 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";
|
||||
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 }
|
||||
terminalObserved: DeliveryEvidenceObserved; inputBoundary: "unknown"; source: "send_result" }
|
||||
// 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, LEGACY_INBOUND_DESTINATION } from "../gateway/slack/config.js";
|
||||
import { DEFAULT_CONFIG, INHERIT_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,14 +144,9 @@ 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", 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));
|
||||
"Displayed daemon instance", describeDefaultReceiver(receiver));
|
||||
}
|
||||
if (resolved) {
|
||||
for (const host of store.listFeedHostSubscriptions()) {
|
||||
@@ -176,13 +171,7 @@ 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 ? 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);
|
||||
unchosen ? "Not chosen: when the connector starts, unrouted messages go to the default receiving agent (Agents & runtime)." : 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,12 +8,11 @@ export interface WakeRecovery {
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
/** Notifications the pass didn't replay because their seats have no session or transport, 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 receiver (no session or transport, or a terminal seat) for ${named})`;
|
||||
return ` waiting=${waiting.notifications} (not replayed: no session or transport for ${named})`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,7 +4,6 @@ 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";
|
||||
@@ -44,9 +43,6 @@ 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 } }>;
|
||||
@@ -145,11 +141,6 @@ 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;
|
||||
@@ -365,14 +356,11 @@ export class WhoamiService {
|
||||
envOverride: query.targetRepoOverride ?? process.env["OPENRIG_TARGET_REPO"] ?? null,
|
||||
});
|
||||
|
||||
const building = rigStillBeingBuilt(this.db, nodeRow.rig_id);
|
||||
return {
|
||||
resolvedBy,
|
||||
identity,
|
||||
peers,
|
||||
...(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",
|
||||
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",
|
||||
edges: { outgoing, incoming },
|
||||
transcript,
|
||||
commands: { sendExamples, captureExamples },
|
||||
|
||||
@@ -733,7 +733,6 @@ 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
|
||||
@@ -816,33 +815,6 @@ 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 } {
|
||||
@@ -1101,28 +1073,6 @@ 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", "transport.prompt_observation"];
|
||||
const OMITTED_TYPES: readonly string[] = ["transport.delivery_evidence"];
|
||||
|
||||
function getEventBus(c: { get: (key: string) => unknown }): EventBus {
|
||||
return c.get("eventBus" as never) as EventBus;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { EventBus } from "../domain/event-bus.js";
|
||||
import { listProjects, selectedProject, projectMission, workSource, insideProject, projectReadResponse } from "../domain/workspace/project-read.js";
|
||||
import { listProjects, selectedProject, projectMission, workSource, projectReadResponse } from "../domain/workspace/project-read.js";
|
||||
import { inferCatalogProject, ProjectReadError } from "../domain/workspace/project-catalog.js";
|
||||
// Slice Story View v0 — HTTP routes.
|
||||
//
|
||||
@@ -166,20 +166,15 @@ 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(servedPath, contentType, c.req.header("Range"));
|
||||
return fileAssetResponse(abs, 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, { includeEvidenceRoot: false });
|
||||
const selected = selectSliceDeps(c, name);
|
||||
if ("response" in selected) return selected.response;
|
||||
const { deps } = selected;
|
||||
const fullPath = c.req.path;
|
||||
@@ -210,39 +205,10 @@ 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,
|
||||
opts: { includeEvidenceRoot?: boolean } = {},
|
||||
): { deps: SlicesRoutesDeps; qualified: false } | { deps: SlicesRoutesDeps; qualified: true; projectRoot: string } | { response: Response } {
|
||||
): { deps: SlicesRoutesDeps; qualified: boolean } | { response: Response } {
|
||||
let deps = getDeps(c);
|
||||
if (!deps) return { response: c.json({ error: "slices_indexer_unavailable" }, 503) };
|
||||
try {
|
||||
@@ -252,15 +218,9 @@ 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: (opts.includeEvidenceRoot ?? true) ? qualifiedEvidenceRoot(project.root, project.missionsRoot) : null,
|
||||
projectId: project.id,
|
||||
missionId: mission,
|
||||
});
|
||||
const indexer = new SliceIndexer({ db: deps.indexer.db, slicesRoot: project.missionsRoot, dogfoodEvidenceRoot: null, projectId: project.id, missionId: mission });
|
||||
deps = { indexer, projector: deps.projector.withIndexer(indexer) };
|
||||
return { deps, qualified: true, projectRoot: project.root };
|
||||
return { deps, qualified: true };
|
||||
} catch (err) { return { response: projectReadResponse(err) }; }
|
||||
}
|
||||
|
||||
|
||||
@@ -132,7 +132,6 @@ 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";
|
||||
@@ -479,11 +478,6 @@ 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,7 +6,6 @@ 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 {
|
||||
@@ -95,24 +94,3 @@ 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");
|
||||
});
|
||||
});
|
||||
@@ -25,10 +25,6 @@ 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();
|
||||
@@ -38,19 +34,13 @@ 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?.(); 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("list-panes")) { onPanes?.(); return "%1|0|/project|120|40|1"; }
|
||||
if (command.includes("display-message")) return "$7";
|
||||
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}`);
|
||||
@@ -139,7 +129,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([
|
||||
held("detach-client -s '$7'"), held("kill-session -t '$7'"),
|
||||
"tmux detach-client -s '$7'", "tmux kill-session -t '$7'",
|
||||
]);
|
||||
expect(status(a.session.id)).toBe("exited");
|
||||
expect(registry.getBindingForNode(a.node.id)).toBeNull();
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
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,16 +160,14 @@ 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";
|
||||
// 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
|
||||
const freshRunningNeedsInput = hook.name === "fresh_running" && currentQuestion;
|
||||
const sent = hook.name === "fresh_permission" || freshRunningNeedsInput ? 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(freshDialogCheck ? 1 : hook.name.startsWith("fresh_") ? 0 : 1);
|
||||
expect(capture).toHaveBeenCalledTimes(hook.name === "fresh_running" ? 1 : hook.name.startsWith("fresh_") ? 0 : 1);
|
||||
} finally { db.close(); }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -57,6 +57,15 @@ 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([]);
|
||||
|
||||
@@ -1,217 +0,0 @@
|
||||
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,38 +221,6 @@ 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,17 +329,8 @@ 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. 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 relaunch doesn't start the same-named app in its place.
|
||||
expect((await rig(["down", "free-rig", "--json", "--force"])).code).toBe(0);
|
||||
// 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, permissionDeciderFor, resolveDefaultReceiver } from "../src/domain/default-receiver.js";
|
||||
import { describeDefaultReceiver, KERNEL_OPERATOR_SESSION, 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,48 +145,4 @@ 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", wireSha256: null, wireBytes: null, writtenAt: null }).seq;
|
||||
terminalObserved: { outcome: null, verified: null }, inputBoundary: "unknown", source: "send_result" }).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,17 +275,11 @@ describe("SSE events route", () => {
|
||||
expect(ids(await readSSEEvents(await app.request("/api/events?replay=2"), 3))).toEqual(seqs);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
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 () => {
|
||||
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: "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);
|
||||
setTimeout(() => { evidence(0); 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"]);
|
||||
|
||||
+2
-3
@@ -35,10 +35,9 @@ try {
|
||||
const seats = new Map();
|
||||
for (const member of ["a", "b", "c", "sibling"]) {
|
||||
const name = `${member}@shrink-fixture`;
|
||||
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"]);
|
||||
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 }) });
|
||||
const session = sessionRegistry.registerSession(node.id, name);
|
||||
sessionRegistry.updateStatus(session.id, "running");
|
||||
sessionRegistry.updateBinding(node.id, { tmuxSession: name, tmuxPane: pane });
|
||||
|
||||
@@ -4,18 +4,15 @@
|
||||
// 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";
|
||||
@@ -25,8 +22,7 @@ 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",
|
||||
withStructural = false) {
|
||||
function fixture(runtime = "claude-code", permissionPromptReader?: (sessionName: string) => Promise<boolean | null>, pane = "unrecognized provider content") {
|
||||
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 });
|
||||
@@ -40,16 +36,13 @@ 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 () => screen.value,
|
||||
listPanes: async () => [], hasSessionEnv: async () => false, capturePaneContent: async () => pane,
|
||||
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, structuralActivity: structural,
|
||||
permissionPromptReader,
|
||||
permissionPromptChanged: permissionPromptReader ? (sessionName, confirmed, permissionRuntime) => {
|
||||
const latest = store.getLatestForNode({ sessionName });
|
||||
store.recordHookEvent({ sessionName, runtime: permissionRuntime, generation: latest?.generation,
|
||||
@@ -77,7 +70,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(); }, structural, show: (next: string) => { screen.value = next; },
|
||||
oracle: () => oracle, reconstruct: () => { oracle = newOracle(); },
|
||||
changeGeneration: (g: string | null) => { currentGeneration = g; } };
|
||||
}
|
||||
|
||||
@@ -309,75 +302,3 @@ 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 and a missing secret", async () => {
|
||||
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 () => {
|
||||
reply = { status: 500 };
|
||||
expect(await ringHostedWake(ring, wake, { env: env() })).toEqual({ classified: "failed", nudgeResult: "failed:webhook (GROK_WAKE_URL) answered 500" });
|
||||
|
||||
@@ -77,15 +77,9 @@ 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 })).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);
|
||||
expect((await ringHostedWake(ring, wake, { env: env(), timeoutMs: 100 })).nudgeResult).toBe("failed:webhook (GROK_WAKE_URL) didn't answer within 100 ms");
|
||||
});
|
||||
|
||||
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 () => {
|
||||
@@ -132,7 +126,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; fetchImpl?: typeof fetch } = {}) {
|
||||
function hostedRepo(db: Database.Database, envLines: string, declared: RosterWake["webhook"] = wake.webhook, opts: { transport?: boolean } = {}) {
|
||||
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");
|
||||
@@ -142,7 +136,6 @@ 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 } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -191,56 +184,6 @@ 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);
|
||||
|
||||
@@ -13,8 +13,6 @@ 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";
|
||||
@@ -110,34 +108,6 @@ 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();
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
// 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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -334,29 +334,6 @@ 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,10 +1394,7 @@ 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.
|
||||
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 });
|
||||
await runSweep();
|
||||
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 { describe, expect, it, vi, type TestContext } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } 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,17 +14,19 @@ const PLUGIN = resolve(HERE, "../assets/plugins/openrig-core");
|
||||
const HOOK = join(PLUGIN, "hooks/scripts/refocus.cjs");
|
||||
const SEAT = "dev-impl@budget-rig";
|
||||
|
||||
interface Fixture { root: string; home: string; bin: string; log: string }
|
||||
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 }
|
||||
|
||||
/** Build isolated hook state and controllable lookup scripts for timing and overlap cases. */
|
||||
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 });
|
||||
});
|
||||
function fixture(): Fixture {
|
||||
root = mkdtempSync(join(tmpdir(), "refocus-budget-"));
|
||||
const home = join(root, "openrig");
|
||||
const bin = join(root, "bin");
|
||||
const log = join(root, "calls.log");
|
||||
@@ -53,20 +55,20 @@ echo "TRACE OK: work and topology"
|
||||
`);
|
||||
chmodSync(join(bin, "rig"), 0o755);
|
||||
chmodSync(join(bin, "trace"), 0o755);
|
||||
return { root, home, bin, log };
|
||||
return { 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(f.root, `${session}.jsonl`);
|
||||
const transcript = join(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: f.root, OPENRIG_HOME: f.home, OPENRIG_SESSION_NAME: SEAT,
|
||||
PATH: `${f.bin}:${process.env.PATH}`, HOME: 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,
|
||||
},
|
||||
@@ -101,11 +103,9 @@ describe("refocus: the registrations' outer bound", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// 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);
|
||||
describe("refocus: the time budget", () => {
|
||||
it("delivers a slow but successful trace when a longer budget is selected", async () => {
|
||||
const f = fixture();
|
||||
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.concurrent("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 ({ expect, onTestFinished }) => {
|
||||
const f = fixture(onTestFinished);
|
||||
it("ends a trace that never finishes as a gap inside the selected budget, keeping the refocus due", async () => {
|
||||
const f = fixture();
|
||||
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.concurrent("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 ({ expect, onTestFinished }) => {
|
||||
it("limits a selected budget to 52 s, so reporting and recording the outcome still end inside the 60 s bound", async () => {
|
||||
// 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(onTestFinished);
|
||||
const f = fixture();
|
||||
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.concurrent("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 ({ expect, onTestFinished }) => {
|
||||
const f = fixture(onTestFinished);
|
||||
it("keeps today's 2 s trace cap when no budget is selected", async () => {
|
||||
const f = fixture();
|
||||
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.concurrent("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 ({ expect, onTestFinished }) => {
|
||||
it("with no budget selected, gives main's result: each lookup keeps its 2 s cap, unclipped, inside the 60 s bound", async () => {
|
||||
// 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(onTestFinished);
|
||||
const f = fixture();
|
||||
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.concurrent("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(onTestFinished);
|
||||
const g = fixture();
|
||||
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.concurrent("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 ({ expect, onTestFinished }) => {
|
||||
it("with no budget selected, still files the issue report after three slow failed attempts that ran past 4.5 s", async () => {
|
||||
// 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(onTestFinished);
|
||||
const f = fixture();
|
||||
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.concurrent("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 ({ expect, onTestFinished }) => {
|
||||
it("with a budget selected, ends lookups that together pass it as a reported gap, and records the failure", async () => {
|
||||
// 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(onTestFinished);
|
||||
const f = fixture();
|
||||
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.concurrent("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 ({ expect, onTestFinished }) => {
|
||||
const f = fixture(onTestFinished);
|
||||
it("preserves a PostCompact that lands while an earlier refocus is being delivered", async () => {
|
||||
const f = fixture();
|
||||
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(f.root, "trace-ready");
|
||||
const release = join(f.root, "trace-release");
|
||||
const ready = join(root!, "trace-ready");
|
||||
const release = join(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 ({ expect, onTestFinished }) => {
|
||||
const f = fixture(onTestFinished);
|
||||
it("delivers a pending refocus once when two handlers overlap", async () => {
|
||||
const f = fixture();
|
||||
await hook(f, "PostCompact", "s1");
|
||||
const ready = join(f.root, "trace-ready");
|
||||
const release = join(f.root, "trace-release");
|
||||
const ready = join(root!, "trace-ready");
|
||||
const release = join(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 ({ expect, onTestFinished }) => {
|
||||
const f = fixture(onTestFinished);
|
||||
it("retains a distinct identified on-demand prompt while an earlier prompt is rendering", async () => {
|
||||
const f = fixture();
|
||||
await hook(f, "PostCompact", "s1");
|
||||
const ready = join(f.root, "trace-ready");
|
||||
const release = join(f.root, "trace-release");
|
||||
const ready = join(root!, "trace-ready");
|
||||
const release = join(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 ({ expect, onTestFinished }) => {
|
||||
const f = fixture(onTestFinished);
|
||||
it("delivers once per identified prompt even if the second handler starts after completion", async () => {
|
||||
const f = fixture();
|
||||
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 ({ expect, onTestFinished }) => {
|
||||
const f = fixture(onTestFinished);
|
||||
it("recovers pending work after the rendering hook is killed", async () => {
|
||||
const f = fixture();
|
||||
await hook(f, "PostCompact", "s1");
|
||||
const ready = join(f.root, "trace-ready");
|
||||
const release = join(f.root, "trace-release");
|
||||
const ready = join(root!, "trace-ready");
|
||||
const release = join(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 ({ expect, onTestFinished }) => {
|
||||
const f = fixture(onTestFinished);
|
||||
it("uses a shared successor guard after its former owner exits", async () => {
|
||||
const f = fixture();
|
||||
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 ({ expect, onTestFinished }) => {
|
||||
const f = fixture(onTestFinished);
|
||||
it("does not take a successor when the old guard was released before the owner exited", async () => {
|
||||
const f = fixture();
|
||||
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 ({ expect, onTestFinished }) => {
|
||||
const f = fixture(onTestFinished);
|
||||
it("reports an unreadable guard and leaves the pending compaction available", async () => {
|
||||
const f = fixture();
|
||||
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 ({ expect, onTestFinished }) => {
|
||||
const f = fixture(onTestFinished);
|
||||
it("still delivers to each of two occupants of one seat when they overlap", async () => {
|
||||
const f = fixture();
|
||||
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 ({ expect, onTestFinished }) => {
|
||||
const f = fixture(onTestFinished);
|
||||
it("ignores a delivery claim and pendingSeq left on disk, so a resumed hook still delivers what is due", async () => {
|
||||
const f = fixture();
|
||||
await hook(f, "PostCompact", "s1");
|
||||
const file = join(f.home, "refocus", `${SEAT}__s1.json`);
|
||||
writeFileSync(file, JSON.stringify({ ...stateOf(f, "s1"), pendingSeq: 3,
|
||||
|
||||
@@ -35,6 +35,62 @@ 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,20 +50,9 @@ 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,8 +27,6 @@ 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 {
|
||||
@@ -1345,144 +1343,3 @@ 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, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
@@ -17,11 +17,9 @@ 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", DECIDER = "lead@perm-rig";
|
||||
const PERSON = "mike@external", OTHER_PERSON = "dana@external", SEAT = "dev-runner@perm-rig", INSTALL = "install-token";
|
||||
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 } });
|
||||
@@ -35,7 +33,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; decider?: string | null; deciderOf?: (requester: string) => string | null; receiver?: string | null } = {}) {
|
||||
function fixture(options: { owner?: string | null; guest?: boolean; realGuest?: boolean; people?: string[]; brokenStream?: boolean } = {}) {
|
||||
const people = options.people ? { ok: true as const, entities: options.people.map(person) } : registry;
|
||||
const db = createDb();
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
@@ -54,20 +52,8 @@ 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 }; } },
|
||||
// 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));
|
||||
transport: { send: async (session) => { nudges.push(session); return { ok: true, verified: true, sent: true }; } } });
|
||||
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,
|
||||
@@ -116,20 +102,12 @@ 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, bus, runner, other, sessions, queueRepo, contract, nudges, requesters, generation, submit, read, withdraw, answer, act, decide, waitsOnPerson, rowsToSeat, grants,
|
||||
return { db, runner, other, sessions, queueRepo, contract, nudges, generation, submit, read, withdraw, answer, waitsOnPerson, rowsToSeat, grants,
|
||||
permits, relaunch: () => occupy(runner.id, SEAT, "handover") };
|
||||
}
|
||||
|
||||
@@ -176,7 +154,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 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(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(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 });
|
||||
@@ -246,24 +224,23 @@ 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],
|
||||
// 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) => {
|
||||
["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) => {
|
||||
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 ? { optionId } : {}) } });
|
||||
expect(read).toMatchObject({ body: { state, 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("keeps the person's words waiting when the agent offered no allow option, never reading them as a no", async () => {
|
||||
it("carries the person's words with the refusal 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: "pending" });
|
||||
expect((await f.read(askId)).body).toEqual({ ok: true, state: "refused", reason: "no_allow_offered", said: "use the other file instead" });
|
||||
});
|
||||
|
||||
it("carries a colleague's words, whole, when the ask was routed to them", async () => {
|
||||
@@ -273,9 +250,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 });
|
||||
// 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());
|
||||
const read = await f.read(askId, 1);
|
||||
expect(read.body).toMatchObject({ state: "permitted", optionId: "allow" });
|
||||
expect(read.body.said).toBe(words.trim());
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -631,395 +608,3 @@ 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,8 +25,6 @@ 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;
|
||||
@@ -105,7 +103,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.some(c => /^tmux if-shell -F -t '\$1' .*#\{==:#\{pid\},4242\}.*kill-session -t .*\$1/.test(c))).toBe(true);
|
||||
expect(f.commands).toContain("tmux kill-session -t '$1'");
|
||||
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.startsWith("tmux show-environment")?"OPENRIG_NODE_ID=a\n":command.startsWith("tmux display-message")?"$123|4242|1700000000\n":"";});tmux.deliveryGuard=guard;
|
||||
const tmux=new TmuxAdapter(async command=>{commands.push(command);return command.includes("display-message")?"$123\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)).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();
|
||||
expect(commands.at(-1)).toBe("tmux kill-session -t '$123'");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.startsWith("tmux show-environment")?"OPENRIG_NODE_ID=c\n":command.startsWith("tmux display-message")?"$44|4242|1700000000\n":"";});tmux.deliveryGuard=f.guard;
|
||||
const commands:string[]=[];const tmux=new TmuxAdapter(async command=>{commands.push(command);return command.includes("display-message")?"$44\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)).toMatch(/^tmux if-shell -F -t '\$44' .*#\{==:#\{pid\},4242\}.*kill-session -t .*\$44/);expect(f.guard.target("c").pane).toBeNull();f.db.close();
|
||||
expect(commands.at(-1)).toBe("tmux 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.startsWith("tmux show-environment")?"OPENRIG_NODE_ID=none\n":command.startsWith("tmux display-message")?"$55|4242|1700000000\n":"";});tmux.deliveryGuard=guard;
|
||||
const tmux=new TmuxAdapter(async command=>{commands.push(command);return command.includes("display-message")?"$55\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)).toMatch(/^tmux if-shell -F -t '\$55' .*#\{==:#\{pid\},4242\}.*kill-session -t .*\$55/);const count=commands.length;
|
||||
expect(commands.at(-1)).toBe("tmux kill-session -t '$55'");const count=commands.length;
|
||||
expect(await tmux.sendKeys("probe",["Enter"])).toMatchObject({ok:false});expect(commands).toHaveLength(count);db.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import type Database from "better-sqlite3";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { createHash } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
import { SettingsStore } from "../src/domain/user-settings/settings-store.js";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
@@ -2841,8 +2840,7 @@ describe("SessionTransport", () => {
|
||||
it("an external CLI seat's refusal records nothing written and no screen check", async () => {
|
||||
seedExternalCliRig();
|
||||
await createTransport(mockTmux(), { eventBus: new EventBus(db) }).send("orch1-lead@rigged-buildout", "hello");
|
||||
expect(evidence()).toMatchObject([{ seatKind: "external_cli", written: "none", terminalObserved: { outcome: null, verified: null },
|
||||
wireSha256: null, wireBytes: null, writtenAt: null }]);
|
||||
expect(evidence()).toMatchObject([{ seatKind: "external_cli", written: "none", terminalObserved: { outcome: null, verified: null } }]);
|
||||
});
|
||||
|
||||
it("a write that may have landed reads uncertain and is written once, never resent", async () => {
|
||||
@@ -2913,29 +2911,6 @@ describe("SessionTransport", () => {
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it("fingerprints the text the paste wrote, never the text itself: a terminal seat's exact text, not the envelope", async () => {
|
||||
seedCanonicalRig("terminal");
|
||||
let typed = "";
|
||||
const tmux = mockTmux({ capturePaneContent: async () => "hello", sendText: async (_target, text) => { typed = text; return { ok: true as const }; } });
|
||||
await createTransport(tmux, { eventBus: new EventBus(db) }).send("dev-impl@my-rig", "env lines\nhello", { exactText: "hello" });
|
||||
const [fact] = evidence();
|
||||
expect(typed).toBe("hello");
|
||||
expect(fact).toMatchObject({ wireSha256: createHash("sha256").update("hello").digest("hex"), wireBytes: 5 });
|
||||
expect(Number.isNaN(Date.parse(fact.writtenAt))).toBe(false);
|
||||
expect(JSON.stringify(fact)).not.toContain("hello");
|
||||
});
|
||||
|
||||
it("fingerprints what was pasted, an urgent send's added line included, not the text the caller passed", async () => {
|
||||
seedCanonicalRig();
|
||||
let typed = "";
|
||||
const tmux = mockTmux({ capturePaneContent: async () => "hello", sendText: async (_target, text) => { typed = text; return { ok: true as const }; } });
|
||||
await createTransport(tmux, { eventBus: new EventBus(db) }).send("dev-impl@my-rig", "hello", { urgent: { by: "orch@r" } });
|
||||
const sha = (text: string) => createHash("sha256").update(text).digest("hex");
|
||||
expect(typed).not.toBe("hello");
|
||||
expect(evidence()).toMatchObject([{ wireSha256: sha(typed), wireBytes: Buffer.byteLength(typed) }]);
|
||||
expect(evidence()[0].wireSha256).not.toBe(sha("hello"));
|
||||
});
|
||||
|
||||
it("a terminal seat's exact-text send, which re-enters send, records once", async () => {
|
||||
seedCanonicalRig("terminal");
|
||||
await createTransport(typing(), { eventBus: new EventBus(db) }).send("dev-impl@my-rig", "env lines\nhello", { exactText: "hello" });
|
||||
|
||||
@@ -161,31 +161,6 @@ describe("PL-slice-story-view-v0 SliceIndexer", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("filters the requested mission before deduplicating a same-named slice", () => {
|
||||
const missionsRoot = path.join(cleanup, "qualified-missions");
|
||||
for (const mission of ["mission-one", "mission-two"]) {
|
||||
writeSlice(path.join(missionsRoot, mission, "slices"), "same-name", {
|
||||
"SPEC.md": `# ${mission}\n`,
|
||||
});
|
||||
}
|
||||
const traversedMissions = fs.readdirSync(missionsRoot);
|
||||
expect(traversedMissions).toHaveLength(2);
|
||||
const requestedMission = traversedMissions[1]!;
|
||||
const expectedPath = path.join(missionsRoot, requestedMission, "slices", "same-name");
|
||||
const indexer = new SliceIndexer({
|
||||
slicesRoot: missionsRoot,
|
||||
dogfoodEvidenceRoot: null,
|
||||
db,
|
||||
missionId: requestedMission,
|
||||
});
|
||||
|
||||
expect(indexer.list().map(({ name, missionId, slicePath }) => [name, missionId, slicePath])).toEqual([
|
||||
["same-name", requestedMission, expectedPath],
|
||||
]);
|
||||
expect(indexer.get("same-name")?.slicePath).toBe(expectedPath);
|
||||
expect(fs.readFileSync(path.join(expectedPath, "SPEC.md"), "utf8")).toBe(`# ${requestedMission}\n`);
|
||||
});
|
||||
|
||||
it("can index legacy flat slices and mission-aware slices from compatibility roots", () => {
|
||||
const missionsRoot = path.join(cleanup, "missions");
|
||||
writeSlice(slicesRoot, "legacy-flat-slice", {
|
||||
@@ -548,153 +523,6 @@ describe("PL-slice-story-view-v0 SliceIndexer", () => {
|
||||
const indexer = new SliceIndexer({ slicesRoot, dogfoodEvidenceRoot: null, db });
|
||||
expect(indexer.get("x")!.proofPacket).toBeNull();
|
||||
});
|
||||
|
||||
const packetPrefixSeparators = [
|
||||
{ label: "hyphen", separator: "-" },
|
||||
{ label: "dot", separator: "." },
|
||||
{ label: "underscore", separator: "_" },
|
||||
] as const;
|
||||
|
||||
it.each(packetPrefixSeparators)(
|
||||
"qualified lookup selects an older slice packet over a newer sibling packet whose only matching token is in the mission prefix ($label separator)",
|
||||
({ separator }) => {
|
||||
const missionsRoot = path.join(cleanup, "missions");
|
||||
writeSlice(path.join(missionsRoot, "a-first", "slices"), "first", { "SPEC.md": "# a-first/first\n" });
|
||||
writeSlice(path.join(missionsRoot, "a-first", "slices"), "second", { "SPEC.md": "# a-first/second\n" });
|
||||
|
||||
const correctDir = path.join(dogfoodRoot, `a-first${separator}first`);
|
||||
fs.mkdirSync(path.join(correctDir, "screenshots"), { recursive: true });
|
||||
fs.writeFileSync(path.join(correctDir, "correct-first.md"), "# correct proof for slice first\n");
|
||||
fs.writeFileSync(path.join(correctDir, "screenshots", "first.png"), "FIRST-PNG\n");
|
||||
fs.utimesSync(correctDir, 1600000010, 1600000010);
|
||||
|
||||
const wrongSiblingDir = path.join(dogfoodRoot, `a-first${separator}second`);
|
||||
fs.mkdirSync(path.join(wrongSiblingDir, "screenshots"), { recursive: true });
|
||||
fs.writeFileSync(path.join(wrongSiblingDir, "wrong-second.md"), "# wrong sibling proof for slice second\n");
|
||||
fs.writeFileSync(path.join(wrongSiblingDir, "screenshots", "second.png"), "SECOND-PNG\n");
|
||||
fs.utimesSync(wrongSiblingDir, 1600000020, 1600000020);
|
||||
|
||||
const indexer = new SliceIndexer({
|
||||
slicesRoot: missionsRoot,
|
||||
dogfoodEvidenceRoot: dogfoodRoot,
|
||||
db,
|
||||
missionId: "a-first",
|
||||
});
|
||||
const byName = new Map(indexer.list().map((entry) => [entry.name, entry.hasProofPacket]));
|
||||
const firstRecord = indexer.get("first")!;
|
||||
const secondRecord = indexer.get("second")!;
|
||||
|
||||
expect({
|
||||
listHasProof: {
|
||||
first: byName.get("first"),
|
||||
second: byName.get("second"),
|
||||
},
|
||||
firstProofDir: firstRecord.proofPacket?.dirName,
|
||||
firstMarkdownFiles: firstRecord.proofPacket?.markdownFiles,
|
||||
firstScreenshots: firstRecord.proofPacket?.screenshots,
|
||||
firstMarkdownBody: firstRecord.proofPacket?.markdownFiles[0]
|
||||
? fs.readFileSync(path.join(firstRecord.proofPacket.absPath, firstRecord.proofPacket.markdownFiles[0]), "utf8")
|
||||
: null,
|
||||
secondProofDir: secondRecord.proofPacket?.dirName,
|
||||
}).toEqual({
|
||||
listHasProof: {
|
||||
first: true,
|
||||
second: true,
|
||||
},
|
||||
firstProofDir: `a-first${separator}first`,
|
||||
firstMarkdownFiles: ["correct-first.md"],
|
||||
firstScreenshots: ["screenshots/first.png"],
|
||||
firstMarkdownBody: "# correct proof for slice first\n",
|
||||
secondProofDir: `a-first${separator}second`,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(packetPrefixSeparators)(
|
||||
"qualified lookup returns no proof packet when only a newer sibling packet shares the mission-prefix token ($label separator)",
|
||||
({ separator }) => {
|
||||
const missionsRoot = path.join(cleanup, "missions");
|
||||
writeSlice(path.join(missionsRoot, "a-first", "slices"), "first", { "SPEC.md": "# a-first/first\n" });
|
||||
writeSlice(path.join(missionsRoot, "a-first", "slices"), "second", { "SPEC.md": "# a-first/second\n" });
|
||||
|
||||
const wrongSiblingDir = path.join(dogfoodRoot, `a-first${separator}second`);
|
||||
fs.mkdirSync(path.join(wrongSiblingDir, "screenshots"), { recursive: true });
|
||||
fs.writeFileSync(path.join(wrongSiblingDir, "wrong-second.md"), "# wrong sibling proof for slice second\n");
|
||||
fs.writeFileSync(path.join(wrongSiblingDir, "screenshots", "second.png"), "SECOND-PNG\n");
|
||||
fs.utimesSync(wrongSiblingDir, 1600000020, 1600000020);
|
||||
|
||||
const indexer = new SliceIndexer({
|
||||
slicesRoot: missionsRoot,
|
||||
dogfoodEvidenceRoot: dogfoodRoot,
|
||||
db,
|
||||
missionId: "a-first",
|
||||
});
|
||||
const byName = new Map(indexer.list().map((entry) => [entry.name, entry.hasProofPacket]));
|
||||
|
||||
expect({
|
||||
listHasProof: {
|
||||
first: byName.get("first"),
|
||||
second: byName.get("second"),
|
||||
},
|
||||
firstProofPacket: indexer.get("first")!.proofPacket,
|
||||
secondProofDir: indexer.get("second")!.proofPacket?.dirName,
|
||||
}).toEqual({
|
||||
listHasProof: {
|
||||
first: false,
|
||||
second: true,
|
||||
},
|
||||
firstProofPacket: null,
|
||||
secondProofDir: `a-first${separator}second`,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves qualified -vN suffix stripping, same-mission overlapping-slice latest-mtime tie-break, and unqualified whole-name matching", () => {
|
||||
const missionsRoot = path.join(cleanup, "missions");
|
||||
writeSlice(path.join(missionsRoot, "a-first", "slices"), "first-v1", { "SPEC.md": "# a-first/first-v1\n" });
|
||||
const olderQualified = path.join(dogfoodRoot, "a-first-first-20260101");
|
||||
const newerOverlappingQualified = path.join(dogfoodRoot, "a-first-first-extra-20260601");
|
||||
fs.mkdirSync(olderQualified, { recursive: true });
|
||||
fs.mkdirSync(newerOverlappingQualified, { recursive: true });
|
||||
fs.writeFileSync(path.join(olderQualified, "older.md"), "# older\n");
|
||||
fs.writeFileSync(path.join(newerOverlappingQualified, "newer.md"), "# newer\n");
|
||||
fs.utimesSync(olderQualified, 1600000010, 1600000010);
|
||||
fs.utimesSync(newerOverlappingQualified, 1600000020, 1600000020);
|
||||
|
||||
const qualifiedIndexer = new SliceIndexer({
|
||||
slicesRoot: missionsRoot,
|
||||
dogfoodEvidenceRoot: dogfoodRoot,
|
||||
db,
|
||||
missionId: "a-first",
|
||||
});
|
||||
expect({
|
||||
qualifiedDir: qualifiedIndexer.get("first-v1")!.proofPacket?.dirName,
|
||||
qualifiedDocs: qualifiedIndexer.get("first-v1")!.proofPacket?.markdownFiles,
|
||||
}).toEqual({
|
||||
qualifiedDir: "a-first-first-extra-20260601",
|
||||
qualifiedDocs: ["newer.md"],
|
||||
});
|
||||
|
||||
const legacyDogfoodRoot = path.join(cleanup, "legacy-dogfood");
|
||||
writeSlice(slicesRoot, "first", { "README.md": "---\n---\n" });
|
||||
const legacyPacket = path.join(legacyDogfoodRoot, "a-first-legacy-only");
|
||||
fs.mkdirSync(legacyPacket, { recursive: true });
|
||||
fs.writeFileSync(path.join(legacyPacket, "legacy.md"), "# legacy\n");
|
||||
fs.utimesSync(legacyPacket, 1600000030, 1600000030);
|
||||
|
||||
const unqualifiedIndexer = new SliceIndexer({
|
||||
slicesRoot,
|
||||
dogfoodEvidenceRoot: legacyDogfoodRoot,
|
||||
db,
|
||||
});
|
||||
expect({
|
||||
unqualifiedDir: unqualifiedIndexer.get("first")!.proofPacket?.dirName,
|
||||
unqualifiedDocs: unqualifiedIndexer.get("first")!.proofPacket?.markdownFiles,
|
||||
}).toEqual({
|
||||
unqualifiedDir: "a-first-legacy-only",
|
||||
unqualifiedDocs: ["legacy.md"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("cache invalidation", () => {
|
||||
|
||||
@@ -22,28 +22,9 @@ const nodeMetadataFault = vi.hoisted(() => ({
|
||||
selectionCode: "EACCES",
|
||||
selectionHits: 0,
|
||||
}));
|
||||
const evidenceRootFault = vi.hoisted(() => ({
|
||||
targetPath: null as string | null,
|
||||
stage: null as "resolve" | "access" | "readdir" | "unexpected" | null,
|
||||
code: "EACCES",
|
||||
hits: 0,
|
||||
}));
|
||||
vi.mock("node:fs", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:fs")>();
|
||||
const injectedError = (code: string) => Object.assign(new Error("injected node metadata failure"), { code });
|
||||
const matchesEvidenceFaultTarget = (candidate: unknown): boolean =>
|
||||
evidenceRootFault.targetPath !== null && typeof candidate === "string" && candidate === evidenceRootFault.targetPath;
|
||||
const maybeThrowEvidenceResolveFault = (candidate: unknown): void => {
|
||||
if (!matchesEvidenceFaultTarget(candidate)) return;
|
||||
if (evidenceRootFault.stage === "resolve") {
|
||||
evidenceRootFault.hits++;
|
||||
throw injectedError(evidenceRootFault.code);
|
||||
}
|
||||
if (evidenceRootFault.stage === "unexpected") {
|
||||
evidenceRootFault.hits++;
|
||||
throw Object.assign(new TypeError("unexpected caller type error"), { code: "ERR_INVALID_ARG_TYPE" });
|
||||
}
|
||||
};
|
||||
const existsSync = ((...args: Parameters<typeof actual.existsSync>) => {
|
||||
if (String(args[0]) === nodeMetadataFault.selectionPath && nodeMetadataFault.selectionPath !== null) {
|
||||
nodeMetadataFault.selectionHits++;
|
||||
@@ -52,14 +33,6 @@ vi.mock("node:fs", async (importOriginal) => {
|
||||
}
|
||||
return actual.existsSync(...args);
|
||||
}) as typeof actual.existsSync;
|
||||
const lstatSync = ((...args: Parameters<typeof actual.lstatSync>) => {
|
||||
maybeThrowEvidenceResolveFault(args[0]);
|
||||
return actual.lstatSync(...args);
|
||||
}) as typeof actual.lstatSync;
|
||||
const realpathSync = Object.assign(((...args: Parameters<typeof actual.realpathSync>) => {
|
||||
maybeThrowEvidenceResolveFault(args[0]);
|
||||
return actual.realpathSync(...args);
|
||||
}) as typeof actual.realpathSync, { native: actual.realpathSync.native });
|
||||
const statSync = ((...args: Parameters<typeof actual.statSync>) => {
|
||||
if (String(args[0]) === nodeMetadataFault.selectionPath && nodeMetadataFault.selectionPath !== null) {
|
||||
nodeMetadataFault.selectionHits++;
|
||||
@@ -72,20 +45,9 @@ vi.mock("node:fs", async (importOriginal) => {
|
||||
nodeMetadataFault.selectionHits++;
|
||||
throw injectedError(nodeMetadataFault.selectionCode);
|
||||
}
|
||||
if (matchesEvidenceFaultTarget(args[0]) && evidenceRootFault.stage === "access") {
|
||||
evidenceRootFault.hits++;
|
||||
throw injectedError(evidenceRootFault.code);
|
||||
}
|
||||
return actual.accessSync(...args);
|
||||
}) as typeof actual.accessSync;
|
||||
const readdirSync = vi.fn(((...args: Parameters<typeof actual.readdirSync>) => {
|
||||
if (matchesEvidenceFaultTarget(args[0]) && evidenceRootFault.stage === "readdir") {
|
||||
evidenceRootFault.hits++;
|
||||
throw injectedError(evidenceRootFault.code);
|
||||
}
|
||||
return actual.readdirSync(...args);
|
||||
}) as typeof actual.readdirSync) as unknown as typeof actual.readdirSync;
|
||||
const readFileSync = vi.fn(((...args: Parameters<typeof actual.readFileSync>) => {
|
||||
const readFileSync = ((...args: Parameters<typeof actual.readFileSync>) => {
|
||||
const requestedPath = String(args[0]);
|
||||
if (catalogReadObservation.activePath !== null && requestedPath === catalogReadObservation.activePath) {
|
||||
catalogReadObservation.reads++;
|
||||
@@ -99,18 +61,10 @@ vi.mock("node:fs", async (importOriginal) => {
|
||||
throw injectedError(nodeMetadataFault.selectionCode);
|
||||
}
|
||||
return actual.readFileSync(...args);
|
||||
}) as typeof actual.readFileSync) as unknown as typeof actual.readFileSync;
|
||||
}) as typeof actual.readFileSync;
|
||||
const actualDefault = (actual as unknown as { default?: object }).default ?? actual;
|
||||
const defaultFs = Object.assign(Object.create(actualDefault), {
|
||||
existsSync,
|
||||
lstatSync,
|
||||
realpathSync,
|
||||
statSync,
|
||||
accessSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
});
|
||||
return { ...actual, existsSync, lstatSync, realpathSync, statSync, accessSync, readdirSync, readFileSync, default: defaultFs };
|
||||
const defaultFs = Object.assign(Object.create(actualDefault), { existsSync, statSync, accessSync, readFileSync });
|
||||
return { ...actual, existsSync, statSync, accessSync, readFileSync, default: defaultFs };
|
||||
});
|
||||
|
||||
import { Hono } from "hono";
|
||||
@@ -132,7 +86,6 @@ import { missionControlActionsSchema } from "../src/db/migrations/037_mission_co
|
||||
import { SliceIndexer } from "../src/domain/slices/slice-indexer.js";
|
||||
import { SliceDetailProjector } from "../src/domain/slices/slice-detail-projector.js";
|
||||
import { slicesRoutes } from "../src/routes/slices.js";
|
||||
import * as slicesRoutesModule from "../src/routes/slices.js";
|
||||
import { browserBoundary } from "../src/middleware/browser-boundary.js";
|
||||
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
@@ -216,14 +169,8 @@ describe("PL-slice-story-view-v0 slices routes", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
evidenceRootFault.targetPath = null;
|
||||
evidenceRootFault.stage = null;
|
||||
evidenceRootFault.code = "EACCES";
|
||||
evidenceRootFault.hits = 0;
|
||||
db.close();
|
||||
fs.rmSync(cleanupDir, { recursive: true, force: true });
|
||||
(fs.readdirSync as unknown as { mockClear: () => void }).mockClear();
|
||||
(fs.readFileSync as unknown as { mockClear: () => void }).mockClear();
|
||||
});
|
||||
|
||||
describe("GET /api/slices (list)", () => {
|
||||
@@ -491,18 +438,6 @@ describe("PL-slice-story-view-v0 slices routes", () => {
|
||||
"SPEC.md": `# ${project}/${mission}\n`, "notes/a b.md": `${project}/${mission} nested document\n`,
|
||||
});
|
||||
}
|
||||
const projectEvidenceRoot = path.join(projectRoot, "dogfood-evidence");
|
||||
for (const mission of ["a-first", "z-later"]) {
|
||||
const scopedProofDir = path.join(projectEvidenceRoot, `${mission}-same-slice`);
|
||||
fs.mkdirSync(path.join(scopedProofDir, "screenshots"), { recursive: true });
|
||||
fs.writeFileSync(path.join(scopedProofDir, "proof.md"), `# ${project}/${mission} proof\n`);
|
||||
fs.writeFileSync(
|
||||
path.join(scopedProofDir, "screenshots/scope.png"),
|
||||
`${project.toUpperCase()}-${mission.toUpperCase()}-PROOF\n`,
|
||||
);
|
||||
const mtime = mission === "a-first" ? 1600000003 : 1600000002;
|
||||
fs.utimesSync(scopedProofDir, mtime, mtime);
|
||||
}
|
||||
const proofDir = path.join(dogfoodRoot, `same-slice-${project}`);
|
||||
fs.mkdirSync(path.join(proofDir, "screenshots"), { recursive: true });
|
||||
fs.writeFileSync(path.join(proofDir, "screenshots/scope.png"), `${project.toUpperCase()}-PROOF\n`);
|
||||
@@ -514,36 +449,17 @@ describe("PL-slice-story-view-v0 slices routes", () => {
|
||||
app = buildApp({ indexer, projector, workspaceRoot });
|
||||
});
|
||||
|
||||
const addAlphaMissionWithProof = (mission: string, mtime: number): void => {
|
||||
const projectRoot = path.join(workspaceRoot, "alpha");
|
||||
const missionsRoot = path.join(projectRoot, "missions");
|
||||
writeSlice(missionsRoot, mission, { "SPEC.md": `# ${mission}\n` });
|
||||
writeSlice(path.join(missionsRoot, mission, "slices"), "same-slice", {
|
||||
"SPEC.md": `# alpha/${mission}\n`,
|
||||
"notes/a b.md": `alpha/${mission} nested document\n`,
|
||||
});
|
||||
const proofDir = path.join(projectRoot, "dogfood-evidence", `${mission}-same-slice`);
|
||||
fs.mkdirSync(path.join(proofDir, "screenshots"), { recursive: true });
|
||||
fs.writeFileSync(path.join(proofDir, "proof.md"), `# alpha/${mission} proof\n`);
|
||||
fs.writeFileSync(path.join(proofDir, "screenshots/scope.png"), `ALPHA-${mission.toUpperCase()}-PROOF\n`);
|
||||
fs.utimesSync(proofDir, mtime, mtime);
|
||||
};
|
||||
|
||||
it.each([
|
||||
{ project: "alpha", mission: "a-first", literal: "# alpha/a-first\n" },
|
||||
{ project: "beta", mission: "a-first", literal: "# beta/a-first\n" },
|
||||
])("detail selects project $project/$mission and discovers project-scoped proof", async ({ project, mission, literal }) => {
|
||||
const detail = await app.request(`/api/slices/same-slice?project=${project}&mission=${mission}`);
|
||||
{ project: "alpha", literal: "# alpha/a-first\n" },
|
||||
{ project: "beta", literal: "# beta/a-first\n" },
|
||||
])("detail selects $project independently and has no qualified proof discovery", async ({ project, literal }) => {
|
||||
const detail = await app.request(`/api/slices/same-slice?project=${project}&mission=a-first`);
|
||||
expect(detail.status).toBe(200);
|
||||
const body = await detail.json();
|
||||
expect(body.slicePath).toBe(path.join(workspaceRoot, project, "missions", mission, "slices", "same-slice"));
|
||||
expect(body.slicePath).toBe(path.join(workspaceRoot, project, "missions/a-first/slices/same-slice"));
|
||||
expect(fs.readFileSync(path.join(body.slicePath, "SPEC.md"), "utf8")).toBe(literal);
|
||||
expect(body.missionId).toBe(mission);
|
||||
expect(body.tests.proofPackets).toMatchObject([{
|
||||
dirName: `${mission}-same-slice`,
|
||||
primaryMarkdown: { relPath: "proof.md", content: `# ${project}/${mission} proof\n` },
|
||||
screenshots: ["screenshots/scope.png"],
|
||||
}]);
|
||||
expect(body.missionId).toBe("a-first");
|
||||
expect(body.tests.proofPackets).toEqual([]);
|
||||
expect(body.tests.aggregate).toEqual({ passCount: 0, failCount: 0 });
|
||||
});
|
||||
|
||||
@@ -557,762 +473,25 @@ describe("PL-slice-story-view-v0 slices routes", () => {
|
||||
expect(await response.json()).toEqual({ relPath: decodeURIComponent(rel), content: literal });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ project: "alpha", mission: "a-first" },
|
||||
{ project: "beta", mission: "a-first" },
|
||||
{ project: "alpha", mission: "z-later" },
|
||||
{ project: "beta", mission: "z-later" },
|
||||
])("qualified proof for $project/$mission serves its scoped packet, not the injected legacy packet", async ({ project, mission }) => {
|
||||
const response = await app.request(`/api/slices/same-slice/proof-asset/screenshots/scope.png?project=${project}&mission=${mission}`);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe("image/png");
|
||||
expect(await response.text()).toBe(`${project.toUpperCase()}-${mission.toUpperCase()}-PROOF\n`);
|
||||
});
|
||||
|
||||
it("qualified detail and proof assets distinguish prefix-overlapping mission names", async () => {
|
||||
addAlphaMissionWithProof("a-first-extra", 1600000004);
|
||||
const shortDetail = await app.request("/api/slices/same-slice?project=alpha&mission=a-first");
|
||||
const shortBody = await shortDetail.json();
|
||||
const shortPacket = shortBody.tests.proofPackets[0];
|
||||
const shortAsset = await app.request("/api/slices/same-slice/proof-asset/screenshots/scope.png?project=alpha&mission=a-first");
|
||||
const shortAssetBytes = await shortAsset.text();
|
||||
|
||||
const longDetail = await app.request("/api/slices/same-slice?project=alpha&mission=a-first-extra");
|
||||
const longBody = await longDetail.json();
|
||||
const longPacket = longBody.tests.proofPackets[0];
|
||||
const longAsset = await app.request("/api/slices/same-slice/proof-asset/screenshots/scope.png?project=alpha&mission=a-first-extra");
|
||||
const longAssetBytes = await longAsset.text();
|
||||
|
||||
expect({
|
||||
short: {
|
||||
detailStatus: shortDetail.status,
|
||||
missionId: shortBody.missionId,
|
||||
proofDir: shortPacket?.dirName,
|
||||
proofMarkdown: shortPacket?.primaryMarkdown?.content,
|
||||
assetStatus: shortAsset.status,
|
||||
assetBytes: shortAssetBytes,
|
||||
},
|
||||
long: {
|
||||
detailStatus: longDetail.status,
|
||||
missionId: longBody.missionId,
|
||||
proofDir: longPacket?.dirName,
|
||||
proofMarkdown: longPacket?.primaryMarkdown?.content,
|
||||
assetStatus: longAsset.status,
|
||||
assetBytes: longAssetBytes,
|
||||
},
|
||||
}).toEqual({
|
||||
short: {
|
||||
detailStatus: 200,
|
||||
missionId: "a-first",
|
||||
proofDir: "a-first-same-slice",
|
||||
proofMarkdown: "# alpha/a-first proof\n",
|
||||
assetStatus: 200,
|
||||
assetBytes: "ALPHA-A-FIRST-PROOF\n",
|
||||
},
|
||||
long: {
|
||||
detailStatus: 200,
|
||||
missionId: "a-first-extra",
|
||||
proofDir: "a-first-extra-same-slice",
|
||||
proofMarkdown: "# alpha/a-first-extra proof\n",
|
||||
assetStatus: 200,
|
||||
assetBytes: "ALPHA-A-FIRST-EXTRA-PROOF\n",
|
||||
},
|
||||
it.each(["alpha", "beta"])("qualified proof for %s does not substitute the injected legacy packet", async (project) => {
|
||||
const response = await app.request(`/api/slices/same-slice/proof-asset/screenshots/scope.png?project=${project}&mission=a-first`);
|
||||
expect({ status: response.status, text: await response.text() }).toEqual({
|
||||
status: 404, text: JSON.stringify({ error: "proof_packet_not_found" }),
|
||||
});
|
||||
});
|
||||
|
||||
it("does not fall back to an overlapping mission's packet when the requested mission has none", async () => {
|
||||
addAlphaMissionWithProof("a-first-extra", 1600000004);
|
||||
fs.rmSync(path.join(workspaceRoot, "alpha", "dogfood-evidence", "a-first-same-slice"), { recursive: true });
|
||||
|
||||
const detail = await app.request("/api/slices/same-slice?project=alpha&mission=a-first");
|
||||
const detailBody = await detail.json();
|
||||
const asset = await app.request("/api/slices/same-slice/proof-asset/screenshots/scope.png?project=alpha&mission=a-first");
|
||||
const assetBody = await asset.text();
|
||||
expect({
|
||||
detailStatus: detail.status,
|
||||
proofPackets: detailBody.tests.proofPackets,
|
||||
assetStatus: asset.status,
|
||||
assetNotFound: assetBody.includes("proof_packet_not_found"),
|
||||
otherMissionBytesLeaked: assetBody.includes("ALPHA-A-FIRST-EXTRA-PROOF\n"),
|
||||
}).toEqual({
|
||||
detailStatus: 200,
|
||||
proofPackets: [],
|
||||
assetStatus: 404,
|
||||
assetNotFound: true,
|
||||
otherMissionBytesLeaked: false,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ siblingKind: "in-project symlinked mission" },
|
||||
{ siblingKind: "plain mission without slices" },
|
||||
])("does not use an overlapping sibling packet when the sibling is a $siblingKind", async ({ siblingKind }) => {
|
||||
addAlphaMissionWithProof("a-first-extra", 1600000004);
|
||||
const projectRoot = path.join(workspaceRoot, "alpha");
|
||||
const siblingMission = path.join(projectRoot, "missions", "a-first-extra");
|
||||
if (siblingKind === "in-project symlinked mission") {
|
||||
const archiveMission = path.join(projectRoot, "archive", "a-first-extra");
|
||||
fs.mkdirSync(path.dirname(archiveMission), { recursive: true });
|
||||
fs.renameSync(siblingMission, archiveMission);
|
||||
fs.symlinkSync(path.relative(path.dirname(siblingMission), archiveMission), siblingMission, "dir");
|
||||
} else {
|
||||
fs.rmSync(path.join(siblingMission, "slices"), { recursive: true, force: true });
|
||||
}
|
||||
fs.rmSync(path.join(projectRoot, "dogfood-evidence", "a-first-same-slice"), { recursive: true, force: true });
|
||||
|
||||
const detail = await app.request("/api/slices/same-slice?project=alpha&mission=a-first");
|
||||
const detailText = await detail.text();
|
||||
const detailBody = JSON.parse(detailText) as { missionId?: string; tests?: { proofPackets?: unknown[] } };
|
||||
const asset = await app.request("/api/slices/same-slice/proof-asset/screenshots/scope.png?project=alpha&mission=a-first");
|
||||
const assetText = await asset.text();
|
||||
const returnedText = `${detailText}\n${assetText}`;
|
||||
expect({
|
||||
detailStatus: detail.status,
|
||||
missionId: detailBody.missionId,
|
||||
proofPackets: detailBody.tests?.proofPackets,
|
||||
assetStatus: asset.status,
|
||||
assetNotFound: assetText.includes("proof_packet_not_found"),
|
||||
siblingBytesLeaked: returnedText.includes("# alpha/a-first-extra proof") || returnedText.includes("ALPHA-A-FIRST-EXTRA-PROOF"),
|
||||
}).toEqual({
|
||||
detailStatus: 200,
|
||||
missionId: "a-first",
|
||||
proofPackets: [],
|
||||
assetStatus: 404,
|
||||
assetNotFound: true,
|
||||
siblingBytesLeaked: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves mission token order when proof names contain the mission id", async () => {
|
||||
addAlphaMissionWithProof("first-a", 1600000004);
|
||||
const detail = await app.request("/api/slices/same-slice?project=alpha&mission=a-first");
|
||||
const body = await detail.json();
|
||||
const packet = body.tests.proofPackets[0];
|
||||
const asset = await app.request("/api/slices/same-slice/proof-asset/screenshots/scope.png?project=alpha&mission=a-first");
|
||||
|
||||
expect({
|
||||
proofDir: packet?.dirName,
|
||||
proofMarkdown: packet?.primaryMarkdown?.content,
|
||||
assetBytes: await asset.text(),
|
||||
}).toEqual({
|
||||
proofDir: "a-first-same-slice",
|
||||
proofMarkdown: "# alpha/a-first proof\n",
|
||||
assetBytes: "ALPHA-A-FIRST-PROOF\n",
|
||||
});
|
||||
});
|
||||
|
||||
const packetPrefixSeparators = [
|
||||
{ label: "hyphen", separator: "-" },
|
||||
{ label: "dot", separator: "." },
|
||||
{ label: "underscore", separator: "_" },
|
||||
] as const;
|
||||
|
||||
it.each(packetPrefixSeparators)(
|
||||
"qualified detail and proof assets select an older slice packet over a newer same-mission packet whose only matching token is in the mission prefix ($label separator)",
|
||||
async ({ separator }) => {
|
||||
addAlphaMissionWithProof("a-first-extra", 1600000040);
|
||||
const alphaRoot = path.join(workspaceRoot, "alpha");
|
||||
const betaRoot = path.join(workspaceRoot, "beta");
|
||||
writeSlice(path.join(alphaRoot, "missions", "a-first", "slices"), "first", {
|
||||
"SPEC.md": "# alpha/a-first/first\n",
|
||||
});
|
||||
writeSlice(path.join(alphaRoot, "missions", "a-first", "slices"), "second", {
|
||||
"SPEC.md": "# alpha/a-first/second\n",
|
||||
});
|
||||
|
||||
const writeScopedPacket = (
|
||||
evidenceRoot: string,
|
||||
dirName: string,
|
||||
markdown: string,
|
||||
assetBytes: string,
|
||||
mtime: number,
|
||||
): void => {
|
||||
const packetDir = path.join(evidenceRoot, dirName);
|
||||
fs.mkdirSync(path.join(packetDir, "screenshots"), { recursive: true });
|
||||
fs.writeFileSync(path.join(packetDir, "proof.md"), markdown);
|
||||
fs.writeFileSync(path.join(packetDir, "screenshots", "scope.png"), assetBytes);
|
||||
fs.utimesSync(packetDir, mtime, mtime);
|
||||
};
|
||||
|
||||
const alphaEvidence = path.join(alphaRoot, "dogfood-evidence");
|
||||
writeScopedPacket(
|
||||
alphaEvidence,
|
||||
`a-first${separator}first`,
|
||||
"# alpha/a-first/first proof\n",
|
||||
"ALPHA-A-FIRST-SLICE-FIRST-PROOF\n",
|
||||
1600000010,
|
||||
);
|
||||
writeScopedPacket(
|
||||
alphaEvidence,
|
||||
`a-first${separator}second`,
|
||||
"# alpha/a-first/second proof\n",
|
||||
"ALPHA-A-FIRST-SLICE-SECOND-PROOF\n",
|
||||
1600000020,
|
||||
);
|
||||
writeScopedPacket(
|
||||
alphaEvidence,
|
||||
`a-first-extra${separator}first`,
|
||||
"# alpha/a-first-extra/first proof\n",
|
||||
"ALPHA-A-FIRST-EXTRA-SLICE-FIRST-PROOF\n",
|
||||
1600000040,
|
||||
);
|
||||
writeScopedPacket(
|
||||
path.join(betaRoot, "dogfood-evidence"),
|
||||
`a-first${separator}first`,
|
||||
"# beta/a-first/first proof\n",
|
||||
"BETA-A-FIRST-SLICE-FIRST-PROOF\n",
|
||||
1600000050,
|
||||
);
|
||||
|
||||
const firstDetail = await app.request("/api/slices/first?project=alpha&mission=a-first");
|
||||
const firstBody = await firstDetail.json();
|
||||
const firstPacket = firstBody.tests.proofPackets[0];
|
||||
const firstAsset = await app.request("/api/slices/first/proof-asset/screenshots/scope.png?project=alpha&mission=a-first");
|
||||
const firstAssetBytes = await firstAsset.text();
|
||||
|
||||
const secondDetail = await app.request("/api/slices/second?project=alpha&mission=a-first");
|
||||
const secondBody = await secondDetail.json();
|
||||
const secondPacket = secondBody.tests.proofPackets[0];
|
||||
const secondAsset = await app.request("/api/slices/second/proof-asset/screenshots/scope.png?project=alpha&mission=a-first");
|
||||
const secondAssetBytes = await secondAsset.text();
|
||||
|
||||
expect({
|
||||
first: {
|
||||
detailStatus: firstDetail.status,
|
||||
missionId: firstBody.missionId,
|
||||
proofDir: firstPacket?.dirName,
|
||||
proofMarkdown: firstPacket?.primaryMarkdown?.content,
|
||||
assetStatus: firstAsset.status,
|
||||
assetBytes: firstAssetBytes,
|
||||
},
|
||||
second: {
|
||||
detailStatus: secondDetail.status,
|
||||
missionId: secondBody.missionId,
|
||||
proofDir: secondPacket?.dirName,
|
||||
proofMarkdown: secondPacket?.primaryMarkdown?.content,
|
||||
assetStatus: secondAsset.status,
|
||||
assetBytes: secondAssetBytes,
|
||||
},
|
||||
}).toEqual({
|
||||
first: {
|
||||
detailStatus: 200,
|
||||
missionId: "a-first",
|
||||
proofDir: `a-first${separator}first`,
|
||||
proofMarkdown: "# alpha/a-first/first proof\n",
|
||||
assetStatus: 200,
|
||||
assetBytes: "ALPHA-A-FIRST-SLICE-FIRST-PROOF\n",
|
||||
},
|
||||
second: {
|
||||
detailStatus: 200,
|
||||
missionId: "a-first",
|
||||
proofDir: `a-first${separator}second`,
|
||||
proofMarkdown: "# alpha/a-first/second proof\n",
|
||||
assetStatus: 200,
|
||||
assetBytes: "ALPHA-A-FIRST-SLICE-SECOND-PROOF\n",
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(packetPrefixSeparators)(
|
||||
"qualified detail and proof assets do not fall back to a same-mission packet whose only matching token is in the mission prefix ($label separator)",
|
||||
async ({ separator }) => {
|
||||
const alphaRoot = path.join(workspaceRoot, "alpha");
|
||||
writeSlice(path.join(alphaRoot, "missions", "a-first", "slices"), "first", {
|
||||
"SPEC.md": "# alpha/a-first/first\n",
|
||||
});
|
||||
const wrongSiblingPacket = path.join(alphaRoot, "dogfood-evidence", `a-first${separator}second`);
|
||||
fs.mkdirSync(path.join(wrongSiblingPacket, "screenshots"), { recursive: true });
|
||||
fs.writeFileSync(path.join(wrongSiblingPacket, "proof.md"), "# alpha/a-first/second proof\n");
|
||||
fs.writeFileSync(path.join(wrongSiblingPacket, "screenshots", "scope.png"), "ALPHA-A-FIRST-SLICE-SECOND-PROOF\n");
|
||||
fs.utimesSync(wrongSiblingPacket, 1600000020, 1600000020);
|
||||
|
||||
const detail = await app.request("/api/slices/first?project=alpha&mission=a-first");
|
||||
const detailText = await detail.text();
|
||||
const detailBody = JSON.parse(detailText) as { missionId?: string; tests?: { proofPackets?: unknown[] } };
|
||||
const asset = await app.request("/api/slices/first/proof-asset/screenshots/scope.png?project=alpha&mission=a-first");
|
||||
const assetText = await asset.text();
|
||||
|
||||
expect({
|
||||
detailStatus: detail.status,
|
||||
missionId: detailBody.missionId,
|
||||
proofPackets: detailBody.tests?.proofPackets,
|
||||
assetStatus: asset.status,
|
||||
assetNotFound: assetText.includes("proof_packet_not_found"),
|
||||
wrongPacketLeaked:
|
||||
detailText.includes("# alpha/a-first/second proof")
|
||||
|| assetText.includes("ALPHA-A-FIRST-SLICE-SECOND-PROOF"),
|
||||
}).toEqual({
|
||||
detailStatus: 200,
|
||||
missionId: "a-first",
|
||||
proofPackets: [],
|
||||
assetStatus: 404,
|
||||
assetNotFound: true,
|
||||
wrongPacketLeaked: false,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const clearEvidenceReadRecord = (): void => {
|
||||
(fs.readdirSync as unknown as { mockClear: () => void }).mockClear();
|
||||
(fs.readFileSync as unknown as { mockClear: () => void }).mockClear();
|
||||
};
|
||||
|
||||
const recordedReadsUnder = (roots: string[]): string[] => {
|
||||
const prefixes = Array.from(new Set(roots.flatMap((root) => {
|
||||
const resolved = path.resolve(root);
|
||||
const parentReal = fs.existsSync(path.dirname(resolved))
|
||||
? path.join(fs.realpathSync(path.dirname(resolved)), path.basename(resolved))
|
||||
: resolved;
|
||||
const targetReal = fs.existsSync(resolved) ? fs.realpathSync(resolved) : parentReal;
|
||||
return [resolved, parentReal, targetReal];
|
||||
})));
|
||||
const matchesRoot = (target: unknown): boolean => {
|
||||
if (typeof target === "number" || target == null) return false;
|
||||
const resolved = path.resolve(String(target));
|
||||
return prefixes.some((prefix) => resolved === prefix || resolved.startsWith(`${prefix}${path.sep}`));
|
||||
};
|
||||
const readdirCalls = (fs.readdirSync as unknown as { mock: { calls: unknown[][] } }).mock.calls
|
||||
.filter((args) => matchesRoot(args[0]))
|
||||
.map((args) => `readdir:${String(args[0])}`);
|
||||
const readFileCalls = (fs.readFileSync as unknown as { mock: { calls: unknown[][] } }).mock.calls
|
||||
.filter((args) => matchesRoot(args[0]))
|
||||
.map((args) => `readFile:${String(args[0])}`);
|
||||
return [...readdirCalls, ...readFileCalls];
|
||||
};
|
||||
|
||||
const withEvidenceRootFault = async <T>(
|
||||
targetPath: string,
|
||||
stage: "resolve" | "access" | "readdir" | "unexpected",
|
||||
code: string,
|
||||
action: () => Promise<T> | T,
|
||||
): Promise<{ result: T; hits: number }> => {
|
||||
evidenceRootFault.targetPath = targetPath;
|
||||
evidenceRootFault.stage = stage;
|
||||
evidenceRootFault.code = code;
|
||||
evidenceRootFault.hits = 0;
|
||||
try {
|
||||
const result = await action();
|
||||
return { result, hits: evidenceRootFault.hits };
|
||||
} finally {
|
||||
evidenceRootFault.targetPath = null;
|
||||
evidenceRootFault.stage = null;
|
||||
evidenceRootFault.code = "EACCES";
|
||||
evidenceRootFault.hits = 0;
|
||||
}
|
||||
};
|
||||
|
||||
// Maintainer-directed PR #2960 contract change: an evidence root symlink
|
||||
// resolving outside the selected project is treated as an absent evidence
|
||||
// root (detail 200 with empty proofPackets; proof-asset 404
|
||||
// proof_packet_not_found) without reading outside the project, while an
|
||||
// escaping asset symlink inside a valid evidence root still returns 409.
|
||||
it("treats a qualified evidence root symlink outside the selected project as absent", async () => {
|
||||
const outsideRoot = path.join(cleanupDir, "outside-proof-root");
|
||||
const outsidePacket = path.join(outsideRoot, "a-first-same-slice");
|
||||
const outsideMarkdown = "OUTSIDE-ROOT-MARKDOWN";
|
||||
const outsideAsset = "OUTSIDE-ROOT-ASSET";
|
||||
fs.mkdirSync(path.join(outsidePacket, "screenshots"), { recursive: true });
|
||||
fs.writeFileSync(path.join(outsidePacket, "proof.md"), `${outsideMarkdown}\n`);
|
||||
fs.writeFileSync(path.join(outsidePacket, "screenshots/scope.png"), `${outsideAsset}\n`);
|
||||
const selectedEvidenceRoot = path.join(workspaceRoot, "alpha", "dogfood-evidence");
|
||||
fs.rmSync(selectedEvidenceRoot, { recursive: true, force: true });
|
||||
fs.symlinkSync(outsideRoot, selectedEvidenceRoot, "dir");
|
||||
|
||||
clearEvidenceReadRecord();
|
||||
const detail = await app.request("/api/slices/same-slice?project=alpha&mission=a-first");
|
||||
const detailText = await detail.text();
|
||||
const detailBody = JSON.parse(detailText) as {
|
||||
slicePath?: string;
|
||||
missionId?: string;
|
||||
tests?: { proofPackets?: unknown[] };
|
||||
};
|
||||
const asset = await app.request("/api/slices/same-slice/proof-asset/screenshots/scope.png?project=alpha&mission=a-first");
|
||||
const assetText = await asset.text();
|
||||
const assetBody = JSON.parse(assetText) as unknown;
|
||||
expect({
|
||||
detailStatus: detail.status,
|
||||
slicePath: detailBody.slicePath,
|
||||
missionId: detailBody.missionId,
|
||||
proofPackets: detailBody.tests?.proofPackets,
|
||||
detailLeaksOutsideProof: detailText.includes(outsideMarkdown),
|
||||
assetStatus: asset.status,
|
||||
assetBody,
|
||||
assetLeaksOutsideProof: assetText.includes(outsideAsset),
|
||||
evidenceReads: recordedReadsUnder([selectedEvidenceRoot, outsideRoot]),
|
||||
}).toEqual({
|
||||
detailStatus: 200,
|
||||
slicePath: path.join(workspaceRoot, "alpha", "missions", "a-first", "slices", "same-slice"),
|
||||
missionId: "a-first",
|
||||
proofPackets: [],
|
||||
detailLeaksOutsideProof: false,
|
||||
assetStatus: 404,
|
||||
assetBody: { error: "proof_packet_not_found" },
|
||||
assetLeaksOutsideProof: false,
|
||||
evidenceReads: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a missing qualified evidence root optional", async () => {
|
||||
fs.rmSync(path.join(workspaceRoot, "alpha", "dogfood-evidence"), { recursive: true });
|
||||
|
||||
const detail = await app.request("/api/slices/same-slice?project=alpha&mission=a-first");
|
||||
const detailBody = await detail.json();
|
||||
const asset = await app.request("/api/slices/same-slice/proof-asset/screenshots/scope.png?project=alpha&mission=a-first");
|
||||
expect({
|
||||
detailStatus: detail.status,
|
||||
proofPackets: detailBody.tests.proofPackets,
|
||||
assetStatus: asset.status,
|
||||
assetError: await asset.json(),
|
||||
}).toEqual({
|
||||
detailStatus: 200,
|
||||
proofPackets: [],
|
||||
assetStatus: 404,
|
||||
assetError: { error: "proof_packet_not_found" },
|
||||
});
|
||||
});
|
||||
|
||||
it("serves a qualified slice document when the selected project's evidence root symlink escapes the project", async () => {
|
||||
const outsideRoot = path.join(cleanupDir, "outside-doc-proof-root");
|
||||
const outsidePacket = path.join(outsideRoot, "z-later-same-slice");
|
||||
const outsideMarkdown = "OUTSIDE-DOC-ROOT-MARKDOWN";
|
||||
const outsideNote = "OUTSIDE-DOC-ROOT-NOTE";
|
||||
fs.mkdirSync(path.join(outsidePacket, "notes"), { recursive: true });
|
||||
fs.writeFileSync(path.join(outsidePacket, "proof.md"), `${outsideMarkdown}\n`);
|
||||
fs.writeFileSync(path.join(outsidePacket, "notes", "a b.md"), `${outsideNote}\n`);
|
||||
const selectedEvidenceRoot = path.join(workspaceRoot, "alpha", "dogfood-evidence");
|
||||
fs.rmSync(selectedEvidenceRoot, { recursive: true, force: true });
|
||||
fs.symlinkSync(outsideRoot, selectedEvidenceRoot, "dir");
|
||||
|
||||
clearEvidenceReadRecord();
|
||||
const response = await app.request("/api/slices/same-slice/doc/notes/a%20b.md?project=alpha&mission=z-later");
|
||||
const responseText = await response.text();
|
||||
const body = JSON.parse(responseText) as unknown;
|
||||
expect({
|
||||
status: response.status,
|
||||
body,
|
||||
evidenceReads: recordedReadsUnder([selectedEvidenceRoot, outsideRoot]),
|
||||
leaksOutsideEvidence: responseText.includes(outsideMarkdown) || responseText.includes(outsideNote),
|
||||
}).toEqual({
|
||||
status: 200,
|
||||
body: { relPath: "notes/a b.md", content: "alpha/z-later nested document\n" },
|
||||
evidenceReads: [],
|
||||
leaksOutsideEvidence: false,
|
||||
});
|
||||
});
|
||||
|
||||
describe("when the selected project's evidence root symlink is dangling", () => {
|
||||
let missingTarget: string;
|
||||
let selectedEvidenceRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
missingTarget = path.join(cleanupDir, "missing-doc-proof-root");
|
||||
selectedEvidenceRoot = path.join(workspaceRoot, "alpha", "dogfood-evidence");
|
||||
fs.rmSync(selectedEvidenceRoot, { recursive: true, force: true });
|
||||
fs.symlinkSync(missingTarget, selectedEvidenceRoot, "dir");
|
||||
expect(fs.existsSync(missingTarget)).toBe(false);
|
||||
expect(fs.lstatSync(selectedEvidenceRoot).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
it("serves a qualified slice document when the selected project's evidence root symlink is dangling", async () => {
|
||||
clearEvidenceReadRecord();
|
||||
const response = await app.request("/api/slices/same-slice/doc/notes/a%20b.md?project=alpha&mission=z-later");
|
||||
const responseText = await response.text();
|
||||
const body = JSON.parse(responseText) as unknown;
|
||||
expect({
|
||||
status: response.status,
|
||||
body,
|
||||
evidenceReads: recordedReadsUnder([selectedEvidenceRoot, missingTarget]),
|
||||
}).toEqual({
|
||||
status: 200,
|
||||
body: { relPath: "notes/a b.md", content: "alpha/z-later nested document\n" },
|
||||
evidenceReads: [],
|
||||
});
|
||||
});
|
||||
|
||||
// Maintainer-directed PR #2960 contract change: a dangling evidence root
|
||||
// symlink is treated as an absent evidence root rather than 409
|
||||
// project_source_unavailable, matching missing-root behavior on detail
|
||||
// (200 with empty proofPackets) and proof-asset (404 proof_packet_not_found).
|
||||
it("treats a dangling qualified evidence root symlink as absent on detail and proof-asset", async () => {
|
||||
clearEvidenceReadRecord();
|
||||
const detail = await app.request("/api/slices/same-slice?project=alpha&mission=z-later");
|
||||
const detailBody = await detail.json() as {
|
||||
slicePath?: string;
|
||||
missionId?: string;
|
||||
tests?: { proofPackets?: unknown[] };
|
||||
};
|
||||
const asset = await app.request("/api/slices/same-slice/proof-asset/screenshots/scope.png?project=alpha&mission=z-later");
|
||||
const assetBody = await asset.json() as unknown;
|
||||
expect({
|
||||
detailStatus: detail.status,
|
||||
slicePath: detailBody.slicePath,
|
||||
missionId: detailBody.missionId,
|
||||
proofPackets: detailBody.tests?.proofPackets,
|
||||
assetStatus: asset.status,
|
||||
assetBody,
|
||||
evidenceReads: recordedReadsUnder([selectedEvidenceRoot, missingTarget]),
|
||||
}).toEqual({
|
||||
detailStatus: 200,
|
||||
slicePath: path.join(workspaceRoot, "alpha", "missions", "z-later", "slices", "same-slice"),
|
||||
missionId: "z-later",
|
||||
proofPackets: [],
|
||||
assetStatus: 404,
|
||||
assetBody: { error: "proof_packet_not_found" },
|
||||
evidenceReads: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["resolve", "access"] as const)(
|
||||
"treats an unreadable qualified evidence root (%s EACCES) as absent on detail and proof-asset",
|
||||
async (stage) => {
|
||||
const selectedEvidenceRoot = fs.realpathSync(path.join(workspaceRoot, "alpha", "dogfood-evidence"));
|
||||
const { result, hits } = await withEvidenceRootFault(selectedEvidenceRoot, stage, "EACCES", async () => {
|
||||
const detail = await app.request("/api/slices/same-slice?project=alpha&mission=a-first");
|
||||
const detailText = await detail.text();
|
||||
const detailBody = JSON.parse(detailText) as {
|
||||
slicePath?: string;
|
||||
missionId?: string;
|
||||
tests?: { proofPackets?: unknown[] };
|
||||
};
|
||||
const asset = await app.request("/api/slices/same-slice/proof-asset/screenshots/scope.png?project=alpha&mission=a-first");
|
||||
const assetText = await asset.text();
|
||||
const assetBody = JSON.parse(assetText) as unknown;
|
||||
const doc = await app.request("/api/slices/same-slice/doc/SPEC.md?project=alpha&mission=a-first");
|
||||
const docBody = await doc.json() as unknown;
|
||||
return {
|
||||
detailStatus: detail.status,
|
||||
slicePath: detailBody.slicePath,
|
||||
missionId: detailBody.missionId,
|
||||
proofPackets: detailBody.tests?.proofPackets,
|
||||
detailLeaksProof: detailText.includes("alpha/a-first proof"),
|
||||
assetStatus: asset.status,
|
||||
assetBody,
|
||||
assetLeaksProof: assetText.includes("ALPHA-PROOF"),
|
||||
docStatus: doc.status,
|
||||
docBody,
|
||||
};
|
||||
});
|
||||
expect({ ...result, faultTriggered: hits > 0 }).toEqual({
|
||||
detailStatus: 200,
|
||||
slicePath: path.join(workspaceRoot, "alpha", "missions", "a-first", "slices", "same-slice"),
|
||||
missionId: "a-first",
|
||||
proofPackets: [],
|
||||
detailLeaksProof: false,
|
||||
assetStatus: 404,
|
||||
assetBody: { error: "proof_packet_not_found" },
|
||||
assetLeaksProof: false,
|
||||
docStatus: 200,
|
||||
docBody: { relPath: "SPEC.md", content: "# alpha/a-first\n" },
|
||||
faultTriggered: true,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("qualifiedEvidenceRoot returns null for unavailable or escaping roots without swallowing unrelated caller errors", async () => {
|
||||
const helper = (slicesRoutesModule as {
|
||||
qualifiedEvidenceRoot?: (projectRoot: string, missionsRoot: string) => string | null;
|
||||
}).qualifiedEvidenceRoot;
|
||||
expect(helper).toBeTypeOf("function");
|
||||
|
||||
const alphaRoot = fs.realpathSync(path.join(workspaceRoot, "alpha"));
|
||||
const alphaMissionsRoot = path.join(alphaRoot, "missions");
|
||||
const selectedEvidenceRoot = path.join(alphaRoot, "dogfood-evidence");
|
||||
expect(helper!(alphaRoot, alphaMissionsRoot)).toBe(selectedEvidenceRoot);
|
||||
|
||||
for (const stage of ["resolve", "access", "readdir"] as const) {
|
||||
const { result, hits } = await withEvidenceRootFault(selectedEvidenceRoot, stage, "EACCES", () =>
|
||||
helper!(alphaRoot, alphaMissionsRoot),
|
||||
);
|
||||
expect({ stage, result, faultTriggered: hits > 0 }).toEqual({
|
||||
stage,
|
||||
result: null,
|
||||
faultTriggered: true,
|
||||
});
|
||||
}
|
||||
|
||||
await expect(
|
||||
withEvidenceRootFault(selectedEvidenceRoot, "unexpected", "EACCES", () =>
|
||||
helper!(alphaRoot, alphaMissionsRoot),
|
||||
),
|
||||
).rejects.toThrow(TypeError);
|
||||
|
||||
fs.rmSync(selectedEvidenceRoot, { recursive: true, force: true });
|
||||
expect(helper!(alphaRoot, alphaMissionsRoot)).toBeNull();
|
||||
|
||||
const missingTarget = path.join(cleanupDir, "missing-helper-evidence-root");
|
||||
fs.symlinkSync(missingTarget, selectedEvidenceRoot, "dir");
|
||||
expect(helper!(alphaRoot, alphaMissionsRoot)).toBeNull();
|
||||
|
||||
fs.rmSync(selectedEvidenceRoot, { recursive: true, force: true });
|
||||
const outsideRoot = path.join(cleanupDir, "outside-helper-evidence-root");
|
||||
fs.mkdirSync(outsideRoot, { recursive: true });
|
||||
fs.symlinkSync(outsideRoot, selectedEvidenceRoot, "dir");
|
||||
expect(helper!(alphaRoot, alphaMissionsRoot)).toBeNull();
|
||||
|
||||
fs.rmSync(selectedEvidenceRoot, { recursive: true, force: true });
|
||||
fs.writeFileSync(selectedEvidenceRoot, "not-a-directory\n");
|
||||
expect(helper!(alphaRoot, alphaMissionsRoot)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a qualified proof asset symlink outside the selected project", async () => {
|
||||
const outsideAsset = "OUTSIDE-DIRECT-ASSET-BYTES";
|
||||
const outsidePath = path.join(cleanupDir, "outside-proof-asset.png");
|
||||
const selectedAsset = path.join(workspaceRoot, "alpha", "dogfood-evidence", "a-first-same-slice", "screenshots", "scope.png");
|
||||
fs.writeFileSync(outsidePath, `${outsideAsset}\n`);
|
||||
fs.rmSync(selectedAsset);
|
||||
fs.symlinkSync(outsidePath, selectedAsset, "file");
|
||||
|
||||
const response = await app.request("/api/slices/same-slice/proof-asset/screenshots/scope.png?project=alpha&mission=a-first");
|
||||
const responseText = await response.text();
|
||||
expect({
|
||||
status: response.status,
|
||||
projectPathEscape: responseText.includes("project_path_escape"),
|
||||
leaksOutsideAsset: responseText.includes(outsideAsset),
|
||||
}).toEqual({ status: 409, projectPathEscape: true, leaksOutsideAsset: false });
|
||||
});
|
||||
|
||||
it("uses the missions-root-relative evidence directory for custom missions.root", async () => {
|
||||
const project = "custom";
|
||||
const mission = "custom-first";
|
||||
const slice = "same-slice";
|
||||
const projectRoot = path.join(workspaceRoot, project);
|
||||
fs.writeFileSync(path.join(workspaceRoot, "workspace.yaml"),
|
||||
"projects:\n - id: alpha\n root: alpha\n - id: beta\n root: beta\n - id: custom\n root: custom\n");
|
||||
writeSlice(workspaceRoot, project, {
|
||||
"project.yaml": "metadata:\n id: custom\nmissions:\n root: work/missions\n",
|
||||
"SPEC.md": "# custom project\n",
|
||||
});
|
||||
const missionsRoot = path.join(projectRoot, "work", "missions");
|
||||
writeSlice(missionsRoot, mission, { "SPEC.md": `# ${mission}\n` });
|
||||
writeSlice(path.join(missionsRoot, mission, "slices"), slice, { "SPEC.md": `# ${project}/${mission}\n` });
|
||||
|
||||
// The CLI contract is path.dirname(project/work/missions)/dogfood-evidence,
|
||||
// i.e. project/work/dogfood-evidence; this is a literal fixture path, not a helper call.
|
||||
const conventionalRoot = path.join(projectRoot, "work", "dogfood-evidence");
|
||||
const projectRootDecoy = path.join(projectRoot, "dogfood-evidence");
|
||||
const writePacket = (root: string, marker: string, mtime: number): void => {
|
||||
const packet = path.join(root, `${mission}-${slice}`);
|
||||
fs.mkdirSync(path.join(packet, "screenshots"), { recursive: true });
|
||||
fs.writeFileSync(path.join(packet, "proof.md"), `# ${marker}\n`);
|
||||
fs.writeFileSync(path.join(packet, "screenshots/scope.png"), `${marker}\n`);
|
||||
fs.utimesSync(packet, mtime, mtime);
|
||||
};
|
||||
const expectedMarker = "CUSTOM-WORK-MISSIONS-PROOF";
|
||||
writePacket(conventionalRoot, expectedMarker, 1600000004);
|
||||
writePacket(projectRootDecoy, "CUSTOM-PROJECT-ROOT-DECOY", 1600000005);
|
||||
|
||||
const detail = await app.request(`/api/slices/${slice}?project=${project}&mission=${mission}`);
|
||||
const body = await detail.json();
|
||||
const packet = body.tests.proofPackets[0];
|
||||
const asset = await app.request(`/api/slices/${slice}/proof-asset/screenshots/scope.png?project=${project}&mission=${mission}`);
|
||||
expect({
|
||||
detailStatus: detail.status,
|
||||
proofDir: packet?.dirName,
|
||||
proofMarkdown: packet?.primaryMarkdown?.content,
|
||||
assetStatus: asset.status,
|
||||
assetBytes: await asset.text(),
|
||||
}).toEqual({
|
||||
detailStatus: 200,
|
||||
proofDir: `${mission}-${slice}`,
|
||||
proofMarkdown: `# ${expectedMarker}\n`,
|
||||
assetStatus: 200,
|
||||
assetBytes: `${expectedMarker}\n`,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps missions.root . healthy and treats its parent evidence store as optional", async () => {
|
||||
const project = "dot-root";
|
||||
const mission = "dot-first";
|
||||
const slice = "same-slice";
|
||||
const projectRoot = path.join(workspaceRoot, project);
|
||||
fs.writeFileSync(path.join(workspaceRoot, "workspace.yaml"),
|
||||
"projects:\n - id: alpha\n root: alpha\n - id: beta\n root: beta\n - id: dot-root\n root: dot-root\n");
|
||||
writeSlice(workspaceRoot, project, {
|
||||
"project.yaml": "metadata:\n id: dot-root\nmissions:\n root: .\n",
|
||||
"SPEC.md": "# dot-root project\n",
|
||||
});
|
||||
writeSlice(projectRoot, mission, { "SPEC.md": `# ${mission}\n` });
|
||||
writeSlice(path.join(projectRoot, mission, "slices"), slice, {
|
||||
"SPEC.md": "# dot-root/dot-first\n",
|
||||
"notes/a b.md": "dot-root own document\n",
|
||||
});
|
||||
|
||||
// Literal parent path for missions.root: .; this is the legacy store outside the project.
|
||||
const parentEvidenceRoot = path.join(workspaceRoot, "dogfood-evidence");
|
||||
const parentPacket = path.join(parentEvidenceRoot, `${mission}-${slice}`);
|
||||
const parentMarkdownMarker = "PARENT-LEGACY-PROOF-ROOT";
|
||||
const parentAssetMarker = "PARENT-LEGACY-PROOF-ASSET";
|
||||
fs.mkdirSync(path.join(parentPacket, "screenshots"), { recursive: true });
|
||||
fs.writeFileSync(path.join(parentPacket, "proof.md"), `# ${parentMarkdownMarker}\n`);
|
||||
fs.writeFileSync(path.join(parentPacket, "screenshots/scope.png"), `${parentAssetMarker}\n`);
|
||||
|
||||
const detail = await app.request(`/api/slices/${slice}?project=${project}&mission=${mission}`);
|
||||
const detailBody = await detail.json();
|
||||
const doc = await app.request(`/api/slices/${slice}/doc/SPEC.md?project=${project}&mission=${mission}`);
|
||||
const docBody = await doc.json();
|
||||
const asset = await app.request(`/api/slices/${slice}/proof-asset/screenshots/scope.png?project=${project}&mission=${mission}`);
|
||||
const assetText = await asset.text();
|
||||
const returnedText = `${JSON.stringify(detailBody)}\n${JSON.stringify(docBody)}\n${assetText}`;
|
||||
expect({
|
||||
detailStatus: detail.status,
|
||||
slicePath: detailBody.slicePath,
|
||||
missionId: detailBody.missionId,
|
||||
proofPackets: detailBody.tests?.proofPackets,
|
||||
docStatus: doc.status,
|
||||
docBody,
|
||||
assetStatus: asset.status,
|
||||
assetNotFound: assetText.includes("proof_packet_not_found"),
|
||||
parentProofBytesLeaked: returnedText.includes(parentMarkdownMarker) || returnedText.includes(parentAssetMarker),
|
||||
}).toEqual({
|
||||
detailStatus: 200,
|
||||
slicePath: path.join(projectRoot, mission, "slices", slice),
|
||||
missionId: mission,
|
||||
proofPackets: [],
|
||||
docStatus: 200,
|
||||
docBody: { relPath: "SPEC.md", content: "# dot-root/dot-first\n" },
|
||||
assetStatus: 404,
|
||||
assetNotFound: true,
|
||||
parentProofBytesLeaked: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("qualified detail finds the later duplicate mission and its own proof packet", async () => {
|
||||
const expectedSlicePath = path.join(workspaceRoot, "alpha", "missions", "z-later", "slices", "same-slice");
|
||||
expect(fs.readFileSync(path.join(expectedSlicePath, "SPEC.md"), "utf8")).toBe("# alpha/z-later\n");
|
||||
it("records detail's existing later-duplicate mission absence without inventing a successful lookup", async () => {
|
||||
expect(fs.readFileSync(path.join(workspaceRoot, "alpha/missions/z-later/slices/same-slice/SPEC.md"), "utf8")).toBe("# alpha/z-later\n");
|
||||
const detail = await app.request("/api/slices/same-slice?project=alpha&mission=z-later");
|
||||
expect(detail.status).toBe(200);
|
||||
expect(await detail.json()).toMatchObject({
|
||||
slicePath: expectedSlicePath,
|
||||
missionId: "z-later",
|
||||
tests: { proofPackets: [{
|
||||
dirName: "z-later-same-slice",
|
||||
primaryMarkdown: { relPath: "proof.md", content: "# alpha/z-later proof\n" },
|
||||
screenshots: ["screenshots/scope.png"],
|
||||
}] },
|
||||
});
|
||||
expect(detail.status).toBe(404);
|
||||
expect(await detail.json()).toEqual({ error: "slice_not_found", name: "same-slice" });
|
||||
});
|
||||
|
||||
it("doc follows the detail route's later-duplicate mission selection", async () => {
|
||||
it("doc preserves the detail route's current missing later-duplicate selection", async () => {
|
||||
const response = await app.request("/api/slices/same-slice/doc/SPEC.md?project=alpha&mission=z-later");
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ relPath: "SPEC.md", content: "# alpha/z-later\n" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ route: "detail", endpoint: "" },
|
||||
{ route: "doc", endpoint: "/doc/SPEC.md" },
|
||||
{ route: "proof", endpoint: "/proof-asset/screenshots/scope.png" },
|
||||
])("qualified $route preserves the source error for a nonexistent mission", async ({ endpoint }) => {
|
||||
const response = await app.request(`/api/slices/same-slice${endpoint}?project=alpha&mission=unrelated`);
|
||||
expect(response.status).toBe(409);
|
||||
expect(response.headers.get("content-type")).toContain("application/json");
|
||||
const body = await response.json() as { error: string; message: string };
|
||||
expect(body.error).toBe("project_source_unavailable");
|
||||
expect(body.message).toMatch(/ENOENT|no such file/i);
|
||||
expect(body).not.toHaveProperty("content");
|
||||
expect(body).not.toHaveProperty("proofPackets");
|
||||
const body = await response.json();
|
||||
expect({ status: response.status, content: body.content }).toEqual({ status: 404, content: undefined });
|
||||
expect(body).toEqual({ error: "doc_not_found" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -47,16 +47,15 @@ function respond(cmd: string, failing: string[], composer = false): string {
|
||||
case "list-sessions": return `${SESSION}\nalpha\n`;
|
||||
case "list-panes": return "%1|0|/w|80|24|1\n";
|
||||
case "display-message":
|
||||
return cmd.includes("#{pid}") ? "$7|4242|1700000000\n" : cmd.includes("session_id") ? "$7\n" : cmd.includes("pane_pid") ? "4242\n" : "1\n";
|
||||
// tmux prints just the variable asked for, or every one when none is named.
|
||||
case "show-environment": return cmd.endsWith(" OPENRIG_NODE_ID") ? "OPENRIG_NODE_ID=n1\n" : "OPENRIG_NODE_ID=n1\nCLAUDECODE=1\nHERDR_PANE_ID=p1\n";
|
||||
return cmd.includes("session_id") ? "$7\n" : cmd.includes("pane_pid") ? "4242\n" : "1\n";
|
||||
case "show-environment": return "OPENRIG_NODE_ID=n1\nCLAUDECODE=1\nHERDR_PANE_ID=p1\n";
|
||||
case "new-session": case "source-file": case "start-server": return "%1\n";
|
||||
default: return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** A shell-path adapter whose every run() call records its caller line, its argv and the string exec received. */
|
||||
function recordingAdapter(calls: RunCall[], opts: { failing?: string[]; guard?: boolean; composer?: boolean; nodeId?: string } = {}): TmuxAdapter {
|
||||
function recordingAdapter(calls: RunCall[], opts: { failing?: string[]; guard?: boolean; composer?: boolean } = {}): TmuxAdapter {
|
||||
const execs: string[] = [];
|
||||
const fileOps: TmuxFileOps = {
|
||||
writeFile: async () => {},
|
||||
@@ -70,8 +69,8 @@ function recordingAdapter(calls: RunCall[], opts: { failing?: string[]; guard?:
|
||||
}, fileOps);
|
||||
if (opts.guard) {
|
||||
adapter.deliveryGuard = {
|
||||
maybeTarget: () => ({ ...TARGET, nodeId: opts.nodeId ?? TARGET.nodeId }),
|
||||
target: () => ({ ...TARGET, nodeId: opts.nodeId ?? TARGET.nodeId }),
|
||||
maybeTarget: () => TARGET,
|
||||
target: () => TARGET,
|
||||
ownsLifecycle: () => false,
|
||||
checkInput: () => {},
|
||||
input: (_name: string, fn: () => Promise<unknown>) => fn(),
|
||||
@@ -161,10 +160,9 @@ async function driveEveryMethod(): Promise<RunCall[]> {
|
||||
// A runtime switch's exchange: two session-id reads, then the guarded if-shell swap (nested quoting); and its env writes.
|
||||
await a.swapRuntimeSwitchPanes({ nodeId: "n1", stagingSession: "staging'q a", canonicalSession: SESSION, canonicalPane: "%1", stagedPane: "%1" });
|
||||
await a.setRuntimeSwitchEnvironment(SESSION, "claude-code", "gen 'q 1");
|
||||
// A failed paste deletes the loaded buffer. The guarded kill reads the session id and server, then the seat's marker
|
||||
// (the session's OPENRIG_NODE_ID, n1 here), and runs each effect held to that id and server.
|
||||
// A failed paste deletes the loaded buffer; the guarded kill reads the session id first.
|
||||
await recordingAdapter(calls, { failing: ["paste-buffer"] }).sendText(SESSION, "hello");
|
||||
await recordingAdapter(calls, { guard: true, nodeId: "n1" }).killSession(SESSION);
|
||||
await recordingAdapter(calls, { guard: true }).killSession(SESSION);
|
||||
await recordingAdapter(calls, { composer: true }).captureComposerSnapshot("%1");
|
||||
const scoped = recordingAdapter(calls);
|
||||
scoped.launchEnvironment = () => ({ inheritTmuxEnvironment: false, env: { PATH: "/a b:$HOME", PRIVATE_SYNTHETIC: "it's a\nvalue" } });
|
||||
|
||||
@@ -11,9 +11,7 @@ import { SeatStructuralActivityService } from "../src/domain/seat-structural-act
|
||||
|
||||
type Panes = Record<string, string>;
|
||||
/** Runs a tmux argv against `panes` the way tmux does: `;` separates commands, and the first failing one ends it. */
|
||||
function runTmux(argv: string[], panes: Panes, vanished = new Set<string>(), maxBuffer = Infinity,
|
||||
/** What tmux expands `#{pane_id} #{pane_pid}` to, per session; left literal when absent. */
|
||||
identities: Record<string, string> = {}): string {
|
||||
function runTmux(argv: string[], panes: Panes, vanished = new Set<string>(), maxBuffer = Infinity): string {
|
||||
if (argv[1] === "list-sessions") return Object.keys(panes).join("\n") + "\n";
|
||||
const cmds: string[][] = [[]];
|
||||
for (const a of argv.slice(1)) {
|
||||
@@ -26,7 +24,7 @@ function runTmux(argv: string[], panes: Panes, vanished = new Set<string>(), max
|
||||
const name = /^=(.*):$/.exec(target)?.[1];
|
||||
if (name === undefined) throw new Error(`expected an exact =name: target, got ${target}`);
|
||||
if (!(name in panes) || vanished.has(name)) throw Object.assign(new Error(`can't find session: ${name}`), { stdout: out });
|
||||
out += c[0] === "display-message" ? `${c[c.length - 1]!.replace("#{pane_id} #{pane_pid}", identities[name] ?? "#{pane_id} #{pane_pid}")}\n` : panes[name];
|
||||
out += c[0] === "display-message" ? `${c[c.length - 1]}\n` : panes[name];
|
||||
}
|
||||
if (out.length > maxBuffer) throw new Error("stdout maxBuffer length exceeded");
|
||||
return out;
|
||||
@@ -43,12 +41,12 @@ const pane = (n: string) => `• ${n} output\n\n❯ \n`;
|
||||
describe("TmuxAdapter.capturePanesContent (#308)", () => {
|
||||
for (const mode of ["shell", "argv"] as const) {
|
||||
/** A TmuxAdapter over the fake tmux in this test's exec mode, recording every tmux argv it runs. */
|
||||
function adapter(panes: Panes, opts: { vanished?: Set<string>; noServer?: boolean; maxBuffer?: number; identities?: Record<string, string> } = {}) {
|
||||
function adapter(panes: Panes, opts: { vanished?: Set<string>; noServer?: boolean; maxBuffer?: number } = {}) {
|
||||
const calls: string[][] = [];
|
||||
const run = (argv: string[]) => {
|
||||
calls.push(argv);
|
||||
if (opts.noServer) throw new Error("no server running on /tmp/tmux-501/default");
|
||||
return runTmux(argv, panes, opts.vanished, opts.maxBuffer, opts.identities);
|
||||
return runTmux(argv, panes, opts.vanished, opts.maxBuffer);
|
||||
};
|
||||
const exec = async (cmd: string) => run(shellToArgv(cmd));
|
||||
const a = mode === "argv" ? new TmuxAdapter(async () => { throw new Error("shell path unused"); }, undefined, async (argv: string[]) => run(argv))
|
||||
@@ -64,17 +62,6 @@ describe("TmuxAdapter.capturePanesContent (#308)", () => {
|
||||
expect(Object.fromEntries([...got!].map(([k, v]) => [k, v.text]))).toEqual({ "gone@r": null, "a@r": pane("a"), "b.x@r": pane("b.x"), "o'q@r": pane("o'q") });
|
||||
});
|
||||
|
||||
it(`${mode}: the marker line's expanded pane id and pid become the capture's pane; a malformed or literal one gives none (#2515)`, async () => {
|
||||
const { a } = adapter({ "a@r": pane("a"), "b@r": pane("b"), "c@r": pane("c") },
|
||||
{ identities: { "a@r": "%5 4100", "b@r": "%5 notapid" } });
|
||||
const got = await a.capturePanesContent(["a@r", "b@r", "c@r"], 20);
|
||||
expect(got!.get("a@r")).toMatchObject({ text: pane("a"), pane: { id: "%5", pid: 4100 } });
|
||||
for (const name of ["b@r", "c@r"]) {
|
||||
expect(got!.get(name)?.text).toBe(pane(name.slice(0, 1)));
|
||||
expect(got!.get(name)?.pane).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it(`${mode}: 50 distinct live sessions = 1 listing + 3 chained calls of at most 24`, async () => {
|
||||
const panes = Object.fromEntries(Array.from({ length: 50 }, (_, i) => [`s${i}@r`, pane(`s${i}`)]));
|
||||
const { a, calls } = adapter(panes);
|
||||
|
||||
@@ -197,42 +197,6 @@ describe("waiting face names the next action the existing ladder can actually ta
|
||||
expect(view(row.qitemId).nextBackstop.dueAt).toBe("2026-09-08T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("shows the owner, progress and backstop of a blocker with a custom queue ID", async () => {
|
||||
const blocker = await queue.create({ qitemId: "custom-blocker", sourceSession: "owner@rig", destinationSession: "healthy@rig", body: "Resolve dependency", nudge: false });
|
||||
const row = await queue.create({ sourceSession: "owner@rig", destinationSession: "worker@rig", body: "Wait for dependency", nudge: false });
|
||||
await queue.claim({ qitemId: blocker.qitemId, destinationSession: "healthy@rig" });
|
||||
await queue.update({ qitemId: row.qitemId, actorSession: "worker@rig", state: "blocked", blockedOn: blocker.qitemId });
|
||||
advance(1);
|
||||
await queue.update({ qitemId: blocker.qitemId, actorSession: "healthy@rig", transitionNote: "Dependency progress" });
|
||||
const waiting = view(row.qitemId);
|
||||
expect(waiting.blocker).toEqual({ ref: "custom-blocker", owner: "healthy@rig", state: "in-progress" });
|
||||
expect(waiting.liveness.subject).toBe("healthy@rig");
|
||||
expect(waiting.lastMeaningfulChange).toEqual(lastMeaningfulTransition(db, blocker.qitemId));
|
||||
expect(waiting.nextBackstop).toMatchObject({ owner: "healthy@rig", mechanism: "blocker-transition / queue-stuck-sweep" });
|
||||
await queue.update({ qitemId: blocker.qitemId, actorSession: "healthy@rig", state: "done", closureReason: "no-follow-on" });
|
||||
expect(queue.getByIdOrThrow(row.qitemId)).toMatchObject({ state: "pending", blockedOn: null });
|
||||
});
|
||||
|
||||
it.each(["external:vendor", "auth:vendor", "fold:vendor"])("keeps %s as a typed gate when an item has the same ID", async (gate) => {
|
||||
await queue.create({ qitemId: gate, sourceSession: "owner@rig", destinationSession: "healthy@rig", body: "Unrelated item", nudge: false });
|
||||
const row = await queue.create({ sourceSession: "owner@rig", destinationSession: "worker@rig", body: "Wait for gate", nudge: false });
|
||||
await queue.update({ qitemId: row.qitemId, actorSession: "worker@rig", state: "blocked", blockedOn: gate });
|
||||
expect(view(row.qitemId).blocker).toEqual({ ref: gate, owner: null, state: null });
|
||||
expect(view(row.qitemId).liveness.subject).toBe(gate);
|
||||
expect(view(row.qitemId).nextBackstop).toMatchObject({ mechanism: "UNVERIFIED: no timed backstop", dueAt: null });
|
||||
});
|
||||
|
||||
it("does not promise a transition wake for a custom blocker that already finished", async () => {
|
||||
const blocker = await queue.create({ qitemId: "finished-blocker", sourceSession: "owner@rig", destinationSession: "healthy@rig", body: "Finished dependency", nudge: false });
|
||||
await queue.update({ qitemId: blocker.qitemId, actorSession: "healthy@rig", state: "done", closureReason: "no-follow-on" });
|
||||
const row = await queue.create({ sourceSession: "owner@rig", destinationSession: "worker@rig", body: "Wait for dependency", nudge: false });
|
||||
await queue.update({ qitemId: row.qitemId, actorSession: "worker@rig", state: "blocked", blockedOn: blocker.qitemId });
|
||||
expect(view(row.qitemId).blocker).toEqual({ ref: blocker.qitemId, owner: "healthy@rig", state: "done" });
|
||||
expect(view(row.qitemId).nextBackstop).toMatchObject({ mechanism: "UNVERIFIED: no timed backstop", dueAt: null });
|
||||
await queue.update({ qitemId: row.qitemId, actorSession: "worker@rig", state: "blocked", blockedOn: blocker.qitemId, wakeAfterSeconds: 600 });
|
||||
expect(view(row.qitemId).nextBackstop.mechanism).toMatch(/^watchdog:/);
|
||||
});
|
||||
|
||||
it("#2739: a claimed row parked with no wake reads as having no timed backstop, and a later wake on the same blocker arms one it shows", async () => {
|
||||
const row = await queue.create({ sourceSession: "owner@rig", destinationSession: "worker@rig", body: "park-without-wake repro", nudge: false });
|
||||
await queue.claim({ qitemId: row.qitemId, destinationSession: "worker@rig" });
|
||||
|
||||
@@ -95,74 +95,6 @@ describe("start-up wake recovery and seats with nothing to receive a wake", () =
|
||||
expect(t.state(b!)).toBe("delivered");
|
||||
});
|
||||
|
||||
// #2939: a running, bound terminal seat (a shell or a TUI, not an agent) gets nothing typed into it, so every retry
|
||||
// would fail the same way (failed:terminal-seat) and only move the row's last nudge time, which the stuck sweep reads
|
||||
// as new evidence. Its failed wake waits, like a stopped seat's, and the row stays owed and untouched.
|
||||
function terminalSeat(t: ReturnType<typeof team>, runtime = "terminal") {
|
||||
t.db.prepare("INSERT INTO nodes (id, rig_id, logical_id, runtime) VALUES ('node-t', 'rig-1', 'dev.t', ?)").run(runtime);
|
||||
t.db.prepare("INSERT INTO sessions (id, node_id, session_name, status) VALUES ('sess-t', 'node-t', 'dev-t@rig1', 'running')").run();
|
||||
t.db.prepare("INSERT INTO bindings (id, node_id, attachment_type, tmux_session) VALUES ('binding-t', 'node-t', 'tmux', 'dev-t@rig1')").run();
|
||||
t.live.add("dev-t@rig1");
|
||||
}
|
||||
|
||||
it("doesn't replay a failed wake to a running, bound terminal seat, nor move its row's evidence (#2939)", async () => {
|
||||
const t = team();
|
||||
terminalSeat(t);
|
||||
t.repo.attachTransport({ send: t.send });
|
||||
const id = (await t.repo.create({ sourceSession: "lead@rig1", destinationSession: "dev-t@rig1", body: "work" })).qitemId;
|
||||
await vi.waitFor(() => expect(t.state(id)).toBe("failed"));
|
||||
expect(t.repo.getById(id)!.lastNudgeResult).toMatch(/^failed:terminal-seat: 'dev-t@rig1' is a terminal seat/);
|
||||
// an earlier attempt, so a retry in the same millisecond can't hide behind an equal timestamp
|
||||
t.db.prepare("UPDATE queue_items SET last_nudge_attempt = '2026-01-01T00:00:00.000Z' WHERE qitem_id = ?").run(id);
|
||||
const before = { row: t.repo.getById(id)!, transitions: t.transitions(id) };
|
||||
t.send.mockClear();
|
||||
|
||||
const tally = await t.repo.drainPendingWakeIntents();
|
||||
|
||||
expect(t.send).not.toHaveBeenCalled();
|
||||
expect(tally).toEqual({ delivered: 0, indeterminate: 0, failed: 0, retained: 0,
|
||||
waiting: { notifications: 1, seats: ["dev-t@rig1"] } });
|
||||
expect(t.state(id)).toBe("failed");
|
||||
const after = t.repo.getById(id)!;
|
||||
expect({ attempt: after.lastNudgeAttempt, result: after.lastNudgeResult, state: after.state, transitions: t.transitions(id) })
|
||||
.toEqual({ attempt: before.row.lastNudgeAttempt, result: before.row.lastNudgeResult, state: "pending", transitions: before.transitions });
|
||||
});
|
||||
|
||||
it("doesn't replay a failed wake to a terminal seat addressed by a canonical name whose session runs under another (#2939)", async () => {
|
||||
const t = team();
|
||||
// dev-u@rig1 ran once under its own name; the terminal node then reconnected under the physical name u-phys.
|
||||
t.db.prepare("INSERT INTO nodes (id, rig_id, logical_id, runtime) VALUES ('node-u', 'rig-1', 'dev.u', 'terminal')").run();
|
||||
t.db.prepare("INSERT INTO sessions (id, node_id, session_name, status) VALUES ('sess-u1', 'node-u', 'dev-u@rig1', 'exited')").run();
|
||||
t.db.prepare("INSERT INTO sessions (id, node_id, session_name, status) VALUES ('sess-u2', 'node-u', 'u-phys', 'running')").run();
|
||||
t.db.prepare("INSERT INTO bindings (id, node_id, attachment_type, tmux_session) VALUES ('binding-u', 'node-u', 'tmux', 'u-phys')").run();
|
||||
expect(physicalSessionName(t.db, "dev-u@rig1")).toBe("u-phys");
|
||||
const [u] = await owe(t, ["dev-u@rig1"]);
|
||||
t.db.prepare("UPDATE queue_items SET last_nudge_attempt = '2026-01-01T00:00:00.000Z' WHERE qitem_id = ?").run(u!);
|
||||
const before = { attempt: t.repo.getById(u!)!.lastNudgeAttempt, transitions: t.transitions(u!) };
|
||||
t.live.add("dev-u@rig1");
|
||||
|
||||
const tally = await t.repo.drainPendingWakeIntents();
|
||||
|
||||
expect(t.send).not.toHaveBeenCalled();
|
||||
expect(tally).toEqual({ delivered: 0, indeterminate: 0, failed: 0, retained: 0,
|
||||
waiting: { notifications: 1, seats: ["dev-u@rig1"] } });
|
||||
expect({ attempt: t.repo.getById(u!)!.lastNudgeAttempt, transitions: t.transitions(u!) }).toEqual(before);
|
||||
});
|
||||
|
||||
it("replays the failed wake once the seat is an agent again (#2939)", async () => {
|
||||
const t = team();
|
||||
terminalSeat(t);
|
||||
t.repo.attachTransport({ send: t.send });
|
||||
const id = (await t.repo.create({ sourceSession: "lead@rig1", destinationSession: "dev-t@rig1", body: "work" })).qitemId;
|
||||
await vi.waitFor(() => expect(t.state(id)).toBe("failed"));
|
||||
t.db.prepare("UPDATE nodes SET runtime = 'claude-code' WHERE id = 'node-t'").run();
|
||||
t.send.mockClear();
|
||||
const tally = await t.repo.drainPendingWakeIntents();
|
||||
expect(t.send.mock.calls.map(([session]) => session)).toEqual(["dev-t@rig1"]);
|
||||
expect(tally).toEqual({ delivered: 1, indeterminate: 0, failed: 0, retained: 0 });
|
||||
expect(t.state(id)).toBe("delivered");
|
||||
});
|
||||
|
||||
it("still gives a never-tried wake to a stopped seat its one attempt, so its row records why it didn't land", async () => {
|
||||
const t = team();
|
||||
const [b] = [(await t.repo.create({ sourceSession: "lead@rig1", destinationSession: "dev-b@rig1", body: "work" })).qitemId];
|
||||
@@ -246,6 +178,6 @@ describe("start-up wake recovery and seats with nothing to receive a wake", () =
|
||||
await vi.waitFor(() => expect(log.log).toHaveBeenCalled());
|
||||
await recovery.stop();
|
||||
expect(log.log).toHaveBeenCalledWith(
|
||||
"wake-intent recovery sweep: delivered=0 indeterminate=0 failed=0 reconciled=0 waiting=3 (not replayed: no receiver (no session or transport, or a terminal seat) for dev-b@rig1, dev-c@rig1)");
|
||||
"wake-intent recovery sweep: delivered=0 indeterminate=0 failed=0 reconciled=0 waiting=3 (not replayed: no session or transport for dev-b@rig1, dev-c@rig1)");
|
||||
});
|
||||
});
|
||||
@@ -17,8 +17,7 @@ import { externalCliAttachmentSchema } from "../src/db/migrations/019_external_c
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
import { TranscriptStore } from "../src/domain/transcript-store.js";
|
||||
import { WhoamiService, ROSTER_BUILDING_NOTE } from "../src/domain/whoami-service.js";
|
||||
import { markRigBuilding, unmarkRigBuilding } from "../src/domain/rigs-being-built.js";
|
||||
import { WhoamiService } from "../src/domain/whoami-service.js";
|
||||
import { createFullTestDb } from "./helpers/test-app.js";
|
||||
|
||||
function setupDb(): Database.Database {
|
||||
@@ -144,25 +143,6 @@ describe("WhoamiService", () => {
|
||||
expect(result["podRoster"]).toBeUndefined();
|
||||
});
|
||||
|
||||
// #2871: the building state belongs to the rig being built, by id, never to another rig with the same name.
|
||||
it("only the rig being built says so; a running rig with the same name answers as before", () => {
|
||||
const { nodeA } = seedRig();
|
||||
const newRig = rigRepo.createRig("my-rig");
|
||||
const newNode = rigRepo.addNode(newRig.id, "dev.impl", { role: "worker", runtime: "claude-code" });
|
||||
markRigBuilding(db, newRig.id);
|
||||
|
||||
const running = svc.resolve({ nodeId: nodeA.id })!;
|
||||
expect(running).not.toHaveProperty("rosterState");
|
||||
expect(running.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");
|
||||
|
||||
const building = svc.resolve({ nodeId: newNode.id })!;
|
||||
expect(building.rosterState).toBe("building");
|
||||
expect(building.peersNote.startsWith(ROSTER_BUILDING_NOTE)).toBe(true);
|
||||
|
||||
unmarkRigBuilding(db, newRig.id);
|
||||
expect(svc.resolve({ nodeId: newNode.id })).not.toHaveProperty("rosterState");
|
||||
});
|
||||
|
||||
it("unknown nodeId returns null", () => {
|
||||
seedRig();
|
||||
const result = svc.resolve({ nodeId: "nonexistent" });
|
||||
|
||||
@@ -471,8 +471,7 @@ async function run(): Promise<void> {
|
||||
const actor = actingAs(snapshot.attentionRead, action.actorSession);
|
||||
view.dispatch({ type: "notice", message: action.verb === "resolve" ? `Resolved ${action.qitemId} as ${actor}: it's unparked for its owner, with your decision.`
|
||||
: action.verb === "approve"
|
||||
// A runner's permission request says what became of the answer: handed to its receiving agent, or kept.
|
||||
? result.answer?.message ?? `Approved ${action.qitemId} as ${actor}: the request is closed on its own row.`
|
||||
? `Approved ${action.qitemId} as ${actor}: the request is closed on its own row.`
|
||||
: result.answer?.message ?? (result.answer?.outcome === "recorded" ? `Recorded on ${action.qitemId}. Still to answer: ${result.answer.unanswered.join("; ")}`
|
||||
: `Answered ${action.qitemId} as ${actor}: the answer went back to the asker, and the request is closed.`) });
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { describe, it } from "vitest";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { createServer } from "node:http";
|
||||
import { DaemonClient } from "../src/daemon-client.js";
|
||||
import { subscribeActivityEvents } from "../src/live-events.js";
|
||||
|
||||
function delay(ms: number): Promise<void> { return new Promise(resolve => setTimeout(resolve, ms)); }
|
||||
|
||||
describe.concurrent("activity stream header deadline", () => {
|
||||
it("rejects a reachable endpoint that never returns response headers", async ({ expect }) => {
|
||||
describe("activity stream header deadline", () => {
|
||||
it("rejects a reachable endpoint that never returns response headers", async () => {
|
||||
let closed = false;
|
||||
const server = createServer((_req, res) => { res.on("close", () => { closed = true; }); });
|
||||
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
|
||||
@@ -23,7 +23,7 @@ describe.concurrent("activity stream header deadline", () => {
|
||||
} finally { if (guard) clearTimeout(guard); server.closeAllConnections(); server.close(); }
|
||||
}, 10_000);
|
||||
|
||||
it("keeps an established SSE stream alive beyond the header deadline", async ({ expect }) => {
|
||||
it("keeps an established SSE stream alive beyond the header deadline", async () => {
|
||||
let closed = false;
|
||||
let writeLater!: () => void;
|
||||
const server = createServer((_req, res) => {
|
||||
@@ -50,8 +50,8 @@ describe.concurrent("activity stream header deadline", () => {
|
||||
});
|
||||
|
||||
|
||||
describe.concurrent("temporary activity stream opening failures", () => {
|
||||
it("reconnects after a slow daemon exceeds the header deadline and delivers its next event", async ({ expect }) => {
|
||||
describe("temporary activity stream opening failures", () => {
|
||||
it("reconnects after a slow daemon exceeds the header deadline and delivers its next event", async () => {
|
||||
let calls = 0;
|
||||
const late = new Set<ReturnType<typeof setTimeout>>();
|
||||
const server = createServer((_req, res) => {
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
// `rig config` and the daemon's settings store are one contract in two files: the CLI
|
||||
// writes ~/.openrig/config.json, the daemon reads it, and the daemon store's own header
|
||||
// says the two key lists are "kept in lockstep". Each list is pinned by its own package
|
||||
// test, so nothing ever compared them — and two keys had already drifted:
|
||||
// ui.terminal.max_live_terminals and retention.usage_samples_days were resolved by the
|
||||
// daemon store and served by `GET /api/config`, while `rig config get|set` rejected both
|
||||
// as "Unknown config key".
|
||||
//
|
||||
// This guard reads both lists as text, because packages/cli cannot import packages/daemon
|
||||
// (neither package depends on the other — stated in source in three places). A future
|
||||
// difference that is genuinely intended for one side only goes in
|
||||
// INTENTIONAL_DIFFERENCES with the reason beside it; an empty list means the twins agree
|
||||
// exactly.
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const CLI_STORE = "packages/cli/src/config-store.ts";
|
||||
const DAEMON_STORE = "packages/daemon/src/domain/user-settings/settings-store.ts";
|
||||
|
||||
/** Keys a side may hold alone. Empty: the settable surfaces agree exactly. */
|
||||
const INTENTIONAL_DIFFERENCES = [];
|
||||
|
||||
function listOf(file, name) {
|
||||
const lines = readFileSync(file, "utf8").split(/\r?\n/);
|
||||
const start = lines.findIndex((line) => line.startsWith(`export const ${name} = [`));
|
||||
assert.notEqual(start, -1, `${file}: could not find "export const ${name} = ["`);
|
||||
const keys = [];
|
||||
for (let i = start + 1; i < lines.length; i += 1) {
|
||||
const line = lines[i];
|
||||
if (/^\] as const;/.test(line.trim())) return keys;
|
||||
const entry = line.match(/^\s*"([^"]+)",?\s*(?:\/\/.*)?$/);
|
||||
if (entry) keys.push(entry[1]);
|
||||
}
|
||||
throw new Error(`${file}: "${name}" was never closed with "] as const;"`);
|
||||
}
|
||||
|
||||
test("every key the daemon accepts is settable through rig config", () => {
|
||||
const cli = listOf(CLI_STORE, "VALID_KEYS");
|
||||
const daemon = listOf(DAEMON_STORE, "SETTINGS_VALID_KEYS");
|
||||
const rejectedByCli = daemon.filter((key) => !cli.includes(key) && !INTENTIONAL_DIFFERENCES.includes(key));
|
||||
assert.deepEqual(
|
||||
rejectedByCli,
|
||||
[],
|
||||
`the daemon resolves these keys and rig config rejects them: ${rejectedByCli.join(", ")}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("every key rig config accepts is one the daemon resolves", () => {
|
||||
const cli = listOf(CLI_STORE, "VALID_KEYS");
|
||||
const daemon = listOf(DAEMON_STORE, "SETTINGS_VALID_KEYS");
|
||||
const unknownToDaemon = cli.filter((key) => !daemon.includes(key) && !INTENTIONAL_DIFFERENCES.includes(key));
|
||||
assert.deepEqual(
|
||||
unknownToDaemon,
|
||||
[],
|
||||
`rig config accepts these keys and the daemon does not resolve them: ${unknownToDaemon.join(", ")}`,
|
||||
);
|
||||
});
|
||||
@@ -146,8 +146,7 @@ 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 that name nobody go to them (a pairing request or an ask can
|
||||
name someone else), and don't offer again.
|
||||
- A default person is there: say requests and asks go to them, 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,
|
||||
|
||||
Reference in new issue
Block a user