Compare commits

..
Author SHA1 Message Date
James B. Shields 5f72f3934c fix(daemon): scope qualified slice reads and proof discovery by mission (#2960)
For #2487. A mission-qualified slice read applies the mission filter before same-name deduplication, so same-named slices in two missions each resolve to their own documents and proof, and proof ownership applies only to a mission-scoped read. An evidence root that is missing, dangling, outside the project or unreadable reads as no proof: the slice detail returns with no proof packets and a proof asset returns proof_packet_not_found, while an asset escaping a valid evidence root still returns project_path_escape. Unqualified reads are unchanged.
2026-11-11 05:00:38 -08:00
Mike Schwarz 16e8b7fb31 fix(daemon): rig down stops only a terminal it can show is the seat's (#2962)
rig down stops a seat's tmux session only when the session carries that seat's OpenRig marker (OPENRIG_NODE_ID), which OpenRig stamps on every session it creates. The session id and its server's pid and start time are read in one command, and the detach and the kill each run under tmux if-shell only while that id is still the same session on the same server, so a server replaced since the read is left alone. A session without the marker, or with another seat's, is left running with its binding and row unchanged, and rig down names it, gives the manual path on the daemon's own tmux server, and exits 2. This matches rig stop, which already refuses to kill an adopted session. The cost: a seat whose session OpenRig didn't create is no longer stopped automatically by any guarded stop.
2026-11-11 04:56:02 -08:00
Wu Shuwen 2be19e641d fix(queue): recognize custom blocker IDs in waiting views (#2983)
Waiting views recognized only blocker IDs starting with qitem-, so a row blocked on an existing custom ID lost its blocker's owner, state and backstop. Blockers now resolve by actual ID, with external:, auth: and fold: gates interpreted first even when a row shares the name. Listing reads batch the custom-blocker lookups, and a blocker that has already finished reports no timed backstop. Tests cover custom IDs, typed-gate collisions and finished blockers.
2026-11-11 04:55:04 -08:00
Mike Schwarz 01b80517a9 docs: say how herdr-app cells and remote tiles attach now (#2981)
Follow-up to #2956. herdr-app.md says a local interactive cell shows the seat read-only until its confirmation gate opens and then its writable tile attaches with plain tmux; the gate, dropped input and timeout apply to local interactive cells, and a remote SSH cell takes input as soon as it runs. terminal-sessions.md says isolated remote tiles attach through rig terminal attach and that printed commands can carry the server option, and tmux-servers.md names native Windows, since WSL2 is supported. herdr-app-provider.ts's comment matches.
2026-11-11 04:40:51 -08:00
ziadeh a95065c0da docs(kernel): say that a daemon start after a reboot restores the kernel (#2965)
The kernel's CULTURE.md and the summary in its rig.yaml said the operator pod owns restarting agents after a host reboot. They now say what the daemon does: the next rig daemon start attempts to restore the kernel when startup 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. The rule for other rigs is unchanged.
2026-11-11 04:38:41 -08:00
Dery Ferdika 81d80a1f2b fix(config): accept the two keys the daemon already resolves (#2969)
rig config get and set accept ui.terminal.max_live_terminals and retention.usage_samples_days, which the daemon's settings store already resolves but the CLI's key list was missing. retention.usage_samples_days defaults to 14 and ui.terminal.max_live_terminals reports 2, the web client's own fallback. Both honour their OPENRIG_ environment overrides, appear in rig config --help, and are carried in rig config --json. A repository test compares the daemon's and the CLI's key lists and fails when either gains a key the other lacks.
2026-11-11 04:31:54 -08:00
Mike Schwarz 8f1dbb8b05 fix(daemon): compare a Codex working counter only within one pane process (#2980)
A Codex seat's working counter and proof are compared with the previous capture only when both name the same pane and process, or when either names none. A capture from a different pane or process is a first sighting, so an in-place Codex restart doesn't inherit its predecessor's proof or read a cross-process counter as an advance, and the new process's own next advance still reads working. The activity-changed notice still compares with the true previous capture. Limits: a capture that names no pane keeps the old comparison, and a restart that keeps both the pane and its first process isn't seen. Follow-up to #2952.
2026-11-11 04:28:16 -08:00
Samuel Smith d9292d5b04 test: overlap independent real-time wait cases (#2959)
Test only. The CLI desktop-wake-loop, daemon refocus-budget-concurrency and TUI activity-stream-deadline tests give each case its own fixtures and cleanup, then run independent real-time waits concurrently. All 29 cases, their assertions and their deadline bounds are kept. Focused runs of these files finish faster; the author measured no whole-suite time or compute change. No production, worker-count, workflow or dependency change.
2026-11-11 02:29:01 -08:00
Mike Schwarz 9d096d2145 docs: how to send a runner its /abort, and what that does and doesn't show (#2968)
Text only. The runner contract's section 6 now says rig send <seat> "/abort" --raw pastes exactly /abort with no envelope and presses Enter, so a runner that follows the contract gets its control; the send is refused while the seat needs input, and then the person types /abort in the pane. A typed control can't name the turn it was meant for, so the result is a request, and cancelled shows only on the runner's own Stop or cancelled. The presets page's Cancel row names the raw form for runner presets, says /abort is ordinary input to a plain preset, and asks each preset's limits to say whether the harness has a cancel. A raw send hasn't been run natively.
2026-11-11 02:18:44 -08:00
Mike Schwarz c818bfe1a5 fix(daemon): a runner answer's failed handoff rolls back whole before the answer is kept (#2966)
Follow-up to #2934's runner permission answers (#2950). EventBus.discardOnThrow runs its writer in its own savepoint, so a handoff that throws anywhere, including after its own transaction released, rolls back whole before keepRunnerAnswer runs: the request stays pending, no row reaches the receiving agent, the person's words are kept and nothing is permitted. With no active notification envelope and no open transaction (the Slack direct-answer path), it runs in a notify envelope of its own, so subscribers hear the handoff only after it commits and nothing on a throw. Inside an existing envelope (mission control), the savepoint and token cleanup are unchanged, with no nested envelope. Success notifications are delivered once, after commit.
2026-11-11 02:13:45 -08:00
Mike SchwarzandKM199 a58db3ac33 fix(activity): observe a Codex choice prompt being answered, so a held wake resumes without escalating (finishes #2515) (#2952)
Finishes #2515 by KM199, who found the gap and shaped the observation, rewritten smaller on today's main. A Codex choice prompt reads needs-input from the seat's structural frame, under #2500's boundary, and a prompt that clears is observed so a held wake resumes without escalating. Each pane capture's marker line names the pane id and pid from the same tmux call, so prompt evidence is bound to the pane and process it was read from and expires where it's read. The occupant comes from the identity reconciler's verified verdict: an in-place Codex restart is followed within one 5 s pass, and a replacement before the first frame is refused. An open selector or draft is never cleared, and tracked approvals keep their path.

Co-authored-by: KM199 <[email protected]>
2026-11-11 02:11:25 -08:00
Mike Schwarz 9887add016 docs: correct how terminal tiles attach, who has a pane, and three stale counts (#2956)
Text only. terminal-sessions.md's sizing paragraph and herdr-app.md described the earlier type-to-attach tile; both now say a writable tile attaches with plain tmux when it opens, and only a read-only tile shows the seat without attaching. The README no longer says every agent has a tmux session, since hosted members get no pane. The transport architecture page's MCP tool count and its command are corrected, cli-reference.md's verification stamp points at its frontmatter, and tmux-servers.md says dedicated selection needs POSIX tmux.
2026-11-11 02:08:22 -08:00
Mike Schwarz 382b3b036a fix(daemon): a hosted member's ring that times out reads unknown and isn't rung again (#2957)
A hosted member's wake ring that reaches its 10 s timeout now reads indeterminate ("may have arrived") instead of failed. hostedRingMayHaveArrived tags the intent the way an accepted ring is tagged, so recovery's existing check finalizes it without a second ring, because each ring can start a run on the member's side. An unset or invalid URL, a missing Authorization header, a redirect, an error status and a refused connection all stay failed and can be retried. The stuck sweep's existing person notice still surfaces a ring that never arrived. Hosted members stay experimental and off by default.
2026-11-11 02:04:31 -08:00
57 changed files with 2503 additions and 678 deletions

No files matched your search

+1 -1
View File
@@ -328,7 +328,7 @@ OpenRig is a multi-agent harness — it manages the system that coding agents fo
- **Connect** Slack through an app you create in your own workspace; the experimental `rig slack manifest` prints that app's manifest ([setup guide](docs/reference/slack-app-setup.md))
- **Evolve** running topologies with `rig grow`, `rig shrink`, `rig launch`, `rig remove`
Every agent runs in a tmux session you can attach to, inspect, and work with directly.
Every terminal seat runs in a tmux session you can attach to, inspect, and work with directly. [Hosted members](docs/reference/hosted-members.md) join through OpenRig's MCP server and have no pane.
## Teams
@@ -32,13 +32,12 @@ Two independent naming axes meet in this layer. They must not be conflated or
blanket-replaced: `rigged` → `rig` is a per-occurrence question, not a global
substitution.
**Axis 1 — MCP tool names are `rig_*`.** `packages/cli/src/mcp-server.ts`
registers **24** tools, all named `rig_*`
(`grep -c 'server.tool(' packages/cli/src/mcp-server.ts`), including
`rig_rig_nodes` (`mcp-server.ts:290`), `rig_send` (`:307`),
`rig_capture` (`:341`), `rig_chatroom_send` (`:366`) and `rig_chatroom_watch`
(`:402`), the five queue tools `rig_queue_create`, `_show`, `_list`, `_claim`
and `_update`, and `rig_telemetry_events`. No `rigged_*` name other than the tmux keys below remains in product
**Axis 1 — MCP tool names are `rig_*`.** Every tool
`packages/cli/src/mcp-server.ts` registers is named `rig_*`, among them
`rig_rig_nodes`, `rig_send`, `rig_capture`, `rig_chatroom_send`,
`rig_chatroom_watch`, the `rig_queue_*` tools and `rig_telemetry_events`.
Count them with `grep -c 'server.registerTool(' packages/cli/src/mcp-server.ts`;
each registration depends on the caller's access, so a caller can see fewer. No `rigged_*` name other than the tmux keys below remains in product
source: **0** hits
(`git grep -n 'rigged_' -- ':(glob)packages/*/src/**' | grep -v -c '@rigged_'`).
+2 -1
View File
@@ -15,7 +15,8 @@ last-updated: 2026-11-09
# CLI Reference — Registered Commands and Options
Verified against source commit `e1dedbf531b6db9961b4c77c0a74564e58274a50`.
Verified against the source commit in this page's `last-verified-against-source`
frontmatter.
The inventory below comes from the actual Commander tree returned by
[`createProgram()`](../../packages/cli/src/index.ts), not a grep of command
strings or an installed CLI from a different commit.
+1 -1
View File
@@ -21,7 +21,7 @@ it never reads an unsupported capability as idle, working, ready or verified.
| Activity | partly | Read from the screen only; there are no activity hooks. Work and attention the screen shows are kept, but a screen that looks idle reads `unknown`, never idle, because a quiet prompt can be an approval or a pause. A [runner preset](#runner-presets-runner-true)'s own reports decide it instead. |
| Attention | partly | Read from the screen: a permission question or numbered choice that OpenRig recognizes reads as needing attention, and `rig send` won't type into it. A prompt it doesn't recognize reads `unknown`. |
| Approval | no | Approvals stay in the harness's own pane. OpenRig relays none, so a person answers them there. A [runner preset](#runner-presets-runner-true) is the exception: its runner submits the agent's permission requests to OpenRig, which asks a person, and the default is permit (section 7 of the [harness and runner contract](harness-runner-contract.md)). |
| Cancel | no | OpenRig has no verb that cancels a running turn. `rig seat stop` and `rig down` end the seat's tmux session; anything the harness runs outside its pane, such as a gateway, is its own, and the preset's `limits` should say what keeps running. Restarting in place and switching harness are refused. |
| Cancel | no | OpenRig has no verb that cancels a running turn. A [runner preset](#runner-presets-runner-true) whose runner follows the contract takes `rig send <seat> "/abort" --raw` as its cancel, but not while the seat needs input, and only as a request (see [Cancel](harness-runner-contract.md#6-cancel-requested-isnt-the-same-as-done)). To a plain preset, `/abort` is ordinary input. Say in the preset's `limits` whether the harness has a cancel and how to use it. `rig seat stop` and `rig down` end the seat's tmux session; anything the harness runs outside its pane, such as a gateway, is its own, and the preset's `limits` should say what keeps running. Restarting in place and switching harness are refused. |
| Transport | yes | The seat is a tmux pane like any other, so `rig capture`, `rig send` and `--host` work as they do for any seat. |
**Not placed:** guidance, skills and startup files (a launch warns that they weren't delivered), and the model: set it in the harness's own
+9 -2
View File
@@ -241,8 +241,15 @@ permission or input handling in existing built-in runtimes or require every poss
- **On `/abort` or Ctrl-C:** send the agent's cancel (for ACP, the `session/cancel` notification, which has no reply),
and record when you sent it.
- A person cancels by typing `/abort` in the seat's pane. A `/abort` sent with `rig send` arrives inside a From/To
envelope, so it's an ordinary prompt.
- **Sending it:** a person types `/abort` in the seat's pane. From another seat or a script,
`rig send <seat> "/abort" --raw` pastes exactly `/abort`, with no From/To envelope, and presses Enter, so a runner
that follows section 5 gets the control. 0.7.8's native runs typed it in the pane; a raw send hasn't been run
natively. An ordinary `rig send` wraps its text in a From/To envelope, so there `/abort` is an ordinary prompt.
- **Not while the seat needs input:** a raw send is still refused while the seat reads needs input
(`target_needs_input`), for example while the runner holds a permission request for a person (section 7). Type
`/abort` in the pane then.
- **A sent `/abort` is a request.** A typed control can't name the turn it was meant for: the runner cancels whatever
turn is running when it reads it. Whether a turn was cancelled is the runner's `Stop` / `cancelled` below.
- **Report "cancelled" only when the agent says so:** for ACP, when `session/prompt` returns `stopReason: "cancelled"`.
Then post `Stop` / `cancelled`.
- **Whether the provider stopped work or spend is unknown.** Don't claim it.
+16 -13
View File
@@ -54,22 +54,22 @@ What an open does, in order:
1. Each viewer is recorded first (`$OPENRIG_HOME/private/herdr-app/clients/`), then started by herdr-core outside
every tab (`hc run --bg --no-parent`, labelled `openrig.*`). A local viewer runs the view's tile for its seat, as in
[terminal sessions](terminal-sessions.md): it shows the seat. An interactive cell starts with the read-only tile and
takes no input until herdr-app has shown the view (step 3); then its writable tile takes over, and the first thing
you type attaches it. A read-only cell only shows. A seat on another host opens as its own ssh attachment, writable
takes no input until herdr-app has shown the view (step 3); then its writable tile takes over and attaches to the
seat with plain tmux. A read-only cell only shows. A seat on another host opens as its own ssh attachment, writable
or read-only as that view composes it. If the seat's session ends, the viewer says so and waits; it never leaves a
shell in the cell.
2. One atomic write of `layouts/rig.json`: your current layout, with OpenRig's stack beside your tab block. Your own
layout file is never written. A `rig.json` OpenRig didn't write is never overwritten.
3. The Rig layout is shown, the view is selected once herdr-app confirms it shows the new file, and the keyboard
goes to the first live cell. Once herdr-app has acknowledged the layout with the view, OpenRig writes the gate file
of each of the view's interactive cells, on every page (including pages the strip hasn't shown yet, since the strip
moves between pages on its own), and each cell takes input a fraction of a second later (it checks every 0.2 s).
Anything that reached a cell before that, typed or written to its terminal (`hc send`), is dropped, not delivered
late. The open reports a view herdr-app
hasn't confirmed as unconfirmed, not opened; its viewers keep running and it stays in the Rig layout, and its cells
take input once herdr-app shows it. OpenRig keeps watching for 10 minutes, across a daemon restart; if herdr-app
hasn't shown it by then, each interactive cell says so, takes no input, and names the command that opens the view
again (`rig terminal open <view> --provider herdr-app`).
of each local interactive cell, on every page (including pages the strip hasn't shown yet, since the strip moves
between pages on its own). These cells take input a fraction of a second later (they check every 0.2 s). Anything
that reached a local interactive cell before that, typed or written to its terminal (`hc send`), is dropped, not
delivered late. Remote cells run their ssh attachments from the start and don't wait for this gate. The open
reports a view herdr-app hasn't confirmed as unconfirmed, not opened; its viewers keep running and it stays in the
Rig layout, and its local interactive cells take input once herdr-app shows it. OpenRig keeps watching for 10
minutes, across a daemon restart; if herdr-app hasn't shown it by then, each local interactive cell says so, takes
no input, and names the command that opens the view again (`rig terminal open <view> --provider herdr-app`).
Other layouts and other herdr-app windows don't show Rig views. Each window (app home) has its own.
@@ -81,9 +81,12 @@ 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.
- 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.
- Once the view is shown, an interactive cell for a local seat attaches its tile to the seat with plain tmux, as a
writable tile does ([terminal sessions](terminal-sessions.md#replacing-a-seats-session)). Until then it only shows
the seat, and anything typed in it is dropped. A cell for a seat on another host is its ssh attachment from the
start, so an interactive one takes input as soon as it runs. When the seat's session is replaced (a handover, a fresh
launch, a restart), the tile follows the same seat to its new session, except an ssh tile on an isolated local tmux
server ([terminal sessions](terminal-sessions.md#replacing-a-seats-session)).
## What OpenRig keeps in the app home
+24 -15
View File
@@ -74,7 +74,11 @@ New tiles keep their place when a seat's tmux session is replaced. They wait up
to 30 seconds for the same session name and managed seat identity to return,
then show the new session with the original read-only setting. This uses the
shared tile command for Herdr, cmux, Rex and plain tmux, including SSH tiles.
In-place handover and restart keep showing the same session.
In-place handover and restart keep showing the same session. The exception is
an SSH tile when the local tmux server is isolated: it runs
`rig terminal attach` on the seat's host (`--read-only` for a read-only tile),
which attaches with tmux once (`tmux attach -r` for a read-only tile). It
doesn't follow a replaced session or a restarted tmux server; reopen the view.
Writable tiles attach immediately, as in 0.7.10. Their visible terminal sizes
the agent's PTY under tmux's normal client sizing policy, and resizing the
@@ -362,20 +366,25 @@ labels are not that provenance. Other providers/platforms remain unsupported
for ensure-viewer until qualified; ordinary open retains its existing behavior.
A tmux window has one size, and a tmux client narrower than it sees only the
left part of each line. So a tile shows its seat without attaching to it: about
once a second it reads the seat's screen with `tmux capture-pane`, redraws only
after tmux records new activity, and lets the tile's own terminal wrap each
line. A tile narrower than the agent's window still shows whole lines, and
showing a seat never changes its size. A rule drawn across the window is cut to
the tile's width. A read-only tile only shows the seat. In a writable tile, the
first thing you type or paste attaches the tile and goes through tmux as it
would in any attached client: a paste arrives as one paste, and tmux's prefix
works (prefix then `[` to scroll back, for example). The tile sizes the window
while you use it, as any client you type in does; after 30 seconds without a
key it detaches and shows the seat again. A tile that's only showing has no
scrollback of its own. If the seat's tmux server restarts, a tile shows the
seat again only when the same managed seat returns. The printed manual attach commands
keep `-f ignore-size`. Observers of a Herdr terminal do not themselves resize
left part of each line. So a read-only tile shows its seat without attaching to
it: about once a second it reads the seat's screen with `tmux capture-pane`,
redraws only after tmux records new activity, and lets the tile's own terminal
wrap each line. A read-only tile narrower than the agent's window still shows
whole lines, and showing a seat never changes its size. A rule drawn across the
window is cut to the tile's width. A read-only tile has no scrollback of its
own. (A read-only SSH tile on an isolated local server attaches read-only
instead; see [Replacing a seat's session](#replacing-a-seats-session).) A
writable tile attaches with plain tmux when it opens (see
[Replacing a seat's session](#replacing-a-seats-session)): keys and paste go
through tmux as in any attached client, tmux's prefix works (prefix then `[` to
scroll back, for example), and the tile sizes the window while it's the active
client. If the seat's tmux server restarts, a tile shows the seat again only
when the same managed seat returns. The printed manual attach commands are
`tmux attach -t` for a writable tile and `tmux attach -r -t` for a read-only
one, with a local seat's tmux server (`-L <name>` or `-S <path>`) when it
isn't tmux's default socket. For a seat on another host they run over `ssh -t`, and
when the local tmux server is isolated, as `rig terminal attach` on that host.
Observers of a Herdr terminal do not themselves resize
it; a controlling browser can. No cross-version resize guarantee is implied.
To type into a seat without changing its size, send the text through OpenRig
+2 -2
View File
@@ -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 Windows, psmux retains its existing
server behavior; requesting `isolated` produces a status note and a pending choice.
Dedicated selection requires POSIX tmux. On native Windows (not a supported platform;
the [README](../../README.md) covers WSL2), psmux retains its existing server behavior; requesting `isolated` produces a status note and a pending choice.
## Two instances on one machine
+3 -1
View File
@@ -79,6 +79,8 @@ Keys:
ui.timezone TUI IANA timezone (default America/Los_Angeles; reopen TUI after changing)
ui.enabled serve the web UI and its terminal WebSocket (default off; restart the daemon)
ui.preview.* refresh_interval_seconds, max_pins, default_lines
ui.terminal.max_live_terminals
cap on simultaneously-live web-client terminals (effective default 2)
recovery.* auto_drive_provider_prompts, provider_auth_env_allowlist
agents.* advisor_session, operator_session
feed.subscriptions.* action_required, approvals, shipped, progress, audit_log
@@ -101,7 +103,7 @@ Keys:
permission prompts still reach a person,
choice prompts follow this setting; unavailable human delivery stays a visible row;
an unreadable registry does not suppress eligibility; delivery still needs a ready route)
retention.* enabled, transitions_days, watchdog_days,
retention.* enabled, transitions_days, watchdog_days, usage_samples_days,
watchdog_keep_per_job, batch_size
retention.events_days delete the history-only event types (views, snapshots, watchdog firings, queue events) older
than N days; 0 keeps them (default). Removed events no longer replay from /api/events, leave
+26
View File
@@ -84,6 +84,7 @@ export interface RiggedConfig {
/** Serve the web UI and its terminal WebSocket. Off by default; the daemon reads it at start. */
enabled: boolean;
timezone: string;
terminal: { maxLiveTerminals: number };
preview: {
refreshIntervalSeconds: number;
maxPins: number;
@@ -218,6 +219,7 @@ export interface RiggedConfig {
enabled: boolean;
transitionsDays: number;
watchdogDays: number;
usageSamplesDays: number;
watchdogKeepPerJob: number;
streamDays: number;
streamMaxItems: number;
@@ -306,6 +308,10 @@ const DEFAULTS = {
ui: {
enabled: false,
timezone: "America/Los_Angeles",
// The daemon store leaves its own resolver value unset for this key and the web
// client falls back to 2 (settings-browser names that fallback), so the CLI reports
// the same effective default rather than inventing a third answer.
terminal: { maxLiveTerminals: 2 },
preview: {
refreshIntervalSeconds: 3,
maxPins: 4,
@@ -416,6 +422,8 @@ const DEFAULTS = {
enabled: true,
transitionsDays: 30,
watchdogDays: 14,
// Usage samples age out after this many days; the daemon's own default.
usageSamplesDays: 14,
watchdogKeepPerJob: 50,
streamDays: 30,
streamMaxItems: 20000,
@@ -499,6 +507,9 @@ export const VALID_KEYS = [
"ui.preview.default_lines",
"ui.timezone",
"ui.enabled",
// The legacy web client's cap on simultaneously-live terminals. The daemon store
// resolves it (SETTINGS_VALID_KEYS), so `rig config` must accept it too.
"ui.terminal.max_live_terminals",
"recovery.auto_drive_provider_prompts",
"recovery.provider_auth_env_allowlist",
// V1 Phase 4 SC-29 exception — allowlist-only additions.
@@ -568,6 +579,8 @@ export const VALID_KEYS = [
"retention.enabled",
"retention.transitions_days",
"retention.watchdog_days",
// How long the daemon keeps usage samples; lockstep with the daemon twin.
"retention.usage_samples_days",
"retention.watchdog_keep_per_job",
// Slice 22 outcome 9 — stream retention: unreferenced items older than N days or beyond the newest N.
"retention.stream_days",
@@ -643,6 +656,7 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
"ui.preview.max_pins": { primary: "OPENRIG_UI_PREVIEW_MAX_PINS" },
"ui.timezone": { primary: "OPENRIG_UI_TIMEZONE" },
"ui.enabled": { primary: "OPENRIG_UI_ENABLED" },
"ui.terminal.max_live_terminals": { primary: "OPENRIG_UI_TERMINAL_MAX_LIVE_TERMINALS" },
"ui.preview.default_lines": { primary: "OPENRIG_UI_PREVIEW_DEFAULT_LINES" },
"recovery.auto_drive_provider_prompts": { primary: "OPENRIG_RECOVERY_AUTO_DRIVE_PROVIDER_PROMPTS" },
"recovery.provider_auth_env_allowlist": { primary: "OPENRIG_RECOVERY_PROVIDER_AUTH_ENV_ALLOWLIST" },
@@ -699,6 +713,7 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
"retention.enabled": { primary: "OPENRIG_RETENTION_ENABLED" },
"retention.transitions_days": { primary: "OPENRIG_RETENTION_TRANSITIONS_DAYS" },
"retention.watchdog_days": { primary: "OPENRIG_RETENTION_WATCHDOG_DAYS" },
"retention.usage_samples_days": { primary: "OPENRIG_RETENTION_USAGE_SAMPLES_DAYS" },
"retention.watchdog_keep_per_job": { primary: "OPENRIG_RETENTION_WATCHDOG_KEEP_PER_JOB" },
"retention.stream_days": { primary: "OPENRIG_RETENTION_STREAM_DAYS" },
"retention.stream_max_items": { primary: "OPENRIG_RETENTION_STREAM_MAX_ITEMS" },
@@ -758,6 +773,7 @@ const KEY_TO_PATH: Record<ValidKey, string[]> = {
"ui.preview.max_pins": ["ui", "preview", "maxPins"],
"ui.timezone": ["ui", "timezone"],
"ui.enabled": ["ui", "enabled"],
"ui.terminal.max_live_terminals": ["ui", "terminal", "maxLiveTerminals"],
"ui.preview.default_lines": ["ui", "preview", "defaultLines"],
"recovery.auto_drive_provider_prompts": ["recovery", "autoDriveProviderPrompts"],
"recovery.provider_auth_env_allowlist": ["recovery", "providerAuthEnvAllowlist"],
@@ -808,6 +824,7 @@ const KEY_TO_PATH: Record<ValidKey, string[]> = {
"retention.enabled": ["retention", "enabled"],
"retention.transitions_days": ["retention", "transitionsDays"],
"retention.watchdog_days": ["retention", "watchdogDays"],
"retention.usage_samples_days": ["retention", "usageSamplesDays"],
"retention.watchdog_keep_per_job": ["retention", "watchdogKeepPerJob"],
"retention.stream_days": ["retention", "streamDays"],
"retention.stream_max_items": ["retention", "streamMaxItems"],
@@ -1088,6 +1105,7 @@ const KEY_CONSTRAINTS: Partial<Record<ValidKey, (raw: string, coerced: string |
new Intl.DateTimeFormat("en-US", { timeZone: value });
} catch { throw new Error("Invalid ui.timezone: use an IANA timezone such as America/Los_Angeles or Europe/London"); }
},
"ui.terminal.max_live_terminals": positiveIntegerConstraint("ui.terminal.max_live_terminals"),
"health.context_pressure.warning_percent": percentageConstraint("health.context_pressure.warning_percent"),
"health.context_pressure.critical_percent": percentageConstraint("health.context_pressure.critical_percent"),
"policies.idle_gate_qitem.scan_interval_seconds": positiveIntegerConstraint("policies.idle_gate_qitem.scan_interval_seconds"),
@@ -1174,6 +1192,12 @@ const KEY_CONSTRAINTS: Partial<Record<ValidKey, (raw: string, coerced: string |
throw new Error(`Invalid value for retention.watchdog_days: must be an integer >= 1, got "${raw}"`);
}
},
// Same bound and message as the daemon twin's KEY_CONSTRAINTS entry.
"retention.usage_samples_days": (raw, coerced) => {
if (!/^-?\d+$/.test((raw ?? "").trim()) || typeof coerced !== "number" || !Number.isInteger(coerced) || coerced < 1) {
throw new Error(`Invalid value for retention.usage_samples_days: must be an integer >= 1, got "${raw}"`);
}
},
"retention.watchdog_keep_per_job": (raw, coerced) => {
if (!/^-?\d+$/.test((raw ?? "").trim()) || typeof coerced !== "number" || !Number.isInteger(coerced) || coerced < 0) {
throw new Error(`Invalid value for retention.watchdog_keep_per_job: must be an integer >= 0, got "${raw}"`);
@@ -1397,6 +1421,7 @@ export class ConfigStore {
ui: {
enabled: v("ui.enabled") as boolean,
timezone: v("ui.timezone") as string,
terminal: { maxLiveTerminals: v("ui.terminal.max_live_terminals") as number },
preview: {
refreshIntervalSeconds: v("ui.preview.refresh_interval_seconds") as number,
maxPins: v("ui.preview.max_pins") as number,
@@ -1487,6 +1512,7 @@ export class ConfigStore {
enabled: v("retention.enabled") as boolean,
transitionsDays: v("retention.transitions_days") as number,
watchdogDays: v("retention.watchdog_days") as number,
usageSamplesDays: v("retention.usage_samples_days") as number,
watchdogKeepPerJob: v("retention.watchdog_keep_per_job") as number,
streamDays: v("retention.stream_days") as number,
streamMaxItems: v("retention.stream_max_items") as number,
@@ -149,6 +149,9 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
"files.allowlist", "progress.scan_roots",
"ui.preview.refresh_interval_seconds", "ui.preview.max_pins", "ui.preview.default_lines", "ui.timezone",
"ui.enabled",
// The legacy web client's cap on simultaneously-live terminals; the daemon store
// resolves it, so `rig config` must accept it too.
"ui.terminal.max_live_terminals",
"recovery.auto_drive_provider_prompts",
"recovery.provider_auth_env_allowlist",
// V1 attempt-3 Phase 4 — Advisor/Operator placeholders.
@@ -213,6 +216,7 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
"retention.enabled",
"retention.transitions_days",
"retention.watchdog_days",
"retention.usage_samples_days", // how long usage samples are kept
"retention.watchdog_keep_per_job",
"retention.stream_days", // slice 22 outcome 9: stream retention
"retention.stream_max_items",
@@ -887,6 +891,44 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
expect(() => store.set("ui.preview.refresh_interval_seconds", "soon")).toThrow(/expected a number/);
});
// Both keys below were resolved by the daemon store and served by `GET /api/config`,
// while `rig config get|set` rejected them as "Unknown config key". Their siblings in
// the same families — every other ui.* and retention.* key — were already settable, so
// this was drift between the two lockstep lists, not a deliberate CLI-only subset.
it("ui.terminal.max_live_terminals is settable, numeric and a positive integer", () => {
const store = new ConfigStore(configPath);
store.set("ui.terminal.max_live_terminals", "3");
expect(store.get("ui.terminal.max_live_terminals")).toBe(3);
expect(JSON.parse(readFileSync(configPath, "utf-8")).ui.terminal.maxLiveTerminals).toBe(3);
for (const raw of ["0", "-1", "2.5", "2abc"]) {
expect(() => store.set("ui.terminal.max_live_terminals", raw), `must reject ${raw}`).toThrow();
}
});
it("retention.usage_samples_days defaults to 14 and keeps an integer >= 1", () => {
const store = new ConfigStore(configPath);
expect(store.resolveWithSource("retention.usage_samples_days"))
.toMatchObject({ value: 14, source: "default", defaultValue: 14 });
store.set("retention.usage_samples_days", "30");
expect(store.get("retention.usage_samples_days")).toBe(30);
for (const raw of ["0", "-1", "1.5"]) {
expect(() => store.set("retention.usage_samples_days", raw), `must reject ${raw}`).toThrow();
}
});
it("both recovered keys honour their OPENRIG_* environment override", () => {
const store = new ConfigStore(configPath);
process.env.OPENRIG_UI_TERMINAL_MAX_LIVE_TERMINALS = "7";
process.env.OPENRIG_RETENTION_USAGE_SAMPLES_DAYS = "9";
try {
expect(store.resolveWithSource("ui.terminal.max_live_terminals")).toMatchObject({ value: 7, source: "env" });
expect(store.resolveWithSource("retention.usage_samples_days")).toMatchObject({ value: 9, source: "env" });
} finally {
delete process.env.OPENRIG_UI_TERMINAL_MAX_LIVE_TERMINALS;
delete process.env.OPENRIG_RETENTION_USAGE_SAMPLES_DAYS;
}
});
// Constraint gap (Slice 27 residue): daemon.port had no KEY_CONSTRAINTS
// entry, so `rig config set daemon.port 99999` stored 99999 and the next
// daemon start died with a raw ERR_SOCKET_BAD_PORT from serve(). The
@@ -979,6 +1021,24 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
expect(cfg.ui.preview.defaultLines).toBe(50);
});
it("RiggedConfig carries both recovered settings, saved values included", () => {
const store = new ConfigStore(configPath);
const defaults = store.resolve();
expect(defaults.ui.terminal.maxLiveTerminals).toBe(2);
expect(defaults.retention.usageSamplesDays).toBe(14);
store.set("ui.terminal.max_live_terminals", "5");
store.set("retention.usage_samples_days", "21");
const saved = store.resolve();
expect(saved.ui.terminal.maxLiveTerminals).toBe(5);
expect(saved.retention.usageSamplesDays).toBe(21);
// `rig config --json` prints exactly this object, so a saved value reaches the
// structured output and not only `rig config get`.
expect(JSON.parse(JSON.stringify(saved))).toMatchObject({
ui: { terminal: { maxLiveTerminals: 5 } },
retention: { usageSamplesDays: 21 },
});
});
it("recovery.auto_drive_provider_prompts defaults false and coerces booleans", () => {
const store = new ConfigStore(configPath);
expect(store.get("recovery.auto_drive_provider_prompts")).toBe(false);
+68 -66
View File
@@ -1,7 +1,7 @@
// The opt-in wake loop in the openrig-user skill, run exactly as shipped: extracted from the skill, executed by sh,
// calling the built `rig` CLI against the daemon's real queue routes over HTTP. It must wake on new owned work,
// survive failed polls, and end at its deadline.
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { describe, it, type TestContext } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
@@ -30,62 +30,48 @@ function shippedLoop(): string {
return match[1]!;
}
describe.skipIf(process.platform === "win32")("the skill's opt-in wake loop", () => {
it("has a built CLI to run the loop with", () => {
// Not a silent skip: the loop runs against this checkout's built `rig`.
expect(fs.existsSync(CLI), `build the CLI first (npm run build): ${fileURLToPath(CLI)} is missing`).toBe(true);
async function fixture(onTestFinished: TestContext["onTestFinished"]) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "wake-loop-"));
let db: Database.Database | undefined;
let server: ServerType | undefined;
onTestFinished(async () => {
try {
if (server) await new Promise<void>((resolve) => server!.close(() => resolve()));
} finally {
try { db?.close(); }
finally { fs.rmSync(root, { recursive: true, force: true }); }
}
});
let db: Database.Database;
let server: ServerType;
let url: string;
let root: string;
let failLists = 0;
let failListsAfter = Infinity; // every list request after this many fails, as if the daemon stopped
let listCalls = 0;
let queue: QueueRepository;
beforeEach(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), "wake-loop-"));
fs.mkdirSync(path.join(root, "bin"));
// The agent's `rig`: this checkout's built CLI.
fs.writeFileSync(path.join(root, "bin", "rig"), `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(fileURLToPath(CLI))} "$@"\n`, { mode: 0o755 });
db = createDb(":memory:");
migrate(db, ALL_MIGRATIONS);
queue = new QueueRepository(db, new EventBus(db), { validateRig: () => true });
const inert = async () => ({ ok: false, error: "inert fixture" });
const { app } = createTestApp(db, {
appDeps: {
sessionTransport: { resolveSessions: async () => ({ ok: false, code: "not_found", error: "inert fixture" }), send: inert, capture: inert, broadcast: inert } as never,
queueRepo: queue,
},
});
failLists = 0;
failListsAfter = Infinity;
listCalls = 0;
server = await new Promise<ServerType>((resolve) => {
const s = serve({
fetch: (request: Request) => {
if (new URL(request.url).pathname === "/api/queue/list") {
listCalls++;
if (failLists > 0 || listCalls > failListsAfter) {
if (failLists > 0) failLists--;
return new Response(JSON.stringify({ error: "fixture: daemon busy" }), { status: 503 });
}
fs.mkdirSync(path.join(root, "bin"));
// The agent's `rig`: this checkout's built CLI.
fs.writeFileSync(path.join(root, "bin", "rig"), `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(fileURLToPath(CLI))} "$@"\n`, { mode: 0o755 });
db = createDb(":memory:");
migrate(db, ALL_MIGRATIONS);
const queue = new QueueRepository(db, new EventBus(db), { validateRig: () => true });
const inert = async () => ({ ok: false, error: "inert fixture" });
const { app } = createTestApp(db, {
appDeps: {
sessionTransport: { resolveSessions: async () => ({ ok: false, code: "not_found", error: "inert fixture" }), send: inert, capture: inert, broadcast: inert } as never,
queueRepo: queue,
},
});
const state = { failLists: 0, failListsAfter: Infinity, listCalls: 0 };
server = await new Promise<ServerType>((resolve) => {
const s = serve({
fetch: (request: Request) => {
if (new URL(request.url).pathname === "/api/queue/list") {
state.listCalls++;
if (state.failLists > 0 || state.listCalls > state.failListsAfter) {
if (state.failLists > 0) state.failLists--;
return new Response(JSON.stringify({ error: "fixture: daemon busy" }), { status: 503 });
}
return app.fetch(request);
},
port: 0, hostname: "127.0.0.1",
}, () => resolve(s));
});
url = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterEach(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
db.close();
fs.rmSync(root, { recursive: true, force: true });
}
return app.fetch(request);
},
port: 0, hostname: "127.0.0.1",
}, () => resolve(s));
});
const url = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
/** Run the shipped loop as a background command would: its own process, this seat's identity, short timings. */
function runLoop(max: number, seat: string | null = DESK) {
@@ -106,26 +92,38 @@ describe.skipIf(process.platform === "win32")("the skill's opt-in wake loop", ()
const assign = (destination: string) => queue.create({
sourceSession: "lead@team", destinationSession: destination, body: "Summarise the failing test.", nudge: false,
} as never);
return { runLoop, assign, queue, state };
}
it("wakes with the new owned item once it is assigned", async () => {
describe.skipIf(process.platform === "win32").concurrent("the skill's opt-in wake loop", () => {
it("has a built CLI to run the loop with", ({ expect }) => {
// Not a silent skip: the loop runs against this checkout's built `rig`.
expect(fs.existsSync(CLI), `build the CLI first (npm run build): ${fileURLToPath(CLI)} is missing`).toBe(true);
});
it("wakes with the new owned item once it is assigned", async ({ expect, onTestFinished }) => {
const { runLoop, assign } = await fixture(onTestFinished);
const waiting = runLoop(30);
setTimeout(() => { assign(DESK); }, 2_000);
const timer = setTimeout(() => { assign(DESK); }, 2_000);
onTestFinished(() => clearTimeout(timer));
const result = await waiting;
expect(result.code).toBe(0);
expect(result.stdout).toMatch(/^NEW WORK: .*"qitemId"/m);
expect(result.seconds).toBeLessThan(15);
}, 40_000);
it("keeps waiting through failed polls and wakes when the daemon answers again", async () => {
it("keeps waiting through failed polls and wakes when the daemon answers again", async ({ expect, onTestFinished }) => {
const { runLoop, assign, state } = await fixture(onTestFinished);
assign(DESK);
failLists = 3;
state.failLists = 3;
const result = await runLoop(30);
expect(result.code).toBe(0);
expect(listCalls).toBeGreaterThanOrEqual(4);
expect(state.listCalls).toBeGreaterThanOrEqual(4);
expect(result.stdout).toMatch(/^NEW WORK: /m);
}, 40_000);
it("doesn't wake for owned work that is already claimed", async () => {
it("doesn't wake for owned work that is already claimed", async ({ expect, onTestFinished }) => {
const { runLoop, assign, queue } = await fixture(onTestFinished);
const item = await assign(DESK);
queue.claim({ qitemId: item.qitemId, destinationSession: DESK } as never);
const result = await runLoop(4);
@@ -134,7 +132,8 @@ describe.skipIf(process.platform === "win32")("the skill's opt-in wake loop", ()
expect(result.stdout).toContain("No new work before the deadline");
}, 40_000);
it("says it couldn't check, and exits non-zero, when every poll failed", async () => {
it("says it couldn't check, and exits non-zero, when every poll failed", async ({ expect, onTestFinished }) => {
const { runLoop, assign } = await fixture(onTestFinished);
assign(DESK);
const result = await runLoop(3, null); // no seat identity: every `rig queue list --owned` fails
expect(result.code).not.toBe(0);
@@ -143,23 +142,26 @@ describe.skipIf(process.platform === "win32")("the skill's opt-in wake loop", ()
expect(result.stdout).toContain("Couldn't check for work: every poll failed.");
}, 40_000);
it("reports when checking stopped working, and exits non-zero, after an early success and failures to the deadline", async () => {
failListsAfter = 1; // one empty successful poll, then the daemon stops answering
it("reports when checking stopped working, and exits non-zero, after an early success and failures to the deadline", async ({ expect, onTestFinished }) => {
const { runLoop, state } = await fixture(onTestFinished);
state.failListsAfter = 1; // one empty successful poll, then the daemon stops answering
const result = await runLoop(4);
expect(result.code).not.toBe(0);
expect(result.stdout).not.toContain("No new work");
expect(result.stdout).toMatch(/Checking stopped working at \d{2}:\d{2}:\d{2}Z: every poll since then failed\./);
}, 40_000);
it("stays quiet when a failed poll is followed by a successful one", async () => {
failLists = 2; // two failures, then the daemon answers again (with no work)
it("stays quiet when a failed poll is followed by a successful one", async ({ expect, onTestFinished }) => {
const { runLoop, state } = await fixture(onTestFinished);
state.failLists = 2; // two failures, then the daemon answers again (with no work)
const result = await runLoop(4);
expect(result.code).toBe(0);
expect(result.stdout).toContain("No new work before the deadline");
expect(result.stdout).not.toContain("Checking stopped working");
}, 40_000);
it("ignores work assigned to another seat and ends at its deadline", async () => {
it("ignores work assigned to another seat and ends at its deadline", async ({ expect, onTestFinished }) => {
const { runLoop, assign } = await fixture(onTestFinished);
assign("impl@team");
const result = await runLoop(4);
expect(result.code).toBe(0);
@@ -44,10 +44,13 @@ machine and have things happen.
SQLite. The daemon's kernel-boot path short-circuits
`already-managed` — no re-instantiation, no fresh agents. Member
tmux sessions survive a daemon-restart-only and the reconciler
marks them healthy. A HOST reboot drops tmux entirely; the
reconciler then marks the kernel's member sessions detached, and
the operator pod owns the agent-restart workflow ("bring my
sessions back online") per "Incomplete setup and restart":
marks them healthy. A HOST reboot drops tmux entirely. After a host
reboot, the next `rig daemon start` attempts to restore the kernel
when startup reconciliation newly marks at least one seat detached
and every kernel seat is detached and still bound. A deliberately
stopped kernel, or one whose seats were all already recorded as
detached before that start, stays down until
`rig up kernel --existing`. See "Incomplete setup and restart":
`rig context get reference/getting-started.md#incomplete-setup-and-restart`.
Other rigs are NOT auto-instantiated by the daemon at any point —
they require explicit operator-initiated `rig up` / `rig restore`
@@ -11,9 +11,12 @@ summary: >
subsequent daemon-restarts the rig record persists in SQLite and the
daemon's kernel-boot path short-circuits already-managed without
re-creating the rig. Member tmux sessions survive daemon-restart-only;
a HOST reboot drops tmux and the reconciler marks sessions detached,
at which point the operator pod owns the agent-restart workflow
through the current operator workflow. Dual-runtime variant; Claude Code +
a HOST reboot drops tmux. The next daemon start attempts to restore the
kernel when startup reconciliation newly marks at least one seat
detached and every kernel seat is detached and still bound; a
deliberately stopped kernel, or one whose seats were all already
recorded as detached, stays down until rig up kernel --existing.
Dual-runtime variant; Claude Code +
Codex both authenticated.
culture_file: culture/CULTURE.md
+1 -1
View File
@@ -79,7 +79,7 @@ function shellWord(value: string): string {
}
/** `tmux`, with the daemon's server flags when it isn't on the default server. */
function tmuxOnDaemonServer(): string {
export function tmuxOnDaemonServer(): string {
const server = daemonServerArgs.map((arg, i) => (i % 2 === 0 ? arg : shellWord(arg))).join(" ");
return `tmux${server ? ` ${server}` : ""}`;
}
+70 -19
View File
@@ -8,6 +8,7 @@ import { inspectComposerInput } from "../domain/session-transport.js";
import { parentSessionEnvKeys, removeParentSessionEnv } from "../domain/parent-session-env.js";
import { addSeatRuntimeDir, seatRuntimeDir } from "../domain/seat-runtime-dir.js";
import type { TmuxControlExec } from "./tmux-control.js";
import { tmuxOnDaemonServer } from "./tmux-server.js";
export type ExecFn = (cmd: string) => Promise<string>;
@@ -277,7 +278,9 @@ function parentSessionEnvWarning(target: string, err: unknown): string {
/** Shell-quote a string using single quotes (POSIX-safe). */
/** One pane's text from a batched capture, with the time its chunk was read (#309 review). `text` is null for a
* session that is gone or an empty capture. */
export interface PaneCapture { text: string | null; capturedAt: Date; /** The listing lacked the session. */ missing?: true }
export interface PaneCapture { text: string | null; capturedAt: Date; /** The listing lacked the session. */ missing?: true;
/** The pane and its process that this capture read, from the same tmux call (#2515). */
pane?: { id: string; pid: number } }
/** The most sessions one batched capture chains (#308). Not a byte bound: a chunk whose output overflows exec's buffer
* fails and is split in half. */
@@ -544,7 +547,7 @@ export class TmuxAdapter {
}
}
private async guardedInput(target: string, write: (pane: string, beforeWrite: () => void) => Promise<TmuxResult>, allowAbsent = false, inputKind: "control" | "text" | "keys" | "respawn" = "control"): Promise<TmuxResult> {
private async guardedInput(target: string, write: (pane: string, beforeWrite: () => void, nodeId?: string) => Promise<TmuxResult>, allowAbsent = false, inputKind: "control" | "text" | "keys" | "respawn" = "control"): Promise<TmuxResult> {
const guard = this.deliveryGuard;
if (!guard) return write(target, () => {});
try {
@@ -594,7 +597,7 @@ export class TmuxAdapter {
}
// Revalidate registry/occupant after the asynchronous observation. Write
// to the immutable pane ID, not a session name which could be recycled.
return guard.input(identity, () => write(pane, () => guard.checkInput(identity)));
return guard.input(identity, () => write(pane, () => guard.checkInput(identity), bound.nodeId));
});
} catch (error) {
return { ok: false, code: (error as { code?: string }).code ?? "guard_target_unknown", message: String((error as Error).message) };
@@ -1308,16 +1311,26 @@ export class TmuxAdapter {
}
}
/** With a delivery guard, terminate by immutable tmux session ID; checked callers validate before detach and kill. */
/** With a delivery guard, terminate by immutable tmux session ID; checked callers validate before detach and kill.
* A seat's session is stopped only when it carries that seat's OpenRig marker, and each effect runs only while that
* id still names a session on the server it was read from: a replaced server reuses ids for other terminals. */
async killSession(name: string, beforeEffect?: () => void): Promise<TmuxResult> {
let stopPhase: StopPhase = "none";
if (this.deliveryGuard) {
const result = await this.guardedInput(name, async (pane, beforeWrite) => {
const stdout = await this.run(["tmux", "display-message", "-p", "-t", pane, "#{session_id}"], `tmux display-message -p -t ${shellQuote(pane)} '#{session_id}'`);
const sessionId = stdout.trim();
if (!/^\$\d+$/.test(sessionId)) return { ok: false, code: "guard_target_unknown", message: "Cannot establish immutable session identity; no session killed." };
const result = await this.guardedInput(name, async (pane, beforeWrite, nodeId) => {
// The session id and its server (pid and start time) in one read, so the effects below can be held to both.
const stdout = await this.run(["tmux", "display-message", "-p", "-t", pane, "#{session_id}|#{pid}|#{start_time}"],
`tmux display-message -p -t ${shellQuote(pane)} '#{session_id}|#{pid}|#{start_time}'`);
const [sessionId = "", pid = "", start = ""] = stdout.trim().split("|");
if (!/^\$\d+$/.test(sessionId) || !/^\d+$/.test(pid) || !/^\d+$/.test(start)) {
return { ok: false, code: "guard_target_unknown", message: "Cannot establish immutable session identity; no session killed." };
}
if (nodeId) {
const unproven = await this.unprovenSeatSession(name, sessionId, nodeId);
if (unproven) return unproven;
}
const kill = async () => {
const result = await this.killSessionUnchecked(sessionId, beforeEffect && (() => { beforeWrite(); beforeEffect(); }), phase => { stopPhase = phase; });
const result = await this.killSessionUnchecked(sessionId, beforeEffect && (() => { beforeWrite(); beforeEffect(); }), phase => { stopPhase = phase; }, { pid, start });
if (result.ok) { this.freshProbes.delete(name); this.freshProbes.delete(pane); this.freshManaged.delete(name); }
return result;
};
@@ -1330,16 +1343,48 @@ export class TmuxAdapter {
return this.killSessionUnchecked(name);
}
/** The session a seat's stop would end is the seat's own: every session OpenRig creates for a seat carries its node's
* OPENRIG_NODE_ID. One without it (adopted, bound or discovered from outside), or with another seat's, is left
* running and named; nothing is detached or killed. */
private async unprovenSeatSession(name: string, sessionId: string, nodeId: string): Promise<TmuxResult | null> {
let marker: string | null = null;
try {
const out = (await this.run(["tmux", "show-environment", "-t", sessionId, "OPENRIG_NODE_ID"])).trim();
if (out.startsWith("OPENRIG_NODE_ID=")) marker = out.slice("OPENRIG_NODE_ID=".length);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (!/unknown variable/i.test(message)) {
return { ok: false, code: "guard_target_unknown", message: `'${name}' was left running: its OpenRig marker couldn't be read (${message.trim()}), so nothing was detached or killed.` };
}
}
if (marker === nodeId) return null;
const why = marker === null ? "doesn't carry this seat's OpenRig marker (OPENRIG_NODE_ID)" : `carries another seat's OpenRig marker (OPENRIG_NODE_ID=${marker})`;
return { ok: false, code: "seat_target_unproven", message: `'${name}' was left running: its tmux session ${sessionId} ${why}, so it can't be shown `
+ "to be this seat's terminal, and nothing was detached or killed. A session OpenRig didn't create, such as an adopted one, has no marker. "
// On the daemon's own tmux server: a person's plain `tmux` can reach another server, and a namesake there.
+ `If it is this seat's, look first with \`${tmuxOnDaemonServer()} attach -t ${shellQuote(`=${name}`)}\`, then stop it with `
+ `\`${tmuxOnDaemonServer()} kill-session -t ${shellQuote(`=${name}`)}\` and run the command again.` };
}
/** Keep effect checks outside command catches so a selection refusal retains its code and phase. */
private async killSessionUnchecked(name: string, beforeEffect?: () => void, phase?: (value: StopPhase) => void): Promise<TmuxResult> {
private async killSessionUnchecked(name: string, beforeEffect?: () => void, phase?: (value: StopPhase) => void,
server?: { pid: string; start: string }): Promise<TmuxResult> {
// The guarded path passes the immutable `$N` id, which stays as it is; a bare name is made exact.
const session = exactTarget(name, "session");
// With its server, an effect runs only while this id is that session on that server: tmux checks and acts in one
// command, so a server replaced since the read (whose ids name other terminals) is left alone.
const changed = "openrig-stop-target-changed";
const effect = (verb: string, flag: string): string[] => server
? ["tmux", "if-shell", "-F", "-t", session,
`#{&&:#{==:#{session_id},${session}},#{&&:#{==:#{pid},${server.pid}},#{==:#{start_time},${server.start}}}}`,
`${verb} ${flag} '${session}'`, `display-message -p ${changed}`]
: ["tmux", verb, flag, session];
// Detach first so `detach-on-destroy off` cannot switch views onto another session.
beforeEffect?.();
phase?.("detach_attempted");
try {
await this.run(["tmux", "detach-client", "-s", session],
`tmux detach-client -s ${shellQuote(session)}`);
const out = await this.run(effect("detach-client", "-s"), server ? undefined : `tmux detach-client -s ${shellQuote(session)}`);
if (out.includes(changed)) return { ok: false, code: "guard_target_changed", message: `${session} is no longer that session on the tmux server it was read from (the server may have been replaced); nothing was detached or killed.` };
} catch (err) {
// tmux 3.7 says "no current client" when nothing is attached (and for a missing session, which the kill classifies).
const message = err instanceof Error ? err.message : String(err);
@@ -1349,8 +1394,8 @@ export class TmuxAdapter {
beforeEffect?.();
phase?.("kill_attempted");
try {
await this.run(["tmux", "kill-session", "-t", session],
`tmux kill-session -t ${shellQuote(session)}`);
const out = await this.run(effect("kill-session", "-t"), server ? undefined : `tmux kill-session -t ${shellQuote(session)}`);
if (out.includes(changed)) return { ok: false, code: "guard_target_changed", message: `${session} is no longer that session on the tmux server it was read from (the server may have been replaced); it was detached, but nothing was killed.` };
const pane = this.freshProbes.get(name);
this.freshProbes.delete(name);
if (pane) this.freshProbes.delete(pane);
@@ -1781,12 +1826,14 @@ export class TmuxAdapter {
const captureChunk = async (chunk: string[]): Promise<void> => {
const nonce = randomUUID().replace(/-/g, "");
const mark = (k: number) => `__openrig_capture_${nonce}_${k}__`;
// The marker line also names the pane and its process that the capture after it reads, in the same call.
const header = (k: number) => `${mark(k)} #{pane_id} #{pane_pid}`;
const argv = ["tmux", ...chunk.flatMap((t, k) => [
...(k > 0 ? [";"] : []),
"display-message", "-p", "-t", `=${t}:`, mark(k), ";", "capture-pane", "-p", "-t", `=${t}:`, "-S", `-${lines}`,
"display-message", "-p", "-t", `=${t}:`, header(k), ";", "capture-pane", "-p", "-t", `=${t}:`, "-S", `-${lines}`,
])];
const legacy = "tmux " + chunk.map((t, k) =>
`display-message -p -t ${shellQuote(`=${t}:`)} ${shellQuote(mark(k))} \\; capture-pane -p -t ${shellQuote(`=${t}:`)} -S -${lines}`,
`display-message -p -t ${shellQuote(`=${t}:`)} ${shellQuote(header(k))} \\; capture-pane -p -t ${shellQuote(`=${t}:`)} -S -${lines}`,
).join(" \\; ");
let output: string;
let capturedAt: Date;
@@ -1830,14 +1877,18 @@ export class TmuxAdapter {
return; // a single session that failed: read per target by the caller
}
const segments: Array<string[] | null> = chunk.map(() => null);
const panes: Array<PaneCapture["pane"]> = chunk.map(() => undefined);
const marker = new RegExp(`^__openrig_capture_${nonce}_(\\d+)__(?: (.*))?$`);
let cur = -1;
const outLines = output.split("\n");
if (outLines.length && outLines[outLines.length - 1] === "") outLines.pop(); // the final newline, not a line
for (const line of outLines) {
const m = line.startsWith(`__openrig_capture_${nonce}_`) ? /_(\d+)__$/.exec(line) : null;
if (m && line === mark(Number(m[1]))) {
const m = line.startsWith(`__openrig_capture_${nonce}_`) ? marker.exec(line) : null;
if (m && Number(m[1]) < chunk.length) {
cur = Number(m[1]);
segments[cur] = [];
const identity = /^(%\d+) (\d+)$/.exec(m[2] ?? "");
panes[cur] = identity ? { id: identity[1]!, pid: Number(identity[2]) } : undefined;
} else if (cur >= 0) {
segments[cur]!.push(line);
}
@@ -1847,7 +1898,7 @@ export class TmuxAdapter {
if (seg == null) return; // its marker never printed: read per target
// capture-pane prints its lines newline-terminated, as the per-target read returns them
const text = seg.length ? seg.join("\n") + "\n" : "";
out.set(t, { text: text === "" ? null : text, capturedAt });
out.set(t, { text: text === "" ? null : text, capturedAt, ...(panes[k] ? { pane: panes[k] } : {}) });
});
};
const unique = [...new Set(targets)];
+7 -4
View File
@@ -111,16 +111,19 @@ export class EventBus {
}
/**
* Run a nested transactional writer (a savepoint inside the active envelope) whose failure the caller handles. If it
* throws, its savepoint has rolled back the event rows it persisted, so their tokens leave the envelope too, and the
* caller's transaction can still commit. Outside an envelope it just runs.
* 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 writer();
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);
+2 -2
View File
@@ -2,7 +2,7 @@ import { physicalSessionName } from "./session-registry.js";
import { resolveNodeFile } from "./scope/node-file.js";
import { belongsToProject } from "./workspace/project-catalog.js";
import { inspectGraph } from "./workflow-reconciliation.js";
import { createProofPolicyRead, readMissionReadiness, readProjectReadinessAsync } from "./proof/judgments.js";
import { createProofPolicyRead, readMissionReadiness, readProjectReadiness } from "./proof/judgments.js";
import { lifecycleObligations, requiredLifecycleSteps } from "./lifecycle-obligations.js";
import { QueueWakeRepository } from "./queue-wake-repository.js";
import { isTypedGateBlocker } from "./queue-repository.js";
@@ -1361,7 +1361,7 @@ export async function buildExecutionView(deps: ExecutionViewDeps, opts?: { missi
...(opts?.project ? { project: opts.project, membership: "exact project:<id> queue tags and lifecycle identity; unscoped rows excluded" } : {}),
view: "execution",
readiness,
project_readiness: missionsRoot ? await readProjectReadinessAsync(missionsRoot, readPolicy) : null,
project_readiness: missionsRoot ? readProjectReadiness(missionsRoot, readPolicy) : null,
// Authored guidance is carried verbatim in meaning, never parsed into edges or acceptance.
planning_guidance: arrangement?.state === "valid" ? arrangement.guidance : [],
mission,
+14 -2
View File
@@ -5,7 +5,7 @@ import type { RosterWake } from "./rosters.js";
import { resolveSecret } from "./gateway/slack/secrets.js";
import { safeDecodeURIComponent, validateOutboundUrl } from "./mission-control/outbound-url-validator.js";
/** How long a trigger may take to answer before the ring counts as failed. */
/** How long a trigger may take to answer. After that the ring is unconfirmed, not failed: the trigger may have it. */
export const HOSTED_WAKE_TIMEOUT_MS = 10_000;
export interface HostedWakeRing {
@@ -66,8 +66,20 @@ export async function ringHostedWake(
} catch (err) {
// An error's text can carry the URL, so only a fixed classification is recorded: the timeout, or the error code.
const timedOut = err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError");
if (timedOut) return failed(urlRef, `didn't answer within ${opts.timeoutMs ?? HOSTED_WAKE_TIMEOUT_MS} ms`);
// The POST may have reached the trigger and started a run, so the ring is unknown, like an unconfirmed send: it isn't
// rung again blindly (see hostedRingMayHaveArrived), and the member's claim is still the pickup.
if (timedOut) {
return { classified: "indeterminate", nudgeResult: `indeterminate:webhook (${urlRef}) didn't answer within ${opts.timeoutMs ?? HOSTED_WAKE_TIMEOUT_MS} ms, `
+ "so it may have the ring; it isn't rung again, and pickup is the member's claim on this row" };
}
const code = (err as { cause?: { code?: unknown } } | null)?.cause?.code;
return failed(urlRef, `couldn't be reached (${typeof code === "string" && /^[A-Z][A-Z0-9_]*$/.test(code) ? code : "network error"})`);
}
}
/** A ring the trigger accepted, or one it may have received before it stopped answering. Either can start a run, so
* recovery doesn't ring that wake intent again. A ring that never left (no URL, an invalid one, a refused connection)
* is neither. */
export function hostedRingMayHaveArrived(nudgeResult: string): boolean {
return nudgeResult.startsWith("wake-accepted:webhook ") || nudgeResult.startsWith("indeterminate:webhook ");
}
@@ -389,22 +389,6 @@ function declaredNodeId(dir: string): string | null {
* from them); it defaults to the ordinary filesystem reader and changes nothing about the evaluation. */
export function readProjectReadiness(missionsRoot: string, readPolicy: ProofPolicyRead = defaultPolicyRead, root?: string, io: ScopeFsDeps = proofFs, observe?: (file: string) => void) {
const missions = fs.readdirSync(missionsRoot).filter(n => fs.statSync(path.join(missionsRoot, n)).isDirectory()).map(name => ({ name, ...readMissionReadiness(contained(missionsRoot, path.join(missionsRoot, name)), readPolicy, root, io, observe) }));
return projectReadiness(missions);
}
/** The same semantic read, yielding between missions for daemon requests and source-watch refreshes.
* A single mission (including its evidence) is still synchronous; this is not a hard time bound or atomic snapshot. */
export async function readProjectReadinessAsync(missionsRoot: string, readPolicy: ProofPolicyRead = defaultPolicyRead, root?: string, io: ScopeFsDeps = proofFs, observe?: (file: string) => void) {
const names = fs.readdirSync(missionsRoot).filter(n => fs.statSync(path.join(missionsRoot, n)).isDirectory());
const missions: Array<MissionReadiness & { name: string }> = [];
for (const name of names) {
await new Promise<void>(resolve => setImmediate(resolve));
missions.push({ name, ...readMissionReadiness(contained(missionsRoot, path.join(missionsRoot, name)), readPolicy, root, io, observe) });
}
return projectReadiness(missions);
}
function projectReadiness(missions: Array<MissionReadiness & { name: string }>) {
const active = missions.filter(m => ["active", "release-candidate"].includes(m.historicalStatus ?? ""));
return { revision: hash(active), state: active.length && active.every(m => m.state === "ready") ? "ready" : active.some(m => m.state === "unknown") ? "unknown" : "not-ready", missions, basis: "Active mission readiness; distinct outcome judgment and publication remain separate" };
}
@@ -3,7 +3,7 @@ import * as path from "node:path";
import { createHash } from "node:crypto";
import type { EventBus } from "../event-bus.js";
import type { ScopeFsDeps } from "../scope/scope-view-projection.js";
import { createProofPolicyRead, proofFs, readProjectReadinessAsync, resolveProjectRoot } from "./judgments.js";
import { createProofPolicyRead, proofFs, readProjectReadiness, resolveProjectRoot } from "./judgments.js";
export interface ProofSourceWatch {
close(): void;
@@ -76,15 +76,16 @@ function shallowWatches(missionsRoot: string, realRoot: string, top: string, pro
* including absent nearer ones, and accepted evidence elsewhere in the project) is watched at its parent directory,
* not recursively, so sibling checkouts and build trees are never subscribed to.
*/
export async function watchProofSources(missionsRoot: string, invalidate: () => void, bus: EventBus, scopeChanged?: () => void): Promise<ProofSourceWatch> {
export function watchProofSources(missionsRoot: string, invalidate: () => void, bus: EventBus, scopeChanged?: () => void): ProofSourceWatch {
missionsRoot = path.resolve(missionsRoot);
let projectRoot = path.dirname(missionsRoot);
try { projectRoot = resolveProjectRoot(missionsRoot) ?? projectRoot; } catch { /* Legacy roots keep the missions root's parent. */ }
const top = path.dirname(path.resolve(projectRoot));
// The authority boundary in real terms: what the readers' containment check accepts.
const project = canonical(projectRoot) ?? path.resolve(projectRoot);
// Keep the full semantic read, but let queue/send requests run between missions.
const basis = async () => {
// ponytail: bounded local workspaces use a full semantic read after a file burst.
// Keep this workload measured; subtree indexing is warranted only when that bound is exceeded.
const basis = () => {
const read = new Set<string>();
const note = (p: string) => { read.add(p); return p; };
const io: ScopeFsDeps = {
@@ -94,8 +95,7 @@ export async function watchProofSources(missionsRoot: string, invalidate: () =>
listDir: (p) => proofFs.listDir(note(p)),
isDirectory: (p) => proofFs.isDirectory(note(p)),
};
const result = await readProjectReadinessAsync(missionsRoot, createProofPolicyRead(), undefined, io, note);
const hash = createHash("sha256").update(JSON.stringify(result.missions.map(m => [m.name, m.revision]))).digest("hex");
const hash = createHash("sha256").update(JSON.stringify(readProjectReadiness(missionsRoot, createProofPolicyRead(), undefined, io, note).missions.map(m => [m.name, m.revision]))).digest("hex");
// A file read through a link inside the project depends on its real location too: observe both.
for (const file of [...read]) {
const real = canonical(file);
@@ -107,7 +107,6 @@ export async function watchProofSources(missionsRoot: string, invalidate: () =>
let closed = false, failed = false, covered = false, readable = false;
let revision = "unavailable", timer: NodeJS.Timeout | undefined;
let scopeDirty = false;
let reading = true, dirty = false;
const notifyScope = () => { try { scopeChanged?.(); } catch { /* A later event or explicit refresh retries the view read. */ } };
let wanted: Shallow = new Map();
const shallow = new Map<string, fs.FSWatcher>();
@@ -164,32 +163,25 @@ export async function watchProofSources(missionsRoot: string, invalidate: () =>
/** Read the sources and keep the subscriptions in step with what was read, even when the result is unchanged.
* Coverage is certified only by a read whose every dependency was already watched before it; if the dependencies
* keep moving past a small budget, the sources are reported unobserved rather than read forever. */
const evaluate = async (): Promise<{ hash: string; covered: boolean }> => {
let read = await basis();
const evaluate = (): string => {
let read = basis();
for (let i = 0; ; i++) {
if (closed) return { hash: revision, covered: false };
// A root whose real location is unknown or outside the project has no anchors there: never certified.
// Coverage belongs to this candidate, just like the hash; neither escapes a discarded read.
if (!resubscribe(read.watches)) return { hash: read.hash, covered: recursiveReal !== null && within(recursiveReal, project) };
if (i === 2) return { hash: read.hash, covered: false };
read = await basis();
if (!resubscribe(read.watches)) { covered = recursiveReal !== null && within(recursiveReal, project); return read.hash; }
if (i === 2) { covered = false; return read.hash; }
read = basis();
}
};
const observed = () => !closed && !failed && covered && readable;
const schedule = () => {
dirty = true;
if (timer || reading || closed) return;
timer = setTimeout(async () => {
if (timer || closed) return;
timer = setTimeout(() => {
timer = undefined;
if (closed) return;
reading = true; dirty = false;
try {
if (scopeDirty) { scopeDirty = false; notifyScope(); }
const before = observed();
const candidate = await evaluate();
if (closed || dirty) return;
const next = candidate.hash;
covered = candidate.covered && !failed;
const next = evaluate();
readable = true;
// Deduplicate on what clients see: the revision and whether the sources are observed. Losing or regaining
// coverage is news even when the proof result itself did not change.
@@ -198,16 +190,12 @@ export async function watchProofSources(missionsRoot: string, invalidate: () =>
invalidate();
notify(observed() ? revision : "unavailable");
} catch (e) {
if (closed) return;
// Never report the sources as watched after a failed read or a watch that could not be attached.
readable = false;
if (e instanceof WatchFailure) covered = false;
revision = "unavailable";
invalidate();
notify("unavailable");
} finally {
reading = false;
if (dirty && !closed) schedule();
}
}, 40);
timer.unref();
@@ -215,10 +203,7 @@ export async function watchProofSources(missionsRoot: string, invalidate: () =>
try {
recursiveReal = canonical(missionsRoot);
recursive = watchDir(missionsRoot, true);
try {
const candidate = await evaluate();
if (!dirty) { revision = candidate.hash; covered = candidate.covered && !failed; readable = true; }
} catch (e) {
try { revision = evaluate(); readable = true; } catch (e) {
if (e instanceof WatchFailure) throw e;
// Direct reads name unavailable inputs; watching may recover them.
covered = true;
@@ -229,9 +214,6 @@ export async function watchProofSources(missionsRoot: string, invalidate: () =>
for (const watcher of shallow.values()) watcher.close();
invalidate(); notify("unavailable"); notifyScope();
return { close() {}, observation: () => ({ state: "unavailable", revision }) };
} finally {
reading = false;
if (dirty && !closed) schedule();
}
return {
observation: () => ({ state: observed() ? "watching" : "unavailable", revision }),
@@ -0,0 +1,12 @@
/** Typed non-queue gates take precedence over custom queue IDs. */
export const TYPED_GATE_BLOCKER_PREFIXES = ["fold:", "auth:", "external:"] as const;
export function typedGateBlockerPrefix(value: string): string | null {
return TYPED_GATE_BLOCKER_PREFIXES.find((prefix) => value.startsWith(prefix)) ?? null;
}
export function isTypedGateBlocker(value: unknown): boolean {
if (typeof value !== "string") return false;
const prefix = typedGateBlockerPrefix(value);
return prefix != null && value.slice(prefix.length).trim().length > 0;
}
+8 -20
View File
@@ -72,6 +72,8 @@ import type { QueueWakeFacts } from "./draft-aware-delivery.js";
import { derivePickup, type PickupReceipt } from "./queue-pickup.js";
import { withRecoveryIndex } from "./queue-recovery.js";
import { lastMeaningfulTransitionMany, meaningfulFor, readWaitingView, WaitingStatements, type WaitingView, type WaitingActivityReader, type WaitingReads } from "./queue-waiting.js";
import { isTypedGateBlocker, typedGateBlockerPrefix } from "./queue-blocker.js";
export { TYPED_GATE_BLOCKER_PREFIXES, isTypedGateBlocker, typedGateBlockerPrefix } from "./queue-blocker.js";
import { isOpenRigSender, OPENRIG_SENDER, wrapPaneEnvelope } from "../lib/pane-envelope.js";
import { isBoundTerminalSeat } from "./terminal-seat.js";
import { renderQueueHandoffNudge } from "./queue-nudge-text.js";
@@ -84,7 +86,7 @@ import { classifyDestination, type DestinationClass } from "./gateway/destinatio
import { join } from "node:path";
import { SettingsStore } from "./user-settings/settings-store.js";
import { hostedWakeFor, isHostedMemberAt, type RosterWake } from "./rosters.js";
import { ringHostedWake } from "./hosted-wake.js";
import { hostedRingMayHaveArrived, ringHostedWake } from "./hosted-wake.js";
import { effectiveGateRoles } from "./gate-predicate.js";
import {
computeClosureRequiredAt,
@@ -180,22 +182,8 @@ const AUTO_UNPARK_BLOCKER_TAG_PREFIX = "queue:auto-unpark:blocker-ref:";
/** The actor on the transition routeToFallback writes; the row's own history is how a later reroute finds the line it wrote. */
const FALLBACK_ACTOR = "system:queue-fallback";
/** 0.5.1-53 Atom 1a — typed non-qitem gate blocker prefixes. A park may be gated on a fold / auth /
* external condition that is NOT a qitem and NOT a human seat; these prefixes make such a gate a
* first-class, compact-visible, downstream-classifiable blocker (the ruling detail rides a transition). */
export const TYPED_GATE_BLOCKER_PREFIXES = ["fold:", "auth:", "external:"] as const;
/** The actor a terminal act is recorded as when it names no responder: explicitly unattributed, never a person. */
export const UNATTRIBUTED_ACTOR = "human@host";
export function typedGateBlockerPrefix(value: string): string | null {
return TYPED_GATE_BLOCKER_PREFIXES.find((p) => value.startsWith(p)) ?? null;
}
/** A well-formed typed gate blocker: a recognized prefix AND a non-empty gate body. */
export function isTypedGateBlocker(value: unknown): boolean {
if (typeof value !== "string") return false;
const p = typedGateBlockerPrefix(value);
return p != null && value.slice(p.length).trim().length > 0;
}
/**
* #165: a claim is the destination acting on the row, which is stronger evidence than
@@ -1677,8 +1665,8 @@ export class QueueRepository {
// The level can change while this send waits for the seat's lease. Under the lease, each member is admitted again with
// its own source and urgency; only those with the first member's outcome go with this send, and the others go back
// to their state before this claim (never marked delivered, held or failed) for their own delivery.
// A hosted member's trigger already accepted the ring for exactly these intents (tagged below): recovery doesn't ring
// it again, since each ring can start a run. Any other wake rings: a new intent, or one claimed but never sent.
// A hosted member's trigger already accepted the ring for exactly these intents, or may have before it stopped
// answering (tagged below): recovery doesn't ring it again, since each ring can start a run. Any other wake rings: a new intent, or one claimed but never sent.
if (group.every((entry) => entry.tags?.includes(HOSTED_WAKE_ACCEPTED_TAG))) {
for (const entry of group) this.outbox.finalizeDelivery(entry.outboxId, "indeterminate");
return "indeterminate";
@@ -1719,8 +1707,8 @@ export class QueueRepository {
const wakeEvent = this.db.transaction(() => {
this.recordNudgeAttempt(qitemId, nudgeResult);
this.outbox!.finalizeDelivery(intent.outboxId, finalState);
// The acceptance belongs to this intent, so recovery can tell this wake already rang.
if (outcome.nudgeResult.startsWith("wake-accepted:")) {
// The acceptance (or a ring that may have arrived) belongs to this intent, so recovery can tell this wake already rang.
if (hostedRingMayHaveArrived(outcome.nudgeResult)) {
this.db.prepare(`UPDATE outbox_entries SET tags = json_insert(COALESCE(tags, '[]'), '$[#]', ?)
WHERE outbox_id = ? AND NOT EXISTS (SELECT 1 FROM json_each(COALESCE(outbox_entries.tags, '[]')) WHERE value = ?)`)
.run(HOSTED_WAKE_ACCEPTED_TAG, intent.outboxId, HOSTED_WAKE_ACCEPTED_TAG);
@@ -5850,7 +5838,7 @@ export class QueueRepository {
* their qitem blockers' last meaningful transitions too. */
private listingReads(rows: QueueItemRow[], includeWaiting: boolean): ListingReads {
const ids = rows.map((r) => r.qitem_id);
if (includeWaiting) for (const r of rows) if (r.blocked_on?.startsWith("qitem-")) ids.push(r.blocked_on);
if (includeWaiting) for (const r of rows) if (r.blocked_on) ids.push(r.blocked_on);
// Each owner's activity once per listing: a row and its waiting view (and every other row to that owner) read the
// same seat state. ownerActivity reads a failure as null, so one session's failure stays that session's.
const activities = new Map<string, ReturnType<WaitingActivityReader>>();
+5 -3
View File
@@ -1,4 +1,5 @@
import { resolveEscalationOwner, hasConsumedEscalationHop } from "./escalation-owner.js";
import { isTypedGateBlocker } from "./queue-blocker.js";
import type Database from "better-sqlite3";
import { resolvePickupThresholdMinutes } from "./queue-pickup.js";
import { SettingsStore } from "./user-settings/settings-store.js";
@@ -142,7 +143,7 @@ export function readWaitingView(db: Database.Database, id: string, readActivity?
const row = held && held.qitem_id === id ? held : statements.statement(`SELECT qitem_id, source_session, destination_session, state, blocked_on, closure_required_at, tags
FROM queue_items WHERE qitem_id = ?`).get(id) as WaitingRow | undefined;
if (!row) return null;
const blocker = row.blocked_on?.startsWith("qitem-") ? statements.statement(
const blocker = row.blocked_on && !isTypedGateBlocker(row.blocked_on) ? statements.statement(
"SELECT destination_session, state FROM queue_items WHERE qitem_id = ?",
).get(row.blocked_on) as { destination_session: string; state: string } | undefined : undefined;
let recoveryOwner: string | null = null;
@@ -151,7 +152,7 @@ export function readWaitingView(db: Database.Database, id: string, readActivity?
const subject = row.blocked_on ? blocker?.destination_session ?? row.blocked_on : row.destination_session;
let observed: ReturnType<WaitingActivityReader> = null;
try { observed = readActivity?.(subject) ?? null; } catch { /* unavailable remains unknown */ }
const meaningful = meaningfulFor(db, row.blocked_on?.startsWith("qitem-") ? row.blocked_on : id, reads);
const meaningful = meaningfulFor(db, blocker ? row.blocked_on! : id, reads);
const sweepInterval = sweepSeconds();
const view: WaitingView = {
obligation: id, owner: row.destination_session, state: row.state, actionableSince: row.state === "pending" ? pendingSince(db, id, statements) : null,
@@ -178,10 +179,11 @@ export function readWaitingView(db: Database.Database, id: string, readActivity?
// and the wake-now marker is due now (#860).
const timerDue = timer ? nextDueAt({ policy: timer.policy, specYaml: timer.spec_yaml, registeredAt: timer.registered_at,
intervalSeconds: timer.interval_seconds, scanIntervalSeconds: timer.scan_interval_seconds, lastEvaluationAt: timer.last_evaluation_at }) : null;
const liveBlocker = blocker && ["pending", "in-progress", "blocked"].includes(blocker.state);
view.nextBackstop = timer ? {
owner: row.destination_session, mechanism: `watchdog:${timer.job_id}`, intervalSeconds: timer.interval_seconds,
dueAt: new Date(timerDue ?? Date.now()).toISOString(),
} :{ owner: blocker?.destination_session ?? row.destination_session, mechanism: blocker ? "blocker-transition / queue-stuck-sweep" : NO_TIMED_BACKSTOP, dueAt: null, intervalSeconds: blocker ? sweepInterval : null };
} :{ owner: liveBlocker ? blocker.destination_session : row.destination_session, mechanism: liveBlocker ? "blocker-transition / queue-stuck-sweep" : NO_TIMED_BACKSTOP, dueAt: null, intervalSeconds: liveBlocker ? sweepInterval : null };
if (timer) {
let notice: { at: string; deliveryStatus: string } | undefined;
try {
@@ -15,9 +15,10 @@ import type {
import { COMMUNITY_HARNESS_RUNG_INVENTORY, COMMUNITY_RUNNER_RUNG_INVENTORY, EVIDENCE_RUNG_RANK, runtimeRungInventory } from "./activity-taxonomy.js";
import { recordedRunner, sessionCommunityHarnessSelect, sessionCommunityRunnerSelect } from "./session-registry.js";
import { hookPrecedes } from "./agent-activity-store.js";
import type { SeatStructuralActivityService } from "./seat-structural-activity-service.js";
import type { SeatStructuralActivityService, StructuralObservation } from "./seat-structural-activity-service.js";
import { DEFAULT_STRUCTURAL_STALE_MS } from "./seat-structural-activity-service.js";
import { SettingsStore } from "./user-settings/settings-store.js";
import { SeatIdentityStore } from "./seat-identity-store.js";
/** Default polling cadence: 1Hz. The default silence window is 3s, so
* 1Hz polling gives at-most ~1s freshness lag on the cached observation (a slower cadence from
@@ -222,14 +223,15 @@ export class SeatActivityService {
const seat = this.ladder.get(seatNodeId);
let codexFrameWorking = false;
let codexFrameProofAt: string | undefined;
const frame = seat?.inventory?.runtime === "codex" && this.structuralActivity
? this.structuralActivity.getStructuralActivity(paneId) : null;
if (seat?.inventory?.runtime === "codex" && this.structuralActivity) {
const frame = this.structuralActivity.getStructuralActivity(paneId);
const capturedAt = frame ? Date.parse(frame.observedAt) : NaN;
const frameStarted = frame?.codexWorkingFrame ? Date.parse(frame.codexWorkingFrame.captureStartedAt) : NaN;
const proofAt = frame?.codexWorkingFrame ? Date.parse(frame.codexWorkingFrame.proofAt) : NaN;
const previousStarted = frame?.codexWorkingFrame?.previousCaptureStartedAt;
codexFrameWorking = frame !== null && frame?.state !== "attention" && Number.isFinite(capturedAt)
&& Number.isFinite(frameStarted) && frameStarted >= seat.inventorySinceMs
&& Number.isFinite(frameStarted) && this.codexFrameQualifies(seat, frame!.codexWorkingFrame!.captureStartedAt, frame!.pane, "if-known")
&& (previousStarted === undefined || Date.parse(previousStarted) >= seat.inventorySinceMs)
&& Number.isFinite(proofAt) && proofAt <= capturedAt && proofAt <= observedAt.getTime()
&& observedAt.getTime() - proofAt < DEFAULT_STRUCTURAL_STALE_MS && frameStarted <= proofAt;
@@ -245,6 +247,23 @@ export class SeatActivityService {
observedAt: paneOverridesQuietWindow ? codexFrameProofAt! : record.lastObservedAt,
activity: isActiveWithinWindow || codexFrameWorking ? "working" : "idle-at-prompt",
});
// #2515: a Codex choice prompt can be answered on screen with no hook after it. The same structural frame, under
// the working frame's boundary (captured after this seat's inventory began, and fresh), says whether a choice is
// showing (1) or the composer is complete and empty (0). Any other frame (a draft, work, an unrecognised screen)
// withdraws this source, so the prompt reads unknown again. A tracked tool approval keeps its own path.
if (seat?.inventory?.runtime === "codex" && this.structuralActivity) {
const prompt = frame && this.codexFrameQualifies(seat, frame.captureStartedAt, frame.pane, "required")
? codexPromptObservation(frame, paneId) : null;
if (prompt) {
seat.structuralPrompt = { freshMs: Date.parse(frame!.checkedAt ?? frame!.observedAt),
startedMs: Date.parse(frame!.captureStartedAt!), pane: frame!.pane! };
this.reportEvidence({ seatNodeId, sessionName: paneId, rung: "needs-input-chrome", sourceId: CODEX_STRUCTURAL_PROMPT_SOURCE,
seq, observedAt: prompt.observedAt, needsInput: prompt.needsInput });
} else if (seat.sources.delete(CODEX_STRUCTURAL_PROMPT_SOURCE)) {
seat.structuralPrompt = undefined;
this.arbitrate(seat);
}
}
if (this.selfReportReader && seat?.inventory?.rungs.some((r) => r.rung === "self-report")) {
const evd = this.selfReportReader(paneId, seatNodeId);
if (evd) this.reportEvidence(evd); // null = unreadable ⇒ the rung simply stales
@@ -416,6 +435,7 @@ export class SeatActivityService {
// auto-declared from their runtime's inventory (claude authoritative standing,
// codex hooks-at-trial, generic sampling floor) — production-complete without
// touching the launch machinery.
const identities = new SeatIdentityStore(db);
for (const r of tmuxRows) {
const known = this.ladder.get(r.node_id);
const inventory = runtimeRungInventory(r.runtime, r.community_harness, recordedRunner(r.community_runner));
@@ -426,6 +446,15 @@ export class SeatActivityService {
this.declareRungInventory({ seatNodeId: r.node_id, sessionName: r.session_name }, inventory);
this.restoreDurableHook({ seatNodeId: r.node_id, sessionName: r.session_name }, inventory, r.runtime);
}
if (r.runtime === "codex") {
// The occupant as OpenRig records it, from the identity reconciler's last verdict for this session: the
// binding's pane it checked, and the pane process it verified as this session's Codex there. It follows
// OpenRig's own in-place restart. No verdict (or a defensive read of a store without one) leaves both unknown.
const verdict = identities.getForNode(r.node_id);
const current = verdict?.sessionName === r.session_name ? verdict : null;
this.recordCodexOccupant(r.node_id, { paneId: current?.evidence.registeredPane ?? null,
pid: current?.verdict === "verified" && current.evidence.registeredPane ? current.evidence.observedPanePid ?? null : null });
}
}
// One tmux call reads every session's window activity for this tick; only with no batch at all does each seat
@@ -495,12 +524,22 @@ export class SeatActivityService {
this.sessionToSeat.set(binding.sessionName, binding.seatNodeId);
seat.inventory = inventory;
seat.inventorySinceMs = this.now().getTime();
// #2515: a new inventory is a new occupant boundary, so the copied prompt evidence from before it goes too.
seat.sources.delete(CODEX_STRUCTURAL_PROMPT_SOURCE);
seat.structuralPrompt = undefined;
seat.trust.clear();
for (const decl of inventory.rungs) seat.trust.set(decl.rung, decl.initialTrust);
seat.promotion.clear();
this.arbitrate(seat);
}
/** A Codex seat's occupant as OpenRig records it (#2515): its binding's pane and its verified pane process, or null
* where unknown. The sweep refreshes it every tick; the next arbitration applies it. */
recordCodexOccupant(seatNodeId: string, occupant: { paneId: string | null; pid: number | null }): void {
const seat = this.ladder.get(seatNodeId);
if (seat) seat.occupant = occupant;
}
/** Adapters push rung evidence (hooks, self-report, chrome, sampling). Per-source
* monotonic seq: stale or reordered reports are dropped — a late lower-seq event
* never revives an idle seat (the SubagentStop class at the service layer). */
@@ -875,8 +914,36 @@ export class SeatActivityService {
* instantly. Chosen against our 1Hz/3s cadence: the 3s silence window already
* absorbs sub-3s lulls; the 2-tick arbitration debounce absorbs the
* window-boundary flap. */
/** #2500's boundary for a Codex structural frame, shared by the working frame and the prompt evidence: captured after
* this seat's current inventory began, and, when the capture names its pane, from the occupant's own pane and process
* (`recordCodexOccupant`). A respawn, a reused pane id or another pane under the same name differs from them.
* "required" refuses a frame that doesn't name its pane, or a seat whose process OpenRig hasn't verified. */
private codexFrameQualifies(seat: SeatLadderState, startedAt: string | undefined, pane: StructuralObservation["pane"],
identity: "required" | "if-known"): boolean {
const started = startedAt ? Date.parse(startedAt) : NaN;
if (!Number.isFinite(started) || started < seat.inventorySinceMs) return false;
if (!pane) return identity !== "required";
return this.fromCodexOccupant(seat, pane, identity);
}
private fromCodexOccupant(seat: SeatLadderState, pane: { id: string; pid: number }, identity: "required" | "if-known"): boolean {
const occupant = seat.occupant;
if (occupant?.paneId && occupant.paneId !== pane.id) return false;
if (occupant?.pid != null) return occupant.pid === pane.pid;
return identity !== "required";
}
private arbitrate(seat: SeatLadderState): void {
this.checkContradiction(seat);
// #2515: the copied Codex prompt evidence holds only while its frame would: fresh, from this inventory, and from the
// occupant's pane and process. Read here, so a getter, a failed window read, a new inventory or a new occupant sees
// it expire too.
const prompt = seat.structuralPrompt;
if (prompt && (this.now().getTime() - prompt.freshMs >= DEFAULT_STRUCTURAL_STALE_MS || prompt.startedMs < seat.inventorySinceMs
|| !this.fromCodexOccupant(seat, prompt.pane, "required"))) {
seat.sources.delete(CODEX_STRUCTURAL_PROMPT_SOURCE);
seat.structuralPrompt = undefined;
}
const candidate = this.rawCandidate(seat);
let nextActivity: ActivityValue = candidate?.activity ?? "unknown";
let decidedBy: EvidenceRungId | null = candidate?.rung ?? null;
@@ -998,6 +1065,21 @@ interface NeedsInputShape {
reason: string | null;
}
const CODEX_STRUCTURAL_PROMPT_SOURCE = "codex:structural-prompt";
/** What a qualified Codex structural frame says about a prompt: a choice or permission prompt on screen (1), or a
* complete empty composer (0), observed at the capture's start. Null for anything else. */
function codexPromptObservation(frame: StructuralObservation, sessionName: string):
{ observedAt: string; needsInput: NeedsInputShape } | null {
if (frame.state === "attention" && (frame.reason === "selection_prompt" || frame.reason === "permission_prompt")) {
return { observedAt: frame.captureStartedAt!, needsInput: { count: 1, reason: frame.reason } };
}
if (frame.idleFrame?.runtime === "codex" && frame.idleFrame.sessionName === sessionName) {
return { observedAt: frame.idleFrame.captureStartedAt, needsInput: { count: 0, reason: null } };
}
return null;
}
/** One relay route's consecutive agreements toward promoting a trial rung. */
interface PromotionRun {
agreements: number;
@@ -1015,6 +1097,10 @@ interface SeatLadderState {
retired: boolean;
inventory: AdapterRungInventory | null;
inventorySinceMs: number;
/** The Codex occupant's pane and process as OpenRig records them, refreshed by the sweep (#2515). */
occupant?: { paneId: string | null; pid: number | null };
/** What keeps the copied Codex prompt evidence valid where it's read: its frame's freshness, capture start and pane. */
structuralPrompt?: { freshMs: number; startedMs: number; pane: { id: string; pid: number } };
sources: Map<string, { latest: ActivityEvidence; latestActivity: ActivityEvidence | null }>;
trust: Map<EvidenceRungId, RungTrust>;
promotion: Map<EvidenceRungId, Map<EvidenceRoute | "untagged", PromotionRun>>;
@@ -14,6 +14,11 @@ export interface StructuralObservation {
reason: string;
evidence: string | null;
observedAt: string;
/** A lower bound taken before the capture's read, when known: a reader may use the frame only for the occupant
* present by then. */
captureStartedAt?: string;
/** The pane and its process this capture read, when the batched capture reported them (#2515). */
pane?: { id: string; pid: number };
/** A complete idle frame read for this capture's target, not a generic footer or hook. */
idleFrame?: { sessionName: string; runtime: "claude-code" | "codex"; captureStartedAt: string };
/** A Codex working observation is usable only for the occupant present when capture began. */
@@ -103,12 +108,14 @@ export class SeatStructuralActivityService {
async pollSeat(sessionName: string, prefetched?: Map<string, PaneCapture> | null, runtime?: string | null, communityHarness?: string | null, prefetchedStartedAt?: Date): Promise<StructuralObservation | null> {
let content: string | null;
let observedAt: Date | null = null;
let pane: StructuralObservation["pane"];
// Completion can lag the bytes: use a lower bound taken before the actual read.
const captureStartedAt = prefetched?.has(sessionName) ? prefetchedStartedAt : this.now();
if (prefetched?.has(sessionName)) {
const capture = prefetched.get(sessionName)!;
content = capture.text;
observedAt = capture.capturedAt; // the capture's own time, never the end of the sweep (#309 review)
pane = capture.pane;
} else {
try {
content = await this.tmuxAdapter.capturePaneContent(sessionName, this.captureLines);
@@ -123,7 +130,9 @@ export class SeatStructuralActivityService {
}
const c = communityHarnessPaneActivity(classifyPaneActivity(content), communityHarness, content);
const at = observedAt ?? this.now();
const previous = this.latestBySession.get(sessionName);
const latest = this.latestBySession.get(sessionName);
// A counter or proof read from another pane or process says nothing about this one, so compare only within one.
const previous = latest?.pane && pane && (latest.pane.id !== pane.id || latest.pane.pid !== pane.pid) ? undefined : latest;
const seconds = runtime === "codex" && captureStartedAt && c.state !== "attention" ? codexWorkingElapsed(content) : null;
const previousAge = previous ? at.getTime() - Date.parse(previous.observedAt) : NaN;
const advanced = seconds !== null && previous?.codexWorkingClock && seconds > previous.codexWorkingClock.seconds
@@ -141,6 +150,8 @@ export class SeatStructuralActivityService {
reason: c.reason,
evidence: c.evidence,
observedAt: at.toISOString(),
...(captureStartedAt ? { captureStartedAt: captureStartedAt.toISOString() } : {}),
...(pane ? { pane } : {}),
...(captureStartedAt && seconds !== null ? { codexWorkingClock: { seconds, captureStartedAt: captureStartedAt.toISOString() } } : {}),
...(workingFrame ? { codexWorkingFrame: workingFrame } : {}),
...(captureStartedAt && c.state === "agent_idle" && (runtime === "claude-code" || runtime === "codex")
@@ -148,7 +159,7 @@ export class SeatStructuralActivityService {
? { idleFrame: { sessionName, runtime, captureStartedAt: captureStartedAt.toISOString() } } : {}),
};
this.latestBySession.set(sessionName, obs);
if (previous?.state !== obs.state || previous?.reason !== obs.reason) this.activityChanged?.(sessionName);
if (latest?.state !== obs.state || latest?.reason !== obs.reason) this.activityChanged?.(sessionName);
// #763: a Codex usage-limit banner is invisible to the hook vocabulary,
// so the reactive tap never fires for it. Report what the seat shows, on
// transition only (the recorder skips an already-reported banner). A poll
@@ -811,19 +811,22 @@ export class SliceIndexer {
// --- internals ---
private readSliceLocations(): SliceLocation[] {
return this.walkSliceLocations().filter((location) => !this.missionId || location.missionId === this.missionId);
return this.walkSliceLocations(false, [], undefined, this.missionId || undefined);
}
/** Every slice location under the roots, whatever mission this indexer is scoped to.
* Directory names are deduplicated (first wins) unless `physical` asks for every folder. */
/** Every slice location under the roots. A requested mission is filtered before
* directory-name deduplication; otherwise the first matching name wins unless
* `physical` asks for every folder. */
private walkSliceLocations(
physical = false,
additionalRoots: readonly string[] = [],
onReadError?: () => void,
missionId?: string,
): SliceLocation[] {
const locations: SliceLocation[] = [];
const seen = new Set<string>();
const addLocation = (location: SliceLocation) => {
if (missionId !== undefined && location.missionId !== missionId) return;
if (!physical) {
if (seen.has(location.name)) return;
seen.add(location.name);
@@ -1085,7 +1088,7 @@ export class SliceIndexer {
const status = this.mapStatus(frontmatter["status"] as string | undefined);
const railItem = this.extractRailItem(frontmatter, missionId);
const qitemIds = this.matchQitems(name, served);
const proofPacket = this.findProofPacket(name);
const proofPacket = this.findProofPacket(name, this.missionId ? missionId : null);
const lastActivityAt = this.computeLastActivity(slicePath, qitemIds, proofPacket);
return {
@@ -1115,7 +1118,7 @@ export class SliceIndexer {
const status = this.mapStatus(frontmatter["status"] as string | undefined);
const railItem = this.extractRailItem(frontmatter, missionId);
const qitemIds = this.matchQitems(name, served);
const proofPacket = this.findProofPacket(name);
const proofPacket = this.findProofPacket(name, this.missionId ? missionId : null);
const lastActivityAt = this.computeLastActivity(slicePath, qitemIds, proofPacket);
const commitRefs = this.extractCommitRefs(frontmatter);
const files = this.listSliceFiles(slicePath);
@@ -1502,7 +1505,34 @@ export class SliceIndexer {
return this.membershipIndex;
}
private findProofPacket(sliceName: string): SliceProofPacket | null {
private discoverMissionIds(): Set<string> {
const missionIds = new Set<string>();
for (const root of this.sliceRoots()) {
let entries: fs.Dirent[];
try { entries = fs.readdirSync(root, { withFileTypes: true }); }
catch { continue; }
for (const entry of entries) {
if ((!entry.isDirectory() && !entry.isSymbolicLink()) || entry.name.startsWith(".")) continue;
// Project readers treat every non-dot directory or symlink entry as a mission;
// ownership needs only its name and never follows a sibling link.
missionIds.add(entry.name);
}
}
return missionIds;
}
private proofDirectoryMissionOwner(dirName: string, missionIds: ReadonlySet<string>): string | null {
let owner: string | null = null;
for (const candidate of missionIds) {
if (!dirName.startsWith(candidate)) continue;
const separator = dirName[candidate.length];
if (separator !== "-" && separator !== "." && separator !== "_") continue;
if (!owner || candidate.length > owner.length) owner = candidate;
}
return owner;
}
private findProofPacket(sliceName: string, missionId: string | null): SliceProofPacket | null {
if (!this.dogfoodEvidenceRoot) return null;
let entries: fs.Dirent[];
try {
@@ -1511,19 +1541,26 @@ export class SliceIndexer {
return null;
}
// Match strategy: token-based. Tokenize slice name on `-`, drop
// trailing `vN` version tokens (real proof dirs typically don't
// include the version suffix), then a dir matches if every remaining
// token appears as a hyphen-bounded substring of the dir name. This
// handles real-world dir-naming where phase indicators land at the
// front (e.g. `pl005-phase-a-mission-control-queue-observability-...`)
// even though the slice folder uses suffix form
// (`mission-control-queue-observability-phase-a`). Latest mtime wins.
// Qualified proof directories use the observed `<mission>-<slice>` form:
// the literal mission ID must lead the name at a separator boundary. If
// known mission IDs overlap, the longest prefix owns the directory. This
// is a fixture-supported rule, not a historically documented contract.
// Unqualified reads retain their legacy slice-only token match. Once a
// qualified directory is attributed, slice tokens match against the
// remainder after that mission prefix and separator (`vN` handling unchanged);
// latest mtime remains the tie-break among same-mission matches.
let knownMissionIds: Set<string> | null = null;
if (missionId) {
knownMissionIds = this.discoverMissionIds();
knownMissionIds.add(missionId);
}
const sliceTokens = sliceName.split("-").filter((t) => t.length > 0 && !/^v\d+$/.test(t));
const matches: { dirent: fs.Dirent; mtime: number }[] = [];
for (const dirent of entries) {
if (!dirent.isDirectory()) continue;
const dirTokenSet = new Set(dirent.name.split(/[-._]/).filter((t) => t.length > 0));
if (knownMissionIds && this.proofDirectoryMissionOwner(dirent.name, knownMissionIds) !== missionId) continue;
const tokenSource = knownMissionIds && missionId ? dirent.name.slice(missionId.length + 1) : dirent.name;
const dirTokenSet = new Set(tokenSource.split(/[-._]/).filter((t) => t.length > 0));
const allTokensPresent = sliceTokens.every((t) => dirTokenSet.has(t));
if (!allTokensPresent) continue;
try {
@@ -4,8 +4,8 @@
// A view opens as:
// 1. journal (each viewer with its op nonce, before anything starts);
// 2. viewers started by herdr-core, outside every tab (`hc run --bg --no-parent`, openrig.* labels), each the
// view's own tile for its seat (an interactive one mirrors the seat and attaches only while someone types in
// it; a read-only one only shows it), each waiting after its tile ends instead of exiting (an exiting viewer
// view's own tile for its seat (an interactive local one shows the seat read-only until its gate opens, then
// attaches with plain tmux; a read-only one only shows it), each waiting after its tile ends instead of exiting (an exiting viewer
// makes herdr collapse the layout), and never `--keep` (that would leave a shell behind in the cell);
// 3. ONE atomic write of layouts/rig.json: the person's layout with OpenRig's stack beside their tabs, each view an
// equal, padded grid with a title row per cell;
+3 -6
View File
@@ -3,7 +3,7 @@ import { proofSourceObservation } from "../domain/proof/source-watch.js";
import * as path from "node:path";
import type { SliceIndexer } from "../domain/slices/slice-indexer.js";
import type { EventBus } from "../domain/event-bus.js";
import { JudgmentError, evidenceAt, readSliceReadiness, readMissionReadiness, readProjectReadinessAsync, recordJudgment, resolveProofScope, resolveProjectRoot, type JudgeInput } from "../domain/proof/judgments.js";
import { JudgmentError, evidenceAt, readSliceReadiness, readMissionReadiness, readProjectReadiness, recordJudgment, resolveProofScope, resolveProjectRoot, type JudgeInput } from "../domain/proof/judgments.js";
import { requireSenderIdentity, resolveRecordedProvenance } from "./require-sender-identity.js";
import { projectById, sameProjectRoot } from "../domain/workspace/project-read.js";
import { ProjectReadError } from "../domain/workspace/project-catalog.js";
@@ -78,15 +78,12 @@ export function proofRoutes(): Hono {
// freshness is reported as unverified rather than borrowing the selected workspace's state.
const observation = (c: Parameters<typeof indexer>[0], project: { id: string; root: string } | null) =>
project ? { state: "unavailable" as const, revision: "unverified" } : proofSourceObservation(c);
app.get("/", async c => {
app.get("/", c => {
const { root, evidenceRoot, scope, project } = target(c, c.req.query("scope"), c.req.query("project"), c.req.query("projectRoot"));
const common = { sourceObservation: observation(c, project), ...(project ? { project } : {}) };
// A catalog project's own root bounds policy, scope identity and evidence (never an ancestor's project.yaml).
const bound = project?.root;
if (!scope) {
const result = await readProjectReadinessAsync(root, undefined, bound);
return c.json({ ...result, ...common, sourceObservation: observation(c, project) });
}
if (!scope) return c.json({ ...readProjectReadiness(root, undefined, bound), ...common });
const dir = resolveProofScope(root, scope);
if (path.basename(path.dirname(dir)) !== "slices") return c.json({ ...readMissionReadiness(dir, undefined, bound), ...common });
const refs = c.req.queries("evidence") ?? [];
+56 -20
View File
@@ -1,5 +1,5 @@
import type { EventBus } from "../domain/event-bus.js";
import { listProjects, selectedProject, projectMission, workSource, projectReadResponse } from "../domain/workspace/project-read.js";
import { listProjects, selectedProject, projectMission, workSource, insideProject, projectReadResponse } from "../domain/workspace/project-read.js";
import { inferCatalogProject, ProjectReadError } from "../domain/workspace/project-catalog.js";
// Slice Story View v0 — HTTP routes.
//
@@ -18,7 +18,7 @@ import { inferCatalogProject, ProjectReadError } from "../domain/workspace/proje
// avoid `/proof-asset` being parsed as a slice-name.
import { Hono, type Context } from "hono";
import { readSliceReadiness, readProjectReadinessAsync } from "../domain/proof/judgments.js";
import { readSliceReadiness, readProjectReadiness } from "../domain/proof/judgments.js";
import * as fs from "node:fs";
import * as path from "node:path";
import { SliceIndexer, SliceListEntry, SliceStatus } from "../domain/slices/slice-indexer.js";
@@ -37,7 +37,7 @@ export function slicesRoutes(): Hono {
// 1) Static literal `/` BEFORE dynamic `/:name` so the list isn't
// shadowed by a slice named "list" or similar.
app.get("/", async (c) => {
app.get("/", (c) => {
const deps = getDeps(c);
if (!deps) return c.json({ error: "slices_indexer_unavailable" }, 503);
if (!deps.indexer.isReady()) {
@@ -61,7 +61,7 @@ export function slicesRoutes(): Hono {
// operation: the list rebuild, the boundToWorkflow per-slice get loop,
// and the mission sidecar share ONE membership batch (pre-scope, each
// uncached get built its own 2-scan batch: 2+2N total queue scans).
const listing = deps.indexer.withMembershipBatch(() => {
return deps.indexer.withMembershipBatch(() => {
const all = deps.indexer.list();
let filtered = filter === "all" ? all : all.filter((s) => s.status === filter);
// Workflows in Spec Library v0: optional lens filter — narrow to
@@ -102,7 +102,7 @@ export function slicesRoutes(): Hono {
complete: projectLookup.complete,
});
const authored = deps.indexer.missionAuthoredStatuses();
return {
return c.json({
slices: filtered.map((s) => ({
...s,
queueCounts: queueCountsByPath.get(s.slicePath) ?? null,
@@ -114,17 +114,13 @@ export function slicesRoutes(): Hono {
// VM-005 (release-0.4.7): additive authored mission-status sidecar so
// chip surfaces can honor authored-wins precedence without a second
// round-trip. The `slices` array itself is byte-untouched.
missions: authored,
};
missions: { ...authored, ...Object.fromEntries(readProjectReadiness(deps.indexer.slicesRoot).missions.map(m => [m.name, {
...authored[m.name],
authoredStatus: m.historicalStatus ?? authored[m.name]?.authoredStatus ?? null,
readiness: m,
}])) },
});
});
if (listing instanceof Response) return listing;
// The membership batch is synchronous and request-local; finish it before yielding to other requests.
const readiness = await readProjectReadinessAsync(deps.indexer.slicesRoot);
return c.json({ ...listing, missions: { ...listing.missions, ...Object.fromEntries(readiness.missions.map(m => [m.name, {
...listing.missions[m.name],
authoredStatus: m.historicalStatus ?? listing.missions[m.name]?.authoredStatus ?? null,
readiness: m,
}])) } });
});
// V0.3.1 slice 17 founder-walk-workspace-state-correctness (walk item 8 — Explorer auto-show): explicit cache invalidation surface.
@@ -170,15 +166,20 @@ export function slicesRoutes(): Hono {
const abs = deps.projector.resolveProofAssetPath(slice.proofPacket, relPath);
if (!abs) return c.json({ error: "proof_asset_not_found" }, 404);
let servedPath = abs;
if (selected.qualified) {
try { servedPath = insideProject(selected.projectRoot, abs); }
catch (err) { return projectReadResponse(err); }
}
const contentType = inferContentType(abs);
return fileAssetResponse(abs, contentType, c.req.header("Range"));
return fileAssetResponse(servedPath, contentType, c.req.header("Range"));
});
// 3) Doc serving for the Docs tab — markdown content of a single file
// inside the slice folder. Path-traversal guarded by the projector.
app.get("/:name/doc/*", (c) => {
const name = c.req.param("name");
const selected = selectSliceDeps(c, name);
const selected = selectSliceDeps(c, name, { includeEvidenceRoot: false });
if ("response" in selected) return selected.response;
const { deps } = selected;
const fullPath = c.req.path;
@@ -209,10 +210,39 @@ export function slicesRoutes(): Hono {
return app;
}
const UNAVAILABLE_EVIDENCE_ROOT_CODES = new Set([
"ENOENT",
"EACCES",
"EPERM",
"ENOTDIR",
"ELOOP",
"EIO",
"ENAMETOOLONG",
"ESTALE",
]);
export function qualifiedEvidenceRoot(projectRoot: string, missionsRoot: string): string | null {
const candidate = path.join(path.dirname(missionsRoot), "dogfood-evidence");
const relative = path.relative(path.resolve(projectRoot), path.resolve(candidate));
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return null;
try {
const resolved = insideProject(projectRoot, candidate);
fs.accessSync(resolved, fs.constants.R_OK | fs.constants.X_OK);
fs.readdirSync(resolved);
return resolved;
} catch (err) {
if (err instanceof ProjectReadError && err.code === "project_path_escape") return null;
const code = (err as NodeJS.ErrnoException | null | undefined)?.code;
if (typeof code === "string" && UNAVAILABLE_EVIDENCE_ROOT_CODES.has(code)) return null;
throw err;
}
}
function selectSliceDeps(
c: Context,
name: string,
): { deps: SlicesRoutesDeps; qualified: boolean } | { response: Response } {
opts: { includeEvidenceRoot?: boolean } = {},
): { deps: SlicesRoutesDeps; qualified: false } | { deps: SlicesRoutesDeps; qualified: true; projectRoot: string } | { response: Response } {
let deps = getDeps(c);
if (!deps) return { response: c.json({ error: "slices_indexer_unavailable" }, 503) };
try {
@@ -222,9 +252,15 @@ function selectSliceDeps(
if (!mission || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(name)) return { response: c.json({ error: "exact_mission_and_slice_required" }, 400) };
const dir = projectMission(project, mission);
workSource(project.root, path.join(dir, "slices", name));
const indexer = new SliceIndexer({ db: deps.indexer.db, slicesRoot: project.missionsRoot, dogfoodEvidenceRoot: null, projectId: project.id, missionId: mission });
const indexer = new SliceIndexer({
db: deps.indexer.db,
slicesRoot: project.missionsRoot,
dogfoodEvidenceRoot: (opts.includeEvidenceRoot ?? true) ? qualifiedEvidenceRoot(project.root, project.missionsRoot) : null,
projectId: project.id,
missionId: mission,
});
deps = { indexer, projector: deps.projector.withIndexer(indexer) };
return { deps, qualified: true };
return { deps, qualified: true, projectRoot: project.root };
} catch (err) { return { response: projectReadResponse(err) }; }
}
+1 -1
View File
@@ -2118,7 +2118,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
deps.sliceIndexer = sliceIndexer;
if (sliceIndexer.isReady()) {
const { watchProofSources } = await import("./domain/proof/source-watch.js");
deps.proofSourceWatch = await watchProofSources(sliceIndexer.slicesRoot, () => sliceIndexer.invalidate(), eventBus, () => {
deps.proofSourceWatch = watchProofSources(sliceIndexer.slicesRoot, () => sliceIndexer.invalidate(), eventBus, () => {
sliceIndexer.invalidate();
eventBus.emit({ type: "scope.sources_changed", scope: sliceIndexer.slicesRoot });
});
+13 -3
View File
@@ -25,6 +25,10 @@ describe("seat-stop.v1 checked stop", () => {
let onDetach: (() => void) | undefined;
let onKill: (() => void) | undefined;
let killError: Error | undefined;
let named: string | undefined;
// A stop's effect, held inside tmux to the session id and server it read.
const held = (inner: string) => "tmux if-shell -F -t '$7' '#{&&:#{==:#{session_id},$7},#{&&:#{==:#{pid},4242},#{==:#{start_time},1700000000}}}' "
+ `'${inner.replace(/'/g, "'\"'\"'")}' 'display-message -p openrig-stop-target-changed'`;
beforeEach(() => {
db = createFullTestDb();
@@ -34,13 +38,19 @@ describe("seat-stop.v1 checked stop", () => {
registry = new SessionRegistry(db);
bus = new EventBus(db);
commands = [];
named = undefined;
onProbe = onPanes = onDetach = onKill = undefined;
killError = undefined;
adapter = new TmuxAdapter(async command => {
commands.push(command);
if (command.includes("has-session")) { onProbe?.(); return ""; }
if (command.includes("list-panes")) { onPanes?.(); return "%1|0|/project|120|40|1"; }
if (command.includes("display-message")) return "$7";
if (command.includes("list-panes")) { onPanes?.(); named = /=([^:']+):/.exec(command)?.[1] ?? named; return "%1|0|/project|120|40|1"; }
// The named session carries its own seat's OpenRig marker.
if (command.startsWith("tmux show-environment")) {
const bound = db.prepare("SELECT node_id FROM bindings WHERE tmux_session = ?").get(named) as { node_id: string } | undefined;
return `OPENRIG_NODE_ID=${bound?.node_id ?? ""}`;
}
if (command.startsWith("tmux display-message")) return "$7|4242|1700000000";
if (command.includes("detach-client")) { onDetach?.(); return ""; }
if (command.includes("kill-session")) { onKill?.(); if (killError) throw killError; return ""; }
throw new Error(`unexpected tmux command: ${command}`);
@@ -129,7 +139,7 @@ describe("seat-stop.v1 checked stop", () => {
const result = await stop(a.name, expected(a.name));
expect(result).toMatchObject({ ok: true, sessionName: a.name, sessionId: a.session.id });
expect(commands.filter(command => command.includes("detach-client") || command.includes("kill-session"))).toEqual([
"tmux detach-client -s '$7'", "tmux kill-session -t '$7'",
held("detach-client -s '$7'"), held("kill-session -t '$7'"),
]);
expect(status(a.session.id)).toBe("exited");
expect(registry.getBindingForNode(a.node.id)).toBeNull();
@@ -0,0 +1,217 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { SeatActivityService } from "../src/domain/seat-activity-service.js";
import { SeatStructuralActivityService } from "../src/domain/seat-structural-activity-service.js";
import { CODEX_ACTIVITY_RUNG_INVENTORY } from "../src/domain/activity-taxonomy.js";
import { classifyPromptAfterRefusal } from "../src/domain/queue-wake-ladder.js";
// Recorded Codex screens: two native choice prompts (directory trust on 0.156.1, the update prompt on 0.160.0), a complete
// empty composer and a staged draft (0.160.0).
const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8");
const TRUST = read("./fixtures/codex-trust-0.156.1.txt");
const UPDATE = read("./fixtures/codex-update-0.160.0.txt");
const IDLE = read("./fixtures/input-box/codex-0.160.0/idle.txt");
const DRAFT = read("./fixtures/input-box/codex-0.160.0/staged-short.J.txt");
// #2500's recorded working frame, as codex-working-queue-oracle.test.ts builds it.
const RECORDED_WORKING = read("./fixtures/input-box/codex-0.160.0/after-enter-0.4s.J.txt");
const WORKING = RECORDED_WORKING.slice(RECORDED_WORKING.indexOf("• Working"))
.replace(/^• Working.*$/m, "◦ Working已(1h 56m 32s • esc to interrupt · 3 background terminals running")
.replace(/^ GPT-.*$/m, " GPT-6.1 Terra high · D:\\workspace");
type Pane = { id: string; pid: number } | undefined;
type Occupant = { paneId: string | null; pid: number | null };
function fixture(first: string) {
let time = Date.parse("2026-11-11T06:00:00.000Z"), screen = first, pane: Pane = { id: "%5", pid: 4100 };
let windowRead: number | null = time / 1000 - 60;
const session = "worker@fixture", node = "node-fixture";
const structural = new SeatStructuralActivityService({ capturePaneContent: async () => screen } as never, () => new Date(time));
const oracle = new SeatActivityService({ tmux: { readPaneLastActivity: async () => windowRead },
defaultWindowSeconds: 3, now: () => new Date(time), structuralActivity: structural } as never);
oracle.declareRungInventory({ seatNodeId: node, sessionName: session }, CODEX_ACTIVITY_RUNG_INVENTORY);
/** The occupant as OpenRig records it (the binding's pane, the process the identity reconciler verified); the sweep
* refreshes it every tick. */
const occupant = (next: Occupant) => oracle.recordCodexOccupant?.(node, next);
occupant({ paneId: "%5", pid: 4100 });
/** The sweep's batched capture: the text with the pane and process tmux reported for it in the same call. */
const capture = async () => structural.pollSeat(session, new Map([[session, { text: screen, capturedAt: new Date(time), ...(pane ? { pane } : {}) }]]),
"codex", null, new Date(time));
const tick = async (ms = 1000) => {
time += ms;
windowRead = time / 1000 - 60;
await capture();
await oracle.pollSeat(session);
};
return { oracle, node, session, structural, tick, capture, at: () => new Date(time).toISOString(),
show: (next: string) => { screen = next; }, pane: (next: Pane) => { pane = next; }, occupant, advance: (ms: number) => { time += ms; },
failWindowRead: () => { windowRead = null; },
state: () => oracle.getSeatState(node)!,
redeclare: () => oracle.declareRungInventory({ seatNodeId: node, sessionName: session }, CODEX_ACTIVITY_RUNG_INVENTORY),
verdict: (refusedAt: string) => classifyPromptAfterRefusal(oracle.getSeatState(node), refusedAt) };
}
describe("a Codex choice prompt read from the structural frame (#2515)", () => {
it.each([["directory trust (0.156.1)", TRUST], ["update (0.160.0)", UPDATE]])(
"a native %s choice reads as needs-input; once answered, an empty composer reads as a clear observed after the refusal", async (_name, choice) => {
const f = fixture(choice);
await f.tick();
expect(f.state()).toMatchObject({ needsInput: { count: 1, reason: "selection_prompt" }, needsInputEvidence: { rung: "needs-input-chrome" } });
const refusedAt = f.at();
expect(f.verdict(refusedAt)).toBe("blocked");
f.show(IDLE);
await f.tick();
expect(f.state()).toMatchObject({ needsInput: { count: 0, reason: null }, needsInputEvidence: { rung: "needs-input-chrome" } });
expect(f.verdict(refusedAt)).toBe("clear");
// The clear is observed at the capture's start: a refusal after it isn't cleared by it.
expect(f.verdict(new Date(Date.parse(f.at()) + 1).toISOString())).toBe("unknown");
});
it.each([["a staged draft", DRAFT], ["an unrecognised screen", "some provider output\nwith no composer"]])(
"%s after the choice never clears: the prompt reads unknown again", async (_name, screen) => {
const f = fixture(TRUST);
await f.tick();
const refusedAt = f.at();
f.show(screen);
await f.tick();
expect(f.state().needsInputEvidence).toBeNull();
expect(f.verdict(refusedAt)).toBe("unknown");
});
it("a frame captured before the seat's current inventory isn't used (it may be another occupant's screen)", async () => {
const f = fixture(IDLE);
await f.capture();
f.advance(500);
// A new occupant's inventory is declared after that capture; no capture has happened since.
f.redeclare();
const refusedAt = new Date(Date.parse(f.at()) - 1000).toISOString();
await f.oracle.pollSeat(f.session); // the sampler reads the cached frame; no new capture
expect(f.state().needsInputEvidence).toBeNull();
expect(f.verdict(refusedAt)).toBe("unknown");
// The next capture is the current occupant's, and counts.
await f.tick();
expect(f.verdict(refusedAt)).toBe("clear");
});
it("a tracked tool approval keeps its own path: an empty composer doesn't clear a pending tracked request", async () => {
const f = fixture(IDLE);
f.oracle.reportEvidence({ seatNodeId: f.node, sessionName: f.session, rung: "lifecycle-hooks", sourceId: "codex:hooks",
seq: 1, observedAt: f.at(), activity: "working", permissionRequest: { id: "tracked-approval" } } as never);
const refusedAt = f.at();
await f.tick();
// The structural zero is ignored while the request is tracked; its own probe decides it.
expect(f.state().needsInputEvidence?.rung ?? null).not.toBe("needs-input-chrome");
expect(f.verdict(refusedAt)).not.toBe("clear");
});
});
describe("the copied prompt evidence expires where it's read (#2952 R1-F1)", () => {
const clearedAfterRefusal = async () => {
const f = fixture(TRUST);
await f.tick();
const refusedAt = f.at();
f.show(IDLE);
await f.tick();
expect(f.verdict(refusedAt)).toBe("clear");
return { f, refusedAt };
};
it("on a getter alone, past the structural window with no capture", async () => {
const { f, refusedAt } = await clearedAfterRefusal();
f.advance(6000);
expect(f.verdict(refusedAt)).toBe("unknown");
expect(f.state().needsInputEvidence).toBeNull();
});
it("when the window read fails, past the structural window", async () => {
const { f, refusedAt } = await clearedAfterRefusal();
f.advance(6000);
f.failWindowRead();
await f.oracle.pollSeat(f.session);
expect(f.verdict(refusedAt)).toBe("unknown");
});
it("when the seat's inventory is declared again, without any new capture", async () => {
const { f, refusedAt } = await clearedAfterRefusal();
f.redeclare();
expect(f.verdict(refusedAt)).toBe("unknown");
});
it("a fresh clear stays positive while its frame is current", async () => {
const { f, refusedAt } = await clearedAfterRefusal();
f.advance(2000);
expect(f.verdict(refusedAt)).toBe("clear");
});
});
describe("the frame must come from the occupant's own pane and process (#2952 R1-F2)", () => {
it.each([
["a same-name successor (a new process in the pane)", { id: "%5", pid: 4200 }],
["a reused pane id held by another process", { id: "%5", pid: 9999 }],
["another pane under the same session name", { id: "%9", pid: 4100 }],
])("%s is refused: the prompt reads unknown, never clear", async (_name, other) => {
const f = fixture(TRUST);
await f.tick(); // the occupant is %5 / 4100
const refusedAt = f.at();
f.show(IDLE);
f.pane(other);
await f.tick();
expect(f.state().needsInputEvidence).toBeNull();
expect(f.verdict(refusedAt)).toBe("unknown");
// Once OpenRig records that pane and process as the occupant (a managed swap's binding, or the reconciler verifying
// the process), its frames count.
f.occupant({ paneId: other.id, pid: other.pid });
await f.tick();
expect(f.verdict(refusedAt)).toBe("clear");
});
it("a capture that doesn't name its pane (the per-seat fallback read) gives no prompt evidence", async () => {
const f = fixture(IDLE);
f.pane(undefined);
await f.tick();
expect(f.state().needsInputEvidence).toBeNull();
});
});
describe("the identity is the occupant's as OpenRig records it, not the first frame's (#2952 round 2)", () => {
it("a replacement before the seat's first frame is refused, so it can't clear an earlier refusal (R2-F1)", async () => {
const f = fixture(IDLE);
const refusedAt = f.at();
f.pane({ id: "%5", pid: 4200 }); // replaced behind the same name before any frame was read
await f.tick();
expect(f.state().needsInputEvidence).toBeNull();
expect(f.verdict(refusedAt)).toBe("unknown");
});
it("a process OpenRig hasn't verified gives no prompt evidence", async () => {
const f = fixture(IDLE);
f.occupant({ paneId: "%5", pid: null });
const refusedAt = f.at();
await f.tick();
expect(f.state().needsInputEvidence).toBeNull();
expect(f.verdict(refusedAt)).toBe("unknown");
});
it("OpenRig's in-place restart (same pane, new process): once the reconciler verifies the process, the prompt clear works", async () => {
const f = fixture(TRUST);
await f.tick();
const refusedAt = f.at();
f.pane({ id: "%5", pid: 4300 });
f.show(IDLE);
await f.tick();
// Until the identity reconciler verifies the new process, its frames aren't the occupant's.
expect(f.verdict(refusedAt)).toBe("unknown");
f.occupant({ paneId: "%5", pid: 4300 });
await f.tick();
expect(f.verdict(refusedAt)).toBe("clear");
});
it("OpenRig's in-place restart keeps #2500's working frame once the reconciler verifies the process", async () => {
const f = fixture(WORKING);
await f.tick();
expect(f.state()).toMatchObject({ activity: "working", decidedBy: "window-sampling" });
f.pane({ id: "%5", pid: 4300 });
f.occupant({ paneId: "%5", pid: 4300 });
await f.tick();
await f.tick();
expect(f.state()).toMatchObject({ activity: "working", decidedBy: "window-sampling" });
});
});
@@ -221,6 +221,38 @@ it("does not renew an adjacent frame using a predecessor's counter", async () =>
expect(oracle.getSeatState(node)?.activity).toBe("idle-at-prompt");
});
it.each([
{ name: "the same counter", predecessorAdjacent: true, step: 0 },
{ name: "an advanced counter", predecessorAdjacent: false, step: 1 },
])("does not credit a restarted pane process with $name from the process before it", async ({ predecessorAdjacent, step }) => {
let time = Date.parse("2026-11-04T20:00:00.000Z"), elapsed = 32, pid = 4100;
const session = "worker@fixture", node = "node-fixture";
const structural = new SeatStructuralActivityService({ capturePaneContent: async () => null } as never, () => new Date(time));
const capture = (adjacent: boolean) => {
const at = new Date(time);
const frame = screen.replace("32s • esc to interrupt", `${elapsed}s • esc to interrupt`);
const text = adjacent ? frame : frame.replace("\n› Ask Codex", "\n• Edited files\n\n› Ask Codex");
return structural.pollSeat(session, new Map([[session, { text, capturedAt: at, pane: { id: "%5", pid } }]]), "codex", null, at);
};
const oracle = new SeatActivityService({ tmux: { readPaneLastActivity: async () => time / 1000 - 60 },
defaultWindowSeconds: 3, now: () => new Date(time), structuralActivity: structural } as never);
oracle.declareRungInventory({ seatNodeId: node, sessionName: session }, CODEX_ACTIVITY_RUNG_INVENTORY);
oracle.recordCodexOccupant(node, { paneId: "%5", pid });
await capture(predecessorAdjacent);
// An in-place restart: OpenRig verifies the new process in the same pane, and the inventory is unchanged.
time += 1000; elapsed += step; pid = 4200;
oracle.recordCodexOccupant(node, { paneId: "%5", pid });
const replaced = await capture(false);
await oracle.pollSeat(session);
expect(oracle.getSeatState(node)?.activity).toBe("idle-at-prompt");
expect(replaced?.codexWorkingFrame).toBeUndefined();
// The new process's own advancing counter still counts.
time += 1000; elapsed++;
await capture(false);
await oracle.pollSeat(session);
expect(oracle.getSeatState(node)?.activity).toBe("working");
});
it("does not treat counter readings separated by a capture outage as continuous activity", async () => {
let time = Date.parse("2026-11-04T20:00:00.000Z"), elapsed = 32;
const session = "worker@fixture", node = "node-fixture";
@@ -329,8 +329,17 @@ exec "$@"
writePreset("free-harness.yaml", ["format: 1", "name: free-harness", `command: ${JSON.stringify(`node ${shellQuote(appScript)}`)}`, "maintainer: Example Maintainer", ""].join("\n"));
await sleep(4_000);
await check();
// A relaunch doesn't start the same-named app in its place.
expect((await rig(["down", "free-rig", "--json", "--force"])).code).toBe(0);
// A relaunch doesn't start the same-named app in its place. The self-attached session is the person's own terminal,
// which OpenRig didn't create, so rig down leaves it running and says so (exit 2) instead of stopping a terminal it can't
// show is the seat's.
const down = await rig(["down", "free-rig", "--json", "--force"]);
expect(down.code, JSON.stringify(down)).toBe(2);
expect(JSON.parse(down.stdout).errors).toEqual([expect.stringMatching(
/^Kill failed for session '[^']+': '[^']+' was left running: its tmux session \$\d+ doesn't carry this seat's OpenRig marker/)]);
// The manual path it names: the person stops their own terminal on the daemon's server, then runs rig down again.
execFileSync("tmux", ["-S", scaffold.tmuxSocketPath, "kill-session", "-t", "=free-seat"], { env: scaffold.env as NodeJS.ProcessEnv });
const again = await rig(["down", "free-rig", "--json", "--force"]);
expect(JSON.parse(again.stdout).errors, JSON.stringify(again)).toEqual([]);
// A deliberate fresh launch of the seat gets main's refusal (a self-attached seat has no startup context, checked
// before any adapter lookup) and starts nothing.
const seatLaunch = await rig(["seat", "launch", "work.free@free-rig", "--fresh", "--reason", "relaunch a free-text seat", "--json"], 60_000);
@@ -24,7 +24,6 @@ import { queueItemsSchema } from "../src/db/migrations/024_queue_items.js";
import { queueTransitionsSchema } from "../src/db/migrations/025_queue_transitions.js";
import { queueItemSummarySchema } from "../src/db/migrations/044_queue_item_summary.js";
import { buildExecutionView, type ExecutionViewDeps } from "../src/domain/execution-view.js";
import { readProjectReadiness } from "../src/domain/proof/judgments.js";
const SHA = "a".repeat(40);
const REGISTRY_FILES = 450;
@@ -121,25 +120,4 @@ describe("execution view review registry", () => {
expect(turnsAtRead[REGISTRY_FILES - 1]! - turnsAtRead[0]!).toBeGreaterThan(0);
expect(turnsAtRead).toHaveLength(REGISTRY_FILES);
});
it("also yields inside the later whole-project readiness read, preserving its projection", async () => {
const deps = fixture("turns", 2), missions = deps.slicesRoot()!;
for (const name of ["beta", "gamma"]) {
fs.mkdirSync(path.join(missions, name));
fs.writeFileSync(path.join(missions, name, "mission.yaml"), "kind: mission\ncomposition: { slices: [] }\n");
}
const first = fs.readdirSync(missions).slice(0, -1).find(name => name !== "turns")!;
let started = false, yielded = false;
hooks.onRead = file => {
if (!started && file === path.join(missions, first, "mission.yaml")) {
started = true;
setImmediate(() => { yielded = true; });
}
};
const doc = await buildExecutionView(deps, { mission: "turns" });
expect(started).toBe(true);
expect(yielded, "earlier registry yields must not hide a later synchronous project walk").toBe(true);
hooks.onRead = null;
expect(doc.project_readiness).toEqual(readProjectReadiness(missions));
});
});
+3 -2
View File
@@ -35,9 +35,10 @@ try {
const seats = new Map();
for (const member of ["a", "b", "c", "sibling"]) {
const name = `${member}@shrink-fixture`;
await native(["new-session", "-d", "-s", name, "sleep 120"]);
const pane = (await native(["list-panes", "-t", name, "-F", "#{pane_id}"])).trim();
const node = rigRepo.addNode(rig.id, member, { runtime: "terminal", ...(member === "sibling" ? {} : { podId: pod.id }) });
// As OpenRig launches a seat: its session carries the node's OPENRIG_NODE_ID, which a guarded stop requires.
await native(["new-session", "-d", "-s", name, "-e", `OPENRIG_NODE_ID=${node.id}`, "sleep 120"]);
const pane = (await native(["list-panes", "-t", name, "-F", "#{pane_id}"])).trim();
const session = sessionRegistry.registerSession(node.id, name);
sessionRegistry.updateStatus(session.id, "running");
sessionRegistry.updateBinding(node.id, { tmuxSession: name, tmuxPane: pane });
@@ -4,15 +4,18 @@
// Harness adapted from review50-r2's PR #1001 review (real hook route, SQLite store, activity service, transport,
// parked-owner policy, queue and ladder; only terminal I/O and the final orchestrator delivery are inert).
import { it, expect, vi, afterEach } from "vitest";
import { readFileSync } from "node:fs";
import { Hono } from "hono";
import type Database from "better-sqlite3";
import { createFullTestDb } from "./helpers/test-app.js";
import { RigRepository } from "../src/domain/rig-repository.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
import { SeatIdentityStore } from "../src/domain/seat-identity-store.js";
import { AgentActivityStore } from "../src/domain/agent-activity-store.js";
import { EventBus } from "../src/domain/event-bus.js";
import { SessionTransport } from "../src/domain/session-transport.js";
import { SeatActivityService } from "../src/domain/seat-activity-service.js";
import { SeatStructuralActivityService } from "../src/domain/seat-structural-activity-service.js";
import { activityRoutes } from "../src/routes/activity.js";
import { QueueRepository } from "../src/domain/queue-repository.js";
import { makeParkedOwnerConsumerPolicy, makeRigAnchor, PARKED_OWNER_POLICY_NAME, REFUSED_PREFIX } from "../src/domain/policies/parked-owner-consumer.js";
@@ -22,7 +25,8 @@ import { startWakeLadderScheduler } from "../src/index.js";
const databases: Database.Database[] = [];
afterEach(() => { for (const db of databases.splice(0)) db.close(); });
function fixture(runtime = "claude-code", permissionPromptReader?: (sessionName: string) => Promise<boolean | null>, pane = "unrecognized provider content") {
function fixture(runtime = "claude-code", permissionPromptReader?: (sessionName: string) => Promise<boolean | null>, pane = "unrecognized provider content",
withStructural = false) {
const db = createFullTestDb(); databases.push(db);
const rigRepo = new RigRepository(db), sessionRegistry = new SessionRegistry(db), bus = new EventBus(db);
const rig = rigRepo.createRig("r1001"), node = rigRepo.addNode(rig.id, "worker.a", { role: "worker", runtime });
@@ -36,13 +40,16 @@ function fixture(runtime = "claude-code", permissionPromptReader?: (sessionName:
isRegisteredOccupantGeneration: (id, g) => Boolean(db.prepare("SELECT 1 FROM occupant_tenures WHERE node_id=? AND generation_uuid=?").get(id, g)),
});
const paste = vi.fn(async () => ({ ok: true as const })), enter = vi.fn(async () => ({ ok: true as const }));
const screen = { value: pane };
const tmux = { hasSession: async () => true, probeSession: async () => ({ state: "present" as const }),
getPaneCommand: async () => runtime === "codex" ? "codex" : "claude", getPanePid: async () => null,
listPanes: async () => [], hasSessionEnv: async () => false, capturePaneContent: async () => pane,
listPanes: async () => [], hasSessionEnv: async () => false, capturePaneContent: async () => screen.value,
sendText: paste, sendKeys: enter, readPaneLastActivity: async () => now().getTime() / 1000 - 120,
};
// The structural capture over the same pane, when a cell needs the oracle to read it (#2515).
const structural = withStructural ? new SeatStructuralActivityService(tmux as never, now) : undefined;
const newOracle = () => new SeatActivityService({ tmux: tmux as never, defaultWindowSeconds: 3, now,
permissionPromptReader,
permissionPromptReader, structuralActivity: structural,
permissionPromptChanged: permissionPromptReader ? (sessionName, confirmed, permissionRuntime) => {
const latest = store.getLatestForNode({ sessionName });
store.recordHookEvent({ sessionName, runtime: permissionRuntime, generation: latest?.generation,
@@ -70,7 +77,7 @@ function fixture(runtime = "claude-code", permissionPromptReader?: (sessionName:
getSeatState: (nodeId) => oracle.getSeatState(nodeId),
getLatestHook: (sessionName) => store.getLatestForNode({ sessionName }) });
return { db, bus, node, name, store, hook, transport, paste, enter, clock, readPromptState,
oracle: () => oracle, reconstruct: () => { oracle = newOracle(); },
oracle: () => oracle, reconstruct: () => { oracle = newOracle(); }, structural, show: (next: string) => { screen.value = next; },
changeGeneration: (g: string | null) => { currentGeneration = g; } };
}
@@ -302,3 +309,75 @@ it("the production scheduler keeps a current Claude prompt alert when an older S
vi.useRealTimers();
}
});
// #2515: a native Codex choice that the person answers on screen, with no hook after it. The structural frame observes the
// choice and then the empty composer, so the refused wake isn't escalated, and the next wake is sent once.
const codexScreen = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8");
it("a Codex wake refused at a native choice the person then answers: no escalation, and the next wake is sent once", async () => {
const f = fixture("codex", undefined, codexScreen("./fixtures/codex-trust-0.156.1.txt"), true);
// The occupant as OpenRig records it: the binding's pane, and the pane process the identity reconciler verified.
new SessionRegistry(f.db).updateBinding(f.node.id, { tmuxPane: "%5" });
new SeatIdentityStore(f.db).upsert({ nodeId: f.node.id, verdict: "verified", evidenceSource: null, reason: null,
evidence: { registeredPane: "%5", observedPanePid: 4100, observedPid: 4101, observedCommand: "codex", matchedLayer: 1 },
sessionName: f.name, observedAt: f.clock.value.toISOString() });
// The sweep's batched capture: the screen with the pane and process tmux reported for it.
const screen = { value: codexScreen("./fixtures/codex-trust-0.156.1.txt") };
const observe = async () => {
await f.structural!.pollSeat(f.name, new Map([[f.name, { text: screen.value, capturedAt: f.clock.value, pane: { id: "%5", pid: 4100 } }]]),
"codex", null, f.clock.value);
await f.oracle().pollAllRunningTmuxSeats(f.db);
};
await observe();
expect(f.oracle().getSeatState(f.node.id)?.needsInput).toMatchObject({ count: 1, reason: "selection_prompt" });
const repo = new QueueRepository(f.db, f.bus, { validateRig: () => true });
const row = await repo.create({ sourceSession: "planner@r1001", destinationSession: f.name,
body: "finish synthetic assigned work", summary: "synthetic obligation", nudge: false });
repo.claim({ qitemId: row.qitemId, destinationSession: f.name });
const history: Array<Record<string, unknown>> = [];
const policy = makeParkedOwnerConsumerPolicy({
diagnoseRig: () => ({ seats: [{ sessionName: f.name, parked: true,
activity: { value: "idle-at-prompt", needsInput: { count: 0, reason: null } },
obligations: { items: [{ qitemId: row.qitemId, state: "in-progress", summary: row.summary }], held: [] } }] }),
history: { listForJob: () => history as never, countForJob: () => history.length },
rows: { listTransitions: (id) => repo.listTransitions(id),
appendNote: (id, note) => { repo.update({ qitemId: id, actorSession: "watchdog@system", transitionNote: note }); return { ok: true }; },
recordNudgeResult: (id, result) => repo.recordNudgeAttempt(id, result), listOpenIds: () => [row.qitemId],
recoveryOwnsWake: (id) => queueRecoveryOwnsWake(f.db, repo.getById(id)),
},
});
const job = { jobId: "fixture-job", policy: PARKED_OWNER_POLICY_NAME, target: { session: makeRigAnchor("r1001") }, context: {} };
const wake = await policy.evaluate(job as never);
expect(wake.action).toBe("send");
const sent = await f.transport.send(f.name, "synthetic assigned-work wake");
expect(sent).toMatchObject({ ok: false, reason: "target_needs_input" });
history.push({ historyId: "history", jobId: job.jobId, evaluatedAt: new Date().toISOString(), outcome: "sent", skipReason: null,
deliveryTargetSession: f.name, deliveryStatus: "failed", deliveryMessage: "synthetic wake",
evaluationNotes: { ...(wake as { notes?: Record<string, unknown> }).notes, deliveryReason: sent.error } });
await policy.evaluate(job as never);
const refused = repo.listTransitions(row.qitemId).find((t) => t.transitionNote?.startsWith(REFUSED_PREFIX));
expect(refused?.transitionNote).toContain("(selection_prompt)");
expect(f.readPromptState(f.name, refused!.ts)).toBe("blocked"); // still open: nothing is typed into it
// The person answers the choice; no hook follows.
f.show(codexScreen("./fixtures/input-box/codex-0.160.0/idle.txt"));
screen.value = codexScreen("./fixtures/input-box/codex-0.160.0/idle.txt");
f.clock.value = new Date(Date.now() + 2_000);
await observe();
expect(f.readPromptState(f.name, refused!.ts)).toBe("clear");
const targets: string[] = [];
const alerts = () => repo.list({ state: ["pending", "in-progress", "blocked"], limit: 1000 })
.filter((r) => r.tags?.includes("wake-prompt-refusal"));
for (const ageMs of [2_000, 3_600_000]) {
f.clock.value = new Date(Date.now() + ageMs);
await observe();
await runWakeLadderTick({ db: f.db, queueRepo: repo, now: f.clock.value,
resolveOrchestrator: () => "lead@r1001", readPromptState: f.readPromptState, retryIntervalSeconds: 1,
attemptWake: async (_id, target) => { targets.push(target); return "verified"; }, log: () => {} });
}
expect(targets).not.toContain("lead@r1001");
expect(alerts()).toHaveLength(0);
expect(f.paste).not.toHaveBeenCalled();
// The next wake goes through normal delivery, once.
expect((await policy.evaluate(job as never)).action).toBe("send");
expect((await f.transport.send(f.name, "synthetic assigned-work wake")).ok).toBe(true);
expect(f.paste).toHaveBeenCalledTimes(1);
});
@@ -65,7 +65,7 @@ describe("a hosted member with a webhook trigger is rung when work lands for it"
expect(outcome.nudgeResult).not.toContain(url);
});
it("records a failed ring for an error, a redirect it doesn't follow, a missing secret and a trigger that doesn't answer", async () => {
it("records a failed ring for an error, a redirect it doesn't follow and a missing secret", async () => {
reply = { status: 500 };
expect(await ringHostedWake(ring, wake, { env: env() })).toEqual({ classified: "failed", nudgeResult: "failed:webhook (GROK_WAKE_URL) answered 500" });
@@ -77,9 +77,15 @@ describe("a hosted member with a webhook trigger is rung when work lands for it"
expect((await ringHostedWake(ring, wake, { env: { GROK_WAKE_URL: url } })).nudgeResult)
.toBe("failed:webhook (GROK_WAKE_URL) not rung: its Authorization value (GROK_WAKE_AUTH) isn't set");
expect(received).toHaveLength(2);
});
// The ring reached a trigger that then didn't answer in time, so it may have started a run: unknown, like an unconfirmed
// send, never failed.
it("a trigger that doesn't answer in time reads unconfirmed, not failed", async () => {
reply = "hang";
expect((await ringHostedWake(ring, wake, { env: env(), timeoutMs: 100 })).nudgeResult).toBe("failed:webhook (GROK_WAKE_URL) didn't answer within 100 ms");
expect(await ringHostedWake(ring, wake, { env: env(), timeoutMs: 100 })).toEqual({ classified: "indeterminate",
nudgeResult: "indeterminate:webhook (GROK_WAKE_URL) didn't answer within 100 ms, so it may have the ring; it isn't rung again, and pickup is the member's claim on this row" });
expect(received).toHaveLength(1);
});
it("through the queue: work for the member rings its trigger from the roster's secrets file, never its terminal, and a claim stays a separate fact", async () => {
@@ -126,7 +132,7 @@ describe("a hosted member with a webhook trigger is rung when work lands for it"
// Real topology and a repository whose trigger comes from a secrets file, as on a running daemon.
// A repository over the same database stands in for the daemon after a restart; `transport: false` for one that
// stops before sending.
function hostedRepo(db: Database.Database, envLines: string, declared: RosterWake["webhook"] = wake.webhook, opts: { transport?: boolean } = {}) {
function hostedRepo(db: Database.Database, envLines: string, declared: RosterWake["webhook"] = wake.webhook, opts: { transport?: boolean; fetchImpl?: typeof fetch } = {}) {
db.prepare("INSERT OR IGNORE INTO rigs (id, name) VALUES ('rig-lab', 'lab')").run();
db.prepare("INSERT OR IGNORE INTO nodes (id, rig_id, logical_id, runtime, cwd) VALUES ('node-lead', 'rig-lab', 'lead', 'claude-code', '/tmp')").run();
const secrets = join(dir, "grok.env");
@@ -136,6 +142,7 @@ describe("a hosted member with a webhook trigger is rung when work lands for it"
loadHumanRegistry: () => ({ ok: true, entities: [] }),
...(opts.transport === false ? {} : { transport: { send: async () => ({ ok: true, verified: true }) } }),
hostedWake: (destination) => destination === "grok@lab" ? { webhook: { ...declared, secrets_env_file: secrets } } : null,
...(opts.fetchImpl ? { hostedWakeFetch: opts.fetchImpl } : {}),
});
}
@@ -184,6 +191,56 @@ describe("a hosted member with a webhook trigger is rung when work lands for it"
}
});
// A trigger that timed out may already be running the work, so startup recovery doesn't ring it again; a refused
// connection never left, so recovery still retries it. A timed-out ring nobody claims is still surfaced to a person.
const ringDouble = (first: "timeout" | "refused") => {
const rings: string[] = [];
let next: "timeout" | "refused" | "accept" = first;
const fetchImpl = (async () => {
rings.push(next);
if (next === "timeout") throw Object.assign(new Error("The operation was aborted due to timeout"), { name: "TimeoutError" });
if (next === "refused") throw Object.assign(new TypeError("fetch failed"), { cause: { code: "ECONNREFUSED" } });
return new Response(null, { status: 202 });
}) as typeof fetch;
return { rings, fetchImpl, accept: () => { next = "accept"; } };
};
const firstResult = (first: "timeout" | "refused") => first === "timeout"
? /^indeterminate:webhook \(GROK_WAKE_URL\) didn't answer within 10000 ms, so it may have the ring/
: /^failed:webhook \(GROK_WAKE_URL\) couldn't be reached \(ECONNREFUSED\)/;
const envLines = () => `GROK_WAKE_URL=${url}\nGROK_WAKE_AUTH=Bearer test-only-value\n`;
it("startup recovery doesn't ring a ring that timed out again; a refused one it rings again", async () => {
for (const first of ["timeout", "refused"] as const) {
const { rings, fetchImpl, accept } = ringDouble(first);
const db = new Database(":memory:");
migrate(db, ALL_MIGRATIONS);
const repo = hostedRepo(db, envLines(), wake.webhook, { fetchImpl });
repo.attachOutbox(new OutboxHandler(db));
const row = await repo.create({ sourceSession: "lead@lab", destinationSession: "grok@lab", body: "work", summary: "Work" });
await vi.waitFor(() => expect(repo.getById(row.qitemId)!.lastNudgeWireResult).toMatch(firstResult(first)));
accept();
await repo.drainPendingWakeIntents();
expect(rings).toEqual(first === "timeout" ? ["timeout"] : ["refused", "accept"]);
db.close();
}
});
it("a ring that timed out and was never claimed still reaches the member's person through the stuck sweep", async () => {
const { fetchImpl } = ringDouble("timeout");
const db = new Database(":memory:");
migrate(db, ALL_MIGRATIONS);
const repo = hostedRepo(db, envLines(), wake.webhook, { fetchImpl });
const row = await repo.create({ sourceSession: "lead@lab", destinationSession: "grok@lab", body: "work", summary: "Work" });
expect(repo.getById(row.qitemId)!.lastNudgeWireResult).toMatch(firstResult("timeout"));
await runStuckSweep({ db, queueRepo: repo, status: createStuckSweepStatus(), resolveOrchestrator: () => null, unclaimedAgeMinutes: 60,
now: new Date(Date.now() + 2 * 3_600_000), log: () => {}, isRegisteredHost: () => false,
resolveOperatorSeat: () => "person@external", resolveRecoveryAgentSeat: () => null });
const finding = repo.list({ limit: 50 }).find((item) => (item.tags ?? []).includes(STUCK_SWEEP_FINDING_TAG));
expect(finding).toMatchObject({ destinationSession: "person@external" });
expect(finding!.body).toContain(row.qitemId);
db.close();
});
it("a new wake for the same row rings: parked on a blocker that completes, the member is rung again, and recovery adds none", async () => {
const db = new Database(":memory:");
migrate(db, ALL_MIGRATIONS);
+3 -6
View File
@@ -265,7 +265,7 @@ describe("retained authority negative controls", () => {
const sliceRevision = readSliceReadiness(f.alpha).revision;
const events: Array<{ type: string; revision: string }> = [];
let invalidated = 0;
const watch = await watchProofSources(f.missions, () => { invalidated++; }, {
const watch = watchProofSources(f.missions, () => { invalidated++; }, {
emit: (event: { type: string; revision: string }) => events.push(event),
} as unknown as EventBus);
try {
@@ -296,11 +296,8 @@ describe("retained authority negative controls", () => {
it("pushes changed source truth, keeps sibling basis, and ignores unchanged source bytes", async () => {
const f = fixture(); f.judge(); const sibling = readSliceReadiness(f.beta).revision;
const events: Array<{ type: string; revision: string }> = []; let invalidated = 0;
const watch = await watchProofSources(f.missions, () => { invalidated++; }, { emit: (e: { type: string; revision: string }) => events.push(e) } as unknown as EventBus);
const watch = watchProofSources(f.missions, () => { invalidated++; }, { emit: (e: { type: string; revision: string }) => events.push(e) } as unknown as EventBus);
try {
// Creation events may interrupt asynchronous setup; count changes only after the initial basis settles.
await expect.poll(() => watch.observation().state, { timeout: 5000 }).toBe("watching");
events.length = 0; invalidated = 0;
const file = join(f.alpha, "proof/evidence.md"); fs.writeFileSync(file, fs.readFileSync(file));
await new Promise(r => setTimeout(r, 100)); expect(events).toEqual([]);
fs.writeFileSync(file, "Measured outcome changed.\n");
@@ -453,7 +450,7 @@ it("notifies scope followers on a node-header change even when proof readiness i
const f = fixture(), file = join(f.alpha, "SPEC.md");
const original = fs.readFileSync(file, "utf8"), before = readMissionReadiness(f.mission).revision;
let scopeChanges = 0;
const watch = await watchProofSources(f.missions, () => {}, { emit: () => {} } as unknown as EventBus, () => { scopeChanges++; });
const watch = watchProofSources(f.missions, () => {}, { emit: () => {} } as unknown as EventBus, () => { scopeChanges++; });
try {
const updated = original.replace("id: 01-alpha", "id: renamed-alias");
fs.writeFileSync(file, updated);
+47 -54
View File
@@ -153,15 +153,8 @@ describe("request-local proof-policy reuse", () => {
});
});
async function startWatch(...args: Parameters<typeof watchProofSources>) {
const opening = watchProofSources(...args);
// Startup itself now yields; settle its cooperative turns under the fixture clock.
await vi.runAllTimersAsync();
return opening;
}
describe("watch-local proof-policy reuse", () => {
async function observe(missions: string) {
function observe(missions: string) {
vi.useFakeTimers();
// Each watched directory's listener, by path: "a change" is delivered by the missions root's own watch.
const listeners = new Map<string, () => void>();
@@ -170,13 +163,13 @@ describe("watch-local proof-policy reuse", () => {
return { on: vi.fn(), close: vi.fn() };
}) as unknown as typeof fs.watch);
const invalidate = vi.fn(), emit = vi.fn();
const watch = await startWatch(missions, invalidate, { emit } as unknown as EventBus);
const watch = watchProofSources(missions, invalidate, { emit } as unknown as EventBus);
const changed = () => listeners.get(missions)!();
const oracle = () => createHash("sha256").update(JSON.stringify(readProjectReadiness(missions).missions.map(m => [m.name, m.revision]))).digest("hex");
const event = async () => {
const event = () => {
const previous = watch.observation().revision, calls = invalidate.mock.calls.length;
const expected = oracle();
changed(); await vi.advanceTimersByTimeAsync(50);
changed(); vi.advanceTimersByTime(50);
expect(watch.observation()).toEqual({ state: "watching", revision: expected });
expect(invalidate).toHaveBeenCalledTimes(calls + Number(previous !== expected));
expect(emit).toHaveBeenCalledTimes(invalidate.mock.calls.length);
@@ -184,17 +177,17 @@ describe("watch-local proof-policy reuse", () => {
return { watch, changed, event, oracle, invalidate };
}
it("reads/parses shared policy once in each real watch callback, preserving the uncached revision", async () => {
it("reads/parses shared policy once in each real watch callback, preserving the uncached revision", () => {
const f = fixture();
fs.cpSync(f.mission, path.join(f.missions, "second"), { recursive: true });
const o = await observe(f.missions), read = vi.spyOn(fs, "readFileSync"), parse = vi.spyOn(YAML, "parse");
const o = observe(f.missions), read = vi.spyOn(fs, "readFileSync"), parse = vi.spyOn(YAML, "parse");
const policy = fs.readFileSync(f.projectFile, "utf8");
try {
for (const text of ["changed outcome", "second outcome"]) {
f.write(path.join(f.dirs[0]!, "SPEC.md"), `# Outcome\n\n## Proof contract\n- [ ] ${text}.\n`);
const expected = o.oracle();
read.mockClear(); parse.mockClear();
o.changed(); await vi.advanceTimersByTimeAsync(50);
o.changed(); vi.advanceTimersByTime(50);
expect(o.watch.observation()).toEqual({ state: "watching", revision: expected });
expect.soft(read.mock.calls.filter(([file]) => file === f.projectFile)).toHaveLength(1);
expect.soft(parse.mock.calls.filter(([text]) => text === policy)).toHaveLength(1);
@@ -203,37 +196,37 @@ describe("watch-local proof-policy reuse", () => {
expect.soft(read.mock.calls.filter(([file]) => file === f.missionFile)).toHaveLength(2);
}
expect(o.invalidate).toHaveBeenCalledTimes(2);
await o.event(); expect(o.invalidate).toHaveBeenCalledTimes(2);
o.event(); expect(o.invalidate).toHaveBeenCalledTimes(2);
} finally { o.watch.close(); }
});
it("observes changed, missing, overridden, malformed and unreadable policy on the next event", async () => {
it("observes changed, missing, overridden, malformed and unreadable policy on the next event", () => {
const f = fixture(), sliceFile = path.join(f.dirs[0]!, "slice.yaml");
const project = fs.readFileSync(f.projectFile, "utf8"), mission = fs.readFileSync(f.missionFile, "utf8"), slice = fs.readFileSync(sliceFile, "utf8");
const o = await observe(f.missions);
const o = observe(f.missions);
try {
for (const [file, base, actor] of [[f.projectFile, project, "new-project"], [f.missionFile, mission, "new-mission"], [sliceFile, slice, "new-slice"]]) {
f.write(file!, { ...YAML.parse(base!), proofPolicy: { judges: [actor] } });
await o.event(); expect(readSliceReadiness(f.dirs[0]!).policy!.judges).toEqual([actor]);
o.event(); expect(readSliceReadiness(f.dirs[0]!).policy!.judges).toEqual([actor]);
}
f.write(sliceFile, slice); f.write(f.missionFile, mission); await o.event();
fs.unlinkSync(f.projectFile); await o.event(); expect(readSliceReadiness(f.dirs[0]!).state).toBe("legacy");
f.write(f.projectFile, project); await o.event();
f.write(sliceFile, slice); f.write(f.missionFile, mission); o.event();
fs.unlinkSync(f.projectFile); o.event(); expect(readSliceReadiness(f.dirs[0]!).state).toBe("legacy");
f.write(f.projectFile, project); o.event();
for (const bad of ["proofPolicy: [unclosed", { proofPolicy: { judges: [] } }]) {
f.write(f.projectFile, bad); await o.event(); expect(readSliceReadiness(f.dirs[0]!).state).toBe("unknown");
f.write(f.projectFile, project); await o.event(); expect(readSliceReadiness(f.dirs[0]!).policy!.judges).toEqual(["judge@trial"]);
f.write(f.projectFile, bad); o.event(); expect(readSliceReadiness(f.dirs[0]!).state).toBe("unknown");
f.write(f.projectFile, project); o.event(); expect(readSliceReadiness(f.dirs[0]!).policy!.judges).toEqual(["judge@trial"]);
}
const original = fs.readFileSync, read = vi.spyOn(fs, "readFileSync");
read.mockImplementation(((file: fs.PathOrFileDescriptor, ...args: any[]) => {
if (file === f.projectFile) throw Object.assign(new Error("policy denied"), { code: "EACCES" });
return (original as any)(file, ...args);
}) as typeof fs.readFileSync);
await o.event(); expect(readSliceReadiness(f.dirs[0]!).state).toBe("unknown");
read.mockImplementation(original); await o.event(); expect(readSliceReadiness(f.dirs[0]!).policy!.judges).toEqual(["judge@trial"]);
o.event(); expect(readSliceReadiness(f.dirs[0]!).state).toBe("unknown");
read.mockImplementation(original); o.event(); expect(readSliceReadiness(f.dirs[0]!).policy!.judges).toEqual(["judge@trial"]);
} finally { o.watch.close(); }
});
it("observes accepted evidence outside the missions root at its parent, never recursively, through edit and atomic replacement", async () => {
it("observes accepted evidence outside the missions root at its parent, never recursively, through edit and atomic replacement", () => {
const f = fixture(), dir = f.dirs[0]!, outside = path.join(f.root, "build", "out"), evidence = path.join(outside, "report.md");
f.write(evidence, "accepted observation");
const item = readSliceReadiness(dir).items[0]!;
@@ -245,7 +238,7 @@ describe("watch-local proof-policy reuse", () => {
return { on: vi.fn(), close: vi.fn() };
}) as unknown as typeof fs.watch);
const invalidate = vi.fn();
const watch = await startWatch(f.missions, invalidate, { emit: vi.fn() } as unknown as EventBus);
const watch = watchProofSources(f.missions, invalidate, { emit: vi.fn() } as unknown as EventBus);
const oracle = () => createHash("sha256").update(JSON.stringify(readProjectReadiness(f.missions).missions.map(m => [m.name, m.revision]))).digest("hex");
try {
expect(watch.observation()).toEqual({ state: "watching", revision: oracle() });
@@ -253,34 +246,34 @@ describe("watch-local proof-policy reuse", () => {
expect(recursive.get(outside)).toBe(false);
expect([...recursive].filter(([root, deep]) => deep && root !== f.missions)).toEqual([]);
f.write(evidence, "changed observation");
listeners.get(outside)!("change", "report.md"); await vi.advanceTimersByTimeAsync(50);
listeners.get(outside)!("change", "report.md"); vi.advanceTimersByTime(50);
expect(readSliceReadiness(dir).state).toBe("unknown");
expect(watch.observation()).toEqual({ state: "watching", revision: oracle() });
fs.writeFileSync(`${evidence}.tmp`, "accepted observation"); fs.renameSync(`${evidence}.tmp`, evidence);
listeners.get(outside)!("rename", "report.md"); await vi.advanceTimersByTimeAsync(50);
listeners.get(outside)!("rename", "report.md"); vi.advanceTimersByTime(50);
expect(readSliceReadiness(dir).state).toBe("ready");
expect(watch.observation()).toEqual({ state: "watching", revision: oracle() });
expect(invalidate).toHaveBeenCalledTimes(2);
} finally { watch.close(); }
});
it("rechecks accepted evidence between events without reusing judgment inputs", async () => {
it("rechecks accepted evidence between events without reusing judgment inputs", () => {
const f = fixture(), dir = f.dirs[0]!, evidence = path.join(dir, "proof/evidence.md");
f.write(evidence, "accepted observation");
const item = readSliceReadiness(dir).items[0]!;
recordJudgment(f.missions, { scope: "trial/slices/slice-0", item: item.id, verdict: "accept", reason: "observed", evidence: ["proof/evidence.md"], expectedRevision: item.revision, expectedPrevious: null }, "judge@trial", "fixture");
const o = await observe(f.missions);
const o = observe(f.missions);
try {
expect(readSliceReadiness(dir).state).toBe("ready");
f.write(evidence, "changed observation"); await o.event(); expect(readSliceReadiness(dir).state).toBe("unknown");
f.write(evidence, "accepted observation"); await o.event(); expect(readSliceReadiness(dir).state).toBe("ready");
f.write(evidence, "changed observation"); o.event(); expect(readSliceReadiness(dir).state).toBe("unknown");
f.write(evidence, "accepted observation"); o.event(); expect(readSliceReadiness(dir).state).toBe("ready");
expect(o.invalidate).toHaveBeenCalledTimes(2);
} finally { o.watch.close(); }
});
});
describe("QA bounded adverse coverage at 8b236e06", () => {
async function adverseFixture() {
function adverseFixture() {
const f = fixture(), dir = f.dirs[0]!, outside = path.join(f.root, "build", "out"), evidence = path.join(outside, "report.md");
f.write(evidence, "accepted observation");
const item = readSliceReadiness(dir).items[0]!;
@@ -291,46 +284,46 @@ describe("QA bounded adverse coverage at 8b236e06", () => {
const h = { event: listener, error: undefined as (() => void) | undefined }; handles.set(root, h);
return { on: (_kind: string, cb: () => void) => { h.error = cb; }, close: vi.fn() };
}) as unknown as typeof fs.watch);
const invalidate = vi.fn(), emit = vi.fn(), watch = await startWatch(f.missions, invalidate, { emit } as unknown as EventBus);
const invalidate = vi.fn(), emit = vi.fn(), watch = watchProofSources(f.missions, invalidate, { emit } as unknown as EventBus);
const oracle = () => createHash("sha256").update(JSON.stringify(readProjectReadiness(f.missions).missions.map(m => [m.name, m.revision]))).digest("hex");
return { f, dir, outside, evidence, handles, watch, oracle, invalidate, emit };
}
it("QA retains unavailable after an async required-handle error and another valid event unless coverage is replaced", async () => {
const t = await adverseFixture();
it("QA retains unavailable after an async required-handle error and another valid event unless coverage is replaced", () => {
const t = adverseFixture();
try {
expect(t.watch.observation()).toEqual({ state: "watching", revision: t.oracle() });
const failed = t.handles.get(t.outside)!; failed.error!();
expect(t.watch.observation().state).toBe("unavailable");
t.f.write(t.evidence, "changed observation");
t.handles.get(t.f.missions)!.event("change", "trial/slices/slice-0/SPEC.md"); await vi.advanceTimersByTimeAsync(50);
t.handles.get(t.f.missions)!.event("change", "trial/slices/slice-0/SPEC.md"); vi.advanceTimersByTime(50);
expect(readSliceReadiness(t.dir).state).toBe("unknown");
expect(t.watch.observation().revision).toBe(t.oracle());
// A fresh semantic read is not repaired notification coverage for the errored, unreplaced handle.
expect(t.watch.observation().state).toBe("unavailable");
} finally { t.watch.close(); }
});
it("rebinds a replaced evidence parent when its structural parent reports a change without a filename", async () => {
const t = await adverseFixture();
it("rebinds a replaced evidence parent when its structural parent reports a change without a filename", () => {
const t = adverseFixture();
try {
const opened = () => vi.mocked(fs.watch).mock.calls.filter(([root]) => root === t.outside).length;
expect(opened()).toBe(1);
fs.renameSync(t.outside, `${t.outside}.old`); t.f.write(t.evidence, "changed observation");
// The platform names nothing: only the structural parent of the replaced directory reports a change.
t.handles.get(path.dirname(t.outside))!.event("rename", null); await vi.advanceTimersByTimeAsync(50);
t.handles.get(path.dirname(t.outside))!.event("rename", null); vi.advanceTimersByTime(50);
expect(readSliceReadiness(t.dir).state).toBe("unknown");
expect(t.watch.observation().revision).toBe(t.oracle());
// The old same-path handle cannot certify coverage: it is replaced, or the sources are reported unobserved.
expect(opened() === 2 || t.watch.observation().state === "unavailable").toBe(true);
} finally { t.watch.close(); }
});
it("watches the real location of evidence reached through a link inside the project", async () => {
const t = await adverseFixture();
it("watches the real location of evidence reached through a link inside the project", () => {
const t = adverseFixture();
try {
// The evidence directory becomes a link to another directory inside the project, with the same accepted bytes.
const real = path.join(t.f.root, "real"), live = path.join(real, "live");
t.f.write(path.join(live, "report.md"), "accepted observation");
fs.renameSync(t.outside, `${t.outside}.old`); fs.symlinkSync(live, t.outside);
t.handles.get(path.dirname(t.outside))!.event("rename", path.basename(t.outside)); await vi.advanceTimersByTimeAsync(50);
t.handles.get(path.dirname(t.outside))!.event("rename", path.basename(t.outside)); vi.advanceTimersByTime(50);
expect(readSliceReadiness(t.dir).state).toBe("ready");
// Replacing the real directory is seen at its own parent, or the sources are reported unobserved.
const watched = vi.mocked(fs.watch).mock.calls.map(([root]) => root);
@@ -338,7 +331,7 @@ describe("QA bounded adverse coverage at 8b236e06", () => {
expect(watched.some((root) => !String(root).startsWith(t.f.root) && root !== path.dirname(t.f.root))).toBe(false);
} finally { t.watch.close(); }
});
for (const name of ["missions", null]) it(`rebinds a linked missions root when its real directory is replaced (${name ?? "no filename"})`, async () => {
for (const name of ["missions", null]) it(`rebinds a linked missions root when its real directory is replaced (${name ?? "no filename"})`, () => {
const f = fixture(), real = path.join(f.root, "real"), linked = path.join(f.root, "linked-missions");
fs.mkdirSync(real); fs.renameSync(f.missions, path.join(real, "missions")); fs.symlinkSync(path.join(real, "missions"), linked);
vi.useFakeTimers();
@@ -349,22 +342,22 @@ describe("QA bounded adverse coverage at 8b236e06", () => {
return { on: vi.fn(), close: vi.fn() };
}) as unknown as typeof fs.watch);
const oracle = () => createHash("sha256").update(JSON.stringify(readProjectReadiness(linked).missions.map(m => [m.name, m.revision]))).digest("hex");
const watch = await startWatch(linked, vi.fn(), { emit: vi.fn() } as unknown as EventBus);
const watch = watchProofSources(linked, vi.fn(), { emit: vi.fn() } as unknown as EventBus);
try {
expect(watch.observation()).toEqual({ state: "watching", revision: oracle() });
fs.renameSync(path.join(real, "missions"), path.join(real, "missions.old"));
fs.cpSync(path.join(real, "missions.old"), path.join(real, "missions"), { recursive: true });
const before = opened.length;
opened.filter((h) => h.root === real).at(-1)!.event("rename", name); await vi.advanceTimersByTimeAsync(50);
opened.filter((h) => h.root === real).at(-1)!.event("rename", name); vi.advanceTimersByTime(50);
// A new mission in the replacement tree reaches clients through the live watches only.
fs.cpSync(path.join(real, "missions", "trial"), path.join(real, "missions", "new-mission"), { recursive: true });
for (const h of opened.slice(before)) h.event("rename", h.recursive ? "new-mission/mission.yaml" : "new-mission");
await vi.advanceTimersByTimeAsync(50);
vi.advanceTimersByTime(50);
expect(watch.observation().state === "unavailable" || watch.observation().revision === oracle()).toBe(true);
expect(opened.some((h) => !h.root.startsWith(f.root) && h.root !== path.dirname(f.root))).toBe(false);
} finally { watch.close(); }
});
for (const start of ["outside", "inside"] as const) it(`reports unavailable while a linked missions root's real location is outside the project (starts ${start})`, async () => {
for (const start of ["outside", "inside"] as const) it(`reports unavailable while a linked missions root's real location is outside the project (starts ${start})`, () => {
const f = fixture(), linked = path.join(f.root, "linked-missions"), inside = path.join(f.root, "real", "missions");
const other = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "policy-reuse-other-"))); roots.push(other);
const outside = path.join(other, "missions");
@@ -377,21 +370,21 @@ describe("QA bounded adverse coverage at 8b236e06", () => {
opened.push({ root, event: listener });
return { on: vi.fn(), close: vi.fn() };
}) as unknown as typeof fs.watch);
const emit = vi.fn(), watch = await startWatch(linked, vi.fn(), { emit } as unknown as EventBus);
const emit = vi.fn(), watch = watchProofSources(linked, vi.fn(), { emit } as unknown as EventBus);
try {
if (start === "inside") {
expect(watch.observation().state).toBe("watching");
// The supported alias is re-pointed outside the project; the link's own parent sees it.
fs.unlinkSync(linked); fs.symlinkSync(outside, linked);
opened.filter((h) => h.root === f.root).at(-1)!.event("rename", "linked-missions"); await vi.advanceTimersByTimeAsync(50);
opened.filter((h) => h.root === f.root).at(-1)!.event("rename", "linked-missions"); vi.advanceTimersByTime(50);
expect(emit.mock.calls.map(([e]) => e.revision)).toContain("unavailable");
}
expect(watch.observation().state).toBe("unavailable");
expect(opened.some((h) => h.root.startsWith(other))).toBe(false);
} finally { watch.close(); }
});
it("QA observes replacement of an external evidence parent or honestly reports unavailable", async () => {
const t = await adverseFixture();
it("QA observes replacement of an external evidence parent or honestly reports unavailable", () => {
const t = adverseFixture();
try {
const previous = t.watch.observation().revision;
expect(readSliceReadiness(t.dir).state).toBe("ready");
@@ -401,7 +394,7 @@ describe("QA bounded adverse coverage at 8b236e06", () => {
if (root === path.dirname(t.outside)) h.event("rename", path.basename(t.outside));
if (root === t.f.root) h.event("rename", "build");
}
await vi.advanceTimersByTimeAsync(50);
vi.advanceTimersByTime(50);
expect(readSliceReadiness(t.dir).state).toBe("unknown");
expect(t.oracle()).not.toBe(previous);
expect(t.watch.observation().state === "unavailable" || t.watch.observation().revision === t.oracle()).toBe(true);
@@ -1,52 +0,0 @@
import { afterEach, expect, it } from "vitest";
import * as fs from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { Hono } from "hono";
import { proofRoutes } from "../src/routes/proof.js";
import { slicesRoutes } from "../src/routes/slices.js";
import { readProjectReadiness } from "../src/domain/proof/judgments.js";
const roots: string[] = [];
afterEach(() => { for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); });
it.each(["proof", "slices"])("serves another event-loop turn during the %s project read without changing the result", async target => {
const root = fs.mkdtempSync(join(tmpdir(), "proof-project-yield-")); roots.push(root);
const missions = join(root, "missions");
fs.mkdirSync(missions);
fs.writeFileSync(join(root, "project.yaml"), "kind: project\nmetadata: { id: trial }\n");
for (const name of ["alpha", "beta", "gamma"]) {
fs.mkdirSync(join(missions, name));
fs.writeFileSync(join(missions, name, "mission.yaml"), "kind: mission\nmetadata: { status: active }\ncomposition: { slices: [] }\n");
}
const readiness = readProjectReadiness(missions);
const expected = target === "proof" ? readiness : {
slices: [], totalCount: 0, filter: "all", boundToWorkflow: null,
missions: Object.fromEntries(readiness.missions.map(m => [m.name, { authoredStatus: m.historicalStatus, readiness: m }])),
};
let sourceReady = true;
const app = new Hono();
app.use("*", async (c, next) => {
c.set("sliceIndexer" as never, { isReady: () => true, slicesRoot: missions,
withMembershipBatch: (fn: () => unknown) => fn(), list: () => [],
observedQueueCounts: () => new Map(), missionAuthoredStatuses: () => ({}),
} as never);
c.set("sliceDetailProjector" as never, {} as never);
c.set("proofSourceWatch" as never, { observation: () => ({ state: sourceReady ? "watching" : "unavailable", revision: "fixture" }) } as never);
await next();
});
app.route("/api/proof", proofRoutes());
app.route("/api/slices", slicesRoutes());
let completed = false;
const read = app.request(`/api/${target}`).then(response => { completed = true; return response; });
await new Promise<void>(resolve => setImmediate(resolve));
if (completed) {
const early = await read; expect(early.status).toBe(200);
expect(await early.clone().json()).toMatchObject(expected);
}
expect(completed, "the proof read must yield before finishing the whole tree").toBe(false);
sourceReady = false;
const response = await read;
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject(target === "proof" ? { ...expected, sourceObservation: { state: "unavailable" } } : expected);
});
@@ -6,7 +6,7 @@ const { watch, createProofPolicyRead, readProjectReadiness, resolveProjectRoot,
}));
// Real-location checks see every fixture path as itself (no links in these mocked trees).
vi.mock("node:fs", () => ({ watch, existsSync: () => true, realpathSync: (p: string) => p }));
vi.mock("../src/domain/proof/judgments.js", () => ({ createProofPolicyRead, readProjectReadiness, readProjectReadinessAsync: async (...args: unknown[]) => readProjectReadiness(...args), resolveProjectRoot, proofFs }));
vi.mock("../src/domain/proof/judgments.js", () => ({ createProofPolicyRead, readProjectReadiness, resolveProjectRoot, proofFs }));
import { watchProofSources } from "../src/domain/proof/source-watch.js";
import type { EventBus } from "../src/domain/event-bus.js";
afterEach(() => { vi.useRealTimers(); vi.resetAllMocks(); proofFs.exists.mockImplementation(() => true); });
@@ -21,14 +21,14 @@ function reads(paths: (missions: string) => Array<[keyof Io, string]>) {
}
const handle = () => { const close = vi.fn(); watch.mockImplementation(() => ({ on: vi.fn(), close })); return close; };
it("watches only the missions root recursively; the project and evidence outside it get shallow, filtered watches", async () => {
it("watches only the missions root recursively; the project and evidence outside it get shallow, filtered watches", () => {
vi.useFakeTimers();
const project = path.resolve("fixture/project");
const missions = path.join(project, "missions");
resolveProjectRoot.mockReturnValue(project);
reads(() => [["exists", path.join(project, "project.yaml")], ["readBytes", path.join(project, "build", "out", "report.md")]]);
const close = handle();
const instance = await watchProofSources(missions, vi.fn(), { emit: vi.fn() } as unknown as EventBus);
const instance = watchProofSources(missions, vi.fn(), { emit: vi.fn() } as unknown as EventBus);
// Inputs at their parents, plus shallow structural anchors up to the project's parent (for replaced directories).
expect(watch.mock.calls.map(([root, options]) => [root, options.recursive])).toEqual([
[missions, true], [project, false], [path.join(project, "build", "out"), false],
@@ -43,23 +43,23 @@ it("watches only the missions root recursively; the project and evidence outside
const notifyProject = watch.mock.calls[1]![2], notifyEvidence = watch.mock.calls[2]![2];
notifyProject("change", "unrelated.txt");
notifyEvidence("change", Buffer.from("other.md"));
await vi.advanceTimersByTimeAsync(50);
vi.advanceTimersByTime(50);
expect(readProjectReadiness).toHaveBeenCalledTimes(2);
notifyProject("rename", Buffer.from("project.yaml"));
await vi.advanceTimersByTimeAsync(50);
vi.advanceTimersByTime(50);
expect(readProjectReadiness).toHaveBeenCalledTimes(3);
notifyEvidence("rename", "report.md");
await vi.advanceTimersByTimeAsync(50);
vi.advanceTimersByTime(50);
expect(readProjectReadiness).toHaveBeenCalledTimes(4);
// No filename: that directory's watch is rebound (closed, reattached by path) and the read verified again.
notifyEvidence("change", null);
await vi.advanceTimersByTimeAsync(50);
vi.advanceTimersByTime(50);
expect(readProjectReadiness).toHaveBeenCalledTimes(6);
instance.close();
expect(close).toHaveBeenCalledTimes(6);
});
it("watches the nearest existing ancestor of a missing input, filtered to the next path segment", async () => {
it("watches the nearest existing ancestor of a missing input, filtered to the next path segment", () => {
vi.useFakeTimers();
const project = path.resolve("fixture/project");
const missions = path.join(project, "missions");
@@ -67,26 +67,26 @@ it("watches the nearest existing ancestor of a missing input, filtered to the ne
proofFs.exists.mockImplementation((p: string) => !p.startsWith(path.join(project, "build")));
reads(() => [["readBytes", path.join(project, "build", "out", "report.md")]]);
handle();
const instance = await watchProofSources(missions, vi.fn(), { emit: vi.fn() } as unknown as EventBus);
const instance = watchProofSources(missions, vi.fn(), { emit: vi.fn() } as unknown as EventBus);
expect(watch.mock.calls.map(([root, options]) => [root, options.recursive])).toEqual([[missions, true], [project, false], [path.dirname(project), false]]);
const notify = watch.mock.calls[1]![2];
notify("rename", "unrelated");
await vi.advanceTimersByTimeAsync(50);
vi.advanceTimersByTime(50);
expect(readProjectReadiness).toHaveBeenCalledTimes(2);
notify("rename", "build");
await vi.advanceTimersByTimeAsync(50);
vi.advanceTimersByTime(50);
expect(readProjectReadiness).toHaveBeenCalledTimes(3);
instance.close();
});
it("reports unavailable and closes what it opened when a required watch fails, without a wider fallback", async () => {
it("reports unavailable and closes what it opened when a required watch fails, without a wider fallback", () => {
const project = path.resolve("fixture/project");
const missions = path.join(project, "missions");
resolveProjectRoot.mockReturnValue(project);
reads(() => [["exists", path.join(project, "project.yaml")]]);
const close = vi.fn();
watch.mockReturnValueOnce({ on: vi.fn(), close }).mockImplementationOnce(() => { throw new Error("ENOSPC"); });
const instance = await watchProofSources(missions, vi.fn(), { emit: vi.fn() } as unknown as EventBus);
const instance = watchProofSources(missions, vi.fn(), { emit: vi.fn() } as unknown as EventBus);
expect(instance.observation().state).toBe("unavailable");
expect(close).toHaveBeenCalledOnce();
expect(watch).toHaveBeenCalledTimes(2);
@@ -94,12 +94,12 @@ it("reports unavailable and closes what it opened when a required watch fails, w
expect(close).toHaveBeenCalledOnce();
});
it("watches nothing else recursively when the evaluation reads nothing outside the missions root", async () => {
it("watches nothing else recursively when the evaluation reads nothing outside the missions root", () => {
const missions = path.resolve("fixture/missions");
resolveProjectRoot.mockReturnValue(path.dirname(missions));
reads((root) => [["exists", path.join(root, "m1", "mission.yaml")]]);
const close = handle();
const instance = await watchProofSources(missions, vi.fn(), { emit: vi.fn() } as unknown as EventBus);
const instance = watchProofSources(missions, vi.fn(), { emit: vi.fn() } as unknown as EventBus);
// Only the missions root's structural anchors are added, shallow.
expect(watch.mock.calls.map(([root, options]) => [root, options.recursive])).toEqual([
[missions, true], [path.dirname(missions), false], [path.dirname(path.dirname(missions)), false],
@@ -108,7 +108,7 @@ it("watches nothing else recursively when the evaluation reads nothing outside t
expect(close).toHaveBeenCalledTimes(3);
});
it("does not report watching when the inputs keep changing past the reconciliation budget", async () => {
it("does not report watching when the inputs keep changing past the reconciliation budget", () => {
const project = path.resolve("fixture/project");
const missions = path.join(project, "missions");
resolveProjectRoot.mockReturnValue(project);
@@ -120,7 +120,7 @@ it("does not report watching when the inputs keep changing past the reconciliati
return { missions: [] };
});
handle();
const instance = await watchProofSources(missions, vi.fn(), { emit: vi.fn() } as unknown as EventBus);
const instance = watchProofSources(missions, vi.fn(), { emit: vi.fn() } as unknown as EventBus);
const attached = new Set(watch.mock.calls.map(([root]) => root));
// Either the final dependency set is actually watched and verified, or the monitor says it is not watching.
const finalDir = path.dirname(sets[2]!);
@@ -129,7 +129,7 @@ it("does not report watching when the inputs keep changing past the reconciliati
instance.close();
});
it("tells clients when coverage is lost or regained even though the proof result is unchanged", async () => {
it("tells clients when coverage is lost or regained even though the proof result is unchanged", () => {
vi.useFakeTimers();
const project = path.resolve("fixture/project");
const missions = path.join(project, "missions");
@@ -146,19 +146,19 @@ it("tells clients when coverage is lost or regained even though the proof result
});
handle();
const emit = vi.fn();
const instance = await watchProofSources(missions, vi.fn(), { emit } as unknown as EventBus);
const instance = watchProofSources(missions, vi.fn(), { emit } as unknown as EventBus);
expect(instance.observation().state).toBe("watching");
const notifyProject = watch.mock.calls[1]![2];
notifyProject("change", "project.yaml"); await vi.advanceTimersByTimeAsync(50);
notifyProject("change", "project.yaml"); vi.advanceTimersByTime(50);
expect(instance.observation().state).toBe("unavailable");
expect(emit.mock.calls.map(([e]) => e.revision)).toEqual(["unavailable"]);
notifyProject("change", "project.yaml"); await vi.advanceTimersByTimeAsync(50);
notifyProject("change", "project.yaml"); vi.advanceTimersByTime(50);
expect(instance.observation().state).toBe("watching");
expect(emit.mock.calls.map(([e]) => e.revision)).toEqual(["unavailable", instance.observation().revision]);
instance.close();
});
it("after close, a queued callback neither re-reads, reopens nor emits", async () => {
it("after close, a queued callback neither re-reads, reopens nor emits", () => {
vi.useFakeTimers();
const project = path.resolve("fixture/project");
const missions = path.join(project, "missions");
@@ -166,141 +166,14 @@ it("after close, a queued callback neither re-reads, reopens nor emits", async (
reads(() => [["exists", path.join(project, "project.yaml")]]);
handle();
const emit = vi.fn();
const instance = await watchProofSources(missions, vi.fn(), { emit } as unknown as EventBus);
const instance = watchProofSources(missions, vi.fn(), { emit } as unknown as EventBus);
const notifyMissions = watch.mock.calls[0]![2];
notifyMissions("change", "x");
instance.close();
notifyMissions("change", "y");
await vi.advanceTimersByTimeAsync(100);
vi.advanceTimersByTime(100);
expect(readProjectReadiness).toHaveBeenCalledTimes(2);
expect(watch).toHaveBeenCalledTimes(3);
expect(emit).not.toHaveBeenCalled();
expect(instance.observation().state).toBe("unavailable");
});
it.each(["changed", "unchanged"])("coalesces mid-read events and publishes only a current %s basis", async mode => {
vi.useFakeTimers();
const project = path.resolve("fixture/project"), missions = path.join(project, "missions");
resolveProjectRoot.mockReturnValue(project);
reads(() => [["exists", path.join(project, "project.yaml")]]);
handle();
const emit = vi.fn();
const instance = await watchProofSources(missions, vi.fn(), { emit } as unknown as EventBus);
let finish!: (value: { missions: Array<{ name: string; revision: string }> }) => void;
readProjectReadiness.mockReturnValueOnce(new Promise(resolve => { finish = resolve; }));
const notify = watch.mock.calls[0]![2];
notify("change", "SPEC.md");
await vi.advanceTimersByTimeAsync(50);
const settledRevision = instance.observation().revision;
const calls = readProjectReadiness.mock.calls.length;
notify("change", "SPEC.md"); notify("change", "SPEC.md");
await vi.advanceTimersByTimeAsync(100);
expect(readProjectReadiness).toHaveBeenCalledTimes(calls);
finish({ missions: [{ name: "trial", revision: "superseded" }] });
await vi.advanceTimersByTimeAsync(0);
expect(instance.observation().revision).toBe(settledRevision);
expect(emit).not.toHaveBeenCalled();
readProjectReadiness.mockReturnValue({ missions: mode === "changed" ? [{ name: "trial", revision: "current" }] : [] });
await vi.advanceTimersByTimeAsync(50);
expect(instance.observation().state).toBe("watching");
expect(emit).toHaveBeenCalledTimes(mode === "changed" ? 1 : 0);
instance.close();
});
it("publishes unchanged-revision coverage recovery only after an interrupted read settles cleanly", async () => {
vi.useFakeTimers();
const project = path.resolve("fixture/project"), missions = path.join(project, "missions");
resolveProjectRoot.mockReturnValue(project);
let call = 0;
readProjectReadiness.mockImplementation((_m: string, _r: unknown, _root: unknown, io?: Io) => {
io?.exists(path.join(project, "project.yaml"));
io?.readBytes(path.join(project, `input-${Math.min(call++, 2)}`, "report.md"));
return { missions: [] };
});
handle();
const emit = vi.fn();
const instance = await watchProofSources(missions, vi.fn(), { emit } as unknown as EventBus);
const settled = instance.observation();
expect(settled.state).toBe("unavailable"); // all three setup passes attached a previously unwatched dependency
const read = readProjectReadiness.getMockImplementation()!;
let finish!: (value: { missions: [] }) => void;
readProjectReadiness.mockImplementationOnce((...args: unknown[]) => {
read(...args);
return new Promise(resolve => { finish = resolve; });
});
const notify = watch.mock.calls[0]![2];
notify("change", "report.md"); await vi.advanceTimersByTimeAsync(50);
notify("change", "report.md");
finish({ missions: [] }); await vi.advanceTimersByTimeAsync(0);
expect.soft(instance.observation()).toEqual(settled);
expect(emit).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(50);
expect(instance.observation()).toEqual({ state: "watching", revision: settled.revision });
expect.soft(emit.mock.calls.map(([event]) => event.revision)).toEqual([settled.revision]);
instance.close();
});
it("reports watch errors immediately while a refresh is still awaiting its read", async () => {
vi.useFakeTimers();
const project = path.resolve("fixture/project"), missions = path.join(project, "missions");
resolveProjectRoot.mockReturnValue(project);
reads(() => [["exists", path.join(project, "project.yaml")]]);
let error!: () => void;
watch.mockImplementation(() => ({ on: (_event: string, callback: () => void) => { error = callback; }, close: vi.fn() }));
const emit = vi.fn();
const instance = await watchProofSources(missions, vi.fn(), { emit } as unknown as EventBus);
let finish!: (value: { missions: [] }) => void;
readProjectReadiness.mockReturnValueOnce(new Promise(resolve => { finish = resolve; }));
watch.mock.calls[0]![2]("change", "report.md"); await vi.advanceTimersByTimeAsync(50);
error();
expect(instance.observation()).toEqual({ state: "unavailable", revision: "unavailable" });
expect(emit.mock.calls.at(-1)![0].revision).toBe("unavailable");
finish({ missions: [] }); await vi.advanceTimersByTimeAsync(0);
expect(instance.observation().state).toBe("unavailable");
expect(emit.mock.calls.every(([event]) => event.revision === "unavailable")).toBe(true);
instance.close();
});
it("does not publish an interrupted setup candidate before a clean refresh", async () => {
vi.useFakeTimers();
const project = path.resolve("fixture/project"), missions = path.join(project, "missions");
resolveProjectRoot.mockReturnValue(project);
reads(() => [["exists", path.join(project, "project.yaml")]]);
const read = readProjectReadiness.getMockImplementation()!;
let finish!: (value: { missions: [] }) => void;
readProjectReadiness.mockImplementationOnce((...args: unknown[]) => {
read(...args);
return new Promise(resolve => { finish = resolve; });
});
handle();
const emit = vi.fn(), opening = watchProofSources(missions, vi.fn(), { emit } as unknown as EventBus);
watch.mock.calls[0]![2]("change", "report.md");
finish({ missions: [] });
const instance = await opening;
expect(instance.observation()).toEqual({ state: "unavailable", revision: "unavailable" });
expect(emit).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(50);
expect(instance.observation().state).toBe("watching");
expect(emit).toHaveBeenCalledTimes(1);
instance.close();
});
it("does not reattach watches or publish when an in-flight read finishes after close", async () => {
vi.useFakeTimers();
const project = path.resolve("fixture/project"), missions = path.join(project, "missions");
resolveProjectRoot.mockReturnValue(project);
reads(() => [["exists", path.join(project, "project.yaml")]]);
handle();
const emit = vi.fn();
const instance = await watchProofSources(missions, vi.fn(), { emit } as unknown as EventBus);
let finish!: (value: { missions: [] }) => void;
readProjectReadiness.mockReturnValueOnce(new Promise(resolve => { finish = resolve; }));
watch.mock.calls[0]![2]("change", "SPEC.md");
await vi.advanceTimersByTimeAsync(50);
const watches = watch.mock.calls.length;
instance.close(); finish({ missions: [] });
await vi.advanceTimersByTimeAsync(100);
expect(watch).toHaveBeenCalledTimes(watches);
expect(emit).not.toHaveBeenCalled();
expect(instance.observation().state).toBe("unavailable");
});
@@ -25,13 +25,10 @@ it("watches the owning project policy above a nested missions root", async () =>
expect(readSliceReadiness(slice).state).toBe("ready");
const events: unknown[] = [];
let invalidated = 0;
const watch = await watchProofSources(missions, () => invalidated++, {
const watch = watchProofSources(missions, () => invalidated++, {
emit: (event: unknown) => events.push(event),
} as unknown as EventBus);
try {
// A creation event can dirty setup before it returns; start assertions at the settled observed basis.
await expect.poll(() => watch.observation().state, { timeout: 5000 }).toBe("watching");
events.length = 0; invalidated = 0;
await new Promise(resolve => setTimeout(resolve, 250));
expect(events).toHaveLength(0);
const before = watch.observation().revision;
@@ -46,11 +43,11 @@ it("watches the owning project policy above a nested missions root", async () =>
} finally { watch.close(); fs.rmSync(root, { recursive: true, force: true }); }
});
it("retains the existing watch boundary for roots without project.yaml", async () => {
it("retains the existing watch boundary for roots without project.yaml", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "proof-legacy-root-"));
const missions = path.join(root, "missions");
fs.mkdirSync(missions);
const watch = await watchProofSources(missions, () => {}, { emit() {} } as unknown as EventBus);
const watch = watchProofSources(missions, () => {}, { emit() {} } as unknown as EventBus);
try { expect(watch.observation().state).toBe("watching"); }
finally { watch.close(); fs.rmSync(root, { recursive: true, force: true }); }
});
@@ -2,7 +2,7 @@
// Each case runs the real hook as a harness does (JSON on stdin, killed at the registration's outer bound), with a stub
// `rig` and a stub trace script whose delays are set per case. The overlaps are hook processes overlapping; whether a
// harness overlaps these events natively is not established here.
import { afterEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi, type TestContext } from "vitest";
import { spawn, spawnSync } from "node:child_process";
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
@@ -14,19 +14,17 @@ const PLUGIN = resolve(HERE, "../assets/plugins/openrig-core");
const HOOK = join(PLUGIN, "hooks/scripts/refocus.cjs");
const SEAT = "dev-impl@budget-rig";
let root: string | undefined;
afterEach(() => {
// A case that made the state folder read-only may have timed out before putting it back.
if (root) try { chmodSync(join(root, "openrig", "refocus"), 0o755); } catch {}
if (root) rmSync(root, { recursive: true, force: true });
root = undefined;
});
interface Fixture { home: string; bin: string; log: string }
interface Fixture { root: string; home: string; bin: string; log: string }
/** Build isolated hook state and controllable lookup scripts for timing and overlap cases. */
function fixture(): Fixture {
root = mkdtempSync(join(tmpdir(), "refocus-budget-"));
function fixture(onTestFinished: TestContext["onTestFinished"]): Fixture {
const root = mkdtempSync(join(tmpdir(), "refocus-budget-"));
// Bind cleanup to the owning test, including cases that construct two fixtures.
onTestFinished(() => {
// A case that made the state folder read-only may have timed out before putting it back.
try { chmodSync(join(root, "openrig", "refocus"), 0o755); } catch {}
rmSync(root, { recursive: true, force: true });
});
const home = join(root, "openrig");
const bin = join(root, "bin");
const log = join(root, "calls.log");
@@ -55,20 +53,20 @@ echo "TRACE OK: work and topology"
`);
chmodSync(join(bin, "rig"), 0o755);
chmodSync(join(bin, "trace"), 0o755);
return { home, bin, log };
return { root, home, bin, log };
}
interface Run { killed: boolean; ms: number; stdout: string; stderr: string }
// Run the hook as the harness does, killed at `outerMs` (both registrations' bound for refocus: 60 s).
function hook(f: Fixture, event: string, session: string, extra: Record<string, string> = {}, outerMs = 60_000, promptId?: string): Promise<Run> {
const transcript = join(root!, `${session}.jsonl`);
const transcript = join(f.root, `${session}.jsonl`);
if (!existsSync(transcript)) writeFileSync(transcript, "");
const started = Date.now();
return new Promise((done) => {
const child = spawn("node", [HOOK, "--runtime", "claude"], {
env: {
PATH: `${f.bin}:${process.env.PATH}`, HOME: root!, OPENRIG_HOME: f.home, OPENRIG_SESSION_NAME: SEAT,
PATH: `${f.bin}:${process.env.PATH}`, HOME: f.root, OPENRIG_HOME: f.home, OPENRIG_SESSION_NAME: SEAT,
PYTHON: join(f.bin, "trace"), OPENRIG_REFOCUS_WORK_NODE: f.home, OPENRIG_WORKSPACE_ROOT: f.home,
OPENRIG_TOPOLOGY_ROOT: f.home, ...extra,
},
@@ -103,9 +101,11 @@ describe("refocus: the registrations' outer bound", () => {
});
});
describe("refocus: the time budget", () => {
it("delivers a slow but successful trace when a longer budget is selected", async () => {
const f = fixture();
// Each budget case owns its files and child processes. Overlap their real waits;
// attempts within a case and the overlapping-event scenarios below stay sequential.
describe.concurrent("refocus: the time budget", () => {
it("delivers a slow but successful trace when a longer budget is selected", async ({ expect, onTestFinished }) => {
const f = fixture(onTestFinished);
await hook(f, "PostCompact", "s1");
const r = await hook(f, "UserPromptSubmit", "s1", { OPENRIG_REFOCUS_BUDGET_MS: "10000", STUB_TRACE_MS: "2500" });
expect(r.killed).toBe(false);
@@ -115,8 +115,8 @@ describe("refocus: the time budget", () => {
expect(stateOf(f, "s1")["pendingOn"]).toBeUndefined();
}, 20_000);
it("ends a trace that never finishes as a gap inside the selected budget, keeping the refocus due", async () => {
const f = fixture();
it("ends a trace that never finishes as a gap inside the selected budget, keeping the refocus due", async ({ expect, onTestFinished }) => {
const f = fixture(onTestFinished);
await hook(f, "PostCompact", "s1");
const r = await hook(f, "UserPromptSubmit", "s1", { OPENRIG_REFOCUS_BUDGET_MS: "10000", STUB_TRACE_MS: "30000" });
expect(r.killed).toBe(false);
@@ -125,9 +125,9 @@ describe("refocus: the time budget", () => {
expect(stateOf(f, "s1")).toMatchObject({ pendingOn: "PostCompact" });
}, 20_000);
it("limits a selected budget to 52 s, so reporting and recording the outcome still end inside the 60 s bound", async () => {
it("limits a selected budget to 52 s, so reporting and recording the outcome still end inside the 60 s bound", async ({ expect, onTestFinished }) => {
// At 52 s the trace's cap is 23.1 s: a 23.5 s trace ends as a gap (at 55 s its cap would have been 24.4 s).
const f = fixture();
const f = fixture(onTestFinished);
await hook(f, "PostCompact", "s1");
const r = await hook(f, "UserPromptSubmit", "s1", { OPENRIG_REFOCUS_BUDGET_MS: "120000", STUB_TRACE_MS: "23500" });
expect(r.killed).toBe(false);
@@ -137,8 +137,8 @@ describe("refocus: the time budget", () => {
expect(stateOf(f, "s1")).toMatchObject({ pendingOn: "PostCompact" });
}, 40_000);
it("keeps today's 2 s trace cap when no budget is selected", async () => {
const f = fixture();
it("keeps today's 2 s trace cap when no budget is selected", async ({ expect, onTestFinished }) => {
const f = fixture(onTestFinished);
await hook(f, "PostCompact", "s1");
const r = await hook(f, "UserPromptSubmit", "s1", { STUB_TRACE_MS: "2500" }, 5_000);
expect(r.killed).toBe(false);
@@ -147,10 +147,10 @@ describe("refocus: the time budget", () => {
expect(stateOf(f, "s1")).toMatchObject({ pendingOn: "PostCompact" });
}, 20_000);
it("with no budget selected, gives main's result: each lookup keeps its 2 s cap, unclipped, inside the 60 s bound", async () => {
it("with no budget selected, gives main's result: each lookup keeps its 2 s cap, unclipped, inside the 60 s bound", async ({ expect, onTestFinished }) => {
// Lookups each under their own 2 s cap (with room for a slow runner's process start-up) that together pass the 4.5 s lookup budget: on
// main's 5 s registration this was killed; now it completes as main's code does.
const f = fixture();
const f = fixture(onTestFinished);
const slow = { STUB_WHOAMI_MS: "1550", STUB_TRACE_MS: "1550", STUB_CONTEXT_MS: "1550", OPENRIG_REFOCUS_CONTENT_REF: "refocus/team.md",
OPENRIG_REFOCUS_WORK_NODE: "", OPENRIG_REFOCUS_TREES: "both" };
await hook(f, "PostCompact", "s1");
@@ -160,7 +160,7 @@ describe("refocus: the time budget", () => {
expect(context(r)).toContain("CONFIGURED REFOCUS CONTENT");
expect(stateOf(f, "s1")).toMatchObject({ firedOn: "UserPromptSubmit" });
// A content-REF seat with a slow whoami gets main's full 2 s trace, not a shorter one (completes on main in ~2.9 s).
const g = fixture();
const g = fixture(onTestFinished);
await hook(g, "PostCompact", "s1");
const ref = await hook(g, "UserPromptSubmit", "s1", { STUB_WHOAMI_MS: "1000", STUB_TRACE_MS: "1500", STUB_CONTEXT_MS: "300",
OPENRIG_REFOCUS_CONTENT_REF: "refocus/team.md", OPENRIG_REFOCUS_WORK_NODE: "", OPENRIG_REFOCUS_TREES: "both" });
@@ -168,10 +168,10 @@ describe("refocus: the time budget", () => {
expect(context(ref)).not.toContain("TRACE GAP");
}, 40_000);
it("with no budget selected, still files the issue report after three slow failed attempts that ran past 4.5 s", async () => {
it("with no budget selected, still files the issue report after three slow failed attempts that ran past 4.5 s", async ({ expect, onTestFinished }) => {
// Default lookups keep their fixed 2 s caps, so a failed default attempt can end around 6 s. Its report gets its
// own window after that (the stub answers a report after 200 ms), so the third failure is reported and confirmed.
const f = fixture();
const f = fixture(onTestFinished);
const slow = { STUB_WHOAMI_MS: "1900", STUB_TRACE_MS: "2500", STUB_CONTEXT_MS: "1900", OPENRIG_REFOCUS_CONTENT_REF: "refocus/team.md",
OPENRIG_REFOCUS_WORK_NODE: "", OPENRIG_REFOCUS_TREES: "both" };
await hook(f, "PostCompact", "s1");
@@ -185,9 +185,9 @@ describe("refocus: the time budget", () => {
expect(readFileSync(f.log, "utf8").split("\n").filter((l) => l.startsWith("rig stream")).length).toBe(1);
}, 60_000);
it("with a budget selected, ends lookups that together pass it as a reported gap, and records the failure", async () => {
it("with a budget selected, ends lookups that together pass it as a reported gap, and records the failure", async ({ expect, onTestFinished }) => {
// A 4.6 s budget gives each lookup a ~2 s cap; three of them pass the budget, so the last is clipped.
const f = fixture();
const f = fixture(onTestFinished);
const slow = { OPENRIG_REFOCUS_BUDGET_MS: "4600", STUB_WHOAMI_MS: "1950", STUB_TRACE_MS: "1950", STUB_CONTEXT_MS: "1950",
OPENRIG_REFOCUS_CONTENT_REF: "refocus/team.md", OPENRIG_REFOCUS_WORK_NODE: "", OPENRIG_REFOCUS_TREES: "both" };
await hook(f, "PostCompact", "s1");
@@ -204,13 +204,13 @@ describe("refocus: the time budget", () => {
});
describe("refocus: overlapping events for one occupant", () => {
it("preserves a PostCompact that lands while an earlier refocus is being delivered", async () => {
const f = fixture();
it("preserves a PostCompact that lands while an earlier refocus is being delivered", async ({ expect, onTestFinished }) => {
const f = fixture(onTestFinished);
await hook(f, "UserPromptSubmit", "s1", { OPENRIG_REFOCUS_NOW: "1" });
const file = join(f.home, "refocus", `${SEAT}__s1.json`);
writeFileSync(file, JSON.stringify({ ...stateOf(f, "s1"), pendingOn: "Stop", pendingAt: "2026-10-18T17:00:00.000Z" }));
const ready = join(root!, "trace-ready");
const release = join(root!, "trace-release");
const ready = join(f.root, "trace-ready");
const release = join(f.root, "trace-release");
// Hold delivery after it has read the state; process start-up time cannot choose the interleaving.
const delivering = hook(f, "UserPromptSubmit", "s1", {
OPENRIG_REFOCUS_BUDGET_MS: "52000", STUB_TRACE_READY: ready, STUB_TRACE_RELEASE: release,
@@ -230,11 +230,11 @@ describe("refocus: overlapping events for one occupant", () => {
expect(stateOf(f, "s1")["pendingOn"]).toBeUndefined();
}, 20_000);
it("delivers a pending refocus once when two handlers overlap", async () => {
const f = fixture();
it("delivers a pending refocus once when two handlers overlap", async ({ expect, onTestFinished }) => {
const f = fixture(onTestFinished);
await hook(f, "PostCompact", "s1");
const ready = join(root!, "trace-ready");
const release = join(root!, "trace-release");
const ready = join(f.root, "trace-ready");
const release = join(f.root, "trace-release");
const first = hook(f, "UserPromptSubmit", "s1", {
OPENRIG_REFOCUS_BUDGET_MS: "10000", STUB_TRACE_READY: ready, STUB_TRACE_RELEASE: release,
}, 60_000, "prompt-A");
@@ -252,11 +252,11 @@ describe("refocus: overlapping events for one occupant", () => {
expect(context(await hook(f, "UserPromptSubmit", "s1"))).toBe("");
}, 20_000);
it("retains a distinct identified on-demand prompt while an earlier prompt is rendering", async () => {
const f = fixture();
it("retains a distinct identified on-demand prompt while an earlier prompt is rendering", async ({ expect, onTestFinished }) => {
const f = fixture(onTestFinished);
await hook(f, "PostCompact", "s1");
const ready = join(root!, "trace-ready");
const release = join(root!, "trace-release");
const ready = join(f.root, "trace-ready");
const release = join(f.root, "trace-release");
const first = hook(f, "UserPromptSubmit", "s1", {
OPENRIG_REFOCUS_BUDGET_MS: "10000", STUB_TRACE_READY: ready, STUB_TRACE_RELEASE: release,
}, 60_000, "prompt-A");
@@ -273,19 +273,19 @@ describe("refocus: overlapping events for one occupant", () => {
expect(context(await hook(f, "UserPromptSubmit", "s1", {}, 60_000, "prompt-D"))).toBe("");
}, 20_000);
it("delivers once per identified prompt even if the second handler starts after completion", async () => {
const f = fixture();
it("delivers once per identified prompt even if the second handler starts after completion", async ({ expect, onTestFinished }) => {
const f = fixture(onTestFinished);
const env = { OPENRIG_REFOCUS_NOW: "1" };
expect(context(await hook(f, "UserPromptSubmit", "s1", env, 60_000, "prompt-1"))).toContain("REFOCUS");
expect(context(await hook(f, "UserPromptSubmit", "s1", env, 60_000, "prompt-1"))).toBe("");
expect(context(await hook(f, "UserPromptSubmit", "s1", env, 60_000, "prompt-2"))).toContain("REFOCUS");
});
it("recovers pending work after the rendering hook is killed", async () => {
const f = fixture();
it("recovers pending work after the rendering hook is killed", async ({ expect, onTestFinished }) => {
const f = fixture(onTestFinished);
await hook(f, "PostCompact", "s1");
const ready = join(root!, "trace-ready");
const release = join(root!, "trace-release");
const ready = join(f.root, "trace-ready");
const release = join(f.root, "trace-release");
const interrupted = hook(f, "UserPromptSubmit", "s1", {
OPENRIG_REFOCUS_BUDGET_MS: "10000", STUB_TRACE_READY: ready, STUB_TRACE_RELEASE: release,
}, 1000);
@@ -302,8 +302,8 @@ describe("refocus: overlapping events for one occupant", () => {
expect(context(await hook(f, "UserPromptSubmit", "s1"))).toBe("");
});
it("uses a shared successor guard after its former owner exits", async () => {
const f = fixture();
it("uses a shared successor guard after its former owner exits", async ({ expect, onTestFinished }) => {
const f = fixture(onTestFinished);
await hook(f, "PostCompact", "s1");
const guard = join(f.home, "refocus", `${SEAT}__s1.json.guard`);
const owner = spawnSync(process.execPath, ["-e", ""]).pid;
@@ -316,8 +316,8 @@ describe("refocus: overlapping events for one occupant", () => {
expect(readlinkSync(guard)).toBe(token);
});
it("does not take a successor when the old guard was released before the owner exited", async () => {
const f = fixture();
it("does not take a successor when the old guard was released before the owner exited", async ({ expect, onTestFinished }) => {
const f = fixture(onTestFinished);
await hook(f, "PostCompact", "s1");
const stateFile = join(f.home, "refocus", `${SEAT}__s1.json`);
const guard = stateFile + ".guard";
@@ -344,8 +344,8 @@ describe("refocus: overlapping events for one occupant", () => {
expect(readlinkSync(guard)).toBe(liveToken);
});
it("reports an unreadable guard and leaves the pending compaction available", async () => {
const f = fixture();
it("reports an unreadable guard and leaves the pending compaction available", async ({ expect, onTestFinished }) => {
const f = fixture(onTestFinished);
await hook(f, "UserPromptSubmit", "s1");
const guard = join(f.home, "refocus", `${SEAT}__s1.json.guard`);
mkdirSync(guard);
@@ -359,8 +359,8 @@ describe("refocus: overlapping events for one occupant", () => {
expect(context(await hook(f, "UserPromptSubmit", "s1"))).toBe("");
});
it("still delivers to each of two occupants of one seat when they overlap", async () => {
const f = fixture();
it("still delivers to each of two occupants of one seat when they overlap", async ({ expect, onTestFinished }) => {
const f = fixture(onTestFinished);
await hook(f, "PostCompact", "s1");
await hook(f, "PostCompact", "s2");
const runs = await Promise.all([
@@ -372,8 +372,8 @@ describe("refocus: overlapping events for one occupant", () => {
});
describe("refocus: state left by an earlier candidate", () => {
it("ignores a delivery claim and pendingSeq left on disk, so a resumed hook still delivers what is due", async () => {
const f = fixture();
it("ignores a delivery claim and pendingSeq left on disk, so a resumed hook still delivers what is due", async ({ expect, onTestFinished }) => {
const f = fixture(onTestFinished);
await hook(f, "PostCompact", "s1");
const file = join(f.home, "refocus", `${SEAT}__s1.json`);
writeFileSync(file, JSON.stringify({ ...stateOf(f, "s1"), pendingSeq: 3,
@@ -50,9 +50,20 @@ class FakeTmux {
if (!found) throw new Error(`can't find session: ${target}`);
return `${found[1].pane}|0|/tmp|80|24|1\n`;
}
// The stop's identity read asks for the session id with its server's pid and start time.
if (command.startsWith("tmux display-message") && command.includes("#{pid}")) return byTarget() ? `${byTarget()![1].id}|4242|1700000000\n` : "\n";
if (command.startsWith("tmux display-message") && command.includes("session_id")) return `${byTarget()?.[1].id ?? ""}\n`;
if (command.startsWith("tmux display-message") && command.includes("pane_current_command")) return byTarget() ? "zsh\n" : "";
if (command.startsWith("tmux capture-pane")) return byTarget() ? "user@host ~ % \n" : "";
if (command.startsWith("tmux if-shell -F") && command.includes("#{==:#{session_id},")) {
// A stop held to the session id and server it read: one branch runs, in the same command.
const found = byTarget();
const wantId = /#\{==:#\{session_id\},(\$\d+)\}/.exec(command)?.[1];
const same = found && found[1].id === wantId && command.includes("#{==:#{pid},4242}") && command.includes("#{==:#{start_time},1700000000}");
if (!same) return "openrig-stop-target-changed\n";
if (command.includes("kill-session")) this.sessions.delete(found[0]);
return "";
}
if (command.startsWith("tmux if-shell -F")) {
// Real tmux expands the format against the target session, then runs one branch in the same command.
const found = byTarget();
+143
View File
@@ -27,6 +27,8 @@ import { ClaudeCodeAdapter } from "../src/adapters/claude-code-adapter.js";
import { replaceFile } from "../src/adapters/replace-file.js";
import { shellQuote } from "../src/adapters/shell-quote.js";
import { claudeActivityRelayPath } from "../src/domain/claude-activity-hooks.js";
import { SeatDeliveryGuard, resolveGuardTarget } from "../src/domain/seat-delivery-guard.js";
import { setDaemonTmuxServer } from "../src/adapters/tmux-server.js";
function mockTmux(killResult?: { ok: boolean; code?: string; message?: string }): TmuxAdapter {
@@ -1343,3 +1345,144 @@ describe("RigTeardownOrchestrator", () => {
eventBus.persistWithinTransaction = origPersist;
});
});
// rig down stops only the seat's own terminal. On the production path (the guard installed, the server chosen by execution
// args, every write as argv), a session is stopped only when it carries the seat's OpenRig marker, and each effect runs
// only on the server and session id it was read from. Each case runs on its own private tmux server.
describe.skipIf(process.platform === "win32")("rig down leaves a terminal it can't show to be the seat's", () => {
const NAME = "dev-impl@owned";
const exec = promisify(execFile);
async function harness(hook?: (argv: string[], tmux: (...a: string[]) => Promise<string>) => Promise<void>) {
// Short socket path for macOS sockaddr_un, no inherited server, no config.
const home = fs.mkdtempSync(path.join(os.tmpdir(), "rd-"));
const env: NodeJS.ProcessEnv = { PATH: process.env.PATH, HOME: home, TMUX_TMPDIR: home, SHELL: "/bin/sh" };
const tmux = async (...args: string[]) => (await exec("tmux", ["-L", "own", "-f", "/dev/null", ...args], { env, timeout: 5000 })).stdout.trim();
const adapter = new TmuxAdapter(async () => { throw new Error("unexpected shell execution"); }, undefined, async (argv) => {
const out = (await exec(argv[0]!, argv.slice(1), { env, timeout: 5000 })).stdout;
await hook?.(argv, tmux);
return out;
}, ["-L", "own", "-f", "/dev/null"]);
const db = createDb();
migrate(db, ALL_MIGRATIONS);
const rigRepo = new RigRepository(db), sessionRegistry = new SessionRegistry(db), eventBus = new EventBus(db);
const guard = new SeatDeliveryGuard(db, (target) => resolveGuardTarget(db, target));
guard.recoverActivation();
adapter.deliveryGuard = guard;
const rig = rigRepo.createRig("owned");
const node = rigRepo.addNode(rig.id, "dev.impl", { runtime: "claude-code", cwd: home });
const bind = async () => {
const pane = (await tmux("list-panes", "-t", `=${NAME}:`, "-F", "#{pane_id}")).split("\n")[0]!;
const session = sessionRegistry.registerSession(node.id, NAME);
sessionRegistry.updateStatus(session.id, "running");
sessionRegistry.updateBinding(node.id, { attachmentType: "tmux", tmuxSession: NAME, tmuxPane: pane });
return session;
};
const teardown = () => new RigTeardownOrchestrator({ db, rigRepo, sessionRegistry, eventBus, tmuxAdapter: adapter,
snapshotCapture: mockSnapshotCapture(db) }).teardown(rig.id, { snapshot: false });
const alive = (name: string) => tmux("has-session", "-t", `=${name}`).then(() => true, () => false);
const close = async () => { await tmux("kill-server").catch(() => {}); db.close(); fs.rmSync(home, { recursive: true, force: true }); };
return { tmux, node, bind, teardown, alive, close, sessionRegistry, db };
}
// What's left after a refusal: the session row still running and its binding intact, so nothing reads as stopped.
function expectLeftRunning(h: Awaited<ReturnType<typeof harness>>, sessionId: string, result: { sessionsKilled: number; errors: string[] }, reason: RegExp) {
expect(result.sessionsKilled).toBe(0);
expect(result.errors).toHaveLength(1);
expect(result.errors[0]).toMatch(new RegExp(`^Kill failed for session '${NAME}': '${NAME}' was left running: its tmux session \\$\\d+ ${reason.source}`));
expect(h.sessionRegistry.getBindingForNode(h.node.id)?.tmuxSession).toBe(NAME);
expect(h.db.prepare("SELECT status FROM sessions WHERE id=?").get(sessionId)).toEqual({ status: "running" });
}
it("the seat's own session, marked with its node, is stopped as before", async () => {
const h = await harness();
try {
await h.tmux("new-session", "-d", "-s", "keepalive", "sleep 300");
await h.tmux("new-session", "-d", "-s", NAME, "-e", `OPENRIG_NODE_ID=${h.node.id}`, "sleep 300");
await h.bind();
expect(await h.teardown()).toMatchObject({ sessionsKilled: 1, errors: [] });
expect(await h.alive(NAME)).toBe(false);
expect(await h.alive("keepalive")).toBe(true);
expect(h.sessionRegistry.getBindingForNode(h.node.id)).toBeNull();
} finally { await h.close(); }
});
it("after the tmux server is replaced, another terminal with the seat's name and its old ids survives", async () => {
const h = await harness();
try {
await h.tmux("new-session", "-d", "-s", NAME, "-e", `OPENRIG_NODE_ID=${h.node.id}`, "sleep 300");
const session = await h.bind();
expect(h.sessionRegistry.getBindingForNode(h.node.id)?.tmuxPane).toBe("%0");
await h.tmux("kill-server");
await h.tmux("new-session", "-d", "-s", NAME, "sleep 301");
expect(await h.tmux("list-panes", "-t", `=${NAME}:`, "-F", "#{session_id} #{pane_id}")).toBe("$0 %0");
expectLeftRunning(h, session.id, await h.teardown(), /doesn't carry this seat's OpenRig marker \(OPENRIG_NODE_ID\)/);
expect(await h.alive(NAME)).toBe(true);
} finally { await h.close(); }
});
it("a session with the seat's name but no marker (adopted, bound or older) is left running, not stopped", async () => {
const h = await harness();
// The daemon is on its own server here, so the manual path must name it: a plain `tmux` reaches another server.
setDaemonTmuxServer(["-L", "own"]);
try {
await h.tmux("new-session", "-d", "-s", NAME, "sleep 300");
const session = await h.bind();
const result = await h.teardown();
expectLeftRunning(h, session.id, result, /doesn't carry this seat's OpenRig marker \(OPENRIG_NODE_ID\)/);
expect(result.errors[0]).toContain(`If it is this seat's, look first with \`tmux -L own attach -t '=${NAME}'\`, `
+ `then stop it with \`tmux -L own kill-session -t '=${NAME}'\` and run the command again.`);
expect(await h.alive(NAME)).toBe(true);
} finally { setDaemonTmuxServer([]); await h.close(); }
});
it("a session marked with another seat's node is left running", async () => {
const h = await harness();
try {
await h.tmux("new-session", "-d", "-s", NAME, "-e", "OPENRIG_NODE_ID=another-node", "sleep 300");
const session = await h.bind();
expectLeftRunning(h, session.id, await h.teardown(), /carries another seat's OpenRig marker \(OPENRIG_NODE_ID=another-node\)/);
expect(await h.alive(NAME)).toBe(true);
} finally { await h.close(); }
});
it("on the same server, a replacement with the seat's name (a different pane) survives", async () => {
const h = await harness();
try {
// The keepalive comes first, so the server stays the same one throughout.
await h.tmux("new-session", "-d", "-s", "keepalive", "sleep 300");
await h.tmux("new-session", "-d", "-s", NAME, "-e", `OPENRIG_NODE_ID=${h.node.id}`, "sleep 300");
const session = await h.bind();
await h.tmux("kill-session", "-t", `=${NAME}`);
await h.tmux("new-session", "-d", "-s", NAME, "sleep 301");
const result = await h.teardown();
expect(result).toMatchObject({ sessionsKilled: 0 });
expect(result.errors[0]).toMatch(/Managed pane identity unavailable or changed; no input written/);
expect(await h.alive(NAME)).toBe(true);
expect(h.db.prepare("SELECT status FROM sessions WHERE id=?").get(session.id)).toEqual({ status: "running" });
} finally { await h.close(); }
});
it("a server replaced between reading the seat's marker and stopping it leaves the new terminal alone", async () => {
let replaced = false;
const h = await harness(async (argv, tmux) => {
// Just after the marker is read on the seat's server, that server is replaced, and another terminal takes the name and
// the old ids.
if (replaced || !argv.includes("show-environment")) return;
replaced = true;
await tmux("kill-server");
await tmux("new-session", "-d", "-s", NAME, "sleep 301");
});
try {
await h.tmux("new-session", "-d", "-s", NAME, "-e", `OPENRIG_NODE_ID=${h.node.id}`, "sleep 300");
const session = await h.bind();
const result = await h.teardown();
expect(replaced).toBe(true);
expect(result.sessionsKilled).toBe(0);
expect(result.errors).toEqual([`Kill failed for session '${NAME}': $0 is no longer that session on the tmux server it was read from `
+ "(the server may have been replaced); nothing was detached or killed."]);
expect(await h.tmux("list-panes", "-t", `=${NAME}:`, "-F", "#{session_id} #{pane_id}")).toBe("$0 %0");
expect(h.db.prepare("SELECT status FROM sessions WHERE id=?").get(session.id)).toEqual({ status: "running" });
} finally { await h.close(); }
});
});
@@ -129,7 +129,7 @@ function fixture(options: { owner?: string | null; guest?: boolean; realGuest?:
const grants = new ActivityGrantStore(db);
/** OpenRig's pull-only records of the permits it decided by default. */
const permits = () => stream.list({ hintTag: "runner-permission-permit", limit: 100 });
return { db, runner, other, sessions, queueRepo, contract, nudges, requesters, generation, submit, read, withdraw, answer, act, decide, waitsOnPerson, rowsToSeat, grants,
return { db, bus, runner, other, sessions, queueRepo, contract, nudges, requesters, generation, submit, read, withdraw, answer, act, decide, waitsOnPerson, rowsToSeat, grants,
permits, relaunch: () => occupy(runner.id, SEAT, "handover") };
}
@@ -962,4 +962,64 @@ describe("an answered request: #2934's follow-ups", () => {
expect(await f.read(askId)).toMatchObject({ body: { state: "pending" } });
expect(f.permits()).toHaveLength(0);
});
it("a handoff that throws after its own write was released rolls back whole, so the kept answer is the only outcome", async () => {
const f = fixture({ decider: DECIDER });
const askId = String((await f.submit()).body.askId);
// The handoff's own transaction has released its savepoint; the throw comes after it, inside the caller's transaction.
const spy = vi.spyOn(f.queueRepo as unknown as { deliverWakeIntentAfterCommit: () => void }, "deliverWakeIntentAfterCommit")
.mockImplementationOnce(() => { throw new Error("failed after the handoff was written"); });
try {
expect(await f.answer(askId, { decision: "only the test file" })).toMatchObject({ status: 200 });
} finally { spy.mockRestore(); }
// Nothing of the handoff remains: the request wasn't handed on, and no row went to the receiving agent.
expect(f.queueRepo.getById(askId)).toMatchObject({ state: "pending" });
expect(f.db.prepare("SELECT COUNT(*) AS n FROM queue_items WHERE handed_off_from = ?").get(askId)).toEqual({ n: 0 });
expect((f.queueRepo.getById(askId)!.humanAnswers?.permission as { text?: string })?.text).toBe("only the test file");
expect(await f.read(askId)).toMatchObject({ body: { state: "pending" } });
expect(f.permits()).toHaveLength(0);
});
});
// The Slack fallback answers a direct runner ask with resolveDirectHumanReply itself (slack-subsystem.ts:179): no notify
// envelope and no transaction is open around it.
describe("a direct answer with no notify envelope (the Slack fallback)", () => {
const watch = (f: ReturnType<typeof fixture>) => {
const seen: Array<{ type: string; qitemId: string; to?: string; inTransaction: boolean }> = [];
const stop = f.bus.subscribe((event) => {
if (event.type === "queue.handed_off") seen.push({ type: event.type, qitemId: event.qitemId, to: event.toSession, inTransaction: f.db.inTransaction });
if (event.type === "queue.created") seen.push({ type: event.type, qitemId: event.qitemId, to: event.destinationSession, inTransaction: f.db.inTransaction });
});
return { seen, stop };
};
it("a handoff that throws after its own write delivers nothing to subscribers, and the answer is kept", async () => {
const f = fixture({ decider: DECIDER });
const askId = String((await f.submit()).body.askId);
const { seen, stop } = watch(f);
const spy = vi.spyOn(f.queueRepo as unknown as { deliverWakeIntentAfterCommit: () => void }, "deliverWakeIntentAfterCommit")
.mockImplementationOnce(() => { throw new Error("failed after the handoff was written"); });
try {
f.queueRepo.resolveDirectHumanReply({ qitemId: askId, actorSession: PERSON, decision: "only the test file" });
} finally { spy.mockRestore(); stop(); }
expect(seen.filter((e) => (e.type === "queue.handed_off" && e.qitemId === askId) || (e.type === "queue.created" && e.to === DECIDER))).toEqual([]);
expect(f.queueRepo.getById(askId)).toMatchObject({ state: "pending" });
expect(f.db.prepare("SELECT COUNT(*) AS n FROM queue_items WHERE handed_off_from = ?").get(askId)).toEqual({ n: 0 });
expect((f.queueRepo.getById(askId)!.humanAnswers?.permission as { text?: string })?.text).toBe("only the test file");
expect(f.permits()).toHaveLength(0);
});
it("a handoff that succeeds reaches subscribers once, after it commits", async () => {
const f = fixture({ decider: DECIDER });
const askId = String((await f.submit()).body.askId);
const { seen, stop } = watch(f);
try {
expect(f.queueRepo.resolveDirectHumanReply({ qitemId: askId, actorSession: PERSON, decision: "only the test file" })).toBe(true);
} finally { stop(); }
const successor = f.db.prepare("SELECT qitem_id FROM queue_items WHERE handed_off_from = ?").get(askId) as { qitem_id: string };
expect(seen.filter((e) => e.qitemId === askId || e.qitemId === successor.qitem_id)).toEqual([
{ type: "queue.handed_off", qitemId: askId, to: DECIDER, inTransaction: false },
{ type: "queue.created", qitemId: successor.qitem_id, to: DECIDER, inTransaction: false },
]);
});
});
@@ -25,6 +25,8 @@ function fixture(hooks: { file?: () => Promise<void>; exec?: (command: string) =
commands.push(command);
return hooks.exec ? hooks.exec(command)
: command.includes("list-panes") ? "%1|0|/inert|80|24|1\n"
: command.startsWith("tmux show-environment") ? "OPENRIG_NODE_ID=a\n"
: command.startsWith("tmux display-message") && command.includes("#{pid}") ? "$1|4242|1700000000"
: command.includes("#{session_id}") ? "$1" : "";
}, { writeFile: async () => { await hooks.file?.(); }, unlink: async () => {}, tmpName: () => "/inert/buffer", bufferName: () => "inert" });
const guard = new SeatDeliveryGuard(db, name => resolveGuardTarget(db, name)); tmux.deliveryGuard = guard;
@@ -103,7 +105,7 @@ describe("guard-wired teardown", () => {
expect(f.sessionRegistry.getBindingForNode("a")?.tmuxPane).toBe("%1");
await f.guard.set("a", false, "person", "stop");
expect(await f.teardown.teardown("r")).toMatchObject({ sessionsKilled: 1, errors: [] });
expect(f.commands).toContain("tmux kill-session -t '$1'");
expect(f.commands.some(c => /^tmux if-shell -F -t '\$1' .*#\{==:#\{pid\},4242\}.*kill-session -t .*\$1/.test(c))).toBe(true);
expect(f.commands.filter(c => c.includes("list-panes"))).toHaveLength(1);
expect(f.db.prepare("SELECT status FROM sessions WHERE id='sa'").get()).toEqual({ status: "exited" });
expect(f.sessionRegistry.getBindingForNode("a")).toBeNull(); f.db.close();
@@ -472,27 +472,27 @@ it("automatic reminder records retained custody and no delivered fire or input",
});
it("guarded session termination binds the immutable session ID, never recycled name",async()=>{
const {db,guard}=fixture();const commands:string[]=[];
const tmux=new TmuxAdapter(async command=>{commands.push(command);return command.includes("display-message")?"$123\n":"";});tmux.deliveryGuard=guard;
const tmux=new TmuxAdapter(async command=>{commands.push(command);return command.startsWith("tmux show-environment")?"OPENRIG_NODE_ID=a\n":command.startsWith("tmux display-message")?"$123|4242|1700000000\n":"";});tmux.deliveryGuard=guard;
vi.spyOn(tmux,"listPanes").mockResolvedValue([{id:"%1"} as never]);
expect(await guard.lifecycle(["a"],()=>tmux.killSession("a"))).toEqual({ok:true});
expect(commands.at(-1)).toBe("tmux kill-session -t '$123'");db.close();
expect(commands.at(-1)).toMatch(/^tmux if-shell -F -t '\$123' '#\{&&:#\{==:#\{session_id\},\$123\},#\{&&:#\{==:#\{pid\},4242\},#\{==:#\{start_time\},1700000000\}\}\}' 'kill-session -t .*\$123.*' 'display-message -p openrig-stop-target-changed'$/);db.close();
});
it("failed first binding compensates only its proven fresh session under the same lifecycle lease",async()=>{
const f=queueFixture();f.db.exec("INSERT INTO nodes(id,rig_id,logical_id) VALUES ('c','rig','fresh');");
const commands:string[]=[];const tmux=new TmuxAdapter(async command=>{commands.push(command);return command.includes("display-message")?"$44\n":"";});tmux.deliveryGuard=f.guard;
const commands:string[]=[];const tmux=new TmuxAdapter(async command=>{commands.push(command);return command.startsWith("tmux show-environment")?"OPENRIG_NODE_ID=c\n":command.startsWith("tmux display-message")?"$44|4242|1700000000\n":"";});tmux.deliveryGuard=f.guard;
vi.spyOn(tmux,"listPanes").mockResolvedValue([{id:"%3"} as never]);
vi.spyOn(f.bus,"persistWithinTransaction").mockImplementation(()=>{throw Error("injected DB failure");});
const launcher=new NodeLauncher({db:f.db,rigRepo:f.rigRepo,sessionRegistry:f.sessionRegistry,eventBus:f.bus,tmuxAdapter:tmux});
expect(await launcher.launchNode("rig","fresh")).toMatchObject({ok:false,code:"db_error"});
expect(commands.at(-1)).toBe("tmux kill-session -t '$44'");expect(f.guard.target("c").pane).toBeNull();f.db.close();
expect(commands.at(-1)).toMatch(/^tmux if-shell -F -t '\$44' .*#\{==:#\{pid\},4242\}.*kill-session -t .*\$44/);expect(f.guard.target("c").pane).toBeNull();f.db.close();
});
it("private probe termination uses only its positively allocated session identity",async()=>{
const {db,guard}=fixture();const commands:string[]=[];
const tmux=new TmuxAdapter(async command=>{commands.push(command);return command.includes("display-message")?"$55\n":"";});tmux.deliveryGuard=guard;
const tmux=new TmuxAdapter(async command=>{commands.push(command);return command.startsWith("tmux show-environment")?"OPENRIG_NODE_ID=none\n":command.startsWith("tmux display-message")?"$55|4242|1700000000\n":"";});tmux.deliveryGuard=guard;
vi.spyOn(tmux,"listPanes").mockResolvedValue([{id:"%55"} as never]);
expect(await tmux.createProbeSession("probe")).toEqual({ok:true});expect(await tmux.killSession("probe")).toEqual({ok:true});
expect(commands.at(-1)).toBe("tmux kill-session -t '$55'");const count=commands.length;
expect(commands.at(-1)).toMatch(/^tmux if-shell -F -t '\$55' .*#\{==:#\{pid\},4242\}.*kill-session -t .*\$55/);const count=commands.length;
expect(await tmux.sendKeys("probe",["Enter"])).toMatchObject({ok:false});expect(commands).toHaveLength(count);db.close();
});
+172
View File
@@ -161,6 +161,31 @@ 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", {
@@ -523,6 +548,153 @@ 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", () => {
+845 -24
View File
@@ -22,9 +22,28 @@ 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++;
@@ -33,6 +52,14 @@ 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++;
@@ -45,9 +72,20 @@ 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 readFileSync = ((...args: Parameters<typeof actual.readFileSync>) => {
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 requestedPath = String(args[0]);
if (catalogReadObservation.activePath !== null && requestedPath === catalogReadObservation.activePath) {
catalogReadObservation.reads++;
@@ -61,10 +99,18 @@ vi.mock("node:fs", async (importOriginal) => {
throw injectedError(nodeMetadataFault.selectionCode);
}
return actual.readFileSync(...args);
}) as typeof actual.readFileSync;
}) as typeof actual.readFileSync) as unknown as typeof actual.readFileSync;
const actualDefault = (actual as unknown as { default?: object }).default ?? actual;
const defaultFs = Object.assign(Object.create(actualDefault), { existsSync, statSync, accessSync, readFileSync });
return { ...actual, existsSync, statSync, accessSync, readFileSync, default: defaultFs };
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 };
});
import { Hono } from "hono";
@@ -86,6 +132,7 @@ 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";
@@ -169,8 +216,14 @@ 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)", () => {
@@ -438,6 +491,18 @@ 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`);
@@ -449,17 +514,36 @@ 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", 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`);
{ 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}`);
expect(detail.status).toBe(200);
const body = await detail.json();
expect(body.slicePath).toBe(path.join(workspaceRoot, project, "missions/a-first/slices/same-slice"));
expect(body.slicePath).toBe(path.join(workspaceRoot, project, "missions", mission, "slices", "same-slice"));
expect(fs.readFileSync(path.join(body.slicePath, "SPEC.md"), "utf8")).toBe(literal);
expect(body.missionId).toBe("a-first");
expect(body.tests.proofPackets).toEqual([]);
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.tests.aggregate).toEqual({ passCount: 0, failCount: 0 });
});
@@ -473,25 +557,762 @@ describe("PL-slice-story-view-v0 slices routes", () => {
expect(await response.json()).toEqual({ relPath: decodeURIComponent(rel), content: literal });
});
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.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("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(404);
expect(await detail.json()).toEqual({ error: "slice_not_found", name: "same-slice" });
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("doc preserves the detail route's current missing later-duplicate selection", async () => {
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");
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"],
}] },
});
});
it("doc follows the detail route's later-duplicate mission selection", async () => {
const response = await app.request("/api/slices/same-slice/doc/SPEC.md?project=alpha&mission=z-later");
const body = await response.json();
expect({ status: response.status, content: body.content }).toEqual({ status: 404, content: undefined });
expect(body).toEqual({ error: "doc_not_found" });
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");
});
it.each([
@@ -47,15 +47,16 @@ 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("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";
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";
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 } = {}): TmuxAdapter {
function recordingAdapter(calls: RunCall[], opts: { failing?: string[]; guard?: boolean; composer?: boolean; nodeId?: string } = {}): TmuxAdapter {
const execs: string[] = [];
const fileOps: TmuxFileOps = {
writeFile: async () => {},
@@ -69,8 +70,8 @@ function recordingAdapter(calls: RunCall[], opts: { failing?: string[]; guard?:
}, fileOps);
if (opts.guard) {
adapter.deliveryGuard = {
maybeTarget: () => TARGET,
target: () => TARGET,
maybeTarget: () => ({ ...TARGET, nodeId: opts.nodeId ?? TARGET.nodeId }),
target: () => ({ ...TARGET, nodeId: opts.nodeId ?? TARGET.nodeId }),
ownsLifecycle: () => false,
checkInput: () => {},
input: (_name: string, fn: () => Promise<unknown>) => fn(),
@@ -160,9 +161,10 @@ 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 first.
// 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.
await recordingAdapter(calls, { failing: ["paste-buffer"] }).sendText(SESSION, "hello");
await recordingAdapter(calls, { guard: true }).killSession(SESSION);
await recordingAdapter(calls, { guard: true, nodeId: "n1" }).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,7 +11,9 @@ 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): string {
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 {
if (argv[1] === "list-sessions") return Object.keys(panes).join("\n") + "\n";
const cmds: string[][] = [[]];
for (const a of argv.slice(1)) {
@@ -24,7 +26,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]}\n` : panes[name];
out += c[0] === "display-message" ? `${c[c.length - 1]!.replace("#{pane_id} #{pane_pid}", identities[name] ?? "#{pane_id} #{pane_pid}")}\n` : panes[name];
}
if (out.length > maxBuffer) throw new Error("stdout maxBuffer length exceeded");
return out;
@@ -41,12 +43,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 } = {}) {
function adapter(panes: Panes, opts: { vanished?: Set<string>; noServer?: boolean; maxBuffer?: number; identities?: Record<string, string> } = {}) {
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);
return runTmux(argv, panes, opts.vanished, opts.maxBuffer, opts.identities);
};
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))
@@ -62,6 +64,17 @@ 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,6 +197,42 @@ 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" });
@@ -1,12 +1,12 @@
import { describe, it, expect } from "vitest";
import { describe, it } 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("activity stream header deadline", () => {
it("rejects a reachable endpoint that never returns response headers", async () => {
describe.concurrent("activity stream header deadline", () => {
it("rejects a reachable endpoint that never returns response headers", async ({ expect }) => {
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("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 () => {
it("keeps an established SSE stream alive beyond the header deadline", async ({ expect }) => {
let closed = false;
let writeLater!: () => void;
const server = createServer((_req, res) => {
@@ -50,8 +50,8 @@ describe("activity stream header deadline", () => {
});
describe("temporary activity stream opening failures", () => {
it("reconnects after a slow daemon exceeds the header deadline and delivers its next event", async () => {
describe.concurrent("temporary activity stream opening failures", () => {
it("reconnects after a slow daemon exceeds the header deadline and delivers its next event", async ({ expect }) => {
let calls = 0;
const late = new Set<ReturnType<typeof setTimeout>>();
const server = createServer((_req, res) => {
+58
View File
@@ -0,0 +1,58 @@
// `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(", ")}`,
);
});