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:
112
packages/engine/src/__tests__/_workflow-vocabulary-fixture.ts
Normal file
112
packages/engine/src/__tests__/_workflow-vocabulary-fixture.ts
Normal 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user