FN-062: enforce AI merge review finding reconciliation

Make AI merge reviews preserve actionable reasons and require explicit resolution of prior blockers before landing.

- Recover reviewer rejection reasons and preserve bounded squash-fidelity findings across corrective passes.
- Require explicit prior-finding acknowledgement, reject unknown acknowledgements as new blockers, and keep advisory concerns landable.
- Park typed AI review blocks without git-conflict retries and cover the reconciliation contract with regression tests and release notes.

Files changed:
 .changeset/fn-062-ai-merge-review-livelock.md      |   7 ++
 docs/architecture.md                               |   1 +
 .../src/__tests__/merge-error-recovery.test.ts     |  29 ++++-
 .../engine/src/__tests__/merger-ai-prompts.test.ts |  25 +++-
 packages/engine/src/__tests__/merger-ai.test.ts    |  80 ++++++++++---
 packages/engine/src/__tests__/workspace-merger.test.ts  |  27 +++-
 packages/engine/src/merge/merger-ai-prompts.ts     |  74 +++++++++---
 packages/engine/src/merge/merger-ai.ts             | 127 ++++++++++++++-------
 packages/engine/src/project-engine.ts              |  27 +++++
 9 files changed, 325 insertions(+), 72 deletions(-)

Fusion-Task-Id: FN-062

Fusion-Task-Lineage: 59d6d94d-a9e3-4f6a-9d16-d434a335e6a3

Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
Fusion Agent
2026-08-20 03:11:39 +00:00
parent 07125886b7
commit 14befb76bc
8 changed files with 325 additions and 73 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Prevent blocked AI merge reviews from retrying as git conflicts.
category: fix
dev: Reconciles and bounds durable squash-review findings across corrective passes.

View File

