fix(slice-22): no orphan rig record on pre-launch failure (OPR.0.3.2.22 Bug 2)

Pre-fix, every failed `rig up <pod-aware-builtin>` left a stopped-state
rig record under the spec name. The instantiator called createRig
BEFORE computing the launch order (cycle check), so a cycle returned
ok:false after the row was already committed. The prelaunch service
hook had the same shape: createRig + pod records committed before the
hook ran, and a service_boot_failed return bailed without rollback.

Operator-facing consequence: the next `rig up <spec>` retry hit the
orphan as a same-named library candidate, surfacing the "ambiguous
library-spec vs restore-target" message — the second-order UX trap
behind the openrig-comms cross-rig paper-cut report
(qitem-20260601002117-c4307ad9 / -003939-9c1da48b / -004200-48af2592).

The fix (rigspec-instantiator.ts, pod-aware PodRigInstantiator):

(1) Reorder: computePodLaunchOrder now runs BEFORE createRig. A
    cycle is detected and returned without any DB write at all.

(2) Rollback on service_boot_failed: when the prelaunch hook fails,
    deleteRig(rigId) is called before returning ok:false. Pods,
    rig_services row, and other rig-scoped state cascade-delete via
    the FK ON DELETE CASCADE wired in migrations 001 (pods) /
    014 (pods reboot variant) / 020 (rig_services). Spec name is
    left free for a clean retry.

Test additions (pod-rigspec-instantiator.test.ts):

- cycle_error: no orphan rig record persists after a two-node
  delegates_to cycle. Discriminator-flip verified by removing the
  reorder: orphan persists (FAIL pre-fix), absent (PASS post-fix).
- service_boot_failed: orphan rolled back after a prelaunch-hook
  failure. Same discriminator-flip pattern.

Test alignment (services-up.test.ts):

- The "service boot failure blocks agent launch with honest error"
  test previously codified the orphan behavior: it asserted
  `expect(createdRig).toBeDefined()` after service_boot_failed.
  Replaced with `expect(findRigsByName).toHaveLength(0)` since the
  rollback is the deliberate design change shipping in this commit.
  The "no agent sessions launched" guarantee follows transitively
  from "no rig". service_boot stage status assertion preserved.

Verification:
- HG-3 acceptance: pod-rigspec-instantiator.test.ts new cycle_error
  + service_boot_failed orphan-rollback tests both PASS.
- Touched suites (pod-rigspec-instantiator + services-up + up-route):
  57/57 PASS.
- Full packages/daemon test/ suite: 271 files, 3990 PASS + 1 skipped,
  ZERO failures.
- tsc --noEmit on @openrig/daemon EXIT=0.

Branch: slice-22-rig-up-paper-cut-fix-round off LOCAL main 0c9e63f1.
Tip 50488427 (Bug 1 BONUS) → this commit. LOCAL only, NO push.
This commit is contained in:
mvschwarz committed 2026-05-31 22:06:12 -07:00
1 parent 5048842701
commit a29c78834f
3 files changed
+89 -15

No files matched your search

