test(engine): prove merge finalization on a renamed board — 3 ledger entries closed, and the ledger itself corrected (#2508)

Test-only. Closes three `auto-merge-finalization` entries from the
unproven-sites ledger.

## Why this one first

It is the **last move a card makes**. Keyed on the literal `done`, a
renamed board's proven-merged card is 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 nothing had run it against a renamed workflow.

## The ledger was wrong, and is corrected in this PR

My own ledger said this family *"needs a REAL git worktree, branch, and
squash … an engine-slow real-git lane, not another table row"*.

`finalizeProvenAutoMergeTask` **needs no git at all** — the merge proof
is a field on the row. It was reachable the whole time. The inference
came from *the family the code sits in* rather than from what the
function actually touches, and it parked reachable coverage for a slice.
The correction is written into the ledger so the remaining entries get
re-checked the same way rather than inheriting the assumption.

## A second correction, from mutation-testing rather than reading

I first claimed `resolveMergeOrchestrationColumn` as covered because it
sits in the same resolver as the other two. **All cases passed with it
hardcoded.** It changes only whether finalization records a
column-mismatch *repair* — never where the card lands, which is why the
other cases are blind to it.

It got its own case. Keyed on `in-review`, a renamed board's card
resting in `checking` compares unequal, so **every ordinary finalization
would be audited as repairing a mismatch that never existed** — a
healthy board reads as one constantly self-healing, and the audit trail
operators use to spot real strandings fills with false positives.

Sitting next to covered code is not coverage.

## Mutation-verified independently

| mutation | result |
|---|---|
| `completeColumn` → `"done"` | 3 fail — both renamed cases + the
differential |
| `isCompleteColumn` → `id === "done"` | exactly the already-done case
fails |
| `mergeColumn` → `"in-review"` | exactly the new audit case fails |

## Shared fixture extracted (pure move)

The vocabulary + IR builder moved to `_workflow-vocabulary-fixture.ts`
so the two suites cannot drift into testing different workflows — two
copies of a differential fixture is precisely how a renamed-workflow
test starts passing for reasons unrelated to the code under test. The
lifecycle suite is unchanged: **20/20 before and after**. The
`mergeOrchestration` trait is an opt-in option so the existing suite's
IR stays byte-identical.

## Fixture note worth keeping

Seeding needed **completed steps**: task creation parses three pending
steps out of the bootstrap PROMPT even with `applyDefaultWorkflowSteps:
false`, and `getTaskHardMergeBlocker` refuses on them (`"task has
incomplete steps"`). Found by the suite blocking on **both**
vocabularies — the signature of a broken fixture rather than a broken
guard.

## Verification

- 51/51 across the lifecycle, merge-family, ratchet and hold-release
suites
- engine `tsc --noEmit` clean
- `pnpm test:gate` green (307 + 10 + 71)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-28 17:15:35 -07:00
committed by GitHub
parent 8288e4a8ab
commit 4eaa509024
3 changed files with 348 additions and 86 deletions

View File

@@ -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;
}

View File

@@ -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.

View File

@@ -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<string> {
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<void> {
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<string> {
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);
});
});