fix(slice-22): import_rig stage failures surface HTTP 4xx + top-level code (OPR.0.3.2.22 Bug 1 BONUS)

Closes the homepage rig up <builtin> bare-500 UX trap from a fresh
0.3.1 install: when the daemon's import_rig stage failed with a code
like cycle_error, the daemon returned HTTP 500 with the real code
buried in stages[N].detail.code. The CLI's code-specific branches
(packages/cli/src/commands/up.ts:208-227) never fired because
res.data["code"] was undefined at top level.

The fix:
- Extend the apply-mode 4xx mapping in /api/up to include import_rig
  stage failures with known codes: cycle_error, preflight_failed,
  validation_failed, service_boot_failed. resolve_spec stage mapping
  is preserved as-is.
- Surface the failure code at the top level of the response body
  (spread result + add code) when a 4xx-mappable code is found, so
  the CLI can branch on res.data["code"].

Plan-mode mapping (line 178-184) is NOT changed: pod-aware plan only
runs resolve_spec + preflight stages and never reaches import_rig, so
the existing resolve_spec-only check is correct for that path.

Bug 1 itself (the conveyor delegates_to cycle) was already closed on
main via f3449baf, brought in by merge dc57b1e0 (conveyor-trust
recoverable-state fix). That cherry-pick was therefore a no-op and
was explicitly skipped per the slice-22 dispatch ruling on
qitem-20260601044405-584f6be2 state-query reply. This commit ships
the in-scope BONUS that makes operator UX of any future
import_rig-stage failure visible at the correct surface, not just
the conveyor case.

Verification:
- HG-1 supporting evidence: existing apply-mode cycle_error case
  (covered by the conveyor builtin spec + f3449baf regression gate
  spec-library-starters.test.ts) plus this commit's new route-level
  pin: apply-mode delegates_to cycle returns HTTP 400 with
  code=cycle_error at top level.
- Regression: full packages/daemon test/ suite green (271 files,
  3987+ tests; 2 cold-start timeouts on schema-packages migration
  tests cleared on retry, pre-existing baseline flakiness — not
  introduced by this change).
- tsc --noEmit on @openrig/daemon EXIT=0.

Branch: slice-22-rig-up-paper-cut-fix-round off LOCAL main 0c9e63f1.
LOCAL only, NO push. Worktree at
openrig/.worktrees/slice-22-rig-up-paper-cut-fix-round.
This commit is contained in:
mvschwarz committed 2026-05-31 22:01:09 -07:00
1 parent 0c9e63f10e
commit 5048842701
2 files changed
+131 -3

No files matched your search

