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,