From 01f081e8aaf1cfb70679830e6aa58f3cd2605a4f Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 30 Jul 2026 21:45:17 -0700 Subject: [PATCH] engine: restore the stall-signal lane wiring #2951 dropped (and the test that proved it) (#2961) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **My defect, shipped in #2951 — and the same family as the one #2956 just fixed.** Found by auditing my own seams after that, not by a failing check. ## What is on `main` right now `surfaceInReviewStalls` reads the project's review columns (converted in #2951), then calls `getInReviewStallReason` **without** `reviewColumns`. The classifier falls back to the literal `in-review`, returns no signal for a renamed-lane card, and the sweep surfaces nothing. That is the textbook **missed pair** this program has a ratchet for: a widened read handing every renamed-board card to a literal classifier. The resolve work happens and is then discarded. On a renamed board an operator sees no stall warnings at all. #2951's conflict resolution dropped two things together: - the per-card `stallLanes` map and the `reviewColumns` argument - **the test that proved the wiring** ## Why nothing caught it **A deleted test cannot fail.** I verified that rebase by comparing the 68 conflict *hunks* — stripping FNXC stamps, confirming 0 of 68 had real content differences — and then ran the gate. The gate passed precisely because the proving test had gone with the code it proved. I verified the conflicts. I did not verify the outcome. Those are different things, and the difference is invisible when the evidence disappears alongside the feature. The `unwired-lane-parameter` guard cannot catch this either, by design: it is deliberately conservative — a mention of the parameter *anywhere* satisfies it — so **partial** wiring is outside its reach. `reviewColumns` is mentioned plenty in `reads.ts`, so the guard is green while this call site goes unwired. ## How I found it The check #2956 used on the sibling defect, applied to every lane seam I have touched: enumerate each function's **call sites** and confirm each one carries the parameter. That enumeration also flags several other call sites without `reviewColumns`/lane arguments (`merger.ts`, `moves.ts`, `auto-merge-finalization.ts`, `merger-ai.ts`, `project-engine.ts`) — I have **not** touched those here; they need per-site judgement about whether the lane answer is even available, and that is a separate change rather than a sweep. ## Revert result | | reverted → | | --- | --- | | `reviewColumns` at the call (i.e. exactly what #2951 shipped) | fails the restored test | ## Verification `pnpm test:gate` 161 + 487 + 13 + 71; blindness suite 71; `self-healing.test.ts` 412; `tsc` engine clean; lint, census `--strict`, FNXC gate, changesets all clean. --- .changeset/restore-stall-lane-wiring.md | 7 ++ ...elf-healing-query-filter-blindness.test.ts | 64 ++++++++++++++++++- packages/engine/src/self-healing.ts | 26 ++++++++ 3 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 .changeset/restore-stall-lane-wiring.md diff --git a/.changeset/restore-stall-lane-wiring.md b/.changeset/restore-stall-lane-wiring.md new file mode 100644 index 0000000000..54c1a7344c --- /dev/null +++ b/.changeset/restore-stall-lane-wiring.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Review stalls are surfaced again on boards with renamed columns. +category: fix +dev: #2951 converted `surfaceInReviewStalls` to read the project's review columns but its conflict resolution dropped the per-card `reviewColumns` argument to `getInReviewStallReason` — and the test proving it — so the sweep resolved lanes and then surfaced nothing. Both restored. 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 9a38c22727..947063c56b 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 @@ -2205,4 +2205,66 @@ describe("the already-merged hard blocker judges the card's OWN review lanes", ( expect(blocker).toContain(`must be in '${RENAMED_VOCAB.review}'`); }); -}); + + /* + FNXC:WorkflowResolvedColumns 2026-07-30-16:25 (RESTORED alongside the wiring it proves): + `surfaceInReviewStalls` tells an operator a card is stalled in review. #2951 converted its READ to the + project's review columns but its bulk conflict resolution dropped the per-card `reviewColumns` + argument — and dropped THIS TEST in the same pass, which is why the regression landed silently. A + deleted test cannot fail, so the gate stayed green over a sweep that resolves lanes and then surfaces + nothing on a renamed board. + + REVERT CHECKS, both measured, each alone: + - literal read restored -> fails, the card is never listed + - `reviewColumns` dropped at the call -> fails, the classifier judges the renamed lane by the + literal and returns no signal + */ + it("surfaces a stalled card on a RENAMED review lane", async () => { + const stalled = { + ...shippedCard(), + id: "FN-STALLED", + column: RENAMED_VOCAB.review, + status: "failed", + error: "merge failed: conflict", + mergeRetries: 99, + mergeDetails: {}, + updatedAt: "2020-01-01T00:00:00.000Z", + } as unknown as Task; + const { store } = productionFaithfulStore([stalled]); + Object.assign(store, { + getSettings: vi.fn(async () => ({ globalPause: false, enginePaused: false, taskStuckTimeoutMs: 60_000 })), + }); + + /* The sweep surfaces by LOGGING and only writes for specific terminal codes, so its own count is + the observable — asserting a write would pin a different branch than the lane read. */ + const surfaced = await new SelfHealingManager(store, { rootDir: "/repo" }).surfaceInReviewStalls(); + + expect(surfaced).toBeGreaterThan(0); + }); + + it("does not surface a stall for a card outside the review lanes", async () => { + /* + Non-vacuous companion: the same failed card in the WIP lane has not reached review, so reporting a + stall there would invent one. + */ + const stalled = { + ...shippedCard(), + id: "FN-STALLED", + column: RENAMED_VOCAB.wip, + status: "failed", + error: "merge failed: conflict", + mergeRetries: 99, + mergeDetails: {}, + updatedAt: "2020-01-01T00:00:00.000Z", + } as unknown as Task; + const { store } = productionFaithfulStore([stalled]); + Object.assign(store, { + getSettings: vi.fn(async () => ({ globalPause: false, enginePaused: false, taskStuckTimeoutMs: 60_000 })), + }); + + const surfaced = await new SelfHealingManager(store, { rootDir: "/repo" }).surfaceInReviewStalls(); + + expect(surfaced).toBe(0); + }); + +}); \ No newline at end of file diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index 9f5ae7090f..650f9efbb2 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -8467,12 +8467,38 @@ export class SelfHealingManager extends SelfHealingGitEvidence { for (const task of await this.store.listTasks({ column, slim: false })) stallCandidatesById.set(task.id, task); } const tasks = [...stallCandidatesById.values()]; + /* + FNXC:WorkflowResolvedColumns 2026-07-30-16:20 (RESTORED — #2951 landed the read without this): + Per-card review lanes for the classifier below. #2951 converted the READ to the project's review + columns, and its bulk conflict resolution dropped this map and the `reviewColumns` argument with + it — leaving the textbook missed pair: a widened read hands every renamed-board card to a + classifier that still judges by the literal `in-review`, so the sweep resolves lanes and then + surfaces nothing. + + The test that proved the wiring was dropped in the same resolution, which is why nothing failed. + A deleted test cannot fail. Restored together. + */ + const stallLanes = new Map>(); + for (const entry of tasks) { + try { + const { ir, source } = await resolveWorkflowIrForTaskWithProvenance(this.store, entry.id); + stallLanes.set( + entry.id, + source === "default" + ? stallReviewColumns + : new Set(REVIEW_ROLES.flatMap((role) => [...columnsWithFlag(ir, role)])), + ); + } catch { + stallLanes.set(entry.id, stallReviewColumns); + } + } let surfaced = 0; for (const task of tasks) { if (task.deletedAt) continue; if (!allowsAutoMergeProcessing(task, settings)) continue; const signal = getInReviewStallReason(task, { + reviewColumns: stallLanes.get(task.id) ?? stallReviewColumns, now: cycleStartMs, activeMergeTaskId, executingTaskIds,