feat(FN-4956): complete Step 2-3 — scope-partition layer3 conflicts

Fusion-Task-Id: FN-4956
Fusion-Task-Lineage: 7105cd5e-27f4-4e84-b2ae-e811a183803f
This commit is contained in:
Fusion (runfusion.ai)
2026-05-18 00:47:32 -07:00
committed by gsxdsm
parent 863fbeac0f
commit 8072554650
2 changed files with 250 additions and 33 deletions

View File

@@ -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 }),
}));
});
});

View File

@@ -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 <ref>`;
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 <ref>`;
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 <ref>`,
`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 <ref>`,
);
}
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<TaskStore>).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