feat(FN-4559): complete Step 1 — shared detector and finalize classification

Fusion-Task-Id: FN-4559
Fusion-Task-Lineage: 9276f683-657c-4604-aaef-143da488b287
This commit is contained in:
Fusion
2026-05-15 00:03:01 -07:00
committed by gsxdsm
parent 8e59e9600d
commit d934273072
4 changed files with 359 additions and 218 deletions

View File

@@ -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<string>();
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);
});
});

View File

@@ -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<AlreadyMergedLookupResult | null> {
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<string, string>();
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<AlreadyMergedLookupResult | null> {
return findAlreadyMergedTaskCommit({
taskId: input.taskId,
lineageId: input.lineageId,
repoDir: input.rootDir,
baseBranch: input.baseBranch,
taskBranch: input.taskBranch,
baseCommitSha: input.baseCommitSha,
});
}

View File

@@ -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<void> {
@@ -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;

View File

@@ -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<AlreadyMergedLookupResult | null> {
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<string, string>();
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<void> {
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,