fix(workflow-spec): unique roles per step in openrig-velocity + projection-shape test

Forward-fix #3 on dcf1994. Guard re-review found the dcf1994 spec was
not coherent under the projector contract. projectSpecGraph
deduplicates by actor_role (keeps the FIRST step per role); the
prior YAML had TWO steps with actor_role: orch (dispatch + merge).
Result: vm-verify's `next_hop.suggested_roles: [orch]` resolved to
dispatch (the kept step) → loop-back instead of forward to merge.
refine had no incoming edge (orphan) and merge had no incoming edge
(orphan). 6 steps, 4 edges, 2 orphans, 1 loop-back.

Resolution: split the orch role into two uniquely-named roles —
`orch-dispatch` (entry) and `orch-merge` (terminal) — both with
preferred_targets pointing at orch-lead@openrig-velocity since the
same human seat performs both in practice. vm-verify now routes to
orch-merge.

Resulting graph (6 nodes, 5 edges, no orphans):
  dispatch → refine → implement → guard-review → vm-verify → merge

Discriminator test added in starter-spec-loader.test.ts: loads the
shipped spec, calls projectSpecGraph(spec, null), asserts the EXACT
edge chain + entry placement (dispatch) + terminal placement (merge)
+ zero orphans (every non-entry node has incoming + every non-terminal
node has outgoing). The prior "has guard role" assertion was a weak
discriminator; this shape-test catches the role-collision class
banked feedback_poc_regression_must_discriminate calls out.

Verified: pre-fix YAML (dcf1994) fails the new discriminator test
(temporarily reverted only the YAML, kept the new test; FAIL on that
case; restored YAML to PASS).

Full daemon suite: 3379 passed / 1 skipped / 0 failed on clean run.
git diff --check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-11 08:26:44 -07:00
1 parent dcf1994567
commit ac7d199450
2 files changed
+74 -22

No files matched your search

