mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-12 18:48:31 -10:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
026d7b35dd | ||
|
|
ac0a739bea | ||
|
|
c43256dba8 | ||
|
|
fe3848e82b | ||
|
|
9364a4ef6e |
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 } : {}),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user