diff --git a/packages/engine/src/__tests__/_workflow-vocabulary-fixture.ts b/packages/engine/src/__tests__/_workflow-vocabulary-fixture.ts index a225d331a8..becb7e306c 100644 --- a/packages/engine/src/__tests__/_workflow-vocabulary-fixture.ts +++ b/packages/engine/src/__tests__/_workflow-vocabulary-fixture.ts @@ -18,6 +18,22 @@ 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 { + /* + FNXC:WorkflowLifecycleColumns 2026-07-29-15:45 (fixture correction): + Before this field `lifecycleIr` emitted NO intake trait for a non-merged board, so the + SEPARATE-LANES shape this fixture is supposed to provide did not exist. Every renamed-versus- + merged differential built on it compared "no intake" against "intake and hold on one column", + not "two distinct lanes" against "one". + + That let assertions pass for the wrong reason: `expect(lifecycle?.intake).not.toBe(hold)` is + vacuously true when `intake` is `undefined`, so it would pass against a resolver that never + resolved intake at all. `undefined` is no longer reachable from this fixture. + + `mergedIntakeAndHold` remains the way a caller forces the merged shape; a vocabulary whose + `intake` equals its `hold` now implies it too, so the merged vocabularies cannot silently lose + their intake trait to a forgotten option. + */ + readonly intake: string; readonly hold: string; readonly wip: string; readonly review: string; @@ -26,6 +42,7 @@ export interface Vocabulary { /** The legacy ids. A guard keyed on a string literal passes here for the wrong reason. */ export const DEFAULT_VOCAB: Vocabulary = { + intake: "triage", hold: "todo", wip: "in-progress", review: "in-review", @@ -34,6 +51,7 @@ export const DEFAULT_VOCAB: Vocabulary = { /** No id overlaps the legacy enum. A guard keyed on a string literal goes silent here. */ export const RENAMED_VOCAB: Vocabulary = { + intake: "inbox", hold: "backlog", wip: "building", review: "checking", @@ -92,6 +110,7 @@ export const MERGED_VOCAB: Vocabulary = { /** A merged board that ALSO renames: both variables move at once, which is the shape a * custom workflow author actually produces. */ export const MERGED_RENAMED_VOCAB: Vocabulary = { + intake: "planning", hold: "planning", wip: "building", review: "checking", @@ -99,16 +118,22 @@ export const MERGED_RENAMED_VOCAB: Vocabulary = { }; export function lifecycleIr(v: Vocabulary, id: string, options: LifecycleIrOptions = {}): WorkflowIr { + /* Merged when the caller says so OR when the vocabulary collapses the two roles onto one id. */ + const merged = options.mergedIntakeAndHold === true || v.intake === v.hold; return { version: "v2", id, name: `lifecycle-${id}`, columns: [ + /* The SEPARATE intake lane, for a non-merged vocabulary only. On a merged one + `v.intake === v.hold`, so declaring it here would duplicate the id — the hold column + carries both traits instead. */ + ...(merged ? [] : [{ id: v.intake, name: "Intake", traits: [{ trait: "intake" as const }] }]), { id: v.hold, name: "Hold", traits: [ - ...(options.mergedIntakeAndHold ? [{ trait: "intake" }] : []), + ...(merged ? [{ trait: "intake" }] : []), { trait: "hold", config: { release: "capacity" } }, ], /* U4 workflow-declared recovery policy (#2478). Declared on the HOLD column of both diff --git a/packages/engine/src/__tests__/workflow-merged-board-live-e2e.pg.test.ts b/packages/engine/src/__tests__/workflow-merged-board-live-e2e.pg.test.ts new file mode 100644 index 0000000000..4e030d4a4e --- /dev/null +++ b/packages/engine/src/__tests__/workflow-merged-board-live-e2e.pg.test.ts @@ -0,0 +1,188 @@ +/* +FNXC:WorkflowLifecycleColumns 2026-07-30-16:30 (E2E evidence — the MERGED board): + +THE BOARD THE PROGRAM ACTUALLY SHIPPED, and the one no E2E family covered. + +Every suite in this directory proves the RENAMED case: a board whose column ids differ from +the legacy enum. U11 shipped something structurally different — it merged Todo into Planning, +so on the default lineage ONE column carries BOTH the intake and hold traits and the id +`triage` no longer exists. + +A renamed differential cannot see that, because renamed boards still have two separate +columns. The merged shape breaks a distinct class of guard: + + - "is this card in intake but NOT in hold" is UNSATISFIABLE — one column, both traits; + - a hold -> intake release is a SELF-MOVE, which a move guard may reject or loop on; + - `intake && column !== "triage"` inverts from sometimes-true to ALWAYS-true, so an + affordance gated on it appears everywhere instead of nowhere. + +The first and third are silent. The second is the interesting one, because it is the shape +that produces a loop rather than a no-op. + +WHAT IS REAL: a PostgreSQL TaskStore, a real persisted workflow whose hold column carries both +traits, the real capacity release sweep, and the real graph column boundary. Assertions read +the persisted row. +*/ +import { beforeAll, beforeEach, afterEach, afterAll, describe, expect, it } from "vitest"; +import "@fusion/core"; +import { resolveLifecycleColumns, resolveWorkflowIrForTask } from "@fusion/core"; + +import { + pgDescribe, + createSharedPgTaskStoreTestHarness, + type SharedPgTaskStoreHarness, +} from "../../../core/src/__test-utils__/pg-test-harness.js"; +import { runHoldReleaseSweep } from "../hold-release.js"; +import { MERGED_VOCAB, RENAMED_VOCAB, lifecycleIr } from "./_workflow-vocabulary-fixture.js"; + +pgDescribe("live MERGED-board E2E: one column carrying both intake and hold", () => { + const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({ + prefix: "fusion_merged_board_e2e", + }); + + beforeAll(h.beforeAll); + beforeEach(h.beforeEach); + afterEach(h.afterEach); + afterAll(h.afterAll); + + /** A workflow whose planning lane carries BOTH intake and hold — the U11 shape. */ + async function seedMergedWorkflow(key: string): Promise { + const created = await h.store().createWorkflowDefinition({ + name: `Merged ${key}`, + kind: "workflow", + ir: lifecycleIr(MERGED_VOCAB, `custom:merged-${key}`, { mergedIntakeAndHold: true }), + } as never); + return (created as { id: string }).id; + } + + async function seedTask(taskId: string, column: string, workflowId: string): Promise { + const store = h.store(); + await store.createTaskWithReservedId( + { description: `merged ${taskId}`, column } as never, + { taskId, applyDefaultWorkflowSteps: false } as never, + ); + await store.writeTaskWorkflowSelection(taskId, workflowId, []); + /* + FNXC:WorkflowLifecycleColumns 2026-07-29-14:40 (fixture fix — and a retracted escalation): + The card needs a PLANNED PROMPT.md before the release sweep will move it. FN-7648's + `isUnplannedForExecution` reads that file for any card resting in an intake- OR hold-trait + column and refuses to move an unplanned card into a processing column. On a MERGED board the + hold lane IS the intake lane, so every card seeded here is subject to that gate — a bootstrap + seed is held, and being held is the GATE WORKING, not a scheduler defect. + + This corrects a wrong escalation of mine. I bisected the failure to #2613's task-creation.ts + (reverting that one file re-greened everything) and reported it as a regression. The bisect was + sound as ATTRIBUTION and wrong as a verdict: #2613 made a card created in `todo` classify as + INTAKE, which post-U11 it genuinely is, so it correctly receives a bootstrap seed instead of a + specified prompt. My fixture had been relying on the pre-U11 semantics where `todo` was not + intake and a card created there was treated as already specified. + + The lesson, recorded because it cost another worker time: a revert that changes an outcome + proves WHICH change moved it, never that the OLD behaviour was the correct one. Diagnosis of + #2613's behaviour belongs to #2634, which probed the actual release gates rather than + bisecting. + */ + const { writeFileSync, mkdirSync } = await import("node:fs"); + const { join } = await import("node:path"); + const dir = join((store as never as { getTasksDir(): string }).getTasksDir(), taskId); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "PROMPT.md"), `# ${taskId}\n\n## Context\nA planned spec.\n\n## Steps\n### Step 1\n- [ ] work\n`, "utf-8"); + 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; + } + + it("resolves intake and hold to the SAME column — the premise every other assertion rests on", async () => { + /* Asserted rather than assumed: if the fixture emitted two columns, every case below would + silently degrade into the renamed case that other suites already cover. */ + const wf = await seedMergedWorkflow("premise"); + await seedTask("FN-MB-0", MERGED_VOCAB.hold, wf); + + const lifecycle = resolveLifecycleColumns(await resolveWorkflowIrForTask(h.store(), "FN-MB-0")); + + expect(lifecycle?.intake).toBe(MERGED_VOCAB.hold); + expect(lifecycle?.hold).toBe(MERGED_VOCAB.hold); + // ...and the legacy `triage` id is genuinely absent from this board. + expect(lifecycle?.intake).not.toBe("triage"); + }); + + it("releases a card out of the merged lane on capacity — the release is not a self-move", async () => { + /* The shape that loops rather than no-ops. The scheduler releases hold -> wip; on a merged + board the SOURCE column is also intake, so a sweep that reasons "intake cards are not + ready" would hold forever, and one that moves hold -> intake would move a card to where + it already is and re-fire every poll. */ + const wf = await seedMergedWorkflow("release"); + await seedTask("FN-MB-1", MERGED_VOCAB.hold, wf); + + const sweep = await runHoldReleaseSweep(h.store(), { now: () => Date.now() }); + + expect(sweep.released).toContain("FN-MB-1"); + expect(await persistedColumn("FN-MB-1")).toBe(MERGED_VOCAB.wip); + // The card LEFT the merged lane; it did not land back on itself. + expect(await persistedColumn("FN-MB-1")).not.toBe(MERGED_VOCAB.hold); + }); + + it("does not re-release a card that already left the merged lane (no repeat firing)", async () => { + /* The loop check. A self-move or an unsatisfiable predicate shows up as the same card being + released on every sweep, which a single-pass test cannot see. */ + const wf = await seedMergedWorkflow("norepeat"); + await seedTask("FN-MB-2", MERGED_VOCAB.hold, wf); + + await runHoldReleaseSweep(h.store(), { now: () => Date.now() }); + const second = await runHoldReleaseSweep(h.store(), { now: () => Date.now() }); + + expect(second.released).not.toContain("FN-MB-2"); + expect(await persistedColumn("FN-MB-2")).toBe(MERGED_VOCAB.wip); + }); + + it("holds a merged-lane card when capacity is exhausted, rather than looping", async () => { + /* The negative half: the merged lane must still be a genuine hold. With the wip slot taken + the card stays put and is reported held — not released, not moved onto itself. */ + const store = h.store(); + await store.updateSettings({ maxConcurrent: 1 } as never); + const wf = await seedMergedWorkflow("capacity"); + await seedTask("FN-MB-3", MERGED_VOCAB.wip, wf); + await seedTask("FN-MB-4", MERGED_VOCAB.hold, wf); + + const sweep = await runHoldReleaseSweep(store, { now: () => Date.now() }); + + expect(sweep.released).not.toContain("FN-MB-4"); + expect(await persistedColumn("FN-MB-4")).toBe(MERGED_VOCAB.hold); + }); + + it("a RENAMED board with separate lanes still behaves — the two shapes are not the same test", async () => { + /* The differential that justifies a third vocabulary: this board has intake and hold as + DISTINCT columns, so it exercises the two-place assumption the merged cases cannot. */ + const created = await h.store().createWorkflowDefinition({ + name: "Renamed separate", + kind: "workflow", + ir: lifecycleIr(RENAMED_VOCAB, "custom:renamed-separate"), + } as never); + await seedTask("FN-MB-5", RENAMED_VOCAB.hold, (created as { id: string }).id); + + const lifecycle = resolveLifecycleColumns(await resolveWorkflowIrForTask(h.store(), "FN-MB-5")); + /* + FNXC:WorkflowLifecycleColumns 2026-07-29-16:00 (strengthened — this was vacuous): + `expect(intake).not.toBe(hold)` alone passes when `intake` is `undefined`, which is exactly + what this fixture produced before it declared a separate intake lane. So the assertion that + was supposed to prove "two distinct lanes" would have passed against a resolver that never + resolved intake at all. + + Asserting the id positively is what makes it bite: intake must be DEFINED, must be the lane + the vocabulary declares, and must differ from hold. Confirmed by mutation — removing the + intake lane from the builder now fails here, where before it stayed green. + */ + expect(lifecycle?.hold).toBe(RENAMED_VOCAB.hold); + expect(lifecycle?.intake).toBe(RENAMED_VOCAB.intake); + expect(lifecycle?.intake).not.toBeUndefined(); + expect(lifecycle?.intake).not.toBe(lifecycle?.hold); + + const sweep = await runHoldReleaseSweep(h.store(), { now: () => Date.now() }); + expect(sweep.released).toContain("FN-MB-5"); + expect(await persistedColumn("FN-MB-5")).toBe(RENAMED_VOCAB.wip); + }); +}); diff --git a/packages/engine/src/__tests__/workflow-planner-lane-live-e2e.pg.test.ts b/packages/engine/src/__tests__/workflow-planner-lane-live-e2e.pg.test.ts new file mode 100644 index 0000000000..b46b690b0d --- /dev/null +++ b/packages/engine/src/__tests__/workflow-planner-lane-live-e2e.pg.test.ts @@ -0,0 +1,176 @@ +/* +FNXC:WorkflowLifecycleColumns 2026-07-29-11:20 (E2E evidence — the planner-lane resolvers): + +`planner-lane-resolution.ts` is the one merged-board-aware conversion in the program that got the +hard case RIGHT, and its correctness rests on a claim that is only unit-proven against a MOCKED +IR: that a merged lineage yields NOTHING from the dedicated-planner resolver, and that saying so +is the correct answer rather than a failure to resolve. + +That claim is worth proving on a real store because `[]` is the shape of a guard that no longer +guards. Two things have to hold and neither is self-evident: + + 1. The merged board must produce `[]` — not `undefined`, and not the merged column. `undefined` + would make both consumers fall back to the LEGACY literal list (`?? LEGACY_...`), silently + reintroducing `triage` on a board that does not declare it. Returning the merged column + would make a parked card with preserved progress skip staleness. + + 2. `[]` must SURVIVE the consumer. Both call sites spell it `context.plannerColumns ?? LEGACY`, + which is only correct because `??` fails over on null/undefined and NOT on empty — a + `.length ? … : LEGACY` spelling would look equivalent and quietly restore the literal. That + is a live hazard, so it is asserted through the real guard rather than by reading the code. + +And the discriminating case the resolver's own comment cites: on a merged lineage the planner +distinction is carried by STATUS, not by the column, so the SAME column with a different status +must give the OPPOSITE answer. A column-only implementation cannot produce that. + +WHAT IS REAL: a PostgreSQL TaskStore, real persisted workflow definitions, the real resolvers, +and the real `evaluateSpecStaleness` guard. Assertions read resolver output and guard verdicts, +never call spies. +*/ +import { beforeAll, beforeEach, afterEach, afterAll, expect, it } from "vitest"; +import "@fusion/core"; + +import { + pgDescribe, + createSharedPgTaskStoreTestHarness, + type SharedPgTaskStoreHarness, +} from "../../../core/src/__test-utils__/pg-test-harness.js"; +import { + resolveDedicatedPlannerColumnsForTask, + resolvePlannerLanesForTask, +} from "../planner-lane-resolution.js"; +import { shouldSkipSpecStalenessForPreservedProgress } from "../spec-staleness.js"; +import { MERGED_RENAMED_VOCAB, RENAMED_VOCAB, lifecycleIr, type Vocabulary } from "./_workflow-vocabulary-fixture.js"; + +/* +A card sitting in the MERGED lane that has ALREADY done work. The preserved progress is the +guard's actual subject — `currentStep > 0` is what makes it return true at all — so a card +without it returns false for a reason that has nothing to do with the planner-lane question. +Learned by getting it wrong: the first version of these two cases passed no progress and read +the resulting `false` as a lane verdict. +*/ +const PRESERVED_PROGRESS_CARD = { + column: MERGED_RENAMED_VOCAB.hold, + status: "in-progress", + currentStep: 2, +} as const; + +pgDescribe("live planner-lane E2E: the merged board must yield NO dedicated planner column", () => { + const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({ + prefix: "fusion_planner_lane_e2e", + }); + + beforeAll(h.beforeAll); + beforeEach(h.beforeEach); + afterEach(h.afterEach); + afterAll(h.afterAll); + + async function seedWorkflow(v: Vocabulary, key: string, merged: boolean): Promise { + const created = await h.store().createWorkflowDefinition({ + name: `Planner ${key}`, + kind: "workflow", + ir: lifecycleIr(v, `custom:planner-${key}`, { mergedIntakeAndHold: merged }), + } as never); + return (created as { id: string }).id; + } + + async function seedTask(taskId: string, column: string, workflowId: string): Promise { + const store = h.store(); + await store.createTaskWithReservedId( + { description: `planner ${taskId}`, column } as never, + { taskId, applyDefaultWorkflowSteps: false } as never, + ); + await store.writeTaskWorkflowSelection(taskId, workflowId, []); + store.taskCache.delete(taskId); + } + + it("yields [] for a MERGED lineage — empty, not undefined, and not the merged column", async () => { + /* The three-way distinction that matters. `toEqual([])` alone would also pass for + `undefined` under a loose matcher, so the emptiness and the definedness are asserted + separately, and the merged column is explicitly ruled out. */ + const wf = await seedWorkflow(MERGED_RENAMED_VOCAB, "merged", true); + await seedTask("FN-PL-1", MERGED_RENAMED_VOCAB.hold, wf); + + const dedicated = await resolveDedicatedPlannerColumnsForTask(h.store(), "FN-PL-1"); + + expect(dedicated).toBeDefined(); + expect(dedicated).toEqual([]); + expect(dedicated).not.toContain(MERGED_RENAMED_VOCAB.hold); + // ...and the legacy id is not smuggled in either. + expect(dedicated).not.toContain("triage"); + }); + + it("still yields the intake lane for a RENAMED lineage with two distinct columns", async () => { + /* The differential. Without this, the case above would also pass for a resolver that + returned [] unconditionally — which is the exact way this guard could go dead. */ + const wf = await seedWorkflow(RENAMED_VOCAB, "renamed", false); + await seedTask("FN-PL-2", RENAMED_VOCAB.hold, wf); + + const dedicated = await resolveDedicatedPlannerColumnsForTask(h.store(), "FN-PL-2"); + + expect(dedicated).toHaveLength(1); + expect(dedicated?.[0]).not.toBe(RENAMED_VOCAB.hold); + expect(dedicated?.[0]).not.toBe("triage"); + }); + + it("reports BOTH lanes as planner lanes on a renamed board, and ONE on a merged board", async () => { + /* `resolvePlannerLanesForTask` answers a different question — "is this card waiting to be + planned?", true in either lane — so it must de-duplicate rather than return [] when the + two roles collapse. A merged board reporting zero planner lanes here would strand every + card waiting to be planned. */ + const renamedWf = await seedWorkflow(RENAMED_VOCAB, "lanes-renamed", false); + await seedTask("FN-PL-3", RENAMED_VOCAB.hold, renamedWf); + const mergedWf = await seedWorkflow(MERGED_RENAMED_VOCAB, "lanes-merged", true); + await seedTask("FN-PL-4", MERGED_RENAMED_VOCAB.hold, mergedWf); + + const renamedLanes = await resolvePlannerLanesForTask(h.store(), "FN-PL-3"); + const mergedLanes = await resolvePlannerLanesForTask(h.store(), "FN-PL-4"); + + expect(renamedLanes).toHaveLength(2); + // Collapsed to one by the Set, and NOT empty — the opposite of the dedicated resolver. + expect(mergedLanes).toEqual([MERGED_RENAMED_VOCAB.hold]); + }); + + it("keeps [] alive through the real guard instead of failing over to the legacy literal", async () => { + /* + The `??` hazard, asserted rather than read. Both consumers spell the fallback + `plannerColumns ?? LEGACY_...`, correct only because `??` does not fail over on empty. + + A card in the merged lane with preserved progress must NOT be treated as sitting in a + planner column: with `[]` the guard sees no planner column and proceeds. If `[]` were + replaced by the legacy list, the merged lane would still not match `triage` — so to make + this bite, the merged lane is checked against a legacy list that DOES contain it, which is + what a `.length ? … : LEGACY` spelling would produce for the faithful U11 vocabulary. + */ + const skipWithEmpty = shouldSkipSpecStalenessForPreservedProgress( + PRESERVED_PROGRESS_CARD as never, + [], + ); + const skipWithLaneAsPlanner = shouldSkipSpecStalenessForPreservedProgress( + PRESERVED_PROGRESS_CARD as never, + [MERGED_RENAMED_VOCAB.hold], + ); + + // Empty planner list -> the card is not in a planner column -> the guard does not bail out. + expect(skipWithEmpty).toBe(true); + // The same card, if the merged lane were reported AS a planner column -> opposite verdict. + expect(skipWithLaneAsPlanner).toBe(false); + }); + + it("gives the OPPOSITE answer for the same column with a planner status — the merged distinction is status, not column", async () => { + /* The case `planner-lane-resolution.ts` cites as its reason for returning []: "same column, + different status, opposite correct answer". A column-only implementation cannot produce + this, which is why the merged board must not report a dedicated planner column at all. */ + const preserved = shouldSkipSpecStalenessForPreservedProgress( + PRESERVED_PROGRESS_CARD as never, + [], + ); + const replanning = shouldSkipSpecStalenessForPreservedProgress( + { ...PRESERVED_PROGRESS_CARD, status: "needs-replan" } as never, + [], + ); + + expect(preserved).toBe(true); + expect(replanning).toBe(false); + }); +}); diff --git a/packages/engine/src/__tests__/workflow-rebound-family-live-e2e.pg.test.ts b/packages/engine/src/__tests__/workflow-rebound-family-live-e2e.pg.test.ts index 9b58b9ab76..c6feb6598c 100644 --- a/packages/engine/src/__tests__/workflow-rebound-family-live-e2e.pg.test.ts +++ b/packages/engine/src/__tests__/workflow-rebound-family-live-e2e.pg.test.ts @@ -34,7 +34,7 @@ import { type SharedPgTaskStoreHarness, } from "../../../core/src/__test-utils__/pg-test-harness.js"; import { SelfHealingManager, autoRecoverWorktreeSessionStartFailure } from "../self-healing.js"; -import { DEFAULT_VOCAB, RENAMED_VOCAB, lifecycleIr, type Vocabulary } from "./_workflow-vocabulary-fixture.js"; +import { DEFAULT_VOCAB, MERGED_VOCAB, RENAMED_VOCAB, lifecycleIr, type Vocabulary } from "./_workflow-vocabulary-fixture.js"; pgDescribe("live rebound E2E: where a recovered card goes back to", () => { const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({ @@ -49,11 +49,11 @@ pgDescribe("live rebound E2E: where a recovered card goes back to", () => { /** Persist the workflow and return the id the STORE assigned — it allocates its own * `WF-###` and ignores the one in the input; binding to the id we passed in would * silently resolve to the DEFAULT builtin IR instead. */ - async function seedWorkflow(v: Vocabulary, key: string): Promise { + async function seedWorkflow(v: Vocabulary, key: string, opts: { mergedIntakeAndHold?: boolean } = {}): Promise { const created = await h.store().createWorkflowDefinition({ name: `Rebound ${key}`, kind: "workflow", - ir: lifecycleIr(v, `custom:${key}`), + ir: lifecycleIr(v, `custom:${key}`, opts), } as never); return (created as { id: string }).id; } @@ -312,6 +312,46 @@ pgDescribe("live rebound E2E: where a recovered card goes back to", () => { expect(await persistedColumn("FN-RB-4")).toBe(RENAMED_VOCAB.wip); }); + it("re-homes a stranded card on a MERGED board, where hold and intake are one column", async () => { + /* + FNXC:WorkflowLifecycleColumns 2026-07-30-17:10 (merged-board evidence): + `resolveReboundTarget` prefers hold -> intake -> first column. On the post-U11 default + lineage those first two COLLAPSE onto one column, so the preference order stops being a + preference at all — and a repair that reasoned "not hold, so try intake" would either pick + the same column twice or fall through to "first column", which is not necessarily a lane a + card may rest in. + + The renamed cases above cannot see this: they have hold and intake as distinct columns, so + the preference order is still meaningful there. This is why the merged shape is a separate + vocabulary rather than another set of ids. + */ + const workflowId = await seedWorkflow(MERGED_VOCAB, "undeclared-merged", { mergedIntakeAndHold: true }); + await strandInUndeclaredColumn("FN-RB-M1", workflowId); + expect(await persistedColumn("FN-RB-M1")).toBe("a-column-no-workflow-declares"); + + const rehomed = await new SelfHealingManager(h.store(), {} as never).reconcileUndeclaredTaskColumns(); + + expect(rehomed).toBe(1); + // The merged planning lane — reached as `hold`, which is also `intake`. + expect(await persistedColumn("FN-RB-M1")).toBe(MERGED_VOCAB.hold); + }); + + it("does not re-home a MERGED-board card that is already in the merged lane", async () => { + /* The self-move check. `resolveReboundTarget` returns the card's OWN column here, and the + sweep skips when `target === task.column` — otherwise it would move a card onto itself + and re-fire on every pass. A single-pass count cannot distinguish that from a no-op, so + the sweep is run twice. */ + const workflowId = await seedWorkflow(MERGED_VOCAB, "merged-inplace", { mergedIntakeAndHold: true }); + await seedTask("FN-RB-M2", MERGED_VOCAB.hold, workflowId); + + const first = await new SelfHealingManager(h.store(), {} as never).reconcileUndeclaredTaskColumns(); + const second = await new SelfHealingManager(h.store(), {} as never).reconcileUndeclaredTaskColumns(); + + expect(first).toBe(0); + expect(second).toBe(0); + expect(await persistedColumn("FN-RB-M2")).toBe(MERGED_VOCAB.hold); + }); + it("leaves an operator-paused card stranded rather than moving it", async () => { /* `userPaused` is an operator park; the sweep must not undo it even to repair a genuinely broken column. */