fix(daemon): prove a resumed Claude seat whose session id changed (#1080)

A Claude seat restored with --resume T1 that Claude Code continues under a new session id can be proved again instead of staying attention_required. OpenRig records the rotation only for the process its own launch observed, bound to that process and used once, and accepts it as a separate clause after the unchanged exact-token checks. Migration 101 adds the rotation record.

Refs https://github.com/mvschwarz/openrig/discussions/1077
This commit is contained in:
haralez authored and GitHub committed 2026-10-10 21:48:46 -07:00
1 parent 1754628ae5
commit bed4d4580f
34 files changed
+1685 -75

No files matched your search

@@ -1,6 +1,6 @@
{
"name": "openrig-core",
"version": "0.1.8",
"version": "0.1.9",
"description": "OpenRig canonical skills and hooks for cross-runtime agent topology coordination, continuity, and operating discipline.",
"author": {
"name": "Mike Schwarz",
@@ -1,6 +1,6 @@
{
"name": "openrig-core",
"version": "0.1.8",
"version": "0.1.9",
"description": "OpenRig canonical skills and hooks for cross-runtime agent topology coordination, continuity, and operating discipline.",
"author": {
"name": "Mike Schwarz",
@@ -1,6 +1,11 @@
#!/usr/bin/env node
"use strict";
const crypto = require("node:crypto");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
// OpenRig activity-relay hook script.
// Reads a hook event payload from stdin, normalizes it, and POSTs to the
// OpenRig daemon's /api/activity/hooks endpoint for real-time UI seat-status.
@@ -154,6 +159,13 @@ function buildSessionIdentityPayload(providerPayload, env = process.env, now = (
const sessionName = firstString(env.OPENRIG_SESSION_NAME, env.RIGGED_SESSION_NAME);
const nodeId = firstString(env.OPENRIG_NODE_ID, env.RIGGED_NODE_ID);
const runtime = firstString(env.OPENRIG_RUNTIME, env.RIGGED_RUNTIME);
const generation = firstString(env.OPENRIG_OCCUPANT_GENERATION, env.RIGGED_OCCUPANT_GENERATION);
// How this session began (startup, resume, clear or compact). The daemon tells a resumed
// conversation's new id from a /clear by it; it is absent from runtimes that do not report it.
const source = firstString(providerPayload.source);
// Set only on the command OpenRig sends to resume this seat, so the daemon can tell the process
// it launched from another Claude sharing the seat's environment.
const resumeLaunch = firstString(env.OPENRIG_RESUME_LAUNCH);
if ((!sessionName && !nodeId) || !runtime) return null;
@@ -162,18 +174,45 @@ function buildSessionIdentityPayload(providerPayload, env = process.env, now = (
sessionName,
nodeId,
runtime,
generation,
hookEvent,
source,
resumeLaunch,
// The daemon finds the Claude that ran this hook from its parent chain.
hookPid: process.pid,
sessionId,
occurredAt: now().toISOString(),
};
}
// #1077 — whether this is the first SessionStart of the launch OpenRig armed (OPENRIG_RESUME_LAUNCH
// for this occupant generation). Recorded on local disk before anything is posted, so a first hook
// whose post never reaches the daemon still makes every later one (an in-process /resume, a child
// sharing the environment) report false. Any error reports false.
function claimFirstLaunchHook(resumeLaunch, generation, dir = path.join(os.tmpdir(), `openrig-resume-launch-${uid()}`)) {
if (!resumeLaunch || !generation) return false;
try {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
const key = crypto.createHash("sha256").update(`${generation}\0${resumeLaunch}`).digest("hex");
fs.closeSync(fs.openSync(path.join(dir, key), "wx", 0o600));
return true;
} catch {
return false;
}
}
function uid() {
try { return typeof process.getuid === "function" ? String(process.getuid()) : "user"; } catch { return "user"; }
}
async function main() {
const providerPayload = parseJson(await readStdin());
const payload = buildOpenRigPayload(providerPayload);
await postHookPayload(payload);
const identityPayload = buildSessionIdentityPayload(providerPayload, process.env);
if (identityPayload && identityPayload.resumeLaunch) {
identityPayload.resumeLaunchFirst = claimFirstLaunchHook(identityPayload.resumeLaunch, identityPayload.generation);
}
await postHookPayload(payload);
if (identityPayload) {
await postHookPayload(identityPayload);
}
@@ -185,6 +224,7 @@ if (require.main === module) {
module.exports = {
buildOpenRigPayload,
claimFirstLaunchHook,
buildSessionIdentityPayload,
parseJson,
postHookPayload,
@@ -13,8 +13,9 @@ import type {
import { resolveConcreteHint } from "../domain/runtime-adapter.js";
import type { ProjectionPlan, ProjectionEntry } from "../domain/projection-planner.js";
import { assessNativeResumeProbe } from "../domain/native-resume-probe.js";
import { observeClaudePaneProcess, type NativeProcessLister } from "../domain/native-process-lineage.js";
import { observeClaudePaneProcess, type ClaudeLaunchedProcess, type NativeProcessLister } from "../domain/native-process-lineage.js";
import { mergeManagedBlock, DEFAULT_CLAUDE_MANAGED_BLOCK_FILE, type ClaudeManagedBlockFile } from "../domain/managed-blocks.js";
import { claudeResumeLaunchEnv, claudeResumeLaunchPrefix } from "./claude-resume-launch.js";
import { shellQuote } from "./shell-quote.js";
import { validateClaudeActivityHookDelivery, claudeActivityRelayPath, CLAUDE_ACTIVITY_RELAY_RELATIVE_PATH } from "../domain/claude-activity-hooks.js";
import { observeClaudePermission } from "../domain/permission-drift.js";
@@ -63,7 +64,7 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
private collectorAssetPath: string | null;
private autoDriveProviderPrompts: boolean;
private listProcesses?: NativeProcessLister;
private autoLaunches = new Map<string, { binding: NodeBinding; token: string; executable?: string; fingerprint?: string }>();
private autoLaunches = new Map<string, { binding: NodeBinding; token: string; executable?: string; fingerprint?: string; process?: ClaudeLaunchedProcess }>();
readonly claudeManagedLaunch?: ClaudeManagedLaunch;
private activityRelayPath: string | null;
private claudeHooksManifestPath: string | null;
@@ -324,8 +325,9 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
const generatedSessionId = opts.resumeToken ? null : this.sessionIdFactory();
const cmd = managed ? managed.command(["--permission-mode", binding.permissionMode!, ...operationalLaunchArgs(this.runtime, binding), ...(model ? ["--model", model] : []), ...(effort ? ["--effort", effort] : []),
...(opts.resumeToken ? ["--resume", opts.resumeToken] : ["--session-id", generatedSessionId!]), "--name", opts.name]) : opts.resumeToken
? `${rendererPrefix}claude ${permissionMode}${modelArg}${effortArg} --resume ${shellQuote(opts.resumeToken)} --name ${opts.name}`
...(opts.resumeToken ? ["--resume", opts.resumeToken] : ["--session-id", generatedSessionId!]), "--name", opts.name],
opts.resumeToken ? claudeResumeLaunchEnv(opts.resumeToken) : undefined) : opts.resumeToken
? `${claudeResumeLaunchPrefix(opts.resumeToken)}${rendererPrefix}claude ${permissionMode}${modelArg}${effortArg} --resume ${shellQuote(opts.resumeToken)} --name ${opts.name}`
: `${rendererPrefix}claude ${permissionMode}${modelArg}${effortArg} --session-id ${generatedSessionId} --name ${opts.name}`;
const textResult = managed ? await this.tmux.sendShellCommand(binding.tmuxSession, cmd, managed.assertCurrent)
@@ -341,11 +343,15 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
return { ok: false, error: `Failed to send Enter: ${enterResult.message}` };
}
this.autoLaunches.set(binding.nodeId, { binding, token: opts.resumeToken ?? generatedSessionId!, executable: managed?.executable });
const launch = { binding, token: opts.resumeToken ?? generatedSessionId!, executable: managed?.executable } as
{ binding: NodeBinding; token: string; executable?: string; fingerprint?: string; process?: ClaudeLaunchedProcess };
this.autoLaunches.set(binding.nodeId, launch);
if (opts.resumeToken) {
const verification = await this.verifyResumeLaunch(binding);
if (!verification.ok) return verification;
return { ok: true, resumeToken: opts.resumeToken, resumeType: "claude_id", appliedLaunch };
// The process readiness pinned first, if it observed one: the caller records it, not a later read.
const launchedProcess = launch.process ? { launchedProcess: { ...launch.process } } : {};
if (!verification.ok) return { ...verification, ...launchedProcess };
return { ok: true, resumeToken: opts.resumeToken, resumeType: "claude_id", appliedLaunch, ...launchedProcess };
}
// Fresh launches already have an explicit --session-id. A same-name file
@@ -404,8 +410,12 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
listProcesses: this.listProcesses, expectedToken: launch.token, selectedExecutable: launch.executable };
const first = await observeClaudePaneProcess(observation);
if (!first) return probe;
// Pin the first exact process for this launch; retries cannot adopt a replacement.
launch.fingerprint ??= first.fingerprint;
// Pin the first exact process for this launch; retries cannot adopt a replacement. It is also
// the launch's record of the process it started (#1077).
if (!launch.fingerprint) {
launch.fingerprint = first.fingerprint;
if (first.process.startedAt) launch.process = { pid: first.process.pid, startedAt: first.process.startedAt };
}
if (first.fingerprint !== launch.fingerprint) return probe;
const native = await observeClaudePaneProcess(observation);
if (native?.fingerprint !== launch.fingerprint) return probe;
@@ -0,0 +1,15 @@
import { shellQuote } from "./shell-quote.js";
// #1077 — set only on the command OpenRig itself sends to resume a Claude seat, so the
// SessionStart hook of that process can say it is the launch the daemon armed. It is a
// command-scoped assignment, never exported into the pane shell: a Claude started by hand in
// the same pane does not carry it.
export const CLAUDE_RESUME_LAUNCH_ENV = "OPENRIG_RESUME_LAUNCH";
export function claudeResumeLaunchEnv(token: string): Record<string, string> {
return { [CLAUDE_RESUME_LAUNCH_ENV]: token };
}
export function claudeResumeLaunchPrefix(token: string): string {
return `${CLAUDE_RESUME_LAUNCH_ENV}=${shellQuote(token)} `;
}
+12 -5
View File
@@ -3,15 +3,17 @@ import { setTimeout as sleep } from "node:timers/promises";
import type { TmuxAdapter } from "./tmux.js";
import type { SeatLaunchEnvironment } from "../domain/seat-launch-environment.js";
import { shellQuote } from "./shell-quote.js";
import { claudeResumeLaunchEnv, claudeResumeLaunchPrefix } from "./claude-resume-launch.js";
import { claudePostureFlag, claudeClassicRendererEnvPrefix } from "./yolo-mode.js";
import { assessNativeResumeProbe } from "../domain/native-resume-probe.js";
import { verifyClaudePaneProcess, type NativeProcessLister } from "../domain/native-process-lineage.js";
import { verifyClaudePaneProcess, type ClaudeLaunchedProcess, type NativeProcessLister } from "../domain/native-process-lineage.js";
import { observeClaudePermission, type AppliedLaunchObservation } from "../domain/permission-drift.js";
import { unresolvedClaudePermissionModes } from "../domain/native-permission-selection.js";
import type { ClaudeManagedLaunch } from "../domain/claude-managed-launch.js";
export type ResumeResult =
| { ok: true; appliedLaunch?: AppliedLaunchObservation }
// `launchedProcess` (Claude): the process this resume's own identity check observed (#1077).
| { ok: true; appliedLaunch?: AppliedLaunchObservation; launchedProcess?: ClaudeLaunchedProcess }
// Non-terminal: a chooser or an inconclusive observation must preserve the
// launch. Attention is not proof of native identity or successful continuity.
| { ok: false; code: "attention_required"; message: string; evidence?: string }
@@ -84,8 +86,8 @@ export class ClaudeResumeAdapter {
const posture = claudePostureFlag(process.env, resolvedPosture, selectedPermissionMode);
const appliedLaunch = observeClaudePermission(posture);
const permissionMode = posture + operationalLaunchArg("claude-code", choice);
const cmd = managed ? managed.command(["--permission-mode", selectedPermissionMode!, ...operationalLaunchArgs("claude-code", choice), ...(model ? ["--model", model] : []), ...(effort ? ["--effort", effort] : []), "--resume", resumeToken!])
: `${claudeClassicRendererEnvPrefix(process.env)}claude ${permissionMode}${modelArg}${effortArg} --resume ${shellQuote(resumeToken!)}`;
const cmd = managed ? managed.command(["--permission-mode", selectedPermissionMode!, ...operationalLaunchArgs("claude-code", choice), ...(model ? ["--model", model] : []), ...(effort ? ["--effort", effort] : []), "--resume", resumeToken!], claudeResumeLaunchEnv(resumeToken!))
: `${claudeResumeLaunchPrefix(resumeToken!)}${claudeClassicRendererEnvPrefix(process.env)}claude ${permissionMode}${modelArg}${effortArg} --resume ${shellQuote(resumeToken!)}`;
const textResult = managed ? await this.tmux.sendShellCommand(tmuxSessionName, cmd, managed.assertCurrent)
: this.options.seatLaunchEnvironment
@@ -195,7 +197,12 @@ export class ClaudeResumeAdapter {
paneContent: finalContent,
claudeResumeIdentityVerified: true,
});
if (verifiedProbe.status === "resumed") return { ok: true };
if (verifiedProbe.status === "resumed") {
// The process this check proved is the launch's record of what it started; the caller
// keeps it rather than reading the pane again.
return { ok: true, ...(identity.process.startedAt
? { launchedProcess: { pid: identity.process.pid, startedAt: identity.process.startedAt } } : {}) };
}
}
}
+2
View File
@@ -103,6 +103,7 @@ import { rigInstallRootSchema } from "./migrations/096_rig_install_root.js";
import { threadPartMapSchema } from "./migrations/097_thread_part_map.js";
import { queueTransitionsQitemIdOrderSchema } from "./migrations/098_queue_transitions_qitem_id_order.js";
import { queueItemsRecoveryTagsSchema } from "./migrations/099_queue_items_recovery_tags.js";
import { resumeRotationSchema } from "./migrations/101_claude_resume_rotation.js";
/** Ordered migrations; numbers may be reserved by independent changes. */
@@ -205,4 +206,5 @@ export const ALL_MIGRATIONS: Migration[] = [
threadPartMapSchema,
queueTransitionsQitemIdOrderSchema,
queueItemsRecoveryTagsSchema,
resumeRotationSchema,
];
@@ -0,0 +1,20 @@
import type { Migration } from "../migrate.js";
// A Claude seat resumed with `--resume T1` whose conversation continues as T2 (#1077).
// resume_launch_token arms that launch: the token OpenRig is about to resume, recorded before the
// launch. The first current-generation SessionStart hook for the row consumes it; when that hook
// reports `source: "resume"`, T2 and the launch marker OpenRig's resume command set, resume_rotated_from
// keeps T1, the one launch identity the proof may still accept in argv, and resume_rotated_process the
// process that sent that hook (JSON pid and start time), the only one whose argv may name T1. Any
// write that changes the stored token clears both. resume_launch_process is the launch path's own
// observation of the process it started on T1 (JSON token, pid, start time), recorded right after the
// launch; a rotation counts only when its process is that one. Re-arming a launch clears it.
export const resumeRotationSchema: Migration = {
name: "101_claude_resume_rotation.sql",
sql: `
ALTER TABLE sessions ADD COLUMN resume_launch_token TEXT;
ALTER TABLE sessions ADD COLUMN resume_rotated_from TEXT;
ALTER TABLE sessions ADD COLUMN resume_rotated_process TEXT;
ALTER TABLE sessions ADD COLUMN resume_launch_process TEXT;
`,
};
@@ -95,7 +95,7 @@ export class ClaudeManagedLaunch {
}
async prepare(request: ClaudeLaunchTarget, mode: string): Promise<{
assertCurrent: () => void; command: (args: readonly string[]) => string; configDir: string; executable: string;
assertCurrent: () => void; command: (args: readonly string[], launchEnv?: Readonly<Record<string, string>>) => string; configDir: string; executable: string;
}> {
const target = Object.freeze({ ...request });
const before = this.target(target.nodeId);
@@ -140,9 +140,10 @@ export class ClaudeManagedLaunch {
const terminal = ["TERM", "COLORTERM", "LANG", "LC_ALL", "LC_CTYPE", "LC_MESSAGES",
"LC_COLLATE", "LC_NUMERIC", "LC_TIME", "LC_MONETARY"]
.map(key => `\${${key}:+"${key}=$${key}"}`);
return Object.freeze({ assertCurrent, configDir: context.configDir, executable: context.executable, command: (args: readonly string[]) => {
return Object.freeze({ assertCurrent, configDir: context.configDir, executable: context.executable, command: (args: readonly string[], launchEnv: Readonly<Record<string, string>> = {}) => {
assertCurrent();
return `cd ${shellQuote(cwd)} && /usr/bin/env -i ${[...assignments, ...forwarded, ...terminal, shellQuote(context.executable), ...args.map(shellQuote)].join(" ")}`;
const launch = Object.entries(launchEnv).map(([key, value]) => shellQuote(`${key}=${value}`));
return `cd ${shellQuote(cwd)} && /usr/bin/env -i ${[...assignments, ...forwarded, ...terminal, ...launch, shellQuote(context.executable), ...args.map(shellQuote)].join(" ")}`;
} });
}
}
@@ -279,12 +279,15 @@ function claudeRuntimeParent(row: NativeProcessRow): boolean {
}
/** Require a live process in the pane's own lineage whose argv names both the
* declared runtime and the exact native resume identity. */
* declared runtime and the exact native resume identity. For Claude, one search also accepts a
* recorded rotation (#1077): the process that qualified it, naming the token it was launched on,
* with no Claude beneath it in its foreground process group. */
export function findExactNativeResumeProcess(
processes: NativeProcessRow[],
panePid: number,
runtime: string | null,
expectedToken: string,
rotation?: ClaudeResumeRotation | null,
): NativeProcessRow | null {
if (runtime === "codex") return selectNativeProcess(processes, panePid, expectedToken, true)?.process ?? null;
if (runtime !== "claude-code") return null;
@@ -317,11 +320,45 @@ export function findExactNativeResumeProcess(
if (claudeRuntimeParent(process) || deeper.size === 0) return process;
continue;
}
// #1077: a recorded rotation, as a separate clause. Only a process that does not name the stored
// token reaches it (the clause above returns or refuses every one that does), and it is
// accepted only as the launch-observed process with no Claude runtime beneath it.
if (process && rotationProcess(process, rotation) && commandUsesExpectedToken(process.command, runtime, rotation!.token)
&& !claudeBeneath(process, byParent)) return process;
for (const child of byParent.get(pid) ?? []) queue.push(child.pid);
}
return null;
}
/** Whether a Claude process runs beneath `top` in its foreground process group: the deepest Claude
* receives input, so `top`'s argv no longer says which conversation the seat holds. */
function claudeBeneath(top: NativeProcessRow, byParent: Map<number, NativeProcessRow[]>): boolean {
const queue = [...(byParent.get(top.pid) ?? [])];
const seen = new Set<number>([top.pid]);
while (queue.length > 0) {
const row = queue.shift()!;
if (seen.has(row.pid)) continue;
seen.add(row.pid);
// An unknown process group may be the foreground one; a foreground group of -1 or 0 is unknown.
const foreground = row.pgid === undefined || top.tpgid === undefined || top.tpgid <= 0 || row.pgid === top.tpgid;
if (foreground && mayBeClaudeRuntime(row)) return true;
queue.push(...(byParent.get(row.pid) ?? []));
}
return false;
}
/** Any process that may be a Claude runtime. This only refuses a proof, so it is broader than the
* identity check: a verified native Claude, any argv0 naming claude (including an older entry with
* no OS executable name), and Node with any argument naming a Claude executable or the npm
* package's cli.js/cli.mjs. Node's own options are not parsed, so none can hide the script. */
function mayBeClaudeRuntime(row: NativeProcessRow): boolean {
if (claudeProcess(row)) return true;
const [argv0 = "", ...args] = tokens(row.command);
if (claudeExecutable(argv0)) return true;
return executableName(argv0) === "node"
&& args.some(arg => claudeExecutable(arg) || /\/@anthropic-ai\/claude-code\/cli\.m?js$/.test(arg));
}
/** The same OS observation serves menu input, restore proof and periodic identity.
* Older callers may carry only pid/ppid/command; that is insufficient positive Codex proof. */
export async function listNativeProcesses(): Promise<NativeProcessRow[]> {
@@ -350,6 +387,19 @@ export async function listNativeProcesses(): Promise<NativeProcessRow[]> {
}
export type NativeProcessLister = () => NativeProcessRow[] | Promise<NativeProcessRow[]>;
/** One OS process, by pid and `ps` start time: a reused pid has another start time. */
export interface ClaudeLaunchedProcess { pid: number; startedAt: string }
/** A recorded Claude resume rotation (#1077): OpenRig launched `process` to resume `token`, and that
* process's first hook said the conversation continued as the stored token. argv may name `token`
* instead of the stored one only in that same process. */
export interface ClaudeResumeRotation { token: string; process: ClaudeLaunchedProcess }
function rotationProcess(row: NativeProcessRow, rotation: ClaudeResumeRotation | null | undefined): boolean {
return !!rotation && row.pid === rotation.process.pid && !!row.startedAt && row.startedAt === rotation.process.startedAt;
}
export type NativeProcessObservation = { panePid: number; process: NativeProcessRow; fingerprint: string };
export type CodexProcessObservation = NativeProcessObservation;
@@ -378,7 +428,7 @@ function nativeProcessCandidates(rows: NativeProcessRow[], panePid: number, runt
fingerprint: JSON.stringify(chain.map(row => [row.pid, row.ppid, row.startedAt, row.pgid, row.tpgid, row.executableName, row.command, row.executablePath])) }));
}
function selectNativeProcess(rows: NativeProcessRow[], panePid: number, expectedToken?: string | null, requireResume = false, runtime: NativeRuntime = "codex", selectedExecutable?: string): NativeProcessObservation | null {
function selectNativeProcess(rows: NativeProcessRow[], panePid: number, expectedToken?: string | null, requireResume = false, runtime: NativeRuntime = "codex", selectedExecutable?: string, rotation?: ClaudeResumeRotation | null): NativeProcessObservation | null {
const matches = nativeProcessCandidates(rows, panePid, runtime, selectedExecutable);
// #1079: a Codex launcher that spawns Codex is one runtime on one chain.
const chain = runtime === "codex" ? launcherChain(matches, rows) : matches;
@@ -386,7 +436,9 @@ function selectNativeProcess(rows: NativeProcessRow[], panePid: number, expected
const observation = chain[0]!;
const { process } = observation;
if (runtime === "claude-code") {
if (!expectedToken || claudeSessionToken(tokens(process.command).slice(1)) !== expectedToken) return null;
if (!expectedToken) return null;
const launched = claudeSessionToken(tokens(process.command).slice(1));
if (launched !== expectedToken && !(launched === rotation?.token && rotationProcess(process, rotation))) return null;
} else {
// Every link that names a conversation must name the expected one, and
// links naming different conversations stay refused. Exact resume proof
@@ -409,12 +461,16 @@ async function observeNativePaneProcess(input: {
requireResume?: boolean;
/** Canonical executable frozen by the managed launch, never re-resolved at observation time. */
selectedExecutable?: string;
/** Claude only: the recorded rotation into `expectedToken` (`claudeResumeRotation`). Its process
* may name the rotation's token instead of `expectedToken`. */
rotation?: ClaudeResumeRotation | null;
}, runtime: NativeRuntime): Promise<NativeProcessObservation | null> {
try {
const pid = await input.tmux.getPanePid(input.target);
if (!pid) return null;
const rows = await (input.listProcesses ?? listNativeProcesses)();
return selectNativeProcess(rows, pid, input.expectedToken, input.requireResume, runtime, input.selectedExecutable);
return selectNativeProcess(rows, pid, input.expectedToken, input.requireResume, runtime, input.selectedExecutable,
runtime === "claude-code" ? input.rotation : undefined);
} catch { return null; }
}
@@ -429,6 +485,54 @@ export async function verifyCodexPaneProcess(input: Parameters<typeof observeCod
return second?.fingerprint === first.fingerprint ? second : null;
}
/** #1077 — the process OpenRig launched to resume `launchToken`, when it emitted a Claude hook:
* the pane's one foreground Claude, whose argv names that token, must be the hook process's parent
* or grandparent (Claude runs a hook through a shell, which may exec it), with no other Claude
* between. A `claude -p --resume` started from inside the seat inherits the launch environment,
* but its own hooks have that child as their nearest Claude. Null on anything unobserved. */
export async function claudeHookFromLaunchedProcess(input: {
target: string;
tmux: { getPanePid(target: string): Promise<number | null> };
listProcesses?: NativeProcessLister;
launchToken: string;
hookPid: number;
}): Promise<ClaudeLaunchedProcess | null> {
try {
const panePid = await input.tmux.getPanePid(input.target);
if (!panePid) return null;
const rows = await (input.listProcesses ?? listNativeProcesses)();
const launched = selectNativeProcess(rows, panePid, input.launchToken, false, "claude-code");
if (!launched) return null;
const byPid = new Map(rows.map((row) => [row.pid, row]));
let current = byPid.get(input.hookPid);
if (!current) return null;
for (let hop = 0; hop < 2; hop++) {
current = byPid.get(current.ppid);
if (!current) return null;
if (current.pid === launched.process.pid) {
return current.startedAt && current.startedAt === launched.process.startedAt
? { pid: current.pid, startedAt: current.startedAt } : null;
}
if (claudeExecutable(tokens(current.command)[0] ?? "") || /claude/i.test(current.executableName ?? "")) return null;
}
return null;
} catch { return null; }
}
/** #1077 — the launch path's own record of the process it started to resume `token`: the pane's one
* foreground Claude naming that token, stable across two samples, observed right after the launch
* succeeded. A rotation counts only for this process. Null when it cannot be observed. */
export async function observeClaudeResumeLaunch(input: {
target: string;
tmux: { getPanePid(target: string): Promise<number | null> };
listProcesses?: NativeProcessLister;
token: string;
}): Promise<ClaudeLaunchedProcess | null> {
const native = await verifyClaudePaneProcess({ target: input.target, tmux: input.tmux, listProcesses: input.listProcesses,
expectedToken: input.token });
return native?.process.startedAt ? { pid: native.process.pid, startedAt: native.process.startedAt } : null;
}
export async function observeClaudePaneProcess(input: Parameters<typeof observeNativePaneProcess>[0]): Promise<NativeProcessObservation | null> {
return observeNativePaneProcess(input, "claude-code");
}
@@ -530,10 +634,14 @@ export async function observeClaudeDelivery(
// Do not promote either source over the other; ordinary delivery warns on
// uncertainty. Live lineage/binding conflicts and strict resume proof stay
// separate. A shim's token is never inherited by an opaque child.
if (named.size === 1 && !named.has(input.expectedToken)) {
// The one exception is a launch token the runtime itself reported resuming
// into the stored one, named by the process that reported it (rotation).
const launchedAs = (index: number) => identities[index] === input.expectedToken
|| (identities[index] === input.rotation?.token && rotationProcess(chain[index]!.process, input.rotation));
if (named.size === 1 && !chain.some((_link, index) => launchedAs(index))) {
return { state: "unknown", detail: "Claude launch identity differs from the stored conversation; current conversation is unverified", fingerprint };
}
return identities[0] === input.expectedToken
return launchedAs(0)
? { state: "verified", detail: "Expected Claude conversation in the bound foreground", fingerprint }
: { ...unknown, fingerprint };
}
@@ -5,7 +5,7 @@ import type Database from "better-sqlite3";
import { NativePermissionStore } from "./native-permission-store.js";
import type { RigRepository } from "./rig-repository.js";
import { resolvePermissionPolicyAttachment } from "./permission-policy/policy-ref.js";
import type { SessionRegistry } from "./session-registry.js";
import { claudeResumeRotation, type SessionRegistry } from "./session-registry.js";
import type { EventBus } from "./event-bus.js";
import type { SnapshotRepository } from "./snapshot-repository.js";
import type { SnapshotCapture } from "./snapshot-capture.js";
@@ -18,7 +18,7 @@ import type { PiResumeAdapter } from "../adapters/pi-resume.js";
import type { OmpResumeAdapter } from "../adapters/omp-resume.js";
import type { TranscriptStore } from "./transcript-store.js";
import { assessNativeResumeProbe } from "./native-resume-probe.js";
import { observeClaudeDelivery, verifyClaudePaneProcess } from "./native-process-lineage.js";
import { observeClaudeDelivery, observeClaudeResumeLaunch, verifyClaudePaneProcess, type ClaudeLaunchedProcess } from "./native-process-lineage.js";
import { reanchorBuiltinStartupFile, reanchorShippedProjectionEntry } from "./builtin-startup-files.js";
import { seatGuidance, restoreMissingGuidance, missingGuidanceBlocks } from "./restore-guidance.js";
import type {
@@ -1153,9 +1153,25 @@ export class RestoreOrchestrator {
await this.rollbackToZeroSession(node.id, sessionName, launchResult?.session.id, priorState);
return { nodeId: node.id, logicalId: node.logicalId, status: "awaiting-decision", error: `Original session unresumable: resume requested but no token available. No session is running. Re-run with --fresh ${node.logicalId} for a deliberate fresh-primed seat, or restore the original session manually.` };
} else {
const launchedSessionId = launchResult?.session.id;
if (launchedSessionId && this.claudeResume.canResume(resumeType, resumeToken)) {
// Before the resume: Claude's SessionStart hook can land before attemptResume returns.
try {
this.sessionRegistry.recordResumeLaunch(launchedSessionId, resumeToken);
} catch { /* best-effort: without it a rotation stays unproved, as before */ }
}
const resumeOutcome = await this.attemptResume(node.id, sessionName, resumeType, resumeToken, node.cwd ?? "/", node.codexConfigProfile, node.model, this.resolveRestorePosture(node.id, rigId), node.effort, warnings);
if (resumeOutcome.kind === "resumed") {
baseStatus = "resumed";
if (launchedSessionId && this.claudeResume.canResume(resumeType, resumeToken)) {
// The process this resume started, as the launch itself observes it (#1077): the one its
// identity check proved, else one observed now.
try {
const launched = resumeOutcome.launchedProcess ?? await observeClaudeResumeLaunch({ target: sessionName, tmux: this.tmuxAdapter,
...(this.listProcesses ? { listProcesses: this.listProcesses } : {}), token: resumeToken });
if (launched) this.sessionRegistry.recordResumeLaunchProcess(launchedSessionId, resumeToken, launched);
} catch { /* best-effort: without it a rotation stays unproved, as before */ }
}
} else if (resumeOutcome.kind === "attention_required") {
// L3 Decision 2: Claude resume-selection prompt -> attention_required.
// Do NOT auto-answer. Reconcile later via reconcileNodeRuntimeTruth
@@ -1327,7 +1343,8 @@ export class RestoreOrchestrator {
try {
const { StartupOrchestrator } = await import("./startup-orchestrator.js");
const startupOrch = new StartupOrchestrator({ db: this.db, sessionRegistry: this.sessionRegistry, eventBus: this.eventBus, tmuxAdapter: this.tmuxAdapter });
const startupOrch = new StartupOrchestrator({ db: this.db, sessionRegistry: this.sessionRegistry, eventBus: this.eventBus, tmuxAdapter: this.tmuxAdapter,
...(this.listProcesses ? { listProcesses: this.listProcesses } : {}) });
const replayAsRestore = baseStatus !== "fresh-primed";
const shouldLaunchHarness = isPodAware;
const startupResult = await startupOrch.startNode({
@@ -1497,7 +1514,10 @@ export class RestoreOrchestrator {
// join; a hook/operator may have changed it meanwhile. Never backfill here.
const retained = sameSession && (managedClaudeResume
? this.sessionRegistry.resumeTokenMatches(sessionId, "claude_id", resumeToken)
// Claude continued the resumed conversation under a new id (its hook said so).
|| this.sessionRegistry.claudeResumeRotatedFrom(sessionId, resumeToken)
: current.resume_token === resumeToken
|| (node.runtime === "claude-code" && this.sessionRegistry.claudeResumeRotatedFrom(sessionId, resumeToken))
|| (!current.resume_token && this.sessionRegistry.updateResumeToken(sessionId, node.runtime === "codex" ? "codex_id" : "claude_id", resumeToken, "scrape")));
if (!retained) {
markManagedResumeAttention();
@@ -1603,7 +1623,7 @@ export class RestoreOrchestrator {
effort?: string | null,
warnings?: string[],
): Promise<
| { kind: "resumed" }
| { kind: "resumed"; launchedProcess?: ClaudeLaunchedProcess }
| { kind: "retry_fresh" }
| { kind: "failed"; message: string }
| { kind: "attention_required"; message: string; evidence?: string }
@@ -1632,7 +1652,7 @@ export class RestoreOrchestrator {
const notice = nonInterruptiveNotice("claude-code", { nonInterruptive, launchPosture: resolvedPosture, permissionMode });
if (notice) warnings?.push(`${sessionName}: ${notice}`);
if (result.appliedLaunch && launchGeneration) this.appliedLaunchStore.recordGeneration(launchGeneration, result.appliedLaunch);
return { kind: "resumed" };
return { kind: "resumed", ...(result.launchedProcess ? { launchedProcess: result.launchedProcess } : {}) };
}
if (result.code === "retry_fresh") return { kind: "retry_fresh" };
// L3: surface attention_required from the Claude probe (resume-selection prompt).
@@ -1783,12 +1803,16 @@ export class RestoreOrchestrator {
}
const sessRow = this.db.prepare(
"SELECT session_name, resume_token FROM sessions WHERE node_id = ? ORDER BY created_at DESC, id DESC LIMIT 1",
).get(nodeId) as { session_name: string; resume_token: string | null } | undefined;
"SELECT session_name, resume_type, resume_token, resume_provenance, resume_rotated_from, resume_rotated_process, resume_launch_process FROM sessions WHERE node_id = ? ORDER BY created_at DESC, id DESC LIMIT 1",
).get(nodeId) as { session_name: string; resume_type: string | null; resume_token: string | null; resume_provenance: string | null; resume_rotated_from: string | null; resume_rotated_process: string | null; resume_launch_process: string | null } | undefined;
if (!sessRow || sessRow.session_name !== sessionName) {
return { ok: false, code: "binding_mismatch", detail: `Canonical binding ${sessionName} does not match the latest session row.` };
}
const expectedResumeToken = sessRow.resume_token;
// The one alternative argv may carry: the token OpenRig launched this row to resume, when the
// first hook after that launch recorded Claude continuing it as the stored token (#1077), and
// only in the process that sent that hook.
const rotation = sessRow.resume_type === "claude_id" ? claudeResumeRotation(sessRow) : null;
if (!expectedResumeToken) {
return { ok: false, code: "resume_token_not_used", detail: "No resume token recorded on the latest session row." };
}
@@ -1812,6 +1836,7 @@ export class RestoreOrchestrator {
"SELECT runtime FROM nodes WHERE id = ?"
).get(nodeId) as { runtime: string | null } | undefined;
const runtime = nodeRow?.runtime ?? null;
// One exact-lineage check per process, accepting the stored token or the recorded rotation.
const identity = await rebindAndVerifyPaneIdentity({
db: this.db,
sessionRegistry: this.sessionRegistry,
@@ -1821,6 +1846,7 @@ export class RestoreOrchestrator {
runtime,
expectedResumeToken,
requireExactResumeLineage: true,
resumeRotation: rotation,
...(this.listProcesses ? { listProcesses: this.listProcesses } : {}),
});
if (!identity.ok) {
@@ -1832,6 +1858,7 @@ export class RestoreOrchestrator {
target: identity.pane,
tmux: this.tmuxAdapter,
expectedToken: expectedResumeToken,
rotation,
requireResume: true,
...(this.listProcesses ? { listProcesses: this.listProcesses } : {}),
});
@@ -94,12 +94,16 @@ export function isAttentionRequiredReadinessCode(code: string | undefined): bool
export type HarnessLaunchRecovery = "retry_fresh" | "attention_required";
// `launchedProcess` (Claude resume launches): the first process the launch's own readiness check
// pinned as the one it started, kept even when a later sample saw it replaced (#1077).
export type HarnessLaunchResult =
| { ok: true; resumeToken?: string; resumeType?: string; appliedLaunch?: import("./permission-drift.js").AppliedLaunchObservation }
| { ok: true; resumeToken?: string; resumeType?: string; appliedLaunch?: import("./permission-drift.js").AppliedLaunchObservation;
launchedProcess?: import("./native-process-lineage.js").ClaudeLaunchedProcess }
// `evidence` carries the last-N pane lines for `attention_required` outcomes
// so the failure can flow honest evidence through to RestoreNodeResult's
// attentionEvidence field. Omitted for non-attention recoveries.
| { ok: false; error: string; recovery?: HarnessLaunchRecovery; evidence?: string };
| { ok: false; error: string; recovery?: HarnessLaunchRecovery; evidence?: string;
launchedProcess?: import("./native-process-lineage.js").ClaudeLaunchedProcess };
// -- Shared concrete-hint resolver --
@@ -2,14 +2,14 @@
// startup_status=attention_required. Managed writer + append-only audit.
import type Database from "better-sqlite3";
import type { SessionRegistry } from "./session-registry.js";
import { CLAUDE_RESUME_ROTATION_COLUMNS, claudeResumeRotation, type SessionRegistry } from "./session-registry.js";
import type { EventBus } from "./event-bus.js";
import type { AgentActivityStore } from "./agent-activity-store.js";
import type { AgentActivity, SeatIdentityVerdict } from "./types.js";
import type { TmuxAdapter } from "../adapters/tmux.js";
import { classifyPaneRuntimeMatch } from "./seat-identity-reconciler.js";
import { SeatIdentityStore } from "./seat-identity-store.js";
import { verifyClaudePaneProcess, verifyClaudePaneRuntime, verifyCodexPaneProcess, type NativeProcessRow, type NativeProcessLister, findExactNativeResumeProcess, listNativeProcesses } from "./native-process-lineage.js";
import { verifyClaudePaneProcess, verifyClaudePaneRuntime, verifyCodexPaneProcess, type NativeProcessRow, type NativeProcessLister, type ClaudeResumeRotation, findExactNativeResumeProcess, listNativeProcesses } from "./native-process-lineage.js";
import { isShellForeground } from "./shell-classifier.js";
type PaneIdentityTmux = Pick<TmuxAdapter, "listPanes" | "getPanePid" | "getPaneCommand">;
@@ -19,6 +19,17 @@ export type PaneIdentityReconcileResult =
| { ok: true; pane: string; pid: number; command: string | null }
| { ok: false; detail: string };
/** The rotation recorded with the node's current stored token, when the caller is checking that
* stored token (#1077). Exact-resume callers pass the rotation they mean to accept, if any. */
function storedRotation(db: Database.Database, nodeId: string, expectedToken: string): ClaudeResumeRotation | null {
try {
const row = db.prepare(
`SELECT ${CLAUDE_RESUME_ROTATION_COLUMNS} FROM sessions WHERE node_id = ? ORDER BY id DESC LIMIT 1`,
).get(nodeId) as Parameters<typeof claudeResumeRotation>[0];
return row?.resume_token === expectedToken ? claudeResumeRotation(row) : null;
} catch { return null; }
}
/** Rebind one managed seat to the sole pane currently in its named tmux
* session and positively identify its declared agent runtime. */
export async function rebindAndVerifyPaneIdentity(input: {
@@ -30,6 +41,9 @@ export async function rebindAndVerifyPaneIdentity(input: {
runtime: string | null;
expectedResumeToken?: string | null;
requireExactResumeLineage?: boolean;
/** Exact-resume Claude callers only: a recorded rotation into `expectedResumeToken`, accepted in
* the same per-process check (`findExactNativeResumeProcess`). */
resumeRotation?: ClaudeResumeRotation | null;
listProcesses?: NativeProcessLister;
now?: () => Date;
}): Promise<PaneIdentityReconcileResult> {
@@ -98,7 +112,8 @@ export async function rebindAndVerifyPaneIdentity(input: {
runtimeMatch = "match";
const observation = { target: pane.id, tmux: input.tmux, listProcesses: input.listProcesses };
const native = expectedResumeToken !== null
? await verifyClaudePaneProcess({ ...observation, expectedToken: expectedResumeToken })
? await verifyClaudePaneProcess({ ...observation, expectedToken: expectedResumeToken,
rotation: input.requireExactResumeLineage ? input.resumeRotation ?? null : storedRotation(input.db, input.nodeId, expectedResumeToken) })
: claudeWrapper && !input.requireExactResumeLineage ? await verifyClaudePaneRuntime(observation) : null;
const currentPanes = await input.tmux.listPanes(input.sessionName).catch(() => []);
const currentPid = await input.tmux.getPanePid(pane.id).catch(() => null);
@@ -111,6 +126,7 @@ export async function rebindAndVerifyPaneIdentity(input: {
pid,
input.runtime,
expectedResumeToken!,
input.resumeRotation,
);
} catch {
// Missing process evidence is ambiguity, never positive identity.
@@ -1,5 +1,6 @@
import { observeClaudePaneProcess, observeClaudePaneRuntime, observeCodexPaneProcess, listNativeProcesses, type NativeProcessLister, type NativeProcessObservation } from "./native-process-lineage.js";
import { isShellForeground } from "./shell-classifier.js";
import { claudeResumeRotation } from "./session-registry.js";
import { randomUUID } from "node:crypto";
import type Database from "better-sqlite3";
import type { TmuxAdapter } from "../adapters/tmux.js";
@@ -70,6 +71,10 @@ interface RunningSeatRow {
session_name: string;
tmux_pane: string | null;
resume_token?: string | null;
resume_provenance?: string | null;
resume_rotated_from?: string | null;
resume_rotated_process?: string | null;
resume_launch_process?: string | null;
}
type PaneObservation = { pid: number | null; command: string | null };
@@ -113,7 +118,11 @@ export class SeatIdentityReconciler {
private runningSeats(): RunningSeatRow[] {
return this.db.prepare(`
SELECT n.id as node_id, n.runtime as runtime,
s.session_name as session_name, b.tmux_pane as tmux_pane, s.resume_token as resume_token
s.session_name as session_name, b.tmux_pane as tmux_pane, s.resume_token as resume_token,
s.resume_provenance as resume_provenance,
s.resume_rotated_from as resume_rotated_from,
s.resume_rotated_process as resume_rotated_process,
s.resume_launch_process as resume_launch_process
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)
@@ -217,7 +226,7 @@ export class SeatIdentityReconciler {
});
return Promise.all(nativeSeats.map((seat) => (seat.runtime === "codex" ? observeCodexPaneProcess
: seat.resume_token !== null && seat.resume_token !== undefined ? observeClaudePaneProcess : observeClaudePaneRuntime)({
target: seat.tmux_pane!, tmux, expectedToken: seat.resume_token,
target: seat.tmux_pane!, tmux, expectedToken: seat.resume_token, rotation: claudeResumeRotation(seat),
listProcesses: () => snapshot ??= this.listProcesses(),
})));
};
+107 -4
View File
@@ -6,6 +6,7 @@ const ulid = monotonicFactory();
import type { Session, Binding } from "./types.js";
import { validateSessionName } from "./session-name.js";
import { formatWatchdogRegistrationError } from "./watchdog-auto-registration.js";
import type { ClaudeLaunchedProcess, ClaudeResumeRotation } from "./native-process-lineage.js";
// GHOST-STAGE atom-B (P12 3548d8eb) — the occupant-generation tenure.
export type OccupantKind = "initial" | "handover" | "adopt" | "fresh";
@@ -38,6 +39,44 @@ interface BindingFields {
cmuxSurface?: string;
}
/** A token write that changes the value invalidates what was known about how the old one began. */
const CLEAR_ROTATION_ON_TOKEN_CHANGE =
"resume_rotated_from = CASE WHEN resume_token IS ? THEN resume_rotated_from ELSE NULL END, " +
"resume_rotated_process = CASE WHEN resume_token IS ? THEN resume_rotated_process ELSE NULL END";
/** The columns `claudeResumeRotation` reads. */
export const CLAUDE_RESUME_ROTATION_COLUMNS = "resume_token, resume_provenance, resume_rotated_from, resume_rotated_process, resume_launch_process";
function launchedProcess(json: string | null | undefined): (ClaudeLaunchedProcess & { token?: unknown }) | null {
try {
const process = JSON.parse(json ?? "null") as (Partial<ClaudeLaunchedProcess> & { token?: unknown }) | null;
return process && Number.isInteger(process.pid) && typeof process.startedAt === "string" && process.startedAt
? { pid: process.pid!, startedAt: process.startedAt, token: process.token } : null;
} catch { return null; }
}
/** The rotation a Claude identity proof may accept besides the stored token (#1077): the token
* OpenRig launched this row's process to resume, when the first hook after that launch, sent by
* that process, said Claude continued it as the stored token; and that process, the only one whose
* argv may name it. The hook's process must be the one the launch path itself observed starting on
* that token (`resume_launch_process`): a process that replaced it before its hook arrived is not.
* Null unless the stored token is still that hook's own record. Callers pass it as `rotation`. */
export function claudeResumeRotation(row: {
resume_token?: string | null;
resume_provenance?: string | null;
resume_rotated_from?: string | null;
resume_rotated_process?: string | null;
resume_launch_process?: string | null;
} | null | undefined): ClaudeResumeRotation | null {
if (!row || row.resume_provenance !== "hook") return null;
const from = row.resume_rotated_from?.trim();
if (!from || from === row.resume_token?.trim()) return null;
const hooked = launchedProcess(row.resume_rotated_process);
const launch = launchedProcess(row.resume_launch_process);
if (!hooked || !launch || launch.token !== from || launch.pid !== hooked.pid || launch.startedAt !== hooked.startedAt) return null;
return { token: from, process: { pid: hooked.pid, startedAt: hooked.startedAt } };
}
/** Resume-token provenance precedence (OPR.0.4.0.22; adoption rung added by
* OPR.0.4.3.20 FR-3). Higher rank wins; a lower-rank write never overwrites a
* higher-rank persisted token.
@@ -345,12 +384,76 @@ export class SessionRegistry {
this.db
.prepare(
"UPDATE sessions SET resume_type = ?, resume_token = ?, resume_provenance = COALESCE(?, resume_provenance), " +
"resume_last_verified = datetime('now'), resume_last_probe_status = 'resumable' WHERE id = ?",
"resume_last_verified = datetime('now'), resume_last_probe_status = 'resumable', " +
`${CLEAR_ROTATION_ON_TOKEN_CHANGE} WHERE id = ?`,
)
.run(type, token, prov, sessionId);
.run(type, token, prov, token, token, sessionId);
return true;
}
/** Record a Claude SessionStart hook's session id, with the evidence of how it began (#1077).
*
* The write itself follows `updateResumeToken`'s hook rank. The first hook carrying the node's
* current occupant generation after OpenRig launched this row with `--resume <T1>` consumes that
* launch, whatever its source: only that hook can name T1 as the token it replaced, and only when
* it reports `source: "resume"`, a different id, and the launch marker OpenRig put on that
* process's command (`OPENRIG_RESUME_LAUNCH=T1`), which a Claude started by hand in the pane does
* not carry, and when the caller observed that the hook came from the launched process itself
* (`claudeHookFromLaunchedProcess`): a child `claude -p --resume` inherits the marker but is a
* different process. The rotation keeps that process (pid and start time), and only it may later
* name T1. Any later hook (an in-process `/resume`, a child `claude -p --resume` sharing the
* seat's environment, a replacement process) has no launch to name, which leaves today's refusal
* in place. */
recordHookSessionIdentity(
sessionId: string,
type: string,
token: string,
evidence: { source: string | null; currentGeneration: boolean; resumeLaunch?: string | null; launchedProcess?: ClaudeLaunchedProcess | null },
): boolean {
return this.db.transaction(() => {
const launched = (this.db.prepare("SELECT resume_launch_token FROM sessions WHERE id = ?").get(sessionId) as
{ resume_launch_token: string | null } | undefined)?.resume_launch_token?.trim() || null;
const written = this.updateResumeToken(sessionId, type, token, "hook");
if (launched && evidence.currentGeneration) {
this.db.prepare("UPDATE sessions SET resume_launch_token = NULL WHERE id = ?").run(sessionId);
if (written && evidence.source === "resume" && evidence.resumeLaunch?.trim() === launched
&& evidence.launchedProcess && launched !== token.trim()) {
this.db.prepare("UPDATE sessions SET resume_rotated_from = ?, resume_rotated_process = ? WHERE id = ?").run(launched,
JSON.stringify({ pid: evidence.launchedProcess.pid, startedAt: evidence.launchedProcess.startedAt }), sessionId);
}
}
return written;
})();
}
/** The resume launch armed for this row and not yet consumed by a hook, if any. */
armedResumeLaunch(sessionId: string): string | null {
return (this.db.prepare("SELECT resume_launch_token FROM sessions WHERE id = ?").get(sessionId) as
{ resume_launch_token: string | null } | undefined)?.resume_launch_token?.trim() || null;
}
/** Arm the launch about to resume `token` in this row's Claude process (null for a fresh launch).
* Recorded before the launch, since the SessionStart hook can land before the launch returns. It
* certifies nothing: it only lets the first hook after this launch name the token it replaced. */
recordResumeLaunch(sessionId: string, token: string | null): void {
this.db.prepare("UPDATE sessions SET resume_launch_token = ?, resume_launch_process = NULL WHERE id = ?").run(token?.trim() || null, sessionId);
}
/** The process the launch path observed starting on `token`, right after that launch succeeded
* (`observeClaudeResumeLaunch`). Only a rotation whose hook came from this process counts. */
recordResumeLaunchProcess(sessionId: string, token: string, process: ClaudeLaunchedProcess): void {
this.db.prepare("UPDATE sessions SET resume_launch_process = ? WHERE id = ?")
.run(JSON.stringify({ token: token.trim(), pid: process.pid, startedAt: process.startedAt }), sessionId);
}
/** True when this row's hook-recorded resume rotation started from `token`. */
claudeResumeRotatedFrom(sessionId: string, token: string): boolean {
const row = this.db.prepare(
`SELECT ${CLAUDE_RESUME_ROTATION_COLUMNS} FROM sessions WHERE id = ? AND resume_type = 'claude_id'`,
).get(sessionId) as Parameters<typeof claudeResumeRotation>[0];
return claudeResumeRotation(row)?.token === token.trim();
}
/** Test durable identity without returning the stored credential or changing
* its provenance. An equal protected token needs no lower-ranked write. */
resumeTokenMatches(sessionId: string, type: string, token: string): boolean {
@@ -373,7 +476,7 @@ export class SessionRegistry {
const result = this.db
.prepare(
"UPDATE sessions SET resume_type = ?, resume_token = ?, resume_provenance = NULL, " +
"resume_last_verified = NULL, resume_last_probe_status = NULL " +
"resume_last_verified = NULL, resume_last_probe_status = NULL, resume_rotated_from = NULL, resume_rotated_process = NULL " +
"WHERE id = ? AND (resume_token IS NULL OR trim(resume_token) = '') " +
"AND resume_provenance IS NULL AND resume_last_verified IS NULL AND resume_last_probe_status IS NULL",
)
@@ -439,7 +542,7 @@ export class SessionRegistry {
// validate path marks-stale instead of clearing, so this has no in-tree
// caller on the live path; kept for explicit-clear callers/tests.
this.db
.prepare("UPDATE sessions SET resume_type = NULL, resume_token = NULL, resume_provenance = NULL, resume_last_verified = NULL, resume_last_probe_status = NULL WHERE id = ?")
.prepare("UPDATE sessions SET resume_type = NULL, resume_token = NULL, resume_provenance = NULL, resume_last_verified = NULL, resume_last_probe_status = NULL, resume_rotated_from = NULL, resume_rotated_process = NULL WHERE id = ?")
.run(sessionId);
}
@@ -4,7 +4,7 @@ export { inspectStartupStagedText } from "./startup-submission-evidence.js";
import { OutboxHandler } from "./outbox-handler.js";
import type Database from "better-sqlite3";
import type { RigRepository } from "./rig-repository.js";
import type { SessionRegistry } from "./session-registry.js";
import { claudeResumeRotation, type SessionRegistry } from "./session-registry.js";
import type { TmuxAdapter } from "../adapters/tmux.js";
import { latestHookWaitsOnPerson, type AgentActivityStore } from "./agent-activity-store.js";
import type { EventBus } from "./event-bus.js";
@@ -13,7 +13,7 @@ import { wrapPaneEnvelope, appendDeliveredSegment, type EnvelopeScope } from "..
import { getSelfHostId } from "./hosts/fanout-contract.js";
import { SeatIdentityStore } from "./seat-identity-store.js";
import { isShellForeground } from "./shell-classifier.js";
import { observeClaudeDelivery, verifyCodexPaneProcess, type ClaudeDeliveryObservation, type NativeProcessLister } from "./native-process-lineage.js";
import { observeClaudeDelivery, verifyCodexPaneProcess, type ClaudeDeliveryObservation, type ClaudeResumeRotation, type NativeProcessLister } from "./native-process-lineage.js";
import type { SlowOperationInstrumentation } from "./slow-op-recorder.js";
import { hashSentText, type CaptureObserverSink, type CaptureSlot, type ObservationInput, type ObservedBinding } from "./capture-observer.js";
@@ -906,7 +906,8 @@ interface SessionTransportDeps {
interface SessionRow { node_id: string; session_name: string; }
interface NodeRow { rig_id: string; logical_id: string; }
interface SessionMetaRow { runtime: string | null; attachment_type: string | null; node_id: string | null; binding_session: string | null; pane: string | null; occupant: string | null; resume_token: string | null; }
interface SessionMetaRow { runtime: string | null; attachment_type: string | null; node_id: string | null; binding_session: string | null; pane: string | null; occupant: string | null; resume_token: string | null;
resume_provenance: string | null; resume_rotated_from: string | null; resume_rotated_process: string | null; resume_launch_process: string | null; }
interface ResolvedTarget { sessionName: string; rigName: string; nodeLogicalId: string; }
interface AbsenceProbeTarget { session_id: string; node_id: string; session_name: string; tmux_pane: string | null; }
@@ -978,6 +979,7 @@ export class SessionTransport {
private getSessionMeta(sessionName: string): {
runtime: string | null; attachmentType: string | null; nodeId: string | null; pane: string | null; occupant: string | null; resumeToken: string | null;
rotation: ClaudeResumeRotation | null;
} {
// One existing statement; P2 reads the binding columns it already joins plus the
// same current-occupant subselect the delivery guard uses. No extra query.
@@ -989,6 +991,10 @@ export class SessionTransport {
b.tmux_session AS binding_session,
b.tmux_pane AS pane,
s.resume_token AS resume_token,
s.resume_provenance AS resume_provenance,
s.resume_rotated_from AS resume_rotated_from,
s.resume_rotated_process AS resume_rotated_process,
s.resume_launch_process AS resume_launch_process,
(SELECT generation_uuid FROM occupant_tenures t WHERE t.node_id = n.id ORDER BY generation_ordinal DESC LIMIT 1) AS occupant
FROM sessions s
JOIN nodes n ON s.node_id = n.id
@@ -1007,6 +1013,7 @@ export class SessionTransport {
pane: row?.binding_session === sessionName ? row?.pane ?? null : null,
occupant: row?.binding_session === sessionName ? row?.occupant ?? null : null,
resumeToken: row?.resume_token ?? null,
rotation: claudeResumeRotation(row),
};
}
@@ -1309,7 +1316,7 @@ export class SessionTransport {
const checkClaudeTarget = async (): Promise<SendResult | null> => {
if (runtime !== "claude-code") return null;
if (bindingChanged()) return changedRecipient();
const observation = await this.claudeDeliveryObservation(sessionName, sessionMeta.pane, sessionMeta.resumeToken);
const observation = await this.claudeDeliveryObservation(sessionName, sessionMeta.pane, sessionMeta.resumeToken, sessionMeta.rotation);
if (bindingChanged()) return changedRecipient();
if (observation.state === "idle_shell" || observation.state === "conflict") {
return { ok: false, sessionName, sent: false, reason: observation.state === "idle_shell" ? "target_runtime_not_running" : "target_runtime_conflict",
@@ -1824,7 +1831,7 @@ export class SessionTransport {
}
}
private async claudeDeliveryObservation(sessionName: string, pane: string | null, resumeToken: string | null): Promise<ClaudeDeliveryObservation> {
private async claudeDeliveryObservation(sessionName: string, pane: string | null, resumeToken: string | null, rotation: ClaudeResumeRotation | null = null): Promise<ClaudeDeliveryObservation> {
const unknown = { state: "unknown" as const, detail: "Claude runtime observation or older launch binding is unavailable" };
try {
const panes = await this.tmuxAdapter.listPanes(sessionName);
@@ -1834,7 +1841,7 @@ export class SessionTransport {
if (!pane || panes.length === 0) return unknown;
const [sessionPid, panePid] = await Promise.all([this.tmuxAdapter.getPanePid(sessionName), this.tmuxAdapter.getPanePid(pane)]);
if (sessionPid && panePid && sessionPid !== panePid) return { state: "conflict", detail: "The session and bound pane name different processes" };
const observation = await observeClaudeDelivery({ target: pane, tmux: this.tmuxAdapter, listProcesses: this.listProcesses, expectedToken: resumeToken });
const observation = await observeClaudeDelivery({ target: pane, tmux: this.tmuxAdapter, listProcesses: this.listProcesses, expectedToken: resumeToken, rotation });
// Refusal already has positive evidence; a later failed read cannot erase it.
if (observation.state === "conflict" || observation.state === "idle_shell") return observation;
const after = await this.tmuxAdapter.listPanes(sessionName);
@@ -23,6 +23,7 @@ import type { AppliedLaunchObservation } from "./permission-drift.js";
import { resolveReadinessTimeoutMs } from "./readiness-timeout.js";
import { SettingsStore } from "./user-settings/settings-store.js";
import { shellQuote } from "../adapters/shell-quote.js";
import { observeClaudeResumeLaunch, type ClaudeLaunchedProcess, type NativeProcessLister } from "./native-process-lineage.js";
// Expanded startup text can put the current input marker above 50 scrollback lines.
const STARTUP_SUBMIT_CAPTURE_LINES = 200;
@@ -115,6 +116,8 @@ interface StartupOrchestratorDeps {
/** Sleep between paste and submit for tmux-driven TUIs. */
sleep?: (ms: number) => Promise<void>;
readinessSettings?: Pick<SettingsStore, "resolveOne">;
/** Process observation for a Claude resume launch's own record of its process (#1077). */
listProcesses?: NativeProcessLister;
}
/**
@@ -146,6 +149,7 @@ export class StartupOrchestrator {
private appliedLaunchStore: AppliedLaunchObservationStore;
private sessionTransport: SessionTransport;
private readinessSettings: Pick<SettingsStore, "resolveOne">;
private listProcesses: NativeProcessLister | undefined;
constructor(deps: StartupOrchestratorDeps) {
if (deps.db !== deps.sessionRegistry.db) throw new Error("StartupOrchestrator: sessionRegistry must share the same db handle");
@@ -157,6 +161,7 @@ export class StartupOrchestrator {
this.readFile = deps.readFile ?? (() => "");
this.sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
this.readinessSettings = deps.readinessSettings ?? new SettingsStore();
this.listProcesses = deps.listProcesses;
this.appliedLaunchStore = new AppliedLaunchObservationStore(deps.db);
this.sessionTransport = new SessionTransport({
db: deps.db,
@@ -169,6 +174,22 @@ export class StartupOrchestrator {
private readFile: (path: string) => string;
/** The process this Claude resume launch started, as the launch itself observes it (#1077): the one
* the adapter's readiness check pinned first, else one observed now. A rotation its hook later
* reports counts only for this process; unobserved, none counts. */
private async recordClaudeResumeLaunch(sessionId: string, target: string | null | undefined, token: string,
pinned: ClaudeLaunchedProcess | undefined): Promise<void> {
try {
if (pinned) {
this.sessionRegistry.recordResumeLaunchProcess(sessionId, token, pinned);
return;
}
if (!target) return;
const launched = await observeClaudeResumeLaunch({ target, tmux: this.tmuxAdapter, listProcesses: this.listProcesses, token });
if (launched) this.sessionRegistry.recordResumeLaunchProcess(sessionId, token, launched);
} catch { /* best-effort: without it a rotation stays unproved, as before */ }
}
async startNode(input: StartupInput): Promise<StartupResult> {
const warnings: string[] = [];
const result = await this.startNodeWithWarnings(input, warnings);
@@ -298,6 +319,12 @@ export class StartupOrchestrator {
let attemptedFreshFallback = false;
while (true) {
if (input.adapter.runtime === "claude-code") {
// Before the launch: Claude's SessionStart hook can land before launchHarness returns.
try {
this.sessionRegistry.recordResumeLaunch(input.sessionId, launchResumeToken ?? null);
} catch { /* best-effort: without it a rotation stays unproved, as before */ }
}
const launchResult = await input.adapter.launchHarness(input.binding, {
name: input.sessionName ?? input.binding.tmuxSession ?? "",
resumeToken: launchResumeToken,
@@ -305,6 +332,9 @@ export class StartupOrchestrator {
});
if (launchResult.ok) {
appliedLaunch = launchResult.appliedLaunch;
if (input.adapter.runtime === "claude-code" && launchResumeToken?.trim()) {
await this.recordClaudeResumeLaunch(input.sessionId, input.binding.tmuxPane ?? input.binding.tmuxSession, launchResumeToken.trim(), launchResult.launchedProcess);
}
const notice = nonInterruptiveNotice(input.adapter.runtime, input.binding);
if (notice) warnings.push(`${input.sessionName ?? input.nodeId}: ${notice}`);
const normalizedResumeToken = launchResult.resumeToken?.trim();
@@ -351,6 +381,10 @@ export class StartupOrchestrator {
);
} catch { /* best-effort */ }
}
// The launch is retained for reconciliation; record its process as it does when ready.
if (input.adapter.runtime === "claude-code" && normalizedResumeToken) {
await this.recordClaudeResumeLaunch(input.sessionId, input.binding.tmuxPane ?? input.binding.tmuxSession, normalizedResumeToken, launchResult.launchedProcess);
}
errors.push(`Harness launch requires attention: ${launchResult.error}`);
// isRestore selects context, not native continuity: pod-aware exact
// resume also uses false. Only an actual fresh launch may re-prime.
@@ -528,7 +562,9 @@ export class StartupOrchestrator {
if (!input.skipHarnessLaunch && input.adapter.runtime === "claude-code"
&& continuityOutcome === "resumed" && input.resumeToken
&& ((input.resumeType !== undefined && !isClaudeResumeType(input.resumeType))
|| !this.sessionRegistry.resumeTokenMatches(input.sessionId, "claude_id", input.resumeToken.trim()))) {
|| !(this.sessionRegistry.resumeTokenMatches(input.sessionId, "claude_id", input.resumeToken.trim())
// Claude continued this resume under a new id; its current-generation hook said so.
|| this.sessionRegistry.claudeResumeRotatedFrom(input.sessionId, input.resumeToken)))) {
return this.fail(deliveryInput, "attention_required", [
"Native resume was observed but its requested type or current session metadata conflicts or could not be retained; session preserved.",
]);
+32 -1
View File
@@ -10,6 +10,7 @@ import type { AgentActivity } from "../domain/types.js";
import * as parkedQuery from "../domain/parked-query.js";
import { runtimeRungInventory } from "../domain/activity-taxonomy.js";
import { validateResumeToken } from "../domain/resume-token-validation.js";
import { claudeHookFromLaunchedProcess, type ClaudeLaunchedProcess, type NativeProcessLister } from "../domain/native-process-lineage.js";
import { transportSenderSession } from "./require-sender-identity.js";
// ── S19 A4 — the ingest half of the adapter seam: hook events reach the ONE oracle ──
@@ -215,8 +216,38 @@ activityRoutes.post("/hooks", async (c) => {
// tokenPersisted reports the stored state, not format validity: a higher-provenance token
// (operator) refuses the hook write, which only counts as persisted when it already matches.
const validation = validateResumeToken(runtime, sessionId);
// A Claude hook also says how the session began. That only counts as evidence about this
// seat's launch when the post carries the node's current occupant generation (#1077).
const generation = stringOrNull(body.generation);
let currentGeneration = false;
try {
currentGeneration = !!generation && sessionRegistry.currentOccupantTenure(resolved.nodeId)?.generationUuid === generation;
} catch { /* an unreadable ledger is no evidence */ }
const source = stringOrNull(body.source);
const resumeLaunch = stringOrNull(body.resumeLaunch);
// Only for a hook that could name the armed launch: was it the launch's first SessionStart (the
// relay records that locally, so a first hook lost in delivery still counts), and was it sent
// by the process OpenRig launched?
let launchedProcess: ClaudeLaunchedProcess | null = null;
const armed = runtime === "claude-code" && currentGeneration && source === "resume" && resumeLaunch
&& body.resumeLaunchFirst === true ? sessionRegistry.armedResumeLaunch(resolved.sessionId) : null;
const hookPid = typeof body.hookPid === "number" && Number.isInteger(body.hookPid) && body.hookPid > 0 ? body.hookPid : null;
const tmux = c.get("tmuxAdapter" as never) as { getPanePid(target: string): Promise<number | null> } | undefined;
if (armed && armed === resumeLaunch?.trim() && hookPid && tmux) {
const binding = sessionRegistry.getBindingForNode(resolved.nodeId);
const target = binding?.tmuxPane ?? binding?.tmuxSession;
if (target) {
launchedProcess = await claudeHookFromLaunchedProcess({ target, tmux, launchToken: armed, hookPid,
listProcesses: c.get("listProcesses" as never) as NativeProcessLister | undefined });
}
}
const persistHook = (type: string, token: string): boolean => {
if (runtime !== "claude-code") return sessionRegistry.updateResumeToken(resolved.sessionId, type, token, "hook");
return sessionRegistry.recordHookSessionIdentity(resolved.sessionId, type, token,
{ source, currentGeneration, resumeLaunch, launchedProcess });
};
const tokenPersisted = validation.ok
&& (sessionRegistry.updateResumeToken(resolved.sessionId, validation.resumeType, validation.token, "hook")
&& (persistHook(validation.resumeType, validation.token)
|| sessionRegistry.resumeTokenMatches(resolved.sessionId, validation.resumeType, validation.token));
eventBus.emit({
type: "agent.session_identity",
@@ -4,7 +4,7 @@ import { createDb } from "../src/db/connection.js";
import { migrate } from "../src/db/migrate.js";
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
import { RigRepository } from "../src/domain/rig-repository.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
import { CLAUDE_RESUME_ROTATION_COLUMNS, SessionRegistry, claudeResumeRotation } from "../src/domain/session-registry.js";
import { SessionTransport } from "../src/domain/session-transport.js";
import { QueueRepository } from "../src/domain/queue-repository.js";
import { OutboxHandler } from "../src/domain/outbox-handler.js";
@@ -17,6 +17,7 @@ import { observeClaudeDelivery, verifyClaudePaneProcess } from "../src/domain/na
const original = "00000000-0000-4000-8000-000000000596";
const rotated = "00000000-0000-4000-8000-000000000597";
const third = "00000000-0000-4000-8000-000000000598";
const name = "test-c@rotation";
const screen = "─────────\n❯\u00a0\n─────────\n ⏵⏵ auto mode on (shift+tab to cycle) · ← for agents\n";
@@ -24,7 +25,15 @@ type Mode = "unchanged" | "rotation" | "duplicate-hook" | "delayed-hook" | "shim
| "unknown" | "foreign-runtime" | "changed-process" | "changed-occupant"
| "changed-pane" | "changed-after-paste" | "stale-hook-foreign-runtime"
| "N1-settings-before" | "settings-after" | "plain-wrong-token" | "lone-wrong-shim"
| "delayed-rotated-hook" | "probe-refresh";
| "delayed-rotated-hook" | "probe-refresh"
| SourcedMode;
// #1077 — the hook also reports SessionStart's source and the seat's occupant generation.
// It also forwards OPENRIG_RESUME_LAUNCH, which only the command OpenRig sent carries.
const sourcedModes = ["resume-rotation", "resume-then-clear", "clear-sourced", "startup-sourced",
"compact-sourced", "resume-stale-generation", "resume-no-source",
"resume-no-marker", "resume-other-marker", "resume-after-launch-hook", "resume-clear-resume",
"resume-child-missed-parent", "resume-in-process-missed-first"] as const;
type SourcedMode = typeof sourcedModes[number];
const tokenOnlyModes = ["N1-settings-before", "settings-after", "plain-wrong-token", "lone-wrong-shim"] as const;
// The hook, SQLite registry and transport are real. Only process/tmux observations
@@ -39,9 +48,31 @@ async function sendAfterHook(mode: Mode, consumer: "send" | "wake" | "handoff" =
const session = registry.registerSession(node.id, name);
registry.updateStatus(session.id, "running");
registry.updateBinding(node.id, { tmuxSession: name, tmuxPane: "%1" });
// OpenRig launched this seat with `--resume original`.
registry.recordResumeLaunch(session.id, original);
// ... and observed the process it started (102 below) right after the launch.
registry.recordResumeLaunchProcess(session.id, original, { pid: 102, startedAt: "Sat Oct 3 01:00:00 2026" });
const store = new AgentActivityStore({ db, eventBus });
const app = new Hono();
// The process table while a SessionStart hook runs: the pane's launched Claude (102) ran the
// relay through a shell (110 -> 111); a `claude -p --resume` its Bash tool started (120, own
// process group) ran its own relay (121 -> 122), inheriting the launch environment.
// The same processes the delivery checks below observe (102 keeps its start time).
const hookStartedAt = "Sat Oct 3 01:00:00 2026";
const hookRows: NativeProcessRow[] = [
{ pid: 100, ppid: 1, pgid: 100, tpgid: 101, executableName: "bash", command: "-bash", startedAt: hookStartedAt },
{ pid: 101, ppid: 100, pgid: 101, tpgid: 101, executableName: "sh", command: "/bin/sh /fixture/launch", startedAt: hookStartedAt },
{ pid: 102, ppid: 101, pgid: 101, tpgid: 101, executableName: "claude", command: `/opt/claude --resume ${original}`, startedAt: hookStartedAt },
{ pid: 110, ppid: 102, pgid: 110, tpgid: 101, executableName: "sh", command: "/bin/sh -c node relay.cjs", startedAt: hookStartedAt },
{ pid: 111, ppid: 110, pgid: 110, tpgid: 101, executableName: "node", command: "node relay.cjs", startedAt: hookStartedAt },
{ pid: 115, ppid: 102, pgid: 115, tpgid: 101, executableName: "bash", command: "/bin/bash -c claude -p --resume", startedAt: hookStartedAt },
{ pid: 120, ppid: 115, pgid: 115, tpgid: 101, executableName: "claude", command: `/opt/claude -p --resume ${original}`, startedAt: hookStartedAt },
{ pid: 121, ppid: 120, pgid: 115, tpgid: 101, executableName: "sh", command: "/bin/sh -c node relay.cjs", startedAt: hookStartedAt },
{ pid: 122, ppid: 121, pgid: 115, tpgid: 101, executableName: "node", command: "node relay.cjs", startedAt: hookStartedAt },
];
app.use("*", async (c, next) => {
c.set("tmuxAdapter" as never, { getPanePid: async () => 100 } as never);
c.set("listProcesses" as never, (() => hookRows) as never);
c.set("agentActivityStore" as never, store as never);
c.set("activityHookToken" as never, "fixture" as never);
c.set("sessionRegistry" as never, registry as never);
@@ -49,11 +80,12 @@ async function sendAfterHook(mode: Mode, consumer: "send" | "wake" | "handoff" =
await next();
});
app.route("/api/activity", activityRoutes);
const hook = async (token: string, occurredAt?: string) => {
const generation = registry.currentOccupantTenure(node.id)!.generationUuid;
const hook = async (token: string, occurredAt?: string, evidence: { source?: string; generation?: string; resumeLaunch?: string; hookPid?: number; resumeLaunchFirst?: boolean } = {}) => {
const response = await app.request("/api/activity/hooks", {
method: "POST",
headers: { "content-type": "application/json", "x-openrig-activity-token": "fixture" },
body: JSON.stringify({ eventFamily: "session_identity", sessionName: name, runtime: "claude-code", sessionId: token, occurredAt }),
body: JSON.stringify({ eventFamily: "session_identity", sessionName: name, runtime: "claude-code", sessionId: token, occurredAt, hookPid: 111, ...evidence }),
});
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({ tokenPersisted: true });
@@ -62,7 +94,28 @@ async function sendAfterHook(mode: Mode, consumer: "send" | "wake" | "handoff" =
// These mismatched fixtures never rotate: even an initial hook is not proof
// of a later conversation transition. Their ordinary delivery is unverified.
const tokenOnly = tokenOnlyModes.some(value => value === mode) || mode === "probe-refresh";
if (mode !== "unchanged" && !tokenOnly) {
const sourced = sourcedModes.some(value => value === mode);
if (sourced) {
const source = mode === "clear-sourced" ? "clear" : mode === "startup-sourced" ? "startup"
: mode === "compact-sourced" ? "compact" : mode === "resume-no-source" ? undefined : "resume";
// A Claude started by hand in the pane carries no marker; a stale one names another launch.
const resumeLaunch = mode === "resume-no-marker" ? undefined : mode === "resume-other-marker" ? third : original;
// The launched process's own first hook kept its id. What follows is an in-process /resume,
// or a child `claude -p --resume` that inherited the seat's environment, marker included.
if (mode === "resume-after-launch-hook") await hook(original, undefined, { source: "resume", generation, resumeLaunch });
// resume-child-missed-parent: the launched process's own hook never reached the daemon; the
// first one to arrive is the child's, with the inherited marker and generation.
const hookPid = mode === "resume-after-launch-hook" || mode === "resume-child-missed-parent" ? 122 : 111;
// The relay claims the launch's first SessionStart on disk before posting, so a later hook
// finds it claimed: resume-in-process-missed-first is the launched process's in-process /resume
// after its own first hook was lost in delivery. resume-child-missed-parent takes the worst
// case, a parent whose relay never ran, so the child claims first and only process binding
// refuses it.
const resumeLaunchFirst = !(mode === "resume-after-launch-hook" || mode === "resume-in-process-missed-first");
await hook(rotated, undefined, { source, generation: mode === "resume-stale-generation" ? "an-earlier-generation" : generation, resumeLaunch, hookPid, resumeLaunchFirst });
if (mode === "resume-then-clear" || mode === "resume-clear-resume") await hook(third, undefined, { source: "clear", generation });
if (mode === "resume-clear-resume") await hook(rotated, undefined, { source: "resume", generation, resumeLaunch });
} else if (mode !== "unchanged" && !tokenOnly) {
await hook(rotated, mode === "delayed-rotated-hook" ? "2000-01-01T00:00:00Z" : undefined);
}
if (mode === "duplicate-hook") await hook(rotated);
@@ -136,8 +189,11 @@ async function sendAfterHook(mode: Mode, consumer: "send" | "wake" | "handoff" =
expect(send).toHaveBeenCalledTimes(1);
const result = await send.mock.results[0]!.value;
const stored = db.prepare("SELECT resume_token FROM sessions WHERE id = ?").get(session.id) as { resume_token: string };
const rotation = db.prepare(`SELECT ${CLAUDE_RESUME_ROTATION_COLUMNS} FROM sessions WHERE id = ?`)
.get(session.id) as Parameters<typeof claudeResumeRotation>[0];
// Independent of delivery effects: strict identity stays unproved for a mismatch.
const input = { target: "%1", tmux, listProcesses: async () => rows, expectedToken: stored.resume_token };
const input = { target: "%1", tmux, listProcesses: async () => rows, expectedToken: stored.resume_token,
rotation: claudeResumeRotation(rotation) };
const observed = await observeClaudeDelivery(input);
const strict = await verifyClaudePaneProcess(input);
return { result, calls, stored, observed, strict };
@@ -195,3 +251,27 @@ describe("ordinary Claude delivery after an in-process conversation change (#596
expect(calls).toEqual(["text"]);
});
});
describe("a resumed conversation that changed its id, with the hook's evidence (#1077)", () => {
it("resume-rotation: the hook said resume for this generation, so send verifies the seat", async () => {
const { result, calls, stored, observed, strict } = await sendAfterHook("resume-rotation");
expect(stored.resume_token).toBe(rotated);
expect(observed.state).toBe("verified");
expect(strict).not.toBeNull();
expect(result.ok).toBe(true);
expect(result.warning ?? "").not.toContain("without verified native identity");
expect(calls).toEqual(["text", "enter"]);
});
it.each(["clear-sourced", "resume-then-clear", "startup-sourced", "compact-sourced", "resume-stale-generation", "resume-no-source",
"resume-no-marker", "resume-other-marker", "resume-after-launch-hook", "resume-clear-resume", "resume-child-missed-parent",
"resume-in-process-missed-first"] as const)(
"%s keeps today's warning: the conversation is unverified", async mode => {
const { result, calls, observed, strict } = await sendAfterHook(mode);
expect(observed.state).toBe("unknown");
expect(strict).toBeNull();
expect(result.ok).toBe(true);
expect(result.warning).toContain("current conversation is unverified");
expect(calls).toEqual(["text", "enter"]);
});
});
@@ -0,0 +1,103 @@
// #1077 — dev-review's reproduction against 2d04850b: a child `claude -p --resume` inherits the launch
// marker and generation; if the launched process's own first hook is missed, the child's hook must still
// not rotate the seat's identity. Control: the parent's hook delivered first.
import { describe, expect, it, vi } from "vitest";
import { Hono } from "hono";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { createFullTestDb } from "./helpers/test-app.js";
import { RigRepository } from "../src/domain/rig-repository.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
import { EventBus } from "../src/domain/event-bus.js";
import { AgentActivityStore } from "../src/domain/agent-activity-store.js";
import { SeatIdentityReconciler } from "../src/domain/seat-identity-reconciler.js";
import { SeatIdentityStore } from "../src/domain/seat-identity-store.js";
import { SeatAttentionReconciler } from "../src/domain/seat-attention-reconciler.js";
import { SessionTransport } from "../src/domain/session-transport.js";
import { activityRoutes } from "../src/routes/activity.js";
import { sessionAdminRoutes } from "../src/routes/sessions.js";
import { claudeResumeLaunchEnv } from "../src/adapters/claude-resume-launch.js";
import type { TmuxAdapter } from "../src/adapters/tmux.js";
const T1 = "00000000-0000-4000-8000-000000001080";
const T2 = "00000000-0000-4000-8000-000000001081";
const relayPath = fileURLToPath(new URL("../assets/plugins/openrig-core/hooks/scripts/activity-relay.cjs", import.meta.url));
describe("resume launch marker binds the reporting process", () => {
it.each([true, false])("a child cannot rotate the parent identity (parent first hook delivered: %s)", async delivered => {
const db = createFullTestDb();
try {
const rigRepo = new RigRepository(db), registry = new SessionRegistry(db), eventBus = new EventBus(db);
const rig = rigRepo.createRig("process-review");
const node = rigRepo.addNode(rig.id, "dev.worker", { runtime: "claude-code" });
const name = "dev-worker@process-review", session = registry.registerSession(node.id, name);
registry.updateStatus(session.id, "running");
registry.updateStartupStatus(session.id, "attention_required");
registry.updateBinding(node.id, { tmuxSession: name, tmuxPane: "%1" });
registry.recordResumeLaunch(session.id, T1);
registry.updateResumeToken(session.id, "claude_id", T1, "scrape");
const generation = registry.currentOccupantTenure(node.id)!.generationUuid;
const activity = new AgentActivityStore({ db, eventBus });
const app = new Hono();
app.use("*", async (c, next) => {
c.set("agentActivityStore" as never, activity as never);
c.set("activityHookToken" as never, "fixture" as never);
c.set("sessionRegistry" as never, registry as never);
c.set("eventBus" as never, eventBus as never);
await next();
});
app.route("/api/activity", activityRoutes);
const postHook = async (payload: unknown) => {
const response = await app.request("/api/activity/hooks", { method: "POST", headers: { "content-type": "application/json", "x-openrig-activity-token": "fixture" }, body: JSON.stringify(payload) });
expect(response.status).toBe(200);
};
if (delivered) await postHook({ eventFamily: "session_identity", hookEvent: "SessionStart", runtime: "claude-code", sessionName: name, nodeId: node.id, sessionId: T1, source: "resume", generation, resumeLaunch: T1 });
// Real OS parent -> child inheritance, using only fixture environment. The
// child invokes the production relay payload builder. No Claude is launched.
const childScript = `const relay = require(${JSON.stringify(relayPath)}); process.stdout.write(JSON.stringify(relay.buildSessionIdentityPayload({ hook_event_name: 'SessionStart', session_id: ${JSON.stringify(T2)}, source: 'resume' })));`;
const parentScript = `const child = require('node:child_process').spawnSync(process.execPath, ['-e', ${JSON.stringify(childScript)}], {encoding:'utf8'}); if(child.status !== 0) throw Error(child.stderr); process.stdout.write(child.stdout);`;
const inherited = spawnSync(process.execPath, ["-e", parentScript], { encoding: "utf8", env: {
...claudeResumeLaunchEnv(T1), OPENRIG_SESSION_NAME: name, OPENRIG_NODE_ID: node.id,
OPENRIG_RUNTIME: "claude-code", OPENRIG_OCCUPANT_GENERATION: generation,
} });
expect(inherited.status, inherited.stderr).toBe(0);
const childPayload = JSON.parse(inherited.stdout);
expect(childPayload).toMatchObject({ sessionId: T2, resumeLaunch: T1, generation });
await postHook(childPayload);
// The child has finished. The original parent still occupies the pane and
// has never changed conversation: argv and the stable process are still T1.
const startedAt = "Fri Oct 9 12:00:00 2026";
const listProcesses = async () => [
{ pid: 100, ppid: 1, pgid: 100, tpgid: 101, executableName: "bash", command: "bash", startedAt },
{ pid: 101, ppid: 100, pgid: 101, tpgid: 101, executableName: "claude", command: `/fixture/claude --resume ${T1}`, startedAt },
];
const tmux = {
listSessions: vi.fn(async () => [{ name }]), listPanes: vi.fn(async () => [{ id: "%1" }]),
getPanePid: vi.fn(async () => 100), getPaneCommand: vi.fn(async () => "bash"),
hasSession: vi.fn(async () => true), probeSession: vi.fn(async () => ({ state: "present" })),
capturePaneContent: vi.fn(async () => "Claude Code\n❯"),
sendText: vi.fn(async () => ({ ok: true })), sendKeys: vi.fn(async () => ({ ok: true })),
} as unknown as TmuxAdapter;
await new SeatIdentityReconciler({ db, tmux, listProcesses }).reconcileAll();
const identity = new SeatIdentityStore(db).getForNode(node.id);
// Exercise the public recovery route from its own recorded mismatch,
// rather than erase that attention class with the preceding polling probe.
new SeatIdentityStore(db).upsert({ nodeId: node.id, sessionName: name, verdict: "mismatch", evidenceSource: "pane_process", reason: "process_identity_mismatch", observedAt: new Date().toISOString(), evidence: { registeredPane: "%1", observedPid: 100, observedCommand: "bash", matchedLayer: null } });
const clear = new SeatAttentionReconciler({ db, sessionRegistry: registry, eventBus, agentActivityStore: activity, tmux, listProcesses, sendVerify: async () => { throw new Error("unexpected input"); } });
const admin = new Hono();
admin.use("*", async (c,next) => { c.set("seatAttentionReconciler" as never, clear as never); c.set("terminalBearerToken" as never, "fixture" as never); await next(); });
admin.route("/api/sessions", sessionAdminRoutes);
const cleared = await admin.request(`/api/sessions/${encodeURIComponent(name)}/clear-attention`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: "Bearer fixture" }, body: "{}" });
const clearBody = await cleared.json();
const sent = await new SessionTransport({ db, rigRepo, sessionRegistry: registry, eventBus, tmuxAdapter: tmux, listProcesses, sleep: async () => {} }).send(name, "offline review");
const stored = db.prepare("SELECT resume_token, resume_launch_token, resume_rotated_from, startup_status FROM sessions WHERE id = ?").get(session.id);
expect.soft(stored).toMatchObject({ resume_rotated_from: null });
expect.soft(identity?.verdict).toBe("mismatch");
expect.soft(cleared.status).toBe(422);
expect.soft((sent as { warning?: string }).warning ?? "").toContain("without verified native identity");
} finally { db.close(); }
});
});
@@ -0,0 +1,275 @@
// #1077 — a resumed Claude seat whose session id changes. OpenRig launched the seat with
// `--resume T1`, Claude continued as T2 and its SessionStart hook recorded T2. The proof accepts T1
// in argv only when the first current-generation hook after that launch said `source: resume`.
// Every other hook (a /clear, startup or compaction, no source, a stale generation, and anything
// after the first) keeps today's refusal.
import { createRequire } from "node:module";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type Database from "better-sqlite3";
import { createDb } from "../src/db/connection.js";
import { migrate } from "../src/db/migrate.js";
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
import { RigRepository } from "../src/domain/rig-repository.js";
import { CLAUDE_RESUME_ROTATION_COLUMNS, SessionRegistry, claudeResumeRotation } from "../src/domain/session-registry.js";
import { verifyClaudePaneProcess, observeClaudeDelivery, type NativeProcessRow } from "../src/domain/native-process-lineage.js";
const require = createRequire(import.meta.url);
const relay = require("../assets/plugins/openrig-core/hooks/scripts/activity-relay.cjs") as {
buildSessionIdentityPayload(payload: Record<string, unknown>, env: Record<string, string>): Record<string, unknown> | null;
};
const T1 = "00000000-0000-4000-8000-000000001077";
const T2 = "00000000-0000-4000-8000-000000001078";
const T3 = "00000000-0000-4000-8000-000000001079";
const launchedClaude = { pid: 4242, startedAt: "Fri Oct 9 01:00:00 2026" };
const databases: Database.Database[] = [];
afterEach(() => { for (const db of databases.splice(0)) db.close(); });
function seat(opts: { armed?: boolean; launchWrite?: boolean; launchObserved?: typeof launchedClaude | null } = {}) {
const db = createDb(); databases.push(db);
migrate(db, ALL_MIGRATIONS);
const rigRepo = new RigRepository(db), registry = new SessionRegistry(db);
const rig = rigRepo.createRig("rotation");
const node = rigRepo.addNode(rig.id, "dev.impl", { runtime: "claude-code" });
const session = registry.registerSession(node.id, "dev-impl@rotation");
// OpenRig arms the launch before starting `claude --resume T1` (startup-orchestrator, legacy restore).
if (opts.armed !== false) registry.recordResumeLaunch(session.id, T1);
// The launch path's own record of the process it started on T1 (null: it could not observe one).
const launchObserved = opts.launchObserved === undefined ? launchedClaude : opts.launchObserved;
if (opts.armed !== false && launchObserved) registry.recordResumeLaunchProcess(session.id, T1, launchObserved);
// The launch path may write T1 afterwards (provenance scrape); the hook can also land first.
if (opts.launchWrite !== false) registry.updateResumeToken(session.id, "claude_id", T1, "scrape");
const generation = registry.currentOccupantTenure(node.id)!.generationUuid;
const row = () => db.prepare(
"SELECT resume_token, resume_provenance, resume_launch_token, resume_rotated_from FROM sessions WHERE id = ?",
).get(session.id) as { resume_token: string; resume_provenance: string; resume_launch_token: string | null; resume_rotated_from: string | null };
// By default the hook comes from the process OpenRig launched, whose command carried the marker.
const hook = (token: string, source: string | null, currentGeneration = true, resumeLaunch: string | null = T1, launchedProcess = true) =>
registry.recordHookSessionIdentity(session.id, "claude_id", token,
{ source, currentGeneration, resumeLaunch, launchedProcess: launchedProcess ? launchedClaude : null });
const rotation = () => claudeResumeRotation(db.prepare(
`SELECT ${CLAUDE_RESUME_ROTATION_COLUMNS} FROM sessions WHERE id = ?`,
).get(session.id) as Parameters<typeof claudeResumeRotation>[0]);
const rotatedFrom = () => rotation()?.token ?? null;
return { db, registry, session, node, generation, row, hook, rotation, rotatedFrom };
}
describe("the first hook after OpenRig's --resume launch", () => {
// dev-review's reproduction against 21c55946: with the first post lost and the claim removed, a
// replacement's own hook counts as first. Only the launch path's record of its process tells them apart.
it.each([
["the launch observed another process (a replacement sent the hook)", { pid: 4243, startedAt: launchedClaude.startedAt }],
["the launch observed a reused pid with another start time", { pid: launchedClaude.pid, startedAt: "Fri Oct 9 00:59:00 2026" }],
["the launch could not observe its process", null],
] as const)("no rotation when %s", (_label, launchObserved) => {
const s = seat({ launchObserved });
expect(s.hook(T2, "resume")).toBe(true);
expect(s.rotation()).toBeNull();
expect(s.registry.claudeResumeRotatedFrom(s.session.id, T1)).toBe(false);
});
it("an early hook counts once the launch records the same process, and re-arming drops it", () => {
const s = seat({ launchObserved: null });
expect(s.hook(T2, "resume")).toBe(true);
expect(s.rotation()).toBeNull();
s.registry.recordResumeLaunchProcess(s.session.id, T1, launchedClaude);
expect(s.rotation()).toEqual({ token: T1, process: launchedClaude });
s.registry.recordResumeLaunch(s.session.id, T2);
expect(s.rotation()).toBeNull();
});
it.each([true, false])("a resume into a new id names the launch token (launch wrote T1 first: %s)", (launchWrite) => {
const s = seat({ launchWrite });
expect(s.hook(T2, "resume")).toBe(true);
expect(s.row()).toEqual({ resume_token: T2, resume_provenance: "hook", resume_launch_token: null, resume_rotated_from: T1 });
expect(s.rotatedFrom()).toBe(T1);
// The rotation keeps the process that qualified it; a later token change drops both.
expect(s.rotation()?.process).toEqual(launchedClaude);
expect(s.registry.claudeResumeRotatedFrom(s.session.id, T1)).toBe(true);
s.registry.updateResumeToken(s.session.id, "claude_id", T3, "operator");
expect(s.db.prepare("SELECT resume_rotated_from, resume_rotated_process FROM sessions WHERE id = ?").get(s.session.id))
.toEqual({ resume_rotated_from: null, resume_rotated_process: null });
});
it.each(["clear", "startup", "compact", null])("source %s consumes the launch and names nothing", (source) => {
const s = seat();
s.hook(T2, source);
expect(s.row()).toMatchObject({ resume_token: T2, resume_launch_token: null, resume_rotated_from: null });
// A resume after that (in-process /resume, a child `claude -p --resume`) has no launch to name.
s.hook(T3, "resume");
expect(s.rotatedFrom()).toBeNull();
});
it("a resume that kept the id consumes the launch; a later in-process /resume names nothing", () => {
const s = seat();
s.hook(T1, "resume");
expect(s.row()).toMatchObject({ resume_token: T1, resume_launch_token: null, resume_rotated_from: null });
s.hook(T2, "resume");
expect(s.rotatedFrom()).toBeNull();
});
it.each([
["a Claude started by hand in the pane (no marker), or a relay that predates it", null],
["a marker naming another launch", T3],
] as const)("%s consumes the launch and names nothing", (_label, marker) => {
const s = seat();
s.hook(T2, "resume", true, marker);
expect(s.row()).toMatchObject({ resume_token: T2, resume_launch_token: null, resume_rotated_from: null });
s.hook(T3, "resume");
expect(s.rotatedFrom()).toBeNull();
});
it("a hook not observed to come from the launched process (a child claude -p inheriting the marker) names nothing", () => {
const s = seat();
s.hook(T2, "resume", true, T1, false);
expect(s.row()).toMatchObject({ resume_token: T2, resume_launch_token: null, resume_rotated_from: null });
});
it("a stale-generation hook neither qualifies nor consumes the launch", () => {
const s = seat();
s.hook(T3, "resume", false);
expect(s.row()).toMatchObject({ resume_launch_token: T1, resume_rotated_from: null });
s.hook(T2, "resume");
expect(s.rotatedFrom()).toBe(T1);
});
it("a hook the provenance rank refuses still consumes the launch and records nothing", () => {
const s = seat();
s.registry.updateResumeToken(s.session.id, "claude_id", T1, "operator");
expect(s.hook(T2, "resume")).toBe(false);
expect(s.row()).toMatchObject({ resume_token: T1, resume_launch_token: null, resume_rotated_from: null });
});
it("without an armed launch (fresh, or a process OpenRig did not launch) nothing qualifies", () => {
const fresh = seat({ armed: false });
fresh.hook(T2, "resume");
expect(fresh.rotatedFrom()).toBeNull();
const freshFallback = seat();
freshFallback.registry.recordResumeLaunch(freshFallback.session.id, null);
freshFallback.hook(T2, "resume");
expect(freshFallback.rotatedFrom()).toBeNull();
});
});
describe("after a recorded rotation", () => {
const rotated = () => {
const s = seat();
s.hook(T2, "resume");
return s;
};
it.each([
["an in-process /resume", "resume"],
["a child claude -p --resume sharing the seat's environment", "resume"],
["a /clear", "clear"],
] as const)("%s drops it and names nothing new", (_label, source) => {
const s = rotated();
s.hook(T3, source);
expect(s.row()).toMatchObject({ resume_token: T3, resume_rotated_from: null });
expect(s.rotatedFrom()).toBeNull();
});
it("a later hook for the same id (a compaction) keeps it", () => {
const s = rotated();
s.hook(T2, "compact");
expect(s.rotatedFrom()).toBe(T1);
});
it("an operator token, or any other write that changes the token, drops it", () => {
const s = rotated();
s.registry.updateResumeToken(s.session.id, "claude_id", T3, "operator");
expect(s.row()).toMatchObject({ resume_token: T3, resume_provenance: "operator", resume_rotated_from: null });
expect(s.rotatedFrom()).toBeNull();
});
it("an equal-value refresh or a later launch-path write of T1 keeps it", () => {
const s = rotated();
s.registry.updateResumeToken(s.session.id, "claude_id", T2, "hook");
s.registry.updateResumeToken(s.session.id, "claude_id", T1, "scrape");
s.registry.recordResumeAttempt(s.session.id, "claude_id", T1);
expect(s.rotatedFrom()).toBe(T1);
});
});
describe("the relay forwards the evidence", () => {
it("sends SessionStart's source, the occupant generation and the launch marker with the session id", () => {
const payload = relay.buildSessionIdentityPayload(
{ hook_event_name: "SessionStart", session_id: T2, source: "resume" },
{ OPENRIG_SESSION_NAME: "dev-impl@rotation", OPENRIG_RUNTIME: "claude-code", OPENRIG_OCCUPANT_GENERATION: "gen-1",
OPENRIG_RESUME_LAUNCH: T1 },
);
expect(payload).toMatchObject({ eventFamily: "session_identity", sessionId: T2, source: "resume", generation: "gen-1", resumeLaunch: T1,
hookPid: process.pid });
});
it("claims the launch's first SessionStart once, on disk, per launch token and generation", () => {
const dir = mkdtempSync(join(tmpdir(), "openrig-relay-claim-"));
try {
expect(relay.claimFirstLaunchHook(T1, "gen-1", dir)).toBe(true);
// A later hook of the same launch (in-process /resume, a child) finds it claimed.
expect(relay.claimFirstLaunchHook(T1, "gen-1", dir)).toBe(false);
expect(relay.claimFirstLaunchHook(T1, "gen-2", dir)).toBe(true);
expect(relay.claimFirstLaunchHook(null, "gen-1", dir)).toBe(false);
expect(relay.claimFirstLaunchHook(T1, null, dir)).toBe(false);
// An unwritable record never reads as first.
expect(relay.claimFirstLaunchHook(T2, "gen-1", join(dir, "missing\0dir"))).toBe(false);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
it("sends nulls when the runtime or launch did not provide them", () => {
const payload = relay.buildSessionIdentityPayload(
{ hook_event_name: "SessionStart", session_id: T2 },
{ OPENRIG_SESSION_NAME: "dev-impl@rotation", OPENRIG_RUNTIME: "claude-code" },
);
expect(payload).toMatchObject({ sessionId: T2, source: null, generation: null, resumeLaunch: null });
});
});
describe("the identity proof", () => {
const startedAt = "Fri Oct 9 01:00:00 2026";
const rows = (argvToken: string): NativeProcessRow[] => [
{ pid: 100, ppid: 1, pgid: 100, tpgid: 101, executableName: "bash", command: "-bash", startedAt },
{ pid: 101, ppid: 100, pgid: 101, tpgid: 101, executableName: "claude", command: `claude --resume ${argvToken}`, startedAt },
];
const tmux = { getPanePid: async () => 100 };
// The rotation names the process that qualified it: 101 at its start time.
const proof = (argvToken: string, rotatedFrom: string | null, qualified = { pid: 101, startedAt }) => ({
target: "%1", tmux, listProcesses: async () => rows(argvToken), expectedToken: T2,
rotation: rotatedFrom ? { token: rotatedFrom, process: qualified } : null,
});
it("accepts the launch token a resume replaced", async () => {
expect(await verifyClaudePaneProcess(proof(T1, T1))).not.toBeNull();
expect(await observeClaudeDelivery(proof(T1, T1))).toMatchObject({ state: "verified" });
});
it("still accepts the stored token itself", async () => {
expect(await verifyClaudePaneProcess(proof(T2, T1))).not.toBeNull();
});
it("refuses the launch token without a recorded resume (the /clear case)", async () => {
expect(await verifyClaudePaneProcess(proof(T1, null))).toBeNull();
expect(await observeClaudeDelivery(proof(T1, null))).toMatchObject({ state: "unknown" });
});
it.each([
["another pid", { pid: 202, startedAt }],
["a reused pid with another start time", { pid: 101, startedAt: "Fri Oct 9 02:00:00 2026" }],
])("refuses the launch token in a process other than the qualified one (%s)", async (_label, qualified) => {
expect(await verifyClaudePaneProcess(proof(T1, T1, qualified))).toBeNull();
expect(await observeClaudeDelivery(proof(T1, T1, qualified))).toMatchObject({ state: "unknown" });
// The stored token itself never depended on the rotation's process.
expect(await verifyClaudePaneProcess(proof(T2, T1, qualified))).not.toBeNull();
});
it("refuses a third token even with a recorded resume", async () => {
expect(await verifyClaudePaneProcess(proof(T3, T1))).toBeNull();
expect(await observeClaudeDelivery(proof(T3, T1))).toMatchObject({ state: "unknown" });
});
});
@@ -0,0 +1,191 @@
import { Hono } from "hono";
import { AgentActivityStore } from "../src/domain/agent-activity-store.js";
import { activityRoutes } from "../src/routes/activity.js";
import { SeatIdentityStore } from "../src/domain/seat-identity-store.js";
import { SeatAttentionReconciler } from "../src/domain/seat-attention-reconciler.js";
import { sessionAdminRoutes } from "../src/routes/sessions.js";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createFullTestDb } from "./helpers/test-app.js";
import { RigRepository } from "../src/domain/rig-repository.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
import { EventBus } from "../src/domain/event-bus.js";
import { SnapshotRepository } from "../src/domain/snapshot-repository.js";
import { CheckpointStore } from "../src/domain/checkpoint-store.js";
import { SnapshotCapture } from "../src/domain/snapshot-capture.js";
import { RigTeardownOrchestrator } from "../src/domain/rig-teardown.js";
import { NodeLauncher } from "../src/domain/node-launcher.js";
import { RestoreOrchestrator } from "../src/domain/restore-orchestrator.js";
import { SeatIdentityReconciler } from "../src/domain/seat-identity-reconciler.js";
import { SessionTransport } from "../src/domain/session-transport.js";
import { ClaudeCodeAdapter } from "../src/adapters/claude-code-adapter.js";
import { ClaudeResumeAdapter } from "../src/adapters/claude-resume.js";
import { CodexResumeAdapter } from "../src/adapters/codex-resume.js";
import { NativePermissionStore } from "../src/domain/native-permission-store.js";
import type { ClaudeManagedLaunch } from "../src/domain/claude-managed-launch.js";
import type { TmuxAdapter } from "../src/adapters/tmux.js";
// #1077 through a real down/up: teardown, NodeLauncher, RestoreOrchestrator, the Claude adapter and
// the HTTP hook and clear-attention routes, with native observations as fixtures. Claude's
// SessionStart can land before the launch writes T1, while restore waits for readiness, or after
// it is done; each resume ordering ends verified, and a /clear in the same ordering stays refused.
// Derived from dev-review's reproduction of the early-hook gap in the first candidate.
// Shape of Fleet's Claude 2.1.220 auto-mode screen after full down/up:
// the header has scrolled out; the empty prompt and mode footer remain.
const autoScreen = "Restored conversation\n────────────────\n❯\u00a0\n────────────────\n ⏵⏵ auto mode on (shift+tab to cycle) · ← for agents\n ✘ Auto-update failed: no write permission to npm prefix · Run claude doctor\n ● high · /effort\n";
// #1077 recovery boundaries, derived from dev-review's reproduction against e427a030: a resume hook
// that lands before any launch path records T1, on the managed path with native observations
// briefly unavailable (attention, which then stays: the launch never observed its process) and on
// the legacy (non-pod) restore path.
const token = "00000000-0000-4000-8000-000000000006";
describe("managed Claude full down/up", () => {
const dbs: ReturnType<typeof createFullTestDb>[] = [];
afterEach(() => { for (const db of dbs.splice(0)) db.close(); });
// legacy-replaced-after-proof (dev-review against 05b26c2f): the legacy resume's identity check
// proves 102 on T1; 202 then replaces it and sends the late first hook. The launch's record must
// stay the process its own check proved, not a later read.
it.each(["managed-resume", "managed-delayed-ready-resume", "legacy-resume", "legacy-keep", "legacy-clear", "legacy-replaced-after-proof"])(
"independent recovery boundary: %s", async (scenario) => {
const legacy = scenario.startsWith("legacy");
const delayed = scenario.includes("delayed-ready");
const replacedAfterProof = scenario === "legacy-replaced-after-proof";
const timing = scenario.endsWith("clear") ? "clear-before-ready" : replacedAfterProof ? "resume-after-ready" : "resume-before-ready";
// The legacy resume's identity check takes the first two process samples.
let nativeReads = 0;
const replaced = () => replacedAfterProof && nativeReads > 2;
let settling = delayed;
const mode: string = "exact";
const db = createFullTestDb(); dbs.push(db);
const rigRepo = new RigRepository(db);
const sessionRegistry = new SessionRegistry(db);
const eventBus = new EventBus(db);
const snapshotRepo = new SnapshotRepository(db);
const checkpointStore = new CheckpointStore(db);
const snapshotCapture = new SnapshotCapture({ db, rigRepo, sessionRegistry, eventBus, snapshotRepo, checkpointStore });
const rig = rigRepo.createRig("restore-test");
db.prepare("INSERT INTO pods (id, rig_id, label) VALUES (?, ?, ?)").run("restore-pod", rig.id, "Test");
const node = rigRepo.addNode(rig.id, "test.c", { runtime: "claude-code", podId: legacy ? undefined : "restore-pod" });
new NativePermissionStore(db).write(node.id, { runtime: "claude-code", mode: "auto" }, "fixture", "retained Fleet posture");
// A legacy (non-pod) node gets the legacy canonical name from NodeLauncher.
const name = legacy ? "r00-restore-test-test_c" : "test-c@restore-test";
const old = sessionRegistry.registerSession(node.id, name);
sessionRegistry.updateStatus(old.id, "running");
sessionRegistry.updateResumeToken(old.id, "claude_id", token, "scrape");
sessionRegistry.updateBinding(node.id, { tmuxSession: name, tmuxPane: "%old" });
if (!legacy) db.prepare("INSERT INTO node_startup_context (node_id, projection_entries_json, resolved_files_json, startup_actions_json, runtime) VALUES (?, ?, ?, ?, ?)").run(node.id, "[]", "[]", "[]", "claude-code");
// legacy-keep: Claude resumes T1 and keeps its id.
const rotated = scenario === "legacy-keep" ? token : "00000000-0000-4000-8000-000000001077";
const activity = new AgentActivityStore({ db, eventBus });
const hooks = new Hono();
hooks.use("*", async (c, next) => {
c.set("tmuxAdapter" as never, tmux as never);
c.set("listProcesses" as never, hookProcesses as never);
c.set("agentActivityStore" as never, activity as never);
c.set("activityHookToken" as never, "fixture" as never);
c.set("sessionRegistry" as never, sessionRegistry as never);
c.set("eventBus" as never, eventBus as never);
await next();
});
hooks.route("/api/activity", activityRoutes);
const row = () => db.prepare("SELECT id, resume_token, resume_provenance, resume_rotated_from, startup_status FROM sessions WHERE node_id = ? ORDER BY id DESC LIMIT 1").get(node.id) as Record<string, unknown>;
const hook = async () => {
const response = await hooks.request("/api/activity/hooks", { method: "POST",
headers: { "content-type": "application/json", "x-openrig-activity-token": "fixture" },
body: JSON.stringify({ eventFamily: "session_identity", hookEvent: "SessionStart", sessionName: name, nodeId: node.id,
runtime: "claude-code", generation: sessionRegistry.currentOccupantTenure(node.id)!.generationUuid,
source: timing.startsWith("clear") ? "clear" : "resume", resumeLaunch: launchMarker, resumeLaunchFirst: true, hookPid: 111, sessionId: rotated }) });
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({ tokenPersisted: true });
};
// The relay forwards the marker the launched process's command carried, as the real one does.
let launchMarker: string | undefined;
const launched = (cmd: string) => { launchMarker = /OPENRIG_RESUME_LAUNCH='?([^' ]+)/.exec(cmd)?.[1]; };
let live = true;
const tmux = {
hasSession: vi.fn(async () => live),
probeSession: vi.fn(async () => ({ state: live ? "present" : "absent" })),
killSession: vi.fn(async () => { live = false; return { ok: true }; }),
createSession: vi.fn(async () => { live = true; return { ok: true }; }),
listSessions: vi.fn(async () => live ? [{ name }] : []),
listWindows: vi.fn(async () => []),
listPanes: vi.fn(async () => {
const current = row();
if (timing === "resume-before-join" && current.id !== old.id && current.resume_token === token && current.startup_status === "ready") await hook();
return ["%new"].map(id => ({ id, index: 0, cwd: "/", width: 80, height: 24, active: true }));
}),
getPanePid: vi.fn(async () => mode === "unobserved-pane-process" ? 999 : 100),
getPaneCommand: vi.fn(async () => mode === "bare-shell" ? "bash" : "sh"),
capturePaneContent: vi.fn(async () => autoScreen),
sendText: vi.fn(async (_session: string, cmd: string) => { launched(cmd); return { ok: true }; }),
sendShellCommand: vi.fn(async (_session: string, cmd: string) => { launched(cmd); if (timing.endsWith("before-ready")) await hook(); return { ok: true }; }),
sendKeys: vi.fn(async () => ({ ok: true })),
} as unknown as TmuxAdapter;
const startedAt = "Thu Oct 1 05:53:16 2026";
const paneRows = () => [
{ pid: 100, ppid: 1, pgid: 100, tpgid: mode === "bare-shell" ? 100 : 101, executableName: "bash", command: "-bash", startedAt },
...(mode === "bare-shell" ? [] : [{ pid: 101, ppid: 100, pgid: 101, tpgid: 101, executableName: "sh", command: "/bin/sh /tmp/fixture-launch.txt", startedAt }]),
...(mode === "bare-shell" ? [] : [{ pid: replaced() ? 202 : 102, ppid: 101, pgid: 101, tpgid: 101, executableName: mode === "native" ? "2.1.285" : "claude", command: `${mode === "native" ? "/fixture/.local/share/claude/versions/2.1.285" : "/opt/claude.exe"} --permission-mode auto --resume ${mode === "wrong-token" ? "different" : token} --name ${name}`, startedAt: replaced() ? "Thu Oct 1 06:10:00 2026" : startedAt }]),
];
const listProcesses = async () => { if (settling) return []; nativeReads += 1; return paneRows(); };
// The relay of a SessionStart hook runs under the launched Claude (102) through a shell.
const hookProcesses = async () => [...paneRows(),
{ pid: 110, ppid: replaced() ? 202 : 102, pgid: 110, tpgid: 101, executableName: "sh", command: "/bin/sh -c node relay.cjs", startedAt },
{ pid: 111, ppid: 110, pgid: 110, tpgid: 101, executableName: "node", command: "node relay.cjs", startedAt }];
// Real teardown captures the running occupant, exits the old row and clears bindings.
const down = await new RigTeardownOrchestrator({ db, rigRepo, sessionRegistry, eventBus, snapshotCapture, tmuxAdapter: tmux }).teardown(rig.id);
expect(down.errors).toEqual([]);
expect(down.sessionsKilled).toBe(1);
expect(snapshotRepo.getSnapshot(down.snapshotId!)?.kind).toBe("auto-pre-down");
expect(sessionRegistry.getBindingForNode(node.id)).toBeNull();
const adapter = new ClaudeCodeAdapter({ tmux, listProcesses, sleep: async () => {},
claudeManagedLaunch: { prepare: async () => ({ command: (args: readonly string[], env: Record<string, string> = {}) => [...Object.entries(env).map(([k, v]) => `${k}=${v}`), "claude", ...args].join(" "), assertCurrent: () => {}, configDir: "/fixture", executable: mode === "native" ? "/fixture/.local/share/claude/versions/2.1.285" : "/opt/claude.exe" }) } as unknown as ClaudeManagedLaunch,
fsOps: {
exists: () => false, readFile: () => "", writeFile: () => {}, mkdirp: () => {}, copyFile: () => {},
} });
const nodeLauncher = new NodeLauncher({ db, rigRepo, sessionRegistry, eventBus, tmuxAdapter: tmux });
const restore = new RestoreOrchestrator({ db, rigRepo, sessionRegistry, eventBus, snapshotRepo, snapshotCapture,
checkpointStore, nodeLauncher, tmuxAdapter: tmux, claudeResume: new ClaudeResumeAdapter(tmux, { listProcesses, maxWaitMs: 0, sleep: async () => {},
claudeManagedLaunch: { prepare: async () => ({ command: (args: readonly string[], env: Record<string, string> = {}) => [...Object.entries(env).map(([k, v]) => `${k}=${v}`), "claude", ...args].join(" "), assertCurrent: () => {}, configDir: "/fixture", executable: "/opt/claude.exe" }) } as unknown as ClaudeManagedLaunch }),
codexResume: new CodexResumeAdapter(tmux), listProcesses });
const up = await restore.restore(down.snapshotId!, { adapters: { "claude-code": adapter } });
expect(up.ok).toBe(true);
if (!up.ok) throw new Error(up.message);
settling = false;
if (timing.endsWith("after-ready")) await hook();
await new SeatIdentityReconciler({ db, tmux, listProcesses }).reconcileAll();
const identity = new SeatIdentityStore(db).getForNode(node.id);
const clear = new SeatAttentionReconciler({ db, sessionRegistry, eventBus, agentActivityStore: activity, tmux, listProcesses,
reconcileRestoreOutcome: (rigId, nodeId) => restore.reconcileNodeRuntimeTruth(rigId, nodeId),
sendVerify: async () => { throw new Error("unexpected input from clear-attention"); } });
const admin = new Hono();
admin.use("*", async (c,next) => {
c.set("seatAttentionReconciler" as never, clear as never);
c.set("terminalBearerToken" as never, "fixture" as never); await next();
});
admin.route("/api/sessions", sessionAdminRoutes);
const cleared = await admin.request(`/api/sessions/${encodeURIComponent(name)}/clear-attention`, {
method: "POST", headers: { "Content-Type": "application/json", Authorization: "Bearer fixture" }, body: "{}" });
const clearBody = await cleared.json();
const transport = new SessionTransport({ db, rigRepo, sessionRegistry, eventBus, tmuxAdapter: tmux, listProcesses, sleep: async () => {} });
const sent = await transport.send(name, "isolated QA restore message");
const resumed = timing.startsWith("resume");
const kept = scenario === "legacy-keep";
expect(up.result.nodes[0].status).toBe(resumed && !delayed ? "resumed" : "attention_required");
expect(row()).toMatchObject(kept
? { resume_token: token, resume_rotated_from: null }
: { resume_token: rotated, resume_provenance: "hook", resume_rotated_from: resumed ? token : null });
// A delayed launch could not observe the process it started, so nothing ties the hook's process
// to OpenRig's launch (a replacement's hook looks the same): the rotation stays unproved, as on
// main, and the seat stays in attention.
const proved = resumed && !delayed && !replacedAfterProof;
expect(identity?.verdict).toBe(proved ? "verified" : "mismatch");
// A resumed seat has nothing to clear; a /clear or unobserved-launch seat stays in attention.
expect(cleared.status).toBe(proved ? 409 : 422);
expect(clearBody).toMatchObject({ ok: false, ...(proved ? { code: "not_in_attention" } : {}) });
expect(sent.ok).toBe(true);
expect(String((sent as { warning?: string }).warning ?? "").includes("without verified native identity")).toBe(!proved);
},
);
});
@@ -0,0 +1,213 @@
import { Hono } from "hono";
import { AgentActivityStore } from "../src/domain/agent-activity-store.js";
import { activityRoutes } from "../src/routes/activity.js";
import { SeatIdentityStore } from "../src/domain/seat-identity-store.js";
import { SeatAttentionReconciler } from "../src/domain/seat-attention-reconciler.js";
import { sessionAdminRoutes } from "../src/routes/sessions.js";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createFullTestDb } from "./helpers/test-app.js";
import { RigRepository } from "../src/domain/rig-repository.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
import { EventBus } from "../src/domain/event-bus.js";
import { SnapshotRepository } from "../src/domain/snapshot-repository.js";
import { CheckpointStore } from "../src/domain/checkpoint-store.js";
import { SnapshotCapture } from "../src/domain/snapshot-capture.js";
import { RigTeardownOrchestrator } from "../src/domain/rig-teardown.js";
import { NodeLauncher } from "../src/domain/node-launcher.js";
import { RestoreOrchestrator } from "../src/domain/restore-orchestrator.js";
import { SeatIdentityReconciler } from "../src/domain/seat-identity-reconciler.js";
import { SessionTransport } from "../src/domain/session-transport.js";
import { ClaudeCodeAdapter } from "../src/adapters/claude-code-adapter.js";
import { ClaudeResumeAdapter } from "../src/adapters/claude-resume.js";
import { CodexResumeAdapter } from "../src/adapters/codex-resume.js";
import { NativePermissionStore } from "../src/domain/native-permission-store.js";
import type { ClaudeManagedLaunch } from "../src/domain/claude-managed-launch.js";
import type { TmuxAdapter } from "../src/adapters/tmux.js";
// #1077 maintainer review of 6266c2fa (pull/1080#issuecomment-6089024117): a recorded rotation
// T1 -> T2 vouches only for the process whose first hook qualified it. After the rotation is
// recorded, a different process started on T1 (a replacement), or a deeper Claude under the
// qualified one naming a third conversation, must leave identity, clear-attention, send and
// strict restore unproved, as on main. So must a replacement that sends the late first hook itself
// (dev-review against 21c55946): the hook's process counts only when it is the one the launch path
// observed starting on T1. Real teardown, restore, hook and clear-attention routes, identity
// reconcile and SessionTransport; native observations are fixtures. Processes are compared by pid
// and start time.
// Shape of Fleet's Claude 2.1.220 auto-mode screen after full down/up:
// the header has scrolled out; the empty prompt and mode footer remain.
const autoScreen = "Restored conversation\n────────────────\n❯\u00a0\n────────────────\n ⏵⏵ auto mode on (shift+tab to cycle) · ← for agents\n ✘ Auto-update failed: no write permission to npm prefix · Run claude doctor\n ● high · /effort\n";
// With the pane labelled claude and Claude's TUI header on screen, strict restore rests on the
// process-lineage search alone.
const tuiScreen = "Claude Code\n❯ \n";
const token = "00000000-0000-4000-8000-000000000006";
const third = "00000000-0000-4000-8000-000000000003";
describe("a recorded Claude resume rotation after the qualifying process", () => {
const dbs: ReturnType<typeof createFullTestDb>[] = [];
afterEach(() => { for (const db of dbs.splice(0)) db.close(); });
// first-hook: the launch's first hook arrives while restore runs. late-hook: that post was lost
// and the relay's claim file removed, so a later post from the same process counts as first.
it.each([
["first-hook", "same"], ["late-hook", "same"],
["first-hook", "replaced"], ["late-hook", "replaced"],
["first-hook", "deeper-third"],
// #1080 maintainer review of 6e36b94d: Claude run by Node (argv[1] is the claude script), and
// an older process entry with no OS executable name or process group, are Claude beneath too.
["first-hook", "deeper-node-hosted"], ["first-hook", "deeper-no-os-name"],
// CodeRabbit on d0419ff7: Node options before the claude script.
["first-hook", "deeper-node-options"],
// dev-review's reproduction against 21c55946: the replacement comes before the late hook, so
// that hook is the replacement's own; only the launch's own observation tells them apart.
["late-hook", "replaced-before-hook"],
// dev-review's reproduction against e130f584: the adapter's readiness check pins 102, then sees
// 202 instead and refuses; the launch's record must stay 102, not a later read of 202.
["late-hook", "replaced-during-readiness"],
] as const)("%s, then %s process", async (hookTiming, after) => {
const db = createFullTestDb(); dbs.push(db);
const rigRepo = new RigRepository(db);
const sessionRegistry = new SessionRegistry(db);
const eventBus = new EventBus(db);
const snapshotRepo = new SnapshotRepository(db);
const checkpointStore = new CheckpointStore(db);
const snapshotCapture = new SnapshotCapture({ db, rigRepo, sessionRegistry, eventBus, snapshotRepo, checkpointStore });
const rig = rigRepo.createRig("restore-test");
db.prepare("INSERT INTO pods (id, rig_id, label) VALUES (?, ?, ?)").run("restore-pod", rig.id, "Test");
const node = rigRepo.addNode(rig.id, "test.c", { runtime: "claude-code", podId: "restore-pod" });
new NativePermissionStore(db).write(node.id, { runtime: "claude-code", mode: "auto" }, "fixture", "retained Fleet posture");
const name = "test-c@restore-test";
const old = sessionRegistry.registerSession(node.id, name);
sessionRegistry.updateStatus(old.id, "running");
sessionRegistry.updateResumeToken(old.id, "claude_id", token, "scrape");
sessionRegistry.updateBinding(node.id, { tmuxSession: name, tmuxPane: "%old" });
db.prepare("INSERT INTO node_startup_context (node_id, projection_entries_json, resolved_files_json, startup_actions_json, runtime) VALUES (?, ?, ?, ?, ?)").run(node.id, "[]", "[]", "[]", "claude-code");
const rotated = "00000000-0000-4000-8000-000000001077";
const activity = new AgentActivityStore({ db, eventBus });
const hooks = new Hono();
hooks.use("*", async (c, next) => {
c.set("tmuxAdapter" as never, tmux as never);
c.set("listProcesses" as never, hookProcesses as never);
c.set("agentActivityStore" as never, activity as never);
c.set("activityHookToken" as never, "fixture" as never);
c.set("sessionRegistry" as never, sessionRegistry as never);
c.set("eventBus" as never, eventBus as never);
await next();
});
hooks.route("/api/activity", activityRoutes);
const row = () => db.prepare("SELECT id, resume_token, resume_provenance, resume_rotated_from, startup_status FROM sessions WHERE node_id = ? ORDER BY id DESC LIMIT 1").get(node.id) as Record<string, unknown>;
const hook = async () => {
const response = await hooks.request("/api/activity/hooks", { method: "POST",
headers: { "content-type": "application/json", "x-openrig-activity-token": "fixture" },
body: JSON.stringify({ eventFamily: "session_identity", hookEvent: "SessionStart", sessionName: name, nodeId: node.id,
runtime: "claude-code", generation: sessionRegistry.currentOccupantTenure(node.id)!.generationUuid,
source: "resume", resumeLaunch: launchMarker, resumeLaunchFirst: true, hookPid: 111, sessionId: rotated }) });
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({ tokenPersisted: true });
};
let launchMarker: string | undefined;
const launched = (cmd: string) => { launchMarker = /OPENRIG_RESUME_LAUNCH='?([^' ]+)/.exec(cmd)?.[1]; };
let live = true;
const tmux = {
hasSession: vi.fn(async () => live),
probeSession: vi.fn(async () => ({ state: live ? "present" : "absent" })),
killSession: vi.fn(async () => { live = false; return { ok: true }; }),
createSession: vi.fn(async () => { live = true; return { ok: true }; }),
listSessions: vi.fn(async () => live ? [{ name }] : []),
listWindows: vi.fn(async () => []),
listPanes: vi.fn(async () => ["%new"].map(id => ({ id, index: 0, cwd: "/", width: 80, height: 24, active: true }))),
getPanePid: vi.fn(async () => 100),
getPaneCommand: vi.fn(async () => phase === "after" && after.startsWith("deeper") ? "claude" : "sh"),
capturePaneContent: vi.fn(async () => phase === "after" && after.startsWith("deeper") ? tuiScreen : autoScreen),
sendText: vi.fn(async (_session: string, cmd: string) => { launched(cmd); return { ok: true }; }),
sendShellCommand: vi.fn(async (_session: string, cmd: string) => { launched(cmd); if (hookTiming === "first-hook") await hook(); return { ok: true }; }),
sendKeys: vi.fn(async () => ({ ok: true })),
} as unknown as TmuxAdapter;
const startedAt = "Thu Oct 1 05:53:16 2026";
// The launch path records its own process (102) once the launch returns. The adapter's readiness
// check cannot see it in time (or, replaced-during-readiness, sees 102 once and then 202), so the
// seat ends in attention and recovers only through clear-attention's strict restore reconcile.
let phase: "launched" | "after" = "launched";
let hooked = false;
let readinessSamples = 0;
const replaced = () => (after === "replaced" && phase === "after") || (after === "replaced-before-hook" && hooked)
|| (after === "replaced-during-readiness" && readinessSamples > 1);
const claude = (pid: number, ppid: number, conversation: string, began: string) =>
({ pid, ppid, pgid: 101, tpgid: 101, executableName: "claude", command: `/opt/claude.exe --permission-mode auto --resume ${conversation} --name ${name}`, startedAt: began });
const deeper = () => after === "deeper-node-hosted" || after === "deeper-node-options"
? { ...claude(103, 102, third, "Thu Oct 1 06:10:00 2026"), executableName: "node",
command: `node ${after === "deeper-node-options" ? "--no-warnings " : ""}/usr/local/bin/claude --resume ${third}` }
: after === "deeper-no-os-name"
? { pid: 103, ppid: 102, command: `claude --resume ${third}` }
: claude(103, 102, third, "Thu Oct 1 06:10:00 2026");
const paneRows = () => [
{ pid: 100, ppid: 1, pgid: 100, tpgid: 101, executableName: "bash", command: "-bash", startedAt },
{ pid: 101, ppid: 100, pgid: 101, tpgid: 101, executableName: "sh", command: "/bin/sh /tmp/fixture-launch.txt", startedAt },
...(replaced() ? [claude(202, 101, token, "Thu Oct 1 06:10:00 2026")] : [claude(102, 101, token, startedAt)]),
...(phase === "after" && after.startsWith("deeper") ? [deeper()] : []),
];
const listProcesses = async () => paneRows();
// The relay of a SessionStart hook runs under the pane's Claude through a shell.
const hookProcesses = async () => [...paneRows(),
{ pid: 110, ppid: replaced() ? 202 : 102, pgid: 110, tpgid: 101, executableName: "sh", command: "/bin/sh -c node relay.cjs", startedAt },
{ pid: 111, ppid: 110, pgid: 110, tpgid: 101, executableName: "node", command: "node relay.cjs", startedAt }];
const down = await new RigTeardownOrchestrator({ db, rigRepo, sessionRegistry, eventBus, snapshotCapture, tmuxAdapter: tmux }).teardown(rig.id);
expect(down.errors).toEqual([]);
const managed = { prepare: async () => ({ command: (args: readonly string[], env: Record<string, string> = {}) => [...Object.entries(env).map(([k, v]) => `${k}=${v}`), "claude", ...args].join(" "), assertCurrent: () => {}, configDir: "/fixture", executable: "/opt/claude.exe" }) } as unknown as ClaudeManagedLaunch;
const readiness = async () => {
if (after !== "replaced-during-readiness") return [];
readinessSamples += 1;
return paneRows();
};
const adapter = new ClaudeCodeAdapter({ tmux, listProcesses: readiness, sleep: async () => {}, claudeManagedLaunch: managed,
fsOps: { exists: () => false, readFile: () => "", writeFile: () => {}, mkdirp: () => {}, copyFile: () => {} } });
const nodeLauncher = new NodeLauncher({ db, rigRepo, sessionRegistry, eventBus, tmuxAdapter: tmux });
const restore = new RestoreOrchestrator({ db, rigRepo, sessionRegistry, eventBus, snapshotRepo, snapshotCapture,
checkpointStore, nodeLauncher, tmuxAdapter: tmux, claudeResume: new ClaudeResumeAdapter(tmux, { listProcesses, maxWaitMs: 0, sleep: async () => {}, claudeManagedLaunch: managed }),
codexResume: new CodexResumeAdapter(tmux), listProcesses });
const up = await restore.restore(down.snapshotId!, { adapters: { "claude-code": adapter } });
expect(up.ok).toBe(true);
if (!up.ok) throw new Error(up.message);
expect(up.result.nodes[0].status).toBe("attention_required");
if (hookTiming === "late-hook") {
// The first post was lost and the claim removed: a later post counts as first.
hooked = after === "replaced-before-hook";
await hook();
}
// The rotation is recorded against the process that qualified it, before anything changes.
expect(row()).toMatchObject({ resume_token: rotated, resume_provenance: "hook", resume_rotated_from: token });
phase = "after";
await new SeatIdentityReconciler({ db, tmux, listProcesses }).reconcileAll();
const identity = new SeatIdentityStore(db).getForNode(node.id);
const clear = new SeatAttentionReconciler({ db, sessionRegistry, eventBus, agentActivityStore: activity, tmux, listProcesses,
reconcileRestoreOutcome: (rigId, nodeId) => restore.reconcileNodeRuntimeTruth(rigId, nodeId),
sendVerify: async () => { throw new Error("unexpected input from clear-attention"); } });
const admin = new Hono();
admin.use("*", async (c, next) => {
c.set("seatAttentionReconciler" as never, clear as never);
c.set("terminalBearerToken" as never, "fixture" as never); await next();
});
admin.route("/api/sessions", sessionAdminRoutes);
const cleared = await admin.request(`/api/sessions/${encodeURIComponent(name)}/clear-attention`, {
method: "POST", headers: { "Content-Type": "application/json", Authorization: "Bearer fixture" }, body: "{}" });
const clearBody = await cleared.json();
const strict = await restore.reconcileNodeRuntimeTruth(rig.id, node.id);
const transport = new SessionTransport({ db, rigRepo, sessionRegistry, eventBus, tmuxAdapter: tmux, listProcesses, sleep: async () => {} });
const sent = await transport.send(name, "isolated QA restore message");
const warning = String((sent as { warning?: string }).warning ?? "");
const same = after === "same";
expect(cleared.status, JSON.stringify(clearBody)).toBe(same ? 200 : 422);
expect(strict.ok, JSON.stringify(strict)).toBe(same);
if (after.startsWith("deeper")) {
// The pane label and screen are main's own evidence (no rotation involved), so only the
// restore proof is in question here; two Claude conversations are a delivery conflict.
expect(strict).toMatchObject({ code: "process_lineage_mismatch" });
// Delivery's own foreground-conflict check is main's and recognises a native Claude only.
if (after === "deeper-third") expect(sent.ok && !warning).toBe(false);
return;
}
expect(identity?.verdict, JSON.stringify(identity)).toBe(same ? "verified" : "mismatch");
expect(sent.ok).toBe(true);
expect(warning.includes("without verified native identity")).toBe(!same);
});
});
@@ -0,0 +1,166 @@
import { Hono } from "hono";
import { AgentActivityStore } from "../src/domain/agent-activity-store.js";
import { activityRoutes } from "../src/routes/activity.js";
import { SeatIdentityStore } from "../src/domain/seat-identity-store.js";
import { SeatAttentionReconciler } from "../src/domain/seat-attention-reconciler.js";
import { sessionAdminRoutes } from "../src/routes/sessions.js";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createFullTestDb } from "./helpers/test-app.js";
import { RigRepository } from "../src/domain/rig-repository.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
import { EventBus } from "../src/domain/event-bus.js";
import { SnapshotRepository } from "../src/domain/snapshot-repository.js";
import { CheckpointStore } from "../src/domain/checkpoint-store.js";
import { SnapshotCapture } from "../src/domain/snapshot-capture.js";
import { RigTeardownOrchestrator } from "../src/domain/rig-teardown.js";
import { NodeLauncher } from "../src/domain/node-launcher.js";
import { RestoreOrchestrator } from "../src/domain/restore-orchestrator.js";
import { SeatIdentityReconciler } from "../src/domain/seat-identity-reconciler.js";
import { SessionTransport } from "../src/domain/session-transport.js";
import { ClaudeCodeAdapter } from "../src/adapters/claude-code-adapter.js";
import { ClaudeResumeAdapter } from "../src/adapters/claude-resume.js";
import { CodexResumeAdapter } from "../src/adapters/codex-resume.js";
import { NativePermissionStore } from "../src/domain/native-permission-store.js";
import type { ClaudeManagedLaunch } from "../src/domain/claude-managed-launch.js";
import type { TmuxAdapter } from "../src/adapters/tmux.js";
// #1077 through a real down/up: teardown, NodeLauncher, RestoreOrchestrator, the Claude adapter and
// the HTTP hook and clear-attention routes, with native observations as fixtures. Claude's
// SessionStart can land before the launch writes T1, while restore waits for readiness, or after
// it is done; each resume ordering ends verified, and a /clear in the same ordering stays refused.
// Derived from dev-review's reproduction of the early-hook gap in the first candidate.
// Shape of Fleet's Claude 2.1.220 auto-mode screen after full down/up:
// the header has scrolled out; the empty prompt and mode footer remain.
const autoScreen = "Restored conversation\n────────────────\n❯\u00a0\n────────────────\n ⏵⏵ auto mode on (shift+tab to cycle) · ← for agents\n ✘ Auto-update failed: no write permission to npm prefix · Run claude doctor\n ● high · /effort\n";
const token = "00000000-0000-4000-8000-000000000006";
describe("managed Claude full down/up", () => {
const dbs: ReturnType<typeof createFullTestDb>[] = [];
afterEach(() => { for (const db of dbs.splice(0)) db.close(); });
it.each(["resume-before-ready", "resume-before-join", "resume-after-ready", "clear-before-ready"])(
"real restore hook ordering: %s", async (timing) => {
const mode: string = "exact";
const db = createFullTestDb(); dbs.push(db);
const rigRepo = new RigRepository(db);
const sessionRegistry = new SessionRegistry(db);
const eventBus = new EventBus(db);
const snapshotRepo = new SnapshotRepository(db);
const checkpointStore = new CheckpointStore(db);
const snapshotCapture = new SnapshotCapture({ db, rigRepo, sessionRegistry, eventBus, snapshotRepo, checkpointStore });
const rig = rigRepo.createRig("restore-test");
db.prepare("INSERT INTO pods (id, rig_id, label) VALUES (?, ?, ?)").run("restore-pod", rig.id, "Test");
const node = rigRepo.addNode(rig.id, "test.c", { runtime: "claude-code", podId: "restore-pod" });
new NativePermissionStore(db).write(node.id, { runtime: "claude-code", mode: "auto" }, "fixture", "retained Fleet posture");
const name = "test-c@restore-test";
const old = sessionRegistry.registerSession(node.id, name);
sessionRegistry.updateStatus(old.id, "running");
sessionRegistry.updateResumeToken(old.id, "claude_id", token, "scrape");
sessionRegistry.updateBinding(node.id, { tmuxSession: name, tmuxPane: "%old" });
db.prepare("INSERT INTO node_startup_context (node_id, projection_entries_json, resolved_files_json, startup_actions_json, runtime) VALUES (?, ?, ?, ?, ?)").run(node.id, "[]", "[]", "[]", "claude-code");
const rotated = "00000000-0000-4000-8000-000000001077";
const activity = new AgentActivityStore({ db, eventBus });
const hooks = new Hono();
hooks.use("*", async (c, next) => {
c.set("tmuxAdapter" as never, tmux as never);
c.set("listProcesses" as never, hookProcesses as never);
c.set("agentActivityStore" as never, activity as never);
c.set("activityHookToken" as never, "fixture" as never);
c.set("sessionRegistry" as never, sessionRegistry as never);
c.set("eventBus" as never, eventBus as never);
await next();
});
hooks.route("/api/activity", activityRoutes);
const row = () => db.prepare("SELECT id, resume_token, resume_provenance, resume_rotated_from, startup_status FROM sessions WHERE node_id = ? ORDER BY id DESC LIMIT 1").get(node.id) as Record<string, unknown>;
const hook = async () => {
const response = await hooks.request("/api/activity/hooks", { method: "POST",
headers: { "content-type": "application/json", "x-openrig-activity-token": "fixture" },
body: JSON.stringify({ eventFamily: "session_identity", hookEvent: "SessionStart", sessionName: name, nodeId: node.id,
runtime: "claude-code", generation: sessionRegistry.currentOccupantTenure(node.id)!.generationUuid,
source: timing.startsWith("clear") ? "clear" : "resume", resumeLaunch: launchMarker, resumeLaunchFirst: true, hookPid: 111, sessionId: rotated }) });
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({ tokenPersisted: true });
};
// The relay forwards the marker the launched process's command carried, as the real one does.
let launchMarker: string | undefined;
const launched = (cmd: string) => { launchMarker = /OPENRIG_RESUME_LAUNCH='?([^' ]+)/.exec(cmd)?.[1]; };
let live = true;
const tmux = {
hasSession: vi.fn(async () => live),
probeSession: vi.fn(async () => ({ state: live ? "present" : "absent" })),
killSession: vi.fn(async () => { live = false; return { ok: true }; }),
createSession: vi.fn(async () => { live = true; return { ok: true }; }),
listSessions: vi.fn(async () => live ? [{ name }] : []),
listWindows: vi.fn(async () => []),
listPanes: vi.fn(async () => {
const current = row();
if (timing === "resume-before-join" && current.id !== old.id && current.resume_token === token && current.startup_status === "ready") await hook();
return ["%new"].map(id => ({ id, index: 0, cwd: "/", width: 80, height: 24, active: true }));
}),
getPanePid: vi.fn(async () => mode === "unobserved-pane-process" ? 999 : 100),
getPaneCommand: vi.fn(async () => mode === "bare-shell" ? "bash" : "sh"),
capturePaneContent: vi.fn(async () => autoScreen),
sendText: vi.fn(async (_session: string, cmd: string) => { launched(cmd); return { ok: true }; }),
sendShellCommand: vi.fn(async (_session: string, cmd: string) => { launched(cmd); if (timing.endsWith("before-ready")) await hook(); return { ok: true }; }),
sendKeys: vi.fn(async () => ({ ok: true })),
} as unknown as TmuxAdapter;
const startedAt = "Thu Oct 1 05:53:16 2026";
const paneRows = () => [
{ pid: 100, ppid: 1, pgid: 100, tpgid: mode === "bare-shell" ? 100 : 101, executableName: "bash", command: "-bash", startedAt },
...(mode === "bare-shell" ? [] : [{ pid: 101, ppid: 100, pgid: 101, tpgid: 101, executableName: "sh", command: "/bin/sh /tmp/fixture-launch.txt", startedAt }]),
...(mode === "bare-shell" ? [] : [{ pid: 102, ppid: 101, pgid: 101, tpgid: 101, executableName: mode === "native" ? "2.1.285" : "claude", command: `${mode === "native" ? "/fixture/.local/share/claude/versions/2.1.285" : "/opt/claude.exe"} --permission-mode auto --resume ${mode === "wrong-token" ? "different" : token} --name ${name}`, startedAt }]),
];
const listProcesses = async () => paneRows();
// The relay of a SessionStart hook runs under the launched Claude (102) through a shell.
const hookProcesses = async () => [...paneRows(),
{ pid: 110, ppid: 102, pgid: 110, tpgid: 101, executableName: "sh", command: "/bin/sh -c node relay.cjs", startedAt },
{ pid: 111, ppid: 110, pgid: 110, tpgid: 101, executableName: "node", command: "node relay.cjs", startedAt }];
// Real teardown captures the running occupant, exits the old row and clears bindings.
const down = await new RigTeardownOrchestrator({ db, rigRepo, sessionRegistry, eventBus, snapshotCapture, tmuxAdapter: tmux }).teardown(rig.id);
expect(down.errors).toEqual([]);
expect(down.sessionsKilled).toBe(1);
expect(snapshotRepo.getSnapshot(down.snapshotId!)?.kind).toBe("auto-pre-down");
expect(sessionRegistry.getBindingForNode(node.id)).toBeNull();
const adapter = new ClaudeCodeAdapter({ tmux, listProcesses, sleep: async () => {},
claudeManagedLaunch: { prepare: async () => ({ command: (args: readonly string[], env: Record<string, string> = {}) => [...Object.entries(env).map(([k, v]) => `${k}=${v}`), "claude", ...args].join(" "), assertCurrent: () => {}, configDir: "/fixture", executable: mode === "native" ? "/fixture/.local/share/claude/versions/2.1.285" : "/opt/claude.exe" }) } as unknown as ClaudeManagedLaunch,
fsOps: {
exists: () => false, readFile: () => "", writeFile: () => {}, mkdirp: () => {}, copyFile: () => {},
} });
const nodeLauncher = new NodeLauncher({ db, rigRepo, sessionRegistry, eventBus, tmuxAdapter: tmux });
const restore = new RestoreOrchestrator({ db, rigRepo, sessionRegistry, eventBus, snapshotRepo, snapshotCapture,
checkpointStore, nodeLauncher, tmuxAdapter: tmux, claudeResume: new ClaudeResumeAdapter(tmux),
codexResume: new CodexResumeAdapter(tmux), listProcesses });
const up = await restore.restore(down.snapshotId!, { adapters: { "claude-code": adapter } });
expect(up.ok).toBe(true);
if (!up.ok) throw new Error(up.message);
if (timing.endsWith("after-ready")) await hook();
await new SeatIdentityReconciler({ db, tmux, listProcesses }).reconcileAll();
const identity = new SeatIdentityStore(db).getForNode(node.id);
const clear = new SeatAttentionReconciler({ db, sessionRegistry, eventBus, agentActivityStore: activity, tmux, listProcesses,
reconcileRestoreOutcome: (rigId, nodeId) => restore.reconcileNodeRuntimeTruth(rigId, nodeId),
sendVerify: async () => { throw new Error("unexpected input from clear-attention"); } });
const admin = new Hono();
admin.use("*", async (c,next) => {
c.set("seatAttentionReconciler" as never, clear as never);
c.set("terminalBearerToken" as never, "fixture" as never); await next();
});
admin.route("/api/sessions", sessionAdminRoutes);
const cleared = await admin.request(`/api/sessions/${encodeURIComponent(name)}/clear-attention`, {
method: "POST", headers: { "Content-Type": "application/json", Authorization: "Bearer fixture" }, body: "{}" });
const clearBody = await cleared.json();
const transport = new SessionTransport({ db, rigRepo, sessionRegistry, eventBus, tmuxAdapter: tmux, listProcesses, sleep: async () => {} });
const sent = await transport.send(name, "isolated QA restore message");
const resumed = timing.startsWith("resume");
expect(up.result.nodes[0].status).toBe(resumed ? "resumed" : "attention_required");
expect(row()).toMatchObject({ resume_token: rotated, resume_provenance: "hook",
resume_rotated_from: resumed ? token : null });
expect(identity?.verdict).toBe(resumed ? "verified" : "mismatch");
// A resumed seat has nothing to clear; a /clear seat cannot be proved and stays in attention.
expect(cleared.status).toBe(resumed ? 409 : 422);
expect(clearBody).toMatchObject({ code: resumed ? "not_in_attention" : "not_demonstrably_responsive" });
expect(sent.ok).toBe(true);
expect(String((sent as { warning?: string }).warning ?? "").includes("current conversation is unverified")).toBe(!resumed);
},
);
});
+6 -5
View File
@@ -72,7 +72,7 @@ describe("ClaudeResumeAdapter", () => {
expect(sendText).toHaveBeenCalledOnce();
expect(sendText.mock.calls[0]![0]).toBe("r99-demo1-lead");
// OPR.0.4.8.2: restore now carries the launch-posture floor (acceptEdits), same as fresh.
expect(sendText.mock.calls[0]![1]).toBe("CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1 claude --permission-mode acceptEdits --resume 'my-session'");
expect(sendText.mock.calls[0]![1]).toBe("OPENRIG_RESUME_LAUNCH='my-session' CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1 claude --permission-mode acceptEdits --resume 'my-session'");
expect(sendKeys).toHaveBeenCalledOnce();
expect(sendKeys.mock.calls[0]![0]).toBe("r99-demo1-lead");
expect(sendKeys.mock.calls[0]![1]).toEqual(["Enter"]);
@@ -88,7 +88,7 @@ describe("ClaudeResumeAdapter", () => {
await adapter.resume("r99-demo1-lead", "claude_name", "my-session", "/repo", undefined, "gpt-5.4-cheap");
expect(sendText.mock.calls[0]![1]).toBe(
"CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1 claude --permission-mode acceptEdits --model 'gpt-5.4-cheap' --resume 'my-session'"
"OPENRIG_RESUME_LAUNCH='my-session' CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1 claude --permission-mode acceptEdits --model 'gpt-5.4-cheap' --resume 'my-session'"
);
});
@@ -100,7 +100,7 @@ describe("ClaudeResumeAdapter", () => {
await adapter.resume("r99-demo1-lead", "claude_name", "my-session", "/repo", undefined, "gpt-5.4-cheap", undefined, undefined, "high");
expect(sendText.mock.calls[0]![1]).toBe(
"CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1 claude --permission-mode acceptEdits --model 'gpt-5.4-cheap' --effort 'high' --resume 'my-session'"
"OPENRIG_RESUME_LAUNCH='my-session' CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1 claude --permission-mode acceptEdits --model 'gpt-5.4-cheap' --effort 'high' --resume 'my-session'"
);
});
@@ -139,7 +139,7 @@ describe("ClaudeResumeAdapter", () => {
await adapter.resume("r99-demo1-lead", "claude_name", "tok; rm -rf /", "/repo");
expect(sendText.mock.calls[0]![1]).toBe("CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1 claude --permission-mode acceptEdits --resume 'tok; rm -rf /'");
expect(sendText.mock.calls[0]![1]).toBe("OPENRIG_RESUME_LAUNCH='tok; rm -rf /' CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1 claude --permission-mode acceptEdits --resume 'tok; rm -rf /'");
});
it("sendKeys(Enter) fails after sendText -> C-c sent to clear buffer", async () => {
@@ -413,7 +413,8 @@ describe("ClaudeResumeAdapter", () => {
const result = await adapter.resume("r99-demo1-lead", "claude_id", "resume-id", "/repo");
expect(result).toEqual({ ok: true, appliedLaunch: CLAUDE_FLOOR_EFFECT });
// The process the identity check proved is the launch's record of what it started (#1077).
expect(result).toEqual({ ok: true, appliedLaunch: CLAUDE_FLOOR_EFFECT, launchedProcess: { pid: 1235, startedAt: "Sat Jan 1 12:00:00 2000" } });
});
it.each([
@@ -90,7 +90,7 @@ describe("launchHarness — per-agent --model reaches the claude launch (51-07 A
it("absent model → the resume command is exact bytes (no --model, posture intact)", async () => {
const tmux = mockTmux();
await adapterWith(tmux).launchHarness(withModel(undefined), { name: "seat", resumeToken: "tok-123" });
expect(lastCmd(tmux)).toBe(`CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1 claude ${POSTURE} --resume 'tok-123' --name seat`);
expect(lastCmd(tmux)).toBe(`OPENRIG_RESUME_LAUNCH='tok-123' CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1 claude ${POSTURE} --resume 'tok-123' --name seat`);
});
// D1 pin — posture BYTE-UNCHANGED both directions: the ONLY delta with/without model is the
@@ -177,7 +177,7 @@ describe("launchHarness — classic-renderer env prefix (OPR.0.5.3.1 scrollback
it("RESUME launch carries the prefix", async () => {
const tmux = mockTmux();
await adapterWith(tmux).launchHarness(makeBinding(), { name: "seat", resumeToken: "tok-123" });
expect(lastCmd(tmux)).toBe(`${PREFIX}claude ${POSTURE} --resume 'tok-123' --name seat`);
expect(lastCmd(tmux)).toBe(`OPENRIG_RESUME_LAUNCH='tok-123' ${PREFIX}claude ${POSTURE} --resume 'tok-123' --name seat`);
});
it("FORK launch carries the prefix", async () => {
@@ -186,11 +186,11 @@ describe("launchHarness — classic-renderer env prefix (OPR.0.5.3.1 scrollback
expect(lastCmd(tmux).startsWith(PREFIX + "claude ")).toBe(true);
});
it("override OPENRIG_CLAUDE_DISABLE_ALTERNATE_SCREEN=0 omits the prefix (byte-identical to pre-change)", async () => {
it("override OPENRIG_CLAUDE_DISABLE_ALTERNATE_SCREEN=0 omits the renderer prefix; the resume keeps its launch marker (#1077)", async () => {
process.env.OPENRIG_CLAUDE_DISABLE_ALTERNATE_SCREEN = "0";
const tmux = mockTmux();
await adapterWith(tmux).launchHarness(makeBinding(), { name: "seat", resumeToken: "tok-123" });
expect(lastCmd(tmux)).toBe(`claude ${POSTURE} --resume 'tok-123' --name seat`);
expect(lastCmd(tmux)).toBe(`OPENRIG_RESUME_LAUNCH='tok-123' claude ${POSTURE} --resume 'tok-123' --name seat`);
});
});
@@ -589,7 +589,7 @@ describe("Claude Code runtime adapter", () => {
const sendText = tmux.sendText as ReturnType<typeof vi.fn>;
expect(sendText).toHaveBeenCalledWith(
"r01-impl",
"CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1 claude --permission-mode acceptEdits --resume 'abc-123' --name dev-impl@test-rig"
"OPENRIG_RESUME_LAUNCH='abc-123' CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1 claude --permission-mode acceptEdits --resume 'abc-123' --name dev-impl@test-rig"
);
});
@@ -62,6 +62,22 @@ function fixture(token: string | null = null) {
}
describe("Claude wrapper manual attention recovery", () => {
// #1077 — launched with review-token, continued as rotated-token; the hook recorded how.
it.each([["resume", "review-token", 200, "ready"], ["clear", null, 422, "attention_required"]] as const)(
"clear-attention after a %s into a new id answers %s", async (_source, rotatedFrom, status, startup) => {
const f = fixture("rotated-token");
// Only the first hook after OpenRig's --resume launch, a resume, records what it replaced.
f.db.prepare("UPDATE sessions SET resume_provenance = 'hook', resume_rotated_from = ?, resume_rotated_process = ?, resume_launch_process = ? WHERE id = ?")
.run(rotatedFrom, rotatedFrom ? JSON.stringify({ pid: child.pid, startedAt }) : null,
JSON.stringify({ token: "review-token", pid: child.pid, startedAt }), f.session.id);
const result = await f.post();
expect(result.status, JSON.stringify(result.body)).toBe(status);
expect(f.startup()).toBe(startup);
expect(f.sendVerify).not.toHaveBeenCalled();
// An exact-resume check names a launch token itself and never borrows the rotation.
expect((await f.verify(true)).ok).toBe(false);
});
it.each([
["relative settings file", ["--settings", '"review.json', "--session-id", "review-token"]],
["name before identity", ["--name", '"review-desk', "--session-id", "review-token"]],
+2 -1
View File
@@ -2,6 +2,7 @@ import { rigInstallRootSchema } from "../../src/db/migrations/096_rig_install_ro
import { threadPartMapSchema } from "../../src/db/migrations/097_thread_part_map.js";
import { queueTransitionsQitemIdOrderSchema } from "../../src/db/migrations/098_queue_transitions_qitem_id_order.js";
import { queueItemsRecoveryTagsSchema } from "../../src/db/migrations/099_queue_items_recovery_tags.js";
import { resumeRotationSchema } from "../../src/db/migrations/101_claude_resume_rotation.js";
import { rigNonInterruptiveSchema } from "../../src/db/migrations/095_rig_non_interruptive.js";
import { mockShellCommand } from "./shell-command-mock.js";
import { vi } from "vitest";
@@ -124,7 +125,7 @@ import fs from "node:fs";
/** Seam B R6: the canonical full-fixture migration list, exported so file-backed
* DB-reopen tests migrate IDENTICALLY to createFullTestDb. */
export const migrationsForFullTestDb = [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema, nodePermissionPolicySchema, rigPermissionPolicySchema, nodePolicyProvenanceSchema, rigPolicyProvenanceSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema, rigPolicySchema, rigArchiveSchema, resumeProvenanceSchema, resumeVerificationSchema, seatIdentityVerdictsSchema, selfHostIdentitySchema, occupantTenuresSchema, daemonLifecycleSchema, watchdogJobsSchema, occupantGenerationStampsSchema, projectionManifestSchema, watchdogTargetGenerationSchema, appliedLaunchObservationsSchema, appliedLaunchObservationInvalidationsSchema, threadSeatMapSchema, queueTransitionWakesSchema, nodeSessionSourceSchema, scopedOperatingPostureSchema, humanNotificationIntentSchema, reviewReadIndexesSchema, inventoryEventIndexesSchema, rigClaudeManagedBlockFileSchema, nodePermissionSelectionsSchema, humanReplyToSchema, humanQuestionsSchema, nodeEffortSchema, rigNonInterruptiveSchema, rigInstallRootSchema, threadPartMapSchema, queueTransitionsQitemIdOrderSchema, queueItemsRecoveryTagsSchema];
export const migrationsForFullTestDb = [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema, nodePermissionPolicySchema, rigPermissionPolicySchema, nodePolicyProvenanceSchema, rigPolicyProvenanceSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema, rigPolicySchema, rigArchiveSchema, resumeProvenanceSchema, resumeVerificationSchema, seatIdentityVerdictsSchema, selfHostIdentitySchema, occupantTenuresSchema, daemonLifecycleSchema, watchdogJobsSchema, occupantGenerationStampsSchema, projectionManifestSchema, watchdogTargetGenerationSchema, appliedLaunchObservationsSchema, appliedLaunchObservationInvalidationsSchema, threadSeatMapSchema, queueTransitionWakesSchema, nodeSessionSourceSchema, scopedOperatingPostureSchema, humanNotificationIntentSchema, reviewReadIndexesSchema, inventoryEventIndexesSchema, rigClaudeManagedBlockFileSchema, nodePermissionSelectionsSchema, humanReplyToSchema, humanQuestionsSchema, nodeEffortSchema, rigNonInterruptiveSchema, rigInstallRootSchema, threadPartMapSchema, queueTransitionsQitemIdOrderSchema, queueItemsRecoveryTagsSchema, resumeRotationSchema];
/**
* P24 — the DECLARED exclusions for {@link migrationsForFullTestDb}. That list is deliberately a
@@ -47,7 +47,7 @@ describe("0.5.2-07 A2-3 — model-fidelity enumeration guard", () => {
it("claude adapter: every claude seat-launch template threads modelArg", () => {
const src = read("adapters/claude-code-adapter.ts");
const templates = [...src.matchAll(/`(?:\$\{rendererPrefix\})?claude \$\{[^`]*`/g)].map((m) => m[0]);
const templates = [...src.matchAll(/`(?:\$\{claudeResumeLaunchPrefix\([^)]*\)\})?(?:\$\{rendererPrefix\})?claude \$\{[^`]*`/g)].map((m) => m[0]);
const seatLaunches = templates.filter((t) => /--resume|--session-id|--fork-session/.test(t));
// fresh (--session-id), resume (--resume … --name), fork (--resume … --fork-session).
expect(seatLaunches.length).toBeGreaterThanOrEqual(3);
@@ -82,7 +82,7 @@ describe("0.5.2-07 A2-3 — model-fidelity enumeration guard", () => {
}
const claudeSrc = read("adapters/claude-code-adapter.ts");
const claudeTemplates = [...claudeSrc.matchAll(/`(?:\$\{rendererPrefix\})?claude \$\{[^`]*`/g)].map((m) => m[0]);
const claudeTemplates = [...claudeSrc.matchAll(/`(?:\$\{claudeResumeLaunchPrefix\([^)]*\)\})?(?:\$\{rendererPrefix\})?claude \$\{[^`]*`/g)].map((m) => m[0]);
const claudeSeatLaunches = claudeTemplates.filter((t) => /--resume|--session-id|--fork-session/.test(t));
expect(claudeSeatLaunches.length).toBeGreaterThanOrEqual(3);
for (const t of claudeSeatLaunches) {
@@ -88,6 +88,49 @@ describe("joined native Codex identity", () => {
expect(findExactNativeResumeProcess([{ pid: 10, ppid: 1, command: `claude --resume ${token}` }], 10, "claude-code", token)?.pid).toBe(10);
expect(findExactNativeResumeProcess([{ pid: 10, ppid: 1, command: "claude --resume wrong" }], 10, "claude-code", token)).toBeNull();
});
// #1080 maintainer review of 6e36b94d: a recorded rotation proves only with no Claude, of any
// runtime shape, beneath the launched process in its foreground.
it("refuses a recorded rotation with any shape of Claude beneath it in the foreground", () => {
const rotated = "00000000-0000-4000-8000-000000001077";
const startedAt = "Thu Oct 1 05:53:16 2026";
const rotation = { token, process: { pid: 10, startedAt } };
const top = { pid: 10, ppid: 1, pgid: 10, tpgid: 10, executableName: "claude", command: `claude --resume ${token}`, startedAt };
const find = (child?: NativeProcessRow) => findExactNativeResumeProcess(child ? [top, child] : [top], 10, "claude-code", rotated, rotation)?.pid ?? null;
expect(find()).toBe(10);
const beneath = { pid: 11, ppid: 10, pgid: 10 };
for (const child of [
{ ...beneath, executableName: "node", command: "node /usr/local/bin/claude --resume other" },
{ ...beneath, executableName: "node", command: "node /usr/lib/node_modules/@anthropic-ai/claude-code/cli.js -p hi" },
{ ...beneath, executableName: "node", command: "node --no-warnings /usr/local/bin/claude --resume other" },
{ ...beneath, executableName: "node", command: "node -r ./preload.cjs --max-old-space-size=4096 -- /usr/local/bin/claude --resume other" },
// Node's options are not parsed, so a value option the guard does not know cannot hide Claude
// (maintainer, against 8208c721), and a claude path given to -r or after -e refuses too.
{ ...beneath, executableName: "node", command: "node --max-old-space-size 8192 /usr/local/bin/claude --session-id other" },
{ ...beneath, executableName: "node", command: "node --diagnostic-dir /tmp/d /usr/local/bin/claude --session-id other" },
{ ...beneath, executableName: "node", command: "node --stack-size 4000 /usr/lib/node_modules/@anthropic-ai/claude-code/cli.mjs" },
{ ...beneath, executableName: "node", command: "node -r /usr/local/bin/claude relay.cjs" },
{ ...beneath, executableName: "node", command: "node --no-warnings -e 1 /usr/local/bin/claude" },
{ ...beneath, command: "claude --resume other" },
{ pid: 11, ppid: 10, command: "/opt/claude.exe --resume other" },
]) expect(find(child), child.command).toBeNull();
// Not Claude, or not in the foreground: the launched process still proves.
expect(find({ ...beneath, executableName: "node", command: "node relay.cjs" })).toBe(10);
expect(find({ ...beneath, executableName: "node", command: "node -e 'require(\"/usr/local/bin/claude\")'" })).toBe(10);
expect(find({ pid: 11, ppid: 10, pgid: 11, executableName: "claude", command: "claude mcp serve" })).toBe(10);
});
// A foreground group of -1 or 0 (no controlling terminal) is unknown, not a group no child is in.
it("refuses a recorded rotation over a Claude child when the launched process's foreground group reads -1 or 0", () => {
const rotated = "00000000-0000-4000-8000-000000001077";
const startedAt = "Thu Oct 1 05:53:16 2026";
const rotation = { token, process: { pid: 10, startedAt } };
const child = { pid: 11, ppid: 10, pgid: 11, tpgid: 11, executableName: "claude", command: "claude --resume other", startedAt };
for (const tpgid of [-1, 0]) {
const top = { pid: 10, ppid: 1, pgid: 10, tpgid, executableName: "claude", command: `claude --resume ${token}`, startedAt };
expect(findExactNativeResumeProcess([top, child], 10, "claude-code", rotated, rotation)?.pid ?? null, String(tpgid)).toBeNull();
expect(findExactNativeResumeProcess([top], 10, "claude-code", rotated, rotation)?.pid ?? null, String(tpgid)).toBe(10);
}
});
});
describe("observeClaudePaneStartedAt", () => {
@@ -21,7 +21,8 @@ function fixture() {
db.exec(`
CREATE TABLE nodes (id TEXT, rig_id TEXT, runtime TEXT);
CREATE TABLE sessions (id INTEGER, node_id TEXT, session_name TEXT, resume_type TEXT,
resume_token TEXT, resume_provenance TEXT, resume_last_verified TEXT, resume_last_probe_status TEXT);
resume_token TEXT, resume_provenance TEXT, resume_last_verified TEXT, resume_last_probe_status TEXT,
resume_rotated_from TEXT, resume_rotated_process TEXT, resume_launch_token TEXT);
CREATE TABLE events (seq INTEGER PRIMARY KEY, node_id TEXT, type TEXT, payload TEXT);
CREATE TABLE occupant_tenures (id TEXT, node_id TEXT, generation_ordinal INTEGER,
generation_uuid TEXT, kind TEXT, native_session_id_at_boot TEXT, boot_at TEXT);
@@ -183,6 +183,35 @@ describe("PluginVendorService — vendoring (HG-2.3)", () => {
expect(fs._store[`${target}/${skill}`]).toContain("The daemon publishes");
});
// #1077 — the relay forwards SessionStart's source, the occupant generation and the resume launch
// marker. An install at the previous version must receive it rather than keep the old relay.
it("the shipped core upgrades 0.1.8 installs to the relay that forwards resume launch evidence", async () => {
const source = "/asset-root/openrig-core";
const target = "/home/test/.openrig/plugins/openrig-core";
const relay = "hooks/scripts/activity-relay.cjs";
const files = [".claude-plugin/plugin.json", ".codex-plugin/plugin.json", relay];
const bundled = Object.fromEntries(files.map(rel => [
`${source}/${rel}`,
readFileSync(new URL(`../assets/plugins/openrig-core/${rel}`, import.meta.url), "utf8"),
]));
for (const manifest of files.slice(0, 2)) {
expect(JSON.parse(bundled[`${source}/${manifest}`]!).version).not.toBe("0.1.8");
}
const fs = mockFs({
...bundled,
[`${target}/.claude-plugin/plugin.json`]: '{"name":"openrig-core","version":"0.1.8"}',
[`${target}/.codex-plugin/plugin.json`]: '{"name":"openrig-core","version":"0.1.8"}',
[`${target}/${relay}`]: "// 0.1.8 relay: session id only",
});
const svc = new PluginVendorService({
vendoredAssetsDir: "/asset-root", userPluginsDir: "/home/test/.openrig/plugins",
fs, httpClient: vi.fn(),
});
await svc.ensureVendored("openrig-core");
for (const rel of files) expect(fs._store[`${target}/${rel}`]).toBe(bundled[`${source}/${rel}`]);
expect(fs._store[`${target}/${relay}`]).toContain("OPENRIG_RESUME_LAUNCH");
});
it("ensureVendored skips silently when vendored asset doesn't exist (no source to copy)", async () => {
const fs = mockFs({});
const svc = new PluginVendorService({
@@ -1012,9 +1012,10 @@ describe("RestoreOrchestrator", () => {
if (failure === "bare") rows.pop();
if (failure === "background") rows[2]!.pgid = 9000;
if (failure === "unrelated") rows[2]!.ppid = 9000;
if (failure === "unstable" && calls > 1) rows[2]!.startedAt = "Sat Jan 1 12:01:00 2000";
// The launch's own record of its process (#1077) takes the first two samples; the A3 proof follows.
if (failure === "unstable" && calls > 3) rows[2]!.startedAt = "Sat Jan 1 12:01:00 2000";
if (failure === "missing-metadata") rows[2]!.startedAt = "";
if (failure === "pane-changed") tmux.listPanes = vi.fn(async () => [{ id: "%other", index: 0, cwd: "/", width: 80, height: 24, active: true }]);
if (failure === "pane-changed" && calls > 2) tmux.listPanes = vi.fn(async () => [{ id: "%other", index: 0, cwd: "/", width: 80, height: 24, active: true }]);
return rows;
};
if (failure === "ambiguous-pane") tmux.listPanes = vi.fn(async () => ["%1", "%2"].map(id => ({ id, index: 0, cwd: "/", width: 80, height: 24, active: true })));
@@ -3299,6 +3300,34 @@ describe("RestoreOrchestrator", () => {
expect(tmux.sendText).not.toHaveBeenCalled();
});
// #1077 — the seat launched with --resume tok-abc-123 and Claude continued as a new id. The
// strict reconciler keeps main's exact current-token path and accepts the launch token only as
// the alternative a qualified rotation names: the launched process's own first hook, a resume.
it.each([
["a resume from the launched process", "resume", "tok-abc-123", "tok-abc-123", true],
["a /clear from the launched process", "clear", "tok-abc-123", "tok-abc-123", false],
["a resume from a Claude started by hand in the pane (no marker)", "resume", null, "tok-abc-123", false],
["a resume carrying another launch's marker", "resume", "tok-other-789", "tok-abc-123", false],
// An operator relaunched Claude on the stored token after a rotation was recorded: main's path.
["argv on the current token after an old rotation record", "resume", "tok-abc-123", "tok-rotated-456", true],
["argv on an unrelated token after a rotation record", "resume", "tok-abc-123", "tok-other-789", false],
] as const)("reconciles Claude after %s", async (_label, source, resumeLaunch, argvToken, accepted) => {
const tmux = mockTmuxForReconciler();
vi.mocked(tmux.hasSession).mockResolvedValue(true);
vi.mocked(tmux.getPaneCommand).mockResolvedValue("sh");
vi.mocked(tmux.capturePaneContent).mockResolvedValue("Claude Code v2.1.220\n ❯ accept edits on");
const seeded = seedFailedAttempt({ restoreOutcome: "attention_required", withResumeToken: true });
const session = db.prepare("SELECT id FROM sessions WHERE node_id = ?").get(seeded.nodeId) as { id: string };
sessionRegistry.recordResumeLaunch(session.id, "tok-abc-123");
sessionRegistry.recordResumeLaunchProcess(session.id, "tok-abc-123", { pid: 1236, startedAt: "Sat Jan 1 12:00:00 2000" });
sessionRegistry.recordHookSessionIdentity(session.id, "claude_id", "tok-rotated-456", { source, currentGeneration: true, resumeLaunch, launchedProcess: { pid: 1236, startedAt: "Sat Jan 1 12:00:00 2000" } });
const result = await createOrchestrator({ tmux, listProcesses: async () => managedClaudeRows(argvToken) }).reconcileNodeRuntimeTruth(seeded.rig.id, seeded.nodeId);
expect(result.ok).toBe(accepted);
if (!result.ok) expect(result.code).toBe("process_lineage_mismatch");
expect(tmux.sendKeys).not.toHaveBeenCalled();
expect(tmux.sendText).not.toHaveBeenCalled();
});
it("reconciles a headerless Claude prompt when exact resume-token lineage is verified", async () => {
const tmux = mockTmuxForReconciler();
vi.mocked(tmux.hasSession).mockResolvedValue(true);
@@ -166,6 +166,32 @@ describe("A3 Claude shell sampling", () => {
expect(f.verdict()?.verdict).toBe("mismatch");
});
// #1077 — launched as `--resume saved-token`, continued as `rotated-token`; the SessionStart hook
// recorded the new id with how it began. Only a current-generation resume proves the seat.
it.each([
["resume", "saved-token", "verified"],
["clear", null, "mismatch"],
["startup", null, "mismatch"],
["resume", "other-token", "mismatch"],
] as const)("a rotated stored token with source %s (replaced %s) reads %s", async (_source, rotatedFrom, expected) => {
const f = fixture();
// The registry records resume_rotated_from only for a resume into a new id; other sources leave it null.
// The rotation names the process that qualified it: the pane's Claude (11).
f.db.prepare("UPDATE sessions SET resume_token = 'rotated-token', resume_provenance = 'hook', resume_rotated_from = ?, resume_rotated_process = ?, resume_launch_process = ?")
.run(rotatedFrom, rotatedFrom ? JSON.stringify({ pid: 11, startedAt: "start" }) : null,
JSON.stringify({ token: "saved-token", pid: 11, startedAt: "start" }));
await f.rec.reconcileAll();
expect(f.verdict()?.verdict).toBe(expected);
});
it("does not accept a recorded resume once the stored token is an operator's", async () => {
const f = fixture();
f.db.prepare("UPDATE sessions SET resume_token = 'rotated-token', resume_provenance = 'operator', resume_rotated_from = 'saved-token', resume_rotated_process = ?, resume_launch_process = ?")
.run(JSON.stringify({ pid: 11, startedAt: "start" }), JSON.stringify({ token: "saved-token", pid: 11, startedAt: "start" }));
await f.rec.reconcileAll();
expect(f.verdict()?.verdict).toBe("mismatch");
});
it("leaves Codex sampling and command count unchanged", async () => {
const f = fixture("codex", "codex");
await f.rec.reconcileAll();