**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}'`);
|
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);
|
for (const task of await this.store.listTasks({ column, slim: false })) stallCandidatesById.set(task.id, task);
|
||||||
}
|
}
|
||||||
const tasks = [...stallCandidatesById.values()];
|
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;
|
let surfaced = 0;
|
||||||
|
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
if (task.deletedAt) continue;
|
if (task.deletedAt) continue;
|
||||||
if (!allowsAutoMergeProcessing(task, settings)) continue;
|
if (!allowsAutoMergeProcessing(task, settings)) continue;
|
||||||
const signal = getInReviewStallReason(task, {
|
const signal = getInReviewStallReason(task, {
|
||||||
|
reviewColumns: stallLanes.get(task.id) ?? stallReviewColumns,
|
||||||
now: cycleStartMs,
|
now: cycleStartMs,
|
||||||
activeMergeTaskId,
|
activeMergeTaskId,
|
||||||
executingTaskIds,
|
executingTaskIds,
|
||||||
|
|||||||
Reference in New Issue
Block a user