fix(FN-5633): fail loudly when an executed, never-merged task has no branch

The AI merge path treated a missing task branch as a benign no-op. That's
correct when the task was never executed or already merged (branch cleaned up
on a re-process), but if the task WAS executed (a baseCommitSha was recorded)
and has no recorded merge, the branch should still exist — its work appears
lost. Now that case throws instead of silently marking the task done; the
benign cases still finalize as a no-op (reason "already-merged" / "no-branch").

Tests: executed+never-merged → throws; already-merged → no-op done;
never-executed → no-op done.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-28 19:28:57 -07:00
parent 29ac58f8d0
commit be0be507b3
2 changed files with 59 additions and 3 deletions

View File

@@ -185,6 +185,39 @@ describe("runAiMerge", () => {
expect(git(dir, "rev-parse main")).toBe(mainBefore);
});
it("fails loudly when an executed, never-merged task has no branch (possible lost work)", async () => {
const { dir } = initRepoWithBranch({ branch: "fusion/fn-1" });
// branch points at a ref that doesn't exist; task was executed (baseCommitSha) and never merged.
const { store } = makeStore(dir, { branch: "fusion/ghost", baseCommitSha: "0123456789abcdef" });
await expect(runAiMerge(store, dir, "FN-1", { manual: true }, {
mergeAgent: vi.fn(), reviewAgent: vi.fn(async () => "REVIEW_VERDICT: approve"),
})).rejects.toThrow(/work appears lost/);
expect(store.moveTask).not.toHaveBeenCalled();
});
it("finalizes as a no-op when an already-merged task's branch is gone (re-process)", async () => {
const { dir } = initRepoWithBranch({ branch: "fusion/fn-1" });
const { store } = makeStore(dir, { branch: "fusion/ghost", baseCommitSha: "0123456789abcdef", mergeDetails: { mergeConfirmed: true } });
const result = await runAiMerge(store, dir, "FN-1", { manual: true }, {
mergeAgent: vi.fn(), reviewAgent: vi.fn(),
});
expect(result.noOp).toBe(true);
expect(store.moveTask).toHaveBeenCalledWith("FN-1", "done");
});
it("finalizes as a no-op when a never-executed task has no branch", async () => {
const { dir } = initRepoWithBranch({ branch: "fusion/fn-1" });
const { store } = makeStore(dir, { branch: "fusion/ghost" }); // no baseCommitSha → never executed
const result = await runAiMerge(store, dir, "FN-1", { manual: true }, {
mergeAgent: vi.fn(), reviewAgent: vi.fn(),
});
expect(result.noOp).toBe(true);
expect(store.moveTask).toHaveBeenCalledWith("FN-1", "done");
});
it("throws a clear error when the task's target branch has no local ref", async () => {
const { dir } = initRepoWithBranch({ branch: "fusion/fn-1" });
const { store } = makeStore(dir, { baseBranch: "release/9.9" }); // never created locally

View File

@@ -599,9 +599,32 @@ export async function runAiMerge(
// Branch must exist to merge it.
if (!(await gitOk(["rev-parse", "--verify", `refs/heads/${branch}`], projectRootDir))) {
await audit.git({ type: "merge:ai-no-branch", target: branch, metadata: { taskId } });
const done = await finalizeTask(store, taskId, noOpResult(task, branch, "no-branch"));
return done;
// A missing branch is benign in two cases — the task was never executed
// (nothing to merge), or it already merged and the branch was cleaned up
// (a re-processed task). But if the task WAS executed (a baseCommitSha was
// recorded when it got a worktree) and was NEVER merged (no recorded
// landing), the branch should still exist — its work appears lost. Fail
// loudly rather than silently marking the task done.
const wasExecuted = !!task.baseCommitSha;
const alreadyMerged = task.mergeDetails?.mergeConfirmed === true || !!task.mergeDetails?.commitSha;
if (wasExecuted && !alreadyMerged) {
await audit.git({
type: "merge:ai-no-branch",
target: branch,
metadata: { taskId, kind: "executed-branch-missing", baseCommitSha: task.baseCommitSha },
});
throw new Error(
`AI merge for ${taskId}: branch "${branch}" is missing, but the task was executed `
+ `(baseCommitSha ${String(task.baseCommitSha).slice(0, 8)}) and has no recorded merge — its work appears lost. `
+ `Not finalizing; investigate.`,
);
}
await audit.git({
type: "merge:ai-no-branch",
target: branch,
metadata: { taskId, kind: alreadyMerged ? "already-merged" : "never-executed" },
});
return await finalizeTask(store, taskId, noOpResult(task, branch, alreadyMerged ? "already-merged" : "no-branch"));
}
// The target branch must exist as a LOCAL ref to merge into it — surface a