P0 STALL 3: rescue cards stranded in a column their workflow no longer declares (fixes 8 red tests on main) (#2591)

Based on `main`. **Fixes STALL 3 — and it needs no data migration.**

## The stall

#2515 removed `triage` from the default lineage while leaving the id
legal for stored rows, and shipped **no migration**. Planning discovery
resolves a card's lanes from its own workflow, and for a default card
`intake` and `hold` **both** resolve to `todo` — so a card *sitting* in
`triage` matched neither branch and was admitted by nothing.

`triage` was the default intake column before #2515, so **every existing
project has cards there.**

Nothing else rescued them. #2515's escape hatch makes an undeclared
source column resolve to the workflow's rebound target, but every path
that *uses* it (executor, agent-heartbeat, merger) is triggered by
**active work**, and a parked card has none. The card sat until an
operator dragged it by hand.

## Proof this is a real regression, not a stale test

**8 tests in `triage.test.ts` were RED on clean `origin/main`** —
verified by swapping main's `triage.ts` into this tree and re-running.
**All 8 pass with this change.** The sharpest:

```
expected "specifyTask" to be called 4 times, but got 0 times
```

Discovery was admitting zero triage cards.

## The fix

A card resting on a legacy pre-implementation id that its own workflow
no longer declares is **unowned by construction** — no lane's rules
apply to it. Admitting it to **planning** heals it through the normal
path: it gets planned, and finalize releases it to the workflow's hold
column, **re-homing the row as a side effect of ordinary work**. No
migration, no backfill, no operator action.

## The narrowing is the load-bearing part

My first version rescued **any** undeclared column, and it was wrong. A
card can also sit in a column its workflow genuinely owns while the
**selection** fails to resolve — the resolved default IR then doesn't
declare that column either. That version re-specified a parked Coding
(Ideas) `ideas` card, breaking **FN-7596's manual-intake rule** (an
ideas card is promoted by an *operator*, never auto-planned).

`triage.test.ts` caught it. The rescue is now scoped to the legacy
planner ids, so a workflow-specific column name is never second-guessed.
That distinction — healing #2515's orphans vs. overruling a workflow
about its own board — is the whole design.

## A user-pause hole this would have opened

`couldBeCandidate` screens `paused` but not `userPaused`, so a row
carrying `userPaused` alone slipped through. Harmless before (an
undeclared-column card was admitted by nothing) and **reachable the
moment admission widens**. Planning a card mutates its lifecycle state,
which the ratified safeguard forbids for a user-paused card — so the
guard is now explicit rather than inherited. Covered by a test and
mutation-verified.

## Cost

Resolution now derives roles **and** declared column ids from one
`resolveWorkflowIrForTask` call, replacing
`resolveTaskLifecycleColumns`. Same call, same `irCache`, same bounded
concurrency window — **cost unchanged**, no added read.

## Verification

- **Mutation-verified three ways**, each failing a different test:
remove the rescue; widen it back to any undeclared column; drop the
user-pause guard
- 8 previously-red-on-main tests now green
- 263 triage/scheduler tests green, merge gate green (482 + 10 + 71),
tsc clean, lint clean

## What this does NOT do

It does not re-home rows that are past the planning stage. Admission
still requires `isTaskStillInPlanningStage`, so a card that advanced
past planning in an undeclared column stays with self-healing's
advanced-recovery sweep rather than being re-specified here. If such
rows exist and are also stranded, that is a separate sweep and a
separate PR.

No changeset: `@fusion/engine` is private.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
gsxdsm
2026-07-29 19:02:55 -07:00
committed by GitHub
parent a56253f426
commit beb33b5dd1
2 changed files with 266 additions and 6 deletions

View File

