mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
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:
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)} `;
|
||||
}
|
||||
@@ -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 } } : {}) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
})));
|
||||
};
|
||||
|
||||
@@ -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.",
|
||||
]);
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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,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();
|
||||
|
||||
Reference in new issue
Block a user