Compare commits

...
Author SHA1 Message Date
core-builder-b 4414ca2fc8 feat(daemon): a person's clicked answer is an event, partial or complete
A click on an ask with questions records the chosen option on the ask.
It is the same write whether the click came from Steering, the Feed,
Slack or an MCP client. Until the last question is answered, nothing
else happens: the ask stays open and no transition is written. So a
client following /api/events never heard of a partial answer, and had
to re-read every ask to notice one.

Each click that changes the answers now adds one queue.answer_recorded
event:
  { qitemId, actorSession, questionId, answered, questions, complete,
    summary }
- complete: false says the set is still partial. A partial answer never
  reads as the full answer, or as no answer.
- The event carries ids and counts only. The person's choice stays on
  the ask, in humanAnswers.
- A repeated click on the same option has no event. Neither does a typed
  reply, which settles the ask through its own queue.updated.

The event is recorded after the answer commits. If it can't be
recorded, that's logged, and the answer and its return go ahead
unchanged. The type is history only, so events retention may prune it
with the other queue events.
2026-11-12 05:11:51 +00:00
Mike Schwarz 25b8f05991 fix(queue): accept punctuation after verified custody targets (#3004)
A custody-verified note that named the exact closure target and ended its sentence with a period raised a false custody finding, because the period was read as part of the target. The exact target followed by one period, comma, semicolon, colon or closing parenthesis now verifies; longer, shorter and different targets still don't match, and an exact target that itself ends in that mark still matches as written. rig queue update --help shows a host-qualified target and says the note may accompany the closure; a separate note isn't required. No data migration and no historical closure edits.
2026-11-11 20:07:49 -08:00
Mike Schwarz a093534871 fix(daemon): a fresh launch after rig unclaim ended the server's last session reopens the server (#3009)
After rig unclaim released an adopted session and the person ended its process and tmux session, a fresh launch said the tmux transport was unavailable and to retry, which never helped when that was the server's last session. The fresh launch's server-restart fallback now also accepts a row rig unclaim released (detached, origin claimed, no binding and no other active row), as it already did for an exited one. The reconciler's live-process check still runs first, and a released seat keeps its process record through the identity sweep, so a released session that's still running blocks the restart.
2026-11-11 19:57:21 -08:00
Edward Bowie 65b466235e fix(cli): don't report a daemon as stopped when this shell can't see its pid (#2860) (#2913)
From a shell that can't reach the daemon, such as Claude Code's Bash sandbox, rig reported "Daemon stopped" or "Daemon not running" while the daemon was running, so an agent concluded it was down. Status now checks the process the daemon recorded: when it's alive, rig says it couldn't reach the daemon from this shell, and it doesn't advise or auto-start another daemon. Only a no-such-process answer reads as missing, other errors read unknown, and a missing answer from a different process namespace is no longer taken as proof the daemon died. A daemon that really stopped still reads stopped. For #2860.
2026-11-11 19:41:43 -08:00
Edward Bowie 27d5d6d206 fix(scripts): read npm pack --json in both npm 10 and npm 12 shapes (#2953) (#2975)
Under npm 12, `npm pack --json` prints an object keyed by package name, so six scripts and tests that read `[0]` failed, including the release pack and the substance gate. A shared reader, `scripts/npm-pack-json.mjs`, accepts both npm 10/11's array and npm 12's object, and all six readers use it; npm 10 and 11 output reads as before. The substance gate binds one package's receipt, so it now refuses pack output that lists more than one package, naming --package-root and any npm workspaces setting, instead of certifying the first entry only. For #2953.
2026-11-11 19:19:48 -08:00
Mike Schwarz aa71304a7d fix(tui): show a seat the daemon reads as unknown as unknown, not idle (#3012)
When the daemon couldn't tell what a running seat was doing, rig ps said unknown, but the TUI's list, explorer and agent-detail status rows said idle unless the seat was a community harness, so a seat thinking quietly could look free to take more work. Those rows now show unknown when the daemon serves an explicit unknown. The daemon's idle and working words keep their mapping, and screen motion still shows active. When no activityState is served, the existing screen fallback remains; this change covers an explicit unknown display. The topology graph view is unchanged.
2026-11-11 19:11:45 -08:00
Coder8124 492170b1b5 fix(daemon): give an unreadable Claude statusline cache its own reason (#2880)
`rig provider status` gave a Claude statusline cache that couldn't be parsed the same unknown reason as a valid cache with no rate-limit windows, so a usage check couldn't tell a broken cache from one with nothing reported yet. An unreadable cache now reports `claude_statusline_cache_unreadable`, and a readable cache with no windows keeps `claude_statusline_cache_had_no_windows`; both stay unknown rows with no percentage. Discovery also keeps a seat whose only cache file is malformed, naming it from the file name, so that seat reads unreadable instead of no cache yet. For #2875.
2026-11-11 18:51:26 -08:00
36 changed files with 969 additions and 84 deletions

No files matched your search

+3 -1
View File
@@ -13,7 +13,9 @@ provider usage stop updating, and Claude resume-token capture at adoption or
handover loses its current session evidence. An earlier sidecar can remain and
contain an older session ID; without one, context and capture report
`missing_sidecar`, and provider usage reports `no_statusline_cache_yet` unless an
earlier cache remains.
earlier cache remains. A cache that exists but cannot be read reports
`claude_statusline_cache_unreadable`; a readable cache that carried no rate
limits reports `claude_statusline_cache_had_no_windows`.
## Feed both commands the same input
+7
View File
@@ -82,6 +82,13 @@ All of them are server-sent events (`text/event-stream`):
`?replay=all`, no `replay`, or any other value sends the whole log first. A `Last-Event-ID` header (what
`EventSource` sends when it reconnects) sends the events after that id instead, whatever `replay` says.
Live events that arrive during the replay follow it, without duplicates.
When a person clicks an answer on an ask with questions (in Steering, the Feed, Slack or an MCP client), the stream
carries `queue.answer_recorded`: `{ qitemId, actorSession, questionId, answered, questions, complete, summary }`.
`complete: false` means the set is still partial: the ask stays open, and its answers haven't gone back yet. The
event carries ids and counts, not the choice; read the ask for its `humanAnswers`. A click that changes nothing (the
same option again) has no event, and neither does a typed reply, which settles the ask through its own
`queue.updated`. The event is recorded after the answer: if it can't be recorded, the answer still stands, so a
client that re-reads the ask after a gap still sees it.
- **`GET /api/activity/events`**: live only, no replay or ids. Each message is named after its event type:
`seat.activity_changed`, `seat.rung_health`, `proof.judged` or `proof.sources_changed`. It tells a view to read
again; it carries no derived state.
+17 -3
View File
@@ -17,6 +17,7 @@ import {
OPENRIG_DIR,
STATE_FILE,
resolveBindIntent,
statusGuardMessage,
} from "../daemon-lifecycle.js";
interface ProcessAliveDeps {
@@ -37,14 +38,16 @@ export function createIsProcessAlive(deps: ProcessAliveDeps): (pid: number) => b
export type SignalOutcome = "sent" | "missing" | "not-permitted";
/** kill(pid, 0) as an outcome. POSIX: only ESRCH means no such process; EPERM means the process
* EXISTS but this shell may not signal it (another user's process, or a sandbox such as Codex's). */
/** kill(pid, 0) as an outcome. POSIX: kill(pid, 0) fails only with ESRCH (no such process) or EPERM
* (the process EXISTS but this shell may not signal it: another user's process, or a sandbox such as
* Codex's). Only ESRCH is "missing"; any other error is not evidence of death, so it reads as
* "not-permitted" (uninspectable), never as a dead process. */
export function signalProbe(pid: number, kill: (pid: number, signal: 0) => unknown = process.kill): SignalOutcome {
try {
kill(pid, 0);
return "sent";
} catch (error) {
return (error as NodeJS.ErrnoException).code === "EPERM" ? "not-permitted" : "missing";
return (error as NodeJS.ErrnoException).code === "ESRCH" ? "missing" : "not-permitted";
}
}
@@ -159,6 +162,11 @@ export function realDeps(): LifecycleDeps {
isProcessAlive,
// RULING 1ae863d2 — sibling-home scan (home-resolution honesty).
listDir: (p) => { try { return fs.readdirSync(p); } catch { return []; } },
// #2860: where the OS has pid namespaces (Linux), a missing pid is only proof from the daemon's own.
pidNamespace: () => {
if (process.platform !== "linux") return "host";
try { return fs.readlinkSync("/proc/self/ns/pid"); } catch { return undefined; }
},
};
}
@@ -369,6 +377,12 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
console.log(` resolved: ${status.siblingHint.resolvedHome}`);
console.log(` sibling: ${status.siblingHint.siblingHome}`);
console.log(" Fix: point OPENRIG_HOME at the right home (or check your shell env) — this CLI is likely resolving the wrong home.");
} else if (status.pidUnseen) {
// #2860: the same words as the command guard.
const unseen = statusGuardMessage(status);
console.log(unseen.fact);
console.log(` ${unseen.consequence}`);
console.log(` ${unseen.action}`);
} else if (!status.startPending) {
// A pending start's probe was refused, not inconclusive; its line above says what is known.
console.log("Daemon state UNVERIFIED — the probe timed out or was inconclusive (this is NOT evidence the daemon is down).");
+7
View File
@@ -1395,6 +1395,13 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
.option("--summary <text>", "Summary persisted onto the item (human-seat parks, or standalone summary update without --state)")
.option("--evidence-ref <path>", "Pointer to the durable artifact, persisted onto the item on a human-seat park or a done close (--state done). For a code result, a JSON file of kind openrig.code-result/v1 (repo, base and revision as full commit ids, paths) lets a reader diff it exactly")
.option("--note <text>", "Transition note for the audit log")
.addHelpText("after", `
After reading an existing remote successor, record its exact closure target, including the host:
rig queue update <source-id> --note "custody-verified: receiver@team@remote-host. Registered-host read confirmed the successor"
The note can accompany the closing update or be appended later; a separate note is not required.
A trailing period, comma, semicolon, colon or closing parenthesis is accepted; a longer or different target is not.
For a new cross-host transfer, prefer rig queue handoff --host <registered-host>.
`)
.option("--source <name@external>", "Outside a managed seat: the external routine that appends the --note, recorded as an external actor (claimed:v1), never a seat; note only, on this host's daemon (a --host that resolves to another host is refused). A managed seat stays the actor. Like any note, it resets the item's stall and notification timing (and its recovery and operator episodes) and advances a claimed item's last activity, but never resets how long an unclaimed item has waited")
.option("--host <id>", QUEUE_HOST_ROW_OPTION_HELP)
.option("--full", "Print the whole response row(s) as today, even when output.compact is on (the receipt's waiting keeps only blocker and nextBackstop)")
+66 -9
View File
@@ -22,8 +22,15 @@ export interface DaemonState {
host?: string;
db: string;
startedAt: string;
/** The pid namespace (Linux `/proc/self/ns/pid`) of the shell that started the daemon. A shell
* in another namespace cannot tell a missing pid from an invisible one, nor map the number.
* Absent on older records. */
pidNamespace?: string;
}
/** Linux' initial pid namespace: where an older record, which names no namespace, is read. */
const INITIAL_PID_NAMESPACE = "pid:[4026531836]";
/**
* OPR.0.4.3.21 — event-loop wedge evidence read from the enriched `/healthz`
* body. Present only when the daemon actually answered healthz with a monitor
@@ -64,6 +71,10 @@ export interface DaemonStatus {
eventLoop?: DaemonEventLoopEvidence;
/** #275 — the OS code behind a failed health probe (e.g. EPERM when a sandbox blocked it). */
probeErrorCode?: string;
/** The recorded pid looks missing and the connection was refused, but this shell is not in the
* daemon's pid namespace and no clean-shutdown receipt matches: nothing here shows the daemon is
* down (#2860). */
pidUnseen?: true;
/** T2 — a refused listener under a live start reservation (daemon-start.lock): a launch is in
* progress and not listening yet. `pid` is the live launcher (else child) PID; `since` its start. */
startPending?: { pid: number; since?: string };
@@ -152,6 +163,15 @@ export function statusGuardMessage(status: DaemonStatus): DaemonNotRunningError
...causeCode,
};
}
// #2860: a refused probe and a missing pid, seen from outside the daemon's pid namespace.
if (status.pidUnseen) {
return {
fact: "Couldn't connect to the daemon from this shell; it may still be running.",
consequence: "This shell can't see the daemon's process (a sandbox or another namespace) and no clean shutdown was recorded, so a refused connection here doesn't show it is down.",
action: "Don't start another daemon from here. Check from a normal terminal with 'rig daemon status'.",
...causeCode,
};
}
// unverified, or running-but-unhealthy: we do NOT know it is down.
return {
fact: "Daemon did not respond — it may be busy or stopped (state not confirmed).",
@@ -248,6 +268,9 @@ export interface LifecycleDeps {
/** #275 — three-state liveness for status reads (production: realDeps). Optional so existing
* mocks keep the boolean rule; absent = derived from isProcessAlive. */
processLiveness?: (pid: number) => ProcessLiveness;
/** This shell's pid namespace ("host" where the OS has none, undefined when unreadable).
* Production: realDeps. Absent = no namespace evidence, a missing pid counts as dead. */
pidNamespace?: () => string | undefined;
// OPR.0.4.2.1 — optional injectable delay for the status-probe bounded settle/retry.
// Defaults to a real setTimeout in production; tests pass a no-op to stay fast. Optional so
// existing deps / mocks / callers are untouched.
@@ -889,7 +912,8 @@ async function startOwnedDaemon(opts: StartOptions, deps: LifecycleDeps, lock: D
}
assertChild();
if (!deps.isProcessAlive(pid!)) throw new Error(`Daemon child ${pid} liveness could not be confirmed; state not published`);
const state: DaemonState = { pid: pid!, port, host: probeHost, db, startedAt: new Date().toISOString() };
const namespace = deps.pidNamespace?.();
const state: DaemonState = { pid: pid!, port, host: probeHost, db, startedAt: new Date().toISOString(), ...(namespace && namespace !== "host" ? { pidNamespace: namespace } : {}) };
try {
deps.writeFile(STATE_FILE, JSON.stringify(state, null, 2));
assertChild();
@@ -950,6 +974,17 @@ function isCleanShutdown(receipt: DaemonShutdownReceipt | undefined): boolean {
return receipt?.outcome === "clean" && receipt.phase === "complete" && receipt.failures.length === 0;
}
/** Whether the recorded pid number means the same process in this shell. A recorded namespace must
* be this shell's own: another namespace (even the host's) numbers processes differently, so its
* missing number says nothing. Only an older record, which names none, is read as the host's. */
function sharesPidNamespace(state: DaemonState, deps: LifecycleDeps): boolean {
if (!deps.pidNamespace) return true;
const own = deps.pidNamespace();
if (own === undefined) return false;
if (state.pidNamespace) return own === state.pidNamespace;
return own === "host" || own === INITIAL_PID_NAMESPACE;
}
function removeMatchingState(deps: LifecycleDeps, stateFile: string, state: DaemonState): void {
// A concurrent start/rebind must not have its state removed by an old stop/status.
const current = readState(deps);
@@ -1153,7 +1188,23 @@ export async function getDaemonStatus(
return { state: "running", port: Number(url.port) || DEFAULT_PORT, host: url.hostname || DEFAULT_HOST, healthy: ev.healthy, reason: ev.reason, eventLoop: ev.eventLoop, ...(ev.databaseWarnings ? { databaseWarnings: ev.databaseWarnings } : {}) };
} catch (err) {
// 1ae863d2: refusal = positive down; anything else (timeout/wedged/blocked) = unverified.
return isRefusedError(err) ? { state: "stopped" } : { state: "unverified", ...endpoint, ...probeErrorCodeOf(err) };
if (!isRefusedError(err)) return { state: "unverified", ...endpoint, ...probeErrorCodeOf(err) };
// #2860: a loopback URL that is this home's recorded daemon is judged like the record: a refusal
// alone proves nothing from a shell that can't see the pid. No record for it keeps the plain rule.
const recorded = "explicitRemote" in endpoint ? null : readState(deps);
if (!recorded || recorded.port !== ("port" in endpoint ? endpoint.port : undefined)) return { state: "stopped" };
const recordedHost = recorded.host ?? DEFAULT_HOST;
const recordedLiveness: ProcessLiveness = deps.processLiveness
? deps.processLiveness(recorded.pid)
: deps.isProcessAlive(recorded.pid) ? "alive" : "dead";
const { receipt } = readShutdownReceipt(deps, resolveLifecycleFile(deps, "daemon.json"));
if ((receiptMatchesState(receipt, recorded) && isCleanShutdown(receipt))
|| (recordedLiveness === "dead" && sharesPidNamespace(recorded, deps))) return { state: "stopped" };
const startPending = liveStartReservation(deps);
return {
state: "unverified", pid: recorded.pid, port: recorded.port, host: recordedHost, ...(startPending ? { startPending } : {}),
...(recordedLiveness !== "alive" && !startPending ? { pidUnseen: true as const } : {}), ...probeErrorCodeOf(err),
};
}
}
@@ -1187,17 +1238,21 @@ export async function getDaemonStatus(
}
}
const liveness: ProcessLiveness = deps.processLiveness
let liveness: ProcessLiveness = deps.processLiveness
? deps.processLiveness(state.pid)
: deps.isProcessAlive(state.pid) ? "alive" : "dead";
// #2860: down needs positive evidence: the daemon's clean-shutdown receipt, or a missing pid seen
// from the daemon's own pid namespace. Elsewhere a missing pid may just be invisible.
const stateFile = resolveLifecycleFile(deps, "daemon.json");
const { receipt } = readShutdownReceipt(deps, stateFile);
const cleanShutdown = receiptMatchesState(receipt, state) && isCleanShutdown(receipt);
if (liveness === "dead") {
// A status read must not erase the identity needed to judge a failed stop.
const stateFile = resolveLifecycleFile(deps, "daemon.json");
const { receipt } = readShutdownReceipt(deps, stateFile);
if (options.cleanupStaleState !== false && receiptMatchesState(receipt, state) && isCleanShutdown(receipt)) {
if (cleanShutdown && options.cleanupStaleState !== false) {
// A status read must not erase the identity needed to judge a failed stop.
removeMatchingState(deps, stateFile, state);
}
return { state: "stale" };
if (cleanShutdown || sharesPidNamespace(state, deps)) return { state: "stale" };
liveness = "unknown";
}
const host = state.host ?? DEFAULT_HOST;
@@ -1213,7 +1268,9 @@ export async function getDaemonStatus(
// T2: unless a live start reservation says a launch is in progress.
if (isRefusedError(err)) {
const startPending = liveStartReservation(deps);
return startPending ? { state: "unverified", pid: state.pid, port: state.port, host, startPending, ...probeErrorCodeOf(err) } : { state: "stopped" };
if (startPending) return { state: "unverified", pid: state.pid, port: state.port, host, startPending, ...probeErrorCodeOf(err) };
// #2860: a refusal from a shell that can't see the pid proves nothing without a clean shutdown.
return cleanShutdown ? { state: "stopped" } : { state: "unverified", pid: state.pid, port: state.port, host, pidUnseen: true, ...probeErrorCodeOf(err) };
}
return { state: "unverified", pid: state.pid, port: state.port, host, ...probeErrorCodeOf(err) };
}
+1 -1
View File
@@ -65,7 +65,7 @@ export function getOfflineHelpPath(): string | undefined {
* reporting a starved loop) gets the request, under its normal timeout. */
export function daemonMayAnswer(status: DaemonStatus): boolean {
if (status.state === "stopped" || status.state === "stale") return false;
if (status.probeErrorCode === "ECONNREFUSED" || status.startPending) return false;
if (status.probeErrorCode === "ECONNREFUSED" || status.startPending || status.pidUnseen) return false;
if (status.siblingHint || isBlockedConnectionCode(status.probeErrorCode)) return false;
return true;
}
@@ -289,6 +289,12 @@ describe("sandboxed liveness: EPERM on kill(pid, 0) is not a dead daemon", () =>
expect(signalProbe(1, throwing("ESRCH"))).toBe("missing");
});
it("an error other than ESRCH is never evidence that the process is gone", () => {
const throwing = (code: string | undefined) => () => { throw Object.assign(new Error(String(code)), { code }); };
expect(signalProbe(1, throwing("EINVAL"))).toBe("not-permitted");
expect(signalProbe(1, throwing(undefined))).toBe("not-permitted");
});
it("unknown liveness plus a blocked probe is unverified with EPERM, never stale, and the guard says blocked", async () => {
const status = await getDaemonStatus(withState({
isProcessAlive: () => false,
@@ -304,14 +310,16 @@ describe("sandboxed liveness: EPERM on kill(pid, 0) is not a dead daemon", () =>
expect(guard.fact).not.toMatch(/not running/i);
});
it("unknown liveness plus a refused probe is still a stopped daemon with down advice", async () => {
// #2860: a refusal from a shell that can't inspect the pid is "stopped" only with the daemon's clean-shutdown receipt.
it("unknown liveness plus a refused probe and no clean shutdown is unverified, not stopped", async () => {
const status = await getDaemonStatus(withState({
isProcessAlive: () => false,
processLiveness: sandboxLiveness,
fetch: probeFailing("ECONNREFUSED"),
}));
expect(status.state).toBe("stopped");
expect(statusGuardMessage(status).fact).toMatch(/not running/i);
expect(status.state).toBe("unverified");
expect(status.pidUnseen).toBe(true);
expect(statusGuardMessage(status).fact).toMatch(/couldn't connect.*from this shell/i);
});
it("unknown liveness plus a healthy probe is running", async () => {
@@ -0,0 +1,207 @@
// #2860 — a sandbox that cuts loopback often also hides the daemon's pid. A refused probe plus a
// missing pid then looked exactly like a stopped daemon, and the guard told the agent to run `rig up`.
//
// Contract pinned here: "stopped" needs positive evidence, either the daemon's own clean-shutdown
// receipt or a missing pid seen from the daemon's pid namespace. Otherwise status says the CLI
// couldn't connect from this shell and the daemon may still be running, and never advises starting
// another daemon.
import { afterEach, describe, it, expect, vi } from "vitest";
import {
daemonStatusGuard,
getDaemonStatus,
statusGuardMessage,
STATE_FILE,
type DaemonState,
type LifecycleDeps,
} from "../src/daemon-lifecycle.js";
import { daemonCommand } from "../src/commands/daemon.js";
import { daemonMayAnswer, shouldAutoStartDaemon } from "../src/daemon-reachability.js";
const INITIAL_NAMESPACE = "pid:[4026531836]";
const DAEMON_NAMESPACE = "pid:[4026532001]";
const SANDBOX_NAMESPACE = "pid:[4026532777]";
const START = "2026-01-01T00:00:00Z";
const REFUSED: LifecycleDeps["fetch"] = async () => {
const err = new Error("fetch failed") as Error & { cause?: { code: string } };
err.cause = { code: "ECONNREFUSED" };
throw err;
};
function cleanReceipt(pid: number): string {
const now = new Date().toISOString();
return JSON.stringify({ schema: "openrig.daemon-shutdown/v1", pid, startedAt: START, completedAt: now, outcome: "clean", phase: "complete", failures: [] });
}
function deps(state: DaemonState, over: Partial<LifecycleDeps> = {}, receipt?: string): LifecycleDeps {
return {
spawn: vi.fn() as unknown as LifecycleDeps["spawn"],
fetch: vi.fn(REFUSED),
kill: vi.fn(() => true),
readFile: vi.fn((p: string) => (p === STATE_FILE ? JSON.stringify(state) : p.endsWith("daemon-shutdown.json") ? receipt ?? null : null)),
writeFile: vi.fn(),
removeFile: vi.fn(),
exists: vi.fn((p: string) => p === STATE_FILE || (receipt !== undefined && p.endsWith("daemon-shutdown.json"))),
mkdirp: vi.fn(),
openForAppend: vi.fn(() => 0),
isProcessAlive: vi.fn(() => false),
processLiveness: vi.fn(() => "dead" as const),
pidNamespace: () => SANDBOX_NAMESPACE,
sleep: async () => {},
...over,
};
}
const recorded = (extra: Partial<DaemonState> = {}): DaemonState =>
({ pid: 4242, port: 7433, db: "x.db", startedAt: START, pidNamespace: DAEMON_NAMESPACE, ...extra });
afterEach(() => vi.restoreAllMocks());
describe("#2860: a refused probe with an invisible pid is not a stopped daemon", () => {
it("reads 'couldn't connect from this shell' when the pid is missing, the probe is refused and there is no clean shutdown", async () => {
const status = await getDaemonStatus(deps(recorded()));
expect(status.state).toBe("unverified");
expect(status.pidUnseen).toBe(true);
const message = statusGuardMessage(status);
expect(message.fact).toMatch(/couldn't connect to the daemon from this shell.*may still be running/i);
expect(`${message.fact} ${message.consequence} ${message.action}`).not.toMatch(/rig up|rig daemon start/i);
expect(message.action).toMatch(/don't start another daemon/i);
expect(shouldAutoStartDaemon(status)).toBe(false);
});
it("blocks commands through the guard with the same message", async () => {
const status = await getDaemonStatus(deps(recorded()));
const lines: string[] = [];
vi.spyOn(console, "error").mockImplementation((line: string) => { lines.push(line); });
const savedExit = process.exitCode;
try {
expect(daemonStatusGuard(status)).toBe(false);
} finally { process.exitCode = savedExit; }
const message = statusGuardMessage(status);
expect(lines).toEqual([`Error: ${message.fact}`, ` ${message.consequence}`, ` ${message.action}`]);
});
it("is the same on an older record that names no namespace", async () => {
const status = await getDaemonStatus(deps({ pid: 4242, port: 7433, db: "x.db", startedAt: START }));
expect(status.pidUnseen).toBe(true);
});
it("is the same when this shell cannot read its own namespace", async () => {
const status = await getDaemonStatus(deps(recorded(), { pidNamespace: () => undefined }));
expect(status.pidUnseen).toBe(true);
});
it("is the same when the pid exists but cannot be inspected (unknown liveness) and the probe is refused", async () => {
const status = await getDaemonStatus(deps(recorded(), { processLiveness: vi.fn(() => "unknown" as const) }));
expect(status.state).toBe("unverified");
expect(status.pidUnseen).toBe(true);
});
it("still answers 'running' when the probe gets through, whatever the pid looks like", async () => {
const status = await getDaemonStatus(deps(recorded(), { fetch: vi.fn(async () => ({ ok: true })) }));
expect(status.state).toBe("running");
});
it("does not remove state it cannot prove stale", async () => {
const d = deps(recorded());
await getDaemonStatus(d);
expect(d.removeFile).not.toHaveBeenCalled();
});
});
describe("#2860: the real stopped cases keep reading as down", () => {
it("a clean shutdown for the recorded pid reads stopped from any shell", async () => {
const d = deps(recorded(), {}, cleanReceipt(4242));
const status = await getDaemonStatus(d);
expect(status.state).toBe("stale");
expect(statusGuardMessage(status).fact).toBe("Daemon not running.");
expect(shouldAutoStartDaemon(status)).toBe(true);
expect(d.removeFile).toHaveBeenCalledWith(STATE_FILE);
});
it("a clean shutdown plus a refused probe reads stopped even when the pid state is unknown", async () => {
const status = await getDaemonStatus(deps(recorded(), { processLiveness: vi.fn(() => "unknown" as const) }, cleanReceipt(4242)));
expect(status.state).toBe("stopped");
});
it("a receipt for another pid is no evidence", async () => {
const status = await getDaemonStatus(deps(recorded(), {}, cleanReceipt(1111)));
expect(status.pidUnseen).toBe(true);
});
it.each([
["the daemon's own namespace", recorded(), () => DAEMON_NAMESPACE],
["the initial namespace it was started in", recorded({ pidNamespace: INITIAL_NAMESPACE }), () => INITIAL_NAMESPACE],
["an older record, from the initial namespace", recorded({ pidNamespace: undefined }), () => INITIAL_NAMESPACE],
["an older record, on a platform without pid namespaces", recorded({ pidNamespace: undefined }), () => "host"],
])("a dead pid in a normal shell (%s) still reads down so 'rig up' can start it", async (_label, state, namespace) => {
const status = await getDaemonStatus(deps(state, { pidNamespace: namespace }));
expect(status.state).toBe("stale");
expect(statusGuardMessage(status).fact).toBe("Daemon not running.");
expect(shouldAutoStartDaemon(status)).toBe(true);
});
it("a number recorded in a nested namespace says nothing from the initial one, whatever it finds there", async () => {
const fetch = vi.fn(REFUSED);
const status = await getDaemonStatus(deps(recorded(), { pidNamespace: () => INITIAL_NAMESPACE, fetch }));
expect(status.state).toBe("unverified");
expect(status.pidUnseen).toBe(true);
expect(fetch).toHaveBeenCalled();
});
it("...and a healthy endpoint reads running, not stale", async () => {
const status = await getDaemonStatus(deps(recorded(), { pidNamespace: () => INITIAL_NAMESPACE, fetch: vi.fn(async () => ({ ok: true })) }));
expect(status.state).toBe("running");
});
it("deps without namespace evidence keep the old rule", async () => {
const d = deps(recorded());
delete d.pidNamespace;
expect((await getDaemonStatus(d)).state).toBe("stale");
});
});
describe("#2860: a selected loopback OPENRIG_URL is judged like the record", () => {
const withUrl = async (url: string, d: LifecycleDeps) => {
process.env.OPENRIG_URL = url;
try { return await getDaemonStatus(d); } finally { delete process.env.OPENRIG_URL; }
};
const URL_OF_DAEMON = "http://127.0.0.1:7433";
it("a refusal with an invisible pid and no clean shutdown is not stopped, and 'rig up' is not advised", async () => {
const status = await withUrl(URL_OF_DAEMON, deps(recorded()));
expect(status.state).toBe("unverified");
expect(status.pidUnseen).toBe(true);
expect(shouldAutoStartDaemon(status)).toBe(false);
expect(daemonMayAnswer(status)).toBe(false);
});
it("a missing pid in the daemon's own namespace reads stopped", async () => {
expect((await withUrl(URL_OF_DAEMON, deps(recorded(), { pidNamespace: () => DAEMON_NAMESPACE }))).state).toBe("stopped");
});
it("a matching clean shutdown reads stopped from any shell", async () => {
expect((await withUrl(URL_OF_DAEMON, deps(recorded(), {}, cleanReceipt(4242)))).state).toBe("stopped");
});
it("with no record for that endpoint a refusal keeps reading stopped", async () => {
expect((await withUrl("http://127.0.0.1:9999", deps(recorded()))).state).toBe("stopped");
expect((await withUrl(URL_OF_DAEMON, deps(recorded(), { exists: vi.fn(() => false), readFile: vi.fn(() => null) }))).state).toBe("stopped");
});
it("a remote URL keeps reading stopped on a refusal", async () => {
expect((await withUrl("http://daemon.example:7433", deps(recorded()))).state).toBe("stopped");
});
});
describe("#2860: `rig daemon status` says what the guard says", () => {
it("prints the guard's three lines", async () => {
const d = deps(recorded());
const status = await getDaemonStatus(d);
const message = statusGuardMessage(status);
const lines: string[] = [];
vi.spyOn(console, "log").mockImplementation((line: string) => { lines.push(line); });
await daemonCommand(d).parseAsync(["status"], { from: "user" });
expect(lines).toEqual([message.fact, ` ${message.consequence}`, ` ${message.action}`]);
});
});
+2 -1
View File
@@ -78,7 +78,8 @@ describe("rig edge", () => {
expect(result.out).toHaveLength(1);
const error = JSON.parse(result.out[0]!).error;
expect(error).toMatchObject({ fact: expect.any(String), consequence: expect.any(String), action: expect.any(String) });
if (causeCode === "ECONNREFUSED") expect(error.fact).toBe("Daemon not running.");
// #2860: a refused connection alone isn't proof the daemon is stopped, so rig edge says so only on positive evidence.
if (causeCode === "ECONNREFUSED") expect(error.fact).toBe("Daemon did not respond — it may be busy or stopped (state not confirmed).");
else expect(error).toMatchObject({ causeCode: "EPERM", fact: expect.stringContaining("blocked") });
expect(client.get).not.toHaveBeenCalled();
expect(client.post).not.toHaveBeenCalled();
@@ -61,15 +61,16 @@ export interface ClaudeUsageReaderDeps {
/**
* Read each Claude seat's provider_usage cache → provider_statusline rows via
* claudeStatuslineSignals. Explicit unknown rows for absent cache (no_statusline_cache_yet) and
* cache-present-but-no/-malformed windows (empty_reading).
* Never throws — a malformed cache is treated as present-with-no-windows, not a crash.
* claudeStatuslineSignals. Explicit unknown rows for absent cache (no_statusline_cache_yet),
* an unreadable cache (unreadable_cache), and a valid cache with no windows (empty_reading).
* Never throws — a malformed cache becomes an unknown row, not a crash.
*/
export function collectClaudeStatuslineSignals(deps: ClaudeUsageReaderDeps): ProviderSignal[] {
const now = deps.now();
const out: ProviderSignal[] = [];
for (const seat of deps.listClaudeSeats()) {
let cachePresent = false;
let cacheUnreadable = false;
let reading: ClaudeStatuslineReading | undefined;
let staleAfter: string | undefined;
let capturedAsOf: string | undefined;
@@ -84,7 +85,8 @@ export function collectClaudeStatuslineSignals(deps: ClaudeUsageReaderDeps): Pro
staleAfter = parsed.staleAfter;
capturedAsOf = typeof parsed.asOf === "string" ? parsed.asOf : undefined;
} catch {
// Malformed cache → present-with-no-windows (empty_reading unknown), never a throw.
// Malformed cache → present-but-unreadable (its own unknown), never a throw.
cacheUnreadable = true;
reading = undefined;
}
}
@@ -92,6 +94,7 @@ export function collectClaudeStatuslineSignals(deps: ClaudeUsageReaderDeps): Pro
...claudeStatuslineSignals({
seatSession: seat.seatSession,
cachePresent,
cacheUnreadable,
reading,
// Signal asOf = when the reading was captured (the cache's asOf) when present+valid; else now.
asOf: cachePresent && capturedAsOf ? capturedAsOf : now,
@@ -109,6 +112,7 @@ export function collectClaudeSignalsFromProviderUsageDirectory(
legacyDirectory?: string,
): ProviderSignal[] {
const seats = new Map<string, ClaudeSeatRef>();
const unreadableFiles = new Set<string>();
for (const candidateDirectory of [directory, legacyDirectory]) {
if (!candidateDirectory) continue;
try {
@@ -119,10 +123,16 @@ export function collectClaudeSignalsFromProviderUsageDirectory(
if (typeof parsed.seatSession === "string") {
seats.set(parsed.seatSession, { seatSession: parsed.seatSession });
}
} catch { /* malformed cache cannot create a usable cache signal */ }
} catch {
// A cache file that exists but cannot be read or parsed still names its seat in the
// filename (telemetrySidecarFilename sanitizes the same seat name). Keep it in the
// seat list so the reader reports an unreadable cache instead of the file vanishing.
unreadableFiles.add(file.slice(0, -".json".length));
}
}
} catch { /* absent cache directory */ }
}
for (const seatSession of unreadableFiles) seats.set(seatSession, { seatSession });
const readFrom = (candidateDirectory: string, seatSession: string): string | null => {
try {
@@ -131,6 +131,8 @@ export interface ClaudeSignalInput {
seatSession: string;
/** false = the statusline cache has not been written yet (before the first API response). */
cachePresent: boolean;
/** True when a cache file exists but cannot be read or parsed. Distinct from an empty reading. */
cacheUnreadable?: boolean;
/** Present only when the cache exists and carried a rate_limits object. */
reading?: ClaudeStatuslineReading;
asOf: string;
@@ -140,6 +142,7 @@ export interface ClaudeSignalInput {
export const CLAUDE_UNKNOWN_REASON = {
no_statusline_cache_yet: "claude_no_statusline_cache_yet",
empty_reading: "claude_statusline_cache_had_no_windows",
unreadable_cache: "claude_statusline_cache_unreadable",
} as const;
function claudeUnknownSignal(
@@ -165,17 +168,22 @@ function claudeUnknownSignal(
/**
* Normalize a Claude statusline `rate_limits` reading into `signals[]` rows.
* - seat before first response (no cache) → explicit unknown row.
* - a cache that exists but cannot be read/parsed → its own unknown reason (broken, not empty).
* - seat with subscription windows → one `provider_statusline` row per window (five_hour, and
* seven_day normalized to "weekly"); a genuine 0 survives as 0.
* - subscription with cache but no windows → explicit unknown row (never fabricated zeros).
*/
export function claudeStatuslineSignals(input: ClaudeSignalInput): ProviderSignal[] {
const { seatSession, cachePresent, reading, asOf, staleAfter } = input;
const { seatSession, cachePresent, cacheUnreadable, reading, asOf, staleAfter } = input;
if (!cachePresent) {
return [claudeUnknownSignal(seatSession, asOf, CLAUDE_UNKNOWN_REASON.no_statusline_cache_yet, staleAfter)];
}
if (cacheUnreadable) {
return [claudeUnknownSignal(seatSession, asOf, CLAUDE_UNKNOWN_REASON.unreadable_cache, staleAfter)];
}
const windows: Array<["five_hour" | "weekly", ClaudeStatuslineWindow | undefined]> = [
["five_hour", reading?.five_hour],
["weekly", reading?.seven_day],
@@ -4400,6 +4400,8 @@ export class QueueRepository {
clickedTs?: string }): RecordHumanAnswerResult {
if (!this.hasHumanQuestionsColumn) return { status: "not-applicable", reason: "schema" };
if (this.closeStaleRunnerPermission(input.qitemId)) return { status: "not-applicable", reason: "state-canceled" };
// Set inside the transaction when this click changed the answers (a declared type, so the closure's write is seen).
let changed = null as { summary: string | null; answered: number } | null;
const result = this.db.transaction((): RecordHumanAnswerResult => {
const item = this.getById(input.qitemId);
if (!item?.humanQuestions?.length) return { status: "not-applicable" as const, reason: "no-questions" };
@@ -4430,8 +4432,21 @@ export class QueueRepository {
const answers: HumanAnswers = { ...(item.humanAnswers ?? {}), [input.questionId]: input.optionId };
this.db.prepare("UPDATE queue_items SET human_answers = ? WHERE qitem_id = ?").run(JSON.stringify(answers), input.qitemId);
const complete = unansweredQuestions(item.humanQuestions, answers).length === 0;
if (item.humanAnswers?.[input.questionId] !== input.optionId) {
changed = { summary: item.summary ?? null, answered: item.humanQuestions.length - unansweredQuestions(item.humanQuestions, answers).length };
}
return { status: "recorded" as const, answers, complete, questions: item.humanQuestions };
})();
// The click is on the ask before anyone hears of it; a failed event never takes it back or holds up its return.
if (changed && result.status === "recorded") {
try {
this.eventBus.emit({ type: "queue.answer_recorded", qitemId: input.qitemId, actorSession: input.actorSession,
questionId: input.questionId, answered: changed.answered, questions: result.questions.length,
complete: result.complete, summary: changed.summary });
} catch (error) {
console.warn(`[queue] ${input.qitemId}'s recorded answer has no event`, error);
}
}
// A pending ask's interrupted close finishes here, as theirs; a park resumes with it through the reply resolver.
if (result.status === "not-applicable" && result.answeredBy) {
this.resolveDirectHumanReply({ qitemId: input.qitemId, actorSession: input.actorSession, decision: "", primaryUser: input.primaryUser });
@@ -116,6 +116,7 @@ export const PRUNABLE_EVENT_TYPES = [
"queue.claimed",
"queue.unclaimed",
"queue.handed_off",
"queue.answer_recorded",
// One delivery-evidence record per send, and one prompt observation per prompt hook; kept only as long as the other
// high-volume event types.
"transport.delivery_evidence",
@@ -291,7 +291,9 @@ function custodyVerifiedTargets(db: Database.Database, qitemId: string, targets:
for (const { transition_note: note } of notes) {
if (!note) continue;
for (const token of note.slice(CUSTODY_VERIFIED_PREFIX.length).trim().split(/\s+/)) {
const named = [token, ...token.split(",").map((t) => t.trim())].filter(Boolean);
const named = [token, ...token.split(",").map((t) => t.trim())]
.filter(Boolean)
.map((t) => targets.includes(t) ? t : t.replace(/[.,;:)]$/, ""));
if (!named.some((t) => targets.includes(t))) continue;
for (const t of named) verified.add(t);
break;
+27 -26
View File
@@ -92,7 +92,7 @@ export class Reconciler {
// A session this server doesn't show isn't gone while its seat's recorded process still runs: the daemon is
// looking at a different tmux server. Detaching it would have the kernel relaunch duplicates and supersede the
// live seats. Without that evidence (after a reboot nothing recorded is running), it detaches as before.
const elsewhere = await this.recordedProcessStillRunning(session, processes);
const elsewhere = await recordedProcessStillRunning(this.db, session, processes);
if (elsewhere) {
unseen.push({ sessionId: session.id, sessionName: session.sessionName, ...elsewhere });
continue;
@@ -123,31 +123,32 @@ export class Reconciler {
return { checked, detached, detachedSessionIds, unseen, errors };
}
}
/** The seat's last observed process, as a conservative hint that it still runs, not proof of identity: the last pid
* evidence recorded for this very session (kept with its own time, so a later poll that couldn't see the seat doesn't
* erase it), and a process with that pid whose start isn't observably later than that observation. Start times are
* whole seconds, so a pid reused within the observation's second still passes. An unreadable table or record is no
* evidence. */
private async recordedProcessStillRunning(
session: { nodeId: string; sessionName: string },
processes: () => Promise<ProcessTableRow[] | null>,
): Promise<{ pid: number; parent: { pid: number; command: string } | null } | null> {
const evidence = new SeatIdentityStore(this.db).getProcessEvidence(session.nodeId);
if (!evidence || evidence.sessionName !== session.sessionName) return null;
const observedAt = Date.parse(evidence.observedAt) / 1000;
const pids = [evidence.panePid, evidence.pid]
.filter((pid): pid is number => typeof pid === "number" && pid > 0);
if (!pids.length || Number.isNaN(observedAt)) return null;
const rows = await processes();
if (!rows) return null;
for (const pid of pids) {
const row = rows.find((r) => r.pid === pid);
const started = row ? lstartToMinTs(row.startedAt) : undefined;
if (!row || started === undefined || started > observedAt) continue;
const parent = rows.find((r) => r.pid === row.ppid);
return { pid, parent: parent ? { pid: parent.pid, command: parent.command } : null };
}
return null;
/** The seat's last observed process, as a conservative hint that it still runs, not proof of identity: the last pid
* evidence recorded for this very session (kept with its own time, so a later poll that couldn't see the seat doesn't
* erase it), and a process with that pid whose start isn't observably later than that observation. Start times are
* whole seconds, so a pid reused within the observation's second still passes. An unreadable table or record is no
* evidence. */
export async function recordedProcessStillRunning(
db: Database.Database,
session: { nodeId: string; sessionName: string },
processes: () => Promise<ProcessTableRow[] | null>,
): Promise<{ pid: number; parent: { pid: number; command: string } | null } | null> {
const evidence = new SeatIdentityStore(db).getProcessEvidence(session.nodeId);
if (!evidence || evidence.sessionName !== session.sessionName) return null;
const observedAt = Date.parse(evidence.observedAt) / 1000;
const pids = [evidence.panePid, evidence.pid]
.filter((pid): pid is number => typeof pid === "number" && pid > 0);
if (!pids.length || Number.isNaN(observedAt)) return null;
const rows = await processes();
if (!rows) return null;
for (const pid of pids) {
const row = rows.find((r) => r.pid === pid);
const started = row ? lstartToMinTs(row.startedAt) : undefined;
if (!row || started === undefined || started > observedAt) continue;
const parent = rows.find((r) => r.pid === row.ppid);
return { pid, parent: parent ? { pid: parent.pid, command: parent.command } : null };
}
return null;
}
@@ -141,6 +141,22 @@ export class SeatIdentityReconciler {
`).all() as RunningSeatRow[];
}
/** Nodes whose latest session `rig unclaim` released (detached, origin claimed). Unclaim leaves the process running,
* so the node's last recorded process stays on record: a fresh launch reads it before reopening a tmux server, and
* must not start a second occupant beside a process that still runs. An older schema without `origin` keeps none. */
private releasedSeats(): string[] {
try {
return (this.db.prepare(`
SELECT n.id as node_id FROM nodes n
JOIN sessions s ON s.node_id = n.id
AND s.id = (SELECT s2.id FROM sessions s2 WHERE s2.node_id = n.id ORDER BY s2.id DESC LIMIT 1)
WHERE s.status = 'detached' AND s.origin = 'claimed'
`).all() as Array<{ node_id: string }>).map((r) => r.node_id);
} catch {
return [];
}
}
/** Reconcile every running tmux-bound seat once and persist the verdicts. */
async reconcileAll(): Promise<void> {
// Skip ticks while actual reads are pending; never release on a deadline
@@ -156,8 +172,8 @@ export class SeatIdentityReconciler {
private async reconcileSweep(generation: number): Promise<void> {
const seats = this.runningSeats();
// Prune verdicts for nodes no longer running (keep the table bounded).
this.store.pruneExcept(seats.map((s) => s.node_id));
// Prune verdicts for nodes no longer running (keep the table bounded), except a released seat's.
this.store.pruneExcept([...seats.map((s) => s.node_id), ...this.releasedSeats()]);
if (seats.length === 0) return;
const observedAt = this.now().toISOString();
@@ -55,6 +55,8 @@ import { sessionCommunityHarnessSelect } from "./session-registry.js";
import { seatLaunchAdditionsReport } from "./seat-launch-additions.js";
import { seatSkillPaths } from "./seat-skill-storage.js";
import { resolveSeatKind, seatProcessOwner } from "./seat-kinds.js";
import { recordedProcessStillRunning } from "./reconciler.js";
import { readProcessTable, type ProcessTableRow } from "./process-table.js";
/**
* S5 (OPR.0.5.4.7) — the seat-lifecycle verb surface: set-model, single-seat stop,
@@ -110,6 +112,8 @@ export interface SeatLifecycleDeps {
eventBus: EventBus;
tmuxAdapter: TmuxAdapter;
listProcesses?: NativeProcessLister;
/** The OS process table, read before reopening a released seat's tmux server; injectable for tests. */
readProcesses?: () => Promise<ProcessTableRow[]>;
nodeLauncher?: NodeLauncher;
startupOrchestrator?: StartupOrchestrator;
runtimeAdapters?: Record<string, RuntimeAdapter>;
@@ -338,6 +342,7 @@ export class SeatLifecycleService {
private readonly eventBus: EventBus;
private readonly tmuxAdapter: TmuxAdapter;
private readonly listProcesses?: NativeProcessLister;
private readonly readProcesses: () => Promise<ProcessTableRow[]>;
private readonly nodeLauncher: NodeLauncher | null;
private readonly startupOrchestrator: StartupOrchestrator | null;
private readonly runtimeAdapters: Record<string, RuntimeAdapter>;
@@ -360,6 +365,7 @@ export class SeatLifecycleService {
this.eventBus = deps.eventBus;
this.tmuxAdapter = deps.tmuxAdapter;
this.listProcesses = deps.listProcesses;
this.readProcesses = deps.readProcesses ?? readProcessTable;
this.nodeLauncher = deps.nodeLauncher ?? null;
this.startupOrchestrator = deps.startupOrchestrator ?? null;
this.runtimeAdapters = deps.runtimeAdapters ?? {};
@@ -1575,17 +1581,34 @@ export class SeatLifecycleService {
// probe sees unavailable transport instead of positive absence. Reopen an
// empty server only when the persisted state proves this seat's managed
// occupant was deliberately stopped and no other managed row remains live.
// `rig unclaim` is as deliberate for an adopted session: it marks the row
// detached and clears the binding, and the person ends the process and its
// tmux session, which can be the server's last session too.
// The probe below still decides absence; a recreated session or failed
// transport remains a refusal.
if ("code" in canonicalProbe) {
const latest = this.latestSession(node.id);
const hasCurrentBinding = this.sessionRegistry.getBindingForNode(node.id) !== null;
const released = latest?.status === "detached" && latest.origin === "claimed";
const stoppedManagedOccupant = latest !== null
&& latest.session_name === canonicalSessionName
&& latest.status === "exited"
&& latest.origin !== "claimed"
&& ((latest.status === "exited" && latest.origin !== "claimed") || released)
&& !hasCurrentBinding
&& this.nonTerminalSessions(node.id).length === 0;
// Unclaim doesn't end the process. While the released session's recorded process still runs, its server may
// only have lost its socket: a server started there would read the session absent and launch a second occupant
// beside it. So keep the refusal, as the reconciler keeps such a session (the same evidence).
const stillRuns = stoppedManagedOccupant && released
? await recordedProcessStillRunning(this.db, { nodeId: node.id, sessionName: canonicalSessionName },
() => this.readProcesses().catch(() => null))
: null;
if (stillRuns) {
return {
...canonicalProbe,
guidance: `This seat's released session last ran as process ${stillRuns.pid}, which is still running, so it may be `
+ "on a tmux server this daemon can't reach. End that process, then launch again.",
};
}
if (stoppedManagedOccupant) {
try {
const restored = await this.tmuxAdapter.startServer();
+4
View File
@@ -330,6 +330,10 @@ export type RigEvent =
// consumers on /api/views/:name/sse when ANY queue state mutation
// changes a view result-set, not just create/handoff/claim/unclaim.
| { type: "queue.updated"; qitemId: string; fromState: string; toState: string; closureReason: string | null; closureTarget: string | null; actorSession: string; summary: string | null }
// A person's clicked answer recorded on an ask with questions: `answered` of `questions` now have an answer, and
// `complete` says whether that's all of them. A partial set is a change to the ask, not its answer: the ask stays open
// until the set is complete and returned.
| { type: "queue.answer_recorded"; qitemId: string; actorSession: string; questionId: string; answered: number; questions: number; complete: boolean; summary: string | null }
// Coordination primitive (PL-004 Phase B) — project (classifier) / view.
// project.classified: emitted when a stream item is successfully projected.
// classifier.lease_*: lifecycle of the daemon-enforced single-writer lease.
@@ -0,0 +1,134 @@
// Route-level: the real Mission Control route and write contract, the real /api/events stream and a migrated SQLite.
// A click on an ask with questions, from Steering or from Slack, is one queue.answer_recorded event that says how much
// of the set is answered; a typed reply, which settles the ask, is not one.
import { afterEach, describe, expect, it, vi } from "vitest";
import Database from "better-sqlite3";
import { Hono } from "hono";
import { migrate } from "../src/db/migrate.js";
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
import { EventBus } from "../src/domain/event-bus.js";
import { QueueRepository } from "../src/domain/queue-repository.js";
import { MissionControlWriteContract } from "../src/domain/mission-control/mission-control-write-contract.js";
import { MissionControlActionLog } from "../src/domain/mission-control/mission-control-action-log.js";
import { missionControlRoutes } from "../src/routes/mission-control.js";
import { eventsRoute } from "../src/routes/events.js";
const PERSON = "human-test@external", ASKER = "requester@test-rig";
const registry = { ok: true as const, entities: [{ entityId: "human-test", class: "human" as const, displayName: "Test person", address: PERSON,
connectorBindings: [{ kind: "slack" as const, connectorRef: "primary", secretsRef: "unused", role: "primary" as const, handle: "UTEST" }],
prefs: { deliveryClass: "A" as const } }] };
const questions = [
{ id: "ship", question: "Ship it?", options: [{ id: "yes", label: "Yes" }, { id: "no", label: "No" }] },
{ id: "when", question: "When?", options: [{ id: "today", label: "Today" }, { id: "monday", label: "Monday" }] },
];
const closes: Array<() => void> = [];
afterEach(() => {
for (const close of closes.splice(0).reverse()) close();
vi.restoreAllMocks();
});
async function fixture() {
const db = new Database(":memory:");
migrate(db, ALL_MIGRATIONS);
const bus = new EventBus(db);
const repo = new QueueRepository(db, bus, { validateRig: () => true, loadHumanRegistry: () => registry,
transport: { send: async () => ({ ok: true, verified: true, sent: true }) } });
const contract = new MissionControlWriteContract({ db, eventBus: bus, queueRepo: repo, actionLog: new MissionControlActionLog(db) });
const app = new Hono();
app.use("*", async (c, next) => {
c.set("queueRepo" as never, repo as never); c.set("eventBus" as never, bus as never);
c.set("missionControlWriteContract" as never, contract as never);
await next();
});
app.route("/api/mission-control", missionControlRoutes());
app.route("/api/events", eventsRoute);
closes.push(() => db.close());
const ask = async () => (await repo.create({ sourceSession: ASKER, destinationSession: PERSON, body: "Two questions", summary: "Ship and when",
humanIntent: "decision", humanQuestions: questions, evidenceRef: "/fixture/decision", nudge: false })).qitemId;
// The person at the terminal, as the TUI sends it: no seat header, the primary user's authority.
const steering = async (body: Record<string, string>) => {
const response = await app.request("/api/mission-control/action", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ verb: "answer", actorSession: PERSON, authority: "primary-user", ...body }) });
return { status: response.status, body: await response.json() as Record<string, unknown> };
};
// What an app following /api/events from its watermark reads: the stored events after it.
const eventsAfter = async (watermark: number) => {
const response = await app.request("/api/events", { headers: { "Last-Event-ID": String(watermark) } });
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let text = "";
const deadline = Date.now() + 300;
while (Date.now() < deadline) {
const chunk = await Promise.race([reader.read(), new Promise<null>((resolve) => setTimeout(() => resolve(null), deadline - Date.now()))]);
if (!chunk || chunk.done) break;
text += decoder.decode(chunk.value, { stream: true });
}
await reader.cancel().catch(() => {});
return text.split("\n").filter((line) => line.startsWith("data:")).map((line) => JSON.parse(line.slice(5)) as Record<string, unknown>);
};
const recorded = async (watermark: number) => (await eventsAfter(watermark)).filter((event) => event.type === "queue.answer_recorded");
return { bus, repo, ask, steering, recorded, eventsAfter };
}
describe("queue.answer_recorded: a person's click on an ask with questions", () => {
it("one event per click that changes the answers, partial until the set is complete, from Steering and from Slack", async () => {
const f = await fixture();
const qitemId = await f.ask();
let watermark = f.bus.maxSeq();
const partial = await f.steering({ qitemId, questionId: "ship", optionId: "yes" });
expect(partial.status).toBe(200);
expect(f.repo.getById(qitemId)).toMatchObject({ state: "pending", humanAnswers: { ship: "yes" } });
const [first, ...noMore] = await f.recorded(watermark);
expect(noMore).toEqual([]);
// Partial: it says so, and counts the set. Ids and counts only: the person's choice stays on the row.
// The person at the terminal OpenRig can't name is recorded as such, as on the answer itself.
expect(first).toMatchObject({ type: "queue.answer_recorded", qitemId, actorSession: "human@host", questionId: "ship",
answered: 1, questions: 2, complete: false, summary: "Ship and when" });
expect(JSON.stringify(first)).not.toMatch(/"yes"|Yes|optionId/);
// The same click again changes nothing, so there's nothing to hear.
watermark = f.bus.maxSeq();
expect(f.repo.recordHumanAnswer({ qitemId, actorSession: PERSON, questionId: "ship", optionId: "yes" })).toMatchObject({ status: "recorded", complete: false });
expect(await f.recorded(watermark)).toEqual([]);
// A Slack-style click (the inbound router's call) completes the set.
watermark = f.bus.maxSeq();
expect(f.repo.recordHumanAnswer({ qitemId, actorSession: PERSON, questionId: "when", optionId: "monday" }))
.toMatchObject({ status: "recorded", complete: true });
expect(await f.recorded(watermark)).toEqual([expect.objectContaining({ qitemId, questionId: "when", answered: 2, questions: 2, complete: true })]);
});
it("a typed reply settles the ask through its own transitions and records no click event (the control)", async () => {
const f = await fixture();
const qitemId = await f.ask();
const watermark = f.bus.maxSeq();
const typed = await f.steering({ qitemId, decision: "Ship it on Monday" });
expect(typed.status).toBe(200);
expect(f.repo.getById(qitemId)?.state).toBe("done");
const events = await f.eventsAfter(watermark);
expect(events.some((event) => event.type === "queue.updated" && event.qitemId === qitemId)).toBe(true);
expect(events.filter((event) => event.type === "queue.answer_recorded")).toEqual([]);
});
it("fails open: a click whose event can't be recorded is still on the ask, and its answer goes back", async () => {
const f = await fixture();
const qitemId = await f.ask();
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const emit = f.bus.emit.bind(f.bus);
vi.spyOn(f.bus, "emit").mockImplementation((event) => {
if (event.type === "queue.answer_recorded") throw new Error("events table unavailable");
return emit(event);
});
const partial = await f.steering({ qitemId, questionId: "ship", optionId: "no" });
expect(partial.status).toBe(200);
const complete = await f.steering({ qitemId, questionId: "when", optionId: "today" });
expect(complete.status).toBe(200);
expect(f.repo.getById(qitemId)).toMatchObject({ state: "done", humanAnswers: { ship: "no", when: "today" } });
expect(f.repo.list({ limit: 50 }).filter((item) => item.replyTo === qitemId)).toHaveLength(1);
expect(warn).toHaveBeenCalledWith(expect.stringContaining(`${qitemId}'s recorded answer has no event`), expect.any(Error));
});
});
@@ -112,6 +112,40 @@ describe("Slice-04 C3 — seat-keyed Claude provider_usage discovery (production
}
});
it("a live seat whose only cache is malformed reports unreadable_cache through real getReadModel", async () => {
const root = fs.mkdtempSync(nodePath.join(os.tmpdir(), "provider-c3-unreadable-"));
try {
const usageDir = nodePath.join(root, "provider-usage");
fs.mkdirSync(usageDir, { recursive: true });
const seatSession = "dev-impl@test-rig";
fs.writeFileSync(nodePath.join(usageDir, `${seatSession}.json`), "{ not json");
const db = createFullTestDb();
seedClaudeSeat(db, seatSession);
const svc = new ProviderServiceImpl({
db,
listRigs: () => [{ id: "rig-1" }],
env: emptyCodexHomeEnv(),
now: () => ASOF,
collectClaudeSignals: () => collectClaudeSignalsFromProviderUsageDirectory(usageDir, () => ASOF),
});
const model = await svc.getReadModel();
const claude = model.signals.filter((signal) => signal.provider === "claude");
expect(claude).toHaveLength(1);
expect(claude[0]).toMatchObject({
seatSession,
sourceClass: "unknown",
authority: "unknown",
unknownReason: "claude_statusline_cache_unreadable",
automationUse: "do_not_automate",
});
expect(claude[0]!.usedPercent).toBeUndefined();
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
it("an exited latest Claude session plus matching stale cache emits zero Claude signals", async () => {
const root = fs.mkdtempSync(nodePath.join(os.tmpdir(), "provider-c3-exited-"));
try {
@@ -76,12 +76,20 @@ describe("collectClaudeStatuslineSignals — reader → provider_statusline / ex
expect(sigs[0]!.unknownReason).toBe(CLAUDE_UNKNOWN_REASON.empty_reading);
});
it("malformed cache JSON → unknown(empty_reading), never throws", () => {
it("malformed cache JSON → unknown(unreadable_cache), never throws", () => {
const sigs = collectClaudeStatuslineSignals(readerDeps({
listClaudeSeats: () => [{ seatSession: "dev-impl@rig" }],
readCacheRaw: () => "{ not json",
}));
expect(sigs[0]!.sourceClass).toBe("unknown");
expect(sigs[0]!.unknownReason).toBe(CLAUDE_UNKNOWN_REASON.unreadable_cache);
});
it("a valid cache with no windows keeps empty_reading (distinct from an unreadable cache)", () => {
const sigs = collectClaudeStatuslineSignals(readerDeps({
listClaudeSeats: () => [{ seatSession: "dev-impl@rig" }],
readCacheRaw: () => JSON.stringify({ seatSession: "dev-impl@rig", asOf: CACHE_ASOF }),
}));
expect(sigs[0]!.unknownReason).toBe(CLAUDE_UNKNOWN_REASON.empty_reading);
});
});
@@ -111,14 +119,33 @@ describe("0.5.8 provider-usage compatibility bridge", () => {
const malformedCanonical = collectClaudeSignalsFromProviderUsageDirectory(canonical, () => ASOF, legacy);
expect(malformedCanonical.find((signal) => signal.seatSession === "legacy@rig")?.usedPercent).toBeUndefined();
expect(malformedCanonical.find((signal) => signal.seatSession === "legacy@rig")?.unknownReason)
.toBe(CLAUDE_UNKNOWN_REASON.empty_reading);
.toBe(CLAUDE_UNKNOWN_REASON.unreadable_cache);
fs.rmSync(path.join(canonical, "[email protected]"));
fs.mkdirSync(path.join(canonical, "[email protected]"));
const wrongTypeCanonical = collectClaudeSignalsFromProviderUsageDirectory(canonical, () => ASOF, legacy);
expect(wrongTypeCanonical.find((signal) => signal.seatSession === "legacy@rig")?.usedPercent).toBeUndefined();
expect(wrongTypeCanonical.find((signal) => signal.seatSession === "legacy@rig")?.unknownReason)
.toBe(CLAUDE_UNKNOWN_REASON.empty_reading);
.toBe(CLAUDE_UNKNOWN_REASON.unreadable_cache);
fs.rmSync(home, { recursive: true, force: true });
});
});
describe("collectClaudeSignalsFromProviderUsageDirectory — a malformed-only cache stays visible", () => {
it("reports unreadable_cache for a seat known only from a malformed file's name", () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), "provider-unreadable-"));
const directory = path.join(home, "state", "provider-usage");
fs.mkdirSync(directory, { recursive: true });
fs.writeFileSync(path.join(directory, "[email protected]"), "{ not json");
try {
const signals = collectClaudeSignalsFromProviderUsageDirectory(directory, () => ASOF);
expect(signals).toHaveLength(1);
expect(signals[0]!.seatSession).toBe("broken@rig");
expect(signals[0]!.sourceClass).toBe("unknown");
expect(signals[0]!.unknownReason).toBe(CLAUDE_UNKNOWN_REASON.unreadable_cache);
expect(signals[0]!.usedPercent).toBeUndefined();
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
});
});
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { claudeStatuslineSignals } from "../src/domain/provider/provider-signals.js";
import { CLAUDE_UNKNOWN_REASON, claudeStatuslineSignals } from "../src/domain/provider/provider-signals.js";
// Slice-04 (OPR.0.5.0.4) — Claude statusline lane normalization (§2 Claude lane + the
// 2026-07-31 RESEARCH verdict). Proof item 6: the statusline sidecar's rate_limits object
@@ -68,5 +68,21 @@ describe("claudeStatuslineSignals — Claude lane honesty", () => {
expect(sigs[0].sourceClass).toBe("unknown");
expect(sigs[0].usedPercent).toBeUndefined();
expect(sigs[0].automationUse).toBe("do_not_automate");
expect(sigs[0].unknownReason).toBe(CLAUDE_UNKNOWN_REASON.empty_reading);
});
it("an unreadable cache gets its own reason, not the empty-reading one", () => {
const sigs = claudeStatuslineSignals({
seatSession: "dev-impl@rig",
cachePresent: true,
cacheUnreadable: true,
asOf: ASOF,
});
expect(sigs).toHaveLength(1);
expect(sigs[0].sourceClass).toBe("unknown");
expect(sigs[0].unknownReason).toBe(CLAUDE_UNKNOWN_REASON.unreadable_cache);
expect(sigs[0].unknownReason).not.toBe(CLAUDE_UNKNOWN_REASON.empty_reading);
expect(sigs[0].usedPercent).toBeUndefined();
expect(sigs[0].automationUse).toBe("do_not_automate");
});
});
@@ -1767,6 +1767,36 @@ describe("S02 standing stuck sweep — both halves, routed findings, quiet-but-o
expect(await findingsFor(row.qitemId)).toHaveLength(0);
});
it.each(["", ".", ",", ";", ":", ")"])("CUSTODY PUNCTUATION: a closing note accepts the exact target followed by %j", async (punctuation) => {
const row = await mkRow();
const target = "qitem-20990101000000-punctuation@remote-host";
repo.update({ qitemId: row.qitemId, actorSession: "worker@r", state: "done",
closureReason: "handed_off_to", closureTarget: target,
transitionNote: `custody-verified: ${target}${punctuation} registered-host read confirmed the successor` });
await runSweep();
expect(await findingsFor(row.qitemId)).toHaveLength(0);
});
it.each(["x.", ".other.", ":123)"])("CUSTODY PUNCTUATION: extra target characters %j do not verify a prefix", async (suffix) => {
const row = await mkRow();
const target = "qitem-20990101000000-punctuation@remote-host";
repo.update({ qitemId: row.qitemId, actorSession: "worker@r", state: "done",
closureReason: "handed_off_to", closureTarget: target,
transitionNote: `custody-verified: ${target}${suffix} registered-host read confirmed another successor` });
await runSweep();
expect(await findingsFor(row.qitemId)).toHaveLength(1);
});
it("CUSTODY PUNCTUATION: a shortened target is not an exact target", async () => {
const row = await mkRow();
const target = "qitem-20990101000000-punctuation@remote-host";
repo.update({ qitemId: row.qitemId, actorSession: "worker@r", state: "done",
closureReason: "handed_off_to", closureTarget: target,
transitionNote: `custody-verified: ${target.slice(0, -1)}. checked elsewhere` });
await runSweep();
expect(await findingsFor(row.qitemId)).toHaveLength(1);
});
it("DISPOSITION CLOSES THE OPEN FINDING: verification landing after the finding minted auto-closes it on the next sweep, and the finding taught the recipe", async () => {
const row = await mkRow();
const missing = "qitem-20990101000000-precon02";
@@ -16,6 +16,7 @@ import { SeatLifecycleService } from "../src/domain/seat-lifecycle-service.js";
import { getNodeInventory } from "../src/domain/node-inventory.js";
import { PsProjectionService } from "../src/domain/ps-projection.js";
import { SeatIdentityStore } from "../src/domain/seat-identity-store.js";
import { SeatIdentityReconciler } from "../src/domain/seat-identity-reconciler.js";
import type { RuntimeAdapter } from "../src/domain/runtime-adapter.js";
import type { ProjectionPlan } from "../src/domain/projection-planner.js";
import type { TmuxAdapter, TmuxResult } from "../src/adapters/tmux.js";
@@ -24,6 +25,7 @@ import { deriveOriented } from "../src/domain/startup-proof.js";
import { deriveRehydrateSessionIdByNode } from "../src/domain/active-occupant.js";
import { readFreshOccupantRelations } from "../src/domain/fresh-occupant-relation.js";
import type { NativeProcessLister, NativeProcessRow } from "../src/domain/native-process-lineage.js";
import type { ProcessTableRow } from "../src/domain/process-table.js";
import { ClaudeCodeAdapter } from "../src/adapters/claude-code-adapter.js";
import { CLAUDE_BYPASS_CONSENT } from "./fixtures/claude-bypass-consent.js";
@@ -59,6 +61,7 @@ describe("SeatLifecycleService.launchFresh", () => {
let activitySwaps: Array<{ nodeId: string; generation: string }>;
let service: SeatLifecycleService;
let listProcesses: NativeProcessLister;
let readProcesses: () => Promise<ProcessTableRow[]>;
beforeEach(() => {
db = createFullTestDb();
@@ -131,6 +134,7 @@ describe("SeatLifecycleService.launchFresh", () => {
listProcesses = async () => [{ pid: 4242, ppid: 1, pgid: 4242, tpgid: 4242,
executableName: "codex", command: "/opt/native/codex -m model", argv: ["/opt/native/codex", "-m", "model"],
startedAt: "Sat Jan 1 12:00:00 2000" }];
readProcesses = async () => [];
runtimeAdapters = { "claude-code": adapter, codex: { ...adapter, runtime: "codex" } };
service = new SeatLifecycleService({
db,
@@ -139,6 +143,7 @@ describe("SeatLifecycleService.launchFresh", () => {
eventBus,
tmuxAdapter: tmux,
listProcesses: () => listProcesses(),
readProcesses: () => readProcesses(),
nodeLauncher,
startupOrchestrator,
runtimeAdapters,
@@ -843,6 +848,105 @@ describe("SeatLifecycleService.launchFresh", () => {
expect(alive.has(seat.sessionName)).toBe(true);
});
// `rig unclaim` releases an adopted session (its row detached, its binding cleared) and leaves its process running;
// the person then ends it and its tmux session. When that was the server's last session, tmux exits with it.
const releaseAdopted = (seat: ReturnType<typeof seedSeat>) => {
sessionRegistry.markDetached(seat.session!.id);
sessionRegistry.clearBinding(seat.node.id);
};
it("reopens an empty tmux server after rig unclaim released the seat's last session", async () => {
const seat = seedSeat({ adopted: true });
const server = modelServerLifetime();
releaseAdopted(seat);
alive.delete(seat.sessionName);
livePanes.delete(seat.sessionName);
server.up = false;
const result = await service.launchFresh({ seatRef: seat.sessionName, fresh: true, reason: "start a new occupant" });
expect(result).toMatchObject({ ok: true, status: "ready", sessionName: seat.sessionName });
expect(server).toEqual({ up: true, starts: 1 });
expect(tmux.createSession).toHaveBeenCalledTimes(1);
expect(killed).toEqual([]);
});
it("still refuses a released seat's fresh launch when its session is live behind a transport blip", async () => {
const seat = seedSeat({ adopted: true });
const server = modelServerLifetime();
releaseAdopted(seat);
// The released process is still running in its session; only the first probe fails.
vi.mocked(tmux.probeSession).mockResolvedValueOnce({ state: "transport_unavailable", cause: "probe blip" });
const result = await service.launchFresh({ seatRef: seat.sessionName, fresh: true, reason: "start a new occupant" });
expect(result).toMatchObject({ ok: false, code: "unmanaged_session_collision" });
expect(server.starts).toBe(0);
expect(tmux.createSession).not.toHaveBeenCalled();
expect(killed).toEqual([]);
expect(alive.has(seat.sessionName)).toBe(true);
});
// Unclaim doesn't end the process, and tmux can lose its socket while its server keeps running: the probe then
// can't reach the server, and a server started at that path is a new, empty one. So the model keeps the two apart:
// `alive` and `server.up` are what the socket answers, and the process table is what still runs.
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const lstart = (d: Date) => `${DAYS[d.getDay()]} ${MONTHS[d.getMonth()]} ${String(d.getDate()).padStart(2, " ")} `
+ `${[d.getHours(), d.getMinutes(), d.getSeconds()].map((n) => String(n).padStart(2, "0")).join(":")} ${d.getFullYear()}`;
const processRow = (pid: number, ppid: number, startedAt: Date, command: string): ProcessTableRow =>
({ pid, ppid, pgid: pid, tpgid: -1, executableName: command.split(" ")[0]!, startedAt: lstart(startedAt), command });
const observed = new Date(Date.now() - 60_000);
const releasedWithLostSocket = async () => {
const seat = seedSeat({ adopted: true });
const server = modelServerLifetime();
// Recorded while the adopted seat ran; then released, and the daemon's identity sweep (every 5 s) runs.
new SeatIdentityStore(db).upsert({
nodeId: seat.node.id, verdict: "verified", evidenceSource: null, reason: null,
evidence: { registeredPane: "%old", observedPid: 5151, observedPanePid: 5150, observedCommand: "claude", matchedLayer: 1 },
sessionName: seat.sessionName, observedAt: observed.toISOString(),
});
releaseAdopted(seat);
const blindPoll = { listSessions: async () => [] } as unknown as ConstructorParameters<typeof SeatIdentityReconciler>[0]["tmux"];
await new SeatIdentityReconciler({ db, tmux: blindPoll }).reconcileAll();
alive.delete(seat.sessionName);
livePanes.delete(seat.sessionName);
server.up = false;
return { seat, server };
};
it("keeps refusing a released seat's fresh launch while its recorded process still runs behind a lost socket", async () => {
const { seat } = await releasedWithLostSocket();
const before = new Date(observed.getTime() - 10 * 60_000);
readProcesses = async () => [processRow(5150, 900, before, "zsh"), processRow(900, 1, before, "tmux new-session")];
const result = await service.launchFresh({ seatRef: seat.sessionName, fresh: true, reason: "start a new occupant" });
expect(result).toMatchObject({ ok: false, code: "tmux_probe_failed",
guidance: expect.stringContaining("last ran as process 5150, which is still running") });
expect(tmux.startServer).not.toHaveBeenCalled();
expect(tmux.createSession).not.toHaveBeenCalled();
expect(killed).toEqual([]);
expect(sessionRegistry.getSessionsForRig(seat.rig.id).map((s) => s.status)).toEqual(["detached"]);
});
it.each([
{ name: "its recorded process has ended", rows: async () => [] as ProcessTableRow[] },
{ name: "its pid now belongs to a later process",
rows: async () => [processRow(5150, 1, new Date(observed.getTime() + 30_000), "vim")] },
{ name: "the process table can't be read", rows: async (): Promise<ProcessTableRow[]> => { throw new Error("ps failed"); } },
])("reopens the server for a released seat when $name", async ({ rows }) => {
const { seat, server } = await releasedWithLostSocket();
readProcesses = rows;
const result = await service.launchFresh({ seatRef: seat.sessionName, fresh: true, reason: "start a new occupant" });
expect(result).toMatchObject({ ok: true, status: "ready", sessionName: seat.sessionName });
expect(server).toEqual({ up: true, starts: 1 });
expect(tmux.createSession).toHaveBeenCalledTimes(1);
expect(killed).toEqual([]);
});
it("proves older non-terminal rows absent on the restored server before superseding them", async () => {
const seat = seedSeat({ clean: true });
const older = sessionRegistry.registerSession(seat.node.id, "r00-dev-impl@fresh-rig");
@@ -440,6 +440,28 @@ describe("SeatIdentityReconciler.reconcileAll", () => {
db.close();
});
it("keeps a released seat's recorded process through the prune, and still prunes a stopped one", async () => {
// rig unclaim leaves the latest row detached with origin claimed, and the process running. A fresh launch reads
// that process's record before reopening a tmux server, so the 5 s sweep mustn't drop it.
const db = createFullTestDb();
seedSeat(db, { nodeId: "n1", sessionName: "s1@rig", pane: "%1", status: "detached" });
db.prepare("UPDATE sessions SET origin = 'claimed' WHERE node_id = 'n1'").run();
seedSeat(db, { nodeId: "n2", sessionName: "s2@rig", pane: "%2", status: "exited" });
const store = new SeatIdentityStore(db);
for (const [nodeId, sessionName, pid] of [["n1", "s1@rig", 5151], ["n2", "s2@rig", 6161]] as const) {
store.upsert({ nodeId, verdict: "verified", evidenceSource: null, reason: null,
evidence: { registeredPane: "%1", observedPid: pid, observedPanePid: pid - 1, observedCommand: "claude", matchedLayer: 1 },
sessionName, observedAt: NOW().toISOString() });
}
const rec = new SeatIdentityReconciler({ db, tmux: makeTmux({ sessions: [] }), now: NOW });
await rec.reconcileAll();
expect(store.getProcessEvidence("n1")).toEqual({ sessionName: "s1@rig", pid: 5151, panePid: 5150, observedAt: NOW().toISOString() });
expect(store.getForNode("n2")).toBeNull();
db.close();
});
it("liveness is NOT derived from heartbeats — a dead pane stays non-green regardless of any heartbeat", async () => {
// The reconciler never reads queue/classifier/hook state; a seat whose pane
// is gone yields pane_missing even if (hypothetically) work/heartbeats exist.
+4 -2
View File
@@ -287,8 +287,10 @@ function toAgentRow(node: NodeInventoryRead): AgentRow {
: node.activityState?.display === "idle"
? "idle"
: (node.terminalActive === true || (node.terminalActive == null && node.agentActivity?.state === "running") ? "active"
// A community harness gives no idle signal: a quiet screen is unknown, not idle.
: node.communityHarness ? "unknown" : "idle"))
// The daemon's activity word decides when it's served (as in `rig ps`): it said unknown, so a quiet
// screen stays unknown. A community harness gives no idle signal either. Only an older daemon that
// serves no activity word keeps the screen fallback.
: node.communityHarness || node.activityState ? "unknown" : "idle"))
: (node.sessionStatus ?? "unknown"),
live: node.lifecycleState === "running",
canRun: node.lifecycleState !== "running"
+23 -3
View File
@@ -710,7 +710,7 @@ describe("snapshot hydration over the §4.A reads (Phase 2)", () => {
expect(output.lines.find((line) => /\bmismatch\s/.test(line))).not.toContain("run ▸");
expect(output.lines.find((line) => /\bmissing\s/.test(line))).not.toContain("run ▸");
});
it("shows a quiet community harness as unknown, never idle, and leaves built-in rows as they were", async () => {
it("shows a quiet seat the daemon reads as unknown as unknown, never idle, for a community harness and a built-in runtime alike", async () => {
const base = (FIXTURES["/api/rigs/01JRIG/nodes"] as Array<Record<string, unknown>>)[0]!;
const quiet = { startupStatus: "ready", sessionStatus: "running", terminalActive: false, agentActivity: { state: "unknown" },
activityState: { activity: "unknown", display: "unknown", needsInput: { count: 0, reason: null }, decidedBy: null } };
@@ -727,8 +727,28 @@ describe("snapshot hydration over the §4.A reads (Phase 2)", () => {
"dev.app-quiet": "unknown",
"dev.app-moving": "active",
"dev.app-prompt": "needs_input",
"dev.pi-quiet": "idle",
"dev.claude-quiet": "idle",
"dev.pi-quiet": "unknown",
"dev.claude-quiet": "unknown",
});
});
it("keeps the daemon's idle and working words, and the screen fallback only for a daemon that serves no activity word", async () => {
const base = (FIXTURES["/api/rigs/01JRIG/nodes"] as Array<Record<string, unknown>>)[0]!;
const running = { startupStatus: "ready", sessionStatus: "running", runtime: "claude-code", agentActivity: { state: "unknown" } };
const served = (display: string) => ({ activity: display === "idle" ? "idle-at-prompt" : display, display, needsInput: { count: 0, reason: null }, decidedBy: null });
const nodes = [
{ ...base, ...running, logicalId: "dev.served-idle", terminalActive: false, activityState: served("idle") },
{ ...base, ...running, logicalId: "dev.served-working", terminalActive: false, activityState: served("working") },
{ ...base, ...running, logicalId: "dev.unknown-moving", terminalActive: true, activityState: served("unknown") },
{ ...base, ...running, logicalId: "dev.legacy-quiet", terminalActive: false, activityState: undefined },
];
const snap = await hydrateSnapshot(fixtureClient({}, { "/api/rigs/01JRIG/nodes": nodes }));
const statuses = Object.fromEntries(myrig(snap).pods[0]!.agents.map((agent) => [agent.name, agent.status]));
expect(statuses).toEqual({
"dev.served-idle": "idle",
"dev.served-working": "active",
"dev.unknown-moving": "active",
"dev.legacy-quiet": "idle",
});
});
+2 -1
View File
@@ -4,6 +4,7 @@ import fs from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "node:test";
import { packEntry } from "./npm-pack-json.mjs";
const script = fs.readFileSync(new URL("./build-package.sh", import.meta.url), "utf8");
const assetsBranch = script.match(/^if \[ -d "\$DAEMON_DIR\/assets" \]; then\n[\s\S]*?^fi$/m)?.[0];
@@ -129,7 +130,7 @@ test("offline npm pack carries staged asset bytes and executability, with npm's
assertStaged(f);
const staged = snapshot(f.staged);
fs.writeFileSync(join(f.cli, "package.json"), JSON.stringify({ name: "openrig-assets-fixture", version: "1.0.0", private: true, files: ["daemon"] }));
const packed = JSON.parse(execFileSync("bash", ["-c", 'umask 0022; exec npm pack --offline --ignore-scripts --json --pack-destination "$1"', "asset-pack", f.root], { cwd: f.cli, encoding: "utf8", timeout: 30_000, stdio: ["ignore", "pipe", "pipe"] }))[0];
const packed = packEntry(execFileSync("bash", ["-c", 'umask 0022; exec npm pack --offline --ignore-scripts --json --pack-destination "$1"', "asset-pack", f.root], { cwd: f.cli, encoding: "utf8", timeout: 30_000, stdio: ["ignore", "pipe", "pipe"] }));
const files = new Map(packed.files.map(file => [file.path, file]));
for (const [name, bytes] of Object.entries(contents)) {
const entry = `daemon/assets/${name}`;
+2 -2
View File
@@ -2,14 +2,14 @@ import { execSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { test } from "node:test";
import assert from "node:assert/strict";
import { packEntry } from "./npm-pack-json.mjs";
test("npm pack of @openrig/cli includes scripts/check-abi.mjs in tarball", () => {
const output = execSync("npm pack --dry-run --json 2>/dev/null", {
cwd: "packages/cli",
encoding: "utf-8",
});
const entries = JSON.parse(output);
const files = entries[0]?.files?.map((f) => f.path) ?? [];
const files = packEntry(output).files?.map((f) => f.path) ?? [];
assert.ok(
files.some((f) => f.includes("scripts/check-abi.mjs")),
+14 -4
View File
@@ -19,6 +19,7 @@ import {
scanInternalLeaks,
} from "./internal-leak-scanner.mjs";
import { LEAK_GUARD_RULES_ENV, leakGuardRulesPath } from "./leak-guard-rules.mjs";
import { packEntries } from "./npm-pack-json.mjs";
const HERE = dirname(fileURLToPath(import.meta.url));
const FIX = "Genericize the content, cite its public home, or re-home it to the internal pack root.";
@@ -155,13 +156,22 @@ function derivePackageArtifacts(packageRoot) {
if (packed.status !== 0) {
throw new Error(`npm artifact enumeration failed:\n${packed.stderr || packed.stdout || `exit ${packed.status}`}`);
}
let parsed;
let entries = [];
try {
parsed = JSON.parse(packed.stdout);
// One parse, in the shared reader: a JSON string (even one holding a serialized list) is not a shape npm prints.
entries = packEntries(packed.stdout);
} catch (error) {
throw new Error(`npm artifact enumeration did not return JSON: ${error.message}`);
if (error instanceof SyntaxError) throw new Error(`npm artifact enumeration did not return JSON: ${error.message}`);
// not a shape npm prints: reported below as no files array
}
const rawFiles = parsed?.[0]?.files;
// One package is packed here; scanning only the first of several would issue a receipt for part of the artifact.
if (entries.length > 1) {
throw new Error(
`npm artifact enumeration listed ${entries.length} packages where one was packed; `
+ "run the gate for one package (check --package-root and any npm workspaces setting)",
);
}
const rawFiles = entries[0]?.files;
if (!Array.isArray(rawFiles)) throw new Error("npm artifact enumeration returned no files array");
const files = rawFiles.map((entry) => entry?.path).filter((path) => typeof path === "string");
if (files.length !== rawFiles.length || files.length === 0) {
+42 -3
View File
@@ -5,6 +5,7 @@ import { execFileSync, spawnSync } from "node:child_process";
import {
existsSync,
mkdirSync,
chmodSync,
mkdtempSync,
readFileSync,
rmSync,
@@ -405,6 +406,44 @@ test("exact-line allowances reach content and derived scans and are recorded in
});
});
test("npm output that is a JSON string is refused, even when the string holds a serialized list", () => {
withFixture(({ root, repo, rules, review, receipt }) => {
write(join(repo, "public/guide.md"), "Generic product guidance.\n");
commitAll(repo, "string output fixture");
writeFileSync(review, JSON.stringify({ surfaces: [] }));
const bin = join(root, "bin");
const serialized = JSON.stringify([{ files: [{ path: "package.json" }, { path: "public/guide.md" }] }]);
write(join(bin, "npm"), `#!${process.execPath}\nprocess.stdout.write(${JSON.stringify(JSON.stringify(serialized))});\n`);
chmodSync(join(bin, "npm"), 0o755);
const result = runGate({ repo, rules, review, receipt, cutSha: gitHead(repo),
env: { ...process.env, PATH: `${bin}:${process.env.PATH}` } });
assert.notEqual(result.status, 0);
assert.match(result.stderr, /npm artifact enumeration returned no files array/);
});
});
test("npm output that lists more than one package is refused before any scan or receipt", () => {
const first = { name: "first", files: [{ path: "package.json" }, { path: "public/guide.md" }] };
const second = { name: "second", files: [{ path: "package.json" }, { path: "public/other.md" }] };
for (const printed of [[first, second], { first, second }]) {
withFixture(({ root, repo, rules, review, receipt }) => {
write(join(repo, "public/guide.md"), "Generic product guidance.\n");
commitAll(repo, "two package output fixture");
writeFileSync(review, JSON.stringify({ surfaces: [] }));
const bin = join(root, "bin");
write(join(bin, "npm"), `#!${process.execPath}\nprocess.stdout.write(${JSON.stringify(JSON.stringify(printed))});\n`);
chmodSync(join(bin, "npm"), 0o755);
const result = runGate({ repo, rules, review, receipt, cutSha: gitHead(repo),
env: { ...process.env, PATH: `${bin}:${process.env.PATH}` } });
assert.notEqual(result.status, 0);
assert.match(result.stderr, /npm artifact enumeration listed 2 packages where one was packed; run the gate for one package \(check --package-root and any npm workspaces setting\)/);
assert.equal(existsSync(receipt), false);
});
}
});
test("the release ceremony names the gate and its durable receipt", () => {
const pkg = JSON.parse(readFileSync("package.json", "utf8"));
assert.equal(pkg.scripts["gate:substance"], "node scripts/check-substance-gate.mjs");
@@ -420,7 +459,7 @@ function writeGateHarness(root, source, invocationPath) {
mkdirSync(root, { recursive: true });
const gate = join(root, "check-substance-gate.mjs");
writeFileSync(gate, source);
for (const module of ["internal-leak-scanner.mjs", "leak-guard-rules.mjs"]) {
for (const module of ["internal-leak-scanner.mjs", "leak-guard-rules.mjs", "npm-pack-json.mjs"]) {
writeFileSync(join(root, module), readFileSync(resolve("scripts", module)));
}
writeFileSync(join(root, "check-internal-leak-guard.mjs"), [
@@ -474,7 +513,7 @@ function withFixture(run) {
}
}
function runGate({ repo, rules, review, receipt, cutSha, gate = GATE }) {
function runGate({ repo, rules, review, receipt, cutSha, gate = GATE, env = process.env }) {
return spawnSync(process.execPath, [
gate,
"--repo", repo,
@@ -485,7 +524,7 @@ function runGate({ repo, rules, review, receipt, cutSha, gate = GATE }) {
"--receipt", receipt,
"--judge", "dev-qa@fixture",
"--cut-sha", cutSha,
], { encoding: "utf8" });
], { encoding: "utf8", env });
}
function write(path, content) {
+6 -5
View File
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
import { delimiter, join } from "node:path";
import { test } from "node:test";
import assert from "node:assert/strict";
import { packEntry } from "./npm-pack-json.mjs";
test("the CLI build carries the unchanged repository LICENSE and README into npm pack", () => {
const root = mkdtempSync(join(tmpdir(), "cli-license-"));
@@ -26,14 +27,14 @@ test("the CLI build carries the unchanged repository LICENSE and README into npm
execFileSync("npm", ["run", "build"], { cwd: cli, env, stdio: "pipe" });
const source = readFileSync(join(root, "LICENSE"));
assert.deepEqual(readFileSync(join(cli, "LICENSE")), source, "build must stage the repository license verbatim");
const packed = JSON.parse(execFileSync("npm", ["pack", "--ignore-scripts", "--json"], { cwd: cli, env, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }));
assert.ok(packed[0].files.some((file) => file.path === "LICENSE"), "npm archive must include LICENSE");
const bytes = execFileSync("tar", ["-xOf", join(cli, packed[0].filename), "package/LICENSE"]);
const packed = packEntry(execFileSync("npm", ["pack", "--ignore-scripts", "--json"], { cwd: cli, env, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }));
assert.ok(packed.files.some((file) => file.path === "LICENSE"), "npm archive must include LICENSE");
const bytes = execFileSync("tar", ["-xOf", join(cli, packed.filename), "package/LICENSE"]);
assert.deepEqual(bytes, source, "packed license must equal the existing root bytes");
// npm shows the package-root README on the registry page; without it the page reads "No README data found".
const readme = readFileSync(join(root, "README.md"));
assert.ok(packed[0].files.some((file) => file.path === "README.md"), "npm archive must include README.md");
const readmeBytes = execFileSync("tar", ["-xOf", join(cli, packed[0].filename), "package/README.md"]);
assert.ok(packed.files.some((file) => file.path === "README.md"), "npm archive must include README.md");
const readmeBytes = execFileSync("tar", ["-xOf", join(cli, packed.filename), "package/README.md"]);
assert.deepEqual(readmeBytes, readme, "packed README must equal the existing root bytes");
} finally {
rmSync(root, { recursive: true, force: true });
+25
View File
@@ -0,0 +1,25 @@
// `npm pack --json` prints an array of entries up to npm 11 and, from npm 12, an object keyed by package name
// ("the --json output of `npm pack` and `npm publish` have changed",
// https://github.com/npm/cli/releases/tag/v12.0.0). Every reader of that output goes through here, so both shapes
// give the same entries.
/** The entries `npm pack --json` printed, in order, from its output text or its parsed value. */
export function packEntries(output) {
const parsed = typeof output === "string" ? JSON.parse(output) : output;
const entries = Array.isArray(parsed)
? parsed
: parsed !== null && typeof parsed === "object" ? Object.values(parsed) : null;
if (!entries || !entries.every((entry) => entry !== null && typeof entry === "object" && !Array.isArray(entry))) {
throw new Error("npm pack --json printed neither an array of entries nor an object of per-package entries");
}
return entries;
}
/** The one entry of a `npm pack --json` run that packed one package; any other count throws. */
export function packEntry(output) {
const entries = packEntries(output);
if (entries.length !== 1) {
throw new Error(`npm pack --json printed ${entries.length} entries where one package was packed`);
}
return entries[0];
}
+35
View File
@@ -0,0 +1,35 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { packEntries, packEntry } from "./npm-pack-json.mjs";
const entry = { id: "@openrig/[email protected]", name: "@openrig/cli", version: "1.2.3", filename: "openrig-cli-1.2.3.tgz",
files: [{ path: "LICENSE" }, { path: "package.json" }] };
test("npm 10 and 11: an array of entries", () => {
assert.deepEqual(packEntries(JSON.stringify([entry])), [entry]);
assert.deepEqual(packEntry(JSON.stringify([entry])), entry);
});
test("npm 12: an object of entries keyed by package name", () => {
assert.deepEqual(packEntries(JSON.stringify({ "@openrig/cli": entry })), [entry]);
assert.deepEqual(packEntry(JSON.stringify({ "@openrig/cli": entry })), entry);
});
test("both shapes keep every entry in order, and a parsed value reads like its text", () => {
const other = { ...entry, name: "@openrig/acp", filename: "openrig-acp-1.2.3.tgz" };
assert.deepEqual(packEntries([entry, other]), [entry, other]);
assert.deepEqual(packEntries({ "@openrig/cli": entry, "@openrig/acp": other }), [entry, other]);
});
test("packEntry refuses any count but one", () => {
assert.throws(() => packEntry("[]"), /0 entries/);
assert.throws(() => packEntry("{}"), /0 entries/);
assert.throws(() => packEntry(JSON.stringify([entry, entry])), /2 entries/);
});
test("output that is neither shape throws", () => {
for (const output of ["null", "\"text\"", "3", JSON.stringify(entry), JSON.stringify([[entry]]), JSON.stringify({ a: null })]) {
assert.throws(() => packEntries(output), /neither an array of entries nor an object of per-package entries/, output);
}
assert.throws(() => packEntries("not json"), SyntaxError);
});
+3 -2
View File
@@ -8,6 +8,7 @@ import { createHash } from 'node:crypto';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { packEntry } from './npm-pack-json.mjs';
const LISTING = ['packages/acp/registry/agent.json', 'packages/acp/registry/icon.svg'];
const readJson = (root, rel) => JSON.parse(readFileSync(join(root, rel), 'utf8'));
@@ -35,8 +36,8 @@ export function packRelease(root, dest) {
mkdirSync(dest, { recursive: true });
// The CLI is packed as the release producer has packed it; the launcher isn't a workspace, so it's packed by path.
// --ignore-scripts skips pack's prepack and postpack, but npm 10 still runs a `prepare`; neither package has one.
const pack = target => JSON.parse(execFileSync('npm', ['pack', ...target, '--ignore-scripts', '--json', '--pack-destination', dest],
{ cwd: root, encoding: 'utf8' }))[0];
const pack = target => packEntry(execFileSync('npm', ['pack', ...target, '--ignore-scripts', '--json', '--pack-destination', dest],
{ cwd: root, encoding: 'utf8' }));
const artifacts = [pack(['--workspace', 'packages/cli']), pack(['./packages/acp'])].map(packed => {
const path = join(dest, packed.filename);
return { name: packed.name, version: packed.version, path, size: packed.size, sha256: sha256(path), integrity: packed.integrity };
+4 -3
View File
@@ -4,6 +4,7 @@ import os from "node:os";
import path from "node:path";
import { execFileSync, spawn, spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { packEntry } from "./npm-pack-json.mjs";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const cli = path.join(root, "packages", "cli");
@@ -47,8 +48,8 @@ async function assertDelayedSplits(entry, pkgDir) {
try {
execFileSync("npm", ["run", "build:package"], { cwd: root, stdio: "inherit" });
const packed = JSON.parse(execFileSync("npm", ["pack", "--ignore-scripts", "--json", "--pack-destination", temp], { cwd: cli, encoding: "utf8" }));
const tarball = path.join(temp, packed[0].filename);
const packed = packEntry(execFileSync("npm", ["pack", "--ignore-scripts", "--json", "--pack-destination", temp], { cwd: cli, encoding: "utf8" }));
const tarball = path.join(temp, packed.filename);
execFileSync("tar", ["-xzf", tarball, "-C", temp]);
const pkgDir = path.join(temp, "package");
@@ -68,7 +69,7 @@ try {
});
assert.equal(launched.status, 0, launched.stderr || `packed TUI exited ${launched.status}`);
await assertDelayedSplits(entry, pkgDir);
console.log(`packed TUI launch PASS: ${packed[0].filename}`);
console.log(`packed TUI launch PASS: ${packed.filename}`);
} finally {
fs.rmSync(temp, { recursive: true, force: true });
}