feat(FN-3842): complete Step 2 — add phantom-guard diagnostics

This commit is contained in:
Fusion
2026-05-09 08:50:17 -07:00
committed by gsxdsm
parent fb486e0af9
commit 78c88a58b8

View File

@@ -2563,6 +2563,10 @@ async function buildDeterministicMergeMessage(params: {
*
* @internal Exported for integration tests only — not part of the public API.
*/
type MergeFinalizeResult =
| { ok: true; reason: "completed" | "head-task-trailer" | "branch-already-merged" }
| { ok: false; reason: "fix-produced-no-content" | "unknown-phantom" };
export async function commitOrAmendMergeWithFixes(
rootDir: string,
taskId: string,
@@ -2577,7 +2581,7 @@ export async function commitOrAmendMergeWithFixes(
aiSummary?: string | null,
aiSubject?: string | null,
fixModifiedFiles: ReadonlySet<string> = new Set(),
): Promise<boolean> {
): Promise<MergeFinalizeResult> {
try {
// Build an allowlist of paths we are permitted to stage.
// Allowlist = (already staged by squash) (unstaged ∩ fixModifiedFiles)
@@ -2681,16 +2685,59 @@ export async function commitOrAmendMergeWithFixes(
const headMoved = currentHead !== preAttemptHeadSha;
if (!hasStaged && !headMoved) {
// FN-1858 guardrail: never claim merge success when we cannot prove this
// task produced commit content. This finalize path distinguishes three
// terminal states: (1) committed-by-AI (HEAD already has this task ID),
// (2) no-op fix where squash state was cleared and must be restored, and
// (3) real phantom where there is truly no task content to commit.
if (await headCarriesTaskIdTrailer(rootDir, taskId)) {
// FN-1858/FN-3842 guardrail: never claim merge success when we cannot
// prove content landed. Check known-success states first, then fallback.
const { stdout: branchTipOut } = await execAsync(`git rev-parse ${branch}`, {
cwd: rootDir,
encoding: "utf-8",
});
const branchTip = branchTipOut.trim();
const trailerOnHead = await headCarriesTaskIdTrailer(rootDir, taskId);
const { stdout: mergeBaseOut } = await execAsync(`git merge-base ${branchTip} ${preAttemptHeadSha}`, {
cwd: rootDir,
encoding: "utf-8",
});
const mergeBase = mergeBaseOut.trim();
const { stdout: diffStatOut } = await execAsync(`git diff --stat ${preAttemptHeadSha}..${branch}`, {
cwd: rootDir,
encoding: "utf-8",
});
const diffStatSummary = diffStatOut.split("\n").slice(0, 20).join("\n");
const { stdout: stagedCountOut } = await execAsync("git diff --cached --name-only", {
cwd: rootDir,
encoding: "utf-8",
});
const { stdout: unstagedCountOut } = await execAsync("git diff --name-only", {
cwd: rootDir,
encoding: "utf-8",
});
const { stdout: untrackedCountOut } = await execAsync("git ls-files --others --exclude-standard", {
cwd: rootDir,
encoding: "utf-8",
});
const stagedCount = stagedCountOut.split("\n").filter(Boolean).length;
const unstagedCount = unstagedCountOut.split("\n").filter(Boolean).length;
const untrackedCount = untrackedCountOut.split("\n").filter(Boolean).length;
const diagnostics =
`${taskId}: phantom-guard diagnostics\n` +
` taskId=${taskId}\n` +
` preAttemptHeadSha=${preAttemptHeadSha}\n` +
` currentHead=${currentHead}\n` +
` branch=${branch}\n` +
` branchTip=${branchTip}\n` +
` mergeBase(branchTip, preAttemptHeadSha)=${mergeBase}\n` +
` headCarriesTaskIdTrailer=${String(trailerOnHead)}\n` +
` stagedCount=${stagedCount} unstagedCount=${unstagedCount} untrackedCount=${untrackedCount}\n` +
` fixModifiedFilesCount=${fixModifiedFiles.size}\n` +
` diffStat(preAttemptHeadSha..branch)\n${diffStatSummary || " <empty>"}`;
mergerLog.warn(diagnostics);
if (trailerOnHead) {
mergerLog.log(
`${taskId}: HEAD already carries Fusion-Task-Id trailer — treating in-merge fix finalize as no-op success`,
);
return true;
return { ok: true, reason: "head-task-trailer" };
}
// No commit and no staged content can still be recoverable when the
@@ -2720,10 +2767,9 @@ export async function commitOrAmendMergeWithFixes(
});
if (restoredStagedOut.trim().length === 0) {
mergerLog.warn(
`${taskId}: refusing to record merge — no commit was created and no changes are staged. ` +
`This usually means the AI agent never ran git commit and the in-merge fix had nothing to add.`,
`${taskId}: refusing to record merge — no commit was created and no changes are staged after squash-restore.`,
);
return false;
return { ok: false, reason: "fix-produced-no-content" };
}
mergerLog.log(`${taskId}: restored squash state after no-op verification fix; proceeding to commit`);
@@ -2765,7 +2811,7 @@ export async function commitOrAmendMergeWithFixes(
{ cwd: rootDir },
);
mergerLog.log(`${taskId}: created fresh merge commit after verification fix (no prior commit to amend)`);
return true;
return { ok: true, reason: "completed" };
}
// HEAD moved — AI agent committed already. Amend with deterministic
@@ -2776,11 +2822,11 @@ export async function commitOrAmendMergeWithFixes(
{ cwd: rootDir },
);
mergerLog.log(`${taskId}: amended merge commit with verification fixes (deterministic message)`);
return true;
return { ok: true, reason: "completed" };
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
mergerLog.warn(`${taskId}: failed to finalize merge commit: ${errorMessage}`);
return false;
return { ok: false, reason: "unknown-phantom" };
}
}
@@ -5062,12 +5108,17 @@ export async function aiMergeTask(
aiMergeSubject,
verificationFixModifiedFiles,
);
if (!finalized) {
if (!finalized.ok) {
// Phantom-merge guard: refused to fabricate a commit. Reset
// any leftover squash state and propagate failure.
const { stdout: currentHeadOut } = await execAsync("git rev-parse HEAD", { cwd: rootDir, encoding: "utf-8" });
const { stdout: branchTipOut } = await execAsync(`git rev-parse ${branch}`, { cwd: rootDir, encoding: "utf-8" });
resetMergeWithWarn(rootDir, taskId, "verification-fix finalize");
const classification = finalized.reason === "fix-produced-no-content"
? "fix produced no content"
: "unknown phantom";
throw new Error(
`${taskId}: verification fix succeeded but no merge commit could be created — refusing to mark merge complete.`,
`${taskId}: verification fix finalize failed (${classification}); preAttemptHeadSha=${preAttemptHeadSha}; currentHead=${currentHeadOut.trim()}; branch=${branch}; branchTip=${branchTipOut.trim()}.`,
);
}
return true; // Merge succeeds
@@ -5176,14 +5227,19 @@ export async function aiMergeTask(
aiMergeSubject,
buildFixModifiedFiles,
);
if (!finalized) {
if (!finalized.ok) {
// Phantom-merge guard: the verification fix passed but no
// commit could be produced (no staged content + HEAD never
// moved). Reset and propagate failure rather than silently
// mutating a previous task's commit.
const { stdout: currentHeadOut } = await execAsync("git rev-parse HEAD", { cwd: rootDir, encoding: "utf-8" });
const { stdout: branchTipOut } = await execAsync(`git rev-parse ${branch}`, { cwd: rootDir, encoding: "utf-8" });
resetMergeWithWarn(rootDir, taskId, "build-verification fix finalize");
const classification = finalized.reason === "fix-produced-no-content"
? "fix produced no content"
: "unknown phantom";
throw new Error(
`${taskId}: build verification fix succeeded but no merge commit could be created — refusing to mark merge complete.`,
`${taskId}: build verification fix finalize failed (${classification}); preAttemptHeadSha=${preAttemptHeadSha}; currentHead=${currentHeadOut.trim()}; branch=${branch}; branchTip=${branchTipOut.trim()}.`,
);
}
return true; // Merge succeeds