diff --git a/packages/engine/src/__tests__/_workflow-vocabulary-fixture.ts b/packages/engine/src/__tests__/_workflow-vocabulary-fixture.ts new file mode 100644 index 0000000000..06b164cfcb --- /dev/null +++ b/packages/engine/src/__tests__/_workflow-vocabulary-fixture.ts @@ -0,0 +1,112 @@ +/* +FNXC:WorkflowLifecycleColumns 2026-07-28-11:10 (shared E2E vocabulary fixture): + +ONE definition of the renamed-vs-default vocabularies and ONE workflow builder, +shared by every live-engine E2E in this directory. + +Extracted (a pure move — the lifecycle suite it came from is unchanged) the moment +a SECOND suite needed it. Two copies of a differential fixture is the failure this +whole program keeps hitting: the copies drift, and then a renamed-workflow test +passes for a reason that has nothing to do with the code under test. The +differential only means anything while both vocabularies come from one builder and +differ ONLY in their four column ids. +*/ +import type { WorkflowIr } from "@fusion/core"; + +/** Staleness threshold declared by the hold column's U4 recovery policy. */ +export const HOLD_STALENESS_MS = 60 * 60_000; + +/** The four lifecycle roles this program's guards are supposed to resolve by TRAIT, not by id. */ +export interface Vocabulary { + readonly hold: string; + readonly wip: string; + readonly review: string; + readonly complete: string; +} + +/** The legacy ids. A guard keyed on a string literal passes here for the wrong reason. */ +export const DEFAULT_VOCAB: Vocabulary = { + hold: "todo", + wip: "in-progress", + review: "in-review", + complete: "done", +}; + +/** No id overlaps the legacy enum. A guard keyed on a string literal goes silent here. */ +export const RENAMED_VOCAB: Vocabulary = { + hold: "backlog", + wip: "building", + review: "checking", + complete: "shipped", +}; + +/** + * ONE workflow shape, two vocabularies. Structurally identical down to node ids and edges so a + * behavioral delta between the two runs can only come from the column ids. + * + * The shape is the lifecycle spine: a hold column that the scheduler releases on capacity, a WIP + * column that holds the slot, a review column, and a terminal complete column. + */ +export interface LifecycleIrOptions { + /* Adds the `merge` trait (flag `mergeOrchestration`) to the review column, which + is what `resolveMergeOrchestrationColumn` keys on. OPT-IN so the lifecycle + suite's IR stays byte-identical to what it was written against — a shared + fixture must not silently change an existing suite's subject. */ + readonly mergeOrchestration?: boolean; +} + +export function lifecycleIr(v: Vocabulary, id: string, options: LifecycleIrOptions = {}): WorkflowIr { + return { + version: "v2", + id, + name: `lifecycle-${id}`, + columns: [ + { + id: v.hold, + name: "Hold", + traits: [{ trait: "hold", config: { release: "capacity" } }], + /* U4 workflow-declared recovery policy (#2478). Declared on the HOLD column of both + vocabularies from the one builder, so the reconciler's role resolution is exercised + against a renamed column with nothing else differing. */ + recovery: { stalenessMs: HOLD_STALENESS_MS, onStale: { action: "surface", code: "e2e-stale-hold" } }, + }, + { + id: v.wip, + name: "Wip", + traits: [{ trait: "wip", config: { limitSetting: "maxConcurrent", countPending: true } }, { trait: "timing" }], + }, + { + id: v.review, + name: "Review", + traits: [ + { trait: "human-review" }, + { trait: "merge-blocker" }, + ...(options.mergeOrchestration ? [{ trait: "merge" }] : []), + ], + }, + { id: v.complete, name: "Complete", traits: [{ trait: "complete" }] }, + ], + nodes: [ + { id: "start", kind: "start", column: v.hold }, + { id: "plan", kind: "prompt", column: v.hold, config: { seam: "planning" } }, + { id: "exec", kind: "prompt", column: v.wip, config: { seam: "execute" } }, + { id: "review", kind: "prompt", column: v.review, config: { seam: "review" } }, + /* A real merge-class node. The IR validator REFUSES a `merge-blocker` column with no + reachable merge-class node ("the gate can never clear without one") — discovered by this + file, and worth keeping: it means the review column here is a genuinely gated one rather + than a decorative label. `merge-gate` itself is pure policy (reads autoMerge, emits + auto-on/auto-off) so it needs no git. */ + { id: "merge-gate", kind: "merge-gate", column: v.review, config: { gate: "auto-merge" } }, + { id: "end", kind: "end", column: v.complete }, + ], + edges: [ + { from: "start", to: "plan" }, + { from: "plan", to: "exec", condition: "success" }, + { from: "exec", to: "review", condition: "success" }, + { from: "review", to: "merge-gate", condition: "success" }, + { from: "merge-gate", to: "end", condition: "success" }, + ], + } as WorkflowIr; +} + + diff --git a/packages/engine/src/__tests__/workflow-lifecycle-live-e2e.pg.test.ts b/packages/engine/src/__tests__/workflow-lifecycle-live-e2e.pg.test.ts index 228eb95495..a22838bb4e 100644 --- a/packages/engine/src/__tests__/workflow-lifecycle-live-e2e.pg.test.ts +++ b/packages/engine/src/__tests__/workflow-lifecycle-live-e2e.pg.test.ts @@ -52,92 +52,10 @@ import { import { WorkflowGraphTaskRunner, type WorkflowColumnBoundaryHooks } from "../workflow-graph-task-runner.js"; import { createExecutorColumnBoundaryHooks } from "../workflow-column-boundary-hooks.js"; import { runHoldReleaseSweep } from "../hold-release.js"; +import { DEFAULT_VOCAB, RENAMED_VOCAB, HOLD_STALENESS_MS, lifecycleIr, type Vocabulary } from "./_workflow-vocabulary-fixture.js"; import { SelfHealingManager } from "../self-healing.js"; import { reconcileRecovery } from "../recovery-reconciler.js"; -/** The four lifecycle roles this program's guards are supposed to resolve by TRAIT, not by id. */ -interface Vocabulary { - readonly hold: string; - readonly wip: string; - readonly review: string; - readonly complete: string; -} - -/** The legacy ids. A guard keyed on a string literal passes here for the wrong reason. */ -const DEFAULT_VOCAB: Vocabulary = { - hold: "todo", - wip: "in-progress", - review: "in-review", - complete: "done", -}; - -/** No id overlaps the legacy enum. A guard keyed on a string literal goes silent here. */ -const RENAMED_VOCAB: Vocabulary = { - hold: "backlog", - wip: "building", - review: "checking", - complete: "shipped", -}; - -/** - * ONE workflow shape, two vocabularies. Structurally identical down to node ids and edges so a - * behavioral delta between the two runs can only come from the column ids. - * - * The shape is the lifecycle spine: a hold column that the scheduler releases on capacity, a WIP - * column that holds the slot, a review column, and a terminal complete column. - */ -function lifecycleIr(v: Vocabulary, id: string): WorkflowIr { - return { - version: "v2", - id, - name: `lifecycle-${id}`, - columns: [ - { - id: v.hold, - name: "Hold", - traits: [{ trait: "hold", config: { release: "capacity" } }], - /* U4 workflow-declared recovery policy (#2478). Declared on the HOLD column of both - vocabularies from the one builder, so the reconciler's role resolution is exercised - against a renamed column with nothing else differing. */ - recovery: { stalenessMs: HOLD_STALENESS_MS, onStale: { action: "surface", code: "e2e-stale-hold" } }, - }, - { - id: v.wip, - name: "Wip", - traits: [{ trait: "wip", config: { limitSetting: "maxConcurrent", countPending: true } }, { trait: "timing" }], - }, - { - id: v.review, - name: "Review", - traits: [{ trait: "human-review" }, { trait: "merge-blocker" }], - }, - { id: v.complete, name: "Complete", traits: [{ trait: "complete" }] }, - ], - nodes: [ - { id: "start", kind: "start", column: v.hold }, - { id: "plan", kind: "prompt", column: v.hold, config: { seam: "planning" } }, - { id: "exec", kind: "prompt", column: v.wip, config: { seam: "execute" } }, - { id: "review", kind: "prompt", column: v.review, config: { seam: "review" } }, - /* A real merge-class node. The IR validator REFUSES a `merge-blocker` column with no - reachable merge-class node ("the gate can never clear without one") — discovered by this - file, and worth keeping: it means the review column here is a genuinely gated one rather - than a decorative label. `merge-gate` itself is pure policy (reads autoMerge, emits - auto-on/auto-off) so it needs no git. */ - { id: "merge-gate", kind: "merge-gate", column: v.review, config: { gate: "auto-merge" } }, - { id: "end", kind: "end", column: v.complete }, - ], - edges: [ - { from: "start", to: "plan" }, - { from: "plan", to: "exec", condition: "success" }, - { from: "exec", to: "review", condition: "success" }, - { from: "review", to: "merge-gate", condition: "success" }, - { from: "merge-gate", to: "end", condition: "success" }, - ], - } as WorkflowIr; -} - -const HOLD_STALENESS_MS = 60 * 60_000; - const OK = { outcome: "success" as const }; /** Records which seams actually ran, so "exactly once" is asserted on real invocations. */ @@ -760,6 +678,9 @@ two self-healing sweeps verified PER SITE — reverting one fails exactly its ow - recovery-reconciler.ts column-declared policy lookup (table row, returned-decision — WEAKER) - hold-release.ts isHeldTask / the capacity release (spine) - the graph column boundary + store.moveTask + the post-commit bus (spine) + - auto-merge-finalization.ts completeColumn / mergeColumn / isCompleteColumn + (workflow-merge-family-live-e2e.pg.test.ts; each of the three mutation-verified + INDEPENDENTLY — the mergeColumn one needed its own case, see below) FINDING — AN UNREACHABLE EXPORT. `resolveRoleRecovery` (recovery-reconciler.ts:194) is the ONLY use of `resolveLifecycleColumns` in that file, and it has NO production caller: `decideRecovery` @@ -772,7 +693,6 @@ by the U4 slice, and guessing which is a decision for its author. NOT PROVEN end to end — real callers this suite does not reach: - merger.ts:324-326 resolveCompleteColumn / resolveMergeOrchestrationColumn / resolveReboundTarget - merger-ai.ts:1022,1039 resolveReboundTarget, resolveLifecycleColumns - - auto-merge-finalization.ts:20-22 completeColumn / mergeColumn / isCompleteColumn - executor.ts:1763,6339,6341 rebound target, merge-orchestration probe, complete column - self-healing.ts:713,6732 resolveReboundTarget (two distinct rebound paths) - mesh-lease-manager.ts:61 resolveReboundTarget @@ -782,8 +702,21 @@ NOT PROVEN end to end — real callers this suite does not reach: - dashboard register-task-workflow-routes.ts:151,166,175,1797 WHY, and what each would take: - - The merge/rebound family (merger, merger-ai, auto-merge-finalization, the executor rebound path, - mesh-lease-manager) needs a REAL git worktree, branch, and squash. This suite deliberately has + - The merge/rebound family (merger, merger-ai, the executor rebound path, mesh-lease-manager) + needs a REAL git worktree, branch, and squash. + + CORRECTION (2026-07-28): this bullet used to include auto-merge-finalization, and that was + too broad. `finalizeProvenAutoMergeTask` needs NO git — the merge proof is a field on the + row — so it was reachable all along and is now covered. The lesson is worth keeping: "needs + a real-git lane" was inferred from the family the code sits in rather than from what the + function actually touches, and that inference parked reachable coverage for a whole slice. + Re-check the remaining entries the same way before assuming they need the lane. + + A second correction from the same slice: `resolveMergeOrchestrationColumn` was FIRST claimed + as covered because it sits in the same resolver as the other two. Mutation-testing it showed + all cases passing with it hardcoded — it changes only whether finalization records a + column-mismatch REPAIR, never where the card lands. It needed a dedicated audit-row + assertion. Sitting next to covered code is not coverage. This suite deliberately has none — `merge-gate` is pure policy and the `merge` seam is scripted. They need an engine-slow real-git lane, not another table row. - The dashboard sites need an HTTP route test with a live store: reachable, different lane. diff --git a/packages/engine/src/__tests__/workflow-merge-family-live-e2e.pg.test.ts b/packages/engine/src/__tests__/workflow-merge-family-live-e2e.pg.test.ts new file mode 100644 index 0000000000..1474f4784f --- /dev/null +++ b/packages/engine/src/__tests__/workflow-merge-family-live-e2e.pg.test.ts @@ -0,0 +1,217 @@ +/* +FNXC:WorkflowMergeFinalization 2026-07-28-11:40 (E2E — closing merge-family ledger entries): + +WHAT THIS CLOSES. The unproven-sites ledger in workflow-lifecycle-live-e2e says the +merge/rebound family "needs a REAL git worktree, branch, and squash … an engine-slow +real-git lane, not another table row". That is true of the MERGER, but it turned out +to be too broad: `finalizeProvenAutoMergeTask` is the step that actually moves a +proven-merged card to the workflow's COMPLETE column, it takes a real `TaskStore`, +and it needs no git at all — the merge proof is a field on the row. So three ledger +entries are reachable today without building that lane: + + auto-merge-finalization.ts resolveCompleteColumn -> the card's destination + auto-merge-finalization.ts resolveMergeOrchestrationColumn -> the pre-complete lane + auto-merge-finalization.ts columnHasFlag(ir, col, "complete") -> the already-done classifier + +WHY IT MATTERS MORE THAN ITS SIZE. This is the last move a card makes. If +`resolveCompleteColumn` silently returned the literal `done` for a workflow whose +complete column is `shipped`, a proven-merged card would be moved to a column its +own workflow does not declare — or refused and left stranded in review with the work +already landed. That is the most expensive failure shape in the lifecycle, and until +now nothing had run it against a renamed workflow. + +SUBSTITUTION BOUNDARY. Only the merge PROOF is seeded (`mergeConfirmed`), which is +what a real merger would have written. Everything downstream — column resolution, +the move, its guards, persistence — is real, and every assertion reads the persisted +row back through `getTask`. +*/ +import { beforeAll, beforeEach, afterEach, afterAll, describe, expect, it } from "vitest"; +import "@fusion/core"; // registers the built-in column traits +import type { MergeResult, TaskStore } from "@fusion/core"; + +import { + pgDescribe, + createSharedPgTaskStoreTestHarness, + type SharedPgTaskStoreHarness, +} from "../../../core/src/__test-utils__/pg-test-harness.js"; +import { finalizeProvenAutoMergeTask } from "../auto-merge-finalization.js"; +import { DEFAULT_VOCAB, RENAMED_VOCAB, lifecycleIr, type Vocabulary } from "./_workflow-vocabulary-fixture.js"; + +pgDescribe("live merge finalization E2E: real store, renamed complete column", () => { + const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({ + prefix: "fusion_merge_family_e2e", + }); + + beforeAll(h.beforeAll); + beforeEach(h.beforeEach); + afterEach(h.afterEach); + afterAll(h.afterAll); + + /** Persist the workflow and return the id the STORE assigned — it allocates its + * own `WF-###` and ignores the one in the input, and binding to the id we passed + * in would silently resolve to the DEFAULT builtin IR instead. */ + async function seedWorkflow(v: Vocabulary, key: string): Promise { + const created = await h.store().createWorkflowDefinition({ + name: `Merge family ${key}`, + kind: "workflow", + // `mergeOrchestration` opt-in: the review column carries the `merge` trait, + // which is the flag `resolveMergeOrchestrationColumn` keys on. + ir: lifecycleIr(v, `custom:${key}`, { mergeOrchestration: true }), + } as never); + return (created as { id: string }).id; + } + + /** A card resting in the workflow's merge-orchestration column, carrying durable + * merge proof — exactly the state a real merger leaves behind. */ + async function seedProvenMergedTask(taskId: string, v: Vocabulary, workflowId: string): Promise { + const store = h.store(); + await store.createTaskWithReservedId( + { description: `merged ${taskId}`, column: v.hold } as never, + { taskId, applyDefaultWorkflowSteps: false } as never, + ); + await store.writeTaskWorkflowSelection(taskId, workflowId, []); + // Walk the card into the review lane through the REAL transition policy rather + // than writing the column directly, so the row is one a real run could produce. + await store.moveTask(taskId, v.wip, { moveSource: "user" } as never); + await store.moveTask(taskId, v.review, { moveSource: "user", allowDirectInReviewMove: true } as never); + /* Steps must be COMPLETE. `getTaskHardMergeBlocker` refuses a card with incomplete + steps ("task has incomplete steps"), and a genuinely merged card has none + outstanding. Set explicitly rather than assumed empty: task creation parses + steps out of the bootstrap PROMPT even with `applyDefaultWorkflowSteps: false`, + so a freshly created card arrives carrying three pending ones. Discovered by + this suite blocking on BOTH vocabularies — the signature of a broken fixture + rather than a broken guard. */ + await store.updateTask(taskId, { + steps: [{ name: "implementation", status: "done" }], + mergeDetails: { mergeConfirmed: true }, + } as never); + store.taskCache.delete(taskId); + } + + async function persistedColumn(taskId: string): Promise { + const store = h.store(); + store.taskCache.delete(taskId); + return (await store.getTask(taskId)).column as string; + } + + const proof = { mergeConfirmed: true } as unknown as MergeResult; + + async function finalize(taskId: string) { + return finalizeProvenAutoMergeTask({ + store: h.store() as TaskStore, + taskId, + result: proof, + source: "direct-ai-merge", + } as never); + } + + describe.each([ + { label: "RENAMED vocabulary", vocab: RENAMED_VOCAB, key: "renamed" }, + { label: "DEFAULT vocabulary (regression floor)", vocab: DEFAULT_VOCAB, key: "default" }, + ])("$label", ({ vocab, key }) => { + it("moves a proven-merged card into the workflow's COMPLETE column", async () => { + const taskId = `FN-MF-${key}-1`; + const workflowId = await seedWorkflow(vocab, `${key}-1`); + await seedProvenMergedTask(taskId, vocab, workflowId); + + expect(await persistedColumn(taskId)).toBe(vocab.review); + + const outcome = await finalize(taskId); + + expect({ outcome: outcome.outcome, reason: outcome.reason }).toEqual({ outcome: "done", reason: undefined }); + // Observed state, not the return value: the row actually landed in the + // workflow's own complete column. + expect(await persistedColumn(taskId)).toBe(vocab.complete); + expect(outcome.previousColumn).toBe(vocab.review); + }); + + it("classifies a card ALREADY in the complete column as already-done, not a second move", async () => { + /* This is `columnHasFlag(ir, col, "complete")`. Keyed on the literal `done`, a + renamed board's finished card reads as unfinished and finalization tries to + move it again — the idempotency this classifier provides is what stops a + retry from re-finalizing. */ + const taskId = `FN-MF-${key}-2`; + const workflowId = await seedWorkflow(vocab, `${key}-2`); + await seedProvenMergedTask(taskId, vocab, workflowId); + await finalize(taskId); + expect(await persistedColumn(taskId)).toBe(vocab.complete); + + const second = await finalize(taskId); + + expect(second.outcome).toBe("already-done"); + expect(await persistedColumn(taskId)).toBe(vocab.complete); + }); + }); + + it("does NOT record a column-mismatch REPAIR when the card was resting in the merge lane", async () => { + /* + This is `resolveMergeOrchestrationColumn`, and it is observable only here. + `shouldRecoveryRehome = latest.column !== mergeColumn` decides whether + finalization treats this as a normal completion or as REPAIRING a stranded card: + the repair branch writes a `task:auto-merge-finalize-column-mismatch-reconciled` + audit row and a log entry saying the column mismatch was fixed. + + Keyed on the literal `in-review`, a renamed board's card resting in `checking` + compares unequal, so EVERY ordinary finalization would be recorded as a repair + of a mismatch that never existed — a healthy board would read as one constantly + self-healing, and the audit trail that operators use to spot real strandings + would be full of false positives. The card still reaches `shipped` either way, + which is exactly why the other cases in this file cannot see it. + */ + const workflowId = await seedWorkflow(RENAMED_VOCAB, "renamed-lane"); + await seedProvenMergedTask("FN-MF-LANE", RENAMED_VOCAB, workflowId); + + const outcome = await finalize("FN-MF-LANE"); + + expect(outcome.outcome).toBe("done"); + expect(await persistedColumn("FN-MF-LANE")).toBe(RENAMED_VOCAB.complete); + + const audit = await h.store().getRunAuditEventsAsync({ taskId: "FN-MF-LANE" }); + const repairs = audit.filter((e) => e.mutationType === "task:auto-merge-finalize-column-mismatch-reconciled"); + expect(repairs).toEqual([]); + }); + + it("never lands a renamed board's card in a legacy column id", async () => { + /* The differential. Both vocabularies run the identical code path above; this + asserts the renamed run touched none of the legacy ids, which is the single + claim the vocabulary conversion rests on. */ + const workflowId = await seedWorkflow(RENAMED_VOCAB, "renamed-diff"); + await seedProvenMergedTask("FN-MF-DIFF", RENAMED_VOCAB, workflowId); + + const outcome = await finalize("FN-MF-DIFF"); + + const legacy = new Set(Object.values(DEFAULT_VOCAB)); + expect(outcome.outcome).toBe("done"); + expect(legacy.has(await persistedColumn("FN-MF-DIFF"))).toBe(false); + expect(legacy.has(outcome.previousColumn as string)).toBe(false); + }); + + it("refuses to finalize a card with NO merge proof, on a renamed board", async () => { + /* The negative half. "Resolve the complete column per workflow" must not become + "move anything in the review lane to done" — the proof gate is what keeps an + unmerged card out of the terminal column. */ + const store = h.store(); + const workflowId = await seedWorkflow(RENAMED_VOCAB, "renamed-noproof"); + await store.createTaskWithReservedId( + { description: "unproven", column: RENAMED_VOCAB.hold } as never, + { taskId: "FN-MF-NOPROOF", applyDefaultWorkflowSteps: false } as never, + ); + await store.writeTaskWorkflowSelection("FN-MF-NOPROOF", workflowId, []); + await store.moveTask("FN-MF-NOPROOF", RENAMED_VOCAB.wip, { moveSource: "user" } as never); + await store.moveTask("FN-MF-NOPROOF", RENAMED_VOCAB.review, { + moveSource: "user", + allowDirectInReviewMove: true, + } as never); + store.taskCache.delete("FN-MF-NOPROOF"); + + const outcome = await finalizeProvenAutoMergeTask({ + store: store as TaskStore, + taskId: "FN-MF-NOPROOF", + source: "direct-ai-merge", + } as never); + + expect(outcome.outcome).toBe("blocked"); + expect(outcome.reason).toBe("missing-merge-confirmation"); + expect(await persistedColumn("FN-MF-NOPROOF")).toBe(RENAMED_VOCAB.review); + }); +});