+20 -3
View File
@@ -294,12 +294,29 @@ upRoutes.post("/", async (c) => {
}
eventBus.emit({ type: "bootstrap.failed", runId: result.runId, sourceRef, error: result.errors[0] ?? "failed" });
const hasBlocked = result.stages.some((s) => s.status === "blocked");
// OPR.0.3.2.22 Bug 1 BONUS — surface the failure code at the TOP LEVEL
// and include import_rig-stage codes (cycle_error, preflight_failed,
// validation_failed, service_boot_failed) in the 4xx map. CLI up.ts
// already branches on res.data["code"] for these codes; before this
// fix the daemon left the code buried in stages[N].detail.code and
// returned a bare 500, which is exactly what the openrig-comms
// hero-flow dogfood hit on a fresh 0.3.1 install.
let topLevelCode: string | undefined;
const hasBadRequest = result.stages.some((s) => {
if (s.stage !== "resolve_spec") return false;
if (s.status !== "failed") return false;
const detail = s.detail as { code?: string } | undefined;
return detail?.code === "file_not_found" || detail?.code === "parse_error" || detail?.code === "validation_failed" || detail?.code === "bundle_error" || detail?.code === "cycle_error" || detail?.code === "invalid_cwd";
const code = detail?.code;
if (!code) return false;
const isResolveSpec4xx = s.stage === "resolve_spec" && (code === "file_not_found" || code === "parse_error" || code === "validation_failed" || code === "bundle_error" || code === "cycle_error" || code === "invalid_cwd");
const isImportRig4xx = s.stage === "import_rig" && (code === "validation_failed" || code === "preflight_failed" || code === "cycle_error" || code === "service_boot_failed");
if (isResolveSpec4xx || isImportRig4xx) {
topLevelCode ??= code;
return true;
}
return false;
});
return c.json(result, hasBlocked ? 409 : hasBadRequest ? 400 : 500);
const failedBody = topLevelCode ? { ...result, code: topLevelCode } : result;
return c.json(failedBody, hasBlocked ? 409 : hasBadRequest ? 400 : 500);
} catch (err) {
bootstrapRepo.updateRunStatus(run.id, "failed");
eventBus.emit({ type: "bootstrap.failed", runId: run.id, sourceRef, error: (err as Error).message });
+111
View File
@@ -794,4 +794,115 @@ edges: []
db = { close: () => undefined } as unknown as Database.Database;
});
});
// --- OPR.0.3.2.22 Bug 1 BONUS — import_rig stage failures with known codes
// (cycle_error, preflight_failed, validation_failed, service_boot_failed)
// surface as HTTP 4xx with the code at the TOP LEVEL of the response
// body. CLI up.ts:208-227 already branches on res.data["code"] for
// these codes — the daemon just has to put the code where the CLI
// looks, instead of leaving operators staring at a bare 500 with the
// real cause buried in stages[N].detail.code.
//
// The conveyor delegates_to cycle (f3449baf already on main) is the
// direct manifestation reported by the openrig-comms hero-flow
// dogfood. This regression also pins the more general contract.
describe("OPR.0.3.2.22 Bug 1 BONUS: import_rig stage failures map to HTTP 4xx with top-level code", () => {
let specDir: string;
let app3: ReturnType<typeof createTestApp>["app"];
let db3: Database.Database;
beforeEach(() => {
specDir = fs.mkdtempSync(path.join(os.tmpdir(), "up-route-import-rig-fail-"));
for (const name of ["builder", "reviewer"]) {
const agentDir = path.join(specDir, "agents", name);
fs.mkdirSync(agentDir, { recursive: true });
fs.writeFileSync(
path.join(agentDir, "agent.yaml"),
`name: ${name}\nversion: "1.0.0"\nresources:\n skills: []\nprofiles:\n default:\n uses:\n skills: []\n`,
);
}
db3 = createFullTestDb();
const realRouterFsOps = {
exists: (p: string) => fs.existsSync(p),
readFile: (p: string) => fs.readFileSync(p, "utf-8"),
readHead: (p: string, n: number) => {
const buf = Buffer.alloc(n);
const fd = fs.openSync(p, "r");
try { fs.readSync(fd, buf, 0, n, 0); } finally { fs.closeSync(fd); }
return buf;
},
};
const realInstantiatorFsOps = {
exists: (p: string) => fs.existsSync(p),
readFile: (p: string) => fs.readFileSync(p, "utf-8"),
};
const setup3 = createTestApp(db3, {
upRouterFsOps: realRouterFsOps,
podInstantiatorFsOps: realInstantiatorFsOps,
});
app3 = setup3.app;
});
afterEach(() => {
db3.close();
fs.rmSync(specDir, { recursive: true, force: true });
});
it("apply-mode delegates_to cycle returns HTTP 400 with code=cycle_error at top level", async () => {
const yaml = `version: "0.2"
name: cycle-bug-rig
pods:
- id: build
label: Build
members:
- id: builder
agent_ref: local:agents/builder
profile: default
runtime: claude-code
cwd: .
edges: []
- id: review
label: Review
members:
- id: reviewer
agent_ref: local:agents/reviewer
profile: default
runtime: claude-code
cwd: .
edges: []
edges:
- from: build.builder
to: review.reviewer
kind: delegates_to
- from: review.reviewer
to: build.builder
kind: delegates_to
`;
const specPath = path.join(specDir, "cycle.yaml");
fs.writeFileSync(specPath, yaml, "utf-8");
const res = await app3.request("/api/up", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sourceRef: specPath }),
});
const bodyForDebug = await res.clone().json();
expect(res.status, `expected 400 for import_rig cycle_error, got ${res.status} with body ${JSON.stringify(bodyForDebug)}`).toBe(400);
const body = await res.json() as { code?: string; stages?: Array<{ stage: string; status: string; detail?: { code?: string } }> };
// The load-bearing fix: code surfaces at TOP LEVEL where CLI looks.
expect(body.code, `expected top-level code=cycle_error, got body=${JSON.stringify(body)}`).toBe("cycle_error");
// Provenance discriminator — proves the code came from import_rig stage
// (not a resolve_spec misclassification that the existing 400-mapping
// path was already handling).
const importRigStage = (body.stages ?? []).find((s) => s.stage === "import_rig");
expect(importRigStage, `expected import_rig stage in body.stages, got ${JSON.stringify(body.stages)}`).toBeDefined();
expect(importRigStage!.status).toBe("failed");
expect(importRigStage!.detail?.code).toBe("cycle_error");
});
});
});