feat(FN-4067): add squash audit gate to merger with integration tests

Adds a squash-merge audit gate that verifies duplicate-cherry-pick commits and file-overlap losses after any squash merge into main, restoring the post-squash audit step to the merge workflow with test coverage for both the audit logic and the broader merger lifecycle.

Fusion-Task-Id: FN-4067

Fusion-Task-Lineage: 593aa917-5640-495b-b1ae-80c3eaa09d01
This commit is contained in:
Fusion
2026-05-12 05:22:14 -07:00
committed by gsxdsm
parent 50c8ab35d7
commit fc863f6e96
10 changed files with 665 additions and 73 deletions

View File

@@ -133,7 +133,7 @@ describe("TaskExecutor enginePaused soft pause (no agent termination)", () => {
"Task marked complete with summary. All steps done. Moving to in-review.",
);
expect(watchdogSpy).toHaveBeenCalledWith("FN-001", "fn_task_done");
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { paused: false, status: null });
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: null });
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
});

View File

@@ -121,6 +121,23 @@ vi.mock("../context-limit-detector.js", () => ({
isContextLimitError: vi.fn(),
}));
vi.mock("../merger-squash-audit.js", () => ({
auditSquashMerge: vi.fn(async () => ({
squashSha: "mergedcommit123",
parentSha: "parent123",
squashSubject: "feat: squash merge",
lookback: 30,
branchSubjects: [],
recentMainSubjects: [],
duplicateSubjects: [],
touchedFiles: [],
touchedFileOverlaps: [],
findings: [],
issueCount: 0,
clean: true,
})),
}));
import {
aiMergeTask,
pushToRemoteAfterMerge,
@@ -149,11 +166,13 @@ import {
} from "../merger.js";
import { mergerLog } from "../logger.js";
import { createFnAgent } from "../pi.js";
import { auditSquashMerge } from "../merger-squash-audit.js";
import { execSync, exec } from "node:child_process";
import * as core from "@fusion/core";
import { type TaskStore, type Task, type MergeResult, DEFAULT_SETTINGS } from "@fusion/core";
const mockedCreateFnAgent = vi.mocked(createFnAgent);
const mockedAuditSquashMerge = vi.mocked(auditSquashMerge);
const mockedExecSync = vi.mocked(execSync);
const mockedExec = vi.mocked(exec);
const { existsSync: mockedExistsSyncRaw, readFileSync: mockedReadFileSyncRaw } = await import("node:fs");
@@ -2116,4 +2135,167 @@ describe("aiMergeTask — reset cleanup failure diagnostics", () => {
// ── New Smart Conflict Resolution API Tests ────────────────────────────
describe("aiMergeTask post-squash audit gate", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(true);
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
mockedAuditSquashMerge.mockResolvedValue({
squashSha: "mergedcommit123",
parentSha: "parent123",
squashSubject: "feat: squash merge",
lookback: 30,
branchSubjects: [],
recentMainSubjects: [],
duplicateSubjects: [],
touchedFiles: [],
touchedFileOverlaps: [],
findings: [],
issueCount: 0,
clean: true,
});
});
function setupAutoResolvedMergeExecSync() {
let squashCalls = 0;
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("symbolic-ref --short HEAD")) return "main" as any;
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123" as any;
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("rev-list --count")) return "1\n" as any;
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash") && !cmdStr.includes("-X")) {
squashCalls += 1;
if (squashCalls === 2) {
const error = new Error("merge conflict");
(error as Error & { stdout?: string; stderr?: string }).stdout = "";
(error as Error & { stdout?: string; stderr?: string }).stderr = "CONFLICT";
throw error;
}
return Buffer.from("");
}
if (cmdStr.includes("diff --name-only --diff-filter=U")) return "pnpm-lock.yaml\n" as any;
if (cmdStr.includes("checkout --ours --") || cmdStr.includes("git add -- pnpm-lock.yaml")) return Buffer.from("");
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
if (cmdStr.includes("git commit ")) return Buffer.from("");
if (cmdStr.includes("show --shortstat")) return "3 files changed, 10 insertions(+), 2 deletions(-)" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
}
function createAuditStore() {
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
testCommand: "pnpm test",
mergeConflictStrategy: "ai-only",
worktreeRebaseBeforeMerge: false,
worktreeRebaseLocalBase: false,
});
return store;
}
it("moves the task to done when the post-squash audit is clean", async () => {
setupAutoResolvedMergeExecSync();
const store = createAuditStore();
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.merged).toBe(true);
expect(mockedAuditSquashMerge).toHaveBeenCalledWith({
rootDir: "/tmp/root",
squashSha: "mergedcommit123",
});
expect(store.appendAgentLog).toHaveBeenCalledWith("FN-050", "post-squash audit clean", "text", undefined, "merger");
expect(store.moveTask).toHaveBeenCalledWith("FN-050", "done");
});
it("blocks completion and logs duplicate-subject findings", async () => {
setupAutoResolvedMergeExecSync();
mockedAuditSquashMerge.mockResolvedValue({
squashSha: "mergedcommit123",
parentSha: "parent123",
squashSubject: "feat: squash merge",
lookback: 30,
branchSubjects: ["feat: duplicate subject"],
recentMainSubjects: ["feat: duplicate subject"],
duplicateSubjects: [{ type: "duplicate-subject", subject: "feat: duplicate subject" }],
touchedFiles: ["src/example.ts"],
touchedFileOverlaps: [],
findings: [{ type: "duplicate-subject", subject: "feat: duplicate subject" }],
issueCount: 1,
clean: false,
});
const store = createAuditStore();
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toThrow(
"FN-050: post-squash audit blocked auto-completion for mergedco",
);
expect(store.moveTask).not.toHaveBeenCalled();
expect(store.appendAgentLog).toHaveBeenCalledWith(
"FN-050",
expect.stringContaining("post-squash audit blocked auto-completion"),
"tool_error",
expect.stringContaining("Duplicate-subject risks:\n- feat: duplicate subject"),
"merger",
);
expect(store.updateTask).toHaveBeenCalledWith("FN-050", { status: null });
});
it("blocks completion and logs touched-file-overlap findings", async () => {
setupAutoResolvedMergeExecSync();
mockedAuditSquashMerge.mockResolvedValue({
squashSha: "mergedcommit123",
parentSha: "parent123",
squashSubject: "feat: squash merge",
lookback: 30,
branchSubjects: ["feat: branch change"],
recentMainSubjects: [],
duplicateSubjects: [],
touchedFiles: ["src/shared.ts"],
touchedFileOverlaps: [{
type: "touched-file-overlap",
file: "src/shared.ts",
recentMainCommits: [{ sha: "abc1234", subject: "fix: recent main change" }],
}],
findings: [{
type: "touched-file-overlap",
file: "src/shared.ts",
recentMainCommits: [{ sha: "abc1234", subject: "fix: recent main change" }],
}],
issueCount: 1,
clean: false,
});
const store = createAuditStore();
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toThrow(
"FN-050: post-squash audit blocked auto-completion for mergedco",
);
expect(store.moveTask).not.toHaveBeenCalled();
expect(store.appendAgentLog).toHaveBeenCalledWith(
"FN-050",
expect.stringContaining("post-squash audit blocked auto-completion"),
"tool_error",
expect.stringContaining("Touched-file overlap risks:\n- src/shared.ts\n - abc1234 fix: recent main change"),
"merger",
);
expect(store.updateTask).toHaveBeenCalledWith("FN-050", { status: null });
});
});

