engine: merging was broken outright on a board with a renamed review lane (#2963)

**Not a degraded message — no task on such a board could be merged at
all.**

`getTaskMergeBlocker`'s column-identity check *returns a blocker* when
the task's column is not a review lane. Both merge entry points called
it without `reviewColumns`, so the check ran against the literal
`in-review`:

```
Cannot merge FN-1: task is in 'signoff', must be in 'in-review'
```

`aiMergeTask` (`merger.ts`) and `runAiMerge` (`merger-ai.ts`) turn that
into a thrown error. Every merge on a renamed board fails, with a
message naming a column the board does not have.

## This exact defect was already found once

The helper's own FNXC comment records it, in `moves.ts`:

> *"so on a renamed board that move threw `Cannot move FN-1 to done:
task is in 'signoff', must be in 'in-review'` even though the transition
had just been validated as legal. A half-conversion, where the outer
question is resolved and the inner one is not."*

That fix added the `reviewColumns` option and wired `moves.ts`. **These
two callers were missed** — same shape, one layer out. A fix that adds
an optional parameter is only as good as the call-site sweep that
follows it.

## How it was found

By enumerating the call sites of every lane-taking helper, rather than
trusting the `unwired-lane-parameter` guard. That guard is deliberately
conservative — a mention of the parameter *anywhere* satisfies it — so
**partial** wiring is invisible to it, and `reviewColumns` is mentioned
plentifully elsewhere. This is the method #2956 used on a sibling
defect, applied to every seam I have touched.

## Two sites deliberately unchanged

- **`moves.ts`** passes `skipColumnIdentityCheck: true`. It has already
proven lane identity from resolved IR traits, so supplying lanes *as
well* would be contradictory rather than additive — the helper's comment
is explicit that the two options answer different questions.
- **`isTaskReadyForMerge`** has **zero** production callers. Adding a
parameter there is precisely the unwired-parameter anti-pattern this
program keeps removing.

## Revert result

| | reverted → |
| --- | --- |
| `reviewColumns` at either call | reproduces the shipped string exactly
|

The middle test pins that string deliberately: it is the
operator-visible failure, so if the wiring regresses the test says what
the operator would have seen. A third case checks that supplying lanes
does **not** switch the identity check off — a card in the wip lane is
still blocked, and the message names the resolved lanes rather than a
column the board lacks.

The cases drive `getTaskMergeBlocker` directly: reaching it through the
merge entry points needs a real repo, worktree and merge run, while the
defect is entirely in *which columns the blocker is asked about*. The
wiring itself is covered by tsc and the guard.

## Verification

`pnpm test:gate` 161 + 487 + 13 + 71; `merger` + `merger-ai` +
`self-healing` suites 461; `tsc` engine clean; lint, census `--strict`,
FNXC gate, changesets all clean.
This commit is contained in:
gsxdsm
2026-07-30 21:50:32 -07:00
committed by GitHub
parent 8d6acf1314
commit 7712e0ada2
4 changed files with 110 additions and 4 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Tasks can be merged again on boards whose review column is renamed.
category: fix
dev: Both merge entry points called `getTaskMergeBlocker` without `reviewColumns`, so its column-identity check used the literal `in-review` and returned a blocker for any renamed review lane — `aiMergeTask` and `runAiMerge` then threw `Cannot merge FN-x: task is in '<lane>', must be in 'in-review'`. Both now resolve the task's own review lanes.

View File

@@ -0,0 +1,69 @@
/*
FNXC:WorkflowResolvedColumns 2026-07-30-17:20 (merging was broken outright on a renamed board):
`getTaskMergeBlocker`'s column-identity check RETURNS A BLOCKER when the task's column is not a review
lane. Both merge entry points called it WITHOUT `reviewColumns`, so on a board whose review lane is
renamed the literal check failed and the merge threw:
Cannot merge FN-1: task is in 'signoff', must be in 'in-review'
That is not a degraded message — no task on such a board could be merged at all. The helper's own
comment records this exact defect being found and fixed in `moves.ts`; these two callers were missed,
which is the same half-conversion shape (outer question resolved, inner one still literal).
WHY THE SEAM, NOT THE MERGE. These cases drive `getTaskMergeBlocker` directly. Reaching it through
`aiMergeTask`/`runAiMerge` needs a real git repo, a worktree and a merge run; the defect is entirely in
which columns the blocker is asked about, so the seam is where it is decidable. The wiring at the two
call sites is covered by the unwired-lane-parameter guard plus tsc.
REVERT CHECK, measured: drop `reviewColumns` from either call and the renamed case reports
`task is in 'signoff', must be in 'in-review'` instead of undefined.
*/
import { describe, expect, it } from "vitest";
import { getTaskMergeBlocker } from "@fusion/core";
import type { Task } from "@fusion/core";
function reviewReadyCard(column: string): Task {
return {
id: "FN-MERGE",
column,
paused: false,
status: null,
error: null,
steps: [{ id: "s1", status: "done" }],
workflowStepResults: [],
} as unknown as Task;
}
describe("the merge blocker judges the board's OWN review lanes", () => {
it("does not block a merge-ready card sitting in a RENAMED review lane", () => {
const blocker = getTaskMergeBlocker(reviewReadyCard("signoff"), {
reviewColumns: new Set(["signoff", "in-review"]),
});
expect(blocker).toBeUndefined();
});
it("reproduces the shipped failure when the lanes are not supplied", () => {
/*
This is exactly what both merge entry points did before the fix, and it is the operator-visible
string: the merge threw with this as its reason.
*/
const blocker = getTaskMergeBlocker(reviewReadyCard("signoff"));
expect(blocker).toBe("task is in 'signoff', must be in 'in-review'");
});
it("still blocks a card that is genuinely outside the review lanes", () => {
/*
Non-vacuous companion: supplying lanes must not turn the identity check off — a card in the wip
lane is not merge-ready on any board, and the message names the resolved lanes rather than a
column the board does not have.
*/
const blocker = getTaskMergeBlocker(reviewReadyCard("building"), {
reviewColumns: new Set(["signoff"]),
});
expect(blocker).toBe("task is in 'building', must be in 'signoff'");
});
});

View File

@@ -60,7 +60,7 @@ import {
type MergeTargetResolution, type MergeTargetResolution,
type Settings, type Settings,
type Task, type Task,
type TaskStore, type TaskStore, resolveReviewColumns
} from "@fusion/core"; } from "@fusion/core";
import { selectUserCommentsForAgentContext } from "./agent-user-comments.js"; import { selectUserCommentsForAgentContext } from "./agent-user-comments.js";
import { resolveTaskWorkingBranch } from "./worktree-names.js"; import { resolveTaskWorkingBranch } from "./worktree-names.js";
@@ -1190,7 +1190,22 @@ export async function runAiMerge(
if (await isAlreadyFinalizedColumn(store, task)) { if (await isAlreadyFinalizedColumn(store, task)) {
return noOpResult(task, branch, "already-finalized"); return noOpResult(task, branch, "already-finalized");
} }
const blocker = getTaskMergeBlocker(task, { manual: options.manual === true }); /*
FNXC:WorkflowResolvedColumns 2026-07-30-17:10 (MERGING WAS BROKEN ON A RENAMED BOARD):
`getTaskMergeBlocker`'s identity check RETURNS A BLOCKER when the column is not a review lane, so
calling it without `reviewColumns` on a board whose review lane is renamed produced
`Cannot merge FN-x: task is in 'signoff', must be in 'in-review'` — and the merge threw. Not a
degraded message: no task could be merged at all.
The helper's own comment records this exact defect being fixed in `moves.ts`; these two merge
entry points were missed. Resolve the task's own review lanes and pass them.
*/
const aiReviewColumns = new Set<string>(["in-review"]);
try {
const aiIr = await resolveWorkflowIrForTask(store, taskId);
if (aiIr) for (const id of resolveReviewColumns(aiIr)) aiReviewColumns.add(id);
} catch { /* degraded: the legacy id above still answers */ }
const blocker = getTaskMergeBlocker(task, { manual: options.manual === true, reviewColumns: aiReviewColumns });
if (blocker) throw new Error(`Cannot merge ${taskId}: ${blocker}`); if (blocker) throw new Error(`Cannot merge ${taskId}: ${blocker}`);
const settings = await store.getSettings(); const settings = await store.getSettings();

View File

@@ -110,7 +110,7 @@ import {
resolveCompleteColumn, resolveCompleteColumn,
resolveMergeOrchestrationColumn, resolveMergeOrchestrationColumn,
resolveTaskLifecycleColumns, resolveTaskLifecycleColumns,
type WorkflowIr, type WorkflowIr, resolveReviewColumns
} from "@fusion/core"; } from "@fusion/core";
import { evaluateAutoMergeFactProviders } from "./auto-merge-fact-providers.js"; import { evaluateAutoMergeFactProviders } from "./auto-merge-fact-providers.js";
import { resolveMergePolicy } from "./merge-trait.js"; import { resolveMergePolicy } from "./merge-trait.js";
@@ -6642,7 +6642,22 @@ export async function aiMergeTask(
branchDeleted: false, branchDeleted: false,
}; };
} }
const mergeBlocker = getTaskMergeBlocker(task, { manual: options.manual === true }); /*
FNXC:WorkflowResolvedColumns 2026-07-30-17:10 (MERGING WAS BROKEN ON A RENAMED BOARD):
`getTaskMergeBlocker`'s identity check RETURNS A BLOCKER when the column is not a review lane, so
calling it without `reviewColumns` on a board whose review lane is renamed produced
`Cannot merge FN-x: task is in 'signoff', must be in 'in-review'` — and the merge threw. Not a
degraded message: no task could be merged at all.
The helper's own comment records this exact defect being fixed in `moves.ts`; these two merge
entry points were missed. Resolve the task's own review lanes and pass them.
*/
const mergeReviewColumns = new Set<string>(["in-review"]);
try {
const mergeIr = await resolveWorkflowIrForTask(store, taskId);
if (mergeIr) for (const id of resolveReviewColumns(mergeIr)) mergeReviewColumns.add(id);
} catch { /* degraded: the legacy id above still answers */ }
const mergeBlocker = getTaskMergeBlocker(task, { manual: options.manual === true, reviewColumns: mergeReviewColumns });
if (mergeBlocker) { if (mergeBlocker) {
throw new Error(`Cannot merge ${taskId}: ${mergeBlocker}`); throw new Error(`Cannot merge ${taskId}: ${mergeBlocker}`);
} }