@@ -0,0 +1,179 @@
/*
FNXC:WorkflowLifecycleColumns 2026-07-29-18:30 (U11 — STALL 3):
#2515 merged Todo into Planning on the default lineage, leaving that workflow with
five columns and NO `triage`. `triage` stayed a legal id (R11, and the Task enum
is a read contract for stored rows), so nothing throws — but planning discovery
resolves a card's lanes from its own workflow, and for a default card BOTH
`intake` and `hold` now resolve to `todo`.
A card SITTING in `triage` therefore matches neither branch and is admitted by
NOTHING. `triage` was the default intake column before #2515, so every existing
project has cards there, and #2515 shipped no data migration re-homing them.
Nothing else rescues them either. #2515's escape hatch makes an undeclared source
column resolve to the workflow's rebound target, so such a card CAN be moved — but
every rebound path (executor, agent-heartbeat, merger) is triggered by ACTIVE work
on the card, and a card parked in Triage has none. It sits until an operator drags
it by hand.
THE FIX NEEDS NO DATA MIGRATION. A card in a column its own workflow does not
declare is, by definition, unowned — no lane's rules apply to it. Admitting it to
PLANNING lets it heal through the normal path: it gets planned, and finalize
releases it to the workflow's hold column, which re-homes the row as a side effect
of ordinary work.
Deliberately narrow. Admission still requires `isTaskStillInPlanningStage`, so a
card that advanced past planning in an undeclared column stays with self-healing's
advanced-recovery sweep rather than being re-specified here.
Written against the post-#2515 implementation and observed FAILING first.
*/
import { describe, expect, it, vi } from "vitest";
import type { Task, TaskStore, WorkflowIr } from "@fusion/core";
import { TriageProcessor } from "../triage.js";
/* `builtin:coding` resolves to the REAL shipped IR (builtin ids short-circuit the
store), so the stall test asserts against what #2515 actually merged rather than
against a fixture that could drift from it. CUSTOM_WF is used where a test needs
a shape the builtins do not have. */
const WF = "builtin:coding";
const CUSTOM_WF = "custom:wf";
/** The merged default lineage exactly as #2515 shipped it: no `triage` column. */
function mergedDefaultIr(): WorkflowIr {
return {
version: "v2",
id: WF,
nodes: [],
edges: [],
columns: [
{ id: "todo", name: "Planning", traits: [{ trait: "intake" }, { trait: "hold", config: { release: "capacity" } }] },
{ id: "in-progress", name: "in-progress", traits: [{ trait: "wip", config: { limitSetting: "maxConcurrent" } }] },
{ id: "in-review", name: "in-review", traits: [{ trait: "review" }] },
{ id: "done", name: "done", traits: [{ trait: "complete" }] },
{ id: "archived", name: "archived", traits: [{ trait: "archived" }] },
],
} as unknown as WorkflowIr;
}
/** A workflow with a declared column that carries no lifecycle ROLE. */
function irWithUnroledColumn(): WorkflowIr {
return {
version: "v2",
id: CUSTOM_WF,
nodes: [],
edges: [],
columns: [
{ id: "todo", name: "Planning", traits: [{ trait: "intake" }, { trait: "hold", config: { release: "capacity" } }] },
{ id: "design-review", name: "design-review", traits: [] },
{ id: "in-progress", name: "in-progress", traits: [{ trait: "wip", config: { limitSetting: "maxConcurrent" } }] },
{ id: "done", name: "done", traits: [{ trait: "complete" }] },
],
} as unknown as WorkflowIr;
}
function task(over: Partial<Task> = {}): Task {
return {
id: "FN-1",
title: "t",
description: "d",
column: "triage",
status: null,
paused: false,
userPaused: false,
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
columnMovedAt: "2026-01-01T00:00:00.000Z",
...over,
} as Task;
}
function createStore(ir: WorkflowIr, workflowId: string = WF): TaskStore {
const selection = { workflowId, stepIds: [] };
return {
on: vi.fn(),
off: vi.fn(),
getTask: vi.fn(async () => undefined),
listTasks: vi.fn(async () => []),
getSettings: vi.fn().mockResolvedValue({}),
getTaskWorkflowSelection: vi.fn(() => selection),
getTaskWorkflowSelectionAsync: vi.fn(async () => selection),
getWorkflowDefinition: vi.fn(async () => ({ ir })),
resolveTaskWorkflowIrSync: vi.fn(() => ir),
logEntry: vi.fn(),
} as unknown as TaskStore;
}
async function discover(tasks: Task[], ir: WorkflowIr, workflowId?: string): Promise<string[]> {
const processor = new TriageProcessor(createStore(ir, workflowId), "/test/project");
const found = await (processor as unknown as {
discoverReadyPlanningTasks: (t: Task[], now: number) => Promise<Task[]>;
}).discoverReadyPlanningTasks(tasks, Date.parse("2026-02-01T00:00:00.000Z"));
return found.map((t) => t.id);
}
describe("planning discovery rescues a card stranded in an undeclared column", () => {
it("admits a card sitting in `triage` after #2515 removed that column", async () => {
/*
THE STALL. Every project upgrading from before #2515 has cards here, and
discovery admitted none of them.
*/
expect(await discover([task({ column: "triage" })], mergedDefaultIr())).toEqual(["FN-1"]);
});
it("does NOT admit a card in an undeclared column that already advanced past planning", async () => {
/*
The narrowing that keeps this from stealing self-healing's work: an undeclared
column is not a licence to re-specify a card that already executed.
*/
expect(
await discover(
[task({ column: "triage", steps: [{ id: "s1" } as never], firstExecutionAt: "2026-01-02T00:00:00.000Z" })],
mergedDefaultIr(),
),
).toEqual([]);
});
it("does NOT admit a card in a DECLARED column that merely carries no role", async () => {
/*
The direction that would be a real regression. `design-review` is declared, so
its workflow owns that card and planning must keep its hands off — "undeclared"
has to mean undeclared, not merely unroled.
*/
expect(await discover([task({ column: "design-review" })], irWithUnroledColumn(), CUSTOM_WF)).toEqual([]);
});
it("still admits a card resting in the declared intake column", async () => {
/* The pre-existing rule must survive. */
expect(await discover([task({ column: "todo" })], mergedDefaultIr())).toEqual(["FN-1"]);
});
it("never admits a paused card from an undeclared column", async () => {
/* The user-pause safeguard outranks every rescue. */
expect(await discover([task({ column: "triage", paused: true })], mergedDefaultIr())).toEqual([]);
expect(await discover([task({ column: "triage", userPaused: true })], mergedDefaultIr())).toEqual([]);
});
it("does NOT rescue a workflow-specific column, even when undeclared", async () => {
/*
The narrowing, and the lesson from `triage.test.ts`. A card can sit in a column
its workflow genuinely owns while the SELECTION fails to resolve — the resolved
default IR then does not declare that column either, and an "any undeclared
column" rescue re-specifies it. That is how the first version of this change
broke FN-7596's manual-intake rule, which requires an OPERATOR to promote an
`ideas` card rather than planning auto-claiming it.
*/
expect(await discover([task({ column: "ideas" })], mergedDefaultIr())).toEqual([]);
});
it("does not admit a card in a terminal column of its own workflow", async () => {
/* `done` is declared, so it is owned and must never be re-planned. */
expect(await discover([task({ column: "done" })], mergedDefaultIr())).toEqual([]);
});
});

