engine: restore the stall-signal lane wiring #2951 dropped (and the test that proved it) (#2961)

**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:
gsxdsm
2026-07-30 21:45:17 -07:00
committed by GitHub
parent 1f0d371228
commit 01f081e8aa
3 changed files with 96 additions and 1 deletions

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

View File

@@ -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);
});
});

View File

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