@@ -6,24 +6,30 @@
# `workflow_spec: [email protected]` and projected onto the mission
# Topology tab by the missions route.
#
# Flow (one slice end-to-end):
# orch dispatches → advisor refines (optional) → driver implements
# → guard narrowly reviews → qa exercises in disposable VM → orch
# merges + closes. Single frontier (v1 projector contract).
# Flow (one slice end-to-end, linear single-frontier chain):
# orch-dispatch → advisor (refine) → driver (implement) →
# guard (review) → qa (vm-verify) → orch-merge.
#
# Roles are uniquely named per step so the projector's role→step
# resolution doesn't collapse two orch steps onto the same node (the
# projector keeps the FIRST step per actor_role; identical roles on
# two steps would orphan the second). orch-dispatch + orch-merge both
# preferred_targets to orch-lead@openrig-velocity since the same human
# seat performs both roles in practice.
workflow:
id: openrig-velocity
version: "1.0"
objective: >
Drive one slice from dispatch through merge using the
openrig-velocity rig's role chain (orch / advisor / driver /
guard / qa).
openrig-velocity rig's role chain (orch-dispatch / advisor / driver /
guard / qa / orch-merge).
target:
rig: openrig-velocity
entry:
role: orch
role: orch-dispatch
coordination_terminal_turn_rule: hot_potato
roles:
orch:
orch-dispatch:
skill_refs: [openrig-user, orchestration-team]
preferred_targets: [orch-lead@openrig-velocity]
advisor:
@@ -38,14 +44,17 @@ workflow:
qa:
skill_refs: [openrig-user, dogfood, verification-before-completion]
preferred_targets: [velocity-qa@openrig-velocity]
orch-merge:
skill_refs: [openrig-user, orchestration-team]
preferred_targets: [orch-lead@openrig-velocity]
steps:
- id: dispatch
actor_role: orch
objective: Author the dispatch packet + queue handoff to driver.
actor_role: orch-dispatch
objective: Author the dispatch packet + queue handoff to advisor or driver.
allowed_exits: [handoff, waiting, failed]
next_hop:
mode: require
suggested_roles: [driver]
suggested_roles: [advisor]
- id: refine
actor_role: advisor
objective: Author or refine the IMPL-PRD before driver picks up.
@@ -73,9 +82,9 @@ workflow:
allowed_exits: [handoff, waiting, failed]
next_hop:
mode: require
suggested_roles: [orch]
suggested_roles: [orch-merge]
- id: merge
actor_role: orch
actor_role: orch-merge
objective: Merge the slice to main + close the dispatch loop.
allowed_exits: [done, failed]
next_hop:
@@ -25,6 +25,7 @@ import { coreSchema } from "../src/db/migrations/001_core_schema.js";
import { workflowSpecsSchema } from "../src/db/migrations/033_workflow_specs.js";
import { WorkflowSpecCache } from "../src/domain/workflow-spec-cache.js";
import { loadStarterWorkflowSpecs, defaultBuiltinSpecsDir } from "../src/domain/workflow/starter-spec-loader.js";
import { projectSpecGraph } from "../src/domain/workflow/slice-workflow-projection.js";
const ALPHA_SPEC = `workflow:
id: alpha-spec
@@ -156,19 +157,61 @@ describe("built-in workflow spec loader", () => {
});
// V0.3.1 slice 13 walk-item 7 — load the actual shipped builtins
// directory and assert [email protected] is in it. This is what
// release-0.3.1/README.md's frontmatter declares; without the spec
// present, the mission Topology tab falls back to the session-name
// union (velocity-qa VM caught exactly this on tip ef3e94d).
it("shipped builtins include [email protected] (mission Topology acceptance for slice 13)", () => {
// directory and assert [email protected] projects to a coherent
// linear graph. velocity-qa VM caught the missing-spec case on
// ef3e94d; guard re-review on 10ed741 caught the role-collision
// case (two steps sharing actor_role 'orch' → projector kept only
// the first → orphan nodes). This test is the projection-shape
// discriminator per banked feedback_poc_regression_must_discriminate:
// exact edge chain + zero orphans + entry/terminal placement.
it("shipped [email protected] projects to a 6-node linear chain with no orphans (mission Topology acceptance)", () => {
const shippedDir = defaultBuiltinSpecsDir();
const result = loadStarterWorkflowSpecs({ cache, builtinDir: shippedDir });
expect(result.errors).toEqual([]);
const row = cache.getByNameVersion("openrig-velocity", "1.0");
expect(row).not.toBeNull();
expect(row!.spec.steps.length).toBeGreaterThan(0);
// Discriminate against the basic-loop spec (which is also in the
// shipped dir): openrig-velocity must have a 'guard' role.
expect(Object.keys(row!.spec.roles)).toContain("guard");
const graph = projectSpecGraph(row!.spec, null);
// Six declared steps; six projected nodes.
expect(graph.nodes.map((n) => n.stepId)).toEqual([
"dispatch",
"refine",
"implement",
"guard-review",
"vm-verify",
"merge",
]);
// Linear chain — exact edges (newer→older permitted only at the
// terminal merge step which has no outgoing edge).
expect(
graph.edges.map((e) => `${e.fromStepId}→${e.toStepId}`),
).toEqual([
"dispatch→refine",
"refine→implement",
"implement→guard-review",
"guard-review→vm-verify",
"vm-verify→merge",
]);
// Entry + terminal placement.
const entryNodes = graph.nodes.filter((n) => n.isEntry).map((n) => n.stepId);
expect(entryNodes).toEqual(["dispatch"]);
const terminalNodes = graph.nodes.filter((n) => n.isTerminal).map((n) => n.stepId);
expect(terminalNodes).toEqual(["merge"]);
// No orphans: every non-entry node has at least one incoming edge
// and every non-terminal node has at least one outgoing edge.
const incoming = new Set(graph.edges.map((e) => e.toStepId));
const outgoing = new Set(graph.edges.map((e) => e.fromStepId));
for (const node of graph.nodes) {
if (!node.isEntry) {
expect(incoming.has(node.stepId), `${node.stepId} has no incoming edge`).toBe(true);
}
if (!node.isTerminal) {
expect(outgoing.has(node.stepId), `${node.stepId} has no outgoing edge`).toBe(true);
}
}
});
});