diff --git a/packages/engine/src/__tests__/self-healing-prewip-role-vocabulary.test.ts b/packages/engine/src/__tests__/self-healing-prewip-role-vocabulary.test.ts new file mode 100644 index 0000000000..4a4c57ac50 --- /dev/null +++ b/packages/engine/src/__tests__/self-healing-prewip-role-vocabulary.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it, vi } from "vitest"; +import "@fusion/core"; // register built-in traits +import type { Task, TaskStore, WorkflowIr } from "@fusion/core"; +import { SelfHealingManager } from "../self-healing.js"; + +/* +FNXC:WorkflowColumns 2026-07-29-09:30 (Phase B — self-healing intake/hold vocabulary): +Proves the converted sweeps resolve their PRE-WIP columns from the task's workflow +instead of the literals "triage"/"todo". + +WHY THIS MATTERS MORE THAN A GREEN SUITE. U11 merges the two pre-implementation +columns into one that KEEPS the id "todo" and DELETES "triage". A `column === +"triage"` guard does not throw when that id disappears — it simply stops matching, +so the sweep silently never fires again and every existing test stays green. That is +the exact failure the plan's Problem Frame measured (82 guards that would stop +matching without failing a test), and it is why each case below is asserted against +a RENAMED-column workflow: on the literal, the renamed case matches nothing. + +Asserted through `filterByPreWipRole` / `resolvePreWipColumns` — the seam every +converted site now routes through — so one test covers all ten rather than +requiring ten sweep fixtures (FN-5048: do not add slow tests). +*/ + +/** A workflow whose intake/hold columns are NOT named triage/todo. */ +const RENAMED_IR: WorkflowIr = { + version: "v2", + name: "renamed-lifecycle", + columns: [ + { id: "inbox", name: "Inbox", traits: [{ trait: "intake" }] }, + { id: "backlog", name: "Backlog", traits: [{ trait: "hold" }] }, + { id: "building", name: "Building", traits: [{ trait: "wip" }] }, + { id: "shipped", name: "Shipped", traits: [{ trait: "complete" }] }, + ], + nodes: [], + edges: [], +} as unknown as WorkflowIr; + +function storeFor(ir?: WorkflowIr): TaskStore { + return { + getTaskWorkflowSelection: vi.fn(() => (ir ? { workflowId: "custom:renamed", stepIds: [] } : undefined)), + getTaskWorkflowSelectionAsync: vi.fn(async () => (ir ? { workflowId: "custom:renamed", stepIds: [] } : undefined)), + getWorkflowDefinition: vi.fn(async () => (ir ? { ir } : undefined)), + } as unknown as TaskStore; +} + +function managerFor(store: TaskStore): SelfHealingManager { + const manager = Object.create(SelfHealingManager.prototype) as SelfHealingManager; + (manager as unknown as Record).store = store; + (manager as unknown as Record).options = {}; + return manager; +} + +const task = (id: string, column: string): Task => ({ id, column } as unknown as Task); + +type Internals = { + resolvePreWipColumns(taskId: string, cache: Map): Promise<{ intake: string; hold: string }>; + filterByPreWipRole(tasks: Task[], roles: Array<"intake" | "hold">, cache: Map): Promise; +}; + +describe("self-healing pre-WIP column vocabulary", () => { + /* + FNXC:WorkflowColumns 2026-07-29-12:15 (post-#2515 audit): + THE CASE THIS CONVERSION EXISTS FOR. #2515 merged the default lineage's two + pre-implementation columns into ONE column with id "todo" carrying BOTH `intake` + and `hold` (`builtin:coding` -> BUILTIN_STEPWISE_FINAL_REVIEW -> clones + BUILTIN_STEPWISE_CODING). So `triage` no longer exists for a default-workflow + card, and every `column === "triage"` guard silently stopped matching — no throw, + no failing test, the sweep just never fires again. + + Both roles resolving to "todo" is the CORRECT post-merge answer and is what makes + the converted sweeps keep firing. Asserting it here is the audit: if a future IR + edit separates them again, or drops a trait, this pins which column each sweep + will actually match. + */ + it("resolves BOTH pre-WIP roles to the merged `todo` column for the default workflow", async () => { + const manager = managerFor(storeFor()) as unknown as Internals; + const columns = await manager.resolvePreWipColumns("FN-1", new Map()); + expect(columns).toEqual({ intake: "todo", hold: "todo" }); + }); + + it("matches a default-workflow card sitting in the merged column (the sweeps still fire)", async () => { + const manager = managerFor(storeFor()) as unknown as Internals; + const kept = await manager.filterByPreWipRole( + [task("A", "todo"), task("B", "in-progress"), task("C", "triage")], + ["intake"], + new Map(), + ); + // "todo" fills intake post-#2515; the legacy literal "triage" does NOT — which + // is exactly why the unconverted guards went silent. + expect(kept.map((t) => t.id)).toEqual(["A"]); + }); + + /* + THE POINT OF THE CONVERSION. On the old literals this returns nothing — `inbox` + is not `"triage"` — so the sweep would silently stop firing for this workflow. + */ + it("resolves a RENAMED workflow's intake and hold columns", async () => { + const manager = managerFor(storeFor(RENAMED_IR)) as unknown as Internals; + const columns = await manager.resolvePreWipColumns("FN-1", new Map()); + expect(columns).toEqual({ intake: "inbox", hold: "backlog" }); + }); + + it("filters by intake role across a renamed workflow", async () => { + const manager = managerFor(storeFor(RENAMED_IR)) as unknown as Internals; + const kept = await manager.filterByPreWipRole( + [task("A", "inbox"), task("B", "backlog"), task("C", "building"), task("D", "triage")], + ["intake"], + new Map(), + ); + // `inbox` fills the intake role; the LEGACY literal `triage` does not, because + // this workflow does not declare it. + expect(kept.map((t) => t.id)).toEqual(["A"]); + }); + + it("filters by intake OR hold role across a renamed workflow", async () => { + const manager = managerFor(storeFor(RENAMED_IR)) as unknown as Internals; + const kept = await manager.filterByPreWipRole( + [task("A", "inbox"), task("B", "backlog"), task("C", "building")], + ["intake", "hold"], + new Map(), + ); + expect(kept.map((t) => t.id)).toEqual(["A", "B"]); + }); + + /* + Recovery sweeps must keep working when a workflow cannot be read: returning + nothing would drop the card out of EVERY converted sweep, a silent loss of + recovery worse than resolving imperfectly. + + MEASURED, and not what I first assumed: an unreadable workflow does NOT reach the + `?? "triage"` literal in `resolvePreWipColumns`, because `resolveWorkflowIrForTask` + already falls back to the DEFAULT workflow IR internally. So the answer is the + default lineage's merged column — strictly better than the legacy literals, since + it is the vocabulary the overwhelming majority of cards actually use. The literal + fallback survives only for a resolvable-but-column-less IR (v1), which is why it + is not asserted here. + */ + it("falls back to the DEFAULT workflow vocabulary when the task's workflow cannot be read", async () => { + const throwingStore = { + getTaskWorkflowSelection: vi.fn(() => { throw new Error("unreadable"); }), + getTaskWorkflowSelectionAsync: vi.fn(async () => { throw new Error("unreadable"); }), + getWorkflowDefinition: vi.fn(async () => { throw new Error("unreadable"); }), + } as unknown as TaskStore; + const manager = managerFor(throwingStore) as unknown as Internals; + expect(await manager.resolvePreWipColumns("FN-1", new Map())).toEqual({ intake: "todo", hold: "todo" }); + }); + + /* + The cache is caller-owned per sweep so a board of N cards on one workflow costs + ONE IR read, not N. Asserted on the resolver call count, since a regression here + is a silent per-card IR read across a 400-card sweep. + */ + it("reads one IR per workflow per sweep, not one per task", async () => { + const store = storeFor(RENAMED_IR); + const manager = managerFor(store) as unknown as Internals; + const cache = new Map(); + await manager.filterByPreWipRole( + [task("A", "inbox"), task("B", "backlog"), task("C", "inbox")], + ["intake"], + cache, + ); + expect(vi.mocked(store.getWorkflowDefinition)).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/engine/src/__tests__/self-healing.test.ts b/packages/engine/src/__tests__/self-healing.test.ts index ffc508d1c8..bb38a907f2 100644 --- a/packages/engine/src/__tests__/self-healing.test.ts +++ b/packages/engine/src/__tests__/self-healing.test.ts @@ -1,3 +1,12 @@ +/* +FNXC:WorkflowColumns 2026-07-29-12:15 (post-#2515 audit): +Fixtures use the MERGED planning column ("todo"), not the deleted "triage". #2515 +collapsed the default lineage's two pre-implementation columns into one with id +"todo" carrying `intake` + `hold`, so a default-workflow card is never in "triage" +again. A fixture left there exercised a state the product can no longer produce — +and, because the converted sweeps resolve intake by ROLE, would have quietly +asserted that the sweeps do nothing. +*/ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; // Mock node modules @@ -8265,7 +8274,7 @@ describe("SelfHealingManager", () => { (store.listTasks as ReturnType).mockResolvedValue([ { id: "FN-100", - column: "triage", + column: "todo", status: "planning", paused: false, log: [ @@ -8301,7 +8310,7 @@ describe("SelfHealingManager", () => { (store.listTasks as ReturnType).mockResolvedValue([ { id: "FN-101", - column: "triage", + column: "todo", status: "planning", paused: false, log: [{ action: "Spec review: APPROVE" }], @@ -8332,7 +8341,7 @@ describe("SelfHealingManager", () => { (store.listTasks as ReturnType).mockResolvedValue([ { id: "FN-102", - column: "triage", + column: "todo", status: "planning", paused: false, log: [ @@ -8428,7 +8437,7 @@ describe("SelfHealingManager", () => { (store.listTasks as ReturnType).mockResolvedValue([ { id: "FN-200", - column: "triage", + column: "todo", status: "planning", paused: false, log: [], @@ -8465,10 +8474,10 @@ describe("SelfHealingManager", () => { it.each([ { column: "in-progress", status: null, worktree: "/tmp/claimed" }, { column: "todo", status: null, worktree: undefined, steps: [{ id: "planned" }] }, - { column: "triage", status: "planning", worktree: "/tmp/claimed", firstExecutionAt: "2026-01-01T00:01:00.000Z" }, + { column: "in-progress", status: "planning", worktree: "/tmp/claimed", firstExecutionAt: "2026-01-01T00:01:00.000Z" }, ])("does not clear a stale candidate advanced to $column", async (live) => { const candidate = { - id: "FN-8361", column: "triage", status: "planning", paused: false, + id: "FN-8361", column: "todo", status: "planning", paused: false, log: [], updatedAt: "2026-01-01T00:00:00.000Z", }; const updateTaskAtomic = vi.fn(async (_id: string, updater: (row: any) => any) => updater({ ...candidate, ...live })); @@ -8494,7 +8503,7 @@ describe("SelfHealingManager", () => { (store.listTasks as ReturnType).mockResolvedValue([ { id: "FN-201", - column: "triage", + column: "todo", status: "planning", paused: false, log: [], @@ -8523,7 +8532,7 @@ describe("SelfHealingManager", () => { (store.listTasks as ReturnType).mockResolvedValue([ { id: "FN-202", - column: "triage", + column: "todo", status: "planning", paused: false, log: [ @@ -8555,7 +8564,7 @@ describe("SelfHealingManager", () => { (store.listTasks as ReturnType).mockResolvedValue([ { id: "FN-203", - column: "triage", + column: "todo", status: "planning", paused: true, log: [], @@ -8584,7 +8593,7 @@ describe("SelfHealingManager", () => { (store.listTasks as ReturnType).mockResolvedValue([ { id: "FN-204", - column: "triage", + column: "todo", status: "planning", paused: false, log: [], @@ -9409,7 +9418,7 @@ describe("stale triage processing eviction before recovery", () => { (store.listTasks as ReturnType).mockResolvedValue([ { id: "FN-100", - column: "triage", + column: "todo", status: "planning", paused: false, log: [{ action: "Spec review: APPROVE" }], @@ -9450,7 +9459,7 @@ describe("stale triage processing eviction before recovery", () => { (store.listTasks as ReturnType).mockResolvedValue([ { id: "FN-100", - column: "triage", + column: "todo", status: "planning", paused: false, log: [{ action: "Spec review: APPROVE" }], @@ -9485,7 +9494,7 @@ describe("stale triage processing eviction before recovery", () => { (store.listTasks as ReturnType).mockResolvedValue([ { id: "FN-101", - column: "triage", + column: "todo", status: "planning", paused: false, log: [{ action: "Spec review: REVISE" }], @@ -9516,9 +9525,9 @@ describe("stale triage processing eviction before recovery", () => { const old = "2026-01-01T00:00:00.000Z"; (store.listTasks as ReturnType).mockResolvedValue([ - { id: "FN-approved-live", column: "triage", status: "planning", paused: false, priority: "normal", createdAt: old, updatedAt: old }, - { id: "FN-orphan-live", column: "triage", status: "planning", paused: false, priority: "normal", createdAt: old, updatedAt: old }, - { id: "FN-refinement-live", column: "triage", status: "planning", paused: false, priority: "normal", sourceType: "task_refine", createdAt: old, updatedAt: old }, + { id: "FN-approved-live", column: "todo", status: "planning", paused: false, priority: "normal", createdAt: old, updatedAt: old }, + { id: "FN-orphan-live", column: "todo", status: "planning", paused: false, priority: "normal", createdAt: old, updatedAt: old }, + { id: "FN-refinement-live", column: "todo", status: "planning", paused: false, priority: "normal", sourceType: "task_refine", createdAt: old, updatedAt: old }, { id: "FN-peer-1", column: "todo", sourceType: "dashboard_ui", createdAt: old, updatedAt: "2026-01-01T00:01:00.000Z" }, { id: "FN-peer-2", column: "todo", sourceType: "dashboard_ui", createdAt: old, updatedAt: "2026-01-01T00:02:00.000Z" }, { id: "FN-peer-3", column: "todo", sourceType: "dashboard_ui", createdAt: old, updatedAt: "2026-01-01T00:03:00.000Z" }, @@ -9551,9 +9560,9 @@ describe("stale triage processing eviction before recovery", () => { }); const old = "2026-01-01T00:00:00.000Z"; (store.listTasks as ReturnType).mockResolvedValue([ - { id: "FN-live", column: "triage", status: "planning", paused: false, priority: "normal", createdAt: old, updatedAt: old }, - { id: "FN-hung", column: "triage", status: "planning", paused: false, priority: "normal", createdAt: old, updatedAt: old }, - { id: "FN-stuck-aborted", column: "triage", status: "planning", paused: false, priority: "normal", createdAt: old, updatedAt: old }, + { id: "FN-live", column: "todo", status: "planning", paused: false, priority: "normal", createdAt: old, updatedAt: old }, + { id: "FN-hung", column: "todo", status: "planning", paused: false, priority: "normal", createdAt: old, updatedAt: old }, + { id: "FN-stuck-aborted", column: "todo", status: "planning", paused: false, priority: "normal", createdAt: old, updatedAt: old }, ]); vi.setSystemTime(new Date("2026-01-01T01:00:00.000Z")); @@ -9576,9 +9585,9 @@ describe("stale triage processing eviction before recovery", () => { }); const old = "2026-01-01T00:00:00.000Z"; const planningTasks = [ - { id: "FN-live", column: "triage", status: "planning", paused: false, priority: "normal", createdAt: old, updatedAt: old }, - { id: "FN-hung", column: "triage", status: "planning", paused: false, priority: "normal", createdAt: old, updatedAt: old }, - { id: "FN-stuck-aborted", column: "triage", status: "planning", paused: false, priority: "normal", createdAt: old, updatedAt: old }, + { id: "FN-live", column: "todo", status: "planning", paused: false, priority: "normal", createdAt: old, updatedAt: old }, + { id: "FN-hung", column: "todo", status: "planning", paused: false, priority: "normal", createdAt: old, updatedAt: old }, + { id: "FN-stuck-aborted", column: "todo", status: "planning", paused: false, priority: "normal", createdAt: old, updatedAt: old }, ]; (store.listTasks as ReturnType).mockResolvedValue(planningTasks); vi.setSystemTime(new Date("2026-01-01T01:00:00.000Z")); @@ -11713,7 +11722,7 @@ describe("FN-5335 triple-proof no-action unit coverage", () => { makeTask({ id: "FN-6770", column: "in-progress" }), makeTask({ id: "FN-6771", column: "todo" }), makeTask({ id: "FN-6780", column: "todo", status: "queued" }), - makeTask({ id: "FN-TRIAGE", column: "triage" }), + makeTask({ id: "FN-TRIAGE", column: "todo" }), makeTask({ id: "FN-DONE", column: "done" }), ]); diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index 68cf694bf5..da7914d742 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -2947,12 +2947,87 @@ export class SelfHealingManager extends SelfHealingGitEvidence { * ordinary triage card. Completed work goes through the normal review * recovery seam; incomplete remediation resumes at its pinned column. */ + /* + FNXC:WorkflowColumns 2026-07-29-09:30 (Phase B — self-healing intake/hold vocabulary): + Per-sweep resolver for the two PRE-WIP lifecycle roles this file gates on. + + `triage` is INTAKE and `todo` is HOLD, but only for the built-in coding shape. + U11 merges them into one column that KEEPS the id "todo" and DELETES "triage", + so every `column === "triage"` here becomes a guard that silently stops matching + the moment that IR lands — recovery disabled with a green suite, which is exactly + the failure the plan's Problem Frame measured (82 guards that would stop matching + without failing a test). + + Resolution is per TASK because a board spans workflows, and the cache is + caller-owned per sweep so 400 cards over three workflows read three IRs, not 400 + (the shape `resolveTaskLifecycleColumns` documents and the completed-stranded + sweep above already uses). + + UNRESOLVABLE workflows return the legacy literals rather than nothing. These are + RECOVERY sweeps: a card whose IR cannot be read must keep its current recovery + behaviour, not silently drop out of every sweep. That is the conservative + direction here, and it differs deliberately from conversions whose failure mode + is a destructive move. + */ + private async resolvePreWipColumns( + taskId: string, + cache: Map>>, + ): Promise<{ intake: string; hold: string }> { + try { + const lifecycle = resolveLifecycleColumns(await resolveWorkflowIrForTask(this.store, taskId, cache)); + return { intake: lifecycle?.intake ?? "triage", hold: lifecycle?.hold ?? "todo" }; + } catch { + return { intake: "triage", hold: "todo" }; + } + } + + /** True when the task's own column fills its workflow's intake or hold role. */ + private async isPreWipColumn(task: Task): Promise { + const columns = await this.resolvePreWipColumns( + task.id, + new Map>>(), + ); + return task.column === columns.intake || task.column === columns.hold; + } + + /** Filter `tasks` to those whose column fills one of the given pre-WIP roles. */ + private async filterByPreWipRole( + tasks: Task[], + roles: Array<"intake" | "hold">, + cache: Map>>, + ): Promise { + const kept: Task[] = []; + for (const task of tasks) { + const columns = await this.resolvePreWipColumns(task.id, cache); + if (roles.some((role) => task.column === columns[role])) kept.push(task); + } + return kept; + } + async recoverAdvancedTriageTasks(): Promise { try { const settings = await this.store.getSettings(); if (settings.globalPause || settings.enginePaused) return 0; - const tasks = await this.store.listTasks({ column: "triage", slim: true }); + /* + FNXC:WorkflowColumns 2026-07-29-17:40 (PR #2560 review — greptile P1): + THE QUERY carried the literal too, and I missed it while converting this + sweep's predicate. `listTasks({ column: "triage" })` returns EMPTY for the + merged default lineage (#2515 collapsed the two pre-implementation columns + into one with id "todo") and for any workflow that renamed its intake column — + so the role-aware filter below received no candidates and this recovery was + dead, silently. Converting a predicate while leaving its source query on a + literal produces a sweep that LOOKS converted and does nothing. + + Read the board and filter by role. `slim` is preserved; the extra cost is one + board read per sweep instead of an indexed column read, which the per-workflow + IR cache below bounds to one resolution per workflow rather than per task. + */ + const tasks = await this.filterByPreWipRole( + await this.store.listTasks({ slim: true, includeArchived: false }), + ["intake"], + new Map>>(), + ); const executingIds = this.options.getExecutingTaskIds?.() ?? new Set(); const planningIds = this.options.getPlanningTaskIds?.() ?? new Set(); const hasForeignPathOwner = (task: Task) => { @@ -2960,9 +3035,14 @@ export class SelfHealingManager extends SelfHealingGitEvidence { const owner = activeSessionRegistry.lookupByPath(task.worktree); return owner != null && owner.taskId !== task.id; }; + /* + FNXC:WorkflowColumns 2026-07-29-09:30 (Phase B): intake ROLE, not the literal + "triage". U11 deletes that id, and a literal here would stop matching with no + test failing — the sweep would simply never fire again. + */ + const preWipCache = new Map>>(); const candidates = tasks.filter((task) => - task.column === "triage" - && task.status == null + task.status == null && !task.paused && !task.error && Boolean(task.worktree) @@ -2980,8 +3060,9 @@ export class SelfHealingManager extends SelfHealingGitEvidence { if (this.options.reserveAdvancedTriageRecovery && !releaseReservation) continue; try { const live = await this.store.getTask(snapshot.id); + const liveColumns = await this.resolvePreWipColumns(live.id, preWipCache); if ( - live.column !== "triage" + live.column !== liveColumns.intake || live.status != null || live.paused || live.error @@ -3014,9 +3095,9 @@ export class SelfHealingManager extends SelfHealingGitEvidence { } const resumeColumn = live.workflowIrPinColumnId; - if (!resumeColumn || resumeColumn === "triage") continue; + if (!resumeColumn || resumeColumn === liveColumns.intake) continue; const moved = await this.store.moveTaskIf(live.id, resumeColumn, (current) => - current.column === "triage" + current.column === liveColumns.intake && current.status == null && !current.paused && !current.error @@ -9198,6 +9279,15 @@ export class SelfHealingManager extends SelfHealingGitEvidence { const maxAutoMergeRetries = resolveMaxAutoMergeRetries(settings); const now = Date.now(); const inReview = await this.store.listTasks({ column: "in-review", slim: true }); + /* + FNXC:WorkflowColumns 2026-07-29-17:40 (PR #2560 review): + This trio is a UNION and is deliberately left on literals. For the merged + default lineage the `triage` read returns empty and `todo` supplies the cards; + for a legacy/custom workflow that still declares `triage` it supplies them. + Either way the union is complete, and the role filter below decides which rows + count as pre-WIP. Unlike the recoverAdvancedTriageTasks query this replaces + nothing and disables nothing — it is a redundant read, not a dead sweep. + */ const triage = await this.store.listTasks({ column: "triage", slim: true }); const todo = await this.store.listTasks({ column: "todo", slim: true }); const inProgress = await this.store.listTasks({ column: "in-progress", slim: true }); @@ -9210,12 +9300,24 @@ export class SelfHealingManager extends SelfHealingGitEvidence { dependentsByBlocker.set(task.blockedBy, dependents); } + /* + FNXC:WorkflowColumns 2026-07-29-09:30 (Phase B): "dependents parked before WIP" + is an intake-or-hold ROLE question, but the filter below is synchronous and + role resolution reads the workflow IR. Precompute the membership once — one + cache for the whole sweep, so N dependents across M workflows cost M IR reads. + */ + const preWipCache = new Map>>(); + const allDependents = [...dependentsByBlocker.values()].flat(); + const preWipDependentIds = new Set( + (await this.filterByPreWipRole(allDependents, ["intake", "hold"], preWipCache)).map((t) => t.id), + ); + const candidates = inReview.filter((task) => { if (task.deletedAt) return false; const cooldownStart = this.deadlockRecoveryCooldown.get(task.id) ?? 0; const cooldownElapsed = now - cooldownStart; const hasBlockedDependents = (dependentsByBlocker.get(task.id) ?? []).some( - (dep) => dep.column === "triage" || dep.column === "todo", + (dep) => preWipDependentIds.has(dep.id), ); return task.column === "in-review" && allowsAutoMergeProcessing(task, settings) && @@ -10693,6 +10795,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence { const executingIds = this.options.getExecutingTaskIds?.() ?? new Set(); const now = Date.now(); + const reaperCache = new Map>>(); let reaped = 0; for (const { taskId } of holders) { @@ -10700,7 +10803,23 @@ export class SelfHealingManager extends SelfHealingGitEvidence { if (executingIds.has(taskId)) continue; const task = await this.store.getTask(taskId).catch(() => null); - const reapableColumn = !task || task.column === "todo" || task.column === "triage"; + /* + FNXC:WorkflowColumns 2026-07-29-09:30 (Phase B): intake-or-hold ROLE, same + behaviour as the literals it replaces. + + NOT changed here, and worth stating: this predicate is arguably too WIDE + under plan-in-place. A card being specified sits in the hold column while a + planner works in its worktree, so "waiting to run must not pin a worktree" + (the rationale this sweep was written with, before planning moved there) no + longer holds. What stops that being a live bug is the FN-6756 liveness gate + below, which now refuses to release a binding while any session surface is + registered. Narrowing the predicate is a BEHAVIOUR change and belongs in its + own commit; this one is vocabulary only. + */ + const preWip = task + ? await this.resolvePreWipColumns(task.id, reaperCache) + : { intake: "triage", hold: "todo" }; + const reapableColumn = !task || task.column === preWip.hold || task.column === preWip.intake; if (!reapableColumn) continue; if (task) { @@ -11279,7 +11398,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence { } else if (linkedTask.assignedAgentId && linkedTask.assignedAgentId !== agent.id) { shouldClear = true; reason = `linked task assigned to ${linkedTask.assignedAgentId}`; - } else if (linkedTask.column === "todo" || linkedTask.column === "triage") { + } else if (await this.isPreWipColumn(linkedTask)) { const activeRun = await agentStore.getActiveHeartbeatRun(agent.id); const proof = evaluateParkedAgentTaskLink({ agent, @@ -12165,12 +12284,22 @@ export class SelfHealingManager extends SelfHealingGitEvidence { // block recovery indefinitely. this.options.evictStaleTriageProcessing?.(); - const tasks = await this.store.listTasks({ column: "triage" }); + /* + FNXC:WorkflowColumns 2026-07-29-09:30 (Phase B): the LIST QUERY carried the + literal too — `listTasks({ column: "triage" })` returns nothing once that id + is gone, so converting only the predicate would have left the sweep dead. + Query the whole board and filter by role. + */ + const preWipCache = new Map>>(); + const tasks = await this.filterByPreWipRole( + await this.store.listTasks({ slim: true, includeArchived: false }), + ["intake"], + preWipCache, + ); const planningIds = this.options.getPlanningTaskIds?.() ?? new Set(); const now = Date.now(); const orphanedApproved = tasks.filter((t) => - t.column === "triage" && t.status === "planning" && !t.paused && !planningIds.has(t.id) && @@ -12215,7 +12344,12 @@ export class SelfHealingManager extends SelfHealingGitEvidence { } const tasks = await this.store.listTasks({ slim: true, includeArchived: false, limit: 500 }); - const candidates = tasks.filter((task) => task.column === "triage" || task.column === "todo"); + // FNXC:WorkflowColumns 2026-07-29-09:30 (Phase B): intake-or-hold role. + const candidates = await this.filterByPreWipRole( + tasks, + ["intake", "hold"], + new Map>>(), + ); let resolved = 0; let processedMarkers = 0; @@ -12317,8 +12451,13 @@ export class SelfHealingManager extends SelfHealingGitEvidence { const planningIds = this.options.getPlanningTaskIds?.() ?? new Set(); const now = Date.now(); - const candidates = tasks.filter((task) => { - if (task.column !== "triage") return false; + // FNXC:WorkflowColumns 2026-07-29-09:30 (Phase B): intake role. + const intakeCandidates = await this.filterByPreWipRole( + tasks, + ["intake"], + new Map>>(), + ); + const candidates = intakeCandidates.filter((task) => { if (task.sourceType !== "task_refine") return false; if (task.paused) return false; if (task.status !== null && task.status !== "planning") return false; @@ -12486,12 +12625,18 @@ export class SelfHealingManager extends SelfHealingGitEvidence { // block recovery indefinitely. this.options.evictStaleTriageProcessing?.(); - const tasks = await this.store.listTasks({ column: "triage" }); + // FNXC:WorkflowColumns 2026-07-29-09:30 (Phase B): see the sibling sweep — the + // column filter is a role filter, and the list query carried the literal too. + const preWipCache = new Map>>(); + const tasks = await this.filterByPreWipRole( + await this.store.listTasks({ slim: true, includeArchived: false }), + ["intake"], + preWipCache, + ); const planningIds = this.options.getPlanningTaskIds?.() ?? new Set(); const now = Date.now(); const orphaned = tasks.filter((t) => - t.column === "triage" && t.status === "planning" && !t.paused && !planningIds.has(t.id) &&