**My defect, shipped in #2951 — and the same family as the one #2956 just fixed.** Found by auditing my own seams after that, not by a failing check. ## What is on `main` right now `surfaceInReviewStalls` reads the project's review columns (converted in #2951), then calls `getInReviewStallReason` **without** `reviewColumns`. The classifier falls back to the literal `in-review`, returns no signal for a renamed-lane card, and the sweep surfaces nothing. That is the textbook **missed pair** this program has a ratchet for: a widened read handing every renamed-board card to a literal classifier. The resolve work happens and is then discarded. On a renamed board an operator sees no stall warnings at all. #2951's conflict resolution dropped two things together: - the per-card `stallLanes` map and the `reviewColumns` argument - **the test that proved the wiring** ## Why nothing caught it **A deleted test cannot fail.** I verified that rebase by comparing the 68 conflict *hunks* — stripping FNXC stamps, confirming 0 of 68 had real content differences — and then ran the gate. The gate passed precisely because the proving test had gone with the code it proved. I verified the conflicts. I did not verify the outcome. Those are different things, and the difference is invisible when the evidence disappears alongside the feature. The `unwired-lane-parameter` guard cannot catch this either, by design: it is deliberately conservative — a mention of the parameter *anywhere* satisfies it — so **partial** wiring is outside its reach. `reviewColumns` is mentioned plenty in `reads.ts`, so the guard is green while this call site goes unwired. ## How I found it The check #2956 used on the sibling defect, applied to every lane seam I have touched: enumerate each function's **call sites** and confirm each one carries the parameter. That enumeration also flags several other call sites without `reviewColumns`/lane arguments (`merger.ts`, `moves.ts`, `auto-merge-finalization.ts`, `merger-ai.ts`, `project-engine.ts`) — I have **not** touched those here; they need per-site judgement about whether the lane answer is even available, and that is a separate change rather than a sweep. ## Revert result | | reverted → | | --- | --- | | `reviewColumns` at the call (i.e. exactly what #2951 shipped) | fails the restored test | ## Verification `pnpm test:gate` 161 + 487 + 13 + 71; blindness suite 71; `self-healing.test.ts` 412; `tsc` engine clean; lint, census `--strict`, FNXC gate, changesets all clean.
This commit is contained in:
7
.changeset/restore-stall-lane-wiring.md
Normal file
7
.changeset/restore-stall-lane-wiring.md
Normal file
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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<string, ReadonlySet<string>>();
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user