diff --git a/packages/engine/src/__tests__/merger-ai.test.ts b/packages/engine/src/__tests__/merger-ai.test.ts index 1b509a4bd..3953c918c 100644 --- a/packages/engine/src/__tests__/merger-ai.test.ts +++ b/packages/engine/src/__tests__/merger-ai.test.ts @@ -434,6 +434,32 @@ describe("runAiMerge", () => { expect(store.moveTask).toHaveBeenCalledWith("FN-1", "done", expect.objectContaining({ moveSource: "engine", preserveProgress: true })); }); + it("short-circuits a zero-commits-ahead branch before the clean-room/merge-agent churn (empty-branch wedge)", async () => { + const { dir } = initRepoWithBranch({ branch: "fusion/fn-1" }); + // Move the task branch back onto main's tip → 0 commits ahead of the + // integration branch (the empty-branch shape a coding agent that produced + // no commits leaves behind). + git(dir, "branch -f fusion/fn-1 main"); + const { store } = makeStore(dir); + const mainBefore = git(dir, "rev-parse main"); + const mergeAgent = vi.fn(async () => { /* would build a squash if reached */ }); + + const result = await runAiMerge(store, dir, "FN-1", { manual: true }, { + mergeAgent, + reviewAgent: vi.fn(async () => "REVIEW_VERDICT: approve"), + }); + + // The zero-ahead short-circuit fires BEFORE the clean-room build + dep + // install, so the merge agent is never invoked. In prod that dep install + // throws and gets transient-retried to exhaustion, terminally parking the + // card failed — skipping it is the wedge fix. + expect(mergeAgent).not.toHaveBeenCalled(); + expect(result.noOp).toBe(true); + expect(result.merged).toBe(false); + expect(git(dir, "rev-parse main")).toBe(mainBefore); + expect(store.moveTask).toHaveBeenCalledWith("FN-1", "done", expect.objectContaining({ moveSource: "engine", preserveProgress: true })); + }); + it("demotes a no-commits task with skipped-out work instead of AI empty-merge finalizing done", async () => { const { dir } = initRepoWithBranch({ branch: "fusion/fn-1" }); git(dir, "merge -q fusion/fn-1"); diff --git a/packages/engine/src/merger-ai.ts b/packages/engine/src/merger-ai.ts index 27c5ab95a..306a426d8 100644 --- a/packages/engine/src/merger-ai.ts +++ b/packages/engine/src/merger-ai.ts @@ -622,6 +622,19 @@ export async function landOneRepo( throwIfAborted(signal, taskId); const tipSha = await git(["rev-parse", "--verify", `refs/heads/${integrationBranch}`], repoRootDir); + // Short-circuit a branch with zero commits ahead of the integration tip + // BEFORE building a clean room + installing deps. A truly-empty branch would + // reach the identical `outcome: "empty"` return below via mergeAndReview → + // no squashSha, but only after the throw-prone dep-install churn (which a + // non-workspace land hard-fails), so the merge gets transient-retried to + // exhaustion and the card is parked failed. Only short-circuit on a CONFIDENT + // 0: a git failure yields "" → parseInt → NaN (≠ 0) and falls through. + const aheadRaw = await git(["rev-list", "--count", `${integrationBranch}..${branch}`], repoRootDir).catch(() => ""); + if (Number.parseInt(aheadRaw.trim(), 10) === 0) { + await audit.git({ type: "merge:ai-empty", target: integrationBranch, metadata: { taskId, tipSha } }); + return { outcome: "empty", tipSha, integrationBranch }; + } + // 1. Clean-room worktree at the integration tip. let mergeRoot: string | undefined; let worktreeAdded = false;