self-healing: a card stuck mid-merge could not be retried on a renamed board (twenty-first sweep) (#2912)

`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.

**The operator's escape hatch was closed by the same bug that caused the
stall.** That stamp is consulted by the merger *and* by the dashboard's
manual Retry gate, so the card could neither progress on its own nor be
retried by hand.

## The redundant guard converts

`task.column !== "in-review"` was redundant while the query pinned the
column; under a resolved read it becomes the per-card verdict. Carries
the #2891 shape — **narrow when the card can answer, broad when it
cannot**.

## Fixture note worth keeping

`updatedAt` in the test is deliberately ancient.
`isStaleMergeActiveStatus` requires the stamp to have sat untouched for
`minAgeMs`, so a fresh fixture would be filtered out for a reason that
has nothing to do with lanes — and would then have passed with the fix
reverted. That is the shape of most of the vacuous assertions on this
branch: a *later* filter rejecting the card, masking whether the lane
logic worked at all.

## Revert results

Each applied alone and the file re-run:

| conversion | reverted → |
| --- | --- |
| the resolved read | fails — the card is never listed |
| the per-card verdict | fails — the renamed review lane is filtered out
|

A non-vacuous companion (same stamp on a wip card → untouched) rules out
a read that returns everything; a merge stamp in the wip lane belongs to
`recoverInProgressLimbo` and the executor, not here.

## Verification

`pnpm test:gate` 161 + 487 + 13 + 71, plus `self-healing.test.ts` 412;
`tsc` engine clean; `pnpm lint`, `check:changesets`, census `--strict`
and `check-sql-column-literals` clean, each run explicitly.
This commit is contained in:
gsxdsm
2026-07-30 18:16:14 -07:00
committed by GitHub
parent 1adf886f04
commit f6e368205e
3 changed files with 90 additions and 3 deletions

View File

@@ -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.

View File

@@ -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<typeof vi.fn> };
}
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();
});
});

View File

@@ -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<string, Task>();
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<string, Set<string>>();
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