diff --git a/.changeset/self-healing-stale-merging-status-query.md b/.changeset/self-healing-stale-merging-status-query.md new file mode 100644 index 0000000000..8b7003448f --- /dev/null +++ b/.changeset/self-healing-stale-merging-status-query.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: A card stuck mid-merge can be retried again on boards with renamed columns. +category: fix +dev: `recoverStaleMergingStatus` read the literal `in-review`, so a stale `merging`/`merging-pr` stamp was never cleared on a renamed board. That stamp gates both the merger and the dashboard's manual Retry, so the card could neither progress nor be retried by hand. Read resolves via `resolveProjectColumnsForRoles`, per-card check resolves per card. 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 ec7dfd9b5f..89b89c4ff3 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 @@ -86,8 +86,9 @@ function productionFaithfulStore(tasks: Task[]) { being a ratchet silently. */ listWorkflowDefinitions: vi.fn(async () => [{ ir: RENAMED_IR }]), + logEntry: vi.fn(async () => undefined), }) as unknown as TaskStore & EventEmitter; - return { store, listTasks }; + return { store, listTasks, updateTask: store.updateTask as unknown as ReturnType }; } function shippedCard(): Task { @@ -720,4 +721,53 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th expect(resolveTarget).not.toHaveBeenCalled(); }); + + /* + FNXC:WorkflowResolvedColumns 2026-07-31-09:45 (the query-filter class, twenty-first sweep): + `recoverStaleMergingStatus` clears a `merging`/`merging-pr` stamp left on a review card with no live + merger behind it. The literal read meant that on a renamed board the stamp was never cleared, so the + card read as mid-merge forever — and that stamp is what the merger AND the dashboard's manual Retry + gate both consult, so the card could neither progress on its own nor be retried by hand. + + `updatedAt` is deliberately ancient: `isStaleMergeActiveStatus` requires the stamp to have sat + untouched for `minAgeMs`, so a fresh fixture would be filtered out for a reason unrelated to lanes. + + REVERT CHECKS, both measured, each alone: + - literal read restored -> fails, the card is never listed + - verdict back to `task.column !== "in-review"` -> fails, the renamed review lane is filtered out + */ + it("clears a stale merge stamp on a RENAMED review lane", async () => { + const stuck = { + ...shippedCard(), + id: "FN-STALESTAMP", + column: RENAMED_VOCAB.review, + status: "merging", + updatedAt: "2020-01-01T00:00:00.000Z", + } as unknown as Task; + const { store, updateTask } = productionFaithfulStore([stuck]); + + await new SelfHealingManager(store, { rootDir: "/repo" }).recoverStaleMergingStatus(); + + expect(updateTask).toHaveBeenCalledWith("FN-STALESTAMP", expect.objectContaining({ status: null })); + }); + + it("does not clear a merge stamp on a card outside the RENAMED review lanes", async () => { + /* + Non-vacuous companion: without it, a read returning every column would satisfy the case above. A + merge stamp on a wip card is not this sweep's business — recoverInProgressLimbo and the executor own + that lane. + */ + const stuck = { + ...shippedCard(), + id: "FN-STALESTAMP", + column: RENAMED_VOCAB.wip, + status: "merging", + updatedAt: "2020-01-01T00:00:00.000Z", + } as unknown as Task; + const { store, updateTask } = productionFaithfulStore([stuck]); + + await new SelfHealingManager(store, { rootDir: "/repo" }).recoverStaleMergingStatus(); + + expect(updateTask).not.toHaveBeenCalled(); + }); }); diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index 2c23e6818f..80b3fc5a15 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -3223,9 +3223,39 @@ export class SelfHealingManager extends SelfHealingGitEvidence { const now = Date.now(); const activeMergeTaskId = this.options.getActiveMergeTaskId?.() ?? null; - const tasks = await this.store.listTasks({ column: "in-review", slim: true }); + /* + FNXC:WorkflowResolvedColumns 2026-07-31-09:40 (the query-filter class, twenty-first sweep): + Clears a `merging`/`merging-pr` stamp left on a review card with no live merger behind it. The + literal read meant that on a renamed board the stamp was never cleared, so the card read as + mid-merge forever — and the merge-active stamp is what the merger and the dashboard Retry gate both + consult, so the card could neither progress nor be retried by hand. + + The `task.column !== "in-review"` check was redundant while the query pinned the column; under a + resolved read it becomes the per-card verdict, so it converts rather than being deleted. + */ + const staleMergeReviewColumns = await resolveProjectColumnsForRoles(this.store, REVIEW_ROLES); + const staleMergeById = new Map(); + for (const column of staleMergeReviewColumns) { + for (const entry of await this.store.listTasks({ column, slim: true })) staleMergeById.set(entry.id, entry); + } + const tasks = [...staleMergeById.values()]; + /* NARROW WHEN THE CARD CAN ANSWER, BROAD WHEN IT CANNOT (#2891). */ + const staleMergeLanes = new Map>(); + for (const entry of tasks) { + try { + const { ir, source } = await resolveWorkflowIrForTaskWithProvenance(this.store, entry.id); + staleMergeLanes.set( + entry.id, + source === "default" + ? new Set(staleMergeReviewColumns) + : new Set(REVIEW_ROLES.flatMap((role) => [...columnsWithFlag(ir, role)])), + ); + } catch { + staleMergeLanes.set(entry.id, new Set(staleMergeReviewColumns)); + } + } const stale = tasks.filter((task) => { - if (task.column !== "in-review" || task.paused) return false; + if (!(staleMergeLanes.get(task.id) ?? staleMergeReviewColumns).has(task.column) || task.paused) return false; /* FNXC:MergeReliability 2026-07-15-21:45 (FN-8004 follow-up): Staleness now comes from the shared `isStaleMergeActiveStatus` leaf, which the dashboard's