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:
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
234
packages/engine/src/already-merged-detector.ts
Normal file
234
packages/engine/src/already-merged-detector.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -79,6 +79,7 @@ import { auditSquashMerge, MERGER_MAIN_OVERLAP_LOOKBACK_COMMITS, type PostMergeA
|
|||||||
import { detectMergeOverlap, restoreBranchWinsFiles } from "./merger-overlap-guard.js";
|
import { detectMergeOverlap, restoreBranchWinsFiles } from "./merger-overlap-guard.js";
|
||||||
import { checkDiffVolume, DiffVolumeRegressionError } from "./merger-diff-volume-gate.js";
|
import { checkDiffVolume, DiffVolumeRegressionError } from "./merger-diff-volume-gate.js";
|
||||||
import { ReadonlyViolationError, filterCustomToolsForReadonly } from "./workflow-step-tool-policy.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";
|
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.
|
* @internal Exported for integration tests only — not part of the public API.
|
||||||
*/
|
*/
|
||||||
type MergeFinalizeResult =
|
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" };
|
| { ok: false; reason: "fix-produced-no-content" | "unknown-phantom" };
|
||||||
|
|
||||||
async function persistFinalizeResetLeftovers(rootDir: string, taskId: string, store?: TaskStore): Promise<void> {
|
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`);
|
mergerLog.log(`${taskId}: squash-restore reported already up to date; treating as branch-already-merged`);
|
||||||
return { ok: true, reason: "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(
|
mergerLog.warn(
|
||||||
`${taskId}: refusing to record merge — no commit was created and no changes are staged after squash-restore.`,
|
`${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)`);
|
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
|
// 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)`);
|
mergerLog.log(`${taskId}: amended merge commit with verification fixes (deterministic message)`);
|
||||||
return { ok: true, reason: "completed" };
|
return { ok: true, reason: "committed" };
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (err instanceof DiffVolumeRegressionError || err instanceof FileScopeViolationError) {
|
if (err instanceof DiffVolumeRegressionError || err instanceof FileScopeViolationError) {
|
||||||
throw err;
|
throw err;
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { classifyError, extractMissingModulePath, isOperatorActionableAgentError
|
|||||||
import { deriveTaskIdFromFusionBranch, inspectBranchConflict, listUniqueBranchCommits } from "./branch-conflicts.js";
|
import { deriveTaskIdFromFusionBranch, inspectBranchConflict, listUniqueBranchCommits } from "./branch-conflicts.js";
|
||||||
import { createRunAuditor, generateSyntheticRunId } from "./run-audit.js";
|
import { createRunAuditor, generateSyntheticRunId } from "./run-audit.js";
|
||||||
import { AutoRecoveryDispatcher } from "./auto-recovery.js";
|
import { AutoRecoveryDispatcher } from "./auto-recovery.js";
|
||||||
|
import { findAlreadyMergedTaskCommit } from "./already-merged-detector.js";
|
||||||
|
|
||||||
const log = createLogger("self-healing");
|
const log = createLogger("self-healing");
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
@@ -243,22 +244,6 @@ interface LandedTaskCommit {
|
|||||||
rebaseBaseSha?: string;
|
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 {
|
function commitOwnedByTask(taskId: string, lineageId: string | undefined, subject: string, body: string): boolean {
|
||||||
if (lineageId && body.includes(`Fusion-Task-Lineage: ${lineageId}`)) {
|
if (lineageId && body.includes(`Fusion-Task-Lineage: ${lineageId}`)) {
|
||||||
return true;
|
return true;
|
||||||
@@ -845,203 +830,6 @@ export class SelfHealingManager {
|
|||||||
return commit;
|
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> {
|
private async cleanupWorktreeOnly(task: Task): Promise<void> {
|
||||||
if (task.worktree && existsSync(task.worktree)) {
|
if (task.worktree && existsSync(task.worktree)) {
|
||||||
try {
|
try {
|
||||||
@@ -3275,7 +3063,7 @@ export class SelfHealingManager {
|
|||||||
if (hasDeclaredOverlap) continue;
|
if (hasDeclaredOverlap) continue;
|
||||||
|
|
||||||
const baseBranch = task.baseBranch || task.executionStartBranch || "main";
|
const baseBranch = task.baseBranch || task.executionStartBranch || "main";
|
||||||
const landed = await this.findAlreadyMergedTaskCommit({
|
const landed = await findAlreadyMergedTaskCommit({
|
||||||
taskId: task.id,
|
taskId: task.id,
|
||||||
lineageId: task.lineageId,
|
lineageId: task.lineageId,
|
||||||
repoDir: this.options.rootDir,
|
repoDir: this.options.rootDir,
|
||||||
@@ -3385,7 +3173,7 @@ export class SelfHealingManager {
|
|||||||
const baseBranch = task.baseBranch || task.executionStartBranch || "main";
|
const baseBranch = task.baseBranch || task.executionStartBranch || "main";
|
||||||
if (!baseBranch) continue;
|
if (!baseBranch) continue;
|
||||||
|
|
||||||
const landed = await this.findAlreadyMergedTaskCommit({
|
const landed = await findAlreadyMergedTaskCommit({
|
||||||
taskId: task.id,
|
taskId: task.id,
|
||||||
lineageId: task.lineageId,
|
lineageId: task.lineageId,
|
||||||
repoDir: this.options.rootDir,
|
repoDir: this.options.rootDir,
|
||||||
|
|||||||
Reference in New Issue
Block a user