From d934273072407567b4f88ee532de778e841ec914 Mon Sep 17 00:00:00 2001 From: Fusion Date: Fri, 15 May 2026 00:03:01 -0700 Subject: [PATCH] =?UTF-8?q?feat(FN-4559):=20complete=20Step=201=20?= =?UTF-8?q?=E2=80=94=20shared=20detector=20and=20finalize=20classification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fusion-Task-Id: FN-4559 Fusion-Task-Lineage: 9276f683-657c-4604-aaef-143da488b287 --- ...r-verification-fix-already-on-main.test.ts | 79 ++++++ .../engine/src/already-merged-detector.ts | 234 ++++++++++++++++++ packages/engine/src/merger.ts | 46 +++- packages/engine/src/self-healing.ts | 218 +--------------- 4 files changed, 359 insertions(+), 218 deletions(-) create mode 100644 packages/engine/src/__tests__/merger-verification-fix-already-on-main.test.ts create mode 100644 packages/engine/src/already-merged-detector.ts diff --git a/packages/engine/src/__tests__/merger-verification-fix-already-on-main.test.ts b/packages/engine/src/__tests__/merger-verification-fix-already-on-main.test.ts new file mode 100644 index 000000000..dc508d44e --- /dev/null +++ b/packages/engine/src/__tests__/merger-verification-fix-already-on-main.test.ts @@ -0,0 +1,79 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { execSync } from "node:child_process"; +import { commitOrAmendMergeWithFixes } from "../merger.js"; +import { DEFAULT_SETTINGS } from "@fusion/core"; + +function git(dir: string, cmd: string): string { + return execSync(cmd, { cwd: dir, stdio: "pipe" }).toString().trim(); +} + +const created = new Set(); +afterEach(() => { + for (const dir of created) rmSync(dir, { recursive: true, force: true }); + created.clear(); +}); + +function mkRepo(): string { + const dir = mkdtempSync(join(tmpdir(), "fusion-test-merge-already-on-main-")); + created.add(dir); + git(dir, "git init -b main"); + git(dir, 'git config user.email "test@example.com"'); + git(dir, 'git config user.name "Test"'); + writeFileSync(join(dir, "README.md"), "seed\n"); + git(dir, "git add README.md"); + git(dir, 'git commit -m "chore: init"'); + return dir; +} + +describe("commitOrAmendMergeWithFixes already-on-main recovery", () => { + it("returns branch-already-merged-on-main when task trailer exists on main but branch tip is misbound", async () => { + const dir = mkRepo(); + + writeFileSync(join(dir, "README.md"), "other\n"); + git(dir, "git add README.md"); + git(dir, 'git commit -m "feat(FN-4545): unrelated"'); + const unrelatedSha = git(dir, "git rev-parse HEAD"); + + writeFileSync(join(dir, "task-file.txt"), "task content\n"); + git(dir, "git add task-file.txt"); + git( + dir, + 'git commit -m "feat(FN-4553): landed task" -m "Fusion-Task-Id: FN-4553" -m "Fusion-Task-Lineage: lineage-4553"', + ); + const landedSha = git(dir, "git rev-parse HEAD"); + + writeFileSync(join(dir, "post.txt"), "post\n"); + git(dir, "git add post.txt"); + git(dir, 'git commit -m "chore: post-landing commit"'); + const preAttemptHeadSha = git(dir, "git rev-parse HEAD"); + + git(dir, `git branch fusion/fn-4553 ${unrelatedSha}`); + + const result = await commitOrAmendMergeWithFixes( + dir, + "FN-4553", + "fusion/fn-4553", + "feat(FN-4553): finalize", + true, + preAttemptHeadSha, + "", + undefined, + { ...DEFAULT_SETTINGS, commitAuthorEnabled: false }, + undefined, + null, + null, + new Set(), + ); + + expect(result).toEqual({ + ok: true, + reason: "branch-already-merged-on-main", + mergeSha: landedSha, + strategy: "trailer", + }); + expect(git(dir, "git rev-parse HEAD")).toBe(preAttemptHeadSha); + }); +}); diff --git a/packages/engine/src/already-merged-detector.ts b/packages/engine/src/already-merged-detector.ts new file mode 100644 index 000000000..4bea0b72c --- /dev/null +++ b/packages/engine/src/already-merged-detector.ts @@ -0,0 +1,234 @@ +import { exec, execSync } from "node:child_process"; +import { promisify } from "node:util"; + +const execAsync = promisify(exec); + +export type AlreadyMergedDetectionStrategy = "trailer" | "ancestry" | "patch-id" | "tree-equal"; + +export interface AlreadyMergedLookupInput { + taskId: string; + lineageId?: string; + repoDir: string; + baseBranch: string; + taskBranch?: string; + baseCommitSha?: string; +} + +export interface AlreadyMergedLookupResult { + sha: string; + strategy: AlreadyMergedDetectionStrategy; +} + +interface DetectAlreadyLandedInput { + rootDir: string; + taskId: string; + lineageId?: string; + baseBranch: string; + taskBranch?: string; + baseCommitSha?: string; +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'`; +} + +export async function findAlreadyMergedTaskCommit( + input: AlreadyMergedLookupInput, +): Promise { + const { taskId, lineageId, repoDir, baseBranch, taskBranch, baseCommitSha } = input; + + try { + if (lineageId) { + const lineagePattern = `^Fusion-Task-Lineage: ${lineageId}$`; + const lineageCommand = [ + "git log", + `--grep=${shellQuote(lineagePattern)}`, + "-E", + "--max-count=1", + "--format=%H", + shellQuote(baseBranch), + ].join(" "); + const lineage = await execAsync(lineageCommand, { + cwd: repoDir, + timeout: 30_000, + maxBuffer: 1024 * 1024, + }); + const lineageSha = lineage.stdout.trim(); + if (lineageSha) { + return { sha: lineageSha, strategy: "trailer" }; + } + } + + const trailerPattern = `^Fusion-Task-Id: ${taskId}$`; + const trailerCommand = [ + "git log", + `--grep=${shellQuote(trailerPattern)}`, + "-E", + "--max-count=1", + "--format=%H", + shellQuote(baseBranch), + ].join(" "); + const { stdout } = await execAsync(trailerCommand, { + cwd: repoDir, + timeout: 30_000, + maxBuffer: 1024 * 1024, + }); + const sha = stdout.trim(); + if (sha) { + return { sha, strategy: "trailer" }; + } + } catch { + // Fall through to ancestry/patch-id checks. + } + + let branchTip: string | null = null; + const branchName = taskBranch || `fusion/${taskId.toLowerCase()}`; + try { + branchTip = execSync(`git rev-parse --verify ${shellQuote(branchName)}`, { + cwd: repoDir, + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + + execSync(`git merge-base --is-ancestor ${shellQuote(branchTip)} ${shellQuote(baseBranch)}`, { + cwd: repoDir, + stdio: ["pipe", "pipe", "pipe"], + }); + + const ancestryCommand = [ + "git log", + "--first-parent", + "--format=%H", + `--grep=${shellQuote(taskId)}`, + "--max-count=1", + shellQuote(baseBranch), + ].join(" "); + const { stdout } = await execAsync(ancestryCommand, { + cwd: repoDir, + timeout: 30_000, + maxBuffer: 1024 * 1024, + }); + const sha = stdout.trim(); + if (sha) { + return { sha, strategy: "ancestry" }; + } + } catch { + // Fall through to patch-id checks. + } + + try { + if (!branchTip) { + branchTip = execSync(`git rev-parse --verify ${shellQuote(branchName)}`, { + cwd: repoDir, + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + } + + let branchBase = baseCommitSha?.trim(); + if (!branchBase) { + const { stdout: mergeBaseStdout } = await execAsync( + `git merge-base ${shellQuote(branchTip)} ${shellQuote(baseBranch)}`, + { + cwd: repoDir, + timeout: 30_000, + maxBuffer: 1024 * 1024, + }, + ); + branchBase = mergeBaseStdout.trim(); + } + + if (!branchBase) { + return null; + } + + const branchPatchIdCommand = `git diff ${shellQuote(branchBase)}..${shellQuote(branchTip)} | git patch-id`; + const { stdout: branchPatchIdOut } = await execAsync(branchPatchIdCommand, { + cwd: repoDir, + shell: "/bin/sh", + timeout: 60_000, + maxBuffer: 32 * 1024 * 1024, + }); + const branchPatchIdLine = branchPatchIdOut + .trim() + .split("\n") + .find((line) => line.trim().length > 0); + const branchPatchId = branchPatchIdLine?.trim().split(/\s+/)[0]; + if (!branchPatchId) { + return null; + } + + const basePatchMapCommand = `git log -n 200 -p --format='%H' ${shellQuote(baseBranch)} | git patch-id`; + const { stdout: basePatchIdsOut } = await execAsync(basePatchMapCommand, { + cwd: repoDir, + shell: "/bin/sh", + timeout: 60_000, + maxBuffer: 32 * 1024 * 1024, + }); + + const basePatchMap = new Map(); + for (const line of basePatchIdsOut.split("\n")) { + const [patchId, sha] = line.trim().split(/\s+/); + if (!patchId || !sha) continue; + basePatchMap.set(patchId, sha); + } + + const matchedSha = basePatchMap.get(branchPatchId); + if (matchedSha) { + return { sha: matchedSha, strategy: "patch-id" }; + } + } catch { + // Fall through to null when patch-id detection fails. + } + + try { + const treeBranchName = taskBranch || `fusion/${taskId.toLowerCase()}`; + execSync(`git rev-parse --verify ${shellQuote(treeBranchName)}`, { + cwd: repoDir, + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + + const { stdout: baseTreeStdout } = await execAsync(`git rev-parse ${shellQuote(baseBranch)}^{tree}`, { + cwd: repoDir, + timeout: 30_000, + maxBuffer: 1024 * 1024, + }); + const { stdout: branchTreeStdout } = await execAsync(`git rev-parse ${shellQuote(treeBranchName)}^{tree}`, { + cwd: repoDir, + timeout: 30_000, + maxBuffer: 1024 * 1024, + }); + + const baseTree = baseTreeStdout.trim(); + const branchTree = branchTreeStdout.trim(); + if (baseTree && branchTree && baseTree === branchTree) { + const { stdout: baseHeadStdout } = await execAsync(`git rev-parse ${shellQuote(baseBranch)}`, { + cwd: repoDir, + timeout: 30_000, + maxBuffer: 1024 * 1024, + }); + const baseHead = baseHeadStdout.trim(); + if (baseHead) { + return { sha: baseHead, strategy: "tree-equal" }; + } + } + } catch { + // Fall through to null when tree-equality detection fails. + } + + return null; +} + +export async function detectAlreadyLandedOnMain( + input: DetectAlreadyLandedInput, +): Promise { + return findAlreadyMergedTaskCommit({ + taskId: input.taskId, + lineageId: input.lineageId, + repoDir: input.rootDir, + baseBranch: input.baseBranch, + taskBranch: input.taskBranch, + baseCommitSha: input.baseCommitSha, + }); +} diff --git a/packages/engine/src/merger.ts b/packages/engine/src/merger.ts index b71d6496e..6dd5aa357 100644 --- a/packages/engine/src/merger.ts +++ b/packages/engine/src/merger.ts @@ -79,6 +79,7 @@ import { auditSquashMerge, MERGER_MAIN_OVERLAP_LOOKBACK_COMMITS, type PostMergeA import { detectMergeOverlap, restoreBranchWinsFiles } from "./merger-overlap-guard.js"; import { checkDiffVolume, DiffVolumeRegressionError } from "./merger-diff-volume-gate.js"; import { ReadonlyViolationError, filterCustomToolsForReadonly } from "./workflow-step-tool-policy.js"; +import { detectAlreadyLandedOnMain, type AlreadyMergedDetectionStrategy } from "./already-merged-detector.js"; export { DiffVolumeRegressionError } from "./merger-diff-volume-gate.js"; @@ -3038,7 +3039,12 @@ 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: true; + reason: "committed" | "head-task-trailer" | "branch-already-merged" | "branch-already-merged-on-main"; + mergeSha?: string; + strategy?: AlreadyMergedDetectionStrategy; + } | { ok: false; reason: "fix-produced-no-content" | "unknown-phantom" }; async function persistFinalizeResetLeftovers(rootDir: string, taskId: string, store?: TaskStore): Promise { @@ -3467,6 +3473,40 @@ export async function commitOrAmendMergeWithFixes( mergerLog.log(`${taskId}: squash-restore reported already up to date; treating as branch-already-merged`); return { ok: true, reason: "branch-already-merged" }; } + + if (currentHead === preAttemptHeadSha) { + let lineageId: string | undefined; + if (store) { + const existingTask = await store.getTask(taskId); + lineageId = existingTask?.lineageId; + } + const landed = await detectAlreadyLandedOnMain({ + rootDir, + taskId, + lineageId, + baseBranch: preAttemptHeadSha, + taskBranch: branch, + baseCommitSha: preAttemptHeadSha, + }); + if (landed) { + mergerLog.log( + `${taskId}: recovered finalize no-content as already-landed branch=${branch} tip=${branchTip.slice(0, 8)} integrationTarget=${preAttemptHeadSha.slice(0, 8)} via=${landed.strategy}`, + ); + await auditor?.database({ + type: "task:auto-recover-finalize-already-on-main", + taskId, + metadata: { + mergeSha: landed.sha, + mergeStrategy: landed.strategy, + baseBranch: preAttemptHeadSha, + branch, + branchTip, + }, + }); + return { ok: true, reason: "branch-already-merged-on-main", mergeSha: landed.sha, strategy: landed.strategy }; + } + } + mergerLog.warn( `${taskId}: refusing to record merge — no commit was created and no changes are staged after squash-restore.`, ); @@ -3549,7 +3589,7 @@ export async function commitOrAmendMergeWithFixes( }); } mergerLog.log(`${taskId}: created fresh merge commit after verification fix (no prior commit to amend)`); - return { ok: true, reason: "completed" }; + return { ok: true, reason: "committed" }; } // HEAD moved — AI agent committed already. Amend with deterministic @@ -3592,7 +3632,7 @@ export async function commitOrAmendMergeWithFixes( }); } mergerLog.log(`${taskId}: amended merge commit with verification fixes (deterministic message)`); - return { ok: true, reason: "completed" }; + return { ok: true, reason: "committed" }; } catch (err: unknown) { if (err instanceof DiffVolumeRegressionError || err instanceof FileScopeViolationError) { throw err; diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index 0bb8b63c1..14d1a80da 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -26,6 +26,7 @@ import { classifyError, extractMissingModulePath, isOperatorActionableAgentError import { deriveTaskIdFromFusionBranch, inspectBranchConflict, listUniqueBranchCommits } from "./branch-conflicts.js"; import { createRunAuditor, generateSyntheticRunId } from "./run-audit.js"; import { AutoRecoveryDispatcher } from "./auto-recovery.js"; +import { findAlreadyMergedTaskCommit } from "./already-merged-detector.js"; const log = createLogger("self-healing"); const execAsync = promisify(exec); @@ -243,22 +244,6 @@ interface LandedTaskCommit { rebaseBaseSha?: string; } -type AlreadyMergedDetectionStrategy = "trailer" | "ancestry" | "patch-id" | "tree-equal"; - -interface AlreadyMergedLookupInput { - taskId: string; - lineageId?: string; - repoDir: string; - baseBranch: string; - taskBranch?: string; - baseCommitSha?: string; -} - -interface AlreadyMergedLookupResult { - sha: string; - strategy: AlreadyMergedDetectionStrategy; -} - function commitOwnedByTask(taskId: string, lineageId: string | undefined, subject: string, body: string): boolean { if (lineageId && body.includes(`Fusion-Task-Lineage: ${lineageId}`)) { return true; @@ -845,203 +830,6 @@ export class SelfHealingManager { return commit; } - private async findAlreadyMergedTaskCommit( - input: AlreadyMergedLookupInput, - ): Promise { - const { taskId, lineageId, repoDir, baseBranch, taskBranch, baseCommitSha } = input; - - try { - if (lineageId) { - const lineagePattern = `^Fusion-Task-Lineage: ${lineageId}$`; - const lineageCommand = [ - "git log", - `--grep=${shellQuote(lineagePattern)}`, - "-E", - "--max-count=1", - "--format=%H", - shellQuote(baseBranch), - ].join(" "); - const lineage = await execAsync(lineageCommand, { - cwd: repoDir, - timeout: 30_000, - maxBuffer: 1024 * 1024, - }); - const lineageSha = lineage.stdout.trim(); - if (lineageSha) { - return { sha: lineageSha, strategy: "trailer" }; - } - } - - const trailerPattern = `^Fusion-Task-Id: ${taskId}$`; - const trailerCommand = [ - "git log", - `--grep=${shellQuote(trailerPattern)}`, - "-E", - "--max-count=1", - "--format=%H", - shellQuote(baseBranch), - ].join(" "); - const { stdout } = await execAsync(trailerCommand, { - cwd: repoDir, - timeout: 30_000, - maxBuffer: 1024 * 1024, - }); - const sha = stdout.trim(); - if (sha) { - return { sha, strategy: "trailer" }; - } - } catch { - // Fall through to ancestry/patch-id checks. - } - - let branchTip: string | null = null; - const branchName = taskBranch || `fusion/${taskId.toLowerCase()}`; - try { - branchTip = execSync(`git rev-parse --verify ${shellQuote(branchName)}`, { - cwd: repoDir, - encoding: "utf-8", - stdio: ["pipe", "pipe", "pipe"], - }).trim(); - - execSync(`git merge-base --is-ancestor ${shellQuote(branchTip)} ${shellQuote(baseBranch)}`, { - cwd: repoDir, - stdio: ["pipe", "pipe", "pipe"], - }); - - const ancestryCommand = [ - "git log", - "--first-parent", - "--format=%H", - `--grep=${shellQuote(taskId)}`, - "--max-count=1", - shellQuote(baseBranch), - ].join(" "); - const { stdout } = await execAsync(ancestryCommand, { - cwd: repoDir, - timeout: 30_000, - maxBuffer: 1024 * 1024, - }); - const sha = stdout.trim(); - if (sha) { - return { sha, strategy: "ancestry" }; - } - } catch { - // Fall through to patch-id checks. - } - - try { - if (!branchTip) { - branchTip = execSync(`git rev-parse --verify ${shellQuote(branchName)}`, { - cwd: repoDir, - encoding: "utf-8", - stdio: ["pipe", "pipe", "pipe"], - }).trim(); - } - - let branchBase = baseCommitSha?.trim(); - if (!branchBase) { - const { stdout: mergeBaseStdout } = await execAsync( - `git merge-base ${shellQuote(branchTip)} ${shellQuote(baseBranch)}`, - { - cwd: repoDir, - timeout: 30_000, - maxBuffer: 1024 * 1024, - }, - ); - branchBase = mergeBaseStdout.trim(); - } - - if (!branchBase) { - return null; - } - - const branchPatchIdCommand = `git diff ${shellQuote(branchBase)}..${shellQuote(branchTip)} | git patch-id`; - const { stdout: branchPatchIdOut } = await execAsync(branchPatchIdCommand, { - cwd: repoDir, - shell: "/bin/sh", - timeout: 60_000, - maxBuffer: 32 * 1024 * 1024, - }); - const branchPatchIdLine = branchPatchIdOut - .trim() - .split("\n") - .find((line) => line.trim().length > 0); - const branchPatchId = branchPatchIdLine?.trim().split(/\s+/)[0]; - if (!branchPatchId) { - return null; - } - - const basePatchMapCommand = `git log -n 200 -p --format='%H' ${shellQuote(baseBranch)} | git patch-id`; - const { stdout: basePatchIdsOut } = await execAsync(basePatchMapCommand, { - cwd: repoDir, - shell: "/bin/sh", - timeout: 60_000, - maxBuffer: 32 * 1024 * 1024, - }); - - const basePatchMap = new Map(); - for (const line of basePatchIdsOut.split("\n")) { - const [patchId, sha] = line.trim().split(/\s+/); - if (!patchId || !sha) continue; - basePatchMap.set(patchId, sha); - } - - const matchedSha = basePatchMap.get(branchPatchId); - if (matchedSha) { - return { sha: matchedSha, strategy: "patch-id" }; - } - } catch { - // Fall through to null when patch-id detection fails. - } - - // Last-resort fallback: if branch and base resolve to identical trees, content is already landed - // but attribution is weak (we cannot identify the exact landing commit), so prefer stronger - // trailer/ancestry/patch-id matches first and use this only at the end. - try { - const treeBranchName = taskBranch || `fusion/${taskId.toLowerCase()}`; - execSync(`git rev-parse --verify ${shellQuote(treeBranchName)}`, { - cwd: repoDir, - encoding: "utf-8", - stdio: ["pipe", "pipe", "pipe"], - }).trim(); - - const { stdout: baseTreeStdout } = await execAsync( - `git rev-parse ${shellQuote(baseBranch)}^{tree}`, - { - cwd: repoDir, - timeout: 30_000, - maxBuffer: 1024 * 1024, - }, - ); - const { stdout: branchTreeStdout } = await execAsync( - `git rev-parse ${shellQuote(treeBranchName)}^{tree}`, - { - cwd: repoDir, - timeout: 30_000, - maxBuffer: 1024 * 1024, - }, - ); - - const baseTree = baseTreeStdout.trim(); - const branchTree = branchTreeStdout.trim(); - if (baseTree && branchTree && baseTree === branchTree) { - const { stdout: baseHeadStdout } = await execAsync(`git rev-parse ${shellQuote(baseBranch)}`, { - cwd: repoDir, - timeout: 30_000, - maxBuffer: 1024 * 1024, - }); - const baseHead = baseHeadStdout.trim(); - if (baseHead) { - return { sha: baseHead, strategy: "tree-equal" }; - } - } - } catch { - // Fall through to null when tree-equality detection fails. - } - - return null; - } - private async cleanupWorktreeOnly(task: Task): Promise { if (task.worktree && existsSync(task.worktree)) { try { @@ -3275,7 +3063,7 @@ export class SelfHealingManager { if (hasDeclaredOverlap) continue; const baseBranch = task.baseBranch || task.executionStartBranch || "main"; - const landed = await this.findAlreadyMergedTaskCommit({ + const landed = await findAlreadyMergedTaskCommit({ taskId: task.id, lineageId: task.lineageId, repoDir: this.options.rootDir, @@ -3385,7 +3173,7 @@ export class SelfHealingManager { const baseBranch = task.baseBranch || task.executionStartBranch || "main"; if (!baseBranch) continue; - const landed = await this.findAlreadyMergedTaskCommit({ + const landed = await findAlreadyMergedTaskCommit({ taskId: task.id, lineageId: task.lineageId, repoDir: this.options.rootDir,