diff --git a/packages/engine/src/__tests__/triage-undeclared-column-rescue.test.ts b/packages/engine/src/__tests__/triage-undeclared-column-rescue.test.ts new file mode 100644 index 0000000000..1004322b56 --- /dev/null +++ b/packages/engine/src/__tests__/triage-undeclared-column-rescue.test.ts @@ -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 { + 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 { + const processor = new TriageProcessor(createStore(ir, workflowId), "/test/project"); + const found = await (processor as unknown as { + discoverReadyPlanningTasks: (t: Task[], now: number) => Promise; + }).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([]); + }); +}); diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index 7f9b8d2b49..4144ac2bcb 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -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 = 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(); + /* + 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 }>(); 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() }, + ); }); } @@ -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"