fix(merger): stop phantom-merge guard stranding tasks whose commit already landed
Tasks were getting stuck in In Review with "verification fix succeeded but no merge commit could be created" even though the merge commit was already on main. Verification failures on attempt 1 were being swallowed by the smart- conflict-resolution retry path, triggering attempt 2 with a stale baseline, and the in-merge-fix finalizer would then fail its phantom-merge check. - Propagate VerificationError out of executeMergeAttempt so the in-merge fix runs once on attempt 1 with the correct preAttemptHeadSha baseline. - In commitOrAmendMergeWithFixes, recognize "task already on HEAD" via the Fusion-Task-Id trailer (line-anchored match) and treat the no-progress finalize as success instead of tripping the guard. - Add real-git regression test plus update merger.test.ts call counts to reflect the (now correctly absent) attempt-2 AI agent. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -2182,6 +2182,17 @@ export async function commitOrAmendMergeWithFixes(
|
||||
const headMoved = currentHead !== preAttemptHeadSha;
|
||||
|
||||
if (!hasStaged && !headMoved) {
|
||||
// Defense-in-depth: if HEAD already carries this task's `Fusion-Task-Id`
|
||||
// trailer, the merge commit landed on a prior code path (e.g. AI commit
|
||||
// in an earlier attempt) and there's simply nothing left for the fix to
|
||||
// fold in. Record success rather than tripping the phantom-merge guard
|
||||
// and stranding the task in In Review when the work is already on main.
|
||||
if (await headCarriesTaskIdTrailer(rootDir, taskId)) {
|
||||
mergerLog.log(
|
||||
`${taskId}: HEAD already carries Fusion-Task-Id trailer — treating in-merge fix finalize as no-op success`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
// Truly nothing happened — neither a commit nor staged changes. Refuse
|
||||
// to fabricate a successful merge: the caller will report failure.
|
||||
mergerLog.warn(
|
||||
@@ -2709,6 +2720,28 @@ function buildTaskIdTrailerArg(taskId: string): string {
|
||||
return ` -m "${FUSION_TASK_ID_TRAILER_KEY}: ${taskId}"`;
|
||||
}
|
||||
|
||||
/** True iff HEAD's commit message contains the `Fusion-Task-Id: <taskId>`
|
||||
* trailer. Used by the in-merge fix finalizer to recognize that the merge
|
||||
* commit already landed on HEAD (e.g. via the AI commit on a prior attempt)
|
||||
* before tripping the phantom-merge guard. Best-effort: any error returns
|
||||
* false so callers fall back to the conservative "refuse to fabricate" path. */
|
||||
async function headCarriesTaskIdTrailer(rootDir: string, taskId: string): Promise<boolean> {
|
||||
try {
|
||||
const { stdout } = await execAsync("git log -1 --pretty=%B HEAD", {
|
||||
cwd: rootDir,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
// Anchor to line boundaries so e.g. FN-37 doesn't match a body line
|
||||
// mentioning FN-3727. Trailer lines are produced by git itself, so the
|
||||
// exact `Key: Value` form is what we look for.
|
||||
const escapedId = taskId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const pattern = new RegExp(`(?:^|\\n)${FUSION_TASK_ID_TRAILER_KEY}: ${escapedId}\\s*(?:\\n|$)`);
|
||||
return pattern.test(stdout);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Idempotently add the Fusion-Task-Id trailer to HEAD's commit. Used after
|
||||
* the AI agent commits to guarantee the trailer is present even when the
|
||||
* agent didn't include it (especially under includeTaskIdInCommit=false,
|
||||
@@ -5576,13 +5609,25 @@ async function executeMergeAttempt(
|
||||
if (error.message?.includes("Build verification failed")) {
|
||||
throw error; // Fatal - don't retry build failures
|
||||
}
|
||||
|
||||
|
||||
// Check if it's a non-conflict merge failure
|
||||
if (error.message?.includes("Merge failed")) {
|
||||
throw error; // Fatal
|
||||
}
|
||||
|
||||
// For attempt 1, return false to trigger attempt 2
|
||||
// VerificationError must propagate so mergeAttempt's catch can run the
|
||||
// in-merge fix against THIS attempt's preAttemptHeadSha baseline. Falling
|
||||
// through to the attempt-1 retry path here would swallow the error,
|
||||
// trigger attempt 2 with a stale baseline (= AI's commit from attempt 1),
|
||||
// and then the in-merge fix's finalizer would see !hasStaged && !headMoved
|
||||
// and trip the phantom-merge guard even though the task's content is
|
||||
// already on HEAD. Retrying with auto-conflict-resolution can't help a
|
||||
// verification failure anyway — there are no conflicts to resolve.
|
||||
if (error?.name === "VerificationError") {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// For attempt 1, return false to trigger attempt 2 (conflict-only path)
|
||||
if (attemptNum === 1 && smartConflictResolution) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user