diff --git a/.changeset/self-healing-done-integrity-query.md b/.changeset/self-healing-done-integrity-query.md new file mode 100644 index 0000000000..3f530fd402 --- /dev/null +++ b/.changeset/self-healing-done-integrity-query.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Merge-evidence repair, already-merged rescue and deadlock recovery run on renamed boards. +category: fix +dev: `recoverAlreadyMergedReviewTasks` had the same defect on the review lane — a card whose merge succeeded stayed parked with status=failed. `reconcileDoneTaskIntegrity` queried `listTasks({ column: "done" })`, which returns nothing on a renamed board, so the sweep never executed. It now resolves the project's complete lanes via `resolveProjectColumnsForRoles` and queries each, unioned with the legacy id. diff --git a/docs/solutions/architecture-patterns/self-healing-sweeps-are-blind-on-a-renamed-board.md b/docs/solutions/architecture-patterns/self-healing-sweeps-are-blind-on-a-renamed-board.md index 4bf35d1276..4475ccd767 100644 --- a/docs/solutions/architecture-patterns/self-healing-sweeps-are-blind-on-a-renamed-board.md +++ b/docs/solutions/architecture-patterns/self-healing-sweeps-are-blind-on-a-renamed-board.md @@ -84,6 +84,103 @@ The two shapes that work: - When you touch a self-healing test, make its `listTasks` fake **honor `options.column`**. That is a one-line change per fake and it converts this whole class from invisible to failing-loudly. - Read `self-healing.ts: N` in the census as "N comparisons", never as "N remaining defects" — in this file the two numbers are not related. +## Converting a sweep: the four-part shape, and the part that is easy to miss + +Four sweeps are converted (`reconcileDoneTaskIntegrity`, `recoverAlreadyMergedReviewTasks`, +`recoverStuckMergeDeadlocks`, `recoverInterruptedMergingTasks`). They are deliberately identical, because +the second one drifted from the first — it was written from the pre-review version and reproduced a flaw +review had already fixed one commit earlier. + +1. **Read** — `resolveProjectColumnsForRoles(store, ROLES)`, then query each column and dedupe by id. A + read happens before any task is in hand, so there is nothing to resolve a per-task lane from. The + legacy ids are unioned in, so a board mid-rename whose rows are still stored under the old id is not + skipped. +2. **Verdict** — resolved per card against **its own** workflow. Widening the read and widening the + verdict are different decisions: a missed row is invisible, a wrong row is a write. Using the project + union as a per-card test claims a card because *some other board* calls its column that role. +3. **Provenance** — `resolveWorkflowIrForTaskWithProvenance`, because the resolver **substitutes** the + built-in IR rather than failing. Without it, `columnsWithFlag(ir, role).length > 0` reads as "this card + answered" when nobody did. It does not change the verdict (measured: identical, since the built-in lane + already *is* the legacy id) — it makes the unrepaired card **reportable** instead of invisible. +4. **The log strings.** Widening a query silently invalidates every message naming the old literal. + `recoverInterruptedMergingTasks` logged `"stale merging task(s) in in-review"` after its read covered + several lanes — an operator debugging a renamed board would have been told the wrong column. +5. **The guards the widened query now ACTIVATES.** This is the one that bites hardest, because it makes a + conversion look complete while delivering nothing. + +## Converting a query is also an activation + +A guard downstream of a literal query is **unreachable on a renamed board** — the sweep never hands it a +row. Unreachable and correct are indistinguishable from the outside, which is exactly why these guards sit +unwired for years without anyone noticing. + +Widen the read and they become reachable for the first time. `recoverMergeableReviewTasks` called +`getTaskMergeBlocker(t)` unwired; the moment its query found renamed-board cards, that call declined every +one of them. **The sweep would have found the cards and refused them** — strictly worse than not finding +them, because it looks fixed. + +Measured across `self-healing.ts`: **4 sweeps hold both a literal column query and a genuinely unwired lane +guard**; 32 hold a literal query with no such guard. + +```text + finalizeNoOpReviewTasks getTaskMergeBlocker + recoverOrphanOnlyScopeViolations getTaskHardMergeBlocker + recoverPostDoneNonContinuableWedge getTaskHardMergeBlocker + recoverCompletionHandoffLimbo getTaskMergeBlocker +``` + +**That number was 6 in the first version of this doc, and both extra rows were my scanner lying.** Worth +recording, because the scan is the thing the next worker will re-run: + +- `recoverAlreadyMergedReviewTasks` was reported unwired **after I had wired it** — the options object sits + on the call's *last* line and the check read only the *first*. A multi-line call needs its whole span. +- `recoverReviewTasksWithFailedPreMergeSteps` was reported with a second unwired guard that is **prose + inside a doc comment** (`"because getTaskMergeBlocker() correctly blocks incomplete steps"`). + +Both are the same failure this program keeps documenting about the census: **an instrument that matches +syntax, read as if it measured meaning.** I published 6, corrected to 5, and only reached 4 by re-checking +the correction itself. Re-run the scan with the span-aware and comment-skipping form, and spot-check each +row against the source before acting on it. + +The scan must also run **before** the query is widened, not after: I converted +`recoverAlreadyMergedReviewTasks` two commits before noticing its guard, so for two commits it found +renamed-board cards and declined them. + +`getTaskHardMergeBlocker` was the blind spot for four of the six: it is a *wrapper*, it had no lane +parameter at all, and every one of its callers sat behind a literal query. Nothing exercised it. + +Parts 4 and 5 are both invisible to the census — one is string contents, the other is reachability — and +each of the **43 remaining queries** carries both risks. + +## Testing a sweep: assert what happens ONLY when the change is correct + +Four assertions on this branch were vacuous — they passed with the fix reverted — and all four shared one +shape: **I asserted something the sweep does regardless of the code under test.** The surface presentation +differed every time, which is why recognising it took four rounds: + +| the assertion | why it proved nothing | +| --- | --- | +| `commitSha` stayed undefined | the write needs a real git repo, so it never happens in the fixture either way | +| `toContain("must be in")` | **two** guards can refuse; the other one caught the card and produced the same substring | +| a warn was emitted | the sweep warns on a path unrelated to the conversion | +| `getSettings` was called | the sweep calls it **unconditionally on its first line** | + +The reliable question is not *"did something observable happen"* but *"what happens **only** when this +change is correct"*. In a self-healing sweep that is almost always **candidacy** — the first thing that +runs once per accepted row, after the filter: + +```ts +// getSettings runs before the filter → useless +// isBranchAheadOfBase runs once per CANDIDATE → separates accepted from declined +const aheadCheck = vi.spyOn(manager as never, "isBranchAheadOfBase").mockResolvedValue(false); +``` + +**Asserting the query is only safe when nothing downstream can veto.** Once a sweep has a lane-sensitive +guard (part 5), a query-only assertion passes while the guard silently rejects every row — which is +precisely the bug being fixed. Where a guard exists, assert the end-to-end outcome instead. + +And run the revert. Every one of the four above was found that way and none by reading. + ## Related - `docs/solutions/test-failures/optional-flags-seam-hides-unconverted-column-guards.md` — the same lesson one level down: the census counts syntax, and a green suite that omits the new parameter carries no information about the change. diff --git a/packages/core/src/task-merge.ts b/packages/core/src/task-merge.ts index ac66409b81..3ad58c3adc 100644 --- a/packages/core/src/task-merge.ts +++ b/packages/core/src/task-merge.ts @@ -349,8 +349,20 @@ export function getLatestFailedPreMergeReviewStep( })[0]; } +/* +FNXC:WorkflowResolvedColumns 2026-07-30-20:50: +`reviewColumns` threads straight through to `getTaskMergeBlocker`, for the same reason that helper takes +it: omitted, the identity check falls back to the literal `in-review` and refuses a card sitting in its +own board's review lane. + +This wrapper was the blind spot behind a whole class: its callers are self-healing sweeps whose column +QUERY was also a literal, so the unwired check was unreachable and therefore unnoticed. Widening a +sweep's query ACTIVATES it — the sweep starts finding renamed-board cards and this then declines every +one. Optional, so no caller changes behaviour until it passes the set. +*/ export function getTaskHardMergeBlocker( task: Pick, + options: { reviewColumns?: ReadonlySet } = {}, ): string | undefined { return getTaskMergeBlocker({ ...task, @@ -358,7 +370,7 @@ export function getTaskHardMergeBlocker( paused: false, status: task.status === "failed" ? undefined : task.status, error: undefined, - }); + }, { reviewColumns: options.reviewColumns }); } export function getTaskDoneBypassBlocker( 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 bfd742e0e0..e96ee00fc1 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 @@ -77,6 +77,15 @@ function productionFaithfulStore(tasks: Task[]) { getTaskWorkflowSelectionAsync: vi.fn(async () => ({ workflowId: "self-healing-lifecycle", stepIds: [] })), getTaskWorkflowSelection: vi.fn(() => ({ workflowId: "self-healing-lifecycle", stepIds: [] })), getWorkflowDefinition: vi.fn(async (id: string) => (id === "self-healing-lifecycle" ? { ir: RENAMED_IR } : undefined)), + /* + FNXC:WorkflowResolvedColumns 2026-07-30-16:50 (the fix landed; this fake had to grow to see it): + `resolveProjectColumnsForRoles` — the seam the sweeps now use — reads `listWorkflowDefinitions()`, + the PROJECT's workflows, because a query runs before any task is in hand. Without this method the + helper degrades to the legacy ids and the sweep still queries only `done`, so this file kept passing + against the FIXED code and reported nothing. A ratchet whose fake cannot reach the new seam stops + being a ratchet silently. + */ + listWorkflowDefinitions: vi.fn(async () => [{ ir: RENAMED_IR }]), }) as unknown as TaskStore & EventEmitter; return { store, listTasks }; } @@ -112,7 +121,7 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th expect(lifecycle?.complete).not.toBe("done"); }); - it("KNOWN DEFECT: the done-integrity sweep asks for the literal `done`, so a RENAMED board yields nothing", async () => { + 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 re-asserts `task.column === "done"` on the rows it gets back. The census counts that re-assertion; @@ -124,14 +133,63 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th const { store, listTasks } = productionFaithfulStore([shippedCard()]); const manager = new SelfHealingManager(store, { rootDir: "/repo" }); - expect(await manager.reconcileDoneTaskIntegrity()).toBe(0); + await manager.reconcileDoneTaskIntegrity(); - // The query asked for the legacy literal, NOT this workflow's resolved complete lane. + /* + THE ASSERTION THIS FILE WAS BUILT TO FLIP. It used to read `not.toHaveBeenCalledWith(… complete)` + and passed because the sweep only ever asked for the literal. The sweep now resolves the project's + complete lanes and queries each, so the renamed lane IS asked for. + + `done` is STILL expected: `resolveProjectColumnsForRoles` unions the legacy ids deliberately, so a + board mid-rename whose rows are still stored under the old id is not skipped. Over-inclusion costs + one extra query the caller then filters; under-inclusion is invisible. + */ + expect(listTasks).toHaveBeenCalledWith(expect.objectContaining({ column: RENAMED_VOCAB.complete })); expect(listTasks).toHaveBeenCalledWith(expect.objectContaining({ column: "done" })); - expect(listTasks).not.toHaveBeenCalledWith(expect.objectContaining({ column: RENAMED_VOCAB.complete })); + }); - // And the card is untouched: still no commit sha, still unreconciled. - expect((await store.getTask("FN-BLIND"))?.mergeDetails?.commitSha).toBeUndefined(); + /* + FNXC:WorkflowResolvedColumns 2026-07-30-18:05 (#2838 review — greptile P1): + + A CARD WHOSE WORKFLOW CANNOT BE RESOLVED MUST NOT BE MISTAKEN FOR ONE THAT ANSWERED. + + `resolveWorkflowIrForTask` does not throw when a task's selection is unresolvable — it SUBSTITUTES + the built-in coding IR, whose complete lane is `done`. The candidate filter therefore saw a non-empty + `columnsWithFlag(ir, "complete")` and treated the built-in vocabulary as this card's own answer, so a + renamed-lane card was rejected on every sweep and its missing merge evidence stayed unrepaired + forever. The provenance form separates the two, and only a real selection counts as an answer. + + WHY THE STORE FAKE DROPS ONLY THE SELECTION READERS. That is precisely the production shape being + modelled: the workflow DEFINITION is fine, the card's link to it is what cannot be read. Deleting the + definition instead would take a different branch and prove nothing about this one. + */ + it("a card whose workflow selection cannot be resolved is not judged by the BUILT-IN complete lane", async () => { + const { store } = productionFaithfulStore([shippedCard()]); + /* No selection for this card: `resolveWorkflowIrForTaskWithProvenance` reports source "default". */ + (store as unknown as { getTaskWorkflowSelectionAsync: unknown }).getTaskWorkflowSelectionAsync = + vi.fn(async () => undefined); + (store as unknown as { getTaskWorkflowSelection: unknown }).getTaskWorkflowSelection = vi.fn(() => undefined); + const manager = new SelfHealingManager(store, { rootDir: "/repo" }); + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + + let warned = ""; + try { + await manager.reconcileDoneTaskIntegrity(); + } finally { + /* Read BEFORE restoring: `mockRestore` clears the recorded calls, so reading afterwards yields + an empty string and the assertion fails for a reason that has nothing to do with the code. */ + warned = warn.mock.calls.map((call) => String(call[0])).join("\n"); + warn.mockRestore(); + } + + /* + The observable claim: the card is REPORTED as unresolvable rather than silently discarded. The + verdict itself is deliberately unchanged — a sweep that WRITES merge evidence must not guess a lane + — so asserting "it got repaired" would be asserting the wrong fix. What must not survive is the + silence, which is what made this unrepairable-forever instead of merely unrepaired. + */ + 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 () => { @@ -160,4 +218,443 @@ 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. + + Two boards in one project: board A calls its COMPLETE lane `shipped`; board B calls its WIP lane + `shipped`. The project union therefore contains `shipped`, which is correct for the READ — board A's + finished cards must be found. Using that same set as the per-card test claims board B's card as + complete because SOME OTHER workflow calls that column complete, and this sweep WRITES merge evidence + onto whatever it accepts. + + Widening the read and widening the verdict are different decisions: a missed row is invisible, a wrong + row is a write. + + REVERT CHECK, measured: re-asserting `completeColumns.has(task.column)` instead of resolving each card + against its own workflow fails this case — the mid-implementation card is reconciled. + */ + it("does not claim a card whose OWN workflow calls its column WIP, even when another board calls it complete", async () => { + const boardB = { + version: "v2", id: "board-b", name: "board b", + columns: [ + { id: "planning", name: "Planning", traits: [{ trait: "intake" }, { trait: "hold", config: { release: "capacity" } }] }, + /* Same id as board A's COMPLETE lane, but here it is WIP. */ + { id: "shipped", name: "Building", traits: [{ trait: "wip", config: { limitSetting: "maxConcurrent" } }] }, + { id: "closed", name: "Done", traits: [{ trait: "complete" }] }, + ], + nodes: [{ id: "start", kind: "start", column: "planning" }], + edges: [], + } as unknown as WorkflowIr; + + const midImplementation = { ...shippedCard(), id: "FN-WIP" } as Task; + const tasksById = new Map([[midImplementation.id, midImplementation]]); + const store = Object.assign(new EventEmitter(), { + getSettings: vi.fn(async () => ({ globalPause: false, enginePaused: false }) as Settings), + listTasks: vi.fn(async (options?: { column?: string }) => { + const all = [...tasksById.values()]; + return options?.column === undefined ? all : all.filter((t) => t.column === options.column); + }), + getTask: vi.fn(async (id: string) => tasksById.get(id)), + updateTask: vi.fn(async (id: string, patch: Partial) => { + tasksById.set(id, { ...tasksById.get(id)!, ...patch } as Task); + return tasksById.get(id)!; + }), + /* The PROJECT declares both boards, so `shipped` is legitimately in the union. */ + listWorkflowDefinitions: vi.fn(async () => [{ ir: RENAMED_IR }, { ir: boardB }]), + /* But THIS card belongs to board B, where `shipped` is WIP. */ + getTaskWorkflowSelectionAsync: vi.fn(async () => ({ workflowId: "board-b", stepIds: [] })), + getTaskWorkflowSelection: vi.fn(() => ({ workflowId: "board-b", stepIds: [] })), + getWorkflowDefinition: vi.fn(async (id: string) => (id === "board-b" ? { ir: boardB } : { ir: RENAMED_IR })), + }) as unknown as TaskStore & EventEmitter; + + const manager = new SelfHealingManager(store, { rootDir: "/repo" }); + await manager.reconcileDoneTaskIntegrity(); + + /* + ASSERTS CANDIDACY, not the write. My first version asserted `commitSha` stayed undefined, which is + true either way here — the write needs a real git repo, so it never happens in this fixture and the + assertion could not distinguish accepted from rejected. The revert passed and exposed it. + + `reconcileDoneTaskIntegrity` returns BEFORE `getSettings()` when the candidate list is empty + (`if (candidates.length === 0) return 0;`), so that call is the observable proof that this card was + NOT accepted as complete. + */ + 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 + workflow, and the built-in complete lane IS `done` — so a naive `columnsWithFlag(ir, "complete")` + yields `["done"]` for a card we could not resolve, the legacy branch never fires, and the card is + rejected on every sweep forever. + + Resolution now goes through `...WithProvenance`, so only `source: "selection"` overrules the legacy + check. A card that DOES name a workflow keeps being judged by it; a card that does not falls back to + the legacy id rather than to the built-in board's vocabulary wearing a resolved disguise. + + REVERT CHECK, measured: resolving without provenance fails this case — `ownComplete` becomes `["done"]` + from the built-in default, so the card in `done` with no selection is REJECTED and `getSettings` is + never reached. + */ + it("still repairs a legacy `done` card whose workflow selection is missing", async () => { + const legacyCard = { ...shippedCard(), id: "FN-LEGACY", column: "done" } as Task; + const tasksById = new Map([[legacyCard.id, legacyCard]]); + const store = Object.assign(new EventEmitter(), { + getSettings: vi.fn(async () => ({ globalPause: false, enginePaused: false }) as Settings), + listTasks: vi.fn(async (options?: { column?: string }) => { + const all = [...tasksById.values()]; + return options?.column === undefined ? all : all.filter((t) => t.column === options.column); + }), + getTask: vi.fn(async (id: string) => tasksById.get(id)), + updateTask: vi.fn(async (id: string, patch: Partial) => { + tasksById.set(id, { ...tasksById.get(id)!, ...patch } as Task); + return tasksById.get(id)!; + }), + listWorkflowDefinitions: vi.fn(async () => [{ ir: RENAMED_IR }]), + /* NO selection for this card — the state that makes the resolver guess. */ + getTaskWorkflowSelectionAsync: vi.fn(async () => undefined), + getTaskWorkflowSelection: vi.fn(() => undefined), + getWorkflowDefinition: vi.fn(async () => undefined), + }) as unknown as TaskStore & EventEmitter; + + const manager = new SelfHealingManager(store, { rootDir: "/repo" }); + await manager.reconcileDoneTaskIntegrity(); + + /* 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 + with `status: "failed"`. Its read was `listTasks({ column: "in-review" })`, which returns nothing on a + renamed board — so the rescue never ran and that card stayed stuck permanently. + + Asserts the QUERY, like the done-integrity case above and for the same reason: the outcome is 0 either + way, so only the question asked distinguishes fixed from broken. The per-card verdict uses the pattern + already revert-proven for the other sweep. + + REVERT CHECK, measured: restoring `listTasks({ column: "in-review" })` fails this — the board's own + review lane is never asked for. + */ + it("the already-merged rescue asks for the board's OWN review lane", async () => { + const parked = { + ...shippedCard(), + id: "FN-STUCK", + column: RENAMED_VOCAB.review, + status: "failed", + mergeRetries: 99, + } as unknown as Task; + const { store, listTasks } = productionFaithfulStore([parked]); + + await new SelfHealingManager(store, { rootDir: "/repo" }).recoverAlreadyMergedReviewTasks(); + + expect(listTasks).toHaveBeenCalledWith(expect.objectContaining({ column: RENAMED_VOCAB.review })); + /* 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): + I wrote this sweep before the provenance fix landed on its sibling and reproduced the pre-fix shape + verbatim: `resolveWorkflowIrForTask` SUBSTITUTES the built-in IR rather than failing, so a card whose + workflow could not be resolved was measured against the built-in `in-review`, rejected, and rejected + again on every pass — with nothing recorded. + + The verdict stays conservative (this sweep mutates column AND status). What provenance buys is that the + unrescued card is REPORTED, which is the whole difference between a known gap and an invisible one. + + REVERT CHECK, measured: resolving without provenance fails this — nothing is warned, because + `own.length > 0` reads the substituted built-in lane as an answer. + */ + it("reports an already-merged card whose workflow could not be resolved", async () => { + const parked = { + ...shippedCard(), + id: "FN-UNRESOLVED", + column: RENAMED_VOCAB.review, + status: "failed", + mergeRetries: 99, + } as unknown as Task; + const tasksById = new Map([[parked.id, parked]]); + const store = Object.assign(new EventEmitter(), { + getSettings: vi.fn(async () => ({ globalPause: false, enginePaused: false }) as Settings), + listTasks: vi.fn(async (options?: { column?: string }) => { + const all = [...tasksById.values()]; + return options?.column === undefined ? all : all.filter((t) => t.column === options.column); + }), + getTask: vi.fn(async (id: string) => tasksById.get(id)), + updateTask: vi.fn(async () => undefined), + /* The project DOES declare the renamed review lane, so the read finds the card... */ + listWorkflowDefinitions: vi.fn(async () => [{ ir: RENAMED_IR }]), + /* ...but THIS card names no workflow, so its own lane vocabulary is unknown. */ + getTaskWorkflowSelectionAsync: vi.fn(async () => undefined), + getTaskWorkflowSelection: vi.fn(() => undefined), + getWorkflowDefinition: vi.fn(async () => undefined), + }) as unknown as TaskStore & EventEmitter; + + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + let warned = ""; + try { + await new SelfHealingManager(store, { rootDir: "/repo" }).recoverAlreadyMergedReviewTasks(); + warned = warn.mock.calls.map((call) => String(call[0])).join("\n"); + } finally { + warn.mockRestore(); + } + + 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 + for the DEPENDENTS whose blocked state proves the deadlock. All four were literals, so on a renamed + board the sweep saw no candidates AND no dependents — doubly blind. + + Its 2026-07-29-17:40 note reasoned the literal `triage`/`todo` pair was a complete union "and the role + filter below decides which rows count". That held for the default and legacy lineages it considered and + fails on a renamed board, where the reads return nothing and the filter is handed nothing to decide + about. Widening the reads restores the property that note relied on; the filter itself is untouched. + + REVERT CHECK, measured: restoring the literal review read fails this — the board's own review lane is + never asked for. + */ + it("the merge-deadlock recovery asks for the board's OWN review and dependent lanes", async () => { + const parked = { + ...shippedCard(), + id: "FN-DEADLOCK", + column: RENAMED_VOCAB.review, + status: "failed", + mergeRetries: 99, + worktree: "/tmp/wt", + } as unknown as Task; + const { store, listTasks } = productionFaithfulStore([parked]); + + await new SelfHealingManager(store, { rootDir: "/repo" }).recoverStuckMergeDeadlocks(); + + /* Candidates: the board's own review lane, plus the legacy id the union keeps reachable. */ + expect(listTasks).toHaveBeenCalledWith(expect.objectContaining({ column: RENAMED_VOCAB.review })); + expect(listTasks).toHaveBeenCalledWith(expect.objectContaining({ column: "in-review" })); + /* Dependents: the board's own pre-WIP and WIP lanes, not just `triage`/`todo`/`in-progress`. */ + 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 + session behind it. Its read was the literal review lane, so on a renamed board that task sat in + `merging` indefinitely. + + Also asserts the LOG, because the old message hardcoded "in in-review" and would have reported a lane + the sweep did not search. A message that names the wrong board is its own small lie. + + REVERT CHECK, measured: restoring the literal read fails this — the board's own review lane is never + asked for. + */ + it("the interrupted-merge recovery asks for the board's OWN review lane and names it", async () => { + const stuck = { + ...shippedCard(), + id: "FN-MERGING", + column: RENAMED_VOCAB.review, + status: "merging", + updatedAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(), + } as unknown as Task; + const { store, listTasks } = productionFaithfulStore([stuck]); + (store.getSettings as ReturnType).mockResolvedValue({ + globalPause: false, + enginePaused: false, + taskStuckTimeoutMs: 60_000, + } as Settings); + + await new SelfHealingManager(store, { rootDir: "/repo" }).recoverInterruptedMergingTasks(); + + 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 + literal review lane, so on a renamed board that card sat in review forever. + + THE INTERESTING PART IS DOWNSTREAM. This sweep's filter calls `getTaskMergeBlocker(t)` — previously + UNWIRED, taking the legacy `in-review` default, and harmless only because the literal query meant a + renamed board never reached it. Widening the read makes that guard REACHABLE for the first time, so + left as-is it would refuse every card on exactly the boards this fix is for: found, then declined. + + Converting a query activates every guard downstream of it. This case asserts the end-to-end outcome — + the card is enqueued — precisely because a query-only assertion would have passed while the blocker + silently rejected it. + + REVERT CHECK, measured: dropping `{ reviewColumns }` from the blocker call fails this — the card is + found by the widened read and then refused. + */ + it("enqueues a mergeable card on a RENAMED board, past the now-reachable merge blocker", async () => { + const ready = { + ...shippedCard(), + id: "FN-READY", + column: RENAMED_VOCAB.review, + status: null, + worktree: "/tmp/wt", + steps: [], + mergeDetails: {}, + } as unknown as Task; + const { store } = productionFaithfulStore([ready]); + const enqueueMerge = vi.fn(async () => undefined); + /* Complete the fake: the recovery loop logs before enqueuing, and an incomplete fake turns a real + enqueue into a caught error the assertion cannot see. */ + Object.assign(store, { + enqueueMerge, + isMergeLaneOwned: vi.fn(async () => false), + logEntry: vi.fn(async () => undefined), + recordRunAuditEvent: vi.fn(async () => undefined), + }); + + await new SelfHealingManager(store, { rootDir: "/repo", enqueueMerge } as never) + .recoverMergeableReviewTasks(); + + /* 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): + `recoverReviewTasksWithFailedPreMergeSteps` auto-revives a card parked with a FAILED pre-merge review + step. Its literal read meant that card stayed parked on a renamed board until a human noticed. + + This sweep is the sharpest example of part 5 of the shape. Its filter asks + `blocker !== "task has failed pre-merge workflow steps"` — an EXACT STRING match. Unwired on a renamed + board the blocker returns "task is in 'checking', must be in 'in-review'" instead, so widening the query + alone would have made the sweep find every card and then reject every card. + + Asserts the END-TO-END outcome (the recover callback fires), not the query, precisely because a + query-only assertion passes while the blocker silently rejects. + + REVERT CHECK, measured (each independently): + - literal read restored -> fails, the card is never found + - { reviewColumns } dropped -> fails, the card is found and then rejected by the string compare + */ + it("revives a failed-pre-merge-step card on a RENAMED board, past the now-reachable blocker", async () => { + const parked = { + ...shippedCard(), + id: "FN-FAILEDSTEP", + column: RENAMED_VOCAB.review, + status: null, + worktree: "/tmp/wt", + steps: [], + mergeDetails: {}, + workflowStepResults: [ + { phase: "pre-merge", source: "optional-group", status: "failed", + workflowStepId: "code-review", workflowStepName: "Code Review", + completedAt: new Date().toISOString() }, + ], + } as unknown as Task; + const { store } = productionFaithfulStore([parked]); + Object.assign(store, { logEntry: vi.fn(async () => undefined) }); + const recoverFailedPreMergeStep = vi.fn(async () => true); + + await new SelfHealingManager(store, { rootDir: "/repo", recoverFailedPreMergeStep } as never) + .recoverReviewTasksWithFailedPreMergeSteps(); + + 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 + merge. Its literal read meant such a task sat in review forever on a renamed board. + + One of the four sweeps holding both a literal query and an unwired `getTaskMergeBlocker`, so the guard + is wired in the same change: widening the read alone would have found the card and declined it. + + REVERT CHECK, measured (each independently): + - literal read restored -> the card is never found + - { reviewColumns } dropped -> the card is found and then declined by the blocker + */ + it("finalizes a no-op card on a RENAMED board, past the now-reachable blocker", async () => { + const noOp = { + ...shippedCard(), + id: "FN-NOOP", + column: RENAMED_VOCAB.review, + status: null, + worktree: "/tmp/wt", + steps: [], + mergeDetails: {}, + } as unknown as Task; + const { store, listTasks } = productionFaithfulStore([noOp]); + Object.assign(store, { logEntry: vi.fn(async () => undefined) }); + + const manager = new SelfHealingManager(store, { rootDir: "/repo" }); + /* + ASSERTS CANDIDACY. My first version asserted `getSettings` was called — which the sweep does + unconditionally on its first line, so it proved nothing. `isBranchAheadOfBase` runs ONCE PER + CANDIDATE, after the filter, so it is the first observable that separates "found and accepted" + from "found and declined by the blocker". + */ + const aheadCheck = vi + .spyOn(manager as unknown as { isBranchAheadOfBase: (t: Task, b: string) => Promise }, + "isBranchAheadOfBase") + .mockResolvedValue(false); + + await manager.finalizeNoOpReviewTasks(); + + 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 + merge queue already owns it. Its literal read meant such a task stayed wedged on a renamed board. + + ASSERTS CANDIDACY, per the rule this file's siblings had to learn four times: `isMergeLaneOwned` runs + once per row that has already passed BOTH the lane test and the merge blocker, so it is the first + observable separating "found and accepted" from "found and skipped". `getSettings` would not do — the + sweep calls it on its first line. + + REVERT CHECK, measured (each independently): + - literal read restored -> never reached, the card is not found + - { reviewColumns } dropped -> the card is found and then skipped by the blocker + */ + it("reaches a limbo card on a RENAMED board, past the now-reachable blocker", async () => { + const wedged = { + ...shippedCard(), + id: "FN-LIMBO", + column: RENAMED_VOCAB.review, + status: null, + worktree: "/tmp/wt", + steps: [], + /* The limbo gate requires status/mergeDetails/review/reviewState ALL null — `{}` is not null. */ + mergeDetails: undefined, + log: [{ action: "Task marked done by agent", timestamp: new Date(Date.now() - 86_400_000).toISOString() }], + } as unknown as Task; + const { store } = productionFaithfulStore([wedged]); + Object.assign(store, { logEntry: vi.fn(async () => undefined) }); + + const manager = new SelfHealingManager(store, { rootDir: "/repo" }); + vi.spyOn(manager as unknown as { isMergeLaneOwned: (id: string) => Promise }, "isMergeLaneOwned") + .mockResolvedValue(false); + /* + DOWNSTREAM of the blocker, deliberately. `isMergeLaneOwned` runs BEFORE it, so spying there would + prove the read and say nothing about the wiring — an observable upstream of the thing under test is + the same vacuity in a new costume. `recoverApprovedStrandedAiMergeCommit` is the first call after the + blocker check. + */ + const pastBlocker = vi + .spyOn(manager as unknown as { + recoverApprovedStrandedAiMergeCommit: (t: Task, s: unknown) => Promise; + }, "recoverApprovedStrandedAiMergeCommit") + .mockResolvedValue(true); + + await manager.recoverCompletionHandoffLimbo(); + + expect(pastBlocker).toHaveBeenCalled(); + }); }); diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index d9161115cb..6d0ed32841 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -30,7 +30,10 @@ 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, 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 } from "@fusion/core"; +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, + resolveProjectColumnsForRoles, + REVIEW_ROLES, +} from "@fusion/core"; import { finalizePlanningSegment } from "@fusion/core"; import type { MeshLeaseManager } from "./mesh-lease-manager.js"; import { createLogger, schedulerLog } from "./logger.js"; @@ -7062,9 +7065,46 @@ 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-30-22:00 (the query-filter class, seventh sweep): + `listTasks({ column: "in-review" })` returned EMPTY on a renamed board, so a task whose branch has + NO commits ahead of base — a genuine no-op merge — was never finalised and sat in review forever. + + Activation check run first: this sweep is one of the four holding both a literal query and an + unwired `getTaskMergeBlocker`. Widening the read alone would have made it find renamed-board cards + and then decline every one, so the guard is wired in the same change. + + Lane set precomputed per card so the candidate filter stays synchronous; one resolution feeds both + the lane test and the blocker. + */ + const noOpReviewColumns = await resolveProjectColumnsForRoles(this.store, REVIEW_ROLES); + const noOpById = new Map(); + for (const column of noOpReviewColumns) { + for (const task of await this.store.listTasks({ column, slim: true })) noOpById.set(task.id, task); + } + const tasks = [...noOpById.values()]; + const noOpIrCache = new Map(); + const unresolvedNoOpCards: string[] = []; + const noOpLanesByTask = new Map>(); + for (const task of tasks) { + const resolved = await resolveWorkflowIrForTaskWithProvenance(this.store, task.id, noOpIrCache) + .catch(() => undefined); + const own = resolved + ? [...new Set(REVIEW_ROLES.flatMap((role) => columnsWithFlag(resolved.ir, role)))] + : []; + if (!resolved || resolved.source === "default") unresolvedNoOpCards.push(task.id); + /* DELIBERATE-LITERAL — the unresolvable-workflow default, reviewed 2026-07-30-22:00. */ + noOpLanesByTask.set(task.id, own.length > 0 ? new Set(own) : new Set(["in-review"])); + } + if (unresolvedNoOpCards.length > 0) { + log.warn( + `no-op review finalize: ${unresolvedNoOpCards.length} card(s) measured against the built-in ` + + `review lane because their own workflow could not be resolved ` + + `(${unresolvedNoOpCards.slice(0, 5).join(", ")}); a renamed review lane there stays unfinalised.`, + ); + } const candidates = tasks.filter((t) => - t.column === "in-review" && + (noOpLanesByTask.get(t.id) ?? new Set(["in-review"])).has(t.column) && allowsAutoMergeProcessing(t, settings) && !t.paused && // FNXC:AutoMergeHold 2026-07-09-17:10: FN-7750 intentionally keeps the pure branchContext-shape predicate here. Stale shared-group members must stay OUT of solo no-op finalize even when their group is not live; only the positive auto-merge-off exemption gates use the live-group predicate. @@ -7081,7 +7121,8 @@ export class SelfHealingManager extends SelfHealingGitEvidence { t.status !== "merging-pr" && t.status !== "awaiting-user-review" && t.status !== "failed" && - getTaskMergeBlocker(t) === undefined, + /* Wired: unwired, this would decline every card the widened read now finds. */ + getTaskMergeBlocker(t, { reviewColumns: noOpLanesByTask.get(t.id) ?? new Set(["in-review"]) }) === undefined, ); if (candidates.length === 0) return 0; @@ -7265,12 +7306,100 @@ export class SelfHealingManager extends SelfHealingGitEvidence { async reconcileDoneTaskIntegrity(): Promise { try { - const tasks = await this.store.listTasks({ column: "done", slim: true }); - const candidates = tasks.filter((task) => - task.column === "done" && + /* + FNXC:WorkflowResolvedColumns 2026-07-30-16:50 (the query-filter class, now unblocked): + THE QUERY WAS THE BUG, not the comparison below it. `listTasks({ column: "done" })` returns an + EMPTY array on a board whose complete lane is renamed, so this sweep never ran — proven in + `self-healing-query-filter-blindness.test.ts` (#2800), which asserts the query ARGUMENT because + the outcome is 0 either way. + + `resolveProjectColumnsForRoles` is the seam that fix needed: a read happens before any task is in + hand, so there is nothing to resolve a per-task lane from. It answers the PROJECT-level question — + every column any workflow here declares for the role — and unions the legacy ids, so a board + mid-rename still surfaces rows stored under the old one. + + Same three-line shape as `stale-task-reporter` and `backlog-pressure-reporter`: resolve the roles, + iterate the set, dedupe by id. + + FNXC:WorkflowResolvedColumns 2026-07-30-17:20 (#2838 review — greptile P1): + THE PROJECT UNION IS FOR THE QUERY, NEVER FOR THE PER-CARD TEST. My first version re-asserted + `completeColumns.has(task.column)`, which is the flat-set mistake `project-lane-vocabulary.ts` + warns about in its own header — a card is claimed as complete because SOME OTHER workflow in the + project calls its column complete. On a project with two boards, one naming its wip lane the same + as another's complete lane, this sweep would rewrite merge evidence onto a card still being worked. + + Widening the read and widening the verdict are different decisions. The read must over-include + (a missed row is invisible); the verdict must not (a wrong row is a write). So the candidate filter + resolves each card against ITS OWN workflow. + */ + const completeColumns = await resolveProjectColumnsForRoles(this.store, ["complete"]); + const byId = new Map(); + for (const column of completeColumns) { + for (const task of await this.store.listTasks({ column, slim: true })) byId.set(task.id, task); + } + const shortlist = [...byId.values()].filter((task) => (!task.mergeDetails?.commitSha || task.mergeDetails.commitSha.trim().length === 0) && (task.modifiedFiles?.length ?? 0) > 0, - ).slice(0, DONE_TASK_INTEGRITY_SWEEP_LIMIT); + ); + const perTaskIrCache = new Map(); + const candidates: Task[] = []; + /* Cards whose own workflow could not be resolved. Collected so an unrepairable backlog is + reportable rather than silent — see the provenance note in the loop. */ + const unresolvedWorkflowCards: string[] = []; + for (const task of shortlist) { + if (candidates.length >= DONE_TASK_INTEGRITY_SWEEP_LIMIT) break; + /* + FNXC:WorkflowResolvedColumns 2026-07-30-17:55 (#2838 review — greptile P1, "workflow fallback + drops renamed completions"): + + PROVENANCE, BECAUSE THIS RESOLVER DOES NOT FAIL — IT SUBSTITUTES. + + `resolveWorkflowIrForTask` degrades to the BUILT-IN coding IR rather than throwing, so the + `.catch(() => undefined)` protected almost nothing: when a task's selection cannot be resolved + the call SUCCEEDS and hands back a board whose complete lane is `done`. The old line then read + `ownComplete.length > 0` as "this card answered", so a renamed-lane card was measured against + the built-in vocabulary, rejected, and — because this sweep is the thing that would have + repaired its missing merge evidence — rejected again on every subsequent sweep. + + The provenance form separates "the card's own workflow says complete = X" from "nobody could + say, here is the default". Only the first is an answer. + + THE VERDICT IS DELIBERATELY UNCHANGED, AND I MEASURED THAT RATHER THAN ASSUMING IT. My first + version also gated `ownComplete` on the provenance, which READS like the fix and is inert: a + defaulted card resolves to the built-in `complete = ["done"]`, and gating it to `[]` falls + through to the literal `done` — the same verdict by a different route. Mutating the gate away + left the suite green, which is how I found it. Shipping it would have been a conversion that + scores as a win and changes nothing, so it is gone. + + The verdict SHOULD stay conservative here: this sweep WRITES merge evidence, and guessing a lane + to rewrite history onto the wrong card is worse than leaving one unrepaired. What the provenance + buys is the thing that was actually missing — the unresolvable card is now REPORTED instead of + being indistinguishable from a card that answered, so "unrepaired" stops meaning "unrepairable + and invisible forever". + */ + const resolved = await resolveWorkflowIrForTaskWithProvenance(this.store, task.id, perTaskIrCache) + .catch(() => undefined); + const ownComplete = resolved ? columnsWithFlag(resolved.ir, "complete") : []; + /* A THROW is unresolvable too. Checking only `source === "default"` left the rarer path — the + resolver raising rather than substituting — silently discarded, which is the same invisibility + this change exists to remove, one branch over. */ + if (!resolved || resolved.source === "default") unresolvedWorkflowCards.push(task.id); + /* DELIBERATE-LITERAL — the degraded per-card default, reviewed 2026-07-30-17:20. Reached when the + card's own workflow could not be resolved AT ALL, or resolves and declares no complete lane. The + project union must not stand in here: that is the flat-set mistake this filter exists to avoid, + so the legacy id is the conservative answer. The census ratchet flagged this line when it + appeared, which is the guard working. */ + const isComplete = ownComplete.length > 0 ? ownComplete.includes(task.column) : task.column === "done"; + if (isComplete) candidates.push(task); + } + + if (unresolvedWorkflowCards.length > 0) { + log.warn( + `done-task integrity sweep: ${unresolvedWorkflowCards.length} card(s) measured against the ` + + `built-in complete lane because their own workflow could not be resolved ` + + `(${unresolvedWorkflowCards.slice(0, 5).join(", ")}); a renamed completion lane there stays unrepaired.`, + ); + } if (candidates.length === 0) return 0; const settings = await this.store.getSettings(); @@ -7412,7 +7541,38 @@ export class SelfHealingManager extends SelfHealingGitEvidence { const settings = await this.store.getSettings(); if (settings.globalPause || settings.enginePaused) return 0; const maxAutoMergeRetries = resolveMaxAutoMergeRetries(settings); - const tasks = await this.store.listTasks({ column: "in-review", slim: true }); + /* + FNXC:WorkflowResolvedColumns 2026-07-30-20:20 (the query-filter class, fifth sweep): + Fifth application of the four-part shape in + `docs/solutions/architecture-patterns/self-healing-sweeps-are-blind-on-a-renamed-board.md`. + `listTasks({ column: "in-review" })` returns EMPTY on a renamed board, so a card that is genuinely + ready to merge — every step done, no blocker — was never re-enqueued and sat in review forever. + + Read via the project union; verdict per card against its own workflow, because this sweep enqueues + a MERGE; provenance so an unresolvable card is reported rather than silently skipped. + */ + const mergeableReviewColumns = await resolveProjectColumnsForRoles(this.store, REVIEW_ROLES); + const mergeableById = new Map(); + for (const column of mergeableReviewColumns) { + for (const task of await this.store.listTasks({ column, slim: true })) mergeableById.set(task.id, task); + } + const tasks = [...mergeableById.values()]; + const mergeableIrCache = new Map(); + const unresolvedMergeableCards: string[] = []; + /* + Returns the card's OWN review lanes, so one resolution feeds BOTH consumers below — the lane test + and `getTaskMergeBlocker`. Two resolutions of the same fact is how the halves of one decision drift. + */ + const ownReviewLanesFor = async (task: Task): Promise> => { + const resolved = await resolveWorkflowIrForTaskWithProvenance(this.store, task.id, mergeableIrCache) + .catch(() => undefined); + const own = resolved + ? [...new Set(REVIEW_ROLES.flatMap((role) => columnsWithFlag(resolved.ir, role)))] + : []; + if (!resolved || resolved.source === "default") unresolvedMergeableCards.push(task.id); + /* DELIBERATE-LITERAL — the unresolvable-workflow default, reviewed 2026-07-30-20:20. */ + return own.length > 0 ? new Set(own) : new Set(["in-review"]); + }; /* FNXC:WorkflowReviewGates 2026-07-26-15:30: Liveness gate, mirroring `recoverGhostReviewTasks` and @@ -7432,7 +7592,6 @@ export class SelfHealingManager extends SelfHealingGitEvidence { const executingIds = this.options.getExecutingTaskIds?.() ?? new Set(); const mergeable = tasks.filter((t) => - t.column === "in-review" && allowsAutoMergeProcessing(t, settings) && !t.paused && !executingIds.has(t.id) && @@ -7455,13 +7614,38 @@ export class SelfHealingManager extends SelfHealingGitEvidence { // refreshes updatedAt, preventing cooldown-based retries from ever // becoming eligible. Also skip tasks explicitly tagged as no-op merges // in case updateTask(moveTask) is briefly out-of-order during recovery. - (t.mergeRetries ?? 0) < maxAutoMergeRetries && - getTaskMergeBlocker(t) === undefined, + (t.mergeRetries ?? 0) < maxAutoMergeRetries, ); + /* + FNXC:WorkflowResolvedColumns 2026-07-30-20:20 (widening a query makes a dormant guard REACHABLE): + `getTaskMergeBlocker` moved out of the synchronous filter above because it needs this card's + resolved review lanes, and those need an await. + + This matters beyond tidiness. That call was previously UNWIRED — it took the legacy `in-review` + default — and was harmless only because the literal query above meant a renamed board never reached + it. Widening the read makes it reachable for the first time, so left as-is it would have refused + every card on exactly the boards this fix is for: the sweep would find them and then decline them. + + Converting a query is therefore not just a read change; it activates every guard downstream of it. + */ + const laneQualifiedMergeable: Task[] = []; + for (const task of mergeable) { + const reviewColumns = await ownReviewLanesFor(task); + if (!reviewColumns.has(task.column)) continue; + if (getTaskMergeBlocker(task, { reviewColumns }) !== undefined) continue; + laneQualifiedMergeable.push(task); + } + if (unresolvedMergeableCards.length > 0) { + log.warn( + `mergeable-review recovery: ${unresolvedMergeableCards.length} card(s) measured against the ` + + `built-in review lane because their own workflow could not be resolved ` + + `(${unresolvedMergeableCards.slice(0, 5).join(", ")}); a renamed review lane there stays stuck.`, + ); + } const ownershipFlags = await Promise.all( - mergeable.map((task) => this.isMergeLaneOwned(task.id)), + laneQualifiedMergeable.map((task) => this.isMergeLaneOwned(task.id)), ); - const unownedMergeable = mergeable.filter((_, i) => !ownershipFlags[i]); + const unownedMergeable = laneQualifiedMergeable.filter((_, i) => !ownershipFlags[i]); const inReviewIds = new Set(tasks.map((task) => task.id)); const mergeableIds = new Set(unownedMergeable.map((task) => task.id)); @@ -7473,7 +7657,9 @@ export class SelfHealingManager extends SelfHealingGitEvidence { if (unownedMergeable.length === 0) return 0; - log.warn(`Found ${unownedMergeable.length} mergeable review task(s) stuck in in-review`); + log.warn( + `Found ${unownedMergeable.length} mergeable review task(s) stuck in ${[...mergeableReviewColumns].join(", ")}`, + ); // Prefer the engine's merge queue so `mergeStrategy` (direct vs. // pull-request) is honored. Fall back to a direct store merge only @@ -7555,7 +7741,48 @@ export class SelfHealingManager extends SelfHealingGitEvidence { 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-30-21:20 (the query-filter class, sixth sweep — and the first + converted with the ACTIVATION check run FIRST, per + `docs/solutions/architecture-patterns/self-healing-sweeps-are-blind-on-a-renamed-board.md`): + + `listTasks({ column: "in-review" })` returned EMPTY on a renamed board, so a card parked with a + FAILED pre-merge review step was never auto-revived and stayed parked until a human noticed. + + The activation check mattered here more than anywhere so far. This sweep's filter asks + `blocker !== "task has failed pre-merge workflow steps"` — an EXACT STRING match. Unwired on a + renamed board the blocker instead returns "task is in 'checking', must be in 'in-review'", which is + not that string, so every card would be rejected the moment the widened query started finding them. + Wiring the blocker is therefore part of this conversion, not a follow-up. + */ + const failedStepReviewColumns = await resolveProjectColumnsForRoles(this.store, REVIEW_ROLES); + const failedStepById = new Map(); + for (const column of failedStepReviewColumns) { + for (const task of await this.store.listTasks({ column, slim: true })) failedStepById.set(task.id, task); + } + const tasks = [...failedStepById.values()]; + const failedStepIrCache = new Map(); + const unresolvedFailedStepCards: string[] = []; + /* One resolution per card, feeding BOTH the lane test and the blocker below. */ + const ownReviewLanesForFailedStep = async (task: Task): Promise> => { + const resolved = await resolveWorkflowIrForTaskWithProvenance(this.store, task.id, failedStepIrCache) + .catch(() => undefined); + const own = resolved + ? [...new Set(REVIEW_ROLES.flatMap((role) => columnsWithFlag(resolved.ir, role)))] + : []; + if (!resolved || resolved.source === "default") unresolvedFailedStepCards.push(task.id); + /* DELIBERATE-LITERAL — the unresolvable-workflow default, reviewed 2026-07-30-21:20. */ + return own.length > 0 ? new Set(own) : new Set(["in-review"]); + }; + const reviewLanesByTask = new Map>(); + for (const task of tasks) reviewLanesByTask.set(task.id, await ownReviewLanesForFailedStep(task)); + if (unresolvedFailedStepCards.length > 0) { + log.warn( + `failed-pre-merge-step revival: ${unresolvedFailedStepCards.length} card(s) measured against the ` + + `built-in review lane because their own workflow could not be resolved ` + + `(${unresolvedFailedStepCards.slice(0, 5).join(", ")}); a renamed review lane there stays parked.`, + ); + } const executingIds = this.options.getExecutingTaskIds?.() ?? new Set(); const latestFailedPreMergeStep = (task: Pick): WorkflowStepResult | undefined => { @@ -7643,7 +7870,8 @@ export class SelfHealingManager extends SelfHealingGitEvidence { }; const candidates = tasks.filter((task) => { - if (task.column !== "in-review") return false; + /* Precomputed above, so this filter stays synchronous. */ + if (!(reviewLanesByTask.get(task.id) ?? new Set(["in-review"])).has(task.column)) return false; if (!allowsAutoMergeProcessing(task, settings)) return false; if (task.paused) return false; /* @@ -7666,7 +7894,11 @@ export class SelfHealingManager extends SelfHealingGitEvidence { // Merge must be blocked *specifically* by the failed pre-merge step — // not by an unrelated condition (incomplete steps, etc.) that is // already handled by a dedicated scan. - const blocker = getTaskMergeBlocker(task); + /* Wired: this comparison is an EXACT STRING match, so an unwired blocker returning + "task is in '', must be in 'in-review'" would reject every card on a renamed board. */ + const blocker = getTaskMergeBlocker(task, { + reviewColumns: reviewLanesByTask.get(task.id) ?? new Set(["in-review"]), + }); if (!parkedRemediationFailure && blocker !== "task has failed pre-merge workflow steps") return false; // The retry flow injects into PROMPT.md + re-executes on the worktree. @@ -8401,23 +8633,62 @@ export class SelfHealingManager extends SelfHealingGitEvidence { const timeoutMs = settings.taskStuckTimeoutMs; if (!timeoutMs || timeoutMs <= 0) return 0; - const tasks = await this.store.listTasks({ column: "in-review", slim: true }); - const statusCandidates = tasks.filter((task) => - task.column === "in-review" && + /* + FNXC:WorkflowResolvedColumns 2026-07-30-19:50 (the query-filter class, fourth sweep): + Same shape as its three siblings: `listTasks({ column: "in-review" })` returns EMPTY on a renamed + board, so a task interrupted mid-merge — status still `merging`, no live session behind it — was + never recovered and sat in that state indefinitely. + + Project union for the READ; per-card verdict for the MUTATION, since this sweep rewrites status and + clears merge state. Provenance so an unresolvable card is reported rather than silently skipped — + identical to `recoverAlreadyMergedReviewTasks` and `recoverStuckMergeDeadlocks`, so the four cannot + drift apart. + */ + const interruptedReviewColumns = await resolveProjectColumnsForRoles(this.store, REVIEW_ROLES); + const interruptedById = new Map(); + for (const column of interruptedReviewColumns) { + for (const task of await this.store.listTasks({ column, slim: true })) interruptedById.set(task.id, task); + } + const interruptedIrCache = new Map(); + const unresolvedInterruptedCards: string[] = []; + const inOwnReviewLaneForInterrupted = async (task: Task): Promise => { + const resolved = await resolveWorkflowIrForTaskWithProvenance(this.store, task.id, interruptedIrCache) + .catch(() => undefined); + const own = resolved + ? [...new Set(REVIEW_ROLES.flatMap((role) => columnsWithFlag(resolved.ir, role)))] + : []; + if (!resolved || resolved.source === "default") unresolvedInterruptedCards.push(task.id); + /* DELIBERATE-LITERAL — the unresolvable-workflow default, reviewed 2026-07-30-19:50. */ + return own.length > 0 ? own.includes(task.column) : task.column === "in-review"; + }; + const statusCandidates = [...interruptedById.values()].filter((task) => allowsAutoMergeProcessing(task, settings) && !task.paused && Boolean(task.status && ACTIVE_MERGE_STATUSES.has(task.status)), ); const candidates: Task[] = []; for (const task of statusCandidates) { + if (!(await inOwnReviewLaneForInterrupted(task))) continue; if (await this.isPastInterruptedMergeGraceAsync(task, timeoutMs)) { candidates.push(task); } } + if (unresolvedInterruptedCards.length > 0) { + log.warn( + `interrupted-merge recovery: ${unresolvedInterruptedCards.length} card(s) measured against the ` + + `built-in review lane because their own workflow could not be resolved ` + + `(${unresolvedInterruptedCards.slice(0, 5).join(", ")}); a renamed review lane there stays stuck.`, + ); + } + if (candidates.length === 0) return 0; - log.warn(`Found ${candidates.length} stale merging task(s) in in-review`); + /* Names the lanes actually searched rather than the literal `in-review`, which is no longer what + was queried and would misreport the board this ran against. */ + log.warn( + `Found ${candidates.length} stale merging task(s) in ${[...interruptedReviewColumns].join(", ")}`, + ); let recovered = 0; for (const task of candidates) { @@ -9408,22 +9679,54 @@ export class SelfHealingManager extends SelfHealingGitEvidence { if (settings.globalPause || settings.enginePaused) return 0; const maxAutoMergeRetries = resolveMaxAutoMergeRetries(settings); const now = Date.now(); - const inReview = await this.store.listTasks({ column: "in-review", slim: true }); /* - FNXC:WorkflowColumns 2026-07-29-17:40 (PR #2560 review): - This trio is a UNION and is deliberately left on literals. For the merged - default lineage the `triage` read returns empty and `todo` supplies the cards; - for a legacy/custom workflow that still declares `triage` it supplies them. - Either way the union is complete, and the role filter below decides which rows - count as pre-WIP. Unlike the recoverAdvancedTriageTasks query this replaces - nothing and disables nothing — it is a redundant read, not a dead sweep. + FNXC:WorkflowResolvedColumns 2026-07-30-19:20 (the query-filter class, third sweep): + FOUR reads, two different jobs, and only the reads were broken. + + `inReview` supplies the CANDIDATES — cards blocked on merge — so it needs both halves: the project + union for the read, and a per-card verdict against the card's own workflow, because this sweep + mutates. Same shape as `recoverAlreadyMergedReviewTasks`, provenance and reporting included. + + The other three supply DEPENDENTS, and their verdict is already correct: `filterByPreWipRole` + resolves intake/hold per card below. The 2026-07-29-17:40 note reasoned that the literal trio was a + complete union "and the role filter below decides which rows count" — which held for the default and + legacy lineages it considered, and does not hold for a RENAMED board, where all three reads return + empty and the role filter is handed nothing to decide about. Widening the reads restores exactly the + property that note relied on; the filter it points at is unchanged. */ - const triage = await this.store.listTasks({ column: "triage", slim: true }); - const todo = await this.store.listTasks({ column: "todo", slim: true }); - const inProgress = await this.store.listTasks({ column: "in-progress", slim: true }); + const reviewColumnsForDeadlock = await resolveProjectColumnsForRoles(this.store, REVIEW_ROLES); + const inReviewById = new Map(); + for (const column of reviewColumnsForDeadlock) { + for (const task of await this.store.listTasks({ column, slim: true })) inReviewById.set(task.id, task); + } + const deadlockIrCache = new Map(); + const unresolvedDeadlockCards: string[] = []; + const inOwnReviewLaneForDeadlock = async (task: Task): Promise => { + const resolved = await resolveWorkflowIrForTaskWithProvenance(this.store, task.id, deadlockIrCache) + .catch(() => undefined); + const own = resolved + ? [...new Set(REVIEW_ROLES.flatMap((role) => columnsWithFlag(resolved.ir, role)))] + : []; + if (!resolved || resolved.source === "default") unresolvedDeadlockCards.push(task.id); + /* DELIBERATE-LITERAL — the unresolvable-workflow default, reviewed 2026-07-30-19:20. */ + return own.length > 0 ? own.includes(task.column) : task.column === "in-review"; + }; + const inReview = [...inReviewById.values()]; + + /* Dependents: read every pre-WIP and WIP lane the project declares; `filterByPreWipRole` below + still decides per card which of them actually count. */ + const dependentSourceColumns = await resolveProjectColumnsForRoles( + this.store, + ["intake", "hold", "countsTowardWip"], + ); + const dependentsById = new Map(); + for (const column of dependentSourceColumns) { + for (const task of await this.store.listTasks({ column, slim: true })) dependentsById.set(task.id, task); + } + const dependentSources = [...dependentsById.values()]; const dependentsByBlocker = new Map(); - for (const task of [...triage, ...todo, ...inProgress]) { + for (const task of dependentSources) { if (!task.blockedBy) continue; const dependents = dependentsByBlocker.get(task.blockedBy) ?? []; dependents.push(task); @@ -9449,8 +9752,9 @@ export class SelfHealingManager extends SelfHealingGitEvidence { const hasBlockedDependents = (dependentsByBlocker.get(task.id) ?? []).some( (dep) => preWipDependentIds.has(dep.id), ); - return task.column === "in-review" && - allowsAutoMergeProcessing(task, settings) && + /* Lane identity is decided per card by `inOwnReviewLaneForDeadlock` below — this synchronous + filter keeps every other condition, so the async check runs only for rows that already qualify. */ + return allowsAutoMergeProcessing(task, settings) && !task.paused && task.status === "failed" && (task.mergeRetries ?? 0) >= maxAutoMergeRetries && @@ -9459,10 +9763,23 @@ export class SelfHealingManager extends SelfHealingGitEvidence { cooldownElapsed >= DEADLOCK_RECOVERY_COOLDOWN_MS; }); - if (candidates.length === 0) return 0; + /* The per-card lane verdict is async, so it cannot live in the filter above. */ + const laneQualified: Task[] = []; + for (const task of candidates) { + if (await inOwnReviewLaneForDeadlock(task)) laneQualified.push(task); + } + if (unresolvedDeadlockCards.length > 0) { + log.warn( + `merge-deadlock recovery: ${unresolvedDeadlockCards.length} card(s) measured against the ` + + `built-in review lane because their own workflow could not be resolved ` + + `(${unresolvedDeadlockCards.slice(0, 5).join(", ")}); a renamed review lane there stays deadlocked.`, + ); + } + + if (laneQualified.length === 0) return 0; let recovered = 0; - for (const task of candidates) { + for (const task of laneQualified) { if (task.deletedAt) continue; const blockedDependents = dependentsByBlocker.get(task.id) ?? []; const blockedTaskIds = blockedDependents.map((dep) => dep.id); @@ -9819,10 +10136,65 @@ export class SelfHealingManager extends SelfHealingGitEvidence { if (settings.globalPause || settings.enginePaused) return 0; const maxAutoMergeRetries = resolveMaxAutoMergeRetries(settings); const executingIds = this.options.getExecutingTaskIds?.() ?? new Set(); - const tasks = await this.store.listTasks({ column: "in-review", slim: true }); - const candidates = tasks.filter((task) => + /* + FNXC:WorkflowResolvedColumns 2026-07-30-18:20 (the query-filter class, second sweep): + THE QUERY WAS THE BUG. `listTasks({ column: "in-review" })` returns EMPTY on a board whose review + lane is renamed, so this sweep never ran — and it is the one that rescues a card whose merge + ACTUALLY SUCCEEDED but is parked in review with `status: "failed"`. Unrun, that card stays stuck + permanently with a merge nobody reconciles. + + Read widened via the project union (a read happens before any task is in hand); the per-card verdict + below is resolved against THAT CARD's own workflow, because this sweep mutates. Widening the read + and widening the verdict are different decisions — see `reconcileDoneTaskIntegrity`. + */ + const reviewColumns = await resolveProjectColumnsForRoles(this.store, REVIEW_ROLES); + const byId = new Map(); + for (const column of reviewColumns) { + for (const task of await this.store.listTasks({ column, slim: true })) byId.set(task.id, task); + } + const reviewIrCache = new Map(); + /* Cards whose own workflow could not be resolved — reported below rather than silently dropped. */ + const unresolvedReviewCards: string[] = []; + /* + FNXC:WorkflowResolvedColumns 2026-07-30-18:50 (#2838 review — greptile P1, same class as the + done-integrity sweep one commit earlier): + PROVENANCE, BECAUSE THIS RESOLVER DOES NOT FAIL — IT SUBSTITUTES. `resolveWorkflowIrForTask` + degrades to the BUILT-IN coding IR rather than throwing, so `own.length > 0` read as "this card + answered" when nobody had: a renamed-lane card was measured against the built-in `in-review`, + rejected, and — because this sweep is what would have rescued its already-merged state — rejected + again on every subsequent pass. + + I wrote this sweep before that fix landed on the done-integrity one and reproduced its pre-fix + shape verbatim. Same resolution, same reporting, so the two cannot drift. + + The VERDICT stays conservative: this sweep mutates a task's column and status, and guessing a lane + is worse than leaving one unrescued. What provenance buys is that the unrescued card is REPORTED. + */ + /* + FNXC:WorkflowResolvedColumns 2026-07-30-20:50 (widening this sweep's query ACTIVATED a guard below): + Returns the card's OWN review lanes rather than a boolean, so ONE resolution feeds BOTH consumers — + this sweep's lane test and the `getTaskHardMergeBlocker` call in its recovery loop. + + That blocker was unwired and UNREACHABLE while this sweep's query was a literal. Widening the read + made it reachable: the sweep now finds renamed-board cards, and unwired it would decline every one, + so the conversion would have looked complete and delivered nothing. Found by scanning this file for + sweeps holding BOTH a literal query and an unwired lane guard — six of them, this one included. + */ + const ownReviewLanesForAlreadyMerged = async (task: Task): Promise> => { + const resolved = await resolveWorkflowIrForTaskWithProvenance(this.store, task.id, reviewIrCache) + .catch(() => undefined); + const own = resolved + ? [...new Set(REVIEW_ROLES.flatMap((role) => columnsWithFlag(resolved.ir, role)))] + : []; + /* A THROW is unresolvable too — the rarer path where the resolver raises rather than substitutes. */ + if (!resolved || resolved.source === "default") unresolvedReviewCards.push(task.id); + /* DELIBERATE-LITERAL — the unresolvable-workflow default, reviewed 2026-07-30-18:50. */ + return own.length > 0 ? new Set(own) : new Set(["in-review"]); + }; + const inOwnReviewLane = async (task: Task): Promise => + (await ownReviewLanesForAlreadyMerged(task)).has(task.column); + const shortlist = [...byId.values()].filter((task) => !task.deletedAt && - task.column === "in-review" && allowsAutoMergeProcessing(task, settings) && task.status === "failed" && (task.mergeRetries ?? 0) >= maxAutoMergeRetries && @@ -9830,6 +10202,19 @@ export class SelfHealingManager extends SelfHealingGitEvidence { !executingIds.has(task.id), ); + /* The per-card lane verdict is async, so it cannot live in the filter above. */ + const candidates: Task[] = []; + for (const task of shortlist) { + if (await inOwnReviewLane(task)) candidates.push(task); + } + if (unresolvedReviewCards.length > 0) { + log.warn( + `already-merged review rescue: ${unresolvedReviewCards.length} card(s) measured against the ` + + `built-in review lane because their own workflow could not be resolved ` + + `(${unresolvedReviewCards.slice(0, 5).join(", ")}); a renamed review lane there stays unrescued.`, + ); + } + if (candidates.length === 0) return 0; let recovered = 0; @@ -9904,11 +10289,12 @@ export class SelfHealingManager extends SelfHealingGitEvidence { mergeTargetSource: mergeTarget.source, }; + /* Wired with THIS card's review lanes — see the note on `ownReviewLanesForAlreadyMerged` above. */ const hardBlocker = getTaskHardMergeBlocker({ ...task, steps: task.steps ?? [], workflowStepResults: task.workflowStepResults, - }); + }, { reviewColumns: await ownReviewLanesForAlreadyMerged(task) }); if (hardBlocker) { await this.store.updateTask(task.id, { status: "failed", @@ -10292,11 +10678,40 @@ export class SelfHealingManager extends SelfHealingGitEvidence { async recoverCompletionHandoffLimbo(): Promise { const settings = await this.store.getSettings(); if (settings.globalPause || settings.enginePaused) return; - const tasks = await this.store.listTasks({ column: "in-review", slim: false }); + /* + FNXC:WorkflowResolvedColumns 2026-07-30-22:40 (the query-filter class, eighth sweep): + `listTasks({ column: "in-review" })` returned EMPTY on a renamed board, so a task stranded in + completion-handoff limbo — falsely marked exhausted while the merge queue already owns it — was never + cleared and stayed wedged. + + Activation check first: this is one of the three remaining sweeps holding both a literal query and an + unwired `getTaskMergeBlocker`, so the guard is wired in the same change. This loop is already async, + so the per-card resolution happens inline rather than needing a precomputed map. + */ + const limboReviewColumns = await resolveProjectColumnsForRoles(this.store, REVIEW_ROLES); + const limboById = new Map(); + for (const column of limboReviewColumns) { + for (const task of await this.store.listTasks({ column, slim: false })) limboById.set(task.id, task); + } + const tasks = [...limboById.values()]; + const limboIrCache = new Map(); + const unresolvedLimboCards: string[] = []; + const ownLimboReviewLanes = async (task: Task): Promise> => { + const resolved = await resolveWorkflowIrForTaskWithProvenance(this.store, task.id, limboIrCache) + .catch(() => undefined); + const own = resolved + ? [...new Set(REVIEW_ROLES.flatMap((role) => columnsWithFlag(resolved.ir, role)))] + : []; + if (!resolved || resolved.source === "default") unresolvedLimboCards.push(task.id); + /* DELIBERATE-LITERAL — the unresolvable-workflow default, reviewed 2026-07-30-22:40. */ + return own.length > 0 ? new Set(own) : new Set(["in-review"]); + }; const now = Date.now(); for (const task of tasks) { - if (task.column !== "in-review" || task.paused) continue; + if (task.paused) continue; + const limboReviewLanes = await ownLimboReviewLanes(task); + if (!limboReviewLanes.has(task.column)) continue; if (!allowsAutoMergeProcessing(task, settings)) continue; if (await this.isFalseCompletionHandoffExhaustionWhileMergeOwned(task)) { await this.store.updateTask(task.id, { @@ -10313,7 +10728,8 @@ export class SelfHealingManager extends SelfHealingGitEvidence { if (task.status != null || task.mergeDetails != null || task.review != null || task.reviewState != null) continue; if (this.options.isTaskActive?.(task.id)) continue; if (await this.isMergeLaneOwned(task.id)) continue; - if (getTaskMergeBlocker(task) !== undefined) continue; + /* Wired: unwired, this would skip every card the widened read now finds. */ + if (getTaskMergeBlocker(task, { reviewColumns: limboReviewLanes }) !== undefined) continue; const doneMarker = [...(task.log ?? [])].reverse().find((entry) => entry.action === "Task marked done by agent"); if (!doneMarker?.timestamp) continue; diff --git a/scripts/lib/lifecycle-column-census-baseline.json b/scripts/lib/lifecycle-column-census-baseline.json index 4cd80ca5a1..31e2c0e5f0 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": 97, + "packages/engine/src/self-healing.ts": 89, "packages/engine/src/scheduler.ts": 12, "packages/core/src/task-store/async-comments-attachments.ts": 9, "packages/engine/src/executor.ts": 8, @@ -58,6 +58,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/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,6 +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/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, @@ -127,7 +129,7 @@ "plugins/fusion-plugin-reports/src/store/report-types.ts\u0000archived": 1 }, "queryByFile": { - "packages/engine/src/self-healing.ts": 48, + "packages/engine/src/self-healing.ts": 37, "packages/core/src/task-store/async-persistence.ts": 2, "packages/core/src/task-store/merge-queue-ops.ts": 2, "packages/cli/src/extension.ts": 1,