@@ -70,7 +70,7 @@ vi.mock("../runtimes/in-process-runtime.js", () => ({
import { ProjectEngine } from "../project-engine.js"; import { ProjectEngine } from "../project-engine.js";
import { runtimeLog } from "../logger.js"; import { runtimeLog } from "../logger.js";
import { VerificationError } from "../merger.js"; import { VerificationError } from "../merger.js";
import { runAiMerge } from "../merge/merger-ai.js"; import { AiMergeBlockedError, runAiMerge } from "../merge/merger-ai.js";
type MockTask = { type MockTask = {
id: string; id: string;
@@ -362,6 +362,33 @@ describe("ProjectEngine merge error recovery", () => {
expect(hasErrorLog(errorSpy, "failed to bounce")).toBe(false); expect(hasErrorLog(errorSpy, "failed to bounce")).toBe(false);
}); });
it("parks typed AI review blocks containing conflicts without retrying or bouncing", async () => {
vi.useFakeTimers();
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
const store = makeStore({
tasks: [makeTask({ mergeRetries: 0 }), makeTask({ mergeRetries: 3 })],
});
vi.mocked(runAiMerge).mockRejectedValueOnce(
new AiMergeBlockedError(TASK_ID, ["review assertions conflicts with builtin settings"]),
);
const engine = createEngine(store);
await runMergeCycle(engine);
expect(store.updateTask).toHaveBeenCalledWith(TASK_ID, {
status: "failed",
mergeRetries: 3,
error: "AI merge review blocked landing; operator intervention is required.",
});
expect(store.updateTask).not.toHaveBeenCalledWith(TASK_ID, expect.objectContaining({ status: null }));
expect(store.moveTask).not.toHaveBeenCalled();
expect(store.logEntry).not.toHaveBeenCalledWith(TASK_ID, expect.any(String), "MergeConflictBounce");
expect(setTimeoutSpy).not.toHaveBeenCalledWith(expect.any(Function), expect.any(Number));
const canMergeTask = (engine as unknown as { canMergeTask: (task: MockTask, retries: number) => boolean }).canMergeTask.bind(engine);
expect(canMergeTask(makeTask({ status: "failed", updatedAt: new Date(0).toISOString() }), 3)).toBe(false);
vi.useRealTimers();
});
it("logs when bouncing fails after conflict retries are exhausted", async () => { it("logs when bouncing fails after conflict retries are exhausted", async () => {
const store = makeStore({ const store = makeStore({
tasks: [makeTask({ mergeRetries: 2 }), makeTask({ mergeRetries: 3 })], tasks: [makeTask({ mergeRetries: 2 }), makeTask({ mergeRetries: 3 })],

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { import {
REVIEW_VERDICT_MARKER, REVIEW_VERDICT_MARKER,
RESOLVED_PRIOR_FINDINGS_MARKER,
buildMergeSystemPrompt, buildMergeSystemPrompt,
buildReviewSystemPrompt, buildReviewSystemPrompt,
parseReviewVerdict, parseReviewVerdict,
@@ -13,6 +14,7 @@ describe("merger-ai prompt/verdict re-exports", () => {
verdict: "reject", verdict: "reject",
reasons: ["reviewer produced no output"], reasons: ["reviewer produced no output"],
severity: "blocking", severity: "blocking",
resolvedPriorReasons: [],
}); });
}); });
@@ -23,6 +25,7 @@ describe("merger-ai prompt/verdict re-exports", () => {
`reviewer did not emit a "${REVIEW_VERDICT_MARKER} approve|reject" line`, `reviewer did not emit a "${REVIEW_VERDICT_MARKER} approve|reject" line`,
], ],
severity: "blocking", severity: "blocking",
resolvedPriorReasons: [],
}); });
}); });
@@ -35,6 +38,7 @@ describe("merger-ai prompt/verdict re-exports", () => {
verdict: "reject", verdict: "reject",
reasons: ["dropped a conflict hunk"], reasons: ["dropped a conflict hunk"],
severity: "blocking", severity: "blocking",
resolvedPriorReasons: [],
}); });
}); });
@@ -47,6 +51,7 @@ describe("merger-ai prompt/verdict re-exports", () => {
verdict: "reject", verdict: "reject",
reasons: ["commit message is vague"], reasons: ["commit message is vague"],
severity: "advisory", severity: "advisory",
resolvedPriorReasons: [],
}); });
}); });
@@ -56,6 +61,7 @@ describe("merger-ai prompt/verdict re-exports", () => {
).toEqual({ ).toEqual({
verdict: "approve", verdict: "approve",
reasons: [], reasons: [],
resolvedPriorReasons: [],
}); });
}); });
@@ -72,6 +78,21 @@ describe("merger-ai prompt/verdict re-exports", () => {
"skipped docs update", "skipped docs update",
], ],
severity: "blocking", severity: "blocking",
resolvedPriorReasons: [],
});
});
it("parses explicitly acknowledged resolved prior findings", () => {
expect(parseReviewVerdict([
`${RESOLVED_PRIOR_FINDINGS_MARKER}`,
"- Missing generated types.",
"- Drops task export",
"SEVERITY: blocking",
"- newly found defect",
`${REVIEW_VERDICT_MARKER} reject`,
].join("\n"))).toMatchObject({
resolvedPriorReasons: ["Missing generated types.", "Drops task export"],
reasons: ["newly found defect"],
}); });
}); });
@@ -82,6 +103,8 @@ describe("merger-ai prompt/verdict re-exports", () => {
); );
expect(buildReviewSystemPrompt()).toContain(REVIEW_VERDICT_MARKER); expect(buildReviewSystemPrompt()).toContain(REVIEW_VERDICT_MARKER);
expect(buildReviewSystemPrompt()).toContain("Do NOT edit, stage, commit"); expect(buildReviewSystemPrompt()).toContain("Do NOT edit, stage, commit");
expect(buildReviewSystemPrompt()).toContain("Whole-tree semantic");
expect(buildReviewSystemPrompt()).toContain("never a blocking veto");
}); });
}); });
@@ -191,7 +214,7 @@ describe("parseReviewVerdict — reason recovery (FN-8004)", () => {
const result = parseReviewVerdict( const result = parseReviewVerdict(
`Everything checks out.\n${REVIEW_VERDICT_MARKER} approve` `Everything checks out.\n${REVIEW_VERDICT_MARKER} approve`
); );
expect(result).toEqual({ verdict: "approve", reasons: [] }); expect(result).toEqual({ verdict: "approve", reasons: [], resolvedPriorReasons: [] });
}); });
it("gives the reviewer an unambiguous, self-consistent ordering instruction", () => { it("gives the reviewer an unambiguous, self-consistent ordering instruction", () => {

View File

@@ -31,6 +31,7 @@ import {
buildReviewPrompt, buildReviewPrompt,
buildReviewSystemPrompt, buildReviewSystemPrompt,
REVIEW_VERDICT_MARKER, REVIEW_VERDICT_MARKER,
RESOLVED_PRIOR_FINDINGS_MARKER,
AiMergeBlockedError, AiMergeBlockedError,
} from "../merge/merger-ai.js"; } from "../merge/merger-ai.js";
import { EXECUTOR_FAILED_INCOMPLETE_REASON } from "../overseer/planner-overseer.js"; import { EXECUTOR_FAILED_INCOMPLETE_REASON } from "../overseer/planner-overseer.js";
@@ -134,16 +135,16 @@ function realMergeAgent(branch: string) {
describe("parseReviewVerdict", () => { describe("parseReviewVerdict", () => {
it("approves cleanly", () => { it("approves cleanly", () => {
expect(parseReviewVerdict("ok\nREVIEW_VERDICT: approve")).toEqual({ verdict: "approve", reasons: [] }); expect(parseReviewVerdict("ok\nREVIEW_VERDICT: approve")).toEqual({ verdict: "approve", reasons: [], resolvedPriorReasons: [] });
}); });
it("rejects with blocking severity by default", () => { it("rejects with blocking severity by default", () => {
expect(parseReviewVerdict("REVIEW_VERDICT: reject\n- dropped a hunk")).toEqual({ expect(parseReviewVerdict("REVIEW_VERDICT: reject\n- dropped a hunk")).toEqual({
verdict: "reject", severity: "blocking", reasons: ["dropped a hunk"], verdict: "reject", severity: "blocking", reasons: ["dropped a hunk"], resolvedPriorReasons: [],
}); });
}); });
it("parses advisory severity and drops the SEVERITY line from reasons", () => { it("parses advisory severity and drops the SEVERITY line from reasons", () => {
expect(parseReviewVerdict("REVIEW_VERDICT: reject\nSEVERITY: advisory\n- nit")).toEqual({ expect(parseReviewVerdict("REVIEW_VERDICT: reject\nSEVERITY: advisory\n- nit")).toEqual({
verdict: "reject", severity: "advisory", reasons: ["nit"], verdict: "reject", severity: "advisory", reasons: ["nit"], resolvedPriorReasons: [],
}); });
}); });
it("fails safe to blocking on empty/garbled output", () => { it("fails safe to blocking on empty/garbled output", () => {
@@ -262,7 +263,7 @@ describe("runAiMerge", () => {
git(dir, "add concurrent.txt"); git(dir, "add concurrent.txt");
git(dir, "commit -q -m 'main: concurrent advance'"); git(dir, "commit -q -m 'main: concurrent advance'");
} }
return "REVIEW_VERDICT: approve"; return `${RESOLVED_PRIOR_FINDINGS_MARKER} ${blocker}\nREVIEW_VERDICT: approve`;
}); });
const result = await runAiMerge(store, dir, "FN-1", { manual: true }, { const result = await runAiMerge(store, dir, "FN-1", { manual: true }, {
@@ -273,7 +274,7 @@ describe("runAiMerge", () => {
expect(result.merged).toBe(true); expect(result.merged).toBe(true);
expect(reviewPrompts).toHaveLength(3); expect(reviewPrompts).toHaveLength(3);
expect(reviewPrompts[2]).toContain(blocker); expect(reviewPrompts[2]).toContain(blocker);
expect(reviewPrompts[2]).toContain("complete resulting tree"); expect(reviewPrompts[2]).toContain("squash's fidelity");
}); });
it("rechecks a durable blocker when a later merge retry starts", async () => { it("rechecks a durable blocker when a later merge retry starts", async () => {
@@ -285,7 +286,7 @@ describe("runAiMerge", () => {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}], }],
}); });
const reviewAgent = vi.fn(async () => "REVIEW_VERDICT: approve"); const reviewAgent = vi.fn(async () => `${RESOLVED_PRIOR_FINDINGS_MARKER} ${blocker}\nREVIEW_VERDICT: approve`);
await runAiMerge(store, dir, "FN-1", { manual: true }, { await runAiMerge(store, dir, "FN-1", { manual: true }, {
mergeAgent: realMergeAgent("fusion/fn-1"), mergeAgent: realMergeAgent("fusion/fn-1"),
@@ -293,7 +294,60 @@ describe("runAiMerge", () => {
}); });
expect(reviewAgent.mock.calls[0]?.[1]).toContain(blocker); expect(reviewAgent.mock.calls[0]?.[1]).toContain(blocker);
expect(reviewAgent.mock.calls[0]?.[1]).toContain("complete resulting tree"); expect(reviewAgent.mock.calls[0]?.[1]).toContain("squash's fidelity");
});
it("reconciles resolved findings and gives corrective merges only new findings", async () => {
const { dir } = initRepoWithBranch({ branch: "fusion/fn-1" });
const { store } = makeStore(dir, {}, { merger: { mode: "ai", maxReviewPasses: 2 } });
const prior = "Missing generated types.";
const introduced = "Drops the task branch export.";
const mergePrompts: string[] = [];
const reviewPrompts: string[] = [];
let reviewCount = 0;
await runAiMerge(store, dir, "FN-1", { manual: true }, {
mergeAgent: async (cwd, prompt) => {
mergePrompts.push(prompt);
await realMergeAgent("fusion/fn-1")(cwd, prompt);
},
reviewAgent: async (_cwd, prompt) => {
reviewPrompts.push(prompt);
reviewCount++;
if (reviewCount === 1) return `${prior}\nSEVERITY: blocking\nREVIEW_VERDICT: reject`;
if (reviewCount === 2) return `${RESOLVED_PRIOR_FINDINGS_MARKER} missing generated types\n\n- ${introduced}\nSEVERITY: blocking\nREVIEW_VERDICT: reject`;
return `${RESOLVED_PRIOR_FINDINGS_MARKER} ${introduced}\nREVIEW_VERDICT: approve`;
},
});
expect(reviewPrompts[1]).toContain(prior);
expect(reviewPrompts[2]).toContain(introduced);
expect(reviewPrompts[2]).not.toContain(prior);
expect(mergePrompts[1]).toContain(prior);
expect(mergePrompts[2]).toContain(introduced);
expect(mergePrompts[2]).not.toContain(prior);
});
it("bounds and normalizes recovered blocking finding contracts", async () => {
const { dir } = initRepoWithBranch({ branch: "fusion/fn-1" });
const reasons = Array.from({ length: 12 }, (_, index) => `finding ${index}`);
const { store } = makeStore(dir, {
log: [{
action: `AI merge BLOCKED after 2 corrective pass(es) — unresolved correctness concern: Finding 0; finding 0!!!; ${reasons.slice(1).join("; ")}`,
timestamp: new Date().toISOString(),
}],
});
const reviewAgent = vi.fn(async () => `${RESOLVED_PRIOR_FINDINGS_MARKER}\n${reasons.slice(0, 8).map((reason) => `- ${reason}`).join("\n")}\nREVIEW_VERDICT: approve`);
await runAiMerge(store, dir, "FN-1", { manual: true }, {
mergeAgent: realMergeAgent("fusion/fn-1"),
reviewAgent,
});
const recoveredPrompt = reviewAgent.mock.calls[0]?.[1] ?? "";
expect(recoveredPrompt).toContain("Finding 0");
expect(recoveredPrompt).not.toContain("finding 8");
expect((recoveredPrompt.match(/^ - finding/igm) ?? []).length).toBeLessThanOrEqual(8);
}); });
it("reviews a durable blocker even when the retried branch has zero commits ahead", async () => { it("reviews a durable blocker even when the retried branch has zero commits ahead", async () => {
@@ -306,7 +360,7 @@ describe("runAiMerge", () => {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}], }],
}); });
const reviewAgent = vi.fn(async () => "REVIEW_VERDICT: approve"); const reviewAgent = vi.fn(async () => `${RESOLVED_PRIOR_FINDINGS_MARKER} ${blocker}\nREVIEW_VERDICT: approve`);
await runAiMerge(store, dir, "FN-1", { manual: true }, { await runAiMerge(store, dir, "FN-1", { manual: true }, {
mergeAgent: vi.fn(async () => { /* zero-ahead corrective review */ }), mergeAgent: vi.fn(async () => { /* zero-ahead corrective review */ }),
@@ -333,7 +387,7 @@ describe("runAiMerge", () => {
}); });
const reviewAgent = vi.fn() const reviewAgent = vi.fn()
.mockResolvedValueOnce(`${blocker}\nSEVERITY: blocking\nREVIEW_VERDICT: reject`) .mockResolvedValueOnce(`${blocker}\nSEVERITY: blocking\nREVIEW_VERDICT: reject`)
.mockResolvedValueOnce("REVIEW_VERDICT: approve"); .mockResolvedValueOnce(`${RESOLVED_PRIOR_FINDINGS_MARKER} ${blocker}\nREVIEW_VERDICT: approve`);
const result = await runAiMerge(store, dir, "FN-1", { manual: true }, { mergeAgent, reviewAgent }); const result = await runAiMerge(store, dir, "FN-1", { manual: true }, { mergeAgent, reviewAgent });
@@ -352,7 +406,7 @@ describe("runAiMerge", () => {
const reviewAgent = vi.fn() const reviewAgent = vi.fn()
.mockResolvedValueOnce(`${blockerX}\nREVIEW_VERDICT: reject`) .mockResolvedValueOnce(`${blockerX}\nREVIEW_VERDICT: reject`)
.mockResolvedValueOnce(`${blockerY}\nREVIEW_VERDICT: reject`) .mockResolvedValueOnce(`${blockerY}\nREVIEW_VERDICT: reject`)
.mockResolvedValueOnce("REVIEW_VERDICT: approve"); .mockResolvedValueOnce(`${RESOLVED_PRIOR_FINDINGS_MARKER} ${blockerX}\n- ${blockerY}\nREVIEW_VERDICT: approve`);
await runAiMerge(store, dir, "FN-1", { manual: true }, { await runAiMerge(store, dir, "FN-1", { manual: true }, {
mergeAgent: realMergeAgent("fusion/fn-1"), mergeAgent: realMergeAgent("fusion/fn-1"),
@@ -363,17 +417,17 @@ describe("runAiMerge", () => {
expect(reviewAgent.mock.calls[2]?.[1]).toContain(blockerY); expect(reviewAgent.mock.calls[2]?.[1]).toContain(blockerY);
}); });
it("recovers every blocker from interrupted per-pass rejection logs", async () => { it("recovers the latest reconciled blocker contract from interrupted logs", async () => {
const { dir } = initRepoWithBranch({ branch: "fusion/fn-1" }); const { dir } = initRepoWithBranch({ branch: "fusion/fn-1" });
const blockerX = "authorization is bypassed"; const blockerX = "authorization is bypassed";
const blockerY = "audit metadata is missing"; const blockerY = "audit metadata is missing";
const { store } = makeStore(dir, { const { store } = makeStore(dir, {
log: [ log: [
{ action: `AI merge review (pass 1): rejected (blocking) — ${blockerX}` }, { action: `AI merge review (pass 1): rejected (blocking) — ${blockerX}` },
{ action: `AI merge review (pass 2): rejected (blocking) — ${blockerY}` }, { action: `AI merge review (pass 2): rejected (blocking) — ${blockerX}; ${blockerY}` },
], ],
}); });
const reviewAgent = vi.fn(async () => "REVIEW_VERDICT: approve"); const reviewAgent = vi.fn(async () => `${RESOLVED_PRIOR_FINDINGS_MARKER} ${blockerX}\n- ${blockerY}\nREVIEW_VERDICT: approve`);
await runAiMerge(store, dir, "FN-1", { manual: true }, { await runAiMerge(store, dir, "FN-1", { manual: true }, {
mergeAgent: realMergeAgent("fusion/fn-1"), mergeAgent: realMergeAgent("fusion/fn-1"),

View File

@@ -30,7 +30,7 @@ import { writeFileSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import type { Task, TaskStore } from "@fusion/core"; import type { Task, TaskStore } from "@fusion/core";
import { assertNotWorkspaceTaskMerge } from "@fusion/core"; import { assertNotWorkspaceTaskMerge } from "@fusion/core";
import { landWorkspaceTask, runAiMerge } from "../merge/merger-ai.js"; import { RESOLVED_PRIOR_FINDINGS_MARKER, landWorkspaceTask, runAiMerge } from "../merge/merger-ai.js";
import { createWorkspaceFixture, hasGit, type WorkspaceFixture } from "./_workspace-fixture.js"; import { createWorkspaceFixture, hasGit, type WorkspaceFixture } from "./_workspace-fixture.js";
const describeIfGit = hasGit ? describe : describe.skip; const describeIfGit = hasGit ? describe : describe.skip;
@@ -217,6 +217,31 @@ describeIfGit("landWorkspaceTask — per-repo merge loop (Phase C U1)", () => {
expect(store.emitted.filter((e) => e.event === "task:merged")).toHaveLength(1); expect(store.emitted.filter((e) => e.event === "task:merged")).toHaveLength(1);
}); });
it("reuses reconciled review findings for a workspace sub-repository", async () => {
fx = await createWorkspaceFixture(["repo-a"]);
addRepoBranchWithEdit(fx, "repo-a", "a feature\n");
const store = createStore({ merger: { mode: "ai", maxReviewPasses: 1 } });
const task = makeTask({ "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH } });
const finding = "drops the workspace task export";
const reviewPrompts: string[] = [];
let reviews = 0;
const result = await landWorkspaceTask(store, task, fx.rootDir, {}, {
mergeAgent: squashMergeAgent(BRANCH),
reviewAgent: async (_cwd, prompt) => {
reviewPrompts.push(prompt);
reviews++;
return reviews === 1
? `${finding}\nSEVERITY: blocking\nREVIEW_VERDICT: reject`
: `${RESOLVED_PRIOR_FINDINGS_MARKER} ${finding}\nREVIEW_VERDICT: approve`;
},
});
expect(result.allLanded).toBe(true);
expect(reviewPrompts).toHaveLength(2);
expect(reviewPrompts[1]).toContain(finding);
});
it("per-repo resolution: each repo lands on its OWN origin/HEAD branch (override-stripping)", async () => { it("per-repo resolution: each repo lands on its OWN origin/HEAD branch (override-stripping)", async () => {
fx = await createWorkspaceFixture(["repo-a", "repo-b"]); fx = await createWorkspaceFixture(["repo-a", "repo-b"]);
// Give each repo a different default integration branch via a bare origin whose // Give each repo a different default integration branch via a bare origin whose

View File

@@ -20,11 +20,15 @@ export interface AiMergeReviewVerdict {
verdict: "approve" | "reject"; verdict: "approve" | "reject";
reasons: string[]; reasons: string[];
severity?: AiMergeReviewSeverity; severity?: AiMergeReviewSeverity;
/** Prior findings the reviewer explicitly confirmed are fixed in this squash. */
resolvedPriorReasons: string[];
} }
export const REVIEW_VERDICT_MARKER = "REVIEW_VERDICT:"; export const REVIEW_VERDICT_MARKER = "REVIEW_VERDICT:";
export const RESOLVED_PRIOR_FINDINGS_MARKER = "RESOLVED_PRIOR_FINDINGS:";
const VERDICT_LINE_RE = /REVIEW_VERDICT:\s*(approve|reject)\b/i; const VERDICT_LINE_RE = /REVIEW_VERDICT:\s*(approve|reject)\b/i;
const SEVERITY_LINE_RE = /SEVERITY:\s*(blocking|advisory)\b/i; const SEVERITY_LINE_RE = /SEVERITY:\s*(blocking|advisory)\b/i;
const RESOLVED_PRIOR_FINDINGS_LINE_RE = /RESOLVED_PRIOR_FINDINGS:\s*(.*)$/i;
/* /*
FNXC:MergerAiReview 2026-07-15-21:30: FNXC:MergerAiReview 2026-07-15-21:30:
@@ -49,6 +53,7 @@ export function parseReviewVerdict(
verdict: "reject", verdict: "reject",
reasons: ["reviewer produced no output"], reasons: ["reviewer produced no output"],
severity: "blocking", severity: "blocking",
resolvedPriorReasons: [],
}; };
const lines = text.split(/\r?\n/); const lines = text.split(/\r?\n/);
@@ -69,31 +74,62 @@ export function parseReviewVerdict(
`reviewer did not emit a "${REVIEW_VERDICT_MARKER} approve|reject" line`, `reviewer did not emit a "${REVIEW_VERDICT_MARKER} approve|reject" line`,
], ],
severity: "blocking", severity: "blocking",
resolvedPriorReasons: [],
}; };
} }
if (decision === "approve") return { verdict: "approve", reasons: [] }; const resolvedPriorReasons = extractResolvedPriorReasons(lines);
if (decision === "approve") return { verdict: "approve", reasons: [], resolvedPriorReasons };
const severity: AiMergeReviewSeverity = SEVERITY_LINE_RE.test(text) const severity: AiMergeReviewSeverity = SEVERITY_LINE_RE.test(text)
? (text.match(SEVERITY_LINE_RE)![1].toLowerCase() as AiMergeReviewSeverity) ? (text.match(SEVERITY_LINE_RE)![1].toLowerCase() as AiMergeReviewSeverity)
: "blocking"; : "blocking";
const resolvedKeys = new Set(resolvedPriorReasons.map(normalizeReasonIdentity));
return { return {
verdict: "reject", verdict: "reject",
reasons: extractRejectReasons(lines, verdictLineIndex), // Marker bullets are acknowledgements; reconciliation separately promotes
// unknown acknowledgements to new findings instead of dropping them.
reasons: extractRejectReasons(lines, verdictLineIndex)
.filter((reason) => !resolvedKeys.has(normalizeReasonIdentity(reason))),
severity, severity,
resolvedPriorReasons,
}; };
} }
/**
* Extract only a clearly delimited, bullet-form resolution acknowledgement.
* A missing or malformed marker intentionally yields no resolutions: callers
* retain every prior blocker rather than guessing that reviewer prose cleared it.
*/
function extractResolvedPriorReasons(lines: string[]): string[] {
const markerIndex = lines.findIndex((line) => RESOLVED_PRIOR_FINDINGS_LINE_RE.test(line));
if (markerIndex === -1) return [];
const inline = lines[markerIndex].match(RESOLVED_PRIOR_FINDINGS_LINE_RE)?.[1].trim();
const resolved = inline && !/^none\b/i.test(inline) ? [inline] : [];
for (let index = markerIndex + 1; index < lines.length; index++) {
const line = lines[index];
if (VERDICT_LINE_RE.test(line) || SEVERITY_LINE_RE.test(line) || !line.trim()) break;
if (!/^\s*(?:[-*•]|\d+[.)])\s+/.test(line)) return [];
resolved.push(cleanReasonLine(line));
}
return resolved;
}
/** Strip bullet/numeric list markers and surrounding whitespace from one line. */ /** Strip bullet/numeric list markers and surrounding whitespace from one line. */
function cleanReasonLine(line: string): string { function cleanReasonLine(line: string): string {
return line.replace(/^\s*(?:[-*•]|\d+[.)])\s+/, "").trim(); return line.replace(/^\s*(?:[-*•]|\d+[.)])\s+/, "").trim();
} }
function normalizeReasonIdentity(reason: string): string {
return reason.trim().toLocaleLowerCase().replace(/[\s\p{P}]+/gu, " ").trim();
}
/** Lines that carry no reviewer reasoning and must never be reported as a reason. */ /** Lines that carry no reviewer reasoning and must never be reported as a reason. */
function isNonReasonLine(line: string): boolean { function isNonReasonLine(line: string): boolean {
const t = line.trim(); const t = line.trim();
if (!t) return true; if (!t) return true;
if (SEVERITY_LINE_RE.test(t)) return true; if (SEVERITY_LINE_RE.test(t)) return true;
if (VERDICT_LINE_RE.test(t)) return true; if (VERDICT_LINE_RE.test(t)) return true;
if (RESOLVED_PRIOR_FINDINGS_LINE_RE.test(t)) return true;
// Markdown scaffolding the reviewer may emit around its analysis. // Markdown scaffolding the reviewer may emit around its analysis.
if (/^#{1,6}\s/.test(t)) return true; if (/^#{1,6}\s/.test(t)) return true;
if (/^(?:-{3,}|={3,}|`{3,})/.test(t)) return true; if (/^(?:-{3,}|={3,}|`{3,})/.test(t)) return true;
@@ -277,19 +313,24 @@ export function buildReviewSystemPrompt(): string {
"merged into the integration branch and decide whether it is safe to land.", "merged into the integration branch and decide whether it is safe to land.",
"", "",
"Investigate with read-only commands (git show, git diff, git log, cat, grep).", "Investigate with read-only commands (git show, git diff, git log, cat, grep).",
"Judge on four axes:", "Judge the squash's fidelity on four axes:",
" 1. Completeness — does the squash contain ALL of the task branch's intended", " 1. Branch preservation — does the squash preserve the task branch's",
" changes? Flag any hunk silently dropped during conflict resolution.", " changes? Flag any hunk silently dropped during conflict resolution.",
" 2. No collateral — does it touch only files within the task's footprint?", " 2. No collateral — does the squash add unrelated changes outside its",
" 3. Conflict soundness — were conflicts resolved coherently (both sides'", " branch/squash footprint?",
" intent preserved), not by blindly discarding one side?", " 3. Conflict soundness — were conflicts resolved coherently (both relevant",
" branch intents retained), not by blindly discarding one side?",
" 4. Commit message — read `git show`'s message: the subject must concisely", " 4. Commit message — read `git show`'s message: the subject must concisely",
" and ACCURATELY summarize the actual changes (not vague, not a mere", " and ACCURATELY summarize the actual changes (not vague, not a mere",
" restatement of the task title, not misleading). A poor/inaccurate", " restatement of the task title, not misleading). A poor/inaccurate",
" message is an ADVISORY concern (it should be rewritten on retry, but", " message is an ADVISORY concern (it should be rewritten on retry, but",
" must not block the merge).", " must not block the merge).",
"", "",
"Bias toward rejection when uncertain.", "Whole-tree semantic or intent concerns in files untouched by this squash are",
"ADVISORY or follow-up material, never a blocking veto: the merger is not",
"authorized to edit outside branch reconciliation.",
"",
"Bias toward rejection when uncertain about squash fidelity.",
"", "",
/* /*
FNXC:MergerAiReview 2026-07-15-21:30: FNXC:MergerAiReview 2026-07-15-21:30:
@@ -306,9 +347,9 @@ export function buildReviewSystemPrompt(): string {
"vague reason produces a blind retry rather than a fix.", "vague reason produces a blind retry rather than a fix.",
"", "",
`Then add a "SEVERITY:" line:`, `Then add a "SEVERITY:" line:`,
" - SEVERITY: blocking — a correctness problem (dropped/lost task changes,", " - SEVERITY: blocking — a squash-fidelity defect: dropped/lost branch",
" incomplete squash, or a conflict resolution that discards intent). The", " changes, unrelated collateral in the squash, or a conflict resolution",
" merge must NOT land if this is unfixable.", " that discards relevant branch intent. The merge must NOT land if unfixable.",
" - SEVERITY: advisory — a quality/style concern that does not risk", " - SEVERITY: advisory — a quality/style concern that does not risk",
" correctness; acceptable to land if unresolved.", " correctness; acceptable to land if unresolved.",
"", "",
@@ -349,9 +390,14 @@ export function buildReviewPrompt(input: {
if (input.priorReasons && input.priorReasons.length > 0) { if (input.priorReasons && input.priorReasons.length > 0) {
lines.push( lines.push(
"", "",
"A prior pass rejected an earlier attempt for these reasons — confirm they", "A prior pass rejected an earlier squash for these findings. Inspect this",
"are now resolved in the complete resulting tree, including code outside", "squash's fidelity, then explicitly identify only findings it resolves:",
"this squash diff. Do not approve merely because the rebuilt diff became smaller:", ` ${RESOLVED_PRIOR_FINDINGS_MARKER}`,
" - <repeat the resolved prior finding>",
"If none are resolved, write `RESOLVED_PRIOR_FINDINGS: none`. Omit this",
"marker only when you cannot determine resolution; omitted or malformed",
"evidence retains every prior blocker.",
"Do not use whole-tree concerns outside this squash's footprint as blockers:",
...input.priorReasons.map((r) => ` - ${r}`) ...input.priorReasons.map((r) => ` - ${r}`)
); );
} }

View File

@@ -206,27 +206,41 @@ function taskHasApprovedAiMergeReview(task: Task | undefined): boolean {
); );
} }
function getOutstandingBlockingMergeReasons(task: Task | undefined): string[] { const MAX_BLOCKING_REVIEW_REASONS = 8;
const actions = task?.log?.map((entry) => entry.action).filter((action): action is string => typeof action === "string") ?? []; const MAX_BLOCKING_REVIEW_REASON_AGE_MS = 7 * 24 * 60 * 60 * 1000;
const reasons: string[] = [];
const addReasons = (value: string): void => { function normalizeBlockingReviewReason(reason: string): string {
for (const reason of value.split(/;\s*/).map((part) => part.trim()).filter(Boolean)) { return reason.trim().toLocaleLowerCase().replace(/[\s\p{P}]+/gu, " ").trim();
if (!reasons.includes(reason)) reasons.push(reason); }
}
}; function boundBlockingReviewReasons(reasons: readonly string[]): string[] {
for (let index = actions.length - 1; index >= 0; index--) { const seen = new Set<string>();
const action = actions[index]; const bounded: string[] = [];
if (/AI merge: (?:landed|finalized).*task → done/i.test(action)) return []; for (const reason of reasons) {
if (/AI merge review \(pass \d+\): approved/i.test(action)) return []; const display = reason.trim().replace(/\s+/g, " ");
const blocked = action.match(/AI merge BLOCKED .*?unresolved correctness concern:\s*(.+)$/i); const key = normalizeBlockingReviewReason(display);
if (blocked?.[1]) { if (!key || seen.has(key)) continue;
addReasons(blocked[1]); seen.add(key);
return reasons; bounded.push(display);
} if (bounded.length === MAX_BLOCKING_REVIEW_REASONS) break;
const rejected = action.match(/AI merge review \(pass \d+\): rejected \(blocking\) —\s*(.+)$/i);
if (rejected?.[1]) addReasons(rejected[1]);
} }
return reasons; return bounded;
}
function getOutstandingBlockingMergeReasons(task: Task | undefined): string[] {
const entries = task?.log ?? [];
for (let index = entries.length - 1; index >= 0; index--) {
const entry = entries[index];
const action = entry.action;
if (/AI merge: (?:landed|finalized).*task → done/i.test(action) || /AI merge review \(pass \d+\): approved/i.test(action)) return [];
const current = action.match(/AI merge (?:BLOCKED .*?unresolved correctness concern:\s*|review \(pass \d+\): rejected \(blocking\) —\s*)(.+)$/i);
if (!current?.[1]) continue;
const ageMs = Date.now() - Date.parse(entry.timestamp);
// Invalid legacy timestamps remain recoverable; only provably stale contracts expire.
if (Number.isFinite(ageMs) && ageMs > MAX_BLOCKING_REVIEW_REASON_AGE_MS) return [];
return boundBlockingReviewReasons(current[1].split(/;\s*/));
}
return [];
} }
function matchesApprovedAiMergeSha(squashSha: string, approvedShas: Set<string>): boolean { function matchesApprovedAiMergeSha(squashSha: string, approvedShas: Set<string>): boolean {
@@ -418,6 +432,7 @@ async function ensureCommitTaskMetadata(
export { export {
REVIEW_VERDICT_MARKER, REVIEW_VERDICT_MARKER,
RESOLVED_PRIOR_FINDINGS_MARKER,
buildMergePrompt, buildMergePrompt,
buildMergeSystemPrompt, buildMergeSystemPrompt,
buildReviewPrompt, buildReviewPrompt,
@@ -2618,7 +2633,10 @@ async function mergeAndReview(input: {
initialPriorReasons?: string[]; initialPriorReasons?: string[];
}): Promise<{ squashSha: string | null; priorReasons: string[] }> { }): Promise<{ squashSha: string | null; priorReasons: string[] }> {
const { mergeRoot, branch, integrationBranch, tipSha, taskTitle, includeTaskId, trailers, taskId, maxPasses, mergeAgent, reviewAgent, audit, log, setStatus, store, signal } = input; const { mergeRoot, branch, integrationBranch, tipSha, taskTitle, includeTaskId, trailers, taskId, maxPasses, mergeAgent, reviewAgent, audit, log, setStatus, store, signal } = input;
let priorReasons = [...(input.initialPriorReasons ?? [])]; let priorReasons = boundBlockingReviewReasons(input.initialPriorReasons ?? []);
// Only findings introduced by the immediately preceding review are actionable
// merger instructions; older findings remain reviewer-verification evidence.
let correctiveReasons: string[] = [];
for (let attempt = 0; ; attempt++) { for (let attempt = 0; ; attempt++) {
throwIfAborted(signal, taskId); throwIfAborted(signal, taskId);
@@ -2629,13 +2647,13 @@ async function mergeAndReview(input: {
if (attempt > 0) { if (attempt > 0) {
await setStatus("merging"); await setStatus("merging");
await log(`AI merge: corrective re-merge (pass ${attempt}/${maxPasses}) addressing: ${priorReasons.join("; ")}`); await log(`AI merge: corrective re-merge (pass ${attempt}/${maxPasses}) addressing new findings: ${correctiveReasons.join("; ") || "reviewer reconciliation"}`);
} }
const latestTaskForMergePrompt = await store.getTask(taskId); const latestTaskForMergePrompt = await store.getTask(taskId);
const mergeUserComments = selectUserCommentsForAgentContext(latestTaskForMergePrompt); const mergeUserComments = selectUserCommentsForAgentContext(latestTaskForMergePrompt);
await mergeAgent(mergeRoot, buildMergePrompt({ await mergeAgent(mergeRoot, buildMergePrompt({
taskId, branch, integrationBranch, tipSha, taskTitle, includeTaskId, trailers, taskId, branch, integrationBranch, tipSha, taskTitle, includeTaskId, trailers,
correctiveReasons: priorReasons.length ? priorReasons : undefined, correctiveReasons: correctiveReasons.length ? correctiveReasons : undefined,
userComments: mergeUserComments, userComments: mergeUserComments,
})); }));
@@ -2665,34 +2683,59 @@ async function mergeAndReview(input: {
metadata: { taskId, attempt, verdict: verdict.verdict, severity: verdict.severity, reasons: verdict.reasons, squashSha: head }, metadata: { taskId, attempt, verdict: verdict.verdict, severity: verdict.severity, reasons: verdict.reasons, squashSha: head },
}); });
if (verdict.verdict === "approve") { /*
FNXC:MergeReviewReconciliation 2026-08-20-02:02:
Review findings are a current squash-fidelity contract, not a permanent transcript.
A reviewer must explicitly acknowledge every supplied blocking finding before approval;
unknown acknowledgements are new findings, never silently discarded. Advisory findings
remain landable and are never carried into the next approval gate.
*/
const priorKeys = new Set(priorReasons.map(normalizeBlockingReviewReason));
const acknowledgedKeys = new Set(
verdict.resolvedPriorReasons
.map(normalizeBlockingReviewReason)
.filter((key) => priorKeys.has(key)),
);
const unknownAcknowledgements = verdict.resolvedPriorReasons.filter(
(reason) => !priorKeys.has(normalizeBlockingReviewReason(reason)),
);
const retainedPriorReasons = priorReasons.filter(
(reason) => !acknowledgedKeys.has(normalizeBlockingReviewReason(reason)),
);
const acknowledgedReasonKeys = new Set(
verdict.resolvedPriorReasons
.filter((reason) => acknowledgedKeys.has(normalizeBlockingReviewReason(reason)))
.map(normalizeBlockingReviewReason),
);
const reportedNewReasons = verdict.reasons.filter(
(reason) => !acknowledgedReasonKeys.has(normalizeBlockingReviewReason(reason)),
);
const newReasons = verdict.verdict === "approve" || verdict.severity === "blocking"
? [...reportedNewReasons, ...unknownAcknowledgements]
: [];
const unresolvedReasons = boundBlockingReviewReasons([...retainedPriorReasons, ...newReasons]);
const budgetExhausted = attempt >= maxPasses;
if (verdict.verdict === "approve" && unresolvedReasons.length === 0) {
await log(`AI merge review (pass ${attempt + 1}): approved squash ${head}`); await log(`AI merge review (pass ${attempt + 1}): approved squash ${head}`);
return { squashSha: emptyMerge ? null : head, priorReasons }; return { squashSha: emptyMerge ? null : head, priorReasons };
} }
/* if (budgetExhausted && (verdict.severity === "blocking" || unresolvedReasons.length > 0)) {
FNXC:MergeReviewBlockers 2026-07-21-21:30: await audit.git({ type: "merge:ai-review-blocked", target: integrationBranch, metadata: { taskId, attempt, reasons: unresolvedReasons } });
Every rejected blocker remains part of the corrective contract until a reviewer approves the complete result. Review an empty corrective rebuild instead of treating it as an unreviewed no-op, and accumulate newly discovered blockers so a later pass cannot regress an earlier concern. await log(`AI merge BLOCKED after ${attempt} corrective pass(es) — unresolved correctness concern: ${unresolvedReasons.join("; ")}`);
throw new AiMergeBlockedError(taskId, unresolvedReasons);
FNXC:MergeReviewBlockers 2026-07-21-21:45: }
Persist the accumulated set in every rejection log so crash recovery restores all outstanding concerns rather than only the latest pass.
*/
const unresolvedReasons = [...new Set([...priorReasons, ...verdict.reasons])];
const budgetExhausted = attempt >= maxPasses;
if (budgetExhausted) { if (budgetExhausted) {
if (verdict.severity === "blocking") { // Advisory: land the squash with the concern logged, without poisoning the next pass.
await audit.git({ type: "merge:ai-review-blocked", target: integrationBranch, metadata: { taskId, attempt, reasons: unresolvedReasons } }); await audit.git({ type: "merge:ai-review-landed-with-concerns", target: integrationBranch, metadata: { taskId, attempt, reasons: reportedNewReasons, squashSha: head } });
await log(`AI merge BLOCKED after ${attempt} corrective pass(es) — unresolved correctness concern: ${unresolvedReasons.join("; ")}`); await log(`AI merge: landing with unresolved advisory concern(s): ${reportedNewReasons.join("; ")}`);
throw new AiMergeBlockedError(taskId, unresolvedReasons); return { squashSha: emptyMerge ? null : head, priorReasons: [] };
}
// Advisory: land the squash with the concern logged.
await audit.git({ type: "merge:ai-review-landed-with-concerns", target: integrationBranch, metadata: { taskId, attempt, reasons: unresolvedReasons, squashSha: head } });
await log(`AI merge: landing with unresolved advisory concern(s): ${unresolvedReasons.join("; ")}`);
return { squashSha: emptyMerge ? null : head, priorReasons: unresolvedReasons };
} }
priorReasons = unresolvedReasons; priorReasons = unresolvedReasons;
await log(`AI merge review (pass ${attempt + 1}): rejected (${verdict.severity}) — ${unresolvedReasons.join("; ")}`); correctiveReasons = boundBlockingReviewReasons(newReasons);
await log(`AI merge review (pass ${attempt + 1}): rejected (${verdict.severity ?? "blocking"}) — ${unresolvedReasons.join("; ")}`);
} }
} }

View File

@@ -84,6 +84,7 @@ import type { RoutineRunner } from "./scheduling/routine-runner.js";
import { sweepStaleAutostashes, VerificationError } from "./merger.js"; import { sweepStaleAutostashes, VerificationError } from "./merger.js";
import { import {
runAiMerge, runAiMerge,
AiMergeBlockedError,
landWorkspaceTask, landWorkspaceTask,
WorkspaceFinalizeBlockedError, WorkspaceFinalizeBlockedError,
WorkspaceMergeDispatchSupersededError, WorkspaceMergeDispatchSupersededError,
@@ -5141,6 +5142,32 @@ export class ProjectEngine {
} }
if (mergeStrategyOnErr === "direct") { if (mergeStrategyOnErr === "direct") {
/*
FNXC:AIMergeReviewRecovery 2026-08-20-02:02:
An exhausted AI review is terminal operator work, not a git conflict.
Its message includes reviewer prose, so classify the typed error before
any text sniffing can turn a natural-language "conflicts" finding into
a retry/bounce/cooldown livelock.
*/
if (err instanceof AiMergeBlockedError) {
try {
await store.updateTask(taskId, {
status: "failed",
mergeRetries: maxAutoMergeRetriesOnErr,
error: "AI merge review blocked landing; operator intervention is required.",
});
await store.logEntry(
taskId,
`AI merge review exhausted its corrective budget; task parked for operator intervention (${Math.min(err.reasons.length, 8)} current blocking finding(s))`,
"AiMergeReviewBlocked",
);
} catch (recoveryErr) {
runtimeLog.error(
`Auto-merge: failed to park ${taskId} after AI review block: ${recoveryErr instanceof Error ? recoveryErr.message : String(recoveryErr)}`,
);
}
continue;
}
const isConflictError = const isConflictError =
errorMsg.includes("conflict") || errorMsg.includes("Conflict"); errorMsg.includes("conflict") || errorMsg.includes("Conflict");