diff --git a/.changeset/self-healing-branch-misbound-query.md b/.changeset/self-healing-branch-misbound-query.md new file mode 100644 index 0000000000..33f56eac9c --- /dev/null +++ b/.changeset/self-healing-branch-misbound-query.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Branch-misbinding is detected again on boards with renamed columns. +category: fix +dev: `recoverBranchMisboundInReviewTasks` read the literal `in-review`, so on a renamed board a review card whose branch tip belongs to another task was never detected. Read resolves via `resolveProjectColumnsForRoles`, per-card check resolves per card. diff --git a/.changeset/self-healing-done-merge-metadata-query.md b/.changeset/self-healing-done-merge-metadata-query.md new file mode 100644 index 0000000000..683057ccc1 --- /dev/null +++ b/.changeset/self-healing-done-merge-metadata-query.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Completed tasks get their merge metadata repaired again on renamed boards. +category: fix +dev: `recoverDoneTaskMergeMetadata` read the literal `done`, so on a renamed board a completed card's merge metadata was never repaired and could keep pointing at a commit that is not the one that landed. Read resolves via `resolveProjectColumnsForRoles(["complete"])` — deliberately not the terminal union — and the per-card check resolves per card. diff --git a/.changeset/self-healing-ghost-review-query.md b/.changeset/self-healing-ghost-review-query.md new file mode 100644 index 0000000000..37b4ba5342 --- /dev/null +++ b/.changeset/self-healing-ghost-review-query.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Ghost review cards are detected again on boards with renamed columns. +category: fix +dev: `recoverGhostReviewTasks` read the literal `in-review`, so a card parked past the stuck timeout with no merge-lane owner was never found on a renamed board. Read resolves via `resolveProjectColumnsForRoles` and the per-card check resolves per card; the kick-back keeps its literal target because it passes `recoveryRehome: true`. diff --git a/.changeset/self-healing-inprogress-limbo-query.md b/.changeset/self-healing-inprogress-limbo-query.md new file mode 100644 index 0000000000..0d490bdf2d --- /dev/null +++ b/.changeset/self-healing-inprogress-limbo-query.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Dead cards no longer hold a work slot forever on boards with renamed columns. +category: fix +dev: `recoverInProgressLimbo` read the literal `in-progress`, so a card holding a wip slot with no worktree, no branch and no started step was never reclaimed on a renamed board. The read resolves via `resolveProjectColumnsForRoles` and the per-card column check resolves per card, falling back to the project set when a card's own workflow is unreadable. diff --git a/.changeset/self-healing-misclassified-failures-query.md b/.changeset/self-healing-misclassified-failures-query.md new file mode 100644 index 0000000000..f813c50238 --- /dev/null +++ b/.changeset/self-healing-misclassified-failures-query.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Falsely-failed tasks with all steps done are cleared again on boards with renamed columns. +category: fix +dev: `recoverMisclassifiedFailures` read the literal `in-review`, so a task parked failed for "without calling fn_task_done" whose steps were all actually done stayed visibly failed on a renamed board. Read resolves via `resolveProjectColumnsForRoles`, per-card check resolves per card. diff --git a/.changeset/self-healing-missing-worktree-review-query.md b/.changeset/self-healing-missing-worktree-review-query.md new file mode 100644 index 0000000000..4828d86443 --- /dev/null +++ b/.changeset/self-healing-missing-worktree-review-query.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Review failures from a missing worktree recover on renamed boards, and on boards with two review columns. +category: fix +dev: `recoverMissingWorktreeReviewFailures` had per-candidate lane wiring but still read the literal `in-review`, so only boards whose review lane kept that name benefited. The read now resolves via `resolveProjectColumnsForRoles`. Its per-candidate set also came from `resolveTaskLifecycleColumns().review` (first column per role) while the classifiers take a membership set; it now unions `columnsWithFlag` across the three review roles. diff --git a/.changeset/self-healing-no-commits-audit-query.md b/.changeset/self-healing-no-commits-audit-query.md new file mode 100644 index 0000000000..57f64b08f3 --- /dev/null +++ b/.changeset/self-healing-no-commits-audit-query.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: The zero-commit audit sees quietly-parked review cards again on renamed boards. +category: fix +dev: `auditNoCommitsExpectedCandidates` read the literal `in-review`, so on a renamed board only the `no_commits` error path fed the audit and a card sitting in a renamed review lane with zero commits and no error was never flagged. Read resolves via `resolveProjectColumnsForRoles`, the lane verdict resolves per card. diff --git a/.changeset/self-healing-no-progress-notaskdone-query.md b/.changeset/self-healing-no-progress-notaskdone-query.md new file mode 100644 index 0000000000..0d7ad9dcfe --- /dev/null +++ b/.changeset/self-healing-no-progress-notaskdone-query.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Failed tasks that produced nothing release their work slot again on renamed boards. +category: fix +dev: `recoverNoProgressNoTaskDoneFailures` read the literal `in-progress`, so a wip card failed for "no fn_task_done" with no step progress and no git work was never requeued on a renamed board and kept holding its slot. Read resolves via `resolveProjectColumnsForRoles`, per-card check resolves per card. diff --git a/.changeset/self-healing-orphan-scope-query.md b/.changeset/self-healing-orphan-scope-query.md new file mode 100644 index 0000000000..c1c823b673 --- /dev/null +++ b/.changeset/self-healing-orphan-scope-query.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Orphan-only scope-violation recovery now runs on boards with renamed columns. +category: fix +dev: `recoverOrphanOnlyScopeViolations` queried the literal `in-review`, so it never ran on a renamed board and such a task stayed failed. Read now resolves via `resolveProjectColumnsForRoles`; the per-card verdict and its `getTaskHardMergeBlocker` resolve from the task's own workflow. diff --git a/.changeset/self-healing-orphaned-executions-query.md b/.changeset/self-healing-orphaned-executions-query.md new file mode 100644 index 0000000000..553552e5e4 --- /dev/null +++ b/.changeset/self-healing-orphaned-executions-query.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: The orphaned-execution signal is now emitted on boards with renamed columns. +category: fix +dev: `recoverOrphanedExecutions` read the literal `in-progress`, so on a renamed board it never emitted `task:orphan-detected-no-action` and an operator had no signal that a wip card had no live session. The sweep takes no lifecycle action; this restores visibility only. Read resolves via `resolveProjectColumnsForRoles`, per-card check resolves per card. diff --git a/.changeset/self-healing-partial-progress-query.md b/.changeset/self-healing-partial-progress-query.md new file mode 100644 index 0000000000..efb233c99a --- /dev/null +++ b/.changeset/self-healing-partial-progress-query.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Partially-completed work is retried again on boards with renamed columns. +category: fix +dev: `recoverPartialProgressNoTaskDoneFailures` read the literal `in-review`, so on a renamed board a card failed for "no fn_task_done" that had made real step progress was never retried and its retry budget was never spent. Read resolves via `resolveProjectColumnsForRoles`, per-card check resolves per card. diff --git a/.changeset/self-healing-post-done-wedge-query.md b/.changeset/self-healing-post-done-wedge-query.md new file mode 100644 index 0000000000..7ef4d1c9ea --- /dev/null +++ b/.changeset/self-healing-post-done-wedge-query.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Post-done wedge recovery now unsticks completed tasks on boards with renamed columns. +category: fix +dev: `recoverPostDoneNonContinuableWedge` queried the literal `in-review`, so a task that finished every step and was wedged `failed` by a post-done continuation error stayed failed on a renamed board. Read now resolves via `resolveProjectColumnsForRoles`, and its `getTaskHardMergeBlocker` judges each card against its own workflow. diff --git a/.changeset/self-healing-pr-conflict-worktree-owner-query.md b/.changeset/self-healing-pr-conflict-worktree-owner-query.md new file mode 100644 index 0000000000..e37b61572c --- /dev/null +++ b/.changeset/self-healing-pr-conflict-worktree-owner-query.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: PR-conflict reclaim no longer reads a busy checkout as unowned on renamed boards. +category: fix +dev: `reclaimPrConflictForTask` built its worktree-owner map from a literal `in-progress` read, so on a renamed board the map was empty and a checkout another task was live in read as unowned. Read resolves via `resolveProjectColumnsForRoles(["countsTowardWip"])`; the map is keyed by worktree path so there is no per-card lane verdict to convert. diff --git a/.changeset/self-healing-reattach-assigned-query.md b/.changeset/self-healing-reattach-assigned-query.md new file mode 100644 index 0000000000..7bfdaa40e6 --- /dev/null +++ b/.changeset/self-healing-reattach-assigned-query.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Idle assigned agents are reattached to their work on boards with renamed columns. +category: fix +dev: `reattachOrphanedAssignedExecutions` read the literal `in-progress`, so on a renamed board an agent that stopped executing a task it was still assigned to was never resumed, leaving the card assigned-but-idle. Read resolves via `resolveProjectColumnsForRoles`, per-card check resolves per card. diff --git a/.changeset/self-healing-reclaim-self-owned-query.md b/.changeset/self-healing-reclaim-self-owned-query.md new file mode 100644 index 0000000000..68e130c39f --- /dev/null +++ b/.changeset/self-healing-reclaim-self-owned-query.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Self-owned branch conflicts are now reclaimed on boards with renamed columns. +category: fix +dev: `reclaimSelfOwnedBranchConflicts` read the literal `todo`/`in-progress`/`in-review` and kept three lane guards on column ids, so a task whose own worktree held its own branch stayed wedged on a renamed board. Reads resolve via `resolveProjectColumnsForRoles` and the three guards resolve per card; the `recoveryRehome` re-home keeps its legacy target by design. diff --git a/.changeset/self-healing-reconcile-completed-query.md b/.changeset/self-healing-reconcile-completed-query.md new file mode 100644 index 0000000000..0e78b2ceca --- /dev/null +++ b/.changeset/self-healing-reconcile-completed-query.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Completing a task now releases its dependents on boards with renamed columns. +category: fix +dev: `reconcileCompletedTask` read the literal `todo`/`in-progress`/`in-review`, so on a renamed board it released nothing and every dependent stayed blocked on finished work. The three reads resolve via `resolveProjectColumnsForRoles`, and dependency satisfaction resolves per dependency (complete/review/archived roles, legacy ids unioned). diff --git a/.changeset/self-healing-stale-blockedby-query.md b/.changeset/self-healing-stale-blockedby-query.md new file mode 100644 index 0000000000..dc1e89dec9 --- /dev/null +++ b/.changeset/self-healing-stale-blockedby-query.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Stale dependency blocks now clear on boards with renamed columns. +category: fix +dev: `clearStaleBlockedBy` read the literal `todo`/`in-progress`/`in-review`, so its already-lane-resolved body never ran on a renamed board and cards stayed blocked behind finished blockers. The three reads now resolve via `resolveProjectColumnsForRoles` and each card is bucketed against its own workflow. diff --git a/.changeset/self-healing-stale-incomplete-review-query.md b/.changeset/self-healing-stale-incomplete-review-query.md new file mode 100644 index 0000000000..713d5f8e22 --- /dev/null +++ b/.changeset/self-healing-stale-incomplete-review-query.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Review cards with unfinished steps are requeued again on boards with renamed columns. +category: fix +dev: `recoverStaleIncompleteReviewTasks` read the literal `in-review`, so a card that reached review on a graph failure with steps still unfinished was never requeued on a renamed board. Read resolves via `resolveProjectColumnsForRoles`, per-card check resolves per card; the requeue keeps its literal target because it passes `recoveryRehome: true`. diff --git a/.changeset/self-healing-stale-merger-status-query.md b/.changeset/self-healing-stale-merger-status-query.md new file mode 100644 index 0000000000..e2be0bc9ee --- /dev/null +++ b/.changeset/self-healing-stale-merger-status-query.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: A finished task no longer blocks the merger queue on boards with renamed columns. +category: fix +dev: `reconcileStaleMergerStatus` read the literal `done`/`archived`, so a terminal card still carrying `merging`/`merging-pr` was never cleared on a renamed board and held the merger queue for every task behind it. One resolved union read over `TERMINAL_ROLES`, deduped. diff --git a/.changeset/self-healing-stuck-completed-query.md b/.changeset/self-healing-stuck-completed-query.md new file mode 100644 index 0000000000..b49fdca6ed --- /dev/null +++ b/.changeset/self-healing-stuck-completed-query.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Step-complete tasks stranded by a dead session now reach review on boards with renamed columns. +category: fix +dev: `recoverCompletedTasks` read the literal `in-progress`, so a task whose steps were all done but whose session died before the hand-off to review was never found on a renamed board. The read resolves via `resolveProjectColumnsForRoles` and the per-card column check resolves per card, falling back to the project set when a card's own workflow is unreadable. diff --git a/.changeset/self-healing-transient-merge-query.md b/.changeset/self-healing-transient-merge-query.md new file mode 100644 index 0000000000..29718293c5 --- /dev/null +++ b/.changeset/self-healing-transient-merge-query.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Merges that failed on a transient fault recover again on boards with renamed columns. +category: fix +dev: `recoverTransientMergeFailures` read the literal `in-review` and kept two lane guards on column ids, so a card that burned its retry budget on a network blip or provider fault stayed failed permanently on a renamed board. Read resolves via `resolveProjectColumnsForRoles`; both the slim-snapshot filter and the full-row re-check resolve per card. diff --git a/.changeset/self-healing-workspace-lane-queries.md b/.changeset/self-healing-workspace-lane-queries.md new file mode 100644 index 0000000000..d162915343 --- /dev/null +++ b/.changeset/self-healing-workspace-lane-queries.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Workspace tasks finish their partial lands and release their worktrees on renamed boards. +category: fix +dev: `reconcileWorkspacePartialLands` and `reconcileOrphanedWorkspaceWorktrees` read the literal `in-review`/`done`, so on a renamed board a workspace task stranded mid-land was never re-enqueued and a finished one never released its per-repo worktrees. Reads resolve via `resolveProjectColumnsForRoles` (review roles, and `complete` only for the cleanup); the partial-land per-card check resolves per card. diff --git a/packages/engine/src/__tests__/self-healing-converted-sweeps-have-no-literal-lane-guards.test.ts b/packages/engine/src/__tests__/self-healing-converted-sweeps-have-no-literal-lane-guards.test.ts new file mode 100644 index 0000000000..10542186f9 --- /dev/null +++ b/packages/engine/src/__tests__/self-healing-converted-sweeps-have-no-literal-lane-guards.test.ts @@ -0,0 +1,146 @@ +/* +FNXC:WorkflowResolvedColumns 2026-07-31-11:55 (the missed-pair ratchet): +A sweep whose READ was converted to resolved lanes but whose LOOP-BODY guards still compare column ids +is worse than one converted nowhere. The widened read admits renamed-board cards, and every literal +guard below it then mis-classifies exactly those cards. + +This is not hypothetical and it is not rare. #2916 found a second lane guard on a re-read row that the +behavioural test caught; a scan of the other converted sweeps then found FIVE more in +`reclaimSelfOwnedBranchConflicts`, and one of them decides whether a backward move needs its +triple-proof — so left literal it would have moved a renamed review card back with the safety gate +silently skipped, a regression INTRODUCED by the conversion. + +WHY A SOURCE SCAN AND NOT A BEHAVIOURAL TEST. Those five sit behind `inspectBranchConflict` and a real +`execAsync`, so reaching them means a git fixture rather than a lane test. This asserts the property the +scan found — no literal lane comparison survives inside a converted sweep — which is exactly the defect +class, and nothing more. It makes no claim about behaviour, and the behavioural cases for each sweep +live in `self-healing-query-filter-blindness.test.ts`. + +REVERT CHECK, measured: restoring any one of the five (e.g. `if (task.column === "in-review") {` inside +`reclaimSelfOwnedBranchConflicts`) fails this test naming that sweep and that literal. +*/ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const SOURCE = fileURLToPath(new URL("../self-healing.ts", import.meta.url)); + +/* +The list is DERIVED, not written down: a sweep counts as converted when its own body calls +`resolveProjectColumnsForRoles`. Written down, this test would be wrong on every branch that converts a +different sweep, and stale the moment one lands — the maintenance burden is what turns a ratchet off. +*/ +const LIFECYCLE_IDS = ["todo", "in-progress", "in-review", "done", "archived", "triage"]; + +/** + * Strips comments before scanning. + * + * Load-bearing: these files carry long FNXC notes that QUOTE the old form to explain why it was + * removed, and an earlier version of this scan reported those as live code. A ratchet that fires on + * its own documentation gets disabled, not fixed. + */ +function stripComments(source: string): string[] { + const out: string[] = []; + let inBlock = false; + for (const line of source.split("\n")) { + const trimmed = line.trim(); + if (inBlock) { + out.push(""); + if (line.includes("*/")) inBlock = false; + continue; + } + if (trimmed.startsWith("/*")) { + out.push(""); + if (!line.includes("*/")) inBlock = true; + continue; + } + if (trimmed.startsWith("//")) { out.push(""); continue; } + out.push(line); + } + return out; +} + +/** The method each line belongs to, by nearest preceding 2-space-indented declaration. */ +function owningMethod(lines: string[], index: number): string | null { + for (let i = index; i >= 0; i--) { + const match = /^ {2}(?:private |public )?(?:async )?([a-zA-Z][A-Za-z0-9_]*)\s*[(<]/.exec(lines[i]!); + if (match) return match[1]!; + } + return null; +} + +/* +A literal on the FALLBACK arm of a resolved ternary is the correct shape, not a missed pair: + + own.length > 0 ? own.includes(task.column) : task.column === "in-review" + +The resolved answer wins whenever it exists; the literal answers only when resolution yielded nothing, +and deleting it would make an unresolvable workflow match no lane at all. Three such lines +(`reconcileDoneTaskIntegrity`, `recoverInterruptedMergingTasks`, `recoverStuckMergeDeadlocks`) are why +this exclusion exists — the first version of this ratchet reported all three as defects. + +Deliberately narrow: it only excuses a literal that shares its line with a RESOLVED membership test. A +bare `task.column === "in-review"` on its own line is still an offender, which is the case that matters. +*/ +function isResolvedFallbackArm(line: string): boolean { + return /(?:\.includes|\.has)\(\s*(?:task|t|entry|dep)\.column\s*\)/.test(line); +} + +/** Every method whose body resolves project lanes — i.e. whose READ has been converted. */ +function convertedSweeps(lines: string[]): string[] { + const found = new Set(); + lines.forEach((line, index) => { + if (!line.includes("resolveProjectColumnsForRoles(")) return; + const owner = owningMethod(lines, index); + if (owner) found.add(owner); + }); + return [...found].sort(); +} + +/* +Documented exceptions, each with the reason recorded at the site too. An entry here is a claim that the +degraded answer is harmless — not that the literal is invisible. +*/ +const ALLOWED: ReadonlyArray<{ sweep: string; because: string }> = [ + { + sweep: "clearStaleBlockedBy", + because: "one literal in a log-dedup closure defined before the lane prefetch; the degraded answer costs a duplicate log line, not a lifecycle decision", + }, +]; + +describe("a converted sweep keeps no literal lane comparison in its body", () => { + const lines = stripComments(readFileSync(SOURCE, "utf8")); + const pattern = new RegExp(`\\.column\\s*(?:!==|===)\\s*"(${LIFECYCLE_IDS.join("|")})"`); + + for (const sweep of convertedSweeps(lines)) { + it(`${sweep} compares no column id`, () => { + const allowance = ALLOWED.find((entry) => entry.sweep === sweep); + const offenders: string[] = []; + lines.forEach((line, index) => { + if (!pattern.test(line)) return; + if (isResolvedFallbackArm(line)) return; + if (owningMethod(lines, index) !== sweep) return; + offenders.push(`${SOURCE.split("/").pop()}:${index + 1} — ${line.trim()}`); + }); + /* An allowed sweep keeps AT MOST its documented one; a second is still a failure. */ + if (allowance) { + expect(offenders.length, `${sweep} is allowed one documented literal (${allowance.because}) but has ${offenders.length}:\n${offenders.join("\n")}`).toBeLessThanOrEqual(1); + return; + } + + expect(offenders, `${sweep} still compares a lifecycle column id:\n${offenders.join("\n")}`).toEqual([]); + }); + } + + it("the scan can actually see a literal, and finds converted sweeps at all", () => { + /* + Two positive controls, because both halves can fail silently. A broken regex makes every case above + pass by finding no offenders; a broken `convertedSweeps` makes them pass by iterating nothing at all + — and an empty `for` loop registers no tests, which reads as green. + */ + const anyLiteralAnywhere = lines.some((line) => pattern.test(line)); + expect(anyLiteralAnywhere, "the literal scan matched nothing — the regex is broken").toBe(true); + + expect(convertedSweeps(lines).length, "no converted sweep found — the derivation is broken").toBeGreaterThan(0); + }); +}); diff --git a/packages/engine/src/__tests__/self-healing-query-filter-blindness.test.ts b/packages/engine/src/__tests__/self-healing-query-filter-blindness.test.ts index 48720fa070..9f64d994ba 100644 --- a/packages/engine/src/__tests__/self-healing-query-filter-blindness.test.ts +++ b/packages/engine/src/__tests__/self-healing-query-filter-blindness.test.ts @@ -35,7 +35,7 @@ multi-column query option plus a resolved union across live workflows — a shar import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { EventEmitter } from "node:events"; import type { Settings, Task, TaskStore } from "@fusion/core"; -import { resolveLifecycleColumns } from "@fusion/core"; +import { getTaskHardMergeBlocker, resolveLifecycleColumns } from "@fusion/core"; /* FNXC:WorkflowResolvedColumns 2026-07-31-04:40: @@ -50,6 +50,17 @@ console.error does NOT work here — vitest installs its own console interceptor line appears in the run output while the spy records nothing (it did, and read as "no warn emitted"). Mocking the logger module captures the call itself, one level below the console. */ +/* +FNXC:WorkflowResolvedColumns 2026-07-31-18:40 (batch fold): +`isBranchAheadOfBase` is a STATIC named import that shells out to git, so it is mocked rather than spied — +the ESM binding is resolved before a spy could replace it. +*/ +const isBranchAheadOfBase = vi.fn(async () => ({ aheadCount: 0 })); +vi.mock("../self-healing-branch.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, isBranchAheadOfBase: (...args: unknown[]) => isBranchAheadOfBase(...args as []) }; +}); + const selfHealingWarn = vi.fn(); vi.mock("../logger.js", async (importOriginal) => { const actual = await importOriginal(); @@ -76,6 +87,7 @@ vi.mock("../run-audit.js", async (importOriginal) => { }; }); +import { createRunAuditor } from "../run-audit.js"; import { SelfHealingManager } from "../self-healing.js"; import { executingTaskLock } from "../active-session-registry.js"; import { RENAMED_VOCAB, lifecycleIr } from "./_workflow-vocabulary-fixture.js"; @@ -118,6 +130,10 @@ function productionFaithfulStore(tasks: Task[]) { */ listWorkflowDefinitions: vi.fn(async () => [{ ir: RENAMED_IR }]), logEntry: vi.fn(async () => undefined), + getAgentLogs: vi.fn(async () => []), + parseFileScopeFromPrompt: vi.fn(async () => []), + recordRunAuditEvent: vi.fn(async () => undefined), + getCompletionHandoffAcceptedMarker: vi.fn(async () => null), }) as unknown as TaskStore & EventEmitter; return { store, listTasks, updateTask: store.updateTask as unknown as ReturnType }; } @@ -152,7 +168,6 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th expect(lifecycle?.complete).toBe(RENAMED_VOCAB.complete); expect(lifecycle?.complete).not.toBe("done"); }); - it("the done-integrity sweep now asks for the board's OWN complete lane (was: KNOWN DEFECT)", async () => { /* `reconcileDoneTaskIntegrity` opens with `listTasks({ column: "done", slim: true })` and then @@ -179,7 +194,6 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th expect(listTasks).toHaveBeenCalledWith(expect.objectContaining({ column: RENAMED_VOCAB.complete })); expect(listTasks).toHaveBeenCalledWith(expect.objectContaining({ column: "done" })); }); - /* FNXC:WorkflowResolvedColumns 2026-07-30-18:05 (#2838 review — greptile P1): @@ -223,7 +237,6 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th expect(warned).toContain("done-task integrity sweep"); expect(warned).toContain("FN-BLIND"); }); - it("proves the fake is what hides it: an ignoring `listTasks` hands the sweep rows production would not", async () => { /* The control, and the reason a green self-healing suite is not evidence that self-healing runs. This @@ -250,7 +263,6 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th const { store } = productionFaithfulStore([card]); expect(await store.listTasks({ column: "done" as never })).toHaveLength(0); }); - /* FNXC:WorkflowResolvedColumns 2026-07-30-17:20 (#2838 review — greptile P1): THE PROJECT UNION IS FOR THE QUERY, NEVER FOR THE PER-CARD VERDICT. @@ -316,7 +328,6 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th expect(store.getSettings).not.toHaveBeenCalled(); expect((await store.getTask("FN-WIP"))?.mergeDetails?.commitSha).toBeUndefined(); }); - /* FNXC:WorkflowResolvedColumns 2026-07-30-17:50 (#2838 review — greptile P1, second round): THE GUESSED-WORKFLOW PATH. `resolveWorkflowIrForTask` returns the BUILT-IN IR when a task names no @@ -359,7 +370,6 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th /* Accepted as a candidate: the sweep reached `getSettings`, which it only does with a non-empty list. */ expect(store.getSettings).toHaveBeenCalled(); }); - /* FNXC:WorkflowResolvedColumns 2026-07-30-18:20 (the query-filter class, second sweep): `recoverAlreadyMergedReviewTasks` rescues a card whose merge ACTUALLY SUCCEEDED but is parked in review @@ -389,7 +399,6 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th /* The legacy id is still asked for — the project union keeps mid-rename rows reachable. */ expect(listTasks).toHaveBeenCalledWith(expect.objectContaining({ column: "in-review" })); }); - /* FNXC:WorkflowResolvedColumns 2026-07-30-18:50 (#2838 review — greptile P1, same class as the done-integrity sweep): @@ -441,7 +450,6 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th expect(warned).toContain("already-merged review rescue"); expect(warned).toContain("FN-UNRESOLVED"); }); - /* FNXC:WorkflowResolvedColumns 2026-07-30-19:20 (the query-filter class, third sweep): `recoverStuckMergeDeadlocks` reads FOUR lanes: the review lane for its candidates, and intake/hold/wip @@ -476,7 +484,6 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th expect(listTasks).toHaveBeenCalledWith(expect.objectContaining({ column: RENAMED_VOCAB.hold })); expect(listTasks).toHaveBeenCalledWith(expect.objectContaining({ column: RENAMED_VOCAB.wip })); }); - /* FNXC:WorkflowResolvedColumns 2026-07-30-19:50 (the query-filter class, fourth sweep): `recoverInterruptedMergingTasks` rescues a task interrupted mid-merge — status still `merging`, no live @@ -509,7 +516,6 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th expect(listTasks).toHaveBeenCalledWith(expect.objectContaining({ column: RENAMED_VOCAB.review })); expect(listTasks).toHaveBeenCalledWith(expect.objectContaining({ column: "in-review" })); }); - /* FNXC:WorkflowResolvedColumns 2026-07-30-20:20 (the query-filter class, fifth sweep): `recoverMergeableReviewTasks` re-enqueues a card that is genuinely ready to merge. Its read was the @@ -554,7 +560,6 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th /* Not just "the query asked" — the card survived the blocker and was acted on. */ expect(enqueueMerge).toHaveBeenCalled(); }); - /* FNXC:WorkflowResolvedColumns 2026-07-30-21:20 (the query-filter class, sixth sweep — activation check run FIRST this time): @@ -597,7 +602,6 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th expect(recoverFailedPreMergeStep).toHaveBeenCalled(); }); - /* FNXC:WorkflowResolvedColumns 2026-07-30-22:00 (the query-filter class, seventh sweep): `finalizeNoOpReviewTasks` finalises a task whose branch has NO commits ahead of base — a genuine no-op @@ -640,7 +644,6 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th expect(listTasks).toHaveBeenCalledWith(expect.objectContaining({ column: RENAMED_VOCAB.review })); expect(aheadCheck).toHaveBeenCalled(); }); - /* FNXC:WorkflowResolvedColumns 2026-07-30-22:40 (the query-filter class, eighth sweep): `recoverCompletionHandoffLimbo` clears a task falsely marked completion-handoff-exhausted while the @@ -689,7 +692,6 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th expect(pastBlocker).toHaveBeenCalled(); }); - /* FNXC:WorkflowResolvedColumns 2026-07-31-06:15 (the query-filter class, fifteenth sweep): `recoverMergedReviewTasks` finalizes a task whose merge is CONFIRMED but which never reached the @@ -727,7 +729,6 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th "recover-merged-review", ); }); - it("ignores a merge-confirmed card sitting in the RENAMED wip lane", async () => { /* Non-vacuous companion: without it, a read returning every column would satisfy the case above. This @@ -752,7 +753,6 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th expect(resolveTarget).not.toHaveBeenCalled(); }); - /* FNXC:WorkflowResolvedColumns 2026-07-31-09:45 (the query-filter class, twenty-first sweep): `recoverStaleMergingStatus` clears a `merging`/`merging-pr` stamp left on a review card with no live @@ -781,7 +781,6 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th expect(updateTask).toHaveBeenCalledWith("FN-STALESTAMP", expect.objectContaining({ status: null })); }); - it("does not clear a merge stamp on a card outside the RENAMED review lanes", async () => { /* Non-vacuous companion: without it, a read returning every column would satisfy the case above. A @@ -801,7 +800,6 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th expect(updateTask).not.toHaveBeenCalled(); }); - /* FNXC:WorkflowResolvedColumns 2026-07-31-04:35 (the query-filter class, fourteenth sweep): `recoverForeignOnlyContaminatedInReviewTasks` classifies a branch that carries ONLY foreign commits and @@ -834,7 +832,6 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th expect(classifyForeignOnlyContamination).toHaveBeenCalledWith(expect.objectContaining({ taskId: "FN-FOREIGN" })); }); - it("does not classify a card whose lane is neither review nor wip on a RENAMED board", async () => { /* Non-vacuous companion: without it, a read returning every column would satisfy the case above. Same @@ -855,7 +852,6 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th expect(classifyForeignOnlyContamination).not.toHaveBeenCalled(); }); - /* FNXC:WorkflowResolvedColumns 2026-07-31-05:50 (#2891 review P1 — the card the sweep disowned): `resolveWorkflowIrForTask` does not fail; it SUBSTITUTES the built-in IR. So a card whose workflow @@ -899,4 +895,1275 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th expect(classifyForeignOnlyContamination).not.toHaveBeenCalled(); }); + /* + FNXC:WorkflowResolvedColumns 2026-07-30-23:58 (the query-filter class, tenth sweep): + `recoverPostDoneNonContinuableWedge` clears a `failed` status on a task that finished every step and + was then wedged only because a post-done session continuation hit a non-continuable error. Its literal + read meant a renamed board's card stayed failed forever with all work done. + + THE ONE SWEEP WHERE BOTH HALVES ARE PROVABLE. Its outcome — updateTask clearing `status`/`error` — is + downstream of the read AND of the getTaskHardMergeBlocker wired in the same change, and nothing on the + path needs git. Contrast the orphan-only sweep, where the blocker sits behind two git calls and only + candidacy could be asserted. + + REVERT CHECKS, both measured, each run alone: + - literal read restored -> fails, the card is never listed + - `{ reviewColumns: wedgeLanes }` dropped -> fails, the blocker judges the renamed lane as + not-a-review-lane and the sweep declines the card it just found + */ + it("clears a post-done wedge on a RENAMED board, and the blocker judges the card's own lanes", async () => { + const wedged = { + ...shippedCard(), + id: "FN-WEDGE", + column: RENAMED_VOCAB.review, + status: "failed", + steps: [{ id: "s1", status: "done" }], + log: [ + { action: "Task marked done by agent", outcome: "" }, + { action: "", outcome: "cannot continue from message role: assistant" }, + ], + } as unknown as Task; + const { store, updateTask } = productionFaithfulStore([wedged]); + + await new SelfHealingManager(store, { rootDir: "/repo" }).recoverPostDoneNonContinuableWedge(); + + expect(updateTask).toHaveBeenCalledWith("FN-WEDGE", expect.objectContaining({ status: null, error: null })); + }); + it("does not clear a wedge for a card sitting in a lane that is NOT a review lane", async () => { + /* + Non-vacuous companion: without it, a read that returned every column would satisfy the case above. + Same renamed board, same wedged card — only its lane changes. + */ + const wedged = { + ...shippedCard(), + id: "FN-WEDGE-WIP", + column: RENAMED_VOCAB.wip, + status: "failed", + steps: [{ id: "s1", status: "done" }], + log: [ + { action: "Task marked done by agent", outcome: "" }, + { action: "", outcome: "cannot continue from message role: assistant" }, + ], + } as unknown as Task; + const { store, updateTask } = productionFaithfulStore([wedged]); + + await new SelfHealingManager(store, { rootDir: "/repo" }).recoverPostDoneNonContinuableWedge(); + + expect(updateTask).not.toHaveBeenCalled(); + }); + /* + FNXC:WorkflowResolvedColumns 2026-07-31-00:50 (the query-filter class, eleventh sweep): + `clearStaleBlockedBy` is the sweep that unsticks a card still pointing at a blocker that has since + finished. Its BODY was already lane-resolved — per-referenced-task lanes, a shared IR cache, legacy ids + unioned, all of it — and none of that ran, because the three reads above it asked for the literal + `todo`/`in-progress`/`in-review`. A textbook case of the class this file exists for: the expensive half + was converted and delivered nothing while the cheap half above it stayed literal. + + Three reads, not one union: the buckets are treated DIFFERENTLY downstream (hold cards seed the + queued-dependency pass; review cards are exempted when paused), so each card is classified against its + own workflow after the union read. + + REVERT CHECK, measured: with the three literal reads restored, this fails — the blocked card is never + listed, so its stale `blockedBy` is never cleared. + */ + it("clears a stale blockedBy on a RENAMED board once the blocker has landed", async () => { + const blocker = { ...shippedCard(), id: "FN-BLOCKER", column: RENAMED_VOCAB.complete } as Task; + const blocked = { + ...shippedCard(), + id: "FN-STUCK", + column: RENAMED_VOCAB.wip, + blockedBy: "FN-BLOCKER", + dependencies: [], + } as unknown as Task; + const { store, updateTask } = productionFaithfulStore([blocker, blocked]); + + await new SelfHealingManager(store, { rootDir: "/repo" }).clearStaleBlockedBy(); + + expect(updateTask).toHaveBeenCalledWith("FN-STUCK", expect.objectContaining({ blockedBy: null })); + }); + it("leaves blockedBy alone while the blocker is still in flight on a RENAMED board", async () => { + /* + Non-vacuous companion: without it, a sweep that cleared every blockedBy it found would satisfy the + case above. Same board, same pair — only the blocker's lane changes. + */ + const blocker = { ...shippedCard(), id: "FN-BLOCKER", column: RENAMED_VOCAB.wip } as Task; + const blocked = { + ...shippedCard(), + id: "FN-STUCK", + column: RENAMED_VOCAB.wip, + blockedBy: "FN-BLOCKER", + dependencies: [], + } as unknown as Task; + const { store, updateTask } = productionFaithfulStore([blocker, blocked]); + + await new SelfHealingManager(store, { rootDir: "/repo" }).clearStaleBlockedBy(); + + expect(updateTask).not.toHaveBeenCalled(); + }); + /* + FNXC:WorkflowResolvedColumns 2026-07-31-01:35 (the query-filter class, twelfth sweep): + `reclaimSelfOwnedBranchConflicts` frees a task whose OWN worktree is holding its OWN branch hostage — + a conflict no other sweep resolves. Three literal reads plus three lane guards in the body, so both + halves convert together: widening the read alone would admit renamed-board cards and then mis-decide + every one, since the phantom-binding check, the blocked-hold skip and the review triple-proof are each + keyed on lane. + + ONE ASSERTION COVERS BOTH HALVES. `isPhantomExecutorBinding` runs only for a card that (a) the read + found and (b) the wip-lane guard accepted. Two private seams are stubbed to reach it — + `getFalsePositiveRequeueSignal` for the live-execution signal, and the binding check itself — which is + the same technique used above for the orphan sweep, and avoids a git/fs fixture entirely. + + REVERT CHECKS, both measured, each run alone: + - literal reads restored -> fails, the card is never listed + - guard back to `task.column === "in-progress"` -> fails, the renamed wip lane does not match, so the + sweep falls through to the no-action path + */ + it("reclaims a self-owned branch conflict on a RENAMED wip lane, guard included", async () => { + const stuck = { + ...shippedCard(), + id: "FN-SELFCONFLICT", + column: RENAMED_VOCAB.wip, + branch: "fusion/FN-SELFCONFLICT", + worktree: "/tmp/worktrees/FN-SELFCONFLICT", + executionStartedAt: "2026-07-30T00:00:00.000Z", + } as unknown as Task; + const { store } = productionFaithfulStore([stuck]); + const manager = new SelfHealingManager(store, { rootDir: "/repo" }); + /* Returns the real shape, not null: the sweep reads `.phantom` straight off it, so a null stub throws and aborts the loop after ONE card — which silently capped the multi-role count at 1 in both states. */ + const isPhantomExecutorBinding = vi.fn(() => ({ phantom: false, metadata: {} })); + Object.assign(manager, { + getFalsePositiveRequeueSignal: vi.fn(() => ({ reason: "executor-active", metadata: {} })), + getRecentRunAuditActivityAgeMs: vi.fn(async () => 0), + isPhantomExecutorBinding, + emitFalsePositiveRequeueNoAction: vi.fn(async () => undefined), + }); + + await manager.reclaimSelfOwnedBranchConflicts(); + + expect(isPhantomExecutorBinding).toHaveBeenCalledWith( + expect.objectContaining({ id: "FN-SELFCONFLICT" }), + expect.anything(), + ); + }); + it("does not evaluate a phantom binding for a card sitting in the RENAMED review lane", async () => { + /* + Non-vacuous companion: without it, a guard matching every column would satisfy the case above. Same + board, same card, same stubs — only its lane changes, and the review lane must not take the wip path. + */ + const stuck = { + ...shippedCard(), + id: "FN-SELFCONFLICT", + column: RENAMED_VOCAB.review, + paused: true, + pausedReason: "branch-conflict-unrecoverable", + branch: "fusion/FN-SELFCONFLICT", + worktree: "/tmp/worktrees/FN-SELFCONFLICT", + executionStartedAt: "2026-07-30T00:00:00.000Z", + } as unknown as Task; + const { store } = productionFaithfulStore([stuck]); + const manager = new SelfHealingManager(store, { rootDir: "/repo" }); + /* Returns the real shape, not null: the sweep reads `.phantom` straight off it, so a null stub throws and aborts the loop after ONE card — which silently capped the multi-role count at 1 in both states. */ + const isPhantomExecutorBinding = vi.fn(() => ({ phantom: false, metadata: {} })); + Object.assign(manager, { + getFalsePositiveRequeueSignal: vi.fn(() => ({ reason: "executor-active", metadata: {} })), + getRecentRunAuditActivityAgeMs: vi.fn(async () => 0), + isPhantomExecutorBinding, + emitFalsePositiveRequeueNoAction: vi.fn(async () => undefined), + }); + + await manager.reclaimSelfOwnedBranchConflicts(); + + expect(isPhantomExecutorBinding).not.toHaveBeenCalled(); + }); + /* + FNXC:WorkflowResolvedColumns 2026-07-31-03:50 (review P1 on #2879 — the hazard the conversion CREATED): + The three literal reads were disjoint BY CONSTRUCTION: one column each, so a card could not appear + twice. Resolved reads are not. A custom workflow may put more than one queried role flag on the SAME + column — here `hold` beside `wip`, a lane that both parks work and counts as work — and that column is + returned by two reads. + + Concatenating the buckets then hands the loop the same STALE SNAPSHOT twice. Not a wasted iteration: + the second pass reads `branch`/`worktree` from state captured before the first pass mutated anything, + so a worktree already reclaimed is reclaimed again against state that no longer exists. + + REVERT CHECK, measured: with the dedupe removed, this fails with 2 calls instead of 1. + */ + it("processes a multi-role column ONCE, not once per role", async () => { + const multiRoleIr = { + ...RENAMED_IR, + columns: RENAMED_IR.columns.map((column) => + column.id === RENAMED_VOCAB.hold + ? { ...column, traits: [...column.traits, { trait: "wip", config: { limitSetting: "maxConcurrent", countPending: true } }] } + : column, + ), + } as typeof RENAMED_IR; + const stuck = { + ...shippedCard(), + id: "FN-MULTIROLE", + column: RENAMED_VOCAB.hold, + branch: "fusion/FN-MULTIROLE", + worktree: "/tmp/worktrees/FN-MULTIROLE", + executionStartedAt: "2026-07-30T00:00:00.000Z", + } as unknown as Task; + const { store } = productionFaithfulStore([stuck]); + Object.assign(store, { + listWorkflowDefinitions: vi.fn(async () => [{ ir: multiRoleIr }]), + getWorkflowDefinition: vi.fn(async (id: string) => (id === "self-healing-lifecycle" ? { ir: multiRoleIr } : undefined)), + }); + const manager = new SelfHealingManager(store, { rootDir: "/repo" }); + /* Returns the real shape, not null: the sweep reads `.phantom` straight off it, so a null stub throws and aborts the loop after ONE card — which silently capped the multi-role count at 1 in both states. */ + const isPhantomExecutorBinding = vi.fn(() => ({ phantom: false, metadata: {} })); + Object.assign(manager, { + getFalsePositiveRequeueSignal: vi.fn(() => ({ reason: "executor-active", metadata: {} })), + getRecentRunAuditActivityAgeMs: vi.fn(async () => 0), + isPhantomExecutorBinding, + emitFalsePositiveRequeueNoAction: vi.fn(async () => undefined), + }); + + await manager.reclaimSelfOwnedBranchConflicts(); + + expect(isPhantomExecutorBinding).toHaveBeenCalledTimes(1); + }); + /* + FNXC:WorkflowResolvedColumns 2026-07-31-03:10 (the query-filter class, thirteenth sweep): + `reconcileCompletedTask` releases everything blocked on a task that just completed. Three literal reads + meant that on a renamed board it released NOTHING — every dependent stayed blocked on work that had + already finished. This is the most visible form of the class: the board simply stops moving, with no + error and no log line saying why. + + The dependency-satisfaction guard converts in the same change, resolved PER DEPENDENCY: a dependency + routinely belongs to a different workflow than the card waiting on it. + + REVERT CHECK, measured: with the three literal reads restored, this fails — the dependent is never + listed, so its `blockedBy` is never cleared. + */ + it("releases a dependent on a RENAMED board when its blocker completes", async () => { + const finished = { ...shippedCard(), id: "FN-DONE", column: RENAMED_VOCAB.complete } as Task; + const waiting = { + ...shippedCard(), + id: "FN-WAITING", + column: RENAMED_VOCAB.wip, + blockedBy: "FN-DONE", + dependencies: [], + } as unknown as Task; + const { store, updateTask } = productionFaithfulStore([finished, waiting]); + + await new SelfHealingManager(store, { rootDir: "/repo" }).reconcileCompletedTask("FN-DONE"); + + expect(updateTask).toHaveBeenCalledWith("FN-WAITING", expect.objectContaining({ blockedBy: null })); + }); + it("does not release a dependent blocked on a DIFFERENT task", async () => { + /* + Non-vacuous companion: without it, a sweep that cleared every blockedBy it found would satisfy the + case above. Same board, same shape — only the blocker id differs. + */ + const finished = { ...shippedCard(), id: "FN-DONE", column: RENAMED_VOCAB.complete } as Task; + const waiting = { + ...shippedCard(), + id: "FN-WAITING", + column: RENAMED_VOCAB.wip, + blockedBy: "FN-SOMEONE-ELSE", + dependencies: [], + } as unknown as Task; + const { store, updateTask } = productionFaithfulStore([finished, waiting]); + + await new SelfHealingManager(store, { rootDir: "/repo" }).reconcileCompletedTask("FN-DONE"); + + expect(updateTask).not.toHaveBeenCalledWith("FN-WAITING", expect.objectContaining({ blockedBy: null })); + }); + /* + FNXC:WorkflowResolvedColumns 2026-07-31-04:10 (the P1 raised on #2879, same hazard in this sweep): + Resolved reads can return ONE column for TWO roles, so a dependent lands in two buckets and the release + below runs twice — `updateTask` and `logEntry` both fire twice for one card, and `blockedByCleared` + over-counts. The literal reads could not do this: one column each, disjoint by construction. + + REVERT CHECK, measured: without the dedupe this fails with 2 clearing writes instead of 1. + */ + it("releases a dependent ONCE when its column carries two queried roles", async () => { + const multiRoleIr = { + ...RENAMED_IR, + columns: RENAMED_IR.columns.map((column) => + column.id === RENAMED_VOCAB.hold + ? { ...column, traits: [...column.traits, { trait: "wip", config: { limitSetting: "maxConcurrent", countPending: true } }] } + : column, + ), + } as typeof RENAMED_IR; + const finished = { ...shippedCard(), id: "FN-DONE", column: RENAMED_VOCAB.complete } as Task; + const waiting = { + ...shippedCard(), + id: "FN-WAITING", + column: RENAMED_VOCAB.hold, + blockedBy: "FN-DONE", + dependencies: [], + } as unknown as Task; + const { store, updateTask } = productionFaithfulStore([finished, waiting]); + Object.assign(store, { + listWorkflowDefinitions: vi.fn(async () => [{ ir: multiRoleIr }]), + getWorkflowDefinition: vi.fn(async (id: string) => (id === "self-healing-lifecycle" ? { ir: multiRoleIr } : undefined)), + }); + + await new SelfHealingManager(store, { rootDir: "/repo" }).reconcileCompletedTask("FN-DONE"); + + const clearingWrites = (updateTask as unknown as { mock: { calls: unknown[][] } }).mock.calls + .filter(([id, patch]) => id === "FN-WAITING" && (patch as { blockedBy?: unknown }).blockedBy === null); + expect(clearingWrites).toHaveLength(1); + }); + /* + FNXC:WorkflowResolvedColumns 2026-07-31-05:05 (#2883 review — "overbroad dependency satisfaction"): + A dependency is satisfied when it reaches a TERMINAL lane or a REVIEW lane, and review here means + `mergeBlocker ∪ humanReview` — NOT merge orchestration. My first version unioned all three review + roles, which counts a merge-orchestration-only column as satisfied and clears `blockedBy` while the + dependency is still being merged. + + The fix is to call `resolveDependencySatisfactionColumns`, the answer the scheduler already uses for + this exact question, rather than to re-derive it. This case pins the narrowed semantics so a future + "simplification" back to the three-role union fails here. + + REVERT CHECK, measured: widening the satisfaction set to include `mergeOrchestration` fails this — the + dependent is released while its dependency is still mid-merge. + */ + it("does NOT treat a merge-orchestration-only dependency as satisfied", async () => { + /* A board whose merge lane is SEPARATE from its human-review lane. */ + const splitReviewIr = { + ...RENAMED_IR, + columns: [ + ...RENAMED_IR.columns.map((column) => + column.id === RENAMED_VOCAB.review + ? { ...column, traits: column.traits.filter((t: { trait: string }) => t.trait !== "merge") } + : column, + ), + { id: "merging", name: "Merging", traits: [{ trait: "merge" }] }, + ], + } as typeof RENAMED_IR; + const finished = { ...shippedCard(), id: "FN-DONE", column: RENAMED_VOCAB.complete } as Task; + const midMerge = { ...shippedCard(), id: "FN-MIDMERGE", column: "merging" } as Task; + const waiting = { + ...shippedCard(), + id: "FN-WAITING", + /* + The HOLD lane is load-bearing. A card in the wip lane takes the sweep's `else` branch, which clears + `blockedBy` without consulting `unresolvedDeps` at all — so the first version of this test passed + with the satisfaction set widened. Eighth vacuous assertion here, eighth caught by the revert. + Only the hold branch re-points the card at its next unmet dependency. + */ + column: RENAMED_VOCAB.hold, + blockedBy: "FN-DONE", + dependencies: ["FN-MIDMERGE"], + } as unknown as Task; + const { store, updateTask } = productionFaithfulStore([finished, midMerge, waiting]); + Object.assign(store, { + listWorkflowDefinitions: vi.fn(async () => [{ ir: splitReviewIr }]), + getWorkflowDefinition: vi.fn(async (id: string) => (id === "self-healing-lifecycle" ? { ir: splitReviewIr } : undefined)), + }); + + await new SelfHealingManager(store, { rootDir: "/repo" }).reconcileCompletedTask("FN-DONE"); + + // Its dependency is still merging, so the card is RE-POINTED at it rather than released. + expect(updateTask).toHaveBeenCalledWith("FN-WAITING", expect.objectContaining({ blockedBy: "FN-MIDMERGE" })); + }); + /* + FNXC:WorkflowResolvedColumns 2026-07-31-06:45 (the query-filter class, sixteenth sweep): + A card that reached a terminal lane while still carrying `merging`/`merging-pr` holds the MERGER QUEUE. + Two literal reads meant that on a renamed board the stale status was never cleared, so one finished + card blocked every task queued behind it — the widest blast radius in this series, since the damage is + not confined to the stranded card. + + REVERT CHECK, measured: with the literal reads restored, this fails — the card is never listed, so its + stale status is never cleared and the queue stays blocked. + */ + it("clears a stale merging status on a RENAMED terminal lane", async () => { + const stale = { + ...shippedCard(), + id: "FN-STALEMERGE", + column: RENAMED_VOCAB.complete, + status: "merging", + } as unknown as Task; + const { store, updateTask } = productionFaithfulStore([stale]); + + await new SelfHealingManager(store, { rootDir: "/repo" }).reconcileStaleMergerStatus(); + + expect(updateTask).toHaveBeenCalledWith("FN-STALEMERGE", expect.objectContaining({ status: null })); + }); + it("leaves a card with no stale merging status alone", async () => { + /* + Non-vacuous companion: without it, a sweep that cleared the status of everything it found would + satisfy the case above. Same board, same terminal lane — only the status differs. + */ + const settled = { ...shippedCard(), id: "FN-SETTLED", column: RENAMED_VOCAB.complete, status: null } as unknown as Task; + const { store, updateTask } = productionFaithfulStore([settled]); + + await new SelfHealingManager(store, { rootDir: "/repo" }).reconcileStaleMergerStatus(); + + expect(updateTask).not.toHaveBeenCalled(); + }); + /* + FNXC:WorkflowResolvedColumns 2026-07-31-07:15 (the query-filter class, seventeenth sweep): + `recoverCompletedTasks` rescues a task whose steps are ALL done but whose session died before the + executor could hand it to review. The literal read meant that on a renamed board it was never found: + finished implementation work sat in the wip lane with no session and nothing to move it on. + + The observable is the injected `recoverCompletedTask` callback — called once per rescued card, with no + git anywhere on the path, so it sits downstream of both the read and the per-card verdict. + + REVERT CHECKS, both measured, each alone: + - literal read restored -> fails, the card is never listed + - verdict back to `t.column === "in-progress"` -> fails, the renamed wip lane does not match + */ + it("rescues a step-complete card stranded on a RENAMED wip lane", async () => { + const stranded = { + ...shippedCard(), + id: "FN-STRANDED", + column: RENAMED_VOCAB.wip, + steps: [{ id: "s1", status: "done" }], + } as unknown as Task; + const { store } = productionFaithfulStore([stranded]); + const recoverCompletedTask = vi.fn(async () => true); + + await new SelfHealingManager(store, { rootDir: "/repo", recoverCompletedTask }).recoverCompletedTasks(); + + expect(recoverCompletedTask).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-STRANDED" })); + }); + it("does not rescue a step-complete card that already reached the RENAMED review lane", async () => { + /* + Non-vacuous companion: without it, a read returning every column would satisfy the case above. Same + board, same finished card — only its lane changes, and a card already in review needs no rescue. + */ + const alreadyMoved = { + ...shippedCard(), + id: "FN-STRANDED", + column: RENAMED_VOCAB.review, + steps: [{ id: "s1", status: "done" }], + } as unknown as Task; + const { store } = productionFaithfulStore([alreadyMoved]); + const recoverCompletedTask = vi.fn(async () => true); + + await new SelfHealingManager(store, { rootDir: "/repo", recoverCompletedTask }).recoverCompletedTasks(); + + expect(recoverCompletedTask).not.toHaveBeenCalled(); + }); + /* + FNXC:WorkflowResolvedColumns 2026-07-31-07:45 (the query-filter class, eighteenth sweep): + `recoverInProgressLimbo` frees a card holding a wip slot with NO worktree, NO branch and no step + started — nothing is running and nothing will. The literal read meant that on a renamed board it was + never found, so the card kept its slot forever and denied that capacity to work that could run. + + Observable is `getFalsePositiveRequeueSignal`, a private method called once per stranded candidate. It + runs BEFORE any lease or git work, so the assertion needs no fixture beyond the card itself. + + REVERT CHECKS, both measured, each alone: + - literal read restored -> fails, the card is never listed + - verdict back to `task.column !== "in-progress"` -> fails, the renamed wip lane is filtered out + */ + it("frees a slot-holding limbo card on a RENAMED wip lane", async () => { + const limbo = { + ...shippedCard(), + id: "FN-LIMBO", + column: RENAMED_VOCAB.wip, + worktree: null, + branch: null, + steps: [{ id: "s1", status: "pending" }], + updatedAt: "2020-01-01T00:00:00.000Z", + } as unknown as Task; + const { store } = productionFaithfulStore([limbo]); + const manager = new SelfHealingManager(store, { rootDir: "/repo" }); + const signal = vi.fn(() => ({ reason: "executor-active", metadata: {} })); + Object.assign(manager, { + getFalsePositiveRequeueSignal: signal, + emitFalsePositiveRequeueNoAction: vi.fn(async () => undefined), + }); + + await manager.recoverInProgressLimbo(); + + expect(signal).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-LIMBO" }), expect.anything()); + }); + it("ignores a limbo-shaped card sitting in the RENAMED hold lane", async () => { + /* + Non-vacuous companion: a card with no worktree and no branch is the NORMAL shape in a hold lane — + that is what a queued card looks like. Without this, a read returning every column would make the + sweep reclaim cards that were never holding a slot at all. + */ + const queued = { + ...shippedCard(), + id: "FN-LIMBO", + column: RENAMED_VOCAB.hold, + worktree: null, + branch: null, + steps: [{ id: "s1", status: "pending" }], + updatedAt: "2020-01-01T00:00:00.000Z", + } as unknown as Task; + const { store } = productionFaithfulStore([queued]); + const manager = new SelfHealingManager(store, { rootDir: "/repo" }); + const signal = vi.fn(() => ({ reason: "executor-active", metadata: {} })); + Object.assign(manager, { + getFalsePositiveRequeueSignal: signal, + emitFalsePositiveRequeueNoAction: vi.fn(async () => undefined), + }); + + await manager.recoverInProgressLimbo(); + + expect(signal).not.toHaveBeenCalled(); + }); + /* + FNXC:WorkflowResolvedColumns 2026-07-31-08:15 (the query-filter class, nineteenth sweep): + `recoverOrphanedExecutions` takes NO lifecycle action — it emits `task:orphan-detected-no-action` so an + operator can see a wip card with no live session behind it. The literal read meant that on a renamed + board the event was never emitted, so the one signal pointing at an orphaned execution was silently + absent. What this restores is visibility, not a repair. + + The observable is therefore the AUDITOR construction, which happens once per detected candidate. + + REVERT CHECKS, both measured, each alone: + - literal read restored -> fails, the card is never listed + - verdict back to `t.column !== "in-progress"` -> fails, the renamed wip lane is filtered out + */ + it("emits the orphan-detected signal for a card on a RENAMED wip lane", async () => { + const orphan = { + ...shippedCard(), + id: "FN-ORPHANEXEC", + column: RENAMED_VOCAB.wip, + worktree: null, + steps: [{ id: "s1", status: "pending" }], + updatedAt: "2020-01-01T00:00:00.000Z", + } as unknown as Task; + const { store } = productionFaithfulStore([orphan]); + (createRunAuditor as unknown as ReturnType).mockClear(); + + await new SelfHealingManager(store, { rootDir: "/repo" }).recoverOrphanedExecutions(); + + expect(createRunAuditor).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ taskId: "FN-ORPHANEXEC", phase: "recover-orphaned-executions" }), + ); + }); + it("does not emit the orphan signal for a card already in the RENAMED review lane", async () => { + /* + Non-vacuous companion: without it, a read returning every column would satisfy the case above. A card + in review is not an orphaned EXECUTION — it has no slot and no session to be missing. + */ + const reviewing = { + ...shippedCard(), + id: "FN-ORPHANEXEC", + column: RENAMED_VOCAB.review, + worktree: null, + steps: [{ id: "s1", status: "pending" }], + updatedAt: "2020-01-01T00:00:00.000Z", + } as unknown as Task; + const { store } = productionFaithfulStore([reviewing]); + (createRunAuditor as unknown as ReturnType).mockClear(); + + await new SelfHealingManager(store, { rootDir: "/repo" }).recoverOrphanedExecutions(); + + expect(createRunAuditor).not.toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ phase: "recover-orphaned-executions" }), + ); + }); + /* + FNXC:WorkflowResolvedColumns 2026-07-31-08:45 (the query-filter class, twentieth sweep): + `reattachOrphanedAssignedExecutions` reattaches a DURABLE AGENT to a task it is still assigned to but + has stopped executing. The literal read meant that on a renamed board the reattach never fired, so the + card sat assigned-but-idle — visibly owned by an agent that had gone quiet, which is worse than + unassigned because the board says someone is on it. + + Observable is the injected `resumeAssignedTaskForAgent`, called once per agent with orphaned work. + + REVERT CHECKS, both measured, each alone: + - literal read restored -> fails, the card is never listed + - verdict back to `task.column !== "in-progress"` -> fails, the renamed wip lane is skipped + */ + function reattachFixture(column: string) { + const assigned = { + ...shippedCard(), + id: "FN-REATTACH", + column, + assignedAgentId: "agent-1", + worktree: null, + steps: [{ id: "s1", status: "pending" }], + updatedAt: "2020-01-01T00:00:00.000Z", + } as unknown as Task; + const { store } = productionFaithfulStore([assigned]); + const resumeAssignedTaskForAgent = vi.fn(async () => undefined); + const agentStore = { + getAgent: vi.fn(async () => ({ id: "agent-1" })), + getActiveHeartbeatRun: vi.fn(async () => null), + }; + const manager = new SelfHealingManager(store, { + rootDir: "/repo", + agentStore: agentStore as never, + resumeAssignedTaskForAgent, + }); + return { manager, resumeAssignedTaskForAgent }; + } + it("reattaches an idle assigned agent on a RENAMED wip lane", async () => { + const { manager, resumeAssignedTaskForAgent } = reattachFixture(RENAMED_VOCAB.wip); + + await manager.reattachOrphanedAssignedExecutions(); + + expect(resumeAssignedTaskForAgent).toHaveBeenCalledWith("agent-1"); + }); + it("does not reattach an agent whose card already reached the RENAMED review lane", async () => { + /* + Non-vacuous companion: without it, a read returning every column would satisfy the case above. An + assigned card in review has finished its execution — resuming it would restart completed work. + */ + const { manager, resumeAssignedTaskForAgent } = reattachFixture(RENAMED_VOCAB.review); + + await manager.reattachOrphanedAssignedExecutions(); + + expect(resumeAssignedTaskForAgent).not.toHaveBeenCalled(); + }); + /* + FNXC:WorkflowResolvedColumns 2026-07-31-10:25 (the query-filter class, twenty-second sweep): + A GHOST review card is one parked in review past the stuck timeout with nobody owning its merge lane. + The literal read meant that on a renamed board it was never found: no merger, no session, and no + timeout ever firing against it. + + THE OBSERVABLE IS CHOSEN CAREFULLY. `isMergeLaneOwned` is called once per surviving candidate, AFTER + the read and the per-card verdict — earlier on this branch I positioned this same spy UPSTREAM of the + guard I was testing and it passed with the fix reverted. Here it sits downstream of both, which is the + whole difference. + + `taskStuckTimeoutMs` must be set and `columnMovedAt` ancient, or the card is filtered out by the + timeout rather than by lane, and the case would pass reverted for the wrong reason. + + REVERT CHECKS, both measured, each alone: + - literal read restored -> fails, the card is never listed + - verdict back to `task.column === "in-review"` -> fails, the renamed review lane is filtered out + */ + function ghostFixture(column: string) { + const ghost = { + ...shippedCard(), + id: "FN-GHOST", + column, + columnMovedAt: "2020-01-01T00:00:00.000Z", + updatedAt: "2020-01-01T00:00:00.000Z", + mergeDetails: {}, + } as unknown as Task; + const { store } = productionFaithfulStore([ghost]); + Object.assign(store, { + getSettings: vi.fn(async () => ({ globalPause: false, enginePaused: false, taskStuckTimeoutMs: 60_000 })), + }); + const manager = new SelfHealingManager(store, { rootDir: "/repo" }); + const isMergeLaneOwned = vi.fn(async () => true); // owned -> no kick-back, so nothing else has to be stubbed + Object.assign(manager, { isMergeLaneOwned }); + return { manager, isMergeLaneOwned }; + } + it("evaluates a ghost review card on a RENAMED review lane", async () => { + const { manager, isMergeLaneOwned } = ghostFixture(RENAMED_VOCAB.review); + + await manager.recoverGhostReviewTasks(); + + expect(isMergeLaneOwned).toHaveBeenCalledWith("FN-GHOST"); + }); + it("does not treat a long-idle card outside the review lanes as a ghost", async () => { + /* + Non-vacuous companion: without it, a read returning every column would satisfy the case above. A card + idle in the HOLD lane is just queued — kicking it back would be the sweep inventing work. + */ + const { manager, isMergeLaneOwned } = ghostFixture(RENAMED_VOCAB.hold); + + await manager.recoverGhostReviewTasks(); + + expect(isMergeLaneOwned).not.toHaveBeenCalled(); + }); + /* + FNXC:WorkflowResolvedColumns 2026-07-31-11:00 (the query-filter class, twenty-third sweep): + `recoverTransientMergeFailures` refunds the retry budget for a merge that failed for a TRANSIENT reason + and burned all its retries. The literal read meant that on a renamed board the refund never happened, + so a card that failed on a network blip or a provider fault stayed failed permanently — visibly failed + to the operator, with no visible cause. + + The error string is a REAL signature (`classifyTransientMergeError` matches "ACP turn failed"), not + invented prose. An unrecognised string is filtered out one line later and the case would pass with the + fix reverted — the same trap that produced the post-done wedge fixture's first failure. + + Observable is the injected `requeueForAutoMerge`, called once per recovered card. + + REVERT CHECKS, both measured, each alone: + - literal read restored -> fails, the card is never listed + - verdict back to `t.column === "in-review"` -> fails, the renamed review lane is filtered out + */ + function transientFixture(column: string) { + const failed = { + ...shippedCard(), + id: "FN-TRANSIENT", + column, + status: "failed", + mergeRetries: 99, + error: "ACP turn failed while merging", + } as unknown as Task; + const { store } = productionFaithfulStore([failed]); + const requeueForAutoMerge = vi.fn(async () => true); + const manager = new SelfHealingManager(store, { rootDir: "/repo", requeueForAutoMerge }); + return { manager, requeueForAutoMerge }; + } + it("refunds a transient merge failure on a RENAMED review lane", async () => { + const { manager, requeueForAutoMerge } = transientFixture(RENAMED_VOCAB.review); + + await manager.recoverTransientMergeFailures(); + + expect(requeueForAutoMerge).toHaveBeenCalled(); + }); + it("does not refund a transient failure for a card outside the review lanes", async () => { + /* + Non-vacuous companion: without it, a read returning every column would satisfy the case above. A + failed card in the wip lane has not reached merge at all — there is no merge budget to refund. + */ + const { manager, requeueForAutoMerge } = transientFixture(RENAMED_VOCAB.wip); + + await manager.recoverTransientMergeFailures(); + + expect(requeueForAutoMerge).not.toHaveBeenCalled(); + }); + /* + FNXC:WorkflowResolvedColumns 2026-07-31-12:45 (the query-filter class, twenty-fourth sweep): + `recoverStaleIncompleteReviewTasks` requeues a review card whose STEPS are not finished — it reached + review on a graph failure, not on completed work. The literal read meant that on a renamed board it was + never requeued: the card sat in review claiming to be done while its own steps said otherwise. + + Observable is `evaluateBackwardMoveTripleProof`, private and called once per candidate BEFORE the move, + so no git fixture is needed. `taskStuckTimeoutMs` is set and a step left non-terminal, or the card is + filtered out for reasons unrelated to lanes. + + REVERT CHECKS, both measured, each alone: + - literal read restored -> fails, the card is never listed + - verdict back to `task.column === "in-review"` -> fails, the renamed review lane is filtered out + */ + function staleIncompleteFixture(column: string) { + const card = { + ...shippedCard(), + id: "FN-INCOMPLETE", + column, + status: "failed", + steps: [{ id: "s1", status: "in-progress" }], + columnMovedAt: "2020-01-01T00:00:00.000Z", + updatedAt: "2020-01-01T00:00:00.000Z", + } as unknown as Task; + const { store } = productionFaithfulStore([card]); + Object.assign(store, { + getSettings: vi.fn(async () => ({ globalPause: false, enginePaused: false, taskStuckTimeoutMs: 60_000 })), + }); + const manager = new SelfHealingManager(store, { rootDir: "/repo" }); + const proof = vi.fn(async () => ({ ok: false, reason: "test" })); + Object.assign(manager, { + evaluateBackwardMoveTripleProof: proof, + emitBackwardMoveNoAction: vi.fn(async () => undefined), + }); + return { manager, proof }; + } + it("requeues a step-incomplete card on a RENAMED review lane", async () => { + const { manager, proof } = staleIncompleteFixture(RENAMED_VOCAB.review); + + await manager.recoverStaleIncompleteReviewTasks(); + + expect(proof).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-INCOMPLETE" }), expect.anything()); + }); + it("leaves a step-incomplete card in the RENAMED wip lane alone", async () => { + /* + Non-vacuous companion: a card with unfinished steps in the WIP lane is not stale — it is simply being + worked on. A read returning every column would requeue live work. + */ + const { manager, proof } = staleIncompleteFixture(RENAMED_VOCAB.wip); + + await manager.recoverStaleIncompleteReviewTasks(); + + expect(proof).not.toHaveBeenCalled(); + }); + /* + FNXC:WorkflowResolvedColumns 2026-07-31-13:25 (the query-filter class, twenty-fifth sweep): + `recoverMisclassifiedFailures` clears a failure the executor parked for "without calling fn_task_done" + on a task whose steps are ALL actually done — the failure is a misclassification, not real work left + undone. The literal read meant that on a renamed board it was never cleared, so finished work stayed + visibly failed and never entered normal review. + + The error string must contain the REAL phrase `isNoTaskDoneFailure` matches. Invented prose is filtered + out one line later and the case would pass with the fix reverted — the same trap as #2916's fixture. + + REVERT CHECKS, both measured, each alone: + - literal read restored -> fails, the card is never listed + - verdict back to `t.column === "in-review"` -> fails, the renamed review lane is filtered out + */ + function misclassifiedFixture(column: string) { + const card = { + ...shippedCard(), + id: "FN-MISCLASS", + column, + status: "failed", + error: "Agent finished without calling fn_task_done", + steps: [{ id: "s1", status: "done" }], + } as unknown as Task; + return productionFaithfulStore([card]); + } + it("clears a misclassified failure on a RENAMED review lane", async () => { + const { store, updateTask } = misclassifiedFixture(RENAMED_VOCAB.review); + + await new SelfHealingManager(store, { rootDir: "/repo" }).recoverMisclassifiedFailures(); + + expect(updateTask).toHaveBeenCalledWith("FN-MISCLASS", expect.objectContaining({ status: null, error: null })); + }); + it("does not clear the same failure for a card in the RENAMED wip lane", async () => { + /* + Non-vacuous companion: without it, a read returning every column would satisfy the case above. A card + still in wip has not handed off, so clearing its failure would hide a live problem. + */ + const { store, updateTask } = misclassifiedFixture(RENAMED_VOCAB.wip); + + await new SelfHealingManager(store, { rootDir: "/repo" }).recoverMisclassifiedFailures(); + + expect(updateTask).not.toHaveBeenCalled(); + }); + /* + FNXC:WorkflowResolvedColumns 2026-07-31-14:15 (the query-filter class, twenty-sixth sweep): + `recoverBranchMisboundInReviewTasks` detects a review card whose BRANCH TIP is bound to a different + task's work. The literal read meant that on a renamed board the misbinding was never detected, so the + card would merge — or refuse to — against a branch that is not its own. + + Observable is `resolveSelfHealingMergeTarget`, private and called once per candidate, so the assertion + sits downstream of both halves without a git fixture. + + REVERT CHECKS, both measured, each alone: + - literal read restored -> fails, the card is never listed + - verdict back to `task.column === "in-review"` -> fails, the renamed review lane is filtered out + */ + function misboundFixture(column: string) { + const card = { + ...shippedCard(), + id: "FN-MISBOUND", + column, + branch: "fusion/FN-MISBOUND", + mergeDetails: {}, + } as unknown as Task; + const { store } = productionFaithfulStore([card]); + const manager = new SelfHealingManager(store, { rootDir: "/repo" }); + const resolveTarget = vi.fn(async () => ({ branch: "main", source: "settings" })); + Object.assign(manager, { + resolveSelfHealingMergeTarget: resolveTarget, + isBranchTipMisboundToTask: vi.fn(async () => ({ rejection: null, branchTip: "abc1234" })), + }); + return { manager, resolveTarget }; + } + it("checks branch binding for a card on a RENAMED review lane", async () => { + const { manager, resolveTarget } = misboundFixture(RENAMED_VOCAB.review); + + await manager.recoverBranchMisboundInReviewTasks(); + + expect(resolveTarget).toHaveBeenCalledWith( + expect.objectContaining({ id: "FN-MISBOUND" }), + expect.anything(), + "recover-branch-misbound-in-review", + ); + }); + it("does not check branch binding for a card in the RENAMED wip lane", async () => { + /* + Non-vacuous companion: a card still in wip legitimately owns a moving branch tip — checking it here + would flag normal in-progress work as misbound. + */ + const { manager, resolveTarget } = misboundFixture(RENAMED_VOCAB.wip); + + await manager.recoverBranchMisboundInReviewTasks(); + + expect(resolveTarget).not.toHaveBeenCalled(); + }); + /* + FNXC:WorkflowResolvedColumns 2026-07-31-14:55 (the query-filter class, twenty-seventh sweep): + `recoverMissingWorktreeReviewFailures` requeues a review card failed because its worktree was gone when + the session tried to start. Its per-candidate lane wiring was already in place — and a note at the site + called the literal QUERY above it "unfixable without a project-level lane resolution before the read". + `resolveProjectColumnsForRoles` is that resolution; it did not exist when the note was written. So the + wiring only ever helped boards whose review lane still happened to be named `in-review`. + + The error string uses a REAL prefix from MISSING_WORKTREE_SESSION_PREFIXES; invented prose is rejected + by `isMissingWorktreeSessionStartFailure` and the case would pass with the fix reverted. + + REVERT CHECK, measured: with the literal read restored, this fails — the card is never listed. + */ + it("requeues a missing-worktree review failure on a RENAMED review lane", async () => { + const card = { + ...shippedCard(), + id: "FN-NOWT", + column: RENAMED_VOCAB.review, + status: "failed", + error: "Refusing to start coding agent in missing worktree: /tmp/gone", + steps: [{ id: "s1", status: "pending" }], + } as unknown as Task; + const { store } = productionFaithfulStore([card]); + const manager = new SelfHealingManager(store, { rootDir: "/repo" }); + const proof = vi.fn(async () => ({ ok: false, reason: "test" })); + Object.assign(manager, { + evaluateBackwardMoveTripleProof: proof, + emitBackwardMoveNoAction: vi.fn(async () => undefined), + }); + + await manager.recoverMissingWorktreeReviewFailures(); + + expect(proof).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-NOWT" }), expect.anything()); + }); + it("resolves the review lanes as a MEMBERSHIP set, not just the first one", async () => { + /* + The arity half. The per-candidate set was built from `resolveTaskLifecycleColumns().review`, which is + the FIRST column per role — so on a board declaring a separate merge lane beside its human-review + lane, a card in the second one read as not-in-review and was skipped. This drives exactly that board. + + REVERT CHECK, measured: with the set back to `new Set([lifecycle?.review ?? "in-review", "in-review"])` + this fails — the card in the second review column is never classified as recoverable. + */ + const splitReviewIr = { + ...RENAMED_IR, + columns: [ + ...RENAMED_IR.columns, + { id: "merging", name: "Merging", traits: [{ trait: "merge" }] }, + ], + } as typeof RENAMED_IR; + const card = { + ...shippedCard(), + id: "FN-NOWT2", + column: "merging", + status: "failed", + error: "Refusing to start coding agent in missing worktree: /tmp/gone", + steps: [{ id: "s1", status: "pending" }], + } as unknown as Task; + const { store } = productionFaithfulStore([card]); + Object.assign(store, { + listWorkflowDefinitions: vi.fn(async () => [{ ir: splitReviewIr }]), + getWorkflowDefinition: vi.fn(async (id: string) => (id === "self-healing-lifecycle" ? { ir: splitReviewIr } : undefined)), + }); + const manager = new SelfHealingManager(store, { rootDir: "/repo" }); + const proof = vi.fn(async () => ({ ok: false, reason: "test" })); + Object.assign(manager, { + evaluateBackwardMoveTripleProof: proof, + emitBackwardMoveNoAction: vi.fn(async () => undefined), + }); + + await manager.recoverMissingWorktreeReviewFailures(); + + expect(proof).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-NOWT2" }), expect.anything()); + }); + /* + FNXC:WorkflowResolvedColumns 2026-07-31-15:40 (the query-filter class, twenty-eighth sweep): + `auditNoCommitsExpectedCandidates` flags a card that finished every step and pushed NO commits — either + a legitimately commit-free task nobody declared as such, or work that silently produced nothing. + + The literal read meant that on a renamed board only the `no_commits` ERROR path fed the audit, so a card + sitting quietly in a renamed review lane with zero commits and no error was never flagged. The sweep + did not go dead — it went half-blind, which is harder to notice. + + REVERT CHECK, measured: with the literal read and verdict restored, this fails — the card contributes + nothing, because it has no `no_commits` error to be caught by the other arm. + */ + it("flags a zero-commit card on a RENAMED review lane with no error text", async () => { + const card = { + ...shippedCard(), + id: "FN-NOCOMMITS", + column: RENAMED_VOCAB.review, + status: null, + error: null, + steps: [{ id: "s1", status: "done" }], + } as unknown as Task; + const { store } = productionFaithfulStore([card]); + isBranchAheadOfBase.mockClear(); + + const flagged = await new SelfHealingManager(store, { rootDir: "/repo" }).auditNoCommitsExpectedCandidates(); + + expect(isBranchAheadOfBase).toHaveBeenCalled(); + expect(flagged).toBe(1); + }); + it("does not flag a zero-commit card that already declared noCommitsExpected", async () => { + /* + Non-vacuous companion: without it, a sweep flagging every zero-commit card it found would satisfy the + case above. Same board, same lane — only the declaration differs, which is the whole point of the flag. + */ + const card = { + ...shippedCard(), + id: "FN-NOCOMMITS", + column: RENAMED_VOCAB.review, + status: null, + error: null, + noCommitsExpected: true, + steps: [{ id: "s1", status: "done" }], + } as unknown as Task; + const { store } = productionFaithfulStore([card]); + isBranchAheadOfBase.mockClear(); + + const flagged = await new SelfHealingManager(store, { rootDir: "/repo" }).auditNoCommitsExpectedCandidates(); + + expect(isBranchAheadOfBase).not.toHaveBeenCalled(); + expect(flagged).toBe(0); + }); + /* + FNXC:WorkflowResolvedColumns 2026-07-31-16:10 (the query-filter class, twenty-ninth sweep): + `recoverNoProgressNoTaskDoneFailures` requeues a wip card the executor failed for "no fn_task_done" + that made NO step progress and left no git work — nothing to salvage, so requeueing is safe. The + literal read meant that on a renamed board it was never requeued: a card that produced nothing sat + failed while still holding its wip slot. + + Observable is `hasRecoverableGitWork`, private and called once per candidate before any requeue, so no + git fixture is needed. The error string carries the REAL phrase `isNoTaskDoneFailure` matches. + + REVERT CHECKS, both measured, each alone: + - literal read restored -> fails, the card is never listed + - verdict back to `task.column === "in-progress"` -> fails, the renamed wip lane is filtered out + */ + function noProgressFixture(column: string) { + const card = { + ...shippedCard(), + id: "FN-NOPROGRESS", + column, + status: "failed", + error: "Agent finished without calling fn_task_done", + steps: [{ id: "s1", status: "pending" }], + } as unknown as Task; + const { store } = productionFaithfulStore([card]); + const manager = new SelfHealingManager(store, { rootDir: "/repo" }); + const hasRecoverableGitWork = vi.fn(async () => true); // true -> sweep leaves it alone, so nothing else needs stubbing + Object.assign(manager, { hasRecoverableGitWork }); + return { manager, hasRecoverableGitWork }; + } + it("considers a no-progress failure on a RENAMED wip lane", async () => { + const { manager, hasRecoverableGitWork } = noProgressFixture(RENAMED_VOCAB.wip); + + await manager.recoverNoProgressNoTaskDoneFailures(); + + expect(hasRecoverableGitWork).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-NOPROGRESS" })); + }); + it("does not consider the same failure once the card reached the RENAMED review lane", async () => { + /* + Non-vacuous companion: a card in review has handed off; requeueing it from here would undo a + completed hand-off rather than rescue a stalled one. + */ + const { manager, hasRecoverableGitWork } = noProgressFixture(RENAMED_VOCAB.review); + + await manager.recoverNoProgressNoTaskDoneFailures(); + + expect(hasRecoverableGitWork).not.toHaveBeenCalled(); + }); + /* + FNXC:WorkflowResolvedColumns 2026-07-31-16:40 (the query-filter class, thirtieth sweep): + `recoverPartialProgressNoTaskDoneFailures` retries a review card failed for "no fn_task_done" that DID + make step progress. Real work exists, so the sweep spends a retry rather than discarding it. The + literal read meant that on a renamed board the retry never fired: partially-completed work was parked + failed with its retry budget untouched — a budget that exists precisely to avoid losing that work. + + A step must be `done` (hasStepProgress) while another is not, or the card is filtered out by + `isTaskWorkComplete` for a reason unrelated to lanes. + + REVERT CHECKS, both measured, each alone: + - literal read restored -> fails, the card is never listed + - verdict back to `task.column === "in-review"` -> fails, the renamed review lane is filtered out + */ + function partialFixture(column: string) { + const card = { + ...shippedCard(), + id: "FN-PARTIAL", + column, + status: "failed", + error: "Agent finished without calling fn_task_done", + steps: [{ id: "s1", status: "done" }, { id: "s2", status: "pending" }], + columnMovedAt: "2020-01-01T00:00:00.000Z", + } as unknown as Task; + const { store } = productionFaithfulStore([card]); + const manager = new SelfHealingManager(store, { rootDir: "/repo" }); + const proof = vi.fn(async () => ({ ok: false, reason: "test" })); + Object.assign(manager, { + evaluateBackwardMoveTripleProof: proof, + emitBackwardMoveNoAction: vi.fn(async () => undefined), + }); + return { manager, proof }; + } + it("retries a partial-progress failure on a RENAMED review lane", async () => { + const { manager, proof } = partialFixture(RENAMED_VOCAB.review); + + await manager.recoverPartialProgressNoTaskDoneFailures(); + + expect(proof).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-PARTIAL" }), expect.anything()); + }); + it("does not retry the same failure while the card is still in the RENAMED wip lane", async () => { + /* + Non-vacuous companion: a card still in wip has not finished its attempt, so spending a retry from + here would burn the budget on work that is still running. + */ + const { manager, proof } = partialFixture(RENAMED_VOCAB.wip); + + await manager.recoverPartialProgressNoTaskDoneFailures(); + + expect(proof).not.toHaveBeenCalled(); + }); + /* + FNXC:WorkflowResolvedColumns 2026-07-31-17:10 (the query-filter class, thirty-first sweep): + `recoverDoneTaskMergeMetadata` repairs the merge metadata of a card that already reached the COMPLETE + lane — the commit sha an operator sees, and that later reconcilers trust. The literal read meant that + on a renamed board a done card's metadata was never repaired, so a completed task could keep pointing + at a commit that is not the one that landed. + + Scoped to `complete`, NOT the terminal union: an archived card is out of scope, and widening to + TERMINAL_ROLES would start repairing rows nobody reads — a behaviour change wearing a conversion's + clothes. The companion case pins that. + + REVERT CHECKS, both measured, each alone: + - literal read restored -> fails, the card is never listed + - verdict back to `task.column !== "done"` -> fails, the renamed complete lane is filtered out + */ + function doneMetaFixture(column: string) { + const card = { + ...shippedCard(), + id: "FN-DONEMETA", + column, + mergeDetails: { mergeConfirmed: true, commitSha: "abcdef1234567890" }, + } as unknown as Task; + const { store } = productionFaithfulStore([card]); + const manager = new SelfHealingManager(store, { rootDir: "/repo" }); + const findLandedTaskCommit = vi.fn(async () => null); + Object.assign(manager, { findLandedTaskCommit }); + return { manager, findLandedTaskCommit }; + } + it("repairs merge metadata on a RENAMED complete lane", async () => { + const { manager, findLandedTaskCommit } = doneMetaFixture(RENAMED_VOCAB.complete); + + await manager.recoverDoneTaskMergeMetadata(); + + expect(findLandedTaskCommit).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-DONEMETA" })); + }); + it("does not touch a card in the RENAMED review lane", async () => { + /* + Non-vacuous companion: without it, a read returning every column would satisfy the case above. A card + still in review has not landed, so "repairing" its merge metadata would invent an answer. + */ + const { manager, findLandedTaskCommit } = doneMetaFixture(RENAMED_VOCAB.review); + + await manager.recoverDoneTaskMergeMetadata(); + + expect(findLandedTaskCommit).not.toHaveBeenCalled(); + }); + /* + FNXC:WorkflowResolvedColumns 2026-07-31-18:15 (the query-filter class, sweeps thirty-three and -four): + The two WORKSPACE sweeps. A workspace task lands PER-REPO, so its failure modes are its own: a + partial land leaves some repos merged and some not, and a finished one leaves per-repo worktrees on + disk. Both were bounded by literal reads, so on a renamed board neither ran — the partial land never + finished, and the disk was never reclaimed. + + `isWorkspaceTaskLive` is what the partial-land sweep calls once per surviving candidate, before any + git or disk work — the private-seam technique, so no workspace fixture is needed. + + REVERT CHECKS, both measured, each alone: + - partial-land literal read + verdict restored -> that case fails, the card is never listed + - orphaned-worktree literal read restored -> that case fails, the done card is never listed + */ + function workspaceCard(id: string, column: string): Task { + return { + ...shippedCard(), + id, + column, + workspaceRepos: [{ path: "repo-a" }], + workspaceWorktrees: { "repo-a": { worktreePath: "/tmp/ws/repo-a" } }, + mergeDetails: {}, + } as unknown as Task; + } + it("re-enqueues a partially-landed workspace task on a RENAMED review lane", async () => { + const { store } = productionFaithfulStore([workspaceCard("FN-WSPARTIAL", RENAMED_VOCAB.review)]); + const manager = new SelfHealingManager(store, { rootDir: "/repo" }); + const isWorkspaceTaskLive = vi.fn(() => ({ live: true, livePaths: ["/tmp/ws/repo-a"] })); + Object.assign(manager, { isWorkspaceTaskLive, emitWorkspacePartialLandNoAction: vi.fn(async () => undefined) }); + + await manager.reconcileWorkspacePartialLands(); + + expect(isWorkspaceTaskLive).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-WSPARTIAL" })); + }); + it("does not re-enqueue a workspace task still in the RENAMED wip lane", async () => { + /* + Non-vacuous companion: a workspace task in wip has not started landing, so there is no partial land + to finish — the comment at the site says execution-stage reconcilers own that lane. + */ + const { store } = productionFaithfulStore([workspaceCard("FN-WSPARTIAL", RENAMED_VOCAB.wip)]); + const manager = new SelfHealingManager(store, { rootDir: "/repo" }); + const isWorkspaceTaskLive = vi.fn(() => ({ live: true, livePaths: ["/tmp/ws/repo-a"] })); + Object.assign(manager, { isWorkspaceTaskLive, emitWorkspacePartialLandNoAction: vi.fn(async () => undefined) }); + + await manager.reconcileWorkspacePartialLands(); + + expect(isWorkspaceTaskLive).not.toHaveBeenCalled(); + }); + it("cleans orphaned workspace worktrees for a card on a RENAMED complete lane", async () => { + const { store } = productionFaithfulStore([workspaceCard("FN-WSDONE", RENAMED_VOCAB.complete)]); + const listTasksSpy = (store as unknown as { listTasks: ReturnType }).listTasks; + listTasksSpy.mockClear(); + + await new SelfHealingManager(store, { rootDir: "/repo" }).reconcileOrphanedWorkspaceWorktrees(); + + /* + The sweep's disk work is guarded by `isPathActive` and real fs checks, so the honest observable is + that it ASKED for the board's own complete lane at all — nothing downstream vetoes on lane here + (the only filter is `isWorkspaceTask`), which is what makes a query assertion sound rather than lazy. + */ + const queried = listTasksSpy.mock.calls.map(([options]) => (options as { column?: string })?.column); + expect(queried).toContain(RENAMED_VOCAB.complete); + }); +}); + + +/* +FNXC:WorkflowResolvedColumns 2026-07-31-02:10 (#2867 review — greptile, "hard-blocker wiring remains +untested"): + +THE WIRING, TESTED AT THE SEAM RATHER THAN THROUGH THE SWEEP. + +`recoverAlreadyMergedReviewTasks` calls `getTaskHardMergeBlocker(..., { reviewColumns: await +ownReviewLanesForAlreadyMerged(task) })`. The sweep test above stops at candidacy, and the note there +defended the gap as "type-checked and identical to a shape proven elsewhere". Type-checking cannot see +it: `reviewColumns` is an OPTIONAL property on an options object, so omitting it compiles. The +inert-seam gate cannot see it either — it tracks trailing optional PARAMETERS, not options-bag +properties. Nothing was watching the argument from either direction. + +Driving the sweep to that line needs `resolveSelfHealingMergeTarget` and `findAlreadyMergedTaskCommit` +to succeed, i.e. a real git repo, which would make this a git fixture rather than a lane test. So the +seam is asserted directly: with the card's resolved lanes supplied there is no blocker, and without +them the same card blocks — reproducing the production symptom exactly, an already-merged card on a +renamed board failed with "Merge confirmed but finalization blocked", the sweep's purpose inverted. + +WHAT THIS STILL DOES NOT COVER, MEASURED RATHER THAN ASSUMED. I deleted the `reviewColumns` argument +from the sweep's call site and these three cases stayed GREEN. They pin the SEAM's behaviour, not the +producer that fills it — the same guard-versus-resolver split that made the planner-metrics option +inert in #2842, where only a test driving the PRODUCER caught the omission. + +So this is an improvement over the claim it replaces ("type-checked and identical to a shape proven +elsewhere", which was unfounded — an optional options-bag property compiles when omitted and the +inert-seam gate does not track those), but it is not coverage of the wiring. Covering that needs a +test that drives `recoverAlreadyMergedReviewTasks` far enough to reach the call, which needs +`resolveSelfHealingMergeTarget` and `findAlreadyMergedTaskCommit` to succeed against a real repo. +Stated here so the next reader does not mistake three green cases for a watched argument. +*/ +describe("the already-merged hard blocker judges the card's OWN review lanes", () => { + const mergedCard = (column: string) => ({ + id: "FN-HARD", + column, + status: "failed" as const, + paused: false, + steps: [], + workflowStepResults: [], + }); + + it("does NOT block an already-merged card in a RENAMED review lane when its lanes are supplied", () => { + const blocker = getTaskHardMergeBlocker(mergedCard(RENAMED_VOCAB.review), { + reviewColumns: new Set([RENAMED_VOCAB.review]), + }); + + expect(blocker).toBeUndefined(); + }); + + it("DOES block the same card when the lanes are omitted — the symptom if the wiring is dropped", () => { + const blocker = getTaskHardMergeBlocker(mergedCard(RENAMED_VOCAB.review)); + + /* The message the operator would see behind "Merge confirmed but finalization blocked". */ + expect(blocker).toContain("must be in 'in-review'"); + }); + + it("still blocks a card that is genuinely outside its board's review lanes", () => { + /* + The paired negative. Wiring the resolved lanes must not degrade into "never blocks" — that would + finalize a merge for a card sitting in WIP. + */ + const blocker = getTaskHardMergeBlocker(mergedCard(RENAMED_VOCAB.wip), { + reviewColumns: new Set([RENAMED_VOCAB.review]), + }); + + expect(blocker).toContain(`must be in '${RENAMED_VOCAB.review}'`); + }); }); diff --git a/packages/engine/src/__tests__/self-healing.test.ts b/packages/engine/src/__tests__/self-healing.test.ts index bb38a907f2..d478cdb1ba 100644 --- a/packages/engine/src/__tests__/self-healing.test.ts +++ b/packages/engine/src/__tests__/self-healing.test.ts @@ -10863,6 +10863,24 @@ describe("SelfHealingManager reclaimStaleActiveBranches (FN-4546)", () => { mockedIsUsableTaskWorktree.mockResolvedValue(true); }); + /* + FNXC:WorkflowResolvedColumns 2026-07-31-01:40 (#2879 review — greptile, "multi-role tasks run + recovery twice"): THE FIX IS IN `self-healing.ts`; NO TEST HERE, AND THE ABSENCE IS DELIBERATE. + + `readBucket` dedupes by id inside ONE role's read, so a custom column carrying two queried traits — + `hold` plus `countsTowardWip`, or a review role beside either — is returned by two reads and the + concatenation handed the recovery loop the same STALE SNAPSHOT twice. + + I wrote a case here and removed it: it reported 0 recoveries, because the card it built reaches the + BRANCH-LEVEL scan (subsumed branch, no worktree) rather than the candidates loop the dedupe lives + in. Reaching that loop needs `branch` AND `worktree` set plus matching git state, i.e. the git + fixture this suite deliberately avoids. A test that passes without exercising the loop would have + been worse than none — that is the vacuous shape this program keeps finding. + + So the dedupe ships uncovered and stated. What would cover it: a candidates-loop fixture with a live + branch/worktree pair, asserting the recovery COUNT (a double-processed card reports 2 for one card's + work) rather than a call count. + */ it("reclaims subsumed fusion task branch with no worktree", async () => { (store.listTasks as any).mockResolvedValueOnce([ { id: "FN-1001", column: "todo", checkedOutBy: null, userPaused: false, worktree: null, branch: null, lineageId: "lin-1" }, diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index c236a0bf4f..5e9b1cb2a5 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -30,8 +30,9 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, import { readFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { isAbsolute, join, relative, resolve } from "node:path"; -import { resolveColumnFlags, IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, IN_REVIEW_STALL_TERMINAL_LOG_PREFIX, allowsAutoMergeProcessing, resolveEffectiveAutoMerge, countRecentIdenticalStallEntries, detectDependencyCycle, detectSelfDefeatingDependency, evaluateNoCommitsNoOpFinalize, evaluateCompletedPromotionFailureProvenance, evaluateSkipBypassTaint, getInReviewStalledSignal, getInReviewStallReason, getPrimaryPrInfo, getStalePausedReviewSignal, getStalePausedTodoSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, isMergeRequestContractShadowEnabled, isWorkspaceTask, isSharedBranchGroupMemberIntegration, isNearDuplicateCanonicalInactive, parseExplicitDuplicateMarker, flagTriageDuplicate, isTriageDuplicateKeepAcknowledged, resolveMaxAutoMergeRetries, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, getBuiltinWorkflow, isBuiltinWorkflowId, resolveWorkflowIrForTask, resolveWorkflowIrForTaskWithProvenance, resolveReboundTarget, columnsWithFlag, resolveLifecycleColumns, resolveTaskLifecycleColumns, workflowHasColumn, planLegacyAdoption, resolveOrphanedPendingStepResults, classifyReviewLease, PLAN_REVIEW_LEASE_STALENESS_MS, DEFAULT_MAX_POST_REVIEW_FIXES, ACTIVE_WORKFLOW_WORK_ITEM_STATES, AWAITING_APPROVAL_PAUSE_REASON, type Agent, type AgentStore, type ChatStore, type MessageStore, type TaskStore, type Settings, type Task, type MergeDetails, type TaskPriority, type MergeResult, type WorkflowStepResult, type WorkflowIr, +import { resolveColumnFlags, IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, IN_REVIEW_STALL_TERMINAL_LOG_PREFIX, allowsAutoMergeProcessing, resolveEffectiveAutoMerge, countRecentIdenticalStallEntries, detectDependencyCycle, detectSelfDefeatingDependency, evaluateNoCommitsNoOpFinalize, evaluateCompletedPromotionFailureProvenance, evaluateSkipBypassTaint, getInReviewStalledSignal, getInReviewStallReason, getPrimaryPrInfo, getStalePausedReviewSignal, getStalePausedTodoSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, isMergeRequestContractShadowEnabled, isWorkspaceTask, isSharedBranchGroupMemberIntegration, isNearDuplicateCanonicalInactive, parseExplicitDuplicateMarker, flagTriageDuplicate, isTriageDuplicateKeepAcknowledged, resolveMaxAutoMergeRetries, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, getBuiltinWorkflow, isBuiltinWorkflowId, resolveWorkflowIrForTask, resolveWorkflowIrForTaskWithProvenance, resolveReboundTarget, columnsWithFlag, resolveLifecycleColumns, workflowHasColumn, planLegacyAdoption, resolveOrphanedPendingStepResults, classifyReviewLease, PLAN_REVIEW_LEASE_STALENESS_MS, DEFAULT_MAX_POST_REVIEW_FIXES, ACTIVE_WORKFLOW_WORK_ITEM_STATES, AWAITING_APPROVAL_PAUSE_REASON, type Agent, type AgentStore, type ChatStore, type MessageStore, type TaskStore, type Settings, type Task, type MergeDetails, type TaskPriority, type MergeResult, type WorkflowStepResult, type WorkflowIr, LEGACY_COLUMN_IDS_BY_ROLE, + TERMINAL_ROLES, resolveProjectColumnsForRoles, REVIEW_ROLES, } from "@fusion/core"; @@ -99,7 +100,7 @@ import { type NtfyNotifier, } from "./notifier.js"; import type { GhostBugDecision } from "./triage-preflight.js"; -import { filterPathsByIgnoreList, getUnmetSchedulingDependencies, isCoordinationOnlyTask, pathsOverlap, shouldHoldActiveFileScopeLease } from "./scheduler.js"; +import { filterPathsByIgnoreList, getUnmetSchedulingDependencies, isCoordinationOnlyTask, pathsOverlap, resolveDependencySatisfactionColumns, shouldHoldActiveFileScopeLease } from "./scheduler.js"; import { runSurfacingSweep, hours, type SurfacingCycle } from "./surfacing-sweeps.js"; /* U4 substrate PR1: the git-evidence readers and their helpers now live in self-healing-git-evidence.ts. Imported back here because call sites remain. */ @@ -173,6 +174,23 @@ import { const log = createLogger("self-healing"); + +/* +DELIBERATE-LITERAL — the no-metadata fallback for dependency satisfaction, mirroring +`isLegacyDependencySatisfied` in scheduler.ts (reviewed there 2026-07-30-20:40). + +Reached ONLY when `resolveDependencySatisfactionColumns` left a dependency unmapped, i.e. its workflow +could not be read at all. DELETING these literals does not "finish the conversion" — it makes an +unresolvable dependency read as never-satisfied, which blocks its dependent forever. That is strictly +worse than the legacy behaviour it would replace. + +Hoisted to module scope so the marker sits in the node's LEADING comments, which is where the census +looks; inline in the conditional it was attached to the wrong node and scored as three unconverted +guards (86 -> 89 on #2883). +*/ +function isDependencySatisfiedWithoutWorkflowMetadata(column: string): boolean { + return column === "done" || column === "archived" || column === "in-review"; +} const worktreeMetadataReconcileLog = createLogger("worktree-metadata-reconcile"); /* FNXC:EngineDiagnostics 2026-07-26-10:25: @@ -2814,11 +2832,43 @@ export class SelfHealingManager extends SelfHealingGitEvidence { if (!recoverFn) return 0; try { - const tasks = await this.store.listTasks({ column: "in-progress", slim: true }); + /* + FNXC:WorkflowResolvedColumns 2026-07-31-07:10 (the query-filter class, seventeenth sweep): + A task whose steps are ALL done but whose session died before the executor could hand it to review. + The literal read meant that on a renamed board it was never found, so finished implementation work + sat in the wip lane with no session and nothing to move it on — the shape this sweep exists to + catch, made unreachable by the query above it. + + The `t.column === "in-progress"` check was redundant while the query pinned the column; under a + resolved read it becomes the per-card verdict, so it converts rather than being deleted. + */ + const stuckWipColumns = await resolveProjectColumnsForRoles(this.store, ["countsTowardWip"]); + const stuckById = new Map(); + for (const column of stuckWipColumns) { + for (const task of await this.store.listTasks({ column, slim: true })) stuckById.set(task.id, task); + } + const tasks = [...stuckById.values()]; + /* + NARROW WHEN THE CARD CAN ANSWER, BROAD WHEN IT CANNOT — the shape #2891 settled on. + `resolveWorkflowIrForTask` SUBSTITUTES the built-in IR rather than failing, so a card with an + unreadable selection would otherwise be rejected by the verdict that the project-scoped query had + just admitted it under. + */ + const stuckLanes = new Map>(); + for (const task of tasks) { + let lanes: Set; + try { + const { ir, source } = await resolveWorkflowIrForTaskWithProvenance(this.store, task.id); + lanes = source === "default" ? new Set(stuckWipColumns) : new Set(columnsWithFlag(ir, "countsTowardWip")); + } catch { + lanes = new Set(stuckWipColumns); + } + stuckLanes.set(task.id, lanes); + } const executingIds = this.options.getExecutingTaskIds?.() ?? new Set(); const stuckCompleted = tasks.filter((t) => - t.column === "in-progress" && + (stuckLanes.get(t.id) ?? stuckWipColumns).has(t.column) && !t.paused && !executingIds.has(t.id) && t.steps.length > 0 && @@ -3519,7 +3569,22 @@ export class SelfHealingManager extends SelfHealingGitEvidence { throw inspection.error; } - const inProgressCandidates = await this.store.listTasks({ column: "in-progress", slim: true }); + /* + FNXC:WorkflowResolvedColumns 2026-07-31-17:40 (the query-filter class, thirty-second sweep): + This read answers "is another LIVE task holding this worktree?" — the same question + `findActiveWorktreeOwner` answers for the executor, and the same failure if it comes back empty: the + checkout reads as unowned and this sweep reclaims a worktree another task is working in. + + No per-card lane verdict downstream: the map below is keyed by WORKTREE PATH, and nothing after it + compares a column. So this is a read-only conversion — there is no pair to convert, which the + derived ratchet from #2879 also confirms. + */ + const prConflictWipColumns = await resolveProjectColumnsForRoles(this.store, ["countsTowardWip"]); + const prConflictById = new Map(); + for (const column of prConflictWipColumns) { + for (const entry of await this.store.listTasks({ column, slim: true })) prConflictById.set(entry.id, entry); + } + const inProgressCandidates = [...prConflictById.values()]; const inProgressByWorktree = new Map(); for (const inProgressTask of inProgressCandidates) { if (inProgressTask.worktree) inProgressByWorktree.set(inProgressTask.worktree, inProgressTask.id); @@ -3684,23 +3749,101 @@ export class SelfHealingManager extends SelfHealingGitEvidence { try { const settings = await this.store.getSettings(); if (settings.globalPause || settings.enginePaused) return 0; - const todoCandidates = await this.store.listTasks({ column: "todo", slim: true }); - const inProgressCandidates = await this.store.listTasks({ column: "in-progress", slim: true }); + /* + FNXC:WorkflowResolvedColumns 2026-07-31-01:30 (the query-filter class, twelfth sweep): + Three lane reads AND three lane guards in the body, so both halves convert together — widening the + read alone would admit renamed-board cards and then mis-decide every one of them (the phantom-binding + check, the blocked-hold skip, and the review triple-proof are all keyed on lane). + + On a renamed board all three reads returned empty, so a task whose own worktree held its own branch + hostage was never reclaimed and stayed wedged behind a conflict only this sweep resolves. + */ + const reclaimHoldColumns = await resolveProjectColumnsForRoles(this.store, ["hold"]); + const reclaimWipColumns = await resolveProjectColumnsForRoles(this.store, ["countsTowardWip"]); + const reclaimReviewColumns = await resolveProjectColumnsForRoles(this.store, REVIEW_ROLES); + /* + Buckets are built FROM THE READ that produced each row, not by re-deriving from `task.column`. + A row returned by `listTasks({ column: X })` is by definition in X, so re-deriving adds nothing and + would silently drop any row whose column field is absent. + */ + const readBucket = async (columns: ReadonlySet): Promise => { + const byId = new Map(); + for (const column of columns) { + for (const task of await this.store.listTasks({ column, slim: true })) byId.set(task.id, task); + } + return [...byId.values()]; + }; + const todoCandidates = await readBucket(reclaimHoldColumns); + const inProgressCandidates = await readBucket(reclaimWipColumns); + const inReviewPausedCandidates = (await readBucket(reclaimReviewColumns)) + .filter((task) => task.paused === true && task.pausedReason === "branch-conflict-unrecoverable"); + /* + Per-card lanes, used by the three lane GUARDS below (phantom-binding, blocked-hold skip, review + triple-proof). Legacy ids unioned so a degraded or mid-rename board still decides correctly. + */ + const reclaimLanes = new Map; wip: Set; review: Set }>(); + const unresolvedReclaimCards: string[] = []; + for (const task of [...todoCandidates, ...inProgressCandidates, ...inReviewPausedCandidates]) { + if (reclaimLanes.has(task.id)) continue; + const { ir, source } = await resolveWorkflowIrForTaskWithProvenance(this.store, task.id); + if (source === "default") unresolvedReclaimCards.push(task.id); + const lanes = { + hold: new Set(LEGACY_COLUMN_IDS_BY_ROLE.hold ?? []), + wip: new Set(LEGACY_COLUMN_IDS_BY_ROLE.countsTowardWip ?? []), + review: new Set(LEGACY_COLUMN_IDS_BY_ROLE.mergeOrchestration ?? []), + }; + for (const id of columnsWithFlag(ir, "hold")) lanes.hold.add(id); + for (const id of columnsWithFlag(ir, "countsTowardWip")) lanes.wip.add(id); + for (const role of REVIEW_ROLES) for (const id of columnsWithFlag(ir, role)) lanes.review.add(id); + reclaimLanes.set(task.id, lanes); + } + if (unresolvedReclaimCards.length > 0) { + log.warn( + `self-owned branch reclaim: ${unresolvedReclaimCards.length} card(s) decided against the built-in workflow (${unresolvedReclaimCards.slice(0, 5).join(", ")})`, + ); + } + const lanesOfReclaim = (id: string) => reclaimLanes.get(id) ?? { + hold: new Set(LEGACY_COLUMN_IDS_BY_ROLE.hold ?? []), + wip: new Set(LEGACY_COLUMN_IDS_BY_ROLE.countsTowardWip ?? []), + review: new Set(LEGACY_COLUMN_IDS_BY_ROLE.mergeOrchestration ?? []), + }; const inProgressByWorktree = new Map(); for (const inProgressTask of inProgressCandidates) { if (inProgressTask.worktree) { inProgressByWorktree.set(inProgressTask.worktree, inProgressTask.id); } } - const inReviewPausedCandidates = (await this.store.listTasks({ column: "in-review", slim: true })) - .filter((task) => task.paused === true && task.pausedReason === "branch-conflict-unrecoverable"); // Per-task auto-merge gating applies to ALL candidate columns, not just // in-review: the FN-5704 regression contract ("short-circuits reclaim // when autoMerge is false") deliberately keeps execution-stage reclaim // and resume-limbo escalation inert in manual-review projects. The // per-task override preserves that for override-less tasks while letting // explicit autoMerge:true tasks recover. - const candidates = [...todoCandidates, ...inProgressCandidates, ...inReviewPausedCandidates] + /* + FNXC:WorkflowResolvedColumns 2026-07-31-01:15 (#2879 review — greptile, "multi-role tasks run + recovery twice"): DEDUPED ACROSS THE BUCKETS, NOT JUST WITHIN EACH. + + `readBucket` dedupes by id, but only inside one role's read. A custom workflow may put more than + one queried flag on ONE column — `hold` plus `countsTowardWip` on a lane that both parks and + counts as work, or a review role beside either — and that column is then returned by two reads. + The task lands in two buckets, and the concatenation below hands the recovery loop the SAME + STALE SNAPSHOT twice. + + That is not a wasted iteration: the second pass re-reads `task.branch`/`task.worktree` from a + snapshot taken before the first pass mutated anything, so a worktree the first pass reclaimed is + reclaimed again against state that no longer exists. The lane-resolution loop above already + guards with `reclaimLanes.has(task.id)`; this is the same guard the consumption side was missing. + + Deduping by id preserves iteration order, and the FIRST bucket's snapshot is the one kept. Spelled + out with an explicit `has` guard rather than `new Map(entries)`: that constructor keeps first + INSERTION ORDER but the LAST value for a repeated key, so it would have silently given last-bucket + precedence while this comment claimed the opposite. + */ + const reclaimCandidateById = new Map(); + for (const task of [...todoCandidates, ...inProgressCandidates, ...inReviewPausedCandidates]) { + if (!reclaimCandidateById.has(task.id)) reclaimCandidateById.set(task.id, task); + } + const candidates = [...reclaimCandidateById.values()] .filter((task) => allowsAutoMergeProcessing(task, settings)); const executingIds = this.options.getExecutingTaskIds?.() ?? new Set(); const activeTaskIds = await this.listActiveHeartbeatTaskIds(); @@ -3718,7 +3861,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence { includeCheckedOutLease: true, }); if (liveExecutionSignal) { - const canEvaluatePhantomBinding = task.column === "in-progress" + const canEvaluatePhantomBinding = lanesOfReclaim(task.id).wip.has(task.column) && (liveExecutionSignal.reason === "executor-active" || liveExecutionSignal.reason === "live-worktree-and-branch"); if (canEvaluatePhantomBinding) { const nowMs = Date.now(); @@ -3790,7 +3933,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence { ); continue; } - if (task.column === "todo" && task.blockedBy) { + if (lanesOfReclaim(task.id).hold.has(task.column) && task.blockedBy) { log.debug(`[self-healing] skipping blocked todo task ${task.id} during self-owned branch reclaim (blockedBy=${task.blockedBy})`); continue; } @@ -3812,7 +3955,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence { } if (!await isUsableTaskWorktree(this.options.rootDir, task.worktree)) continue; - const reviewProof = task.column === "in-review" + const reviewProof = lanesOfReclaim(task.id).review.has(task.column) ? await this.evaluateBackwardMoveTripleProof(task, { stage: "reclaim-self-owned-branch-conflict", graceMs: settings.taskStuckTimeoutMs ?? STALE_ACTIVE_BRANCH_EXECUTION_GRACE_MS, @@ -3933,7 +4076,15 @@ export class SelfHealingManager extends SelfHealingGitEvidence { `[recovery] tip-already-merged ${task.id} branch=${branchName} tip=${inspection.tipSha.slice(0, 12)} integrationRef=${inspection.integrationRef} reason=stale-cached-metadata-ghost-conflict`, ); - if (task.column === "in-review") { + /* + FNXC:WorkflowResolvedColumns 2026-07-31-11:40 (SELF-AUDIT after #2916 found the same class): + These loop-body lane guards were MISSED when I converted this sweep's read. That is not a + cosmetic gap: this one decides whether the backward move needs `reviewProof`. Left literal, + a renamed review card admitted by the widened read reads as NOT-in-review, so the + triple-proof gate is skipped entirely and the card is moved back without it — a safety + check silently bypassed BY the conversion. + */ + if (lanesOfReclaim(task.id).review.has(task.column)) { if (!reviewProof?.ok) { await this.emitBackwardMoveNoAction(task, "reclaim-self-owned-branch-conflict", "task:reclaim-self-owned-branch-conflict-no-action", reviewProof!); } else { @@ -4038,7 +4189,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence { `[recovery] reclaim-live-zero-commits ${task.id} branch=${task.branch} worktree=${inspection.livePath} tip=${inspection.tipSha.slice(0, 12)} reason=zero-unique-commits-vs-main`, ); - if (task.column === "in-review") { + if (lanesOfReclaim(task.id).review.has(task.column)) { if (!reviewProof?.ok) { await this.emitBackwardMoveNoAction(task, "reclaim-self-owned-branch-conflict", "task:reclaim-self-owned-branch-conflict-no-action", reviewProof!); } else { @@ -4128,12 +4279,12 @@ export class SelfHealingManager extends SelfHealingGitEvidence { const unchangedSincePriorResume = hasPriorSnapshot && task.resumeLimboTipSha === inspection.tipSha && task.resumeLimboStepSignature === stepSignature; - const isNoProgressResume = task.column === "in-progress" + const isNoProgressResume = lanesOfReclaim(task.id).wip.has(task.column) && unchangedSincePriorResume && !hasActiveSessionSignal; const resumeAttemptCount = isNoProgressResume ? (task.resumeLimboCount ?? 0) + 1 : 0; - if (task.column === "in-progress" && isNoProgressResume && resumeAttemptCount >= MAX_NO_PROGRESS_RESUME_ATTEMPTS) { + if (lanesOfReclaim(task.id).wip.has(task.column) && isNoProgressResume && resumeAttemptCount >= MAX_NO_PROGRESS_RESUME_ATTEMPTS) { const idleAnchor = task.executionStartedAt ?? task.columnMovedAt ?? task.updatedAt; const idleAnchorMs = Date.parse(idleAnchor ?? ""); const idleMs = Number.isFinite(idleAnchorMs) ? Math.max(0, Date.now() - idleAnchorMs) : null; @@ -4202,7 +4353,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence { `[recovery] ${wasPausedBranchConflict ? "reclaim-paused-review" : "reclaim-self-owned"} ${task.id} at ${reclaimedWorktreePath} (${preservedCommitCount} commits preserved, tip ${inspection.tipSha.slice(0, 12)})`, ); - if (task.column === "in-review") { + if (lanesOfReclaim(task.id).review.has(task.column)) { if (!reviewProof?.ok) { await this.emitBackwardMoveNoAction(task, "reclaim-self-owned-branch-conflict", "task:reclaim-self-owned-branch-conflict-no-action", reviewProof!); } else { @@ -4596,20 +4747,92 @@ export class SelfHealingManager extends SelfHealingGitEvidence { if (blockerScope.length === 0 || isCoordinationOnlyTask(blocker, blockerScope)) return false; return pathsOverlap(dependentScope, blockerScope); }; - const todoTasks = await this.store.listTasks({ column: "todo", slim: true }); - const inProgressTasks = await this.store.listTasks({ column: "in-progress", slim: true }); - const inReviewTasks = (await this.store.listTasks({ column: "in-review", slim: true })).filter((t) => !t.paused); + /* + FNXC:WorkflowResolvedColumns 2026-07-31-03:05 (the query-filter class, thirteenth sweep): + When a task completes, this releases everything blocked on it. Three literal reads meant that on a + renamed board it released NOTHING — every dependent stayed blocked on a task that had already + finished, which is the most visible form of this class: the board simply stops moving. - const dependents = [...todoTasks, ...inProgressTasks, ...inReviewTasks].filter( + Buckets come from the read that produced each row (a row returned by `listTasks({ column: X })` is + in X by definition); re-deriving from `task.column` would only be able to lose rows. + */ + const completedHoldColumns = await resolveProjectColumnsForRoles(this.store, ["hold"]); + const completedWipColumns = await resolveProjectColumnsForRoles(this.store, ["countsTowardWip"]); + const completedReviewColumns = await resolveProjectColumnsForRoles(this.store, REVIEW_ROLES); + const readDependentBucket = async (columns: ReadonlySet): Promise => { + const byId = new Map(); + for (const column of columns) { + for (const entry of await this.store.listTasks({ column, slim: true })) byId.set(entry.id, entry); + } + return [...byId.values()]; + }; + const todoTasks = await readDependentBucket(completedHoldColumns); + const inProgressTasks = await readDependentBucket(completedWipColumns); + const inReviewTasks = (await readDependentBucket(completedReviewColumns)).filter((t) => !t.paused); + + /* + FNXC:WorkflowResolvedColumns 2026-07-31-02:40 (#2883 review — greptile P1, "duplicate dependent + reconciliation"; same class as #2879 one sweep over): + DEDUPED ACROSS THE BUCKETS, NOT JUST WITHIN EACH. + + `readDependentBucket` dedupes by id inside ONE role's read. A custom workflow may put two queried + roles on ONE column, which two reads then return, so the dependent landed in two buckets and was + reconciled twice from the same stale snapshot — the second pass deciding against `blockedBy` state + the first pass had already cleared, and `updateTask`/`logEntry` firing twice for one card. + + FNXC:WorkflowResolvedColumns 2026-07-31-04:05 (precedence correction): + Written first as `new Map(entries)` with a comment claiming first-bucket precedence. That + constructor keeps first insertion ORDER but the LAST value for a repeated key, so it did the + opposite of what it said. The explicit `has` guard below makes the code match the claim; order is + still preserved, so `todoTaskIds` classifies the dependent by the read that found it first. + */ + const dependentById = new Map(); + for (const entry of [...todoTasks, ...inProgressTasks, ...inReviewTasks]) { + if (!dependentById.has(entry.id)) dependentById.set(entry.id, entry); + } + /* One IR cache for the whole reconcile, so a board spanning three workflows reads three IRs, not one per dependency row. */ + const depSatisfactionIrCache = new Map(); + const dependents = [...dependentById.values()].filter( (t) => t.blockedBy === taskId || t.overlapBlockedBy === taskId, ); const todoTaskIds = new Set(todoTasks.map((t) => t.id)); for (const dependent of dependents) { try { - const unresolvedDeps = dependent.dependencies.filter((depId) => { + /* + A dependency is SATISFIED once it reaches a complete, review or archived lane — resolved per + DEPENDENCY, since a dependency routinely belongs to a different workflow than the card waiting + on it (the answer main settled on in branch-group-ops, #2720). Legacy ids unioned, because + `resolveWorkflowIrForTask` returns the built-in IR for a missing workflow and without the union + a degraded board reads a finished dependency as unmet — the exact stall being cleared here. + */ + /* + FNXC:WorkflowResolvedColumns 2026-07-31-05:00 (#2883 review — greptile P1, "overbroad + dependency satisfaction"): REUSE THE SCHEDULER'S RESOLVER, DO NOT RE-DERIVE IT. + + The first version unioned all three review roles, which counts a `mergeOrchestration`-only + column as satisfied. `resolveDependencySatisfactionColumns` — the shared answer the scheduler + already uses for exactly this question — defines review as `mergeBlocker ∪ humanReview` and + deliberately excludes merge orchestration. Two readers of one fact disagreeing is how this + program has already gone wrong; the fix is to call the existing one, not to match it by hand. + + Its contract also covers the degraded case: an unresolvable dependency is left UNMAPPED, and + the literal fallback below then answers, so a dependency whose workflow cannot be read does not + silently read as never-satisfied and block its dependent forever. + */ + const depRows = dependent.dependencies + .map((depId) => taskById.get(depId)) + .filter((dep): dep is Task => Boolean(dep)); + const depSatisfaction = await resolveDependencySatisfactionColumns(this.store, depRows, depSatisfactionIrCache); + const unresolvedDeps: string[] = []; + for (const depId of dependent.dependencies) { const dep = taskById.get(depId); - return dep && dep.column !== "done" && dep.column !== "in-review" && dep.column !== "archived"; - }); + if (!dep) continue; + const columns = depSatisfaction.get(depId); + const satisfied = columns + ? columns.terminal.has(dep.column) || columns.review.has(dep.column) + : isDependencySatisfiedWithoutWorkflowMetadata(dep.column); + if (!satisfied) unresolvedDeps.push(depId); + } const overlapBlockedBy = dependent.overlapBlockedBy === taskId ? null : (dependent.overlapBlockedBy ?? null); const hasActiveOverlapBlocker = await hasActiveFileScopeOverlapBlocker(dependent, overlapBlockedBy); @@ -5466,9 +5689,75 @@ export class SelfHealingManager extends SelfHealingGitEvidence { const executingTaskIds = this.options.getExecutingTaskIds?.() ?? new Set(); const now = Date.now(); - const todoTasks = await this.store.listTasks({ column: "todo" }); - const inProgressTasks = await this.store.listTasks({ column: "in-progress" }); - const inReviewTasks = await this.store.listTasks({ column: "in-review" }); + /* + FNXC:WorkflowResolvedColumns 2026-07-31-00:45 (the query-filter class, eleventh sweep): + THREE lane reads whose downstream treatment DIFFERS — hold cards seed the queued-dependency pass, + review cards are exempted when paused — so this cannot collapse into one union. Read the project's + columns for all three role groups, dedupe into one map, then classify each card against ITS OWN + workflow. On a renamed board all three reads returned empty, so no stale `blockedBy` was ever cleared + and the cards stayed blocked behind dependencies that had long since finished. + */ + /* + FNXC:WorkflowResolvedColumns 2026-07-31-00:30 (#2876 review — greptile, "traitless workflow + columns stay invisible"): CONFIRMED, DEFERRED, AND THE REASON IS THAT IT IS NOT LOCAL. + + `resolveProjectColumnsForRoles` returns its legacy floor plus what workflows DECLARE for the + role. A board that renames its lanes but declares no lifecycle traits contributes nothing, so + the card never enters `blockedCandidates` and the per-card classification below — which is + correct — never runs for it. Invisible before the guard is reached. + + This is the three-state rule at PROJECT scope: unreadable and untraited take the legacy answer, + and only a board that EXPRESSES traits and still lacks the role is answering. The merge queue + got the per-task version of this in #2819. + + NOT FIXED HERE BECAUSE A BLANKET FIX WOULD BE WRONG. The safe direction differs by caller: for + a sweep, over-inclusion costs extra `listTasks` calls the per-card check discards; for the + analytics aggregators (#2864, #2866) the same widening inflates a number an operator reads. It + needs an opt-in — `resolveProjectColumnsForRoles(store, roles, { untraitedProject: + "declared-columns" })` defaulting to today's behaviour — which is a shared-helper contract + change touching every sweep, both aggregators and the glasses notifier. + + Premise correction for whoever writes the fixture: this is a hand-authored V2 board, not a v1 + upgrade. `synthesizeDefaultColumns` emits the DEFAULT ids with `traits: []`, so a v1-upgraded + board cannot have a renamed lane — a v1-shaped fixture would pass vacuously. + */ + /* `hold` only, NOT `intake`: the original read asked for `todo`. Adding intake would newly scan `triage` cards — a behavior change riding along in a conversion. */ + const blockedHoldColumns = await resolveProjectColumnsForRoles(this.store, ["hold"]); + const blockedWipColumns = await resolveProjectColumnsForRoles(this.store, ["countsTowardWip"]); + const blockedReviewColumns = await resolveProjectColumnsForRoles(this.store, REVIEW_ROLES); + const blockedCandidates = new Map(); + for (const column of new Set([...blockedHoldColumns, ...blockedWipColumns, ...blockedReviewColumns])) { + for (const task of await this.store.listTasks({ column })) blockedCandidates.set(task.id, task); + } + /* Per-card classification: a card the union pulled in must land in the bucket ITS workflow says. */ + const todoTasks: Task[] = []; + const inProgressTasks: Task[] = []; + const inReviewTasks: Task[] = []; + const unresolvedBlockedCards: string[] = []; + for (const task of blockedCandidates.values()) { + const { ir, source } = await resolveWorkflowIrForTaskWithProvenance(this.store, task.id); + if (source === "default") unresolvedBlockedCards.push(task.id); + /* + Legacy ids UNIONED into each bucket rather than compared separately: `resolveWorkflowIrForTask` + returns the BUILT-IN IR for a missing or corrupt workflow, and a board mid-rename still has rows + under the old id. This mirrors `lanesOf` below, which unions the same way for referenced tasks. + */ + const bucket = (roles: readonly string[], legacy: readonly string[]): boolean => { + const lanes = new Set(legacy); + for (const role of roles) { + for (const id of columnsWithFlag(ir, role as Parameters[1])) lanes.add(id); + } + return lanes.has(task.column); + }; + if (bucket(["hold"], LEGACY_COLUMN_IDS_BY_ROLE.hold ?? [])) todoTasks.push(task); + if (bucket(["countsTowardWip"], LEGACY_COLUMN_IDS_BY_ROLE.countsTowardWip ?? [])) inProgressTasks.push(task); + if (bucket(REVIEW_ROLES, LEGACY_COLUMN_IDS_BY_ROLE.mergeOrchestration ?? [])) inReviewTasks.push(task); + } + if (unresolvedBlockedCards.length > 0) { + log.warn( + `stale blockedBy cleanup: ${unresolvedBlockedCards.length} card(s) classified against the built-in workflow (${unresolvedBlockedCards.slice(0, 5).join(", ")})`, + ); + } const blockedTasks = [ ...todoTasks, ...inProgressTasks, @@ -6409,9 +6698,22 @@ export class SelfHealingManager extends SelfHealingGitEvidence { */ async reconcileStaleMergerStatus(): Promise { try { - const done = await this.store.listTasks({ column: "done", slim: true }); - const archived = await this.store.listTasks({ column: "archived", slim: true }); - const candidates = [...done, ...archived].filter((task) => { + /* + FNXC:WorkflowResolvedColumns 2026-07-31-06:40 (the query-filter class, sixteenth sweep): + A card that reached a terminal lane while still carrying `merging`/`merging-pr` holds the merger + queue. Two literal reads meant that on a renamed board the stale status was never cleared, so one + finished card blocked the queue for every task behind it. + + ONE union read, not two buckets: unlike the sweeps before it, nothing here treats complete and + archived differently — the only filter is on `status` — so splitting them would add a distinction + the code does not make. Deduped, because the two roles can share a column (the P1 on #2879). + */ + const terminalColumns = await resolveProjectColumnsForRoles(this.store, TERMINAL_ROLES); + const terminalById = new Map(); + for (const column of terminalColumns) { + for (const task of await this.store.listTasks({ column, slim: true })) terminalById.set(task.id, task); + } + const candidates = [...terminalById.values()].filter((task) => { const s = task.status; return s === "merging" || s === "merging-pr"; }); @@ -8005,7 +8307,37 @@ export class SelfHealingManager extends SelfHealingGitEvidence { if (!timeoutMs || timeoutMs <= 0) return 0; const now = Date.now(); - const tasks = await this.store.listTasks({ column: "in-review", slim: true }); + /* + FNXC:WorkflowResolvedColumns 2026-07-31-12:40 (the query-filter class, twenty-fourth sweep): + A review card whose STEPS are not finished — it reached review on a graph failure, not on completed + work. The literal read meant that on a renamed board it was never requeued, so the card sat in + review claiming to be done while its own steps said otherwise. + + The per-card verdict below converts with it. The triple-proof is NOT lane-gated in this sweep, so + there is no second pair to convert — checked deliberately, because that is the defect #2916 found + one sweep over and the self-audit found five of in another. + */ + const staleIncompleteColumns = await resolveProjectColumnsForRoles(this.store, REVIEW_ROLES); + const staleIncompleteById = new Map(); + for (const column of staleIncompleteColumns) { + for (const entry of await this.store.listTasks({ column, slim: true })) staleIncompleteById.set(entry.id, entry); + } + const tasks = [...staleIncompleteById.values()]; + /* NARROW WHEN THE CARD CAN ANSWER, BROAD WHEN IT CANNOT (#2891). */ + const staleIncompleteLanes = new Map>(); + for (const entry of tasks) { + try { + const { ir, source } = await resolveWorkflowIrForTaskWithProvenance(this.store, entry.id); + staleIncompleteLanes.set( + entry.id, + source === "default" + ? new Set(staleIncompleteColumns) + : new Set(REVIEW_ROLES.flatMap((role) => [...columnsWithFlag(ir, role)])), + ); + } catch { + staleIncompleteLanes.set(entry.id, new Set(staleIncompleteColumns)); + } + } /* * FNXC:WorkflowLifecycle 2026-06-29-11:27: * Restart recovery must not leave errored review-column cards with unfinished @@ -8014,7 +8346,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence { * progress preserved instead of waiting for the stale timeout. */ const staleIncomplete = tasks.filter((task) => - task.column === "in-review" && + (staleIncompleteLanes.get(task.id) ?? staleIncompleteColumns).has(task.column) && allowsAutoMergeProcessing(task, settings) && !task.paused && (!task.status || task.status === "failed") && @@ -8409,10 +8741,43 @@ export class SelfHealingManager extends SelfHealingGitEvidence { const now = Date.now(); const executingIds = this.options.getExecutingTaskIds?.() ?? new Set(); - const tasks = await this.store.listTasks({ column: "in-review", slim: true }); + /* + FNXC:WorkflowResolvedColumns 2026-07-31-10:20 (the query-filter class, twenty-second sweep): + A GHOST review card — parked in review past the stuck timeout with nobody owning its merge lane. + The literal read meant that on a renamed board it was never found, so the card sat in review + indefinitely with no merger, no session and no timeout ever firing against it. + + The `task.column === "in-review"` check was redundant while the query pinned the column; under a + resolved read it becomes the per-card verdict, so it converts rather than being deleted. + + The kick-back below keeps its literal `todo` DELIBERATELY: it passes `recoveryRehome: true`, one of + the 22 documented escapes `moves.ts` exempts so a card stranded in an undeclared column stays + rescuable. Converting it removes the rescue path. + */ + const ghostReviewColumns = await resolveProjectColumnsForRoles(this.store, REVIEW_ROLES); + const ghostById = new Map(); + for (const column of ghostReviewColumns) { + for (const entry of await this.store.listTasks({ column, slim: true })) ghostById.set(entry.id, entry); + } + const tasks = [...ghostById.values()]; + /* NARROW WHEN THE CARD CAN ANSWER, BROAD WHEN IT CANNOT (#2891). */ + const ghostLanes = new Map>(); + for (const entry of tasks) { + try { + const { ir, source } = await resolveWorkflowIrForTaskWithProvenance(this.store, entry.id); + ghostLanes.set( + entry.id, + source === "default" + ? new Set(ghostReviewColumns) + : new Set(REVIEW_ROLES.flatMap((role) => [...columnsWithFlag(ir, role)])), + ); + } catch { + ghostLanes.set(entry.id, new Set(ghostReviewColumns)); + } + } // Pre-filter sync conditions, then resolve async merge-lane ownership. const candidates = tasks.filter((task) => - task.column === "in-review" && + (ghostLanes.get(task.id) ?? ghostReviewColumns).has(task.column) && allowsAutoMergeProcessing(task, settings) && !task.paused && !executingIds.has(task.id) && @@ -8517,9 +8882,38 @@ export class SelfHealingManager extends SelfHealingGitEvidence { if (settings.globalPause || settings.enginePaused) return 0; const maxAutoMergeRetries = resolveMaxAutoMergeRetries(settings); - const slim = await this.store.listTasks({ column: "in-review", slim: true }); + /* + FNXC:WorkflowResolvedColumns 2026-07-31-10:55 (the query-filter class, twenty-third sweep): + A merge that failed for a TRANSIENT reason and burned its whole retry budget. The literal read + meant that on a renamed board the retry budget was never refunded, so a card that failed on a + network blip stayed failed permanently — an operator-visible failure with no operator-visible cause. + + The `t.column === "in-review"` check was redundant while the query pinned the column; under a + resolved read it becomes the per-card verdict, so it converts rather than being deleted. + */ + const transientReviewColumns = await resolveProjectColumnsForRoles(this.store, REVIEW_ROLES); + const transientById = new Map(); + for (const column of transientReviewColumns) { + for (const entry of await this.store.listTasks({ column, slim: true })) transientById.set(entry.id, entry); + } + const slim = [...transientById.values()]; + /* NARROW WHEN THE CARD CAN ANSWER, BROAD WHEN IT CANNOT (#2891). */ + const transientLanes = new Map>(); + for (const entry of slim) { + try { + const { ir, source } = await resolveWorkflowIrForTaskWithProvenance(this.store, entry.id); + transientLanes.set( + entry.id, + source === "default" + ? new Set(transientReviewColumns) + : new Set(REVIEW_ROLES.flatMap((role) => [...columnsWithFlag(ir, role)])), + ); + } catch { + transientLanes.set(entry.id, new Set(transientReviewColumns)); + } + } const candidates = slim.filter((t) => - t.column === "in-review" + (transientLanes.get(t.id) ?? transientReviewColumns).has(t.column) && allowsAutoMergeProcessing(t, settings) && t.status === "failed" && (t.mergeRetries ?? 0) >= maxAutoMergeRetries @@ -8537,10 +8931,15 @@ export class SelfHealingManager extends SelfHealingGitEvidence { for (const slimTask of candidates) { const task = await this.store.getTask(slimTask.id).catch(() => null); if (!task) continue; - // Re-check selector on the full row — the slim snapshot is best-effort - // and may be stale once we await. + /* + Re-check selector on the full row — the slim snapshot is best-effort and may be stale once we + await. FNXC:WorkflowResolvedColumns 2026-07-31-11:10: this SECOND lane guard converts with the + first. Converting only the read left it rejecting every renamed-board card the widened query + found, and the new test failed on exactly that — the "convert the pair or neither" rule, caught + by the test rather than by reading. + */ if ( - task.column !== "in-review" + !(transientLanes.get(task.id) ?? transientReviewColumns).has(task.column) || task.status !== "failed" || (task.mergeRetries ?? 0) < maxAutoMergeRetries ) { @@ -8879,9 +9278,38 @@ export class SelfHealingManager extends SelfHealingGitEvidence { const activeMergeTaskId = this.options.getActiveMergeTaskId?.() ?? null; // Workspace tasks live in in-review (post-capture/review, pre/partial land). A task already // done is finished; todo/in-progress are owned by execution-stage reconcilers. - const tasks = await this.store.listTasks({ column: "in-review", slim: true }); + /* + FNXC:WorkflowResolvedColumns 2026-07-31-18:05 (the query-filter class, thirty-third sweep): + A WORKSPACE task lands per-repo, and this re-enqueues one whose lands are partial or zero. The + literal read meant that on a renamed board a workspace task stranded mid-land was never + re-enqueued — some repos landed, some not, and nothing to finish the job. + + The comment above records WHY the review lane is the right one; it stays true, only the way the + lane is named changes. + */ + const wsPartialColumns = await resolveProjectColumnsForRoles(this.store, REVIEW_ROLES); + const wsPartialById = new Map(); + for (const column of wsPartialColumns) { + for (const entry of await this.store.listTasks({ column, slim: true })) wsPartialById.set(entry.id, entry); + } + const tasks = [...wsPartialById.values()]; + /* NARROW WHEN THE CARD CAN ANSWER, BROAD WHEN IT CANNOT (#2891). */ + const wsPartialLanes = new Map>(); + for (const entry of tasks) { + try { + const { ir, source } = await resolveWorkflowIrForTaskWithProvenance(this.store, entry.id); + wsPartialLanes.set( + entry.id, + source === "default" + ? new Set(wsPartialColumns) + : new Set(REVIEW_ROLES.flatMap((role) => [...columnsWithFlag(ir, role)])), + ); + } catch { + wsPartialLanes.set(entry.id, new Set(wsPartialColumns)); + } + } const candidates = tasks.filter((task) => - task.column === "in-review" && + (wsPartialLanes.get(task.id) ?? wsPartialColumns).has(task.column) && isWorkspaceTask(task) && task.mergeDetails?.mergeConfirmed !== true && // Active transient merge statuses are owned by the live merger; recover-interrupted / @@ -9286,8 +9714,21 @@ export class SelfHealingManager extends SelfHealingGitEvidence { if (settings.globalPause || settings.enginePaused) return 0; // Done workspace tasks are the canonical "safe to clean" set (their lands are finalized). - const doneTasks = await this.store.listTasks({ column: "done", slim: true }); - const candidates = doneTasks.filter((task) => isWorkspaceTask(task)); + /* + FNXC:WorkflowResolvedColumns 2026-07-31-18:10 (the query-filter class, thirty-fourth sweep): + Removes the per-repo worktrees a finished workspace task left behind. The literal read meant that + on a renamed board they were never removed — disk held by tasks that finished, growing quietly. + + `complete` only, NOT the terminal union: the comment above calls DONE tasks "the canonical safe to + clean set" precisely because their lands are finalized, and an archived row is a different claim. + No per-card verdict: the filter is `isWorkspaceTask`, not a lane test. + */ + const wsDoneColumns = await resolveProjectColumnsForRoles(this.store, ["complete"]); + const wsDoneById = new Map(); + for (const column of wsDoneColumns) { + for (const entry of await this.store.listTasks({ column, slim: true })) wsDoneById.set(entry.id, entry); + } + const candidates = [...wsDoneById.values()].filter((task) => isWorkspaceTask(task)); if (candidates.length === 0) return 0; let cleaned = 0; @@ -9363,9 +9804,35 @@ export class SelfHealingManager extends SelfHealingGitEvidence { async recoverDoneTaskMergeMetadata(): Promise { try { - const tasks = await this.store.listTasks({ column: "done", slim: true }); + /* + FNXC:WorkflowResolvedColumns 2026-07-31-17:05 (the query-filter class, thirty-first sweep): + Repairs the merge metadata of a card that already reached the COMPLETE lane — the commit sha an + operator sees, and that later reconcilers trust. The literal read meant that on a renamed board a + done card's metadata was never repaired, so a completed task could keep pointing at a commit that + is not the one that landed. + + `complete` only, NOT the terminal union: an ARCHIVED card is out of scope here, and widening to + TERMINAL_ROLES would start repairing metadata on rows nobody is reading — a behaviour change + wearing a conversion's clothes. + */ + const doneMetaColumns = await resolveProjectColumnsForRoles(this.store, ["complete"]); + const doneMetaById = new Map(); + for (const column of doneMetaColumns) { + for (const entry of await this.store.listTasks({ column, slim: true })) doneMetaById.set(entry.id, entry); + } + const tasks = [...doneMetaById.values()]; + /* NARROW WHEN THE CARD CAN ANSWER, BROAD WHEN IT CANNOT (#2891). */ + const doneMetaLanes = new Map>(); + for (const entry of tasks) { + try { + const { ir, source } = await resolveWorkflowIrForTaskWithProvenance(this.store, entry.id); + doneMetaLanes.set(entry.id, source === "default" ? new Set(doneMetaColumns) : new Set(columnsWithFlag(ir, "complete"))); + } catch { + doneMetaLanes.set(entry.id, new Set(doneMetaColumns)); + } + } const candidates = tasks.filter((task) => { - if (task.column !== "done" || task.paused) return false; + if (!(doneMetaLanes.get(task.id) ?? doneMetaColumns).has(task.column) || task.paused) return false; if (task.mergeDetails?.commitSha) return true; return Boolean(task.baseCommitSha); }); @@ -10042,9 +10509,43 @@ export class SelfHealingManager extends SelfHealingGitEvidence { const settings = await this.store.getSettings(); if (settings.globalPause || settings.enginePaused) return 0; const executingIds = this.options.getExecutingTaskIds?.() ?? new Set(); - const tasks = await this.store.listTasks({ column: "in-review", slim: true }); + /* + FNXC:WorkflowResolvedColumns 2026-07-30-23:20 (the query-filter class, ninth sweep): + `listTasks({ column: "in-review" })` returned EMPTY on a renamed board, so a task failed by an + ORPHAN-ONLY file-scope violation — commits that belong to no declared scope — was never recovered + and stayed failed. + + Activation check first: one of the two sweeps still holding both a literal query and an unwired + `getTaskHardMergeBlocker`, so the guard is wired in the same change. + */ + const orphanReviewColumns = await resolveProjectColumnsForRoles(this.store, REVIEW_ROLES); + const orphanById = new Map(); + for (const column of orphanReviewColumns) { + for (const task of await this.store.listTasks({ column, slim: true })) orphanById.set(task.id, task); + } + const tasks = [...orphanById.values()]; + const orphanIrCache = new Map(); + const unresolvedOrphanCards: string[] = []; + const orphanLanesByTask = new Map>(); + for (const task of tasks) { + const resolved = await resolveWorkflowIrForTaskWithProvenance(this.store, task.id, orphanIrCache) + .catch(() => undefined); + const own = resolved + ? [...new Set(REVIEW_ROLES.flatMap((role) => columnsWithFlag(resolved.ir, role)))] + : []; + if (!resolved || resolved.source === "default") unresolvedOrphanCards.push(task.id); + /* DELIBERATE-LITERAL — the unresolvable-workflow default, reviewed 2026-07-30-23:20. */ + orphanLanesByTask.set(task.id, own.length > 0 ? new Set(own) : new Set(["in-review"])); + } + if (unresolvedOrphanCards.length > 0) { + log.warn( + `orphan-only scope recovery: ${unresolvedOrphanCards.length} card(s) measured against the ` + + `built-in review lane because their own workflow could not be resolved ` + + `(${unresolvedOrphanCards.slice(0, 5).join(", ")}); a renamed review lane there stays failed.`, + ); + } const candidates = tasks.filter((task) => - task.column === "in-review" && + (orphanLanesByTask.get(task.id) ?? new Set(["in-review"])).has(task.column) && allowsAutoMergeProcessing(task, settings) && task.status === "failed" && task.scopeOverride !== true && @@ -10107,11 +10608,12 @@ export class SelfHealingManager extends SelfHealingGitEvidence { mergeTargetSource: mergeTarget.source, }; + /* Wired: unwired, this would decline every card the widened read now finds. */ const hardBlocker = getTaskHardMergeBlocker({ ...task, steps: task.steps ?? [], workflowStepResults: task.workflowStepResults, - }); + }, { reviewColumns: orphanLanesByTask.get(task.id) ?? new Set(["in-review"]) }); if (hardBlocker) { await this.store.updateTask(task.id, { status: "failed", @@ -10480,11 +10982,56 @@ export class SelfHealingManager extends SelfHealingGitEvidence { try { const settings = await this.store.getSettings(); if (settings.globalPause || settings.enginePaused) return 0; - const tasks = await this.store.listTasks({ column: "in-review", slim: false }); + /* + FNXC:WorkflowResolvedColumns 2026-07-30-23:55 (the query-filter class, tenth sweep): + Read the review lanes this PROJECT declares rather than the literal `in-review`, then decide each + card against ITS OWN workflow below. A card wedged `failed` after a post-done continuation error on + a renamed board was never listed at all, so it stayed failed with every step done. + */ + /* + FNXC:WorkflowResolvedColumns 2026-07-31-00:45 (#2869 review — greptile, "traitless review lanes + remain invisible"): CONFIRMED, DEFERRED, SAME CLASS AS #2876. + + A board that renames its review lane but declares NO lifecycle traits contributes nothing to this + union, so the card is not in `wedgeById` and the per-card fallback below — including its + `new Set(["in-review"])` degraded answer — never runs for it. The fallback cannot rescue a card + the query never returned. + + The three-state rule at PROJECT scope. Not fixed here because the safe direction differs by + caller: over-inclusion is free for a sweep (the per-card check discards it) and inflates an + operator-facing number in the analytics aggregators, so it needs an opt-in + (`{ untraitedProject: "declared-columns" }`) on the shared helper rather than a change to what it + returns by default. + + Fixture note: hand-authored V2 without traits, NOT a v1 upgrade — `synthesizeDefaultColumns` + emits the default ids, so a v1-shaped fixture cannot express a renamed lane and would pass + vacuously. + */ + const wedgeReviewColumns = await resolveProjectColumnsForRoles(this.store, REVIEW_ROLES); + const wedgeById = new Map(); + for (const column of wedgeReviewColumns) { + for (const task of await this.store.listTasks({ column, slim: false })) wedgeById.set(task.id, task); + } + const tasks = [...wedgeById.values()]; + /* Per-card review lanes; also fed to the hard-blocker below so it judges the card's own vocabulary. */ + const wedgeLanesByTask = new Map>(); + const unresolvedWedgeCards: string[] = []; + for (const task of tasks) { + const { ir, source } = await resolveWorkflowIrForTaskWithProvenance(this.store, task.id); + if (source === "default") unresolvedWedgeCards.push(task.id); + const own = REVIEW_ROLES.flatMap((role) => [...columnsWithFlag(ir, role)]); + wedgeLanesByTask.set(task.id, own.length > 0 ? new Set(own) : new Set(["in-review"])); + } + if (unresolvedWedgeCards.length > 0) { + log.warn( + `post-done non-continuable wedge recovery: ${unresolvedWedgeCards.length} card(s) fell back to the built-in workflow (${unresolvedWedgeCards.slice(0, 5).join(", ")})`, + ); + } let recovered = 0; for (const task of tasks) { - if (task.column !== "in-review" || task.deletedAt) continue; + const wedgeLanes = wedgeLanesByTask.get(task.id) ?? new Set(["in-review"]); + if (!wedgeLanes.has(task.column) || task.deletedAt) continue; if (!allowsAutoMergeProcessing(task, settings)) continue; if (task.paused || task.userPaused) continue; if (task.status !== "failed") continue; @@ -10492,7 +11039,8 @@ export class SelfHealingManager extends SelfHealingGitEvidence { if (!(task.steps ?? []).every((step) => step.status === "done" || step.status === "skipped")) continue; const doneMarker = [...(task.log ?? [])].reverse().find((entry) => entry.action === "Task marked done by agent"); if (!doneMarker) continue; - if (getTaskHardMergeBlocker({ ...task, status: undefined, error: undefined, steps: task.steps ?? [], workflowStepResults: task.workflowStepResults })) continue; + /* Wired in the SAME change as the read: widening the query without this makes the sweep find renamed-board cards and decline every one. */ + if (getTaskHardMergeBlocker({ ...task, status: undefined, error: undefined, steps: task.steps ?? [], workflowStepResults: task.workflowStepResults }, { reviewColumns: wedgeLanes })) continue; const evidence = this.getPostDoneNonContinuableEvidence(task); if (!evidence) continue; @@ -10894,9 +11442,38 @@ export class SelfHealingManager extends SelfHealingGitEvidence { if (settings.globalPause || settings.enginePaused) return 0; const executingIds = this.options.getExecutingTaskIds?.() ?? new Set(); - const tasks = await this.store.listTasks({ column: "in-review", slim: true }); + /* + FNXC:WorkflowResolvedColumns 2026-07-31-14:10 (the query-filter class, twenty-sixth sweep): + A review card whose BRANCH TIP is bound to a different task's work. The literal read meant that on + a renamed board the misbinding was never detected, so the card would merge — or refuse to — against + a branch that is not its own. + + The per-card verdict below converts with it. No second pair; verified with the derived ratchet from + #2879 rather than by eye. + */ + const misboundColumns = await resolveProjectColumnsForRoles(this.store, REVIEW_ROLES); + const misboundById = new Map(); + for (const column of misboundColumns) { + for (const entry of await this.store.listTasks({ column, slim: true })) misboundById.set(entry.id, entry); + } + const tasks = [...misboundById.values()]; + /* NARROW WHEN THE CARD CAN ANSWER, BROAD WHEN IT CANNOT (#2891). */ + const misboundLanes = new Map>(); + for (const entry of tasks) { + try { + const { ir, source } = await resolveWorkflowIrForTaskWithProvenance(this.store, entry.id); + misboundLanes.set( + entry.id, + source === "default" + ? new Set(misboundColumns) + : new Set(REVIEW_ROLES.flatMap((role) => [...columnsWithFlag(ir, role)])), + ); + } catch { + misboundLanes.set(entry.id, new Set(misboundColumns)); + } + } const candidates = tasks.filter((task) => - task.column === "in-review" && + (misboundLanes.get(task.id) ?? misboundColumns).has(task.column) && Boolean(task.branch) && task.mergeDetails?.mergeConfirmed !== true && !executingIds.has(task.id), @@ -11239,10 +11816,39 @@ export class SelfHealingManager extends SelfHealingGitEvidence { */ async recoverMisclassifiedFailures(): Promise { try { - const tasks = await this.store.listTasks({ column: "in-review", slim: true }); + /* + FNXC:WorkflowResolvedColumns 2026-07-31-13:20 (the query-filter class, twenty-fifth sweep): + A task the executor parked `failed` for "no fn_task_done" whose steps are ALL actually done — the + failure is a misclassification, not real. The literal read meant that on a renamed board the error + was never cleared, so finished work stayed visibly failed and never entered normal review. + + The per-card verdict below converts with it. No second pair: nothing else in this sweep is + lane-gated (verified with the derived ratchet from #2879, not by eye). + */ + const misclassifiedColumns = await resolveProjectColumnsForRoles(this.store, REVIEW_ROLES); + const misclassifiedById = new Map(); + for (const column of misclassifiedColumns) { + for (const entry of await this.store.listTasks({ column, slim: true })) misclassifiedById.set(entry.id, entry); + } + const tasks = [...misclassifiedById.values()]; + /* NARROW WHEN THE CARD CAN ANSWER, BROAD WHEN IT CANNOT (#2891). */ + const misclassifiedLanes = new Map>(); + for (const entry of tasks) { + try { + const { ir, source } = await resolveWorkflowIrForTaskWithProvenance(this.store, entry.id); + misclassifiedLanes.set( + entry.id, + source === "default" + ? new Set(misclassifiedColumns) + : new Set(REVIEW_ROLES.flatMap((role) => [...columnsWithFlag(ir, role)])), + ); + } catch { + misclassifiedLanes.set(entry.id, new Set(misclassifiedColumns)); + } + } const misclassified = tasks.filter((t) => - t.column === "in-review" && + (misclassifiedLanes.get(t.id) ?? misclassifiedColumns).has(t.column) && !t.paused && t.status === "failed" && isNoTaskDoneFailure(t) && @@ -11461,18 +12067,48 @@ export class SelfHealingManager extends SelfHealingGitEvidence { async auditNoCommitsExpectedCandidates(): Promise { try { - const inReviewTasks = await this.store.listTasks({ column: "in-review", slim: true }); + /* + FNXC:WorkflowResolvedColumns 2026-07-31-15:30 (the query-filter class, twenty-eighth sweep): + Audits cards that finished every step but pushed NO commits — either a legitimately commit-free task + that never declared itself so, or work that silently produced nothing. The literal read meant that on + a renamed board only the `no_commits` ERROR path fed the audit, so a card sitting quietly in a + renamed review lane with zero commits and no error was never flagged. + + The lane verdict below converts with it; the failed-task read beside it is already lane-independent. + */ + const noCommitsColumns = await resolveProjectColumnsForRoles(this.store, REVIEW_ROLES); + const noCommitsById = new Map(); + for (const column of noCommitsColumns) { + for (const entry of await this.store.listTasks({ column, slim: true })) noCommitsById.set(entry.id, entry); + } + const inReviewTasks = [...noCommitsById.values()]; const allTasks = await this.store.listTasks({ slim: true }); const failedTasks = allTasks.filter((task) => task.status === "failed"); const candidateMap = new Map(); for (const task of [...inReviewTasks, ...failedTasks]) { candidateMap.set(task.id, task); } + /* NARROW WHEN THE CARD CAN ANSWER, BROAD WHEN IT CANNOT (#2891). Covers the failed-task rows too, + which arrive from the lane-independent read above. */ + const noCommitsLanes = new Map>(); + for (const entry of candidateMap.values()) { + try { + const { ir, source } = await resolveWorkflowIrForTaskWithProvenance(this.store, entry.id); + noCommitsLanes.set( + entry.id, + source === "default" + ? new Set(noCommitsColumns) + : new Set(REVIEW_ROLES.flatMap((role) => [...columnsWithFlag(ir, role)])), + ); + } catch { + noCommitsLanes.set(entry.id, new Set(noCommitsColumns)); + } + } const candidates = [...candidateMap.values()].filter((task) => { if (task.noCommitsExpected === true) return false; if (task.steps.length === 0 || !task.steps.every((step) => step.status === "done" || step.status === "skipped")) return false; const noCommitsError = typeof task.error === "string" && /no_commits/i.test(task.error); - return task.column === "in-review" || noCommitsError; + return (noCommitsLanes.get(task.id) ?? noCommitsColumns).has(task.column) || noCommitsError; }); if (candidates.length === 0) return 0; @@ -11601,13 +12237,41 @@ export class SelfHealingManager extends SelfHealingGitEvidence { } try { - const tasks = await this.store.listTasks({ column: "in-progress", slim: true }); + /* + FNXC:WorkflowResolvedColumns 2026-07-31-07:40 (the query-filter class, eighteenth sweep): + A card holding a wip slot with NO worktree, NO branch and no step started — nothing is running and + nothing will. The literal read meant that on a renamed board it was never found, so the card kept + its slot indefinitely and the capacity it holds is denied to work that could actually run. + + The `task.column !== "in-progress"` check was redundant while the query pinned the column; under a + resolved read it becomes the per-card verdict, so it converts rather than being deleted. + */ + const limboWipColumns = await resolveProjectColumnsForRoles(this.store, ["countsTowardWip"]); + const limboById = new Map(); + for (const column of limboWipColumns) { + for (const entry of await this.store.listTasks({ column, slim: true })) limboById.set(entry.id, entry); + } + const tasks = [...limboById.values()]; + /* + NARROW WHEN THE CARD CAN ANSWER, BROAD WHEN IT CANNOT (#2891): `resolveWorkflowIrForTask` + SUBSTITUTES the built-in IR rather than failing, so an unreadable selection would otherwise reject + the very card the project-scoped query just admitted from a renamed lane. + */ + const limboLanes = new Map>(); + for (const entry of tasks) { + try { + const { ir, source } = await resolveWorkflowIrForTaskWithProvenance(this.store, entry.id); + limboLanes.set(entry.id, source === "default" ? new Set(limboWipColumns) : new Set(columnsWithFlag(ir, "countsTowardWip"))); + } catch { + limboLanes.set(entry.id, new Set(limboWipColumns)); + } + } const executingIds = this.options.getExecutingTaskIds?.() ?? new Set(); const activeHeartbeatTaskIds = await this.listActiveHeartbeatTaskIds(); const now = Date.now(); const stranded = tasks.filter((task) => { - if (task.column !== "in-progress" || task.paused) { + if (!(limboLanes.get(task.id) ?? limboWipColumns).has(task.column) || task.paused) { return false; } const hasMissingWorktreePath = typeof task.worktree === "string" && task.worktree.length > 0 && !existsSync(task.worktree); @@ -11767,12 +12431,38 @@ export class SelfHealingManager extends SelfHealingGitEvidence { */ async recoverOrphanedExecutions(): Promise { try { - const tasks = await this.store.listTasks({ column: "in-progress", slim: true }); + /* + FNXC:WorkflowResolvedColumns 2026-07-31-08:10 (the query-filter class, nineteenth sweep): + WHAT THIS SWEEP RESTORES IS VISIBILITY, NOT A REPAIR. It takes no lifecycle action — it only emits + `task:orphan-detected-no-action` so an operator can see a wip card with no live session behind it. + The literal read meant that on a renamed board the event was never emitted, so the one signal + pointing at an orphaned execution was silently absent. Worth stating because the rest of this series + fixes stalls; this one fixes a blind spot. + + The `t.column !== "in-progress"` check was redundant while the query pinned the column; under a + resolved read it becomes the per-card verdict, so it converts rather than being deleted. + */ + const orphanWipColumns = await resolveProjectColumnsForRoles(this.store, ["countsTowardWip"]); + const orphanExecById = new Map(); + for (const column of orphanWipColumns) { + for (const entry of await this.store.listTasks({ column, slim: true })) orphanExecById.set(entry.id, entry); + } + const tasks = [...orphanExecById.values()]; + /* NARROW WHEN THE CARD CAN ANSWER, BROAD WHEN IT CANNOT (#2891). */ + const orphanExecLanes = new Map>(); + for (const entry of tasks) { + try { + const { ir, source } = await resolveWorkflowIrForTaskWithProvenance(this.store, entry.id); + orphanExecLanes.set(entry.id, source === "default" ? new Set(orphanWipColumns) : new Set(columnsWithFlag(ir, "countsTowardWip"))); + } catch { + orphanExecLanes.set(entry.id, new Set(orphanWipColumns)); + } + } const executingIds = this.options.getExecutingTaskIds?.() ?? new Set(); const now = Date.now(); const orphaned = tasks.filter((t) => { - if (t.column !== "in-progress" || t.paused || executingIds.has(t.id) || isTaskWorkComplete(t)) { + if (!(orphanExecLanes.get(t.id) ?? orphanWipColumns).has(t.column) || t.paused || executingIds.has(t.id) || isTaskWorkComplete(t)) { return false; } const staleness = now - new Date(t.updatedAt).getTime(); @@ -11847,13 +12537,37 @@ export class SelfHealingManager extends SelfHealingGitEvidence { return 0; } - const tasks = await this.store.listTasks({ column: "in-progress", slim: true }); + /* + FNXC:WorkflowResolvedColumns 2026-07-31-08:40 (the query-filter class, twentieth sweep): + Reattaches a DURABLE AGENT to a task it is still assigned to but has stopped executing. The literal + read meant that on a renamed board the reattach never fired, so the agent's own assignment was + never resumed and the card sat assigned-but-idle — visibly owned by an agent that had gone quiet. + + The `task.column !== "in-progress"` check was redundant while the query pinned the column; under a + resolved read it becomes the per-card verdict, so it converts rather than being deleted. + */ + const reattachWipColumns = await resolveProjectColumnsForRoles(this.store, ["countsTowardWip"]); + const reattachById = new Map(); + for (const column of reattachWipColumns) { + for (const entry of await this.store.listTasks({ column, slim: true })) reattachById.set(entry.id, entry); + } + const tasks = [...reattachById.values()]; + /* NARROW WHEN THE CARD CAN ANSWER, BROAD WHEN IT CANNOT (#2891). */ + const reattachLanes = new Map>(); + for (const entry of tasks) { + try { + const { ir, source } = await resolveWorkflowIrForTaskWithProvenance(this.store, entry.id); + reattachLanes.set(entry.id, source === "default" ? new Set(reattachWipColumns) : new Set(columnsWithFlag(ir, "countsTowardWip"))); + } catch { + reattachLanes.set(entry.id, new Set(reattachWipColumns)); + } + } const executingIds = this.options.getExecutingTaskIds?.() ?? new Set(); const now = Date.now(); const candidates: Task[] = []; for (const task of tasks) { - if (task.column !== "in-progress") continue; + if (!(reattachLanes.get(task.id) ?? reattachWipColumns).has(task.column)) continue; if (task.paused || task.deletedAt) continue; if (!task.assignedAgentId) continue; if (executingIds.has(task.id)) continue; @@ -12673,11 +13387,35 @@ export class SelfHealingManager extends SelfHealingGitEvidence { */ async recoverNoProgressNoTaskDoneFailures(): Promise { try { - const tasks = await this.store.listTasks({ column: "in-progress", slim: true }); + /* + FNXC:WorkflowResolvedColumns 2026-07-31-16:05 (the query-filter class, twenty-ninth sweep): + A wip card the executor failed for "no fn_task_done" that made NO step progress and left no git + work — nothing to salvage, so it is safe to requeue. The literal read meant that on a renamed board + it was never requeued, so a card that produced nothing sat failed while still holding its wip slot. + + The per-card verdict below converts with it. No second pair; verified with the derived ratchet + from #2879. + */ + const noProgressColumns = await resolveProjectColumnsForRoles(this.store, ["countsTowardWip"]); + const noProgressById = new Map(); + for (const column of noProgressColumns) { + for (const entry of await this.store.listTasks({ column, slim: true })) noProgressById.set(entry.id, entry); + } + const tasks = [...noProgressById.values()]; + /* NARROW WHEN THE CARD CAN ANSWER, BROAD WHEN IT CANNOT (#2891). */ + const noProgressLanes = new Map>(); + for (const entry of tasks) { + try { + const { ir, source } = await resolveWorkflowIrForTaskWithProvenance(this.store, entry.id); + noProgressLanes.set(entry.id, source === "default" ? new Set(noProgressColumns) : new Set(columnsWithFlag(ir, "countsTowardWip"))); + } catch { + noProgressLanes.set(entry.id, new Set(noProgressColumns)); + } + } const executingIds = this.options.getExecutingTaskIds?.() ?? new Set(); const candidates = tasks.filter((task) => - task.column === "in-progress" && + (noProgressLanes.get(task.id) ?? noProgressColumns).has(task.column) && task.status === "failed" && isNoTaskDoneFailure(task) && !task.paused && @@ -12757,7 +13495,20 @@ export class SelfHealingManager extends SelfHealingGitEvidence { try { const settings = await this.store.getSettings(); if (settings.globalPause || settings.enginePaused) return 0; - const tasks = await this.store.listTasks({ column: "in-review", slim: true }); + /* + FNXC:WorkflowResolvedColumns 2026-07-31-14:45 (the query-filter class, twenty-seventh sweep): + THE NOTE BELOW SAID THIS WAS UNFIXABLE. It called the literal query "unfixable without a + project-level lane resolution before the read" — which is precisely what + `resolveProjectColumnsForRoles` provides; it did not exist when that note was written. The wiring + below was therefore doing real work only for boards whose review lane still happens to be called + `in-review`. Fully renamed boards never reached it. + */ + const missingWtColumns = await resolveProjectColumnsForRoles(this.store, REVIEW_ROLES); + const missingWtById = new Map(); + for (const column of missingWtColumns) { + for (const entry of await this.store.listTasks({ column, slim: true })) missingWtById.set(entry.id, entry); + } + const tasks = [...missingWtById.values()]; /* FNXC:WorkflowLifecycleColumns 2026-08-02-20:20 (PR #2745 review — greptile P1: "recovery lanes are not wired", and it is right): @@ -12772,9 +13523,22 @@ export class SelfHealingManager extends SelfHealingGitEvidence { workflow (the common partial-rename case) and for the merge-active variant. */ const recoveryIrCache = new Map(); + /* + FNXC:WorkflowResolvedColumns 2026-07-31-14:50 (the ARITY trap, same seam): + These classifiers take a MEMBERSHIP set, and `resolveTaskLifecycleColumns().review` is the FIRST + column per role — so a board declaring more than one review column contributed only one of them and + a card sitting in the others read as not-in-review. `columnsWithFlag` over the three review roles is + the membership answer; the legacy id stays unioned for a board mid-rename. + */ const reviewColumnsFor = async (taskId: string): Promise> => { - const lifecycle = await resolveTaskLifecycleColumns(this.store, taskId, recoveryIrCache); - return new Set([lifecycle?.review ?? "in-review", "in-review"]); + const columns = new Set(["in-review"]); + try { + const ir = await resolveWorkflowIrForTask(this.store, taskId, recoveryIrCache); + if (ir) { + for (const role of REVIEW_ROLES) for (const id of columnsWithFlag(ir, role)) columns.add(id); + } + } catch { /* degraded: the legacy id above still answers */ } + return columns; }; const candidateChecks = await Promise.all(tasks.map(async (task) => { const reviewColumns = await reviewColumnsFor(task.id); @@ -12907,10 +13671,40 @@ export class SelfHealingManager extends SelfHealingGitEvidence { try { const settings = await this.store.getSettings(); if (settings.globalPause || settings.enginePaused) return 0; - const tasks = await this.store.listTasks({ column: "in-review", slim: true }); + /* + FNXC:WorkflowResolvedColumns 2026-07-31-16:35 (the query-filter class, thirtieth sweep): + A review card failed for "no fn_task_done" that DID make step progress — real work exists, so it is + retried rather than discarded. The literal read meant that on a renamed board the retry never fired, + so partially-completed work was parked failed with its retry budget untouched: the budget exists + precisely to avoid losing that work, and it was never spent. + + The per-card verdict below converts with it. No second pair; verified with the derived ratchet + from #2879. + */ + const partialColumns = await resolveProjectColumnsForRoles(this.store, REVIEW_ROLES); + const partialById = new Map(); + for (const column of partialColumns) { + for (const entry of await this.store.listTasks({ column, slim: true })) partialById.set(entry.id, entry); + } + const tasks = [...partialById.values()]; + /* NARROW WHEN THE CARD CAN ANSWER, BROAD WHEN IT CANNOT (#2891). */ + const partialLanes = new Map>(); + for (const entry of tasks) { + try { + const { ir, source } = await resolveWorkflowIrForTaskWithProvenance(this.store, entry.id); + partialLanes.set( + entry.id, + source === "default" + ? new Set(partialColumns) + : new Set(REVIEW_ROLES.flatMap((role) => [...columnsWithFlag(ir, role)])), + ); + } catch { + partialLanes.set(entry.id, new Set(partialColumns)); + } + } const candidates = tasks.filter((task) => - task.column === "in-review" && + (partialLanes.get(task.id) ?? partialColumns).has(task.column) && allowsAutoMergeProcessing(task, settings) && task.status === "failed" && isNoTaskDoneFailure(task) && diff --git a/scripts/lib/lifecycle-column-census-baseline.json b/scripts/lib/lifecycle-column-census-baseline.json index b1bf22769d..8894a7677f 100644 --- a/scripts/lib/lifecycle-column-census-baseline.json +++ b/scripts/lib/lifecycle-column-census-baseline.json @@ -1,7 +1,7 @@ { "generatedFrom": "node scripts/lifecycle-column-census.mjs --strict --update-baseline", "byFile": { - "packages/engine/src/self-healing.ts": 84, + "packages/engine/src/self-healing.ts": 56, "packages/engine/src/scheduler.ts": 12, "packages/engine/src/executor.ts": 8, "packages/core/src/task-store/async-comments-attachments.ts": 6, @@ -40,6 +40,7 @@ }, "deliberateByFile": { "packages/dashboard/app/components/TaskContextMenu.tsx\u0000in-review": 3, + "packages/engine/src/self-healing.ts\u0000in-review": 3, "packages/core/src/live-agent-count.ts\u0000in-progress": 2, "packages/core/src/live-agent-count.ts\u0000in-review": 2, "packages/core/src/store.ts\u0000in-review": 2, @@ -58,7 +59,7 @@ "packages/engine/src/scheduler.ts\u0000done": 2, "packages/engine/src/scheduler.ts\u0000in-progress": 2, "packages/engine/src/scheduler.ts\u0000in-review": 2, - "packages/engine/src/self-healing.ts\u0000in-review": 2, + "packages/engine/src/self-healing.ts\u0000done": 2, "packages/engine/src/usage-limit-detector.ts\u0000archived": 2, "packages/engine/src/usage-limit-detector.ts\u0000done": 2, "plugins/fusion-plugin-reports/src/store/report-store.ts\u0000archived": 2, @@ -120,7 +121,7 @@ "packages/engine/src/hold-release.ts\u0000in-review": 1, "packages/engine/src/project-engine.ts\u0000in-review": 1, "packages/engine/src/scheduler.ts\u0000todo": 1, - "packages/engine/src/self-healing.ts\u0000done": 1, + "packages/engine/src/self-healing.ts\u0000archived": 1, "packages/engine/src/triage.ts\u0000triage": 1, "plugins/fusion-plugin-even-cards/src/cards/board-cards.ts\u0000archived": 1, "plugins/fusion-plugin-even-cards/src/cards/board-cards.ts\u0000done": 1, @@ -128,9 +129,9 @@ "plugins/fusion-plugin-reports/src/store/report-types.ts\u0000archived": 1 }, "queryByFile": { - "packages/engine/src/self-healing.ts": 32, "packages/core/src/task-store/async-persistence.ts": 2, "packages/core/src/task-store/merge-queue-ops.ts": 2, + "packages/engine/src/self-healing.ts": 2, "packages/core/src/async-mission-store.ts": 1, "packages/core/src/task-store/archive-lifecycle-2.ts": 1, "packages/core/src/task-store/async-archive-lineage.ts": 1,