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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user