View File

@@ -0,0 +1,126 @@
import { afterEach, describe, expect, it } from "vitest";
import { execSync, spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { auditSquashMerge } from "../merger-squash-audit.js";
const hasGit = spawnSync("git", ["--version"], { stdio: "pipe" }).status === 0;
const describeIfGit = hasGit ? describe : describe.skip;
function git(repo: string, command: string): string {
return execSync(command, { cwd: repo, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
}
function write(repo: string, relativePath: string, content: string): void {
writeFileSync(join(repo, relativePath), content, "utf-8");
}
describeIfGit("auditSquashMerge", () => {
const repos: string[] = [];
afterEach(() => {
for (const repo of repos.splice(0)) {
rmSync(repo, { recursive: true, force: true });
}
});
function setupRepo(): string {
const repo = mkdtempSync(join(tmpdir(), "fusion-merger-squash-audit-"));
repos.push(repo);
git(repo, "git init -b main");
git(repo, 'git config user.email "test@example.com"');
git(repo, 'git config user.name "Test User"');
write(repo, "README.md", "init\n");
git(repo, "git add README.md && git commit -m 'init'");
return repo;
}
function createSquashCommit(repo: string, bodyLines: string[]): string {
const body = bodyLines.map((line) => `- ${line}`).join("\n");
git(repo, `git commit -m ${JSON.stringify("feat: squash merge")} -m ${JSON.stringify(body)}`);
return git(repo, "git rev-parse HEAD");
}
it("returns a clean result when no duplicate subjects or recent-main file overlaps exist", async () => {
const repo = setupRepo();
git(repo, "git checkout -b feature/clean");
write(repo, "feature.txt", "branch-only\n");
git(repo, "git add feature.txt && git commit -m 'feat: branch clean change'");
git(repo, "git checkout main");
write(repo, "main-only.txt", "recent main\n");
git(repo, "git add main-only.txt && git commit -m 'chore: recent main touch'");
git(repo, "git merge --squash feature/clean");
const squashSha = createSquashCommit(repo, ["feat: branch clean change"]);
const findings = await auditSquashMerge({ rootDir: repo, squashSha, lookback: 10 });
expect(findings.clean).toBe(true);
expect(findings.issueCount).toBe(0);
expect(findings.duplicateSubjects).toEqual([]);
expect(findings.touchedFileOverlaps).toEqual([]);
});
it("reports duplicate branch subjects that match recent main commits", async () => {
const repo = setupRepo();
git(repo, "git checkout -b feature/dupe");
write(repo, "branch.txt", "feature\n");
git(repo, "git add branch.txt && git commit -m 'feat: duplicate subject'");
git(repo, "git checkout main");
write(repo, "main.txt", "main\n");
git(repo, "git add main.txt && git commit -m 'feat: duplicate subject'");
git(repo, "git merge --squash feature/dupe");
const squashSha = createSquashCommit(repo, ["feat: duplicate subject"]);
const findings = await auditSquashMerge({ rootDir: repo, squashSha, lookback: 10 });
expect(findings.clean).toBe(false);
expect(findings.duplicateSubjects).toEqual([
{ type: "duplicate-subject", subject: "feat: duplicate subject" },
]);
expect(findings.issueCount).toBe(1);
});
it("reports touched-file overlaps with recent main commits", async () => {
const repo = setupRepo();
write(repo, "shared.txt", "alpha\nbeta\ngamma\n");
git(repo, "git add shared.txt && git commit -m 'chore: add shared file'");
git(repo, "git checkout -b feature/overlap");
write(repo, "shared.txt", "alpha-branch\nbeta\ngamma\n");
git(repo, "git add shared.txt && git commit -m 'feat: branch edits shared file'");
git(repo, "git checkout main");
write(repo, "shared.txt", "alpha\nbeta\ngamma-main\n");
git(repo, "git add shared.txt && git commit -m 'fix: main edits shared file'");
git(repo, "git merge --squash feature/overlap");
const squashSha = createSquashCommit(repo, ["feat: branch edits shared file"]);
const findings = await auditSquashMerge({ rootDir: repo, squashSha, lookback: 10 });
expect(findings.clean).toBe(false);
expect(findings.duplicateSubjects).toEqual([]);
expect(findings.touchedFileOverlaps).toHaveLength(1);
expect(findings.touchedFileOverlaps[0]).toMatchObject({
type: "touched-file-overlap",
file: "shared.txt",
});
expect(findings.touchedFileOverlaps[0].recentMainCommits).toEqual(
expect.arrayContaining([
{
sha: expect.any(String),
subject: "fix: main edits shared file",
},
]),
);
expect(findings.issueCount).toBe(1);
});
});

View File

@@ -31,6 +31,15 @@ export {
type MergerOptions,
type AutostashOrphanRecord,
} from "./merger.js";
export {
auditSquashMerge,
formatSquashAuditReport,
type SquashAuditFindings,
type SquashAuditFinding,
type SquashAuditDuplicateSubjectFinding,
type SquashAuditTouchedFileOverlapFinding,
type SquashAuditRecentMainCommit,
} from "./merger-squash-audit.js";
export { reviewStep, type ReviewType, type ReviewVerdict, type ReviewResult, type ReviewOptions } from "./reviewer.js";
export { createFnAgent, promptWithFallback, describeModel, setHostExtensionPaths, getHostExtensionPaths, type AgentOptions, type AgentResult } from "./pi.js";

View File

@@ -0,0 +1,187 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const DEFAULT_LOOKBACK = 30;
const GIT_OUTPUT_MAX_BUFFER = 10 * 1024 * 1024;
export interface SquashAuditRecentMainCommit {
sha: string;
subject: string;
}
export interface SquashAuditDuplicateSubjectFinding {
type: "duplicate-subject";
subject: string;
}
export interface SquashAuditTouchedFileOverlapFinding {
type: "touched-file-overlap";
file: string;
recentMainCommits: SquashAuditRecentMainCommit[];
}
export type SquashAuditFinding = SquashAuditDuplicateSubjectFinding | SquashAuditTouchedFileOverlapFinding;
export interface SquashAuditFindings {
squashSha: string;
parentSha: string;
squashSubject: string;
lookback: number;
branchSubjects: string[];
recentMainSubjects: string[];
duplicateSubjects: SquashAuditDuplicateSubjectFinding[];
touchedFiles: string[];
touchedFileOverlaps: SquashAuditTouchedFileOverlapFinding[];
findings: SquashAuditFinding[];
issueCount: number;
clean: boolean;
}
export async function auditSquashMerge({
rootDir,
squashSha,
lookback = DEFAULT_LOOKBACK,
}: {
rootDir: string;
squashSha: string;
lookback?: number;
}): Promise<SquashAuditFindings> {
const normalizedLookback = normalizeLookback(lookback);
const parentSha = await git(rootDir, ["rev-parse", `${squashSha}^`]);
const squashSubject = await git(rootDir, ["log", "-1", "--format=%s", squashSha]);
const branchSubjects = normalizeLines(await git(rootDir, ["log", "-1", "--format=%b", squashSha]))
.map((line) => line.replace(/^- /, "").trim())
.filter(Boolean);
const recentMainCommits = await listRecentMainCommits(rootDir, parentSha, normalizedLookback);
const recentMainSubjects = recentMainCommits.map((entry) => entry.subject);
const duplicateSubjects = branchSubjects
.filter((subject) => recentMainSubjects.includes(subject))
.map((subject) => ({ type: "duplicate-subject", subject }) satisfies SquashAuditDuplicateSubjectFinding);
const touchedFiles = normalizeLines(await git(rootDir, ["diff", "--name-only", parentSha, squashSha]));
const touchedFileOverlaps: SquashAuditTouchedFileOverlapFinding[] = [];
for (const file of touchedFiles) {
const overlappingCommits: SquashAuditRecentMainCommit[] = [];
for (const commit of recentMainCommits) {
const touchedInCommit = await git(rootDir, ["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha, "--", file]);
if (normalizeLines(touchedInCommit).includes(file)) {
overlappingCommits.push({ sha: commit.shortSha, subject: commit.subject });
}
}
if (overlappingCommits.length > 0) {
touchedFileOverlaps.push({
type: "touched-file-overlap",
file,
recentMainCommits: overlappingCommits,
});
}
}
const findings: SquashAuditFinding[] = [...duplicateSubjects, ...touchedFileOverlaps];
return {
squashSha,
parentSha,
squashSubject,
lookback: normalizedLookback,
branchSubjects,
recentMainSubjects,
duplicateSubjects,
touchedFiles,
touchedFileOverlaps,
findings,
issueCount: findings.length,
clean: findings.length === 0,
};
}
export function formatSquashAuditReport(findings: SquashAuditFindings): string {
const lines: string[] = [
`Auditing squash: ${findings.squashSha}${findings.squashSubject}`,
`Parent (main before squash): ${findings.parentSha}`,
`Lookback window on main: ${findings.lookback} commits`,
"",
"=== Duplicate-cherry-pick risk ===",
];
if (findings.duplicateSubjects.length === 0) {
lines.push("(none — no branch commit subjects match recent main commits)", "");
} else {
lines.push(
"WARN: branch contains commits whose subjects match recent main commits.",
"Auto-resolve may have picked the older side, dropping refinements.",
"Action: diff each main commit below against HEAD and confirm its",
"net contribution survived. Restore anything dropped as a follow-up.",
"",
...findings.duplicateSubjects.map((entry) => ` - ${entry.subject}`),
"",
);
}
lines.push(`=== Touched-file overlap (${findings.touchedFiles.length} files in squash) ===`);
if (findings.touchedFileOverlaps.length === 0) {
lines.push("(none — squash touches files no recent main commit touched)", "");
} else {
lines.push(
"Files the squash touched that also have recent main activity.",
"Action: for each commit below, verify its changes still appear",
"in HEAD. Reapply any silently dropped changes on the same branch.",
"",
);
for (const overlap of findings.touchedFileOverlaps) {
lines.push(` ${overlap.file}`);
for (const commit of overlap.recentMainCommits) {
lines.push(` - ${commit.sha} ${commit.subject}`);
}
}
lines.push("");
}
lines.push(`Audit complete. ${findings.issueCount} item(s) for the calling agent to review.`);
return lines.join("\n");
}
async function git(rootDir: string, args: string[]): Promise<string> {
const { stdout } = await execFileAsync("git", args, {
cwd: rootDir,
encoding: "utf-8",
maxBuffer: GIT_OUTPUT_MAX_BUFFER,
});
return stdout.trim();
}
function normalizeLines(value: string): string[] {
const trimmed = value.trim();
if (!trimmed) return [];
return trimmed.split("\n").map((line) => line.trim()).filter(Boolean);
}
async function listRecentMainCommits(rootDir: string, parentSha: string, lookback: number): Promise<Array<{ sha: string; shortSha: string; subject: string }>> {
const entries = normalizeLines(await git(rootDir, ["log", `--format=%H~%h~%s`, `-n`, String(lookback), parentSha]));
return entries
.map((entry) => {
const [sha, shortSha, ...subjectParts] = entry.split("~");
const subject = subjectParts.join("~").trim();
if (!sha?.trim() || !shortSha?.trim() || !subject) {
return null;
}
return {
sha: sha.trim(),
shortSha: shortSha.trim(),
subject,
};
})
.filter((entry): entry is { sha: string; shortSha: string; subject: string } => entry !== null);
}
function normalizeLookback(value: number | undefined): number {
if (!Number.isFinite(value) || !value || value < 1) {
return DEFAULT_LOOKBACK;
}
return Math.trunc(value);
}

View File

@@ -70,6 +70,7 @@ import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
import { Type } from "typebox";
import { createRunAuditor, generateSyntheticRunId, type EngineRunContext } from "./run-audit.js";
import { createWebFetchTool } from "./agent-tools.js";
import { auditSquashMerge, type SquashAuditFindings } from "./merger-squash-audit.js";
/** Conflict type classification for merge conflict resolution */
export type ConflictType =
@@ -475,6 +476,17 @@ export class MergeAbortedError extends Error {
}
}
export class SquashAuditError extends Error {
constructor(
taskId: string,
public readonly squashSha: string,
public readonly findings: SquashAuditFindings,
) {
super(buildSquashAuditBlockingMessage(taskId, squashSha, findings));
this.name = "SquashAuditError";
}
}
export function throwIfAborted(signal: AbortSignal | undefined, taskId: string): void {
if (!signal?.aborted) return;
throw new MergeAbortedError(`Merge aborted for ${taskId}: engine shutdown requested`);
@@ -4143,6 +4155,46 @@ function quoteArg(value: string): string {
return `"${value.replace(/(["\\$`])/g, "\\$1")}"`;
}
function shouldRunPostSquashAudit(result: MergeResult, mergeWasEmpty: boolean, isEmptyCommit: boolean, commitSha?: string): boolean {
if (mergeWasEmpty || isEmptyCommit || !commitSha) {
return false;
}
return (result.autoResolvedCount ?? 0) > 0 || result.attemptsMade === 3;
}
function buildSquashAuditBlockingMessage(taskId: string, squashSha: string, findings: SquashAuditFindings): string {
const riskParts: string[] = [];
if (findings.duplicateSubjects.length > 0) {
riskParts.push(`${findings.duplicateSubjects.length} duplicate-subject risk${findings.duplicateSubjects.length === 1 ? "" : "s"}`);
}
if (findings.touchedFileOverlaps.length > 0) {
riskParts.push(`${findings.touchedFileOverlaps.length} touched-file overlap risk${findings.touchedFileOverlaps.length === 1 ? "" : "s"}`);
}
const summary = riskParts.length > 0 ? riskParts.join(", ") : `${findings.issueCount} audit finding(s)`;
return `${taskId}: post-squash audit blocked auto-completion for ${squashSha.slice(0, 8)} (${summary})`;
}
function formatSquashAuditAgentLog(findings: SquashAuditFindings): string {
const lines: string[] = [];
if (findings.duplicateSubjects.length > 0) {
lines.push("Duplicate-subject risks:");
for (const duplicate of findings.duplicateSubjects) {
lines.push(`- ${duplicate.subject}`);
}
}
if (findings.touchedFileOverlaps.length > 0) {
if (lines.length > 0) lines.push("");
lines.push("Touched-file overlap risks:");
for (const overlap of findings.touchedFileOverlaps) {
lines.push(`- ${overlap.file}`);
for (const commit of overlap.recentMainCommits) {
lines.push(` - ${commit.sha} ${commit.subject}`);
}
}
}
return lines.join("\n");
}
/**
* Resolve a non-empty commit body for fallback merge commits. Used by sites
* that would otherwise emit `-m ""` when the branch's commit log is empty
@@ -6065,6 +6117,27 @@ export async function aiMergeTask(
// attemptWithSideStrategy return true without committing when nothing
// was staged. The recorded HEAD then has nothing to do with this task.
const recordedSha = (isEmptyCommit || mergeWasEmpty) ? undefined : commitSha;
const auditSha = recordedSha;
if (auditSha && shouldRunPostSquashAudit(result, mergeWasEmpty, isEmptyCommit, auditSha)) {
const auditFindings = await auditSquashMerge({
rootDir,
squashSha: auditSha,
});
if (!auditFindings.clean) {
const auditError = new SquashAuditError(taskId, auditSha, auditFindings);
await store.appendAgentLog(
taskId,
auditError.message,
"tool_error",
formatSquashAuditAgentLog(auditFindings),
"merger",
);
await store.updateTask(taskId, { status: null });
throw auditError;
}
await store.appendAgentLog(taskId, "post-squash audit clean", "text", undefined, "merger");
}
if (isEmptyCommit) {
mergerLog.warn(
`${taskId}: local squash produced an empty commit (${commitSha?.slice(0, 8)}) — branch likely contained dupes of main. Skipping commitSha; recovery will backfill when real commit lands.`,
@@ -6138,6 +6211,9 @@ export async function aiMergeTask(
"merger",
);
} catch (err: any) {
if (err instanceof SquashAuditError || err?.name === "SquashAuditError") {
throw err;
}
mergerLog.warn(`${taskId}: failed to collect/store merge details: ${err.message}`);
}