@@ -645,7 +645,21 @@ export class PodRigInstantiator {
return { ok: false, code: "preflight_failed", errors: preflight.errors, warnings: preflight.warnings };
}
// 3. Create rig
// 3. Compute launch order from edges (rejects cycles)
//
// OPR.0.3.2.22 Bug 2: this MUST run BEFORE createRig. Pre-fix, a
// cycle in the spec left an orphan stopped-state rig record because
// createRig had already committed before the cycle check ran. The
// operator-facing consequence was the "ambiguous library-spec vs
// restore-target" UX trap on the very next `rig up <builtin>` retry.
let launchOrder: string[];
try {
launchOrder = this.computePodLaunchOrder(rigSpec);
} catch (err) {
return { ok: false, code: "cycle_error", message: (err as Error).message };
}
// 4. Create rig (after cycle check passes)
let rigId: string;
try {
const rig = this.deps.rigRepo.createRig(rigSpec.name);
@@ -659,14 +673,6 @@ export class PodRigInstantiator {
return { ok: false, code: "instantiate_error", message: (err as Error).message };
}
// 4. Compute launch order from edges (rejects cycles)
let launchOrder: string[];
try {
launchOrder = this.computePodLaunchOrder(rigSpec);
} catch (err) {
return { ok: false, code: "cycle_error", message: (err as Error).message };
}
// 5. Create pods + nodes + edges, then launch in topological order
const nodeResults: { logicalId: string; status: "launched" | "failed" | "attention_required"; error?: string; evidence?: string; sessionName?: string }[] = [];
const nodeIdMap: Record<string, string> = {}; // "pod.member" -> node DB id
@@ -701,10 +707,15 @@ export class PodRigInstantiator {
const orderMap = new Map(launchOrder.map((id, i) => [id, i]));
memberEntries.sort((a, b) => (orderMap.get(a.qualifiedId) ?? 999) - (orderMap.get(b.qualifiedId) ?? 999));
// Prelaunch hook: service gate runs after topology setup, before any node launch
// Prelaunch hook: service gate runs after topology setup, before any node launch.
//
// OPR.0.3.2.22 Bug 2: if the hook fails, roll back the rig record so
// the spec name is left free for a clean retry. Pods rely on rigs
// via ON DELETE CASCADE so deleting the rig is sufficient.
if (opts?.prelaunchHook) {
const hookResult = await opts.prelaunchHook(rigId);
if (!hookResult.ok) {
this.deps.rigRepo.deleteRig(rigId);
return { ok: false, code: "service_boot_failed", message: hookResult.message };
}
}
@@ -604,6 +604,66 @@ profiles:
db.close();
});
// OPR.0.3.2.22 Bug 2 — no orphan rig record on cycle_error.
// Reorder fix: computePodLaunchOrder must run BEFORE createRig so a
// detected cycle returns before any DB write happens. Before the fix,
// every failed `rig up <builtin>` attempt left an orphan stopped-state
// rig record that made retries report "ambiguous library-spec vs
// restore-target" — the second-order UX trap behind the openrig-comms
// hero-flow paper-cut.
it("cycle_error: no orphan rig record persists (Bug 2 reorder)", async () => {
const files = {
[`${RIG_ROOT}/agents/a/agent.yaml`]: agentYaml("a"),
[`${RIG_ROOT}/agents/b/agent.yaml`]: agentYaml("b"),
};
const { db, rigRepo, inst } = setup(files);
const specName = "orphan-cycle-test-rig";
const spec = makeRigSpec({
name: specName,
pods: [{
id: "dev", label: "Dev",
members: [
{ id: "a", agentRef: "local:agents/a", profile: "default", runtime: "claude-code", cwd: "." },
{ id: "b", agentRef: "local:agents/b", profile: "default", runtime: "claude-code", cwd: "." },
],
edges: [
{ kind: "delegates_to", from: "a", to: "b" },
{ kind: "delegates_to", from: "b", to: "a" },
],
}],
});
const yaml = RigSpecCodec.serialize(spec);
const result = await inst.instantiate(yaml, RIG_ROOT);
expect(result.ok).toBe(false);
if (!result.ok && "code" in result) {
expect(result.code).toBe("cycle_error");
}
// Load-bearing: no rig record persisted under the spec name.
const orphans = rigRepo.findRigsByName(specName);
expect(orphans, `expected no orphan rig records after cycle_error, found ${JSON.stringify(orphans)}`).toHaveLength(0);
db.close();
});
// OPR.0.3.2.22 Bug 2 — rollback on service_boot_failed (prelaunch-hook
// failure). Unlike cycle_error, the rig record + pods have been created
// by the time the hook runs (the hook needs rigId), so the fix wraps
// the failure return with rigRepo.deleteRig(rigId).
it("service_boot_failed: rolls back the created rig record (Bug 2 prelaunch-hook rollback)", async () => {
const { db, rigRepo, inst } = setup();
const specName = "orphan-prelaunch-test-rig";
const yaml = RigSpecCodec.serialize(makeRigSpec({ name: specName }));
const result = await inst.instantiate(yaml, RIG_ROOT, {
prelaunchHook: async () => ({ ok: false, code: "service_boot_failed", message: "test: service boot refused" }),
});
expect(result.ok).toBe(false);
if (!result.ok && "code" in result) {
expect(result.code).toBe("service_boot_failed");
}
const orphans = rigRepo.findRigsByName(specName);
expect(orphans, `expected no orphan rig records after service_boot_failed, found ${JSON.stringify(orphans)}`).toHaveLength(0);
db.close();
});
// NS-T05: orphan tmux sessions killed on total failure
it("kills orphan tmux sessions on total failure", async () => {
const files = {
+8 -5
View File
@@ -185,11 +185,14 @@ pods:
// Service boot stage should show failed
const serviceStage = result.stages.find((s) => s.stage === "service_boot");
expect(serviceStage).toBeDefined();
// No agent sessions should have been launched
const createdRig = rigRepo.findRigsByName("failing-services-rig")[0];
expect(createdRig).toBeDefined();
const sessions = createdRig ? setup.sessionRegistry.getSessionsForRig(createdRig.id) : [];
expect(sessions).toHaveLength(0);
// OPR.0.3.2.22 Bug 2: pre-fix, a service_boot_failed left an orphan rig
// record (no sessions, but the rig existed) that produced the
// "ambiguous library-spec vs restore-target" UX trap on the next retry.
// Post-fix, the prelaunch-hook failure rolls back the rig record, so
// the spec name stays free for a clean retry — and the "no agent
// sessions launched" guarantee follows transitively from "no rig".
const orphans = rigRepo.findRigsByName("failing-services-rig");
expect(orphans, `expected no orphan rig records after service_boot_failed, found ${JSON.stringify(orphans)}`).toHaveLength(0);
expect(serviceStage!.status).toBe("failed");
});