feat(FN-4949): add reclaim deferral guards for active executor worktrees

Adds reclaim deferral guards to self-healing to prevent stale-branch cleanup from racing against in-flight executor sessions, with corresponding audit telemetry and test coverage for the interaction. Also updates AGENTS.md to document the new behavior.

Fusion-Task-Id: FN-4949
This commit is contained in:
Fusion (runfusion.ai)
2026-05-17 18:41:42 -07:00
committed by gsxdsm
parent 287673c261
commit 543ea66799
4 changed files with 499 additions and 13 deletions

View File

@@ -2583,6 +2583,98 @@ describe("aiMergeTask post-squash audit gate", () => {
expect(store.appendAgentLog).toHaveBeenCalledWith("FN-050", "post-rebase range audit clean", "text", undefined, "merger");
});
it("degrades to squash fallback when no usable base can be resolved on the rebase route", async () => {
setupRebaseRouteExecSync();
const baseImpl = mockedExecSync.getMockImplementation();
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes('git rev-parse "main"')) return "" as any;
if (cmdStr.includes('git rev-parse --verify "abc123^{commit}"')) return "abc123\n" as any;
if (cmdStr.includes('git merge-base --is-ancestor "abc123" "landedcommit002"')) return "" as any;
if (cmdStr.includes('git rev-list --reverse "..fusion/fn-050"')) return "commit-a\ncommit-b\ncommit-c\n" as any;
if (cmdStr.includes('git diff --shortstat "..HEAD"')) return "2 files changed, 6 insertions(+), 1 deletion(-)" as any;
if (cmdStr.includes("git show --shortstat --format= HEAD")) return "2 files changed, 6 insertions(+), 1 deletion(-)" as any;
return baseImpl ? baseImpl(cmd) : Buffer.from("");
});
mockedAuditSquashMerge.mockResolvedValue({
strategy: "rebase",
rangeBaseSha: "basehead123",
rangeHeadSha: "landedcommit002",
parentSha: "basehead123",
auditTargetLabel: "basehead123..landedcommit002",
lookback: 30,
branchSubjects: ["fix: substantive one", "feat: substantive two"],
recentMainSubjects: [],
duplicateSubjects: [],
touchedFiles: [],
touchedFileOverlaps: [],
findings: [],
issueCount: 0,
clean: true,
});
const store = createAuditStore({}, { prompt: "**Direct Merge Commit Strategy:** always-rebase" });
await aiMergeTask(store, "/tmp/root", "FN-050");
expect(mockedAuditSquashMerge).toHaveBeenCalledWith(expect.objectContaining({
strategy: "squash",
squashSha: "landedcommit002",
}));
expect(store.appendAgentLog).toHaveBeenCalledWith(
"FN-050",
expect.stringContaining("post-merge audit degraded to single-commit squash fallback"),
"text",
undefined,
"merger",
);
});
it("logs degraded squash fallback when no rebase range base can be resolved", async () => {
setupRebaseRouteExecSync();
const baseImpl = mockedExecSync.getMockImplementation();
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes('git rev-parse "main"')) return "" as any;
if (cmdStr.includes("git rev-parse --verify") && cmdStr.includes("^{commit}")) return "landedcommit002\n" as any;
if (cmdStr.includes("git merge-base") && cmdStr.includes("landedcommit002") && cmdStr.includes("main")) return "landedcommit002\n" as any;
if (cmdStr.includes('git rev-list --reverse "..fusion/fn-050"')) return "commit-a\ncommit-b\ncommit-c\n" as any;
if (cmdStr.includes('git diff --shortstat "..HEAD"')) return "2 files changed, 6 insertions(+), 1 deletion(-)" as any;
if (cmdStr.includes("git show --shortstat --format= HEAD")) return "2 files changed, 6 insertions(+), 1 deletion(-)" as any;
return baseImpl ? baseImpl(cmd) : Buffer.from("");
});
mockedAuditSquashMerge.mockResolvedValue({
strategy: "squash",
squashSha: "landedcommit002",
parentSha: "basehead123",
auditTargetLabel: "landedcommit002",
squashSubject: "feat: squash merge",
lookback: 30,
branchSubjects: ["feat: substantive two"],
recentMainSubjects: [],
duplicateSubjects: [],
touchedFiles: ["src/feature-b.ts"],
touchedFileOverlaps: [],
findings: [],
issueCount: 0,
clean: true,
});
const store = createAuditStore({}, { prompt: "**Direct Merge Commit Strategy:** always-rebase" });
await aiMergeTask(store, "/tmp/root", "FN-050");
expect(mockedAuditSquashMerge).toHaveBeenCalledWith(expect.objectContaining({
strategy: "squash",
squashSha: "landedcommit002",
}));
expect(store.appendAgentLog).toHaveBeenCalledWith(
"FN-050",
expect.stringContaining("post-merge audit degraded to single-commit squash fallback"),
"text",
undefined,
"merger",
);
});
it("honors the per-task always-rebase override", async () => {
setupRebaseRouteExecSync();
mockedAuditSquashMerge.mockResolvedValue({

View File

@@ -0,0 +1,196 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execSync } from "node:child_process";
import { afterEach, describe, expect, it, vi } from "vitest";
import { resolvePostMergeAuditInvocation } from "../merger.js";
function git(cwd: string, command: string): string {
return execSync(`git ${command}`, { cwd, encoding: "utf-8" }).trim();
}
function createRepoWithFeatureCommit() {
const dir = mkdtempSync(join(tmpdir(), "fn-4961-audit-"));
git(dir, "init -b main");
git(dir, "config user.email test@example.com");
git(dir, "config user.name Test");
writeFileSync(join(dir, "file.txt"), "a\n");
git(dir, "add file.txt");
git(dir, "commit -m base");
const baseSha = git(dir, "rev-parse HEAD");
writeFileSync(join(dir, "file.txt"), "a\nb\n");
git(dir, "add file.txt");
git(dir, "commit -m main-two");
const mainTipSha = git(dir, "rev-parse HEAD");
git(dir, "checkout -b feature");
writeFileSync(join(dir, "feature.txt"), "c\n");
git(dir, "add feature.txt");
git(dir, "commit -m feature");
const auditSha = git(dir, "rev-parse HEAD");
return { dir, baseSha, mainTipSha, auditSha };
}
describe("resolvePostMergeAuditInvocation", () => {
const cleanup: string[] = [];
afterEach(() => {
for (const dir of cleanup.splice(0)) rmSync(dir, { recursive: true, force: true });
});
it("uses explicit rebaseMergeBaseSha when present", async () => {
const { dir, baseSha, auditSha } = createRepoWithFeatureCommit();
cleanup.push(dir);
const appendAgentLog = vi.fn().mockResolvedValue(undefined);
const mergerLog = { log: vi.fn(), warn: vi.fn() };
const input = await resolvePostMergeAuditInvocation({
rootDir: dir,
strategy: "rebase",
auditSha,
rebaseMergeBaseSha: baseSha,
diffBaseRef: undefined,
mergeTargetBranch: "main",
taskBaseCommitSha: undefined,
taskId: "FN-4961",
store: { appendAgentLog },
mergerLog,
});
expect(input).toMatchObject({ strategy: "rebase", rangeBaseSha: baseSha, rangeHeadSha: auditSha });
expect(appendAgentLog).not.toHaveBeenCalled();
});
it("derives from diffBaseRef when resolvable and ancestor", async () => {
const { dir, mainTipSha, auditSha } = createRepoWithFeatureCommit();
cleanup.push(dir);
const appendAgentLog = vi.fn().mockResolvedValue(undefined);
const mergerLog = { log: vi.fn(), warn: vi.fn() };
const input = await resolvePostMergeAuditInvocation({
rootDir: dir,
strategy: "rebase",
auditSha,
diffBaseRef: mainTipSha,
mergeTargetBranch: "main",
taskId: "FN-4961",
store: { appendAgentLog },
mergerLog,
});
expect(input).toMatchObject({ strategy: "rebase", rangeBaseSha: mainTipSha, rangeHeadSha: auditSha });
expect(appendAgentLog).toHaveBeenCalledWith(
"FN-4961",
expect.stringContaining("from diffBaseRef"),
"text",
undefined,
"merger",
);
});
it("falls back to task base commit sha", async () => {
const { dir, baseSha, auditSha } = createRepoWithFeatureCommit();
cleanup.push(dir);
const appendAgentLog = vi.fn().mockResolvedValue(undefined);
const mergerLog = { log: vi.fn(), warn: vi.fn() };
const input = await resolvePostMergeAuditInvocation({
rootDir: dir,
strategy: "rebase",
auditSha,
diffBaseRef: "missing-ref",
taskBaseCommitSha: baseSha,
mergeTargetBranch: "main",
taskId: "FN-4961",
store: { appendAgentLog },
mergerLog,
});
expect(input).toMatchObject({ strategy: "rebase", rangeBaseSha: baseSha, rangeHeadSha: auditSha });
expect(appendAgentLog).toHaveBeenCalledWith(
"FN-4961",
expect.stringContaining("from baseCommitSha"),
"text",
undefined,
"merger",
);
});
it("falls back to merge-base when other candidates are unavailable", async () => {
const { dir, mainTipSha, auditSha } = createRepoWithFeatureCommit();
cleanup.push(dir);
const appendAgentLog = vi.fn().mockResolvedValue(undefined);
const mergerLog = { log: vi.fn(), warn: vi.fn() };
const input = await resolvePostMergeAuditInvocation({
rootDir: dir,
strategy: "rebase",
auditSha,
diffBaseRef: auditSha,
taskBaseCommitSha: "missing",
mergeTargetBranch: "main",
taskId: "FN-4961",
store: { appendAgentLog },
mergerLog,
});
expect(input).toMatchObject({ strategy: "rebase", rangeBaseSha: mainTipSha, rangeHeadSha: auditSha });
expect(appendAgentLog).toHaveBeenCalledWith(
"FN-4961",
expect.stringContaining("from merge-base"),
"text",
undefined,
"merger",
);
});
it("degrades to squash when all candidates are unusable", async () => {
const { dir, auditSha } = createRepoWithFeatureCommit();
cleanup.push(dir);
const appendAgentLog = vi.fn().mockResolvedValue(undefined);
const mergerLog = { log: vi.fn(), warn: vi.fn() };
const input = await resolvePostMergeAuditInvocation({
rootDir: dir,
strategy: "rebase",
auditSha,
diffBaseRef: auditSha,
taskBaseCommitSha: auditSha,
mergeTargetBranch: "feature",
taskId: "FN-4961",
store: { appendAgentLog },
mergerLog,
});
expect(input).toMatchObject({ strategy: "squash", squashSha: auditSha });
expect(appendAgentLog).toHaveBeenCalledWith(
"FN-4961",
expect.stringContaining("post-merge audit degraded to single-commit squash fallback"),
"text",
undefined,
"merger",
);
expect(mergerLog.warn).toHaveBeenCalled();
});
it("always returns squash input for squash strategy", async () => {
const { dir, auditSha } = createRepoWithFeatureCommit();
cleanup.push(dir);
const appendAgentLog = vi.fn().mockResolvedValue(undefined);
const input = await resolvePostMergeAuditInvocation({
rootDir: dir,
strategy: "squash",
auditSha,
mergeTargetBranch: "main",
taskId: "FN-4961",
store: { appendAgentLog },
mergerLog: { log: vi.fn(), warn: vi.fn() },
});
expect(input).toMatchObject({ strategy: "squash", squashSha: auditSha });
expect(appendAgentLog).not.toHaveBeenCalled();
});
});

View File

@@ -154,4 +154,69 @@ describeIfGit("auditSquashMerge", () => {
file: "shared.txt",
});
});
it("rebase strategy audits overlap across all commits in a multi-commit range", async () => {
const repo = setupRepo();
write(repo, "shared-a.txt", "a0\n");
write(repo, "shared-b.txt", "b0\n");
git(repo, "git add shared-a.txt shared-b.txt && git commit -m 'chore: seed shared files'");
git(repo, "git checkout -b feature/multi-range");
write(repo, "shared-a.txt", "a0\nfeature-a\n");
git(repo, "git add shared-a.txt && git commit -m 'feat: branch overlap a'");
write(repo, "shared-b.txt", "b0\nfeature-b\n");
git(repo, "git add shared-b.txt && git commit -m 'feat: branch overlap b'");
const rangeHeadSha = git(repo, "git rev-parse HEAD");
git(repo, "git checkout main");
write(repo, "shared-a.txt", "a0\nmain-a\n");
git(repo, "git add shared-a.txt && git commit -m 'feat: branch overlap a'");
write(repo, "shared-b.txt", "b0\nmain-b\n");
git(repo, "git add shared-b.txt && git commit -m 'feat: branch overlap b'");
const rangeBaseSha = git(repo, "git merge-base feature/multi-range main");
const findings = await auditSquashMerge({
rootDir: repo,
strategy: "rebase",
rangeBaseSha,
rangeHeadSha,
lookback: 20,
});
expect(findings.clean).toBe(false);
expect(findings.touchedFileOverlaps).toEqual(expect.arrayContaining([
expect.objectContaining({ file: "shared-a.txt" }),
expect.objectContaining({ file: "shared-b.txt" }),
]));
});
it("single-commit squash fallback on the same multi-commit branch only sees tip subject", async () => {
const repo = setupRepo();
write(repo, "shared-a.txt", "a0\n");
write(repo, "shared-b.txt", "b0\n");
git(repo, "git add shared-a.txt shared-b.txt && git commit -m 'chore: seed shared files'");
git(repo, "git checkout -b feature/multi-fallback");
write(repo, "shared-a.txt", "a0\nfeature-a\n");
git(repo, "git add shared-a.txt && git commit -m 'feat: branch overlap a'");
write(repo, "shared-b.txt", "b0\nfeature-b\n");
git(repo, "git add shared-b.txt && git commit -m 'feat: branch overlap b'");
git(repo, "git checkout main");
write(repo, "shared-a.txt", "a0\nmain-a\n");
git(repo, "git add shared-a.txt && git commit -m 'feat: branch overlap a'");
write(repo, "shared-b.txt", "b0\nmain-b\n");
git(repo, "git add shared-b.txt && git commit -m 'feat: branch overlap b'");
write(repo, "shared-b.txt", "b0\nmain-b\nfinal\n");
git(repo, "git add shared-b.txt && git commit -m 'feat: squash merge' -m '- feat: branch overlap b'");
const squashSha = git(repo, "git rev-parse HEAD");
const findings = await auditSquashMerge({ rootDir: repo, strategy: "squash", squashSha, lookback: 20 });
expect(findings.duplicateSubjects).toEqual([{ type: "duplicate-subject", subject: "feat: branch overlap b" }]);
expect(findings.duplicateSubjects).not.toContainEqual({ type: "duplicate-subject", subject: "feat: branch overlap a" });
});
});

