fix(daemon): remove conveyor starter launch cycle

QA fresh-install dogfood for release-0.3.2 slice 01 found that the
built-in conveyor starter could not launch on a clean isolated daemon.
The reviewer-to-builder rework path was modeled as delegates_to, which
is also a launch-order dependency, creating a hard build/review cycle.

Model that rework visibility as can_observe instead. The workflow spec
still owns the review-to-build rework path. Add a bundled starter-spec
regression gate so shipped rig specs cannot carry launch-dependency
cycles again.

Verification:
- npm test --workspace @openrig/daemon -- spec-library-starters.test.ts
- npm test --workspace @openrig/cli -- test/workspace-max-files-validation.test.ts test/workflow-exit-validation.test.ts test/workflow-ux.test.ts
- npm run build --workspace @openrig/daemon
- HG-7 isolated daemon fresh-install dogfood through workflow instantiate
This commit is contained in:
mvschwarz committed 2026-05-17 16:08:46 -07:00
1 parent 2b4772c785
commit f3449bafdd
2 files changed
+77 -1

No files matched your search

@@ -58,7 +58,7 @@ edges:
- kind: delegates_to
from: build.builder
to: review.reviewer
- kind: delegates_to
- kind: can_observe
from: review.reviewer
to: build.builder
- kind: can_observe
@@ -77,6 +77,74 @@ const STARTER_AGENT_SPECS = [
"agents/orchestration/orchestrator/agent.yaml",
];
function assertAcyclicLaunchDependencyGraph(file: string, parsed: Record<string, unknown>): void {
const pods = (parsed["pods"] as Array<Record<string, unknown>> | undefined) ?? [];
const allIds: string[] = [];
const inDegree: Record<string, number> = {};
const adjacency: Record<string, string[]> = {};
for (const pod of pods) {
const podId = String(pod["id"]);
const members = (pod["members"] as Array<Record<string, unknown>> | undefined) ?? [];
for (const member of members) {
const qid = `${podId}.${String(member["id"])}`;
allIds.push(qid);
inDegree[qid] = 0;
adjacency[qid] = [];
}
}
const addDependency = (from: string, to: string) => {
if (!adjacency[from] || !adjacency[to]) return;
adjacency[from]!.push(to);
inDegree[to] = (inDegree[to] ?? 0) + 1;
};
const addEdge = (edge: Record<string, unknown>, qualify?: (id: string) => string) => {
const kind = String(edge["kind"]);
if (kind !== "delegates_to" && kind !== "spawned_by") return;
const rawFrom = String(edge["from"]);
const rawTo = String(edge["to"]);
const from = qualify ? qualify(rawFrom) : rawFrom;
const to = qualify ? qualify(rawTo) : rawTo;
if (kind === "delegates_to") {
addDependency(from, to);
} else {
addDependency(to, from);
}
};
for (const pod of pods) {
const podId = String(pod["id"]);
const edges = (pod["edges"] as Array<Record<string, unknown>> | undefined) ?? [];
for (const edge of edges) {
addEdge(edge, (id) => `${podId}.${id}`);
}
}
const edges = (parsed["edges"] as Array<Record<string, unknown>> | undefined) ?? [];
for (const edge of edges) {
addEdge(edge);
}
const queue = allIds.filter((id) => inDegree[id] === 0).sort();
const visited: string[] = [];
while (queue.length > 0) {
const current = queue.shift()!;
visited.push(current);
for (const next of (adjacency[current] ?? []).sort()) {
inDegree[next] = (inDegree[next] ?? 1) - 1;
if (inDegree[next] === 0) queue.push(next);
queue.sort();
}
}
if (visited.length !== allIds.length) {
const cycled = allIds.filter((id) => !visited.includes(id));
throw new Error(`${file} has a launch dependency cycle among: ${cycled.join(", ")}`);
}
}
describe("Starter specs", () => {
const specReviewService = new SpecReviewService();
@@ -203,6 +271,14 @@ describe("Starter specs", () => {
}
});
it("all rig specs have acyclic launch-dependency edges", () => {
for (const file of RIG_SPECS) {
const yaml = readFileSync(join(SPECS_ROOT, file), "utf-8");
const parsed = parseYaml(yaml) as Record<string, unknown>;
assertAcyclicLaunchDependencyGraph(file, parsed);
}
});
it("service-backed proof rigs pass canonical rigPreflight with explicit cwdOverride", () => {
const fsOps = {
readFile: (p: string) => readFileSync(p, "utf-8"),