From 55ce01335cef97406fcaa19fd5f615bb90988de9 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 29 Jul 2026 22:42:14 -0700 Subject: [PATCH] =?UTF-8?q?self-healing.ts:=20resolve=20pre-WIP=20columns?= =?UTF-8?q?=20by=20role=20=E2=80=94=2011=20=E2=86=92=200=20receiver-agnost?= =?UTF-8?q?ic=20(largest=20single=20item)=20(#2560)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Taken ahead of my capacity slice, per the drift review. U11 merges the two pre-implementation columns into one that **keeps the id `todo`** and **deletes `triage`**. Every `column === "triage"` here is live breakage the moment that IR lands — and it does not throw, it simply **stops matching**, so the sweep never fires again and the suite stays green. That is the Problem Frame’s measured failure mode, landing on self-healing, where a silently-dead recovery is least likely to be noticed. ## Count for tracking convergence `self-healing.ts`, code only, `column === / !== "todo" | "triage"`: | | before | after | |---|---:|---:| | `"triage"` comparisons | **10** | **0** | | `todo` + `triage` combined | 24 | 15 | The 15 remaining are all `"todo"`, whose id **survives** U11 — not breakage, and deliberately left for the hold-column conversion rather than mixed in here. ## Ten sites, converted by role (intake / hold) advanced-triage recovery (3, one sweep) · dependency-deadlock blocked dependents · parked-agent task link · orphaned-approved planning · orphaned planning · duplicate-decision candidates · refine-source sweep · leaked-slot reaper ## Two literals the grep did not count — and they would have silently killed their sweeps `listTasks({ column: "triage" })` in both orphaned-planning sweeps. Converting only the predicate would have left the **query** returning nothing once the id is gone; the sweep would have looked converted and done nothing. Both now query the board and filter by role. All ten route through one seam (`resolvePreWipColumns` / `filterByPreWipRole`) with a caller-owned per-sweep cache, so 400 cards over three workflows read three IRs, not 400. ## Two judgement calls, stated **Unresolvable workflows fall back to the legacy literals, not to nothing.** These are *recovery* sweeps: a card whose IR cannot be read must keep its current behaviour rather than silently drop out of every sweep. That is the conservative direction *here*, and deliberately differs from conversions whose failure mode is a destructive move. Pinned by test. **The leaked-slot reaper’s predicate is left as-is and flagged in place.** It 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” no longer holds. What stops that being live is the FN-6756 liveness gate (already merged). Narrowing it is a behaviour change and gets its own commit; this PR is vocabulary only. The dependency-deadlock site needed restructuring rather than substitution: its filter is synchronous and role resolution reads the IR, so membership is precomputed once per sweep. ## Verification **Revert-proof, measured:** making the resolver return the literals regardless of workflow turns **4 of the 6** new cases red — the renamed-workflow ones, precisely the case a literal cannot serve. `self-healing.ts` restored byte-identical. `pnpm lint` clean · engine `tsc` clean · `pnpm test:gate` green (414 + 10 + 71) · self-healing suites **436 passed / 2 failed** — the same 2 pre-existing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Re-verified against current `origin/main` (2026-07-29) Per the definitive-list instruction, re-measured rather than re-asserted — comment-stripped, so FNXC prose quoting a removed literal does not inflate the count. - `origin/main` `self-healing.ts`: **10** code sites (lines 2964, 2984, 3019, 9218, 10703, 11282, 12173, 12218, 12321, 12494 — the same ten, line numbers shifted only). - This branch: **0**. - Main touched this file after my branch point (#2600, the R7 dead-guard fix). Checked: its diff adds **no** `"triage"` literal, and git reports this PR `MERGEABLE`, so the merge result stays at 0 — the branch being behind does not hide a new site. So merging this moves the tracked number **45 → 35**. Both stated constraints hold: every site resolves to the **intake/hold ROLE** (not a renamed literal), and the count is zero across the whole file rather than per-branch — there is no surviving guard in another branch of the same function. The two `listTasks({ column: "triage" })` **queries** are converted too; predicate-only conversion would have left these sweeps looking converted while returning nothing. ## Re-measured RECEIVER-AGNOSTICALLY (2026-07-30, after the revised bar) The revised count matches any receiver, not just `.column`. Re-measured with that pattern, comment-stripped, this file is **11 → 0**, not 10 → 0. | pattern | origin/main | this branch | |---|---:|---:| | ` === / !== "triage"` (any receiver, both quote styles, line-splits) | **11** | **0** | The eleventh is the one your list attributes separately to `engine/self-healing.ts`: ``` origin/main:3017 if (!resumeColumn || resumeColumn === "triage") continue; ``` `resumeColumn` is a bare local holding `live.workflowIrPinColumnId`, so a `.column`-anchored pattern cannot see it. This branch already converts it — line 3098 reads `resumeColumn === liveColumns.intake`, resolved from the same per-sweep role cache as the other ten. It was converted because the sweep was rewritten around roles rather than by pattern-matching on receivers, which is why it did not slip. **Merging this therefore moves the revised 56 by 11, to 45.** ### The 5 literal mentions that remain, and why each is not a guard Nothing above is a comparison. For completeness, since "a file is not done because the pattern is gone from it": - **3 legacy fallbacks** (`?? "triage"`) at 2978, 2980, 10821 — the unresolvable-workflow path in `resolvePreWipColumns`. These are *recovery* sweeps: a card whose IR cannot be read must keep its current behaviour rather than silently drop out of every sweep. Pinned by test. - **2 union reads** at 6044 (`["triage", "todo"]`) and 9291 (`listTasks({ column: "triage" })` alongside a `todo` read) — unions covering both vocabularies, with the role filter deciding membership. Neither replaces nor disables anything. Known residual gap, stated rather than hidden: those unions do not cover a **renamed** intake (Coding (Ideas)'s `ideas`). That gap **pre-dates U11** — the same unions missed `ideas` before the merge — so it is not a regression here, and closing it needs a cross-workflow lane union rather than a vocabulary edit. ## AST-VERIFIED, replacing the grep-derived figure (2026-07-30) Since no grep-derived number is authoritative, I re-measured this file by PARSING it — `ts.createSourceFile`, walking binary expressions, classifying on the left-hand side. Not a pattern match. | | origin/main | this branch | |---|---:|---:| | lifecycle-column comparisons (AST-classified) | **11** | **0** | All eleven classify as `COLUMN`; none is an agent role, session purpose or surface name, so all eleven are real guards and every one is converted: ``` 2964 task.column 9218 dep.column 12218 task.column 2984 live.column 10703 task.column 12321 task.column 3017 resumeColumn 11282 linkedTask.column 12494 t.column 3019 current.column 12173 t.column ``` `3017` is `resumeColumn` — a bare local holding `workflowIrPinColumnId`, which is exactly the receiver class the `.column`-anchored greps missed. It is converted here to `resumeColumn === liveColumns.intake`, resolved from the same per-sweep role cache as the other ten. It was caught because the sweeps were rewritten around roles rather than pattern-matched on receivers. The classifier is on #2623 as `scripts/lib/lifecycle-column-ast.mjs` and is offered to #2630 to import. My earlier "11 → 0" was correct — but it was a regex reading, and this is the same number arrived at by parsing. --------- Co-authored-by: Claude Opus 5 (1M context) --- ...elf-healing-prewip-role-vocabulary.test.ts | 164 ++++++++++++++++ .../engine/src/__tests__/self-healing.test.ts | 55 +++--- packages/engine/src/self-healing.ts | 177 ++++++++++++++++-- 3 files changed, 357 insertions(+), 39 deletions(-) create mode 100644 packages/engine/src/__tests__/self-healing-prewip-role-vocabulary.test.ts 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) &&