diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index 9f6476119..b2775b0d3 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -13,7 +13,7 @@ * by cleaning oldest idle worktrees when count exceeds 2× maxWorktrees. */ -import { exec } from "node:child_process"; +import { exec, execSync } from "node:child_process"; import { promisify } from "node:util"; import { existsSync, readdirSync, rmSync, statSync } from "node:fs"; import { isAbsolute, join, relative, resolve } from "node:path"; @@ -131,6 +131,21 @@ interface LandedTaskCommit { deletions?: number; } +type AlreadyMergedDetectionStrategy = "trailer" | "ancestry" | "patch-id"; + +interface AlreadyMergedLookupInput { + taskId: string; + repoDir: string; + baseBranch: string; + taskBranch?: string; + baseCommitSha?: string; +} + +interface AlreadyMergedLookupResult { + sha: string; + strategy: AlreadyMergedDetectionStrategy; +} + function commitOwnedByTask(taskId: string, subject: string, body: string): boolean { return body.includes(`Fusion-Task-Id: ${taskId}`) || subject.includes(taskId); } @@ -594,6 +609,153 @@ export class SelfHealingManager { return commit; } + private async findAlreadyMergedTaskCommit( + input: AlreadyMergedLookupInput, + ): Promise { + const { taskId, repoDir, baseBranch, taskBranch, baseCommitSha } = input; + + try { + 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: true, + 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: true, + 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. + } + + return null; + } + + private async cleanupWorktreeOnly(task: Task): Promise { + if (task.worktree && existsSync(task.worktree)) { + try { + await execAsync(`git worktree remove ${shellQuote(task.worktree)} --force`, { + cwd: this.options.rootDir, + timeout: 120_000, + }); + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : String(err); + log.warn( + `Failed to remove worktree ${task.worktree} for ${task.id}: ${errorMessage} — non-fatal, cleanup can retry later`, + ); + } + } + } + private async cleanupInterruptedMergeArtifacts(task: Task): Promise { if (task.worktree && existsSync(task.worktree)) { try {