mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-11 17:32:22 -10:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9c6ca3649 | ||
|
|
4afc1c4a90 | ||
|
|
776b94dade | ||
|
|
01a00ef252 |
No files matched your search
@@ -13,7 +13,7 @@ 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 ClaudeLaunchedProcess, type NativeProcessLister } from "../domain/native-process-lineage.js";
|
||||
import { observeClaudePaneProcess, witnessedClaudeVersionLabel, 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";
|
||||
@@ -383,6 +383,13 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
private async assessManagedProbe(binding: NodeBinding, paneCommand: string | null, paneContent: string) {
|
||||
const input = { runtime: "claude-code", paneCommand, paneContent };
|
||||
const probe = assessNativeResumeProbe(input);
|
||||
// #86: an unmanaged native install's version label reads as `claude` once its OS executable is witnessed. The
|
||||
// witness runs only when the label would change the result; a managed launch keeps its exact proof below.
|
||||
const asClaude = assessNativeResumeProbe({ ...input, paneCommand: "claude" });
|
||||
if (probe.status !== "resumed" && asClaude.status === "resumed" && binding.permissionMode === undefined && binding.tmuxSession
|
||||
&& await witnessedClaudeVersionLabel(paneCommand, { target: binding.tmuxSession, tmux: this.tmux, listProcesses: this.listProcesses })) {
|
||||
return asClaude;
|
||||
}
|
||||
// Headerless managed launches (including bypass mode) use the same readiness
|
||||
// precedence as resume. This candidate result is returned only after the
|
||||
// exact launch identity below is proved; screen text alone is insufficient.
|
||||
|
||||
@@ -6,7 +6,7 @@ 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 ClaudeLaunchedProcess, type NativeProcessLister } from "../domain/native-process-lineage.js";
|
||||
import { verifyClaudePaneProcess, witnessedClaudeVersionLabel, 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";
|
||||
@@ -106,7 +106,7 @@ export class ClaudeResumeAdapter {
|
||||
return { ok: false, code: "resume_failed", message: keyResult.message };
|
||||
}
|
||||
|
||||
const result = await this.verifyResume(tmuxSessionName, resumeToken!).catch((error): ResumeResult => ({
|
||||
const result = await this.verifyResume(tmuxSessionName, resumeToken!, !managed).catch((error): ResumeResult => ({
|
||||
ok: false,
|
||||
code: "attention_required",
|
||||
message: `Claude resume observation unavailable; launch retained: ${error instanceof Error ? error.message : String(error)}`,
|
||||
@@ -114,7 +114,18 @@ export class ClaudeResumeAdapter {
|
||||
return result.ok ? { ...result, appliedLaunch } : result;
|
||||
}
|
||||
|
||||
private async verifyResume(tmuxSessionName: string, resumeToken: string): Promise<ResumeResult> {
|
||||
/** #86: as the launch adapter's probe, an unmanaged native install's version label reads as `claude` once its OS
|
||||
* executable is witnessed. The witness runs only when the label would change the result. */
|
||||
private async assessProbe(target: string, paneCommand: string | null, paneContent: string, unmanaged: boolean) {
|
||||
const input = { runtime: "claude-code", paneCommand, paneContent };
|
||||
const probe = assessNativeResumeProbe(input);
|
||||
const asClaude = assessNativeResumeProbe({ ...input, paneCommand: "claude" });
|
||||
if (unmanaged && probe.status !== "resumed" && asClaude.status === "resumed"
|
||||
&& await witnessedClaudeVersionLabel(paneCommand, { target, tmux: this.tmux, listProcesses: this.options.listProcesses })) return asClaude;
|
||||
return probe;
|
||||
}
|
||||
|
||||
private async verifyResume(tmuxSessionName: string, resumeToken: string, unmanaged: boolean): Promise<ResumeResult> {
|
||||
const pollMs = this.options.pollMs ?? 200;
|
||||
const maxWaitMs = this.options.maxWaitMs ?? 5_000;
|
||||
const sleepFn = this.options.sleep ?? sleep;
|
||||
@@ -123,11 +134,7 @@ export class ClaudeResumeAdapter {
|
||||
for (let attempt = 0; attempt < attempts; attempt++) {
|
||||
const paneCommand = await this.tmux.getPaneCommand(tmuxSessionName);
|
||||
const paneContent = (await this.tmux.capturePaneContent(tmuxSessionName, 40)) ?? "";
|
||||
const probe = assessNativeResumeProbe({
|
||||
runtime: "claude-code",
|
||||
paneCommand,
|
||||
paneContent,
|
||||
});
|
||||
const probe = await this.assessProbe(tmuxSessionName, paneCommand, paneContent, unmanaged);
|
||||
|
||||
if (probe.code === "no_conversation_found") {
|
||||
return {
|
||||
@@ -162,11 +169,7 @@ export class ClaudeResumeAdapter {
|
||||
|
||||
const finalCommand = await this.tmux.getPaneCommand(tmuxSessionName);
|
||||
const finalContent = (await this.tmux.capturePaneContent(tmuxSessionName, 40)) ?? "";
|
||||
const finalProbe = assessNativeResumeProbe({
|
||||
runtime: "claude-code",
|
||||
paneCommand: finalCommand,
|
||||
paneContent: finalContent,
|
||||
});
|
||||
const finalProbe = await this.assessProbe(tmuxSessionName, finalCommand, finalContent, unmanaged);
|
||||
|
||||
if (finalProbe.code === "no_conversation_found") {
|
||||
return { ok: false, code: "retry_fresh", message: "Claude resume failed: no conversation found for the requested session" };
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type PaneBindingObservation,
|
||||
} from "./pane-binding-observation.js";
|
||||
import { SeatIdentityStore } from "./seat-identity-store.js";
|
||||
import { witnessedClaudeVersionLabel, type NativeProcessLister } from "./native-process-lineage.js";
|
||||
|
||||
export type ClaimResult =
|
||||
| { ok: true; nodeId: string; sessionId: string }
|
||||
@@ -84,6 +85,8 @@ interface ClaimServiceDeps {
|
||||
readSessionFile(sessionName: string): { ok: true; sessionFile: string } | { ok: false; reason: string };
|
||||
};
|
||||
ompRunnerStateStore?: ResumeTokenCaptureDeps["ompRunnerStateStore"];
|
||||
/** #86 — process rows for the native version-label witness; defaults to the OS listing. */
|
||||
listProcesses?: NativeProcessLister;
|
||||
}
|
||||
|
||||
interface BindOptions {
|
||||
@@ -119,6 +122,7 @@ export class ClaimService {
|
||||
private resumeTokenCapturer: ClaimServiceDeps["resumeTokenCapturer"] | null;
|
||||
private piRunnerStateStore: ClaimServiceDeps["piRunnerStateStore"] | null;
|
||||
private ompRunnerStateStore: ClaimServiceDeps["ompRunnerStateStore"] | null;
|
||||
private listProcesses: NativeProcessLister | undefined;
|
||||
|
||||
constructor(deps: ClaimServiceDeps) {
|
||||
if (deps.db !== deps.rigRepo.db) throw new Error("ClaimService: rigRepo must share the same db handle");
|
||||
@@ -138,6 +142,7 @@ export class ClaimService {
|
||||
this.resumeTokenCapturer = deps.resumeTokenCapturer ?? null;
|
||||
this.piRunnerStateStore = deps.piRunnerStateStore ?? null;
|
||||
this.ompRunnerStateStore = deps.ompRunnerStateStore ?? null;
|
||||
this.listProcesses = deps.listProcesses;
|
||||
}
|
||||
|
||||
private async observeBindingPane(
|
||||
@@ -505,11 +510,10 @@ export class ClaimService {
|
||||
const projectionDrift: string[] = [];
|
||||
const paneObservation = await this.observeBindingPane(sessionName);
|
||||
if (!paneObservation.ok) projectionDrift.push(paneObservation.detail);
|
||||
const paneTarget = paneObservation.ok ? paneObservation.pane : sessionName;
|
||||
let paneCommand: string | null = null;
|
||||
try {
|
||||
paneCommand = await this.tmuxAdapter.getPaneCommand(
|
||||
paneObservation.ok ? paneObservation.pane : sessionName,
|
||||
);
|
||||
paneCommand = await this.tmuxAdapter.getPaneCommand(paneTarget);
|
||||
} catch { paneCommand = null; }
|
||||
if (nodeRow.runtime) {
|
||||
const expectation: Record<string, string[]> = {
|
||||
@@ -520,7 +524,10 @@ export class ClaimService {
|
||||
const expected = expectation[nodeRow.runtime];
|
||||
if (!paneCommand) {
|
||||
projectionDrift.push(`runtime unverified: pane command unreadable; node declares runtime "${nodeRow.runtime}"`);
|
||||
} else if (expected && expected.length > 0 && !expected.includes(paneCommand)) {
|
||||
} else if (expected && expected.length > 0 && !expected.includes(paneCommand)
|
||||
// #86: a native install's version label confirms claude-code only with its OS executable witness.
|
||||
&& !(nodeRow.runtime === "claude-code" && await witnessedClaudeVersionLabel(paneCommand,
|
||||
{ target: paneTarget, tmux: this.tmuxAdapter, listProcesses: this.listProcesses }))) {
|
||||
projectionDrift.push(`runtime unverified: pane command "${paneCommand}" does not confirm runtime "${nodeRow.runtime}"`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -597,6 +597,24 @@ export async function verifyClaudePaneRuntime(input: Parameters<typeof observeCl
|
||||
return second?.fingerprint === first.fingerprint ? second : null;
|
||||
}
|
||||
|
||||
const NATIVE_VERSION_LABEL = /^\d+\.\d+\.\d+(?:[-+][\w.-]+)?$/;
|
||||
|
||||
/** #86: the native installer names Claude's process, and so the pane label, after its versioned file. Whether that
|
||||
* label may stand for `claude`: only when the pane's one stable foreground Claude is the version-named process
|
||||
* itself, witnessed by its OS executable path (`claudeProcess`: argv0 claude, the installer's versions layout, a
|
||||
* basename equal to the OS name), and the pane's pid is unchanged. The label alone is never identity, and a path
|
||||
* read that fails or times out is no witness. Like a `claude` label, this says nothing about the conversation. */
|
||||
export async function witnessedClaudeVersionLabel(paneCommand: string | null,
|
||||
input: Omit<Parameters<typeof observeClaudePaneRuntime>[0], "selectedExecutable">): Promise<boolean> {
|
||||
const label = paneCommand?.trim() ?? "";
|
||||
if (!NATIVE_VERSION_LABEL.test(label)) return false;
|
||||
const native = await verifyClaudePaneRuntime(input);
|
||||
// argv0 `claude` keeps the OS path the deciding evidence: a versioned argv0 matches the OS name without it.
|
||||
if (!native || executableName(tokens(native.process.command)[0] ?? "") !== "claude"
|
||||
|| native.process.executableName !== label || !native.process.executablePath) return false;
|
||||
return await input.tmux.getPanePid(input.target).catch(() => null) === native.panePid;
|
||||
}
|
||||
|
||||
/** The `ps` start time (lstart, local time) of the one stable Claude foreground
|
||||
* process. This makes no claim about its conversation token. */
|
||||
export async function observeClaudePaneStartedAt(input: Parameters<typeof observeClaudePaneRuntime>[0]): Promise<string | null> {
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
// #86 — Claude's native installer names its process, and so tmux's pane label, after its versioned file (2.1.300).
|
||||
// An unmanaged seat whose label is a version reads as `claude` only when the OS executable path witnesses native
|
||||
// Claude. Every case runs through existing seams, so the same file runs unchanged on main: the witnessed cases fail
|
||||
// there (timeout, no verified resume, reconcile drift) and the refusals already hold.
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { ClaudeCodeAdapter, type ClaudeAdapterFsOps } from "../src/adapters/claude-code-adapter.js";
|
||||
import { ClaudeResumeAdapter } from "../src/adapters/claude-resume.js";
|
||||
import type { TmuxAdapter } from "../src/adapters/tmux.js";
|
||||
import type { NodeBinding } from "../src/domain/runtime-adapter.js";
|
||||
import type { NativeProcessRow } from "../src/domain/native-process-lineage.js";
|
||||
import type { ClaudeManagedLaunch } from "../src/domain/claude-managed-launch.js";
|
||||
import { ClaimService } from "../src/domain/claim-service.js";
|
||||
import { createFullTestDb, createTestApp } from "./helpers/test-app.js";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
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 { NativePermissionStore } from "../src/domain/native-permission-store.js";
|
||||
import { ContextUsageStore } from "../src/domain/context-usage-store.js";
|
||||
import { ContextMonitor } from "../src/domain/context-monitor.js";
|
||||
|
||||
const VERSION = "2.1.300";
|
||||
const NATIVE_PATH = `/fixture/.local/share/claude/versions/${VERSION}`;
|
||||
const startedAt = "Fri Oct 16 01:00:00 2026";
|
||||
|
||||
// The staging subshell exec'd Claude, so Claude leads the pane's foreground group under the pane shell.
|
||||
function rows(command = "claude --permission-mode acceptEdits --resume tok --name seat"): NativeProcessRow[] {
|
||||
return [
|
||||
{ pid: 10, ppid: 1, pgid: 10, tpgid: 12, executableName: "zsh", command: "-zsh", startedAt },
|
||||
{ pid: 12, ppid: 10, pgid: 12, tpgid: 12, executableName: VERSION, executablePath: NATIVE_PATH, command, startedAt },
|
||||
];
|
||||
}
|
||||
|
||||
function mockTmux(label: string, panePid: () => Promise<number | null> = async () => 10): TmuxAdapter {
|
||||
return {
|
||||
sendText: vi.fn(async () => ({ ok: true as const })),
|
||||
sendKeys: vi.fn(async () => ({ ok: true as const })),
|
||||
sendShellCommand: vi.fn(async () => ({ ok: true as const })),
|
||||
hasSession: vi.fn(async () => true),
|
||||
getPaneCommand: vi.fn(async () => label),
|
||||
// Claude hasn't drawn its prompt yet: no screen text can prove the resume.
|
||||
capturePaneContent: vi.fn(async () => ""),
|
||||
getPanePid: vi.fn(panePid),
|
||||
listPanes: vi.fn(async () => []),
|
||||
createSession: vi.fn(async () => ({ ok: true as const })),
|
||||
killSession: vi.fn(async () => ({ ok: true as const })),
|
||||
listSessions: vi.fn(async () => []),
|
||||
listWindows: vi.fn(async () => []),
|
||||
startPipePane: vi.fn(async () => ({ ok: true as const })),
|
||||
stopPipePane: vi.fn(async () => ({ ok: true as const })),
|
||||
setSessionOption: vi.fn(async () => ({ ok: true as const })),
|
||||
getSessionOption: vi.fn(async () => null),
|
||||
} as unknown as TmuxAdapter;
|
||||
}
|
||||
|
||||
const fsOps = {
|
||||
exists: () => false, readFile: () => "", writeFile: () => {}, mkdirp: () => {}, copyFile: () => {}, listFiles: () => [],
|
||||
} as unknown as ClaudeAdapterFsOps;
|
||||
|
||||
function binding(extra: Partial<NodeBinding> = {}): NodeBinding {
|
||||
return { id: "b1", nodeId: "n1", tmuxSession: "dev-impl@rig", tmuxWindow: null, tmuxPane: null,
|
||||
cmuxWorkspace: null, cmuxSurface: null, updatedAt: "", cwd: "/project", ...extra };
|
||||
}
|
||||
|
||||
// The seat's persisted managed permission mode; by default the seat has no managed selection.
|
||||
type PermissionModeReader = (nodeId: string) => string | undefined;
|
||||
const adapter = (tmux: TmuxAdapter, listProcesses: () => NativeProcessRow[] | Promise<NativeProcessRow[]>,
|
||||
resolvePermissionMode: PermissionModeReader | null = () => undefined) =>
|
||||
new ClaudeCodeAdapter({ tmux, fsOps, listProcesses, sleep: async () => {}, ...(resolvePermissionMode ? { resolvePermissionMode } : {}) });
|
||||
|
||||
describe("#86 unmanaged launch wait", () => {
|
||||
it.each([
|
||||
["OpenRig's own resume command", rows()],
|
||||
["a flag from the user's own claude, which exact proof can't parse", rows("claude --strict-mcp-config --permission-mode acceptEdits --resume tok --name seat")],
|
||||
])("resumes a witnessed native version label: %s", async (_name, processRows) => {
|
||||
const result = await adapter(mockTmux(VERSION), async () => processRows)
|
||||
.launchHarness(binding(), { name: "seat", resumeToken: "tok" });
|
||||
expect(result).toMatchObject({ ok: true, resumeToken: "tok" });
|
||||
});
|
||||
|
||||
it("checkReady reads a witnessed native version label as Claude", async () => {
|
||||
expect(await adapter(mockTmux(VERSION), rows).checkReady(binding())).toEqual({ ready: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("#86 refusals that stay refused", () => {
|
||||
const awaiting = { ready: false, code: "awaiting_runtime" };
|
||||
const cases: [string, NativeProcessRow[] | (() => NativeProcessRow[]), string?, (() => Promise<number | null>)?][] = [
|
||||
["a path outside the native layout", rows().map(r => r.pid === 12 ? { ...r, executablePath: `/opt/other/${VERSION}` } : r)],
|
||||
["a basename that isn't the OS name", rows().map(r => r.pid === 12 ? { ...r, executablePath: NATIVE_PATH.replace("300", "299") } : r)],
|
||||
["argv0 that isn't claude", rows("node /fixture/cli.js --resume tok")],
|
||||
["a version-only argv0", rows(`${VERSION} --resume tok`)],
|
||||
["two candidates", [...rows(), { ...rows()[1]!, pid: 13 }]],
|
||||
// listNativeProcesses leaves the path unset when the OS read fails or times out.
|
||||
["a path read that failed or timed out", rows().map(r => ({ ...r, executablePath: undefined }))],
|
||||
["argv0 naming the native path, with no OS path read", rows(`${NATIVE_PATH} --resume tok`).map(r => ({ ...r, executablePath: undefined }))],
|
||||
["argv0 naming the native path, with an unrelated OS path", rows(`${NATIVE_PATH} --resume tok`).map(r => r.pid === 12 ? { ...r, executablePath: "/opt/other/claude" } : r)],
|
||||
["a background process", rows().map(r => r.pid === 12 ? { ...r, pgid: 88 } : r)],
|
||||
["a label naming a different version", rows(), "2.1.301"],
|
||||
["a pane pid change after the samples", rows(), VERSION, vi.fn<() => Promise<number | null>>()
|
||||
.mockResolvedValueOnce(10).mockResolvedValueOnce(10).mockResolvedValue(11)],
|
||||
];
|
||||
it.each(cases)("%s", async (_name, processRows, label = VERSION, panePid) => {
|
||||
const list = typeof processRows === "function" ? processRows : () => processRows;
|
||||
expect(await adapter(mockTmux(label, panePid), list).checkReady(binding())).toMatchObject(awaiting);
|
||||
});
|
||||
|
||||
it("a process that changes between the two samples", async () => {
|
||||
const list = vi.fn<() => NativeProcessRow[]>().mockReturnValueOnce(rows())
|
||||
.mockReturnValue(rows().map(r => r.pid === 12 ? { ...r, startedAt: startedAt.replace("01:00:00", "01:00:01") } : r));
|
||||
expect(await adapter(mockTmux(VERSION), list).checkReady(binding())).toMatchObject(awaiting);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#86 results that don't change", () => {
|
||||
it("a managed seat keeps its exact proof; the witness isn't consulted", async () => {
|
||||
const list = vi.fn(rows);
|
||||
expect(await adapter(mockTmux(VERSION), list).checkReady(binding({ permissionMode: "auto" })))
|
||||
.toMatchObject({ ready: false, code: "awaiting_runtime" });
|
||||
expect(list).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each<[string, PermissionModeReader | null]>([
|
||||
["a persisted managed selection, through a binding without permissionMode", () => "auto"],
|
||||
["an unreadable selection", () => { throw new Error("Invalid persisted native permission selection; launch refused."); }],
|
||||
["no selection reader", null],
|
||||
])("the witness isn't consulted with %s", async (_name, resolvePermissionMode) => {
|
||||
const list = vi.fn(rows);
|
||||
expect(await adapter(mockTmux(VERSION), list, resolvePermissionMode).checkReady(binding()))
|
||||
.toMatchObject({ ready: false, code: "awaiting_runtime" });
|
||||
expect(list).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a shell label stays a return to the shell", async () => {
|
||||
expect(await adapter(mockTmux("zsh"), rows).checkReady(binding())).toMatchObject({ ready: false, code: "returned_to_shell" });
|
||||
});
|
||||
|
||||
it("a claude label stays ready by name, with no process read", async () => {
|
||||
const list = vi.fn(rows);
|
||||
expect(await adapter(mockTmux("claude"), list).checkReady(binding())).toEqual({ ready: true });
|
||||
expect(list).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("#86 legacy restore resume", () => {
|
||||
const resumeAdapter = (tmux: TmuxAdapter, listProcesses: () => NativeProcessRow[], extra = {}) =>
|
||||
new ClaudeResumeAdapter(tmux, { listProcesses, pollMs: 1, maxWaitMs: 2, sleep: async () => {}, ...extra });
|
||||
|
||||
it("an unmanaged resume with a witnessed native version label is verified", async () => {
|
||||
expect(await resumeAdapter(mockTmux(VERSION), rows).resume("dev-impl@rig", "claude_id", "tok", "/project"))
|
||||
.toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
it("an unwitnessed version label stays unverified", async () => {
|
||||
const list = () => rows().map(r => ({ ...r, executablePath: undefined }));
|
||||
expect(await resumeAdapter(mockTmux(VERSION), list).resume("dev-impl@rig", "claude_id", "tok", "/project"))
|
||||
.toMatchObject({ ok: false, code: "attention_required" });
|
||||
});
|
||||
|
||||
it("a managed resume keeps its exact proof; the witness isn't consulted", async () => {
|
||||
const list = vi.fn(rows);
|
||||
const claudeManagedLaunch = { prepare: async () => ({ command: (args: readonly string[]) => `claude ${args.join(" ")}`,
|
||||
assertCurrent: () => {}, configDir: "/fixture", executable: NATIVE_PATH }) } as unknown as ClaudeManagedLaunch;
|
||||
const result = await resumeAdapter(mockTmux(VERSION), list, { claudeManagedLaunch })
|
||||
.resume("dev-impl@rig", "claude_id", "tok", "/project", undefined, undefined, "auto", "n1");
|
||||
expect(result).toMatchObject({ ok: false, code: "attention_required" });
|
||||
expect(list).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("#86 reconcile-session drift", () => {
|
||||
async function reconcile(listProcesses: () => NativeProcessRow[]) {
|
||||
const db = createFullTestDb();
|
||||
const tmux = mockTmux(VERSION);
|
||||
const setup = createTestApp(db, { tmux });
|
||||
const deps = { db, rigRepo: setup.rigRepo, sessionRegistry: setup.sessionRegistry, discoveryRepo: setup.discoveryRepo,
|
||||
eventBus: setup.eventBus, tmuxAdapter: tmux, listProcesses };
|
||||
const claims = new ClaimService(deps as ConstructorParameters<typeof ClaimService>[0]);
|
||||
const rig = setup.rigRepo.createRig("drift-rig");
|
||||
const node = setup.rigRepo.addNode(rig.id, "dev.impl", { runtime: "claude-code", cwd: "/work/repo", podId: null });
|
||||
setup.sessionRegistry.markDetached(setup.sessionRegistry.registerSession(node.id, "dev-impl@drift-rig").id);
|
||||
setup.sessionRegistry.updateBinding(node.id, { tmuxSession: "dev-impl@drift-rig" });
|
||||
const result = await claims.reconcileSession({ sessionName: "dev-impl@drift-rig" });
|
||||
db.close();
|
||||
if (!result.ok) throw new Error(result.message);
|
||||
return result.result;
|
||||
}
|
||||
|
||||
it("a witnessed native version label confirms the runtime; continuity stays unverified", async () => {
|
||||
const result = await reconcile(rows);
|
||||
expect(result.projectionDrift.join(" ")).not.toContain("runtime unverified");
|
||||
expect(result.continuity).toBe("unverified");
|
||||
});
|
||||
|
||||
it("an unwitnessed version label keeps the drift line", async () => {
|
||||
const result = await reconcile(() => rows().map(r => r.pid === 12 ? { ...r, executablePath: `/opt/other/${VERSION}` } : r));
|
||||
expect(result.projectionDrift.join(" ")).toContain(`runtime unverified: pane command "${VERSION}" does not confirm runtime "claude-code"`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#86 recovery readiness (ContextMonitor.pollOnce) follows the seat's persisted selection", () => {
|
||||
async function poll(opts: { selection?: string; label?: string; rows: () => NativeProcessRow[] }): Promise<string> {
|
||||
const db = createDb();
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
const rigRepo = new RigRepository(db);
|
||||
const sessions = new SessionRegistry(db);
|
||||
const node = rigRepo.addNode(rigRepo.createRig("recovery-rig").id, "dev.impl", { runtime: "claude-code", cwd: "/project" });
|
||||
const session = sessions.registerSession(node.id, "dev-impl@recovery-rig");
|
||||
db.prepare("UPDATE sessions SET status = 'running', startup_status = 'attention_required' WHERE id = ?").run(session.id);
|
||||
const permissions = new NativePermissionStore(db);
|
||||
if (opts.selection) permissions.write(node.id, { runtime: "claude-code", mode: opts.selection }, "test", "managed seat");
|
||||
const stateDir = mkdtempSync(join(tmpdir(), "label-witness-"));
|
||||
const claude = new ClaudeCodeAdapter({ tmux: mockTmux(opts.label ?? VERSION), fsOps, listProcesses: opts.rows, sleep: async () => {},
|
||||
resolvePermissionMode: (nodeId) => permissions.resolve(nodeId, "claude-code").permissionMode });
|
||||
const monitor = new ContextMonitor(db, new ContextUsageStore(db, { stateDir, codexHomeDir: stateDir }),
|
||||
{ ensureContextCollector: () => {}, checkReady: (b) => claude.checkReady(b) });
|
||||
try {
|
||||
await monitor.pollOnce();
|
||||
return (db.prepare("SELECT startup_status FROM sessions WHERE id = ?").get(session.id) as { startup_status: string }).startup_status;
|
||||
} finally {
|
||||
monitor.stop();
|
||||
db.close();
|
||||
rmSync(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
it.each<[string, Parameters<typeof poll>[0], string]>([
|
||||
["managed auto, a version label, the wrong conversation", { selection: "auto", rows: () => rows("claude --permission-mode auto --resume other --name seat") }, "attention_required"],
|
||||
["managed auto, a version label, no OS path", { selection: "auto", rows: () => rows().map(r => ({ ...r, executablePath: undefined })) }, "attention_required"],
|
||||
["managed auto, the ordinary claude label, as on main", { selection: "auto", label: "claude", rows }, "ready"],
|
||||
["unmanaged, a witnessed version label", { rows }, "ready"],
|
||||
])("%s", async (_name, opts, expected) => {
|
||||
expect(await poll(opts)).toBe(expected);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user