Compare commits

...
6 changed files with 228 additions and 13 deletions

No files matched your search

@@ -50,7 +50,7 @@ export function chooseRestoreSnapshot(deps: ExistingRigRestoreDeps, rigId: strin
ok: false,
status: 404,
body: {
error: `Rig exists but ${staleSnapshot ? "its restore snapshots name an older occupant" : "has no restore-usable snapshot"} and current DB state is insufficient for rehydrate. Start fresh with: rig up <spec-path>`,
error: `Rig exists but ${staleSnapshot ? "its selected restore snapshot does not match current occupant state" : "has no restore-usable snapshot"} and current DB state is insufficient for rehydrate. Start fresh with: rig up <spec-path>`,
code: "no_snapshot",
blockers: eligibility.blockers,
},
@@ -17,14 +17,16 @@ export interface CurrentStateRehydrateEligibility {
* not permission to revive a historical occupant; current-state capture
* preserves the ambiguity and restore refuses it explicitly.
*/
export function snapshotMatchesCurrentOccupants(
export function assessSnapshotCurrentOccupants(
db: Database.Database,
rig: RigWithRelations,
snapshot: Snapshot,
): boolean {
if (rig.nodes.length === 0) return true;
): { matches: boolean; stoppedFreshNodeIds: Set<string> } {
const stoppedFreshNodeIds = new Set<string>();
const mismatch = () => ({ matches: false, stoppedFreshNodeIds: new Set<string>() });
if (rig.nodes.length === 0) return { matches: true, stoppedFreshNodeIds };
const rows = db.prepare(
`SELECT s.id, s.node_id AS nodeId, s.status,
`SELECT s.*, s.node_id AS nodeId,
s.resume_type AS resumeType, s.resume_token AS resumeToken
FROM sessions s
JOIN nodes n ON n.id = s.node_id
@@ -35,6 +37,7 @@ export function snapshotMatchesCurrentOccupants(
status: string;
resumeType: string | null;
resumeToken: string | null;
intentionally_stopped?: number;
}>;
const recorded = readFreshOccupantRelations(db, rig.rig.id);
@@ -44,18 +47,40 @@ export function snapshotMatchesCurrentOccupants(
for (const node of rig.nodes) {
const current = resolveActiveOccupantRow(candidates, Object.prototype.hasOwnProperty.call(recorded, node.id) ? recorded : undefined, node.id);
if (current.kind === "none") continue;
if (current.kind === "ambiguous") return false;
if (current.kind === "ambiguous") return mismatch();
const captured = resolveActiveSnapshotSession(snapshot.data, node.id);
const stoppedHistory = snapshot.data.sessions.filter(s => s.nodeId === node.id && s.id === current.session.id);
// An absent occupant is current only with the deliberate stop, the exact
// exited/native history, and a fresh effect fenced to the current tenure.
// Missing/ambiguous legacy relations never qualify as recorded absence.
if (captured.kind === "none" && snapshot.data.activeOccupantsByNode?.[node.id]?.kind === "absent"
&& current.session.status === "exited" && current.session.intentionally_stopped === 1
&& recorded[node.id] === current.session.id && stoppedHistory.length === 1
&& stoppedHistory[0]!.status === "exited"
&& (stoppedHistory[0]!.resumeType ?? null) === current.session.resumeType
&& (stoppedHistory[0]!.resumeToken ?? null) === current.session.resumeToken
&& (db.prepare("SELECT kind FROM occupant_tenures WHERE node_id=? ORDER BY generation_ordinal DESC LIMIT 1").get(node.id) as { kind: string } | undefined)?.kind === "fresh") {
stoppedFreshNodeIds.add(node.id);
continue;
}
if (
captured.kind !== "resolved"
|| captured.session.id !== current.session.id
|| (captured.session.resumeType ?? null) !== current.session.resumeType
|| (captured.session.resumeToken ?? null) !== current.session.resumeToken
) {
return false;
return mismatch();
}
}
return true;
return { matches: true, stoppedFreshNodeIds };
}
export function snapshotMatchesCurrentOccupants(db: Database.Database, rig: RigWithRelations, snapshot: Snapshot): boolean {
return assessSnapshotCurrentOccupants(db, rig, snapshot).matches;
}
export function stoppedFreshSnapshotReason(logicalId: string): string {
return `Seat '${logicalId}' was deliberately stopped in the current fresh generation and this snapshot. It remains stopped; choose an explicit per-seat launch to start it.`;
}
/**
@@ -1,6 +1,7 @@
import { observeSolePane } from "./pane-binding-observation.js";
import { automaticRestoreOverrides, type AutomaticSeatReplacement } from "./existing-rig-restore.js";
import { restoreRuntimeCompatibilityError } from "./restore-runtime-compatibility.js";
import { assessSnapshotCurrentOccupants, stoppedFreshSnapshotReason } from "./rehydrate-eligibility.js";
import { nonInterruptiveNotice, nonInterruptiveSummary } from "../adapters/non-interruptive.js";
import { effortPrecedence, readSeatEffort } from "./seat-effort.js";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
@@ -419,6 +420,7 @@ export class RestoreOrchestrator {
// 4. Compute restore plan
const plan = this.computeRestorePlan(snapshot.data).filter(entry => !replacements.length || selectedIds.has(entry.node.id));
const { stoppedFreshNodeIds } = assessSnapshotCurrentOccupants(this.db, rig, snapshot);
// 5. Execute restore with compensating pattern per node
const nodeResults: RestoreNodeResult[] = [];
@@ -427,7 +429,7 @@ export class RestoreOrchestrator {
else if (opts?.nonInterruptive === false) restoreWarnings.push(nonInterruptiveSummary(false));
for (const entry of plan) {
const replacement = replacements.find(target => target.nodeId === entry.node.id);
const result = await this.restoreNodeWithCompensation(entry, rigId, snapshotId, snapshot.data, opts, restoreWarnings);
const result = await this.restoreNodeWithCompensation(entry, rigId, snapshotId, snapshot.data, opts, restoreWarnings, stoppedFreshNodeIds.has(entry.node.id));
nodeResults.push(result);
if (replacement && (this.db.prepare("SELECT status FROM sessions WHERE id = ?").get(replacement.sessionId) as { status: string } | undefined)?.status === "exited") {
restoreWarnings.push(`Stopped ${entry.node.logicalId}'s startup occupant for explicit replacement; replacement outcome: ${result.status}.`);
@@ -920,6 +922,7 @@ export class RestoreOrchestrator {
data: SnapshotData,
opts?: { adapters?: Record<string, import("./runtime-adapter.js").RuntimeAdapter>; fsOps?: { exists(path: string): boolean }; freshLogicalIds?: string[]; onSessionLaunched?: (nodeId: string, sessionId: string) => void; automaticReplacements?: AutomaticSeatReplacement[] },
warnings?: string[],
preserveStoppedFresh = false,
): Promise<RestoreNodeResult> {
// Compare against the live logical target without changing saved post-launch inputs.
const node = entry.node;
@@ -943,6 +946,9 @@ export class RestoreOrchestrator {
}
const freshRequested = opts?.freshLogicalIds?.includes(node.logicalId) ?? false;
if (occupantResolution.kind === "none" && preserveStoppedFresh && !freshRequested) {
return { nodeId, logicalId: node.logicalId, status: "awaiting-decision", error: stoppedFreshSnapshotReason(node.logicalId) };
}
const runtimeError = restoreRuntimeCompatibilityError(liveNode ? liveNode.runtime : node.runtime,
data.nodeStartupContext?.[nodeId]?.runtime);
if (runtimeError) {
@@ -16,6 +16,7 @@ import { resolveActiveOccupantRow, resolveActiveSnapshotSession, deriveRehydrate
import type { RigWithRelations, Snapshot } from "./types.js";
import { parseSqliteUtcMs } from "./sqlite-time.js";
import { effectiveRestorePolicy, isOrdinaryTerminalSeat, type RestoreSeatFacts } from "./restore-policy.js";
import { assessSnapshotCurrentOccupants, stoppedFreshSnapshotReason } from "./rehydrate-eligibility.js";
/** OPR.0.4.3.20 FR-6 — a present token whose last verification is older than this
* is surfaced as `stale` (age-based staleness — the "stale while running" signal
@@ -68,6 +69,8 @@ export interface RestorePlanPreview {
}
export interface PreviewSessionRow {
/** Current stop proved against this snapshot; never persisted as new history. */
stoppedFresh?: boolean;
nodeId: string;
restorePolicy: string | null;
resumeType: string | null;
@@ -133,7 +136,7 @@ function intendedActionFor(
resolution: ActiveOccupantResolution<PreviewSessionRow>,
freshRequested: boolean,
seat: RestoreSeatFacts,
absentHistory: { logicalId: string; rigName: string; row: PreviewSessionRow | null },
absentHistory: { logicalId: string; rigName: string; row: PreviewSessionRow | null; stoppedFresh: boolean },
): { intendedAction: RestorePlanPreviewNode["intendedAction"]; reason?: string } {
// OPR.0.5.7.1 — a broken authoritative relation beats --fresh, exactly as
// execution fails loudly before its fresh check: --fresh cannot override
@@ -150,6 +153,9 @@ function intendedActionFor(
reason: "listed in --fresh — apply would deliberately skip the resume (operation B)",
};
}
if (resolution.kind === "none" && absentHistory.stoppedFresh) {
return { intendedAction: "awaiting-decision", reason: stoppedFreshSnapshotReason(absentHistory.logicalId) };
}
// No occupant, but an earlier one left a resume token: apply stops for the same decision.
if (resolution.kind === "none" && absentHistory.row) {
return {
@@ -182,7 +188,9 @@ export function collectPreviewSessionRows(
snapshot: Snapshot | null,
): PreviewSessionRow[] {
if (snapshot) {
const { stoppedFreshNodeIds } = assessSnapshotCurrentOccupants(db, rig, snapshot);
return (snapshot.data.sessions ?? []).map((s) => ({
stoppedFresh: stoppedFreshNodeIds.has(s.nodeId),
nodeId: s.nodeId,
restorePolicy: s.restorePolicy ?? null,
resumeType: s.resumeType ?? null,
@@ -250,10 +258,11 @@ export function buildRestorePlanPreview(
currentRuntime: node.runtime,
recordedSessions: sessionRows.filter((row) => row.nodeId === savedNodeId),
};
const stoppedFresh = sessionRows.some(row => row.nodeId === savedNodeId && row.stoppedFresh);
const { intendedAction, reason } = runtimeError
? { intendedAction: "awaiting-decision" as const, reason: runtimeError }
: intendedActionFor(resolution, freshRequested, seat,
{ logicalId: node.logicalId, rigName: rig.rig.name, row: absentSeatResumeHistory(sessionRows, savedNodeId) });
{ logicalId: node.logicalId, rigName: rig.rig.name, row: absentSeatResumeHistory(sessionRows, savedNodeId), stoppedFresh });
// OPR.0.4.3.20 FR-6 — per-seat token state (read-only), derived from the
// RESOLVED occupant only — never from a historical row.
const occupant = resolution.kind === "resolved" ? resolution.session : null;
@@ -272,7 +281,7 @@ export function buildRestorePlanPreview(
// silent); runtime mismatches and broken relations cannot be resolved
// by a fresh request, so --fresh must not be suggested as their remedy.
// A terminal has no token to find and relaunches fresh on its own.
freshRequired: runtimeError || resolution.kind === "ambiguous" ? false : tokenState === "missing" && !isOrdinaryTerminalSeat(seat),
freshRequired: stoppedFresh || runtimeError || resolution.kind === "ambiguous" ? false : tokenState === "missing" && !isOrdinaryTerminalSeat(seat),
...(runtimePrompt ? { runtimePrompt } : {}),
};
});
+1 -1
View File
@@ -174,7 +174,7 @@ async function renderExistingRestore(
nodes: result.result.nodes,
warnings: [...joinedWarning, ...(capturedCurrentState
? [staleSnapshot
? "Existing restore snapshots named an older occupant; captured current DB state as auto-rehydrate snapshot for reboot recovery."
? "The selected restore snapshot did not match current occupant state; captured current DB state as auto-rehydrate snapshot for reboot recovery."
: "No restore-usable snapshot existed; captured current DB state as auto-rehydrate snapshot for reboot recovery.", ...result.result.warnings]
: result.result.warnings)],
attachCommand,
@@ -0,0 +1,175 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createFullTestDb, createTestApp } from "./helpers/test-app.js";
import { bootKernelIfNeeded, classifyManagedKernel } from "../src/domain/kernel-boot.js";
import { restoreExistingRigUnattended } from "../src/domain/existing-rig-restore.js";
import { noServerTerminal } from "./helpers/no-server-terminal.js";
import { snapshotMatchesCurrentOccupants } from "../src/domain/rehydrate-eligibility.js";
const databases: ReturnType<typeof createFullTestDb>[] = [];
afterEach(() => { for (const db of databases.splice(0)) db.close(); });
// Real seat stop, down/snapshot, up and restore; terminal/provider effects are inert.
async function fixture(mode: "token" | "no-token" | "fresh-policy" = "token", rigName = "fresh-stopped") {
const db = createFullTestDb(); databases.push(db);
const h = createTestApp(db), { rigRepo, sessionRegistry: sessions, snapshotCapture, tmuxAdapter: tmux } = h;
const rig = rigRepo.createRig(rigName);
const paused = rigRepo.addNode(rig.id, "paused", { runtime: "codex" });
const previous = sessions.registerSession(paused.id, `paused@${rigName}`);
sessions.updateStatus(previous.id, "running");
const others = ["one", "two", "three"].map(name => {
const node = rigRepo.addNode(rig.id, name, { runtime: "claude-code" });
const session = sessions.registerSession(node.id, `${name}@${rigName}`);
sessions.updateStatus(session.id, "running");
sessions.updateResumeToken(session.id, "claude_name", `native-${name}`, "hook");
return { node, session };
});
const predecessorSnapshot = snapshotCapture.captureSnapshot(rig.id, "manual");
sessions.updateStatus(previous.id, "exited");
const current = sessions.registerSession(paused.id, `paused@${rigName}`, "fresh");
sessions.updateStatus(current.id, "running");
if (mode !== "no-token") sessions.updateResumeToken(current.id, "codex_id", "00000000-0000-4000-8000-000000000002", "hook");
if (mode === "fresh-policy") db.prepare("UPDATE sessions SET restore_policy='relaunch_fresh' WHERE id=?").run(current.id);
sessions.updateBinding(paused.id, { tmuxSession: current.sessionName, tmuxPane: "%42" });
const generation = sessions.currentOccupantTenure(paused.id)!.generationUuid;
const freshEvent = { nodeId: paused.id, sessionId: current.id, newGeneration: generation };
db.prepare("INSERT INTO events(rig_id,node_id,type,payload) VALUES (?,?,'seat.fresh_launched',?)").run(rig.id, paused.id, JSON.stringify(freshEvent));
const earlierRunning = snapshotCapture.captureSnapshot(rig.id, "auto-pre-down");
const post = (url: string, body: unknown = {}) => h.app.request(url, {
method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body),
});
tmux.probeSession = vi.fn(async () => ({ state: "present" as const }));
const stopped = await post(`/api/seat/stop/${encodeURIComponent(current.sessionName)}`, { reason: "keep this current seat stopped" });
expect(stopped.status).toBe(200);
expect(await stopped.json()).toMatchObject({ ok: true, sessionId: current.id });
const down = await post("/api/down", { rigId: rig.id, force: true });
expect(down.status).toBe(200);
const { snapshotId, errors } = await down.json(); expect(errors).toEqual([]);
tmux.probeSession = vi.fn(async () => ({ state: "absent" as const }));
vi.mocked(tmux.createSession).mockClear(); vi.mocked(tmux.sendText).mockClear();
const snapshot = h.snapshotRepo.getSnapshot(snapshotId)!;
expect(snapshot.data.activeOccupantsByNode?.[paused.id]).toEqual({ kind: "absent" });
expect(snapshot.data.sessions.find(s => s.id === current.id)?.status).toBe("exited");
expect(sessions.currentOccupantTenure(paused.id)?.generationUuid).toBe(generation);
return { ...h, db, rig, paused, current, previous, generation, freshEvent, others, snapshot, earlierRunning, predecessorSnapshot, post };
}
describe("a deliberately stopped current fresh seat does not block its siblings", () => {
it("daemon start still skips a kernel with a deliberately stopped current fresh seat", async () => {
const f = await fixture("token", "kernel");
const restoreLostKernel = vi.fn(async () => ({ errors: [] as string[] }));
const restore = vi.spyOn(f.restoreOrchestrator, "restore");
expect(classifyManagedKernel(f.rigRepo, f.sessionRegistry)).toEqual({ kind: "stopped" });
const tracker = await bootKernelIfNeeded({
rigRepo: f.rigRepo, sessionRegistry: f.sessionRegistry, eventBus: f.eventBus,
bootstrapOrchestrator: f.bootstrapOrchestrator, specsDir: "/tmp", cwdOverride: "/tmp",
probeRuntimes: async () => ({ claudeCode: "ok", codex: "ok" }), log: () => {},
degradedTimeoutMs: 0, restoreLostKernel,
});
try {
expect(tracker.getStatus().kernelState).toBe("skipped");
expect(restoreLostKernel).not.toHaveBeenCalled();
expect(restore).not.toHaveBeenCalled();
expect(f.tmuxAdapter.createSession).not.toHaveBeenCalled();
expect(f.db.prepare("SELECT status,intentionally_stopped FROM sessions WHERE id=?").get(f.current.id))
.toEqual({ status: "exited", intentionally_stopped: 1 });
} finally { tracker.stop(); }
});
it("the shared unattended helper preserves the stopped seat when explicitly invoked", async () => {
const f = await fixture("no-token", "kernel");
const restore = vi.spyOn(f.restoreOrchestrator, "restore");
expect(await restoreExistingRigUnattended(f, f.rig.id, () => true)).toEqual({ errors: [] });
expect(restore).toHaveBeenCalledExactlyOnceWith(f.snapshot.id, expect.objectContaining({ automatic: true }));
const outcome = await restore.mock.results[0]!.value;
expect(outcome).toMatchObject({ ok: true, result: { nodes: expect.arrayContaining([
expect.objectContaining({ logicalId: "paused", status: "awaiting-decision" }),
]) } });
for (const { node } of f.others) {
expect(f.tmuxAdapter.sendText).toHaveBeenCalledWith(expect.any(String), expect.stringContaining(`--resume 'native-${node.logicalId}'`));
}
expect(vi.mocked(f.tmuxAdapter.createSession).mock.calls.every(([name]) => !name.includes("paused"))).toBe(true);
expect(f.db.prepare("SELECT status,intentionally_stopped FROM sessions WHERE id=?").get(f.current.id))
.toEqual({ status: "exited", intentionally_stopped: 1 });
expect(f.sessionRegistry.currentOccupantTenure(f.paused.id)?.generationUuid).toBe(f.generation);
expect(f.sessionRegistry.getBindingForNode(f.paused.id)).toBeNull();
});
it("a no-server down cannot turn detached snapshot history into a proved stopped fresh occupant", async () => {
const f = await fixture();
// A reboot-shaped row before explicit down: detached, not a prior deliberate stop.
f.sessionRegistry.updateStatus(f.current.id, "running");
f.sessionRegistry.markDetached(f.current.id);
const detached = f.snapshotCapture.captureSnapshot(f.rig.id, "auto-pre-down");
expect(detached.data.sessions.find(s => s.id === f.current.id)?.status).toBe("detached");
const terminal = noServerTerminal();
f.tmuxAdapter.probeSession = terminal.probeSession;
f.tmuxAdapter.startServer = terminal.startServer;
const result = await f.teardownOrchestrator.teardown(f.rig.id);
expect(result).toMatchObject({ alreadyStopped: true, errors: [] });
expect(terminal.startServer).toHaveBeenCalledTimes(1);
expect(f.db.prepare("SELECT status,intentionally_stopped FROM sessions WHERE id=?").get(f.current.id))
.toEqual({ status: "exited", intentionally_stopped: 1 });
expect(snapshotMatchesCurrentOccupants(f.db, f.rigRepo.getRig(f.rig.id)!, detached)).toBe(false);
expect(f.tmuxAdapter.createSession).not.toHaveBeenCalled();
});
it.each(["cli", "explorer"])("%s apply without a plan resumes from the accurate stopped snapshot", async entry => {
const f = await fixture();
const result = await f.post(entry === "cli" ? "/api/up" : `/api/rigs/${f.rig.id}/up`, { sourceRef: f.rig.name });
const body = await result.json();
expect(result.status, JSON.stringify(body)).toBe(200);
expect(body.snapshotId).toBe(f.snapshot.id);
expect(body.nodes.find((n: { logicalId: string }) => n.logicalId === "paused").status).toBe("awaiting-decision");
});
it.each(["cli", "explorer"].flatMap(entry => ["token", "no-token", "fresh-policy"].map(mode => ({ entry, mode: mode as "token" | "no-token" | "fresh-policy" }))))(
"$entry up keeps the stopped seat stopped ($mode), using the newest accurate snapshot", async ({ entry, mode }) => {
const f = await fixture(mode);
const up = (plan: boolean) => f.post(entry === "cli" ? "/api/up" : `/api/rigs/${f.rig.id}/up`, { sourceRef: f.rig.name, plan });
const restore = vi.spyOn(f.restoreOrchestrator, "restore");
const before = f.db.prepare("SELECT total_changes() AS n").get();
const planned = await up(true);
expect(planned.status).toBe(200);
const plan = await planned.json();
expect(plan).toMatchObject({ snapshot: { id: f.snapshot.id }, wouldCaptureCurrentState: false, mutated: false });
expect(plan.nodes.find((n: { logicalId: string }) => n.logicalId === "paused").intendedAction).toBe("awaiting-decision");
for (const { node, session } of f.others) {
expect(plan.nodes.find((n: { logicalId: string }) => n.logicalId === node.logicalId))
.toMatchObject({ occupantSessionId: session.id, intendedAction: "resume-original" });
}
expect(restore).not.toHaveBeenCalled();
expect(f.db.prepare("SELECT total_changes() AS n").get()).toEqual(before);
const applied = await up(false); expect(applied.status).toBe(200);
const result = await applied.json();
expect(result.nodes.find((n: { logicalId: string }) => n.logicalId === "paused").status).toBe("awaiting-decision");
expect(restore).toHaveBeenCalledExactlyOnceWith(f.snapshot.id, expect.any(Object));
expect(f.snapshot.id).not.toBe(f.earlierRunning.id);
for (const { node } of f.others) {
expect(f.tmuxAdapter.sendText).toHaveBeenCalledWith(expect.any(String), expect.stringContaining(`--resume 'native-${node.logicalId}'`));
}
expect(vi.mocked(f.tmuxAdapter.createSession).mock.calls.every(([name]) => !name.includes("paused"))).toBe(true);
expect(f.db.prepare("SELECT status,intentionally_stopped FROM sessions WHERE id=?").get(f.current.id))
.toEqual({ status: "exited", intentionally_stopped: 1 });
expect(f.sessionRegistry.currentOccupantTenure(f.paused.id)?.generationUuid).toBe(f.generation);
expect(f.sessionRegistry.getBindingForNode(f.paused.id)).toBeNull();
});
it.each(["ambiguous-current", "ambiguous-snapshot", "foreign-history", "missing-history", "no-stop-intent"])(
"still rejects an unproved stopped relation: %s", async kind => {
const f = await fixture(), snapshot = structuredClone(f.snapshot);
if (kind === "ambiguous-current") f.db.prepare("INSERT INTO events(rig_id,type,payload) VALUES (?,'seat.fresh_launched',?)").run(f.rig.id, JSON.stringify({ ...f.freshEvent, sessionId: f.previous.id }));
if (kind === "ambiguous-snapshot") snapshot.data.activeOccupantsByNode![f.paused.id] = { kind: "ambiguous", candidateIds: [f.current.id, f.previous.id] };
if (kind === "foreign-history") snapshot.data.sessions.find(s => s.id === f.current.id)!.nodeId = f.others[0]!.node.id;
if (kind === "missing-history") snapshot.data.sessions = snapshot.data.sessions.filter(s => s.id !== f.current.id);
if (kind === "no-stop-intent") f.db.prepare("UPDATE sessions SET intentionally_stopped=0 WHERE id=?").run(f.current.id);
expect(snapshotMatchesCurrentOccupants(f.db, f.rigRepo.getRig(f.rig.id)!, snapshot)).toBe(false);
expect(f.tmuxAdapter.createSession).not.toHaveBeenCalled();
});
it("does not accept an older snapshot when the current fresh occupant is not stopped", async () => {
const f = await fixture();
f.sessionRegistry.updateStatus(f.current.id, "running");
expect(snapshotMatchesCurrentOccupants(f.db, f.rigRepo.getRig(f.rig.id)!, f.predecessorSnapshot)).toBe(false);
expect(f.tmuxAdapter.createSession).not.toHaveBeenCalled();
});
});