mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 11:51:37 -10:00
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:
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 = {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user