Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions packages/engine/src/__tests__/merger-ai.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
13 changes: 13 additions & 0 deletions packages/engine/src/merger-ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => "");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Bare Ref Names Can Miscount

This early return uses bare branch names for the rev-list range, while the surrounding code verifies refs/heads/${integrationBranch} explicitly. If a tag or other ref shares the integration branch name, Git can resolve the range against that ref and report 0, causing landOneRepo to return empty before the normal merge path even though the task branch still has work relative to the branch head.

Suggested change
const aheadRaw = await git(["rev-list", "--count", `${integrationBranch}..${branch}`], repoRootDir).catch(() => "");
const aheadRaw = await git(["rev-list", "--count", `refs/heads/${integrationBranch}..refs/heads/${branch}`], repoRootDir).catch(() => "");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Qualify branch refs

The empty-branch check still lets Git resolve the integration side as a bare ref. When a repo has a tag or other ref named like the integration branch, rev-list can count from that ref instead of refs/heads/<integrationBranch> and return 0. This branch then returns outcome: "empty", skips the merge path, and finalizes the task as a no-op even though the task branch still has commits relative to the real branch head.

Suggested change
const aheadRaw = await git(["rev-list", "--count", `${integrationBranch}..${branch}`], repoRootDir).catch(() => "");
const aheadRaw = await git(["rev-list", "--count", `refs/heads/${integrationBranch}..refs/heads/${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;
Expand Down