View File

@@ -34,7 +34,7 @@ import {
resolveAgentMemoryInclusionMode,
resolvePlanApprovalRequired,
resolveWorkflowIrForTask,
resolveTaskLifecycleColumns,
resolveLifecycleColumns,
getStepParser,
computePlanApprovalFingerprint,
extractIntentSignature,
@@ -54,7 +54,6 @@ import {
localeDisplayName,
parsePlanningPlanMd,
type NearDuplicateCandidate,
resolveLifecycleColumns,
} from "@fusion/core";
@@ -291,6 +290,7 @@ export interface PlanningHandoffReport {
}
/*
FNXC:WorkflowLifecycleColumns 2026-07-29-08:40 (U11 conversion — triage planner lanes):
The PLANNER LANES for a task: the columns where specification happens, resolved
@@ -324,6 +324,15 @@ function resolvePlannerLanes(store: TaskStore, taskId: string): { hold: string;
return { hold: "todo", intake: "triage" };
}
}
/*
FNXC:WorkflowLifecycleColumns 2026-07-29-19:10 (U11 — STALL 3):
The pre-implementation column ids that shipped as the builtin lifecycle vocabulary,
and therefore the only ids a lineage change can leave a card stranded on. Used to
scope the undeclared-column rescue in `discoverReadyPlanningTasks`; deliberately not
derived from the IR, since the point is to recognise a column the CURRENT workflow
no longer has.
*/
const LEGACY_PLANNER_COLUMN_IDS: ReadonlySet<string> = new Set(["triage", "todo"]);
export class TriageProcessor {
private running = false;
@@ -1523,15 +1532,35 @@ export class TriageProcessor {
};
const candidates = allTasks.filter(couldBeCandidate);
const lifecycleByTaskId = new Map<string, { intake?: string; hold?: string }>();
/*
FNXC:WorkflowLifecycleColumns 2026-07-29-18:40 (U11 — STALL 3):
Resolves the IR ONCE per candidate and derives both the lifecycle roles and the
DECLARED column ids from it. Previously this called `resolveTaskLifecycleColumns`,
which returns roles only; the declared set is what the undeclared-column rescue
below needs, and taking it from the same resolution keeps the cost identical
(same call, same `irCache`, same bounded window) rather than adding a read.
*/
const lifecycleByTaskId = new Map<string, { intake?: string; hold?: string; declared: ReadonlySet<string> }>();
const RESOLUTION_CONCURRENCY = 8;
for (let offset = 0; offset < candidates.length; offset += RESOLUTION_CONCURRENCY) {
const window = candidates.slice(offset, offset + RESOLUTION_CONCURRENCY);
const resolved = await Promise.all(
window.map((t) => resolveTaskLifecycleColumns(this.store, t.id, irCache)),
window.map(async (t) => {
try {
const ir = await resolveWorkflowIrForTask(this.store, t.id, irCache);
const roles = resolveLifecycleColumns(ir);
const columns = (ir as { columns?: { id: string }[] }).columns ?? [];
return { ...roles, declared: new Set(columns.map((c) => c.id)) };
} catch {
return undefined;
}
}),
);
window.forEach((t, index) => {
lifecycleByTaskId.set(t.id, resolved[index] ?? { intake: "triage", hold: "todo" });
lifecycleByTaskId.set(
t.id,
resolved[index] ?? { intake: "triage", hold: "todo", declared: new Set<string>() },
);
});
}
@@ -1539,10 +1568,62 @@ export class TriageProcessor {
// candidate and both predicates are correctly false for it.
const isAtHoldColumn = (t: Task): boolean => lifecycleByTaskId.get(t.id)?.hold === t.column;
const isAtIntakeColumn = (t: Task): boolean => lifecycleByTaskId.get(t.id)?.intake === t.column;
/*
FNXC:WorkflowLifecycleColumns 2026-07-29-18:40 (U11 — STALL 3):
A card in a column its OWN workflow does not declare is unowned by construction:
no lane's rules apply to it, so no sweep claims it.
#2515 created a population of exactly these. It removed `triage` from the default
lineage while leaving the id legal for stored rows, and shipped no migration — so
every project upgrading has cards sitting in a column their workflow no longer
knows about. Both role checks above miss them (a default card's `intake` and
`hold` are BOTH `todo`), and the rebound paths that could move them are all
triggered by ACTIVE work, which a parked card has none of. Discovery admitted
nothing and the card sat until an operator dragged it by hand.
Admitting it to PLANNING heals it without a data migration: it gets planned, and
finalize releases it to the workflow's hold column, re-homing the row as a side
effect of ordinary work.
An EMPTY declared set means the IR could not be resolved (or is column-less v1) —
that is ignorance, not evidence of strandedness, so it must never trigger the
rescue. Requiring a non-empty set keeps an unresolvable workflow behaving exactly
as before.
NARROWED to the LEGACY LIFECYCLE IDS, and this is the load-bearing half. "Any
undeclared column" is too broad: a card can also sit in a column its workflow
genuinely owns while the SELECTION fails to resolve, and then the resolved
default IR does not declare that column either. The first version of this rescue
re-specified a parked Coding (Ideas) `ideas` card for exactly that reason, which
breaks FN-7596's manual-intake rule — an ideas card is promoted by an OPERATOR,
never auto-planned. `triage.test.ts` caught it.
So the rescue is scoped to the population a lineage change can actually strand: a
card resting on a legacy PRE-IMPLEMENTATION id that its own workflow no longer
declares. A workflow-specific column name is never rescued, which is the
difference between healing #2515's orphans and second-guessing a workflow about
its own board.
*/
const isAtUndeclaredColumn = (t: Task): boolean => {
const declared = lifecycleByTaskId.get(t.id)?.declared;
if (declared === undefined || declared.size === 0) return false;
return LEGACY_PLANNER_COLUMN_IDS.has(t.column) && !declared.has(t.column);
};
const eligibleTriageTasks = candidates.filter(
// `!isAtHoldColumn` keeps the two branches disjoint for a merged intake+hold column.
(t) => isAtIntakeColumn(t) && !isAtHoldColumn(t) && isTaskStillInPlanningStage(t)
/* `isTaskStillInPlanningStage` is what keeps the rescue narrow: a card that
advanced past planning in an undeclared column stays with self-healing's
advanced-recovery sweep instead of being re-specified here. */
/* `t.userPaused !== true` is explicit rather than inherited from `t.paused`.
The ratified safeguard is that a user-paused card's lifecycle state is never
MUTATED, and planning a card mutates it. `couldBeCandidate` screens only
`paused`, so a row carrying `userPaused` without `paused` slipped through —
invisible before this change (an undeclared-column card was admitted by
nothing at all) and reachable the moment the rescue widens admission. */
(t) => ((isAtIntakeColumn(t) && !isAtHoldColumn(t)) || isAtUndeclaredColumn(t))
&& t.userPaused !== true
&& isTaskStillInPlanningStage(t)
&& !this.advancedRecoveryReservations.has(t.id)
&& !this.processing.has(t.id) && !this.hasLivePlanningWork(t.id) && !t.paused
&& t.status !== "awaiting-approval" && t.status !== "failed" && t.status !== "stuck-killed"