merge: slice 22 — rig up paper-cut fix-round (release-0.3.2)

This commit is contained in:
mvschwarz committed 2026-06-01 04:02:37 -07:00
commit ea03680de4
16 files changed
+678 -18

No files matched your search

+48
View File
@@ -7,6 +7,14 @@ import type { StatusDeps } from "./status.js";
const LONG_RUNNING_UP_TIMEOUT_MS = 120_000;
// OPR.0.3.2.22 Bug 3 helper — mirrors cwd-resolution.isPathInsideRoot in
// the daemon so the CLI can decide whether a path-form sourceRef lives
// inside the daemon install root without importing the daemon package.
function isPathInsideRoot(candidate: string, root: string): boolean {
const relative = nodePath.relative(nodePath.resolve(root), nodePath.resolve(candidate));
return relative === "" || (!relative.startsWith("..") && !nodePath.isAbsolute(relative));
}
export function upCommand(
depsOverride?: StatusDeps & {
lifecycleDeps?: LifecycleDeps;
@@ -189,6 +197,46 @@ Examples:
const isRigBundle = !isRigName && /\.rigbundle$/i.test(sourceRef);
const targetRoot = opts.target ?? (isRigBundle ? process.cwd() : undefined);
// OPR.0.3.2.22 Bug 3 — extend the bare `rig up <builtin>` default-cwd
// treatment to path-form. Builtin starter specs declare member-level
// cwd: "." which resolves to the spec directory (inside the daemon's
// install root). Without --cwd that trips getOpenRigInstallCwdError
// at preflight. Bare-name form is already rescued at the
// resolveLibrarySpec branch above (entry.sourceType === "builtin");
// path-form `rig up <install-internal-spec>` is the remaining gap.
//
// Detection: source is path-form (not isRigName, not isRigBundle),
// no --cwd was given, no defaultLibraryCwdOverride was set by the
// bare-name branch above, AND the resolved path lives inside the
// daemon's install root (fetched via /api/info). When all hold,
// default cwdOverride to process.cwd() so the operator's project
// dir is used as launch cwd, and print a one-line notice. If
// /api/info is unavailable, fall through silently — the daemon
// preflight will still surface the install-cwd error.
//
// Structural redesign of how builtin starter specs declare cwd is
// out of scope (deferred to 0.3.3 per the slice triage).
if (!opts.cwd && !isRigName && !isRigBundle && defaultLibraryCwdOverride === undefined) {
try {
// Short timeout: a healthy daemon answers /api/info in <100ms; if
// it doesn't, fall through to the daemon's own preflight error
// rather than adding a multi-second stall to every rig up.
const infoRes = await client.get<{ installRoot?: string }>("/api/info", { timeoutMs: 2000 });
const installRoot = infoRes.data?.installRoot;
if (installRoot && isPathInsideRoot(sourceRef, installRoot)) {
defaultLibraryCwdOverride = process.cwd();
if (!opts.json) {
console.log("Defaulting cwd to current directory because the spec lives inside the OpenRig install.");
}
}
} catch {
// /api/info unavailable — fall through. The daemon preflight
// returns getOpenRigInstallCwdError if this is in fact the
// install-internal case; the operator gets the same hint they
// would have gotten pre-Bug-3.
}
}
const res = await client.post<Record<string, unknown>>("/api/up", {
sourceRef,
plan: opts.plan ?? false,
+107
View File
@@ -137,7 +137,13 @@ describe("Up CLI", () => {
lastBody = JSON.parse(body);
res.writeHead(201, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "completed", runId: "r", rigId: "g", stages: [], errors: [] }));
return;
}
// OPR.0.3.2.22 Bug 3 — path-form sourceRef triggers a /api/info
// call for install-root awareness; respond with a 404 so the CLI
// falls through (this test does not exercise the install-internal
// path-form gate).
res.writeHead(404).end();
});
await captureLogs(async () => {
@@ -220,7 +226,10 @@ describe("Up CLI", () => {
lastBody = JSON.parse(body);
res.writeHead(201, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "completed", runId: "r", rigId: "g", stages: [], errors: [] }));
return;
}
// OPR.0.3.2.22 Bug 3 /api/info fallback (404 → CLI falls through).
res.writeHead(404).end();
});
await captureLogs(async () => {
@@ -364,6 +373,104 @@ describe("Up CLI", () => {
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
});
// OPR.0.3.2.22 Bug 3 — path-form `rig up <spec-inside-install>` without
// --cwd should default cwdOverride to process.cwd() so the spec's
// member-level cwd: "." does not resolve into the OpenRig install root
// and trip getOpenRigInstallCwdError at preflight. The bare-name form
// is already rescued at the resolveLibrarySpec branch (covered by the
// earlier library-name test); this pins the path-form gap.
it("up path-form spec inside install root defaults cwdOverride to caller cwd and prints notice", async () => {
const fs = await import("node:fs");
const path = await import("node:path");
const os = await import("node:os");
const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), "up-bug3-install-"));
const specPath = path.join(installRoot, "rigs", "demo", "rig.yaml");
fs.mkdirSync(path.dirname(specPath), { recursive: true });
fs.writeFileSync(specPath, "version: '0.2'\nname: demo\npods: []\nedges: []\n");
let lastBody: Record<string, unknown> = {};
let infoCalls = 0;
const origListeners = server.listeners("request");
server.removeAllListeners("request");
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
let body = "";
for await (const chunk of req) body += chunk;
if (req.url === "/api/info" && req.method === "GET") {
infoCalls += 1;
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ installRoot }));
return;
}
if (req.url === "/api/up" && req.method === "POST") {
lastBody = JSON.parse(body);
res.writeHead(201, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "completed", runId: "r", rigId: "g", stages: [], errors: [] }));
return;
}
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "not found" }));
});
try {
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "up", specPath]);
});
expect(infoCalls, "expected /api/info to be queried for install-root awareness").toBe(1);
expect(lastBody.cwdOverride).toBe(process.cwd());
expect(logs.some((l) => l.includes("Defaulting cwd to current directory because the spec lives inside the OpenRig install"))).toBe(true);
} finally {
server.removeAllListeners("request");
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
fs.rmSync(installRoot, { recursive: true, force: true });
}
});
it("up path-form spec OUTSIDE install root leaves cwdOverride undefined", async () => {
const fs = await import("node:fs");
const path = await import("node:path");
const os = await import("node:os");
const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), "up-bug3-install-"));
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "up-bug3-project-"));
const specPath = path.join(projectRoot, "rig.yaml");
fs.writeFileSync(specPath, "version: '0.2'\nname: outside-rig\npods: []\nedges: []\n");
let lastBody: Record<string, unknown> = {};
const origListeners = server.listeners("request");
server.removeAllListeners("request");
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
let body = "";
for await (const chunk of req) body += chunk;
if (req.url === "/api/info" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ installRoot }));
return;
}
if (req.url === "/api/up" && req.method === "POST") {
lastBody = JSON.parse(body);
res.writeHead(201, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "completed", runId: "r", rigId: "g", stages: [], errors: [] }));
return;
}
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "not found" }));
});
try {
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "up", specPath]);
});
expect(lastBody.cwdOverride).toBeUndefined();
expect(logs.some((l) => l.includes("Defaulting cwd to current directory because the spec lives inside the OpenRig install"))).toBe(false);
} finally {
server.removeAllListeners("request");
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
fs.rmSync(installRoot, { recursive: true, force: true });
fs.rmSync(projectRoot, { recursive: true, force: true });
}
});
it("up apply uses a long-running daemon timeout budget", async () => {
let timeoutMs: number | undefined;
const post = vi.fn(async (_path: string, _body: unknown, options?: { timeoutMs?: number }) => {
@@ -777,6 +777,21 @@ export class BootstrapOrchestrator {
status: "failed",
detail: { code: bootResult.code, error: bootResult.error, receipt: bootResult.receipt },
});
// OPR.0.3.2.22 Bug 2 follow-up — serviceOrch.boot can already have
// started compose resources before failing during status/wait. The
// PodRigInstantiator will delete the rig record next, which cascades
// away rig_services and the normal teardown handle — so any
// already-started compose containers would orphan. Tear them down
// here best-effort while the rig handle still exists. Teardown
// errors are swallowed so they cannot mask the boot failure that
// is the load-bearing return.
try {
await serviceOrch.teardown(rigId);
} catch {
// Best-effort. If teardown also fails, the boot-failure error
// is what the operator needs; manual `docker compose down`
// remains available with the compose file path from the spec.
}
return { ok: false, code: "service_boot_failed", message: `Service boot failed: ${bootResult.error}` };
}
@@ -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 };
}
}
@@ -49,6 +49,24 @@ function isYamlFile(filename: string): boolean {
return filename.endsWith(".yaml") || filename.endsWith(".yml");
}
// OPR.0.3.2.22 Bug 4 — noise directories that should never enter the
// spec library walk. The set matches progress-indexer.ts:81 exactly.
// frontmatter-validator.ts:103 is a related scanner that uses a
// narrower subset (node_modules / .git / .worktrees / dist / build);
// if this list and the progress-indexer list ever diverge, reconcile
// there first. The `.worktrees` entry closes the stale-row class
// where conveyor.yaml inside a worktree was sliding into the library
// cache and producing the `rig specs show conveyor` ambiguous-match UX.
const SKIP_DIRS = new Set([
".worktrees",
"node_modules",
".git",
"dist",
"build",
".turbo",
".next",
]);
function walkYamlFiles(rootPath: string): string[] {
const files: string[] = [];
const stack = [rootPath];
@@ -65,6 +83,7 @@ function walkYamlFiles(rootPath: string): string[] {
for (const entry of entries) {
const absPath = join(current, entry.name);
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name)) continue;
stack.push(absPath);
continue;
}
@@ -326,6 +326,46 @@ export class WorkflowSpecCache {
return result.changes;
}
/**
* OPR.0.3.2.22 Bug 4 — prune cached rows whose source_path lives in
* noise directories that the post-Bug-4 walkYamlFiles SKIP_DIRS
* guard now refuses to scan. Without this prune, rows that were
* inserted before the SKIP_DIRS guard landed would survive forever
* (the scanner cleanup at spec-library-workflow-scanner.ts only
* fires for paths starting with the workspace `workflows/` folder
* prefix). Called once at startup. Returns the number of rows
* removed.
*
* installRoot guard (Bug 4 follow-up): when supplied, rows whose
* source_path starts with the install root are PRESERVED even if
* they match a noise pattern. This is the load-bearing safety for
* shipped built-in workflow specs that live at
* `<pkg>/dist/builtins/workflow-specs/` in production npm-published
* daemons — without this guard the unscoped DELETE would nuke
* every shipped built-in on every boot. Pass
* `getOpenRigInstallRoot()` from cwd-resolution at the call site.
* When omitted, no install-root preservation is applied (test-only
* convenience for tests that operate fully outside any install).
*/
pruneNoiseDirRows(installRoot?: string): number {
const guardClause = installRoot ? ` AND source_path NOT LIKE ? || '%'` : "";
const params: string[] = installRoot ? [installRoot] : [];
const result = this.db
.prepare(
`DELETE FROM workflow_specs WHERE (
source_path LIKE '%/.worktrees/%'
OR source_path LIKE '%/node_modules/%'
OR source_path LIKE '%/.git/%'
OR source_path LIKE '%/dist/%'
OR source_path LIKE '%/build/%'
OR source_path LIKE '%/.turbo/%'
OR source_path LIKE '%/.next/%'
)${guardClause}`,
)
.run(...params);
return result.changes;
}
getByIdOrThrow(specId: string): WorkflowSpecRow {
const row = this.db
.prepare(`SELECT * FROM workflow_specs WHERE spec_id = ?`)
+22
View File
@@ -0,0 +1,22 @@
import { Hono } from "hono";
import { getOpenRigInstallRoot } from "../domain/cwd-resolution.js";
// GET /api/info — daemon-info surface for tactical CLI awareness paths
// (OPR.0.3.2.22 Bug 3 first consumer: CLI default-cwd extension for
// path-form `rig up <install-internal-spec>` without --cwd).
//
// installRoot is the daemon's on-disk install root (the parent of the
// daemon's package directory). The CLI uses it to detect when a spec
// path lives inside the OpenRig install — the case that hits
// getOpenRigInstallCwdError at preflight without a --cwd override.
export function infoRoutes(): Hono {
const app = new Hono();
app.get("/", (c) => {
return c.json({
installRoot: getOpenRigInstallRoot(),
});
});
return app;
}
+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 });
+2
View File
@@ -42,6 +42,7 @@ import type { PsProjectionService } from "./domain/ps-projection.js";
import type { UpCommandRouter } from "./domain/up-command-router.js";
import type { RigTeardownOrchestrator } from "./domain/rig-teardown.js";
import { upRoutes } from "./routes/up.js";
import { infoRoutes } from "./routes/info.js";
import { downRoutes } from "./routes/down.js";
import { kernelStatusRoutes } from "./routes/kernel-status.js";
import type { TranscriptStore } from "./domain/transcript-store.js";
@@ -483,6 +484,7 @@ export function createApp(deps: AppDeps): Hono {
app.route("/api/bundles", bundleRoutes);
app.route("/api/ps", psRoutes);
app.route("/api/up", upRoutes);
app.route("/api/info", infoRoutes());
app.route("/api/down", downRoutes);
app.route("/api/kernel", kernelStatusRoutes);
app.route("/api/transcripts", transcriptRoutes());
+19
View File
@@ -907,6 +907,25 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
// `<workspace.specs_root>/workflows`; SettingsStore resolves
// workspaceSpecsRoot from env > config > workspace-default.
deps.workflowSpecCache = workflowRuntime.specCache;
// OPR.0.3.2.22 Bug 4 — one-time prune of cache rows whose source_path
// lives in noise directories (.worktrees, node_modules, etc.). The
// post-Bug-4 walkYamlFiles SKIP_DIRS guard prevents NEW rows from
// those locations, but legacy rows from prior daemon versions (or
// operators who hand-imported a spec via path-form before SKIP_DIRS
// shipped) would persist without this prune. Cheap (single DELETE
// with bounded LIKE patterns) and safe (matches only the same
// directories the walker now refuses to enter).
//
// installRoot guard: shipped built-in workflow specs live at
// `<pkg>/dist/builtins/workflow-specs/` in production. Without the
// install-root preservation clause, the `%/dist/%` pattern would
// delete them on every boot (then loadStarterWorkflowSpecs re-seeds
// — wasteful at best, broken if the loader ever skips re-seeding).
// Pass the resolved install root so rows under it are preserved.
const { getOpenRigInstallRoot } = await import("./domain/cwd-resolution.js");
workflowRuntime.specCache.pruneNoiseDirRows(getOpenRigInstallRoot());
try {
const settingsStore = new ContextPackSettingsStore();
const cfg = settingsStore.resolveConfig();
+22
View File
@@ -0,0 +1,22 @@
import { describe, it, expect } from "vitest";
import { Hono } from "hono";
import { infoRoutes } from "../src/routes/info.js";
import { getOpenRigInstallRoot } from "../src/domain/cwd-resolution.js";
// OPR.0.3.2.22 Bug 3 — daemon-info surface for tactical CLI awareness
// paths. The CLI consumes /api/info to decide when path-form
// `rig up <spec>` lands inside the OpenRig install (the case that
// trips getOpenRigInstallCwdError at preflight unless --cwd is given).
describe("GET /api/info", () => {
it("returns the daemon installRoot computed from cwd-resolution", async () => {
const app = new Hono();
app.route("/api/info", infoRoutes());
const res = await app.request("/api/info");
expect(res.status).toBe(200);
const body = await res.json() as { installRoot: string };
expect(body.installRoot).toBe(getOpenRigInstallRoot());
expect(typeof body.installRoot).toBe("string");
expect(body.installRoot.length).toBeGreaterThan(0);
});
});
@@ -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 = {
+76 -5
View File
@@ -185,14 +185,85 @@ 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");
});
// OPR.0.3.2.22 Bug 2 follow-up (guard BLOCKING on a29c7883) — when
// boot returns ok:false after compose may have started (e.g.
// wait_timeout), the prelaunch-hook MUST call teardown best-effort
// before the rig record is deleted. Otherwise compose resources
// orphan: rig_services cascade-removes with the rig, taking the
// normal teardown handle with it. Discriminator: assert teardown
// was called on boot failure AND the rig record is still removed.
it("service boot failure tears down compose before rig record is deleted (no compose orphan)", async () => {
const composePath = path.join(tmpDir, "docker-compose.yml");
fs.writeFileSync(composePath, "version: '3'\nservices:\n vault:\n image: vault:1.15\n");
const specPath = writeSpec(`
version: "0.2"
name: teardown-on-boot-failure-rig
services:
kind: compose
compose_file: docker-compose.yml
wait_for:
- url: http://localhost:8200/v1/sys/health
pods:
- id: infra
label: Infra
members:
- id: server
runtime: terminal
agent_ref: "builtin:terminal"
profile: none
cwd: /tmp
edges: []
`);
const composeAdapter = new ComposeServicesAdapter(mockExec());
const rigRepo = new RigRepository(db);
const serviceOrch = new ServiceOrchestrator({ rigRepo, composeAdapter });
vi.spyOn(serviceOrch, "boot").mockResolvedValue({
ok: false,
code: "wait_timeout",
error: "Service wait targets not healthy after 30s: HTTP probe failed: http://localhost:8200/v1/sys/health",
receipt: {
kind: "compose",
composeFile: "docker-compose.yml",
projectName: "teardown-on-boot-failure-rig",
services: [{ name: "vault", status: "running", health: "starting" }],
waitFor: [{ target: { url: "http://localhost:8200/v1/sys/health" }, status: "unhealthy", detail: "HTTP probe failed" }],
capturedAt: new Date().toISOString(),
},
});
const teardownSpy = vi.spyOn(serviceOrch, "teardown").mockResolvedValue({ ok: true });
const setup = createTestApp(db);
(setup.bootstrapOrchestrator as any).deps.serviceOrchestrator = serviceOrch;
(setup.bootstrapOrchestrator as any).deps.rigRepo = rigRepo;
const result = await setup.bootstrapOrchestrator.bootstrap({
sourceRef: specPath,
mode: "apply",
});
expect(result.status).toBe("failed");
// Discriminator: teardown MUST be called when boot fails — that is
// the bug guard caught (compose orphan downstream of rig deletion).
expect(teardownSpy, "expected serviceOrch.teardown to be called best-effort on boot failure").toHaveBeenCalledTimes(1);
// And the rig record is still rolled back (Bug 2 original contract).
const orphans = rigRepo.findRigsByName("teardown-on-boot-failure-rig");
expect(orphans).toHaveLength(0);
});
it("plan mode does not boot services", async () => {
const composePath = path.join(tmpDir, "docker-compose.yml");
fs.writeFileSync(composePath, "version: '3'\nservices:\n vault:\n image: vault:1.15\n");
@@ -169,4 +169,29 @@ describe("SpecLibraryService", () => {
expect(rigs).toHaveLength(1);
expect(rigs[0]!.relativePath).toBe("rigs/launch/demo/rig.yaml");
});
// OPR.0.3.2.22 Bug 4 — walkYamlFiles now skips noise directories
// (.worktrees, node_modules, .git, dist, build, .turbo, .next). The
// conveyor.yaml under .worktrees/release-0.3.0-... is the load-bearing
// case from the openrig-comms paper-cut report; this pins the
// SKIP_DIRS guard at the SpecLibraryService.scan() seam.
it("scan skips yaml files inside noise directories (SKIP_DIRS)", () => {
writeFileSync(join(tmpDir, "canonical.yaml"), VALID_RIG_YAML);
const NOISE_DIRS = [".worktrees", "node_modules", ".git", "dist", "build", ".turbo", ".next"];
for (const dir of NOISE_DIRS) {
mkdirSync(join(tmpDir, dir, "deeper"), { recursive: true });
writeFileSync(join(tmpDir, dir, "deeper", "noise.yaml"), VALID_RIG_YAML.replace("test-rig", `noise-${dir}`));
}
const lib = createLibrary();
lib.scan();
const entries = lib.list();
expect(entries, `expected only canonical rig (got ${JSON.stringify(entries.map((e) => e.relativePath))})`).toHaveLength(1);
expect(entries[0]!.sourcePath).toContain("canonical.yaml");
for (const dir of NOISE_DIRS) {
expect(entries.every((e) => !e.sourcePath.includes(`/${dir}/`)), `entry from ${dir} leaked through SKIP_DIRS`).toBe(true);
}
});
});
+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");
});
});
});
@@ -157,4 +157,75 @@ describe("WorkflowSpecCache (PL-004 Phase D)", () => {
const found = cache.getByNameVersion("test-three-step", "1");
expect(found?.specId).toBe(cached.specId);
});
// OPR.0.3.2.22 Bug 4 — startup prune removes legacy cache rows whose
// source_path lives in noise directories that walkYamlFiles' new
// SKIP_DIRS guard now refuses to scan. Without this prune, stale
// rows from before SKIP_DIRS shipped would survive forever and keep
// showing up in `rig specs show` / `rig specs preview` candidates.
it("pruneNoiseDirRows removes cache rows from .worktrees/node_modules paths and preserves canonical-path rows", () => {
const fixtures: Array<{ path: string; specName: string; noise: boolean }> = [
{ path: join(tmp, "workflows", "canon.yaml"), specName: "canonical-spec", noise: false },
{ path: join(tmp, ".worktrees", "feature-branch", "workflows", "stale.yaml"), specName: "stale-worktree-spec", noise: true },
{ path: join(tmp, "node_modules", "@vendor", "spec", "stale.yaml"), specName: "stale-node-modules-spec", noise: true },
{ path: join(tmp, "dist", "stale.yaml"), specName: "stale-dist-spec", noise: true },
];
const { mkdirSync } = require("node:fs") as typeof import("node:fs");
for (const f of fixtures) {
mkdirSync(join(f.path, ".."), { recursive: true });
writeFileSync(f.path, SAMPLE_SPEC.replace("test-three-step", f.specName));
cache.readThrough(f.path);
}
expect(cache.listAll()).toHaveLength(4);
const removed = cache.pruneNoiseDirRows();
expect(removed, "expected 3 noise rows removed (.worktrees + node_modules + dist)").toBe(3);
const remaining = cache.listAll();
expect(remaining).toHaveLength(1);
expect(remaining[0]!.sourcePath).toBe(fixtures[0]!.path);
});
it("pruneNoiseDirRows returns 0 when there are no noise rows", () => {
const canonicalPath = join(tmp, "workflows", "canon.yaml");
const { mkdirSync } = require("node:fs") as typeof import("node:fs");
mkdirSync(join(canonicalPath, ".."), { recursive: true });
writeFileSync(canonicalPath, SAMPLE_SPEC);
cache.readThrough(canonicalPath);
expect(cache.pruneNoiseDirRows()).toBe(0);
expect(cache.listAll()).toHaveLength(1);
});
// OPR.0.3.2.22 Bug 4 follow-up (guard BLOCKING on 79d06f8d) —
// shipped built-in workflow specs live at
// `<pkg>/dist/builtins/workflow-specs/` in production
// npm-published daemons. The unscoped prune from the prior commit
// matched `%/dist/%` and would have nuked every built-in on every
// boot. The installRoot guard preserves rows whose source_path
// starts with the install root.
it("pruneNoiseDirRows with installRoot preserves built-in rows under <installRoot>/dist while removing user noise", () => {
const installRoot = join(tmp, "install", "@openrig", "daemon");
const builtinPath = join(installRoot, "dist", "builtins", "workflow-specs", "shipped.yaml");
const userNoisePath = join(tmp, "user-workspace", "some-project", "dist", "stale.yaml");
const { mkdirSync } = require("node:fs") as typeof import("node:fs");
for (const p of [builtinPath, userNoisePath]) {
mkdirSync(join(p, ".."), { recursive: true });
}
writeFileSync(builtinPath, SAMPLE_SPEC.replace("test-three-step", "shipped-builtin-spec"));
writeFileSync(userNoisePath, SAMPLE_SPEC.replace("test-three-step", "user-noise-spec"));
cache.readThrough(builtinPath);
cache.readThrough(userNoisePath);
expect(cache.listAll()).toHaveLength(2);
const removed = cache.pruneNoiseDirRows(installRoot);
expect(removed, "expected only the user-noise row removed; built-in must survive").toBe(1);
const remaining = cache.listAll();
expect(remaining).toHaveLength(1);
expect(remaining[0]!.sourcePath).toBe(builtinPath);
});
});