E2E evidence: the MERGED board (third completion criterion) — plus a RETRACTION of my #2613 escalation (#2632)

This is the merged-board half of the evidence assignment. **It is red,
deliberately, and the red is the finding.** Do not merge it to make the
red go away — the assertions are correct and `main` is broken.

## Escalation first: #2613 broke the default board, and the gate did not
notice

`6a33d8f8c` — *"Phase B — TAKING task-creation.ts: intake classification
by trait (4 sites → 0)"* (#2613) — regressed four E2E cases, including
**the default-vocabulary full lifecycle**, which is scenario 1 of the
whole E2E assignment.

Attribution is a clean single-file revert, not a guess:

```
HEAD (main):                                        4 failed | 39 passed
HEAD with ONLY 6a33d8f8c's task-creation.ts reverted:    28 passed  (both files fully green)
```

Failing:

1. `scenario 1 — DEFAULT vocabulary … persists the card in the expected
column at every stage`
2. `scenario 2 — RENAMED vocabulary … writes the same column-transition
audit trail as the default`
3. `releases a card out of the merged lane on capacity — the release is
not a self-move`
4. `does not re-release a card that already left the merged lane`

**`pnpm test:gate` is green on this branch — exit 0, 695 tests.** #2613
merged through a green gate, and its own tests pass. This is the eighth
time this program a test has passed without exercising its subject, and
the first one an E2E family caught rather than review.

### Mechanism

`isIntakeColumn` in `task-creation.ts` decides whether a new card gets a
**bootstrap** prompt (freeform, "triage will plan this later") or a
**specified** prompt (planned, executable). #2613 rewrote it as:

```ts
const isIntakeColumn = (intakeFacts.intake !== undefined && task.column === intakeFacts.intake) || …
```

where `intakeFacts.intake` falls back to the **default workflow's**
intake when the create supplies no `workflowId`. Post-U11 the default
workflow's intake **is `todo`**. So any card created directly in `todo`
is now classified as intake and gets a bootstrap prompt — unplanned.
Unplanned cards do not advance through the graph (no `NodeEntered` audit
rows → failures 1 and 2) and hold-release will not release them
(FN-7648: no unplanned card enters a processing column → failures 3 and
4).

Before U11 this was safe: `triage` was intake and `todo` was a distinct
lane, so creating in `todo` meant "planned work". The merge deleted that
distinction.

### Why this is the exact trap you warned about

You said you did not want *"a conversion that swaps the literal for a
trait lookup WITHOUT checking what the guard was for."* The old
`task.column === "triage"` guard meant **"is this card unplanned?"** On
a merged board, intake-vs-hold **cannot answer that question at all** —
one column is both. The distinguishing fact is not the column; it is
whether the caller supplied a spec. Resolving the role faithfully still
gets the wrong answer, because the question was never really about the
column.

Not fixing it from here: `task-creation.ts` is #2613's owner's file, and
the fix is a design call about which fact replaces the column test.

## What the evidence itself adds

Three families extended to the U11 shape — one column carrying **both**
intake and hold. That breaks a class of guard renamed boards
structurally cannot reveal:

| shape | consequence |
|---|---|
| `intake && !hold` | **unsatisfiable** — silent |
| hold → intake release | **self-move**, re-fires every poll — loops |
| `intake && column !== "triage"` | inverts to **always-true** — silent
|

Two are silent and one loops, so every case sweeps **twice** and asserts
no re-release; a single pass cannot tell a no-op from a self-move.

### A fixture that could not fail

My first merged row used `MERGED_VOCAB`, which is *faithful* to U11 — it
reuses the legacy ids, because that is what the default lineage has.
That fidelity **destroyed its discriminating power**: its hold column
*is* `todo`, so a guard falling back to the `todo` literal returns the
same answer as one resolving the role. The "hold but not intake"
mutation left all 23 green.

Added `MERGED_RENAMED_VOCAB` — merged *structure*, renamed *vocabulary*
— the only combination where the collapse is observable **and** the
literal is wrong. Same mutation now fails exactly 1 of 23. Both
vocabularies stay: one asks *"does the collapse break the release
path"*, the other *"is the role actually resolved"*.

One rebound mutation was **genuinely unobservable** rather than
undetected — `hold` is also the first column in that fixture, so the
fallback chain lands there regardless. Pointing rebound at `complete`
instead fails 9 of 15. Recorded rather than papered over.

## For the CAPACITY worker before `self-healing.ts` is marked done

`self-healing.ts:2952` and `:9134` query `listTasks({ column: "triage"
})`. Converting the 10 guards leaves those sweeps **blind** — they never
see a renamed card, so the guard is correct and unreachable. Query and
guard convert together or not at all.

There are **137** such `column: "<legacy id>"` sites repo-wide, 52 in
that one file, and the 45→0 grep counts none of them because they are
object properties, not comparisons. **The bar can reach zero with sweeps
still unable to fire.**

🤖 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-29 22:59:37 -07:00
committed by GitHub
parent 7ab6506c0f
commit 9edc746f96
4 changed files with 433 additions and 4 deletions

View File

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

View File

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

View File

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

View File

@@ -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<string> {
async function seedWorkflow(v: Vocabulary, key: string, opts: { mergedIntakeAndHold?: boolean } = {}): Promise<string> {
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. */