From 8072554650a0212572d279bffa0d7e3d4c16ce25 Mon Sep 17 00:00:00 2001 From: "Fusion (runfusion.ai)" Date: Mon, 18 May 2026 00:47:32 -0700 Subject: [PATCH] =?UTF-8?q?feat(FN-4956):=20complete=20Step=202-3=20?= =?UTF-8?q?=E2=80=94=20scope-partition=20layer3=20conflicts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fusion-Task-Id: FN-4956 Fusion-Task-Lineage: 7105cd5e-27f4-4e84-b2ae-e811a183803f --- .../merger-layer3-scope-partition.test.ts | 76 ++++++- packages/engine/src/merger.ts | 207 +++++++++++++++--- 2 files changed, 250 insertions(+), 33 deletions(-) diff --git a/packages/engine/src/__tests__/merger-layer3-scope-partition.test.ts b/packages/engine/src/__tests__/merger-layer3-scope-partition.test.ts index 3d82e52c6..8963e02c9 100644 --- a/packages/engine/src/__tests__/merger-layer3-scope-partition.test.ts +++ b/packages/engine/src/__tests__/merger-layer3-scope-partition.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; -import { partitionConflictsByFileScope } from "../merger.js"; +import { createMockStore, mockedExecSync } from "./merger-test-helpers.js"; +import { applyLayer3ConflictScopePartition, partitionConflictsByFileScope } from "../merger.js"; describe("partitionConflictsByFileScope", () => { it("treats empty declared scope as no enforcement", () => { @@ -75,3 +76,74 @@ describe("partitionConflictsByFileScope", () => { }); }); }); + +describe("applyLayer3ConflictScopePartition", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("resolves out-of-scope conflicts to ours and emits skip audit", async () => { + const store = createMockStore() as any; + store.parseFileScopeFromPrompt = vi.fn().mockResolvedValue(["packages/desktop/src/**"]); + const task = await store.getTask("FN-050"); + const gitCalls: string[] = []; + + mockedExecSync.mockImplementation((cmd: any) => { + const c = String(cmd); + gitCalls.push(c); + if (c.includes("git diff --cached --name-only")) { + return "packages/desktop/src/foo.ts\n"; + } + return ""; + }); + + const audit = { git: vi.fn().mockResolvedValue(undefined) } as any; + const result = await applyLayer3ConflictScopePartition({ + store, + task, + taskId: "FN-050", + rootDir: "/tmp/root", + branch: "fusion/fn-050", + conflictFiles: ["packages/desktop/src/foo.ts", "AGENTS.md"], + auditor: audit, + }); + + expect(result.inScopeConflicts).toEqual(["packages/desktop/src/foo.ts"]); + expect(result.skippedFiles).toEqual(["AGENTS.md"]); + expect(gitCalls.some((call) => call.includes("git checkout --ours -- AGENTS.md"))).toBe(true); + expect(audit.git).toHaveBeenCalledWith(expect.objectContaining({ + type: "merge:layer3:foreign-file-skipped", + target: "fusion/fn-050", + metadata: expect.objectContaining({ + taskId: "FN-050", + skippedFiles: ["AGENTS.md"], + inScopeCount: 1, + viaScopeOverride: false, + }), + })); + }); + + it("emits scope override bypass audit and skips partition", async () => { + const store = createMockStore({ scopeOverride: true, scopeOverrideReason: "hotfix" }) as any; + store.parseFileScopeFromPrompt = vi.fn().mockResolvedValue(["packages/desktop/src/**"]); + const task = await store.getTask("FN-050"); + const audit = { git: vi.fn().mockResolvedValue(undefined) } as any; + + const result = await applyLayer3ConflictScopePartition({ + store, + task, + taskId: "FN-050", + rootDir: "/tmp/root", + branch: "fusion/fn-050", + conflictFiles: ["packages/desktop/src/foo.ts", "AGENTS.md"], + auditor: audit, + }); + + expect(result.inScopeConflicts).toEqual(["packages/desktop/src/foo.ts", "AGENTS.md"]); + expect(result.skippedFiles).toEqual([]); + expect(audit.git).toHaveBeenCalledWith(expect.objectContaining({ + type: "merge:layer3:scope-override-bypass", + metadata: expect.objectContaining({ viaScopeOverride: true }), + })); + }); +}); diff --git a/packages/engine/src/merger.ts b/packages/engine/src/merger.ts index 0524fa2ce..beae36481 100644 --- a/packages/engine/src/merger.ts +++ b/packages/engine/src/merger.ts @@ -2565,43 +2565,65 @@ async function tryRecoverHardFailApply(params: { // 3-way produced conflict markers → existing AI conflict resolver handles it. if (threeWayConflicted.length > 0) { + const task = await ctx.store.getTask(taskId); + const partitioned = await applyLayer3ConflictScopePartition({ + store: ctx.store, + task, + taskId, + rootDir, + branch: task.branch || `fusion/${taskId.toLowerCase()}`, + conflictFiles: threeWayConflicted, + auditor: ctx.options.auditor, + }); + const aiConflictedFiles = partitioned.inScopeConflicts; + if (!smartConflictResolution) { - const message = `Autostash 3-way produced conflict markers in ${threeWayConflicted.length} file(s) and smartConflictResolution is disabled. Stash ${sha.slice(0, 7)} left intact.`; + const message = `Autostash 3-way produced conflict markers in ${aiConflictedFiles.length} file(s) and smartConflictResolution is disabled. Stash ${sha.slice(0, 7)} left intact.`; await ctx.store.logEntry( taskId, `Autostash 3-way left conflict markers — manual resolution required (smart resolution disabled)`, message, ).catch(() => undefined); - return { status: "conflict-needs-manual", stashSha: sha, conflictedFiles: threeWayConflicted, message }; + return { status: "conflict-needs-manual", stashSha: sha, conflictedFiles: aiConflictedFiles, message }; + } + + if (aiConflictedFiles.length === 0) { + const dropResult = await dropAutostashBySha(rootDir, taskId, sha); + await ctx.store.logEntry( + taskId, + "Autostash hard-fail recovered via 3-way scope partition (no in-scope conflicts remained)", + `${dropResult.dropped ? "" : `Stash drop failed (${dropResult.reason ?? "unknown"}); clean up manually.`}`, + ).catch(() => undefined); + return { status: "ai-resolved", stashSha: sha, conflictedFiles: [] }; } await ctx.store.logEntry( taskId, - `Autostash 3-way left conflicts in ${threeWayConflicted.length} file(s) — invoking AI to resolve`, - threeWayConflicted.join("\n"), + `Autostash 3-way left conflicts in ${aiConflictedFiles.length} file(s) — invoking AI to resolve`, + aiConflictedFiles.join("\n"), ).catch(() => undefined); const aiResult = await runAiAgentForAutostashConflict({ store: ctx.store, rootDir, taskId, - conflictedFiles: threeWayConflicted, + conflictedFiles: aiConflictedFiles, options: ctx.options, settings: ctx.settings, }); const stillConflicted = aiResult.success - ? await findFilesWithConflictMarkers(rootDir, threeWayConflicted) - : threeWayConflicted; + ? await findFilesWithConflictMarkers(rootDir, aiConflictedFiles) + : aiConflictedFiles; if (aiResult.success && stillConflicted.length === 0) { const dropResult = await dropAutostashBySha(rootDir, taskId, sha); await ctx.store.logEntry( taskId, - `Autostash hard-fail recovered via 3-way + AI conflict resolution (${threeWayConflicted.length} file(s))`, - `Resolved files:\n${threeWayConflicted.join("\n")}${dropResult.dropped ? "" : `\n\nStash drop failed (${dropResult.reason ?? "unknown"}); clean up manually.`}`, + `Autostash hard-fail recovered via 3-way + AI conflict resolution (${aiConflictedFiles.length} file(s))`, + `Resolved files:\n${aiConflictedFiles.join("\n")}${dropResult.dropped ? "" : `\n\nStash drop failed (${dropResult.reason ?? "unknown"}); clean up manually.`}`, ).catch(() => undefined); - return { status: "ai-resolved", stashSha: sha, conflictedFiles: threeWayConflicted }; + return { status: "ai-resolved", stashSha: sha, conflictedFiles: aiConflictedFiles }; } const failureMsg = `3-way+AI resolution incomplete; markers remain in ${stillConflicted.join(", ") || "(unknown)"}. Stash ${sha.slice(0, 7)} left intact.`; @@ -2972,38 +2994,62 @@ async function restoreUnrelatedRootDirChanges( // Conflict path: try AI resolution if enabled. const conflictedFiles = await getConflictedFiles(rootDir); + const task = await ctx.store.getTask(taskId); + const partitioned = await applyLayer3ConflictScopePartition({ + store: ctx.store, + task, + taskId, + rootDir, + branch: task.branch || `fusion/${taskId.toLowerCase()}`, + conflictFiles: conflictedFiles, + auditor: ctx.options.auditor, + }); + const aiConflictedFiles = partitioned.inScopeConflicts; + const smartConflictResolution = (ctx.settings.smartConflictResolution ?? ctx.settings.autoResolveConflicts) !== false; if (!smartConflictResolution) { - const message = `Autostash apply conflicted in ${conflictedFiles.length} file(s) and smartConflictResolution is disabled. Stash ${sha.slice(0, 7)} left intact; resolve manually with: cd ${rootDir} && # edit files, then git stash drop `; + const message = `Autostash apply conflicted in ${aiConflictedFiles.length} file(s) and smartConflictResolution is disabled. Stash ${sha.slice(0, 7)} left intact; resolve manually with: cd ${rootDir} && # edit files, then git stash drop `; mergerLog.warn(`${taskId}: ${message}`); await ctx.store .logEntry( taskId, - `Autostash apply conflicted in ${conflictedFiles.length} file(s) — manual resolution required (smart resolution disabled)`, + `Autostash apply conflicted in ${aiConflictedFiles.length} file(s) — manual resolution required (smart resolution disabled)`, message, ) .catch(() => undefined); return { status: "conflict-needs-manual", stashSha: sha, - conflictedFiles, + conflictedFiles: aiConflictedFiles, message, }; } + if (aiConflictedFiles.length === 0) { + const aiDropResult = await dropAutostashBySha(rootDir, taskId, sha); + if (aiDropResult.dropped) { + await ctx.store.logEntry(taskId, "Autostash conflict resolved by Layer 3 scope partition (no in-scope conflicts remained)"); + } + return { + status: "ai-resolved", + stashSha: sha, + conflictedFiles: [], + }; + } + await ctx.store.logEntry( taskId, - `Autostash apply conflicted in ${conflictedFiles.length} file(s) — invoking AI to resolve`, - conflictedFiles.join("\n"), + `Autostash apply conflicted in ${aiConflictedFiles.length} file(s) — invoking AI to resolve`, + aiConflictedFiles.join("\n"), ); const aiResult = await runAiAgentForAutostashConflict({ store: ctx.store, rootDir, taskId, - conflictedFiles, + conflictedFiles: aiConflictedFiles, options: ctx.options, settings: ctx.settings, }); @@ -3017,13 +3063,13 @@ async function restoreUnrelatedRootDirChanges( return { status: "conflict-needs-manual", stashSha: sha, - conflictedFiles, + conflictedFiles: aiConflictedFiles, message, }; } // Verify the agent actually removed all conflict markers. - const stillConflicted = await findFilesWithConflictMarkers(rootDir, conflictedFiles); + const stillConflicted = await findFilesWithConflictMarkers(rootDir, aiConflictedFiles); if (stillConflicted.length > 0) { const message = `AI agent reported success but conflict markers remain in: ${stillConflicted.join(", ")}. Stash ${sha.slice(0, 7)} left intact; recover manually.`; mergerLog.warn(`${taskId}: ${message}`); @@ -3041,27 +3087,27 @@ async function restoreUnrelatedRootDirChanges( // Success — AI resolved the conflict. Drop the stash since its content // has been applied (with conflict resolution edits on top). mergerLog.log( - `${taskId}: AI-resolved autostash conflict in ${conflictedFiles.length} file(s); dropping stash ${sha.slice(0, 7)}`, + `${taskId}: AI-resolved autostash conflict in ${aiConflictedFiles.length} file(s); dropping stash ${sha.slice(0, 7)}`, ); const aiDropResult = await dropAutostashBySha(rootDir, taskId, sha); if (aiDropResult.dropped) { await ctx.store.logEntry( taskId, - `Autostash conflict resolved by AI in ${conflictedFiles.length} file(s)`, - conflictedFiles.join("\n"), + `Autostash conflict resolved by AI in ${aiConflictedFiles.length} file(s)`, + aiConflictedFiles.join("\n"), ); } else { await ctx.store.logEntry( taskId, - `Autostash conflict resolved by AI in ${conflictedFiles.length} file(s), but stash entry failed to drop`, - `Resolved files:\n${conflictedFiles.join("\n")}\n\nDrop failure: ${aiDropResult.reason ?? "unknown"}\n\nClean up manually with:\n cd ${rootDir} && git stash list | grep ${sha.slice(0, 7)} && git stash drop `, + `Autostash conflict resolved by AI in ${aiConflictedFiles.length} file(s), but stash entry failed to drop`, + `Resolved files:\n${aiConflictedFiles.join("\n")}\n\nDrop failure: ${aiDropResult.reason ?? "unknown"}\n\nClean up manually with:\n cd ${rootDir} && git stash list | grep ${sha.slice(0, 7)} && git stash drop `, ); } return { status: "ai-resolved", stashSha: sha, - conflictedFiles, + conflictedFiles: aiConflictedFiles, }; } @@ -3948,6 +3994,84 @@ export function partitionConflictsByFileScope(params: { return { inScope, outOfScope }; } +export async function applyLayer3ConflictScopePartition(params: { + store: TaskStore; + task: Task; + taskId: string; + rootDir: string; + branch: string; + conflictFiles: string[]; + auditor?: RunAuditor; +}): Promise<{ inScopeConflicts: string[]; skippedFiles: string[]; declaredScope: string[]; viaScopeOverride: boolean }> { + const { store, task, taskId, rootDir, branch, conflictFiles, auditor } = params; + if (conflictFiles.length === 0 || typeof (store as Partial).parseFileScopeFromPrompt !== "function") { + return { inScopeConflicts: conflictFiles, skippedFiles: [], declaredScope: [], viaScopeOverride: false }; + } + + const declaredScope = await store.parseFileScopeFromPrompt(taskId); + if (task.scopeOverride === true) { + const reasonSuffix = task.scopeOverrideReason?.trim() ? ` — reason: ${task.scopeOverrideReason.trim()}` : ""; + await store.appendAgentLog(taskId, `Layer 3 arbiter scope partition bypassed via scopeOverride${reasonSuffix}`, "text", undefined, "merger"); + if (auditor) { + await auditor.git({ + type: "merge:layer3:scope-override-bypass", + target: branch, + metadata: { + taskId, + skippedFiles: [], + declaredScope, + inScopeCount: conflictFiles.length, + viaScopeOverride: true, + }, + }).catch((error: unknown) => { + mergerLog.warn(`${taskId}: failed to emit merge:layer3:scope-override-bypass run_audit event: ${error instanceof Error ? error.message : String(error)}`); + }); + } + return { inScopeConflicts: conflictFiles, skippedFiles: [], declaredScope, viaScopeOverride: true }; + } + + if (declaredScope.length === 0) { + return { inScopeConflicts: conflictFiles, skippedFiles: [], declaredScope, viaScopeOverride: false }; + } + + const { inScope, outOfScope } = partitionConflictsByFileScope({ conflictFiles, declaredScope }); + for (const file of outOfScope) { + // In merge and rebase conflict contexts, `--ours` resolves to the + // integration-target side (main bytes), which we keep for out-of-scope files. + await resolveWithOurs(file, rootDir); + } + + const { stdout: stagedOut } = await execAsync("git diff --cached --name-only", { cwd: rootDir, encoding: "utf-8" }); + const stagedAfterPartition = stagedOut.split("\n").map((line) => line.trim()).filter(Boolean); + const outOfScopeStillStaged = stagedAfterPartition.filter((file) => outOfScope.includes(file)); + if (outOfScopeStillStaged.length > 0) { + throw new Error(`Layer 3 scope partition failed for ${taskId}: out-of-scope files still staged after prefer-main resolution: ${outOfScopeStillStaged.join(", ")}`); + } + + if (outOfScope.length > 0) { + const summary = `Layer 3 arbiter: skipped ${outOfScope.length} foreign file(s) — took main's version for: ${outOfScope.join(", ")}`; + await store.appendAgentLog(taskId, summary, "text", undefined, "merger"); + await store.logEntry(taskId, summary, undefined, "Layer3AIArbiterScopeSkip"); + if (auditor) { + await auditor.git({ + type: "merge:layer3:foreign-file-skipped", + target: branch, + metadata: { + taskId, + skippedFiles: outOfScope, + declaredScope, + inScopeCount: inScope.length, + viaScopeOverride: false, + }, + }).catch((error: unknown) => { + mergerLog.warn(`${taskId}: failed to emit merge:layer3:foreign-file-skipped run_audit event: ${error instanceof Error ? error.message : String(error)}`); + }); + } + } + + return { inScopeConflicts: inScope, skippedFiles: outOfScope, declaredScope, viaScopeOverride: false }; +} + /** * Validate that the diff stays within the task's declared File Scope. * Returns warnings for out-of-scope changes, especially large deletions. @@ -8260,9 +8384,20 @@ export async function executeMergeAttempt( } const mergeExitedWithConflicts = mergeError !== undefined; if (conflictedFiles.length > 0 || mergeExitedWithConflicts) { - // Classify each conflicted file + const task = await store.getTask(taskId); + const partitioned = await applyLayer3ConflictScopePartition({ + store, + task, + taskId, + rootDir, + branch, + conflictFiles: conflictedFiles, + auditor: params.auditor, + }); + + // Classify each conflicted file remaining in scope. const classified: { file: string; type: ConflictType }[] = []; - for (const file of conflictedFiles) { + for (const file of partitioned.inScopeConflicts) { const type = await classifyConflict(file, rootDir); classified.push({ file, type }); } @@ -8438,11 +8573,21 @@ export async function executeMergeAttempt( } // Check for conflicts - const conflictedOutput = execSyncText("git diff --name-only --diff-filter=U", { - cwd: rootDir, - encoding: "utf-8", - }).trim(); - hasConflicts = conflictedOutput.length > 0; + const conflictedFiles = await getConflictedFiles(rootDir); + hasConflicts = conflictedFiles.length > 0; + if (hasConflicts) { + const task = await store.getTask(taskId); + const partitioned = await applyLayer3ConflictScopePartition({ + store, + task, + taskId, + rootDir, + branch, + conflictFiles: conflictedFiles, + auditor: params.auditor, + }); + hasConflicts = partitioned.inScopeConflicts.length > 0; + } if (hasConflicts && !smartConflictResolution) { // No auto-resolve - AI will handle all conflicts