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.
|
being a ratchet silently.
|
||||||
*/
|
*/
|
||||||
listWorkflowDefinitions: vi.fn(async () => [{ ir: RENAMED_IR }]),
|
listWorkflowDefinitions: vi.fn(async () => [{ ir: RENAMED_IR }]),
|
||||||
|
logEntry: vi.fn(async () => undefined),
|
||||||
}) as unknown as TaskStore & EventEmitter;
|
}) as unknown as TaskStore & EventEmitter;
|
||||||
return { store, listTasks };
|
return { store, listTasks, updateTask: store.updateTask as unknown as ReturnType<typeof vi.fn> };
|
||||||
}
|
}
|
||||||
|
|
||||||
function shippedCard(): Task {
|
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();
|
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 now = Date.now();
|
||||||
const activeMergeTaskId = this.options.getActiveMergeTaskId?.() ?? null;
|
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) => {
|
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):
|
FNXC:MergeReliability 2026-07-15-21:45 (FN-8004 follow-up):
|
||||||
Staleness now comes from the shared `isStaleMergeActiveStatus` leaf, which the dashboard's
|
Staleness now comes from the shared `isStaleMergeActiveStatus` leaf, which the dashboard's
|
||||||
|
|||||||
Reference in New Issue
Block a user