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:
7
.changeset/self-healing-stale-merging-status-query.md
Normal file
7
.changeset/self-healing-stale-merging-status-query.md
Normal 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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user