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:
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user