View File

@@ -80,7 +80,13 @@ import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
import { Type } from "typebox";
import { createRunAuditor, generateSyntheticRunId, type EngineRunContext, type RunAuditor } from "./run-audit.js";
import { createWebFetchTool } from "./agent-tools.js";
import { auditSquashMerge, MERGER_MAIN_OVERLAP_LOOKBACK_COMMITS, type PostMergeAuditStrategy, type SquashAuditFindings } from "./merger-squash-audit.js";
import {
auditSquashMerge,
MERGER_MAIN_OVERLAP_LOOKBACK_COMMITS,
type PostMergeAuditInput,
type PostMergeAuditStrategy,
type SquashAuditFindings,
} from "./merger-squash-audit.js";
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";
@@ -5061,6 +5067,132 @@ function shouldRunPostMergeAudit(
return (result.autoResolvedCount ?? 0) > 0 || result.attemptsMade === 3;
}
export interface ResolvePostMergeAuditInvocationInput {
rootDir: string;
strategy: PostMergeAuditStrategy;
auditSha: string;
rebaseMergeBaseSha?: string;
diffBaseRef?: string;
mergeTargetBranch: string;
taskBaseCommitSha?: string;
taskId: string;
store: Pick<TaskStore, "appendAgentLog">;
mergerLog: { warn: (message: string) => void; log: (message: string) => void; };
}
async function resolveAuditRangeBaseCandidate(opts: {
rootDir: string;
auditSha: string;
candidateRef: string;
}): Promise<string | undefined> {
const candidateRef = opts.candidateRef.trim();
if (!candidateRef) return undefined;
try {
const { stdout: resolvedOut } = await execAsync(`git rev-parse --verify ${quoteArg(`${candidateRef}^{commit}`)}`, {
cwd: opts.rootDir,
encoding: "utf-8",
});
const resolvedSha = resolvedOut.trim();
if (!resolvedSha || resolvedSha === opts.auditSha) {
return undefined;
}
await execAsync(`git merge-base --is-ancestor ${quoteArg(resolvedSha)} ${quoteArg(opts.auditSha)}`, {
cwd: opts.rootDir,
encoding: "utf-8",
});
return resolvedSha;
} catch {
return undefined;
}
}
export async function resolvePostMergeAuditInvocation(
opts: ResolvePostMergeAuditInvocationInput,
): Promise<PostMergeAuditInput> {
if (opts.strategy === "squash") {
return {
rootDir: opts.rootDir,
strategy: "squash",
squashSha: opts.auditSha,
};
}
if (opts.rebaseMergeBaseSha && opts.rebaseMergeBaseSha !== opts.auditSha) {
return {
rootDir: opts.rootDir,
strategy: "rebase",
rangeBaseSha: opts.rebaseMergeBaseSha,
rangeHeadSha: opts.auditSha,
};
}
const rangeCandidates: Array<{ source: "diffBaseRef" | "baseCommitSha"; ref?: string }> = [
{ source: "diffBaseRef", ref: opts.diffBaseRef },
{ source: "baseCommitSha", ref: opts.taskBaseCommitSha },
];
for (const candidate of rangeCandidates) {
if (!candidate.ref?.trim()) continue;
const resolved = await resolveAuditRangeBaseCandidate({
rootDir: opts.rootDir,
auditSha: opts.auditSha,
candidateRef: candidate.ref,
});
if (!resolved) continue;
const infoMessage = `${opts.taskId}: post-merge audit using rebase range base from ${candidate.source} (${resolved.slice(0, 8)}..${opts.auditSha.slice(0, 8)})`;
opts.mergerLog.log(infoMessage);
await opts.store.appendAgentLog(opts.taskId, infoMessage, "text", undefined, "merger");
return {
rootDir: opts.rootDir,
strategy: "rebase",
rangeBaseSha: resolved,
rangeHeadSha: opts.auditSha,
};
}
let mergeBaseSha: string | undefined;
try {
const { stdout } = await execAsync(`git merge-base ${quoteArg(opts.auditSha)} ${quoteArg(opts.mergeTargetBranch)}`, {
cwd: opts.rootDir,
encoding: "utf-8",
});
const mergeBaseRef = stdout.trim();
if (mergeBaseRef) {
mergeBaseSha = await resolveAuditRangeBaseCandidate({
rootDir: opts.rootDir,
auditSha: opts.auditSha,
candidateRef: mergeBaseRef,
});
}
} catch {
mergeBaseSha = undefined;
}
if (mergeBaseSha) {
const infoMessage = `${opts.taskId}: post-merge audit using rebase range base from merge-base (${mergeBaseSha.slice(0, 8)}..${opts.auditSha.slice(0, 8)})`;
opts.mergerLog.log(infoMessage);
await opts.store.appendAgentLog(opts.taskId, infoMessage, "text", undefined, "merger");
return {
rootDir: opts.rootDir,
strategy: "rebase",
rangeBaseSha: mergeBaseSha,
rangeHeadSha: opts.auditSha,
};
}
const degradedMessage = `${opts.taskId}: post-merge audit degraded to single-commit squash fallback (multi-commit branch, no usable rangeBase)`;
opts.mergerLog.warn(degradedMessage);
await opts.store.appendAgentLog(opts.taskId, degradedMessage, "text", undefined, "merger");
return {
rootDir: opts.rootDir,
strategy: "squash",
squashSha: opts.auditSha,
};
}
/**
* Decide what to do with a dirty post-merge audit (FN-4333 hot-fix).
*
@@ -7414,18 +7546,19 @@ export async function aiMergeTask(
&& postMergeAuditMode !== "off"
&& shouldRunPostMergeAudit(selectedPostMergeAuditStrategy, result, mergeWasEmpty, isEmptyCommit, auditSha)
) {
const auditFindings = selectedPostMergeAuditStrategy === "rebase" && rebaseMergeBaseSha
? await auditSquashMerge({
rootDir,
strategy: "rebase",
rangeBaseSha: rebaseMergeBaseSha,
rangeHeadSha: auditSha,
})
: await auditSquashMerge({
rootDir,
strategy: "squash",
squashSha: auditSha,
});
const auditInvocation = await resolvePostMergeAuditInvocation({
rootDir,
strategy: selectedPostMergeAuditStrategy,
auditSha,
rebaseMergeBaseSha,
diffBaseRef,
mergeTargetBranch: mergeTarget.branch,
taskBaseCommitSha: task.baseCommitSha,
taskId,
store,
mergerLog,
});
const auditFindings = await auditSquashMerge(auditInvocation);
if (!auditFindings.clean) {
// FN-4333/FN-4344: rebase overlap-only findings can be auto-cleared
// when deterministic verification already proved the merged tree,