feat(FN-4108): persist scope override fields (+4 more)
Commits merged: - test(FN-4073): finalize invariant regression coverage - feat(FN-4073): complete Step 4 — document merge invariant - feat(FN-4073): complete Step 3 — enforce squash file-scope invariant - feat(FN-4073): complete Step 2 — add file-scope invariant helper - feat(FN-4073): complete Step 1 — persist scope override fields Files changed: .changeset/FN-4073-file-scope-invariant.md | 9 + AGENTS.md | 6 + packages/core/src/__tests__/store-update.test.ts | 18 ++ packages/core/src/db.ts | 2 + packages/core/src/store.ts | 34 +- packages/core/src/types.ts | 8 + .../__tests__/merger-file-scope-invariant.test.ts | 357 +++++++++++++++++++++ .../engine/src/__tests__/merger-test-helpers.ts | 5 + packages/engine/src/merger.ts | 155 ++++++++- 9 files changed, 582 insertions(+), 12 deletions(-) Fusion-Task-Id: FN-4108
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { DEFAULT_SETTINGS, type MergeResult, type Task } from "@fusion/core";
|
||||
import { createMockStore, mockedExecSync } from "./merger-test-helpers.js";
|
||||
import {
|
||||
assertSquashOverlapsFileScope,
|
||||
attemptWithSideStrategy,
|
||||
commitOrAmendMergeWithFixes,
|
||||
executeMergeAttempt,
|
||||
FileScopeViolationError,
|
||||
} from "../merger.js";
|
||||
|
||||
function createInvariantStore(scope: string[], taskOverrides: Record<string, unknown> = {}) {
|
||||
const store = createMockStore(taskOverrides) as unknown as {
|
||||
parseFileScopeFromPrompt: ReturnType<typeof vi.fn>;
|
||||
appendAgentLog: ReturnType<typeof vi.fn>;
|
||||
moveTask: ReturnType<typeof vi.fn>;
|
||||
updateTask: ReturnType<typeof vi.fn>;
|
||||
logEntry: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
store.parseFileScopeFromPrompt = vi.fn().mockResolvedValue(scope);
|
||||
store.appendAgentLog = vi.fn().mockResolvedValue(undefined);
|
||||
store.moveTask = vi.fn().mockResolvedValue(undefined);
|
||||
store.updateTask = vi.fn().mockResolvedValue(undefined);
|
||||
store.logEntry = vi.fn().mockResolvedValue(undefined);
|
||||
return store;
|
||||
}
|
||||
|
||||
function createMergeResult(): MergeResult {
|
||||
const task: Task = {
|
||||
id: "FN-4073",
|
||||
description: "Test task",
|
||||
column: "in-review",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return {
|
||||
task,
|
||||
branch: "fn/fn-4073",
|
||||
merged: false,
|
||||
worktreeRemoved: false,
|
||||
branchDeleted: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe("assertSquashOverlapsFileScope", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function mockStagedFiles(files: string[]) {
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr === "git diff --cached --name-only") {
|
||||
return files.join("\n");
|
||||
}
|
||||
return "";
|
||||
});
|
||||
}
|
||||
|
||||
it("passes without logging when no declared scope exists", async () => {
|
||||
const store = createInvariantStore([]);
|
||||
mockStagedFiles(["packages/engine/src/merger.ts"]);
|
||||
|
||||
await expect(assertSquashOverlapsFileScope({
|
||||
store: store as never,
|
||||
taskId: "FN-4073",
|
||||
rootDir: "/tmp/root",
|
||||
task: await (store as any).getTask("FN-4073"),
|
||||
})).resolves.toBeUndefined();
|
||||
|
||||
expect(store.appendAgentLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes without logging when staged files fully overlap scope", async () => {
|
||||
const store = createInvariantStore(["packages/engine/src/merger.ts"]);
|
||||
mockStagedFiles(["packages/engine/src/merger.ts"]);
|
||||
|
||||
await expect(assertSquashOverlapsFileScope({
|
||||
store: store as never,
|
||||
taskId: "FN-4073",
|
||||
rootDir: "/tmp/root",
|
||||
task: await (store as any).getTask("FN-4073"),
|
||||
})).resolves.toBeUndefined();
|
||||
|
||||
expect(store.appendAgentLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes when staged files partially overlap scope", async () => {
|
||||
const store = createInvariantStore(["packages/engine/src/merger.ts"]);
|
||||
mockStagedFiles([
|
||||
"packages/engine/src/merger.ts",
|
||||
"packages/core/src/store.ts",
|
||||
]);
|
||||
|
||||
await expect(assertSquashOverlapsFileScope({
|
||||
store: store as never,
|
||||
taskId: "FN-4073",
|
||||
rootDir: "/tmp/root",
|
||||
task: await (store as any).getTask("FN-4073"),
|
||||
})).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("throws when staged files have zero overlap with scope", async () => {
|
||||
const store = createInvariantStore(["packages/engine/src/merger.ts"]);
|
||||
mockStagedFiles(["packages/core/src/store.ts"]);
|
||||
|
||||
await expect(assertSquashOverlapsFileScope({
|
||||
store: store as never,
|
||||
taskId: "FN-4073",
|
||||
rootDir: "/tmp/root",
|
||||
task: await (store as any).getTask("FN-4073"),
|
||||
})).rejects.toMatchObject({
|
||||
name: "FileScopeViolationError",
|
||||
taskId: "FN-4073",
|
||||
stagedFiles: ["packages/core/src/store.ts"],
|
||||
declaredScope: ["packages/engine/src/merger.ts"],
|
||||
} satisfies Partial<FileScopeViolationError>);
|
||||
});
|
||||
|
||||
it("matches nested files against glob entries", async () => {
|
||||
const store = createInvariantStore(["packages/foo/**"]);
|
||||
mockStagedFiles(["packages/foo/src/bar/baz.ts"]);
|
||||
|
||||
await expect(assertSquashOverlapsFileScope({
|
||||
store: store as never,
|
||||
taskId: "FN-4073",
|
||||
rootDir: "/tmp/root",
|
||||
task: await (store as any).getTask("FN-4073"),
|
||||
})).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores .changeset files for overlap and still throws without real overlap", async () => {
|
||||
const store = createInvariantStore(["packages/engine/src/merger.ts"]);
|
||||
mockStagedFiles([".changeset/foo.md"]);
|
||||
|
||||
await expect(assertSquashOverlapsFileScope({
|
||||
store: store as never,
|
||||
taskId: "FN-4073",
|
||||
rootDir: "/tmp/root",
|
||||
task: await (store as any).getTask("FN-4073"),
|
||||
})).rejects.toMatchObject({
|
||||
name: "FileScopeViolationError",
|
||||
stagedFiles: [".changeset/foo.md"],
|
||||
} satisfies Partial<FileScopeViolationError>);
|
||||
});
|
||||
|
||||
it("bypasses enforcement and logs once when scopeOverride is true", async () => {
|
||||
const store = createInvariantStore(["packages/engine/src/merger.ts"], { scopeOverride: true });
|
||||
mockStagedFiles(["packages/core/src/store.ts"]);
|
||||
|
||||
await expect(assertSquashOverlapsFileScope({
|
||||
store: store as never,
|
||||
taskId: "FN-4073",
|
||||
rootDir: "/tmp/root",
|
||||
task: await (store as any).getTask("FN-4073"),
|
||||
})).resolves.toBeUndefined();
|
||||
|
||||
expect(store.appendAgentLog).toHaveBeenCalledTimes(1);
|
||||
expect(store.appendAgentLog).toHaveBeenCalledWith(
|
||||
"FN-4073",
|
||||
"file-scope invariant bypassed via scopeOverride",
|
||||
"text",
|
||||
undefined,
|
||||
"merger",
|
||||
);
|
||||
});
|
||||
|
||||
it("includes the override reason in the bypass log", async () => {
|
||||
const store = createInvariantStore(["packages/engine/src/merger.ts"], {
|
||||
scopeOverride: true,
|
||||
scopeOverrideReason: "hotfix",
|
||||
});
|
||||
mockStagedFiles(["packages/core/src/store.ts"]);
|
||||
|
||||
await expect(assertSquashOverlapsFileScope({
|
||||
store: store as never,
|
||||
taskId: "FN-4073",
|
||||
rootDir: "/tmp/root",
|
||||
task: await (store as any).getTask("FN-4073"),
|
||||
})).resolves.toBeUndefined();
|
||||
|
||||
expect(store.appendAgentLog).toHaveBeenCalledWith(
|
||||
"FN-4073",
|
||||
"file-scope invariant bypassed via scopeOverride — reason: hotfix",
|
||||
"text",
|
||||
undefined,
|
||||
"merger",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("file-scope invariant wiring", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("blocks the standard merge AI path before the commit when staged files are out of scope", async () => {
|
||||
const store = createInvariantStore(["packages/engine/src/merger.ts"]);
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("git diff --cached --quiet")) return "1";
|
||||
if (cmdStr === "git diff --cached --name-only") return "packages/core/src/store.ts";
|
||||
return "";
|
||||
});
|
||||
|
||||
const result = createMergeResult();
|
||||
await expect(executeMergeAttempt({
|
||||
store: store as never,
|
||||
rootDir: "/tmp/root",
|
||||
taskId: "FN-4073",
|
||||
branch: "fn/fn-4073",
|
||||
commitLog: "feat: branch work",
|
||||
diffStat: "1 file changed",
|
||||
includeTaskId: true,
|
||||
smartConflictResolution: true,
|
||||
mergeConflictStrategy: "smart-prefer-branch",
|
||||
attemptNum: 1,
|
||||
options: {},
|
||||
result,
|
||||
settings: { ...DEFAULT_SETTINGS },
|
||||
}, { aiWasInvoked: false })).rejects.toBeInstanceOf(FileScopeViolationError);
|
||||
|
||||
expect(store.appendAgentLog).toHaveBeenCalledWith(
|
||||
"FN-4073",
|
||||
expect.stringContaining("File-scope invariant violation"),
|
||||
"tool_error",
|
||||
expect.stringContaining("declaredScope:"),
|
||||
"merger",
|
||||
);
|
||||
expect(mockedExecSync).toHaveBeenCalledWith("git reset --merge", expect.objectContaining({ cwd: "/tmp/root" }));
|
||||
expect(store.moveTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows the -X fallback commit when staged files partially overlap scope", async () => {
|
||||
const store = createInvariantStore(["packages/engine/src/merger.ts"]);
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("git merge -X ours --squash")) return "";
|
||||
if (cmdStr.includes("git diff --name-only --diff-filter=U")) return "";
|
||||
if (cmdStr.includes("git diff --cached --quiet")) return "1";
|
||||
if (cmdStr === "git diff --cached --name-only") return "packages/engine/src/merger.ts\npackages/core/src/store.ts";
|
||||
if (cmdStr.includes("git commit ")) return "";
|
||||
return "";
|
||||
});
|
||||
|
||||
await expect(attemptWithSideStrategy({
|
||||
store: store as never,
|
||||
rootDir: "/tmp/root",
|
||||
taskId: "FN-4073",
|
||||
branch: "fn/fn-4073",
|
||||
commitLog: "feat: branch work",
|
||||
diffStat: "1 file changed",
|
||||
includeTaskId: true,
|
||||
sourceIssueRef: undefined,
|
||||
smartConflictResolution: true,
|
||||
mergeConflictStrategy: "smart-prefer-main",
|
||||
attemptNum: 3,
|
||||
options: {},
|
||||
result: createMergeResult(),
|
||||
settings: { ...DEFAULT_SETTINGS },
|
||||
}, "ours")).resolves.toBe(true);
|
||||
|
||||
expect(store.appendAgentLog).not.toHaveBeenCalledWith(
|
||||
"FN-4073",
|
||||
expect.stringContaining("File-scope invariant violation"),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("bypasses the -X fallback invariant when scopeOverride is true and logs the reason", async () => {
|
||||
const store = createInvariantStore(["packages/engine/src/merger.ts"], {
|
||||
scopeOverride: true,
|
||||
scopeOverrideReason: "hotfix",
|
||||
});
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("git merge -X ours --squash")) return "";
|
||||
if (cmdStr.includes("git diff --name-only --diff-filter=U")) return "";
|
||||
if (cmdStr.includes("git diff --cached --quiet")) return "1";
|
||||
if (cmdStr.includes("git commit ")) return "";
|
||||
return "";
|
||||
});
|
||||
|
||||
await expect(attemptWithSideStrategy({
|
||||
store: store as never,
|
||||
rootDir: "/tmp/root",
|
||||
taskId: "FN-4073",
|
||||
branch: "fn/fn-4073",
|
||||
commitLog: "feat: branch work",
|
||||
diffStat: "1 file changed",
|
||||
includeTaskId: true,
|
||||
sourceIssueRef: undefined,
|
||||
smartConflictResolution: true,
|
||||
mergeConflictStrategy: "smart-prefer-main",
|
||||
attemptNum: 3,
|
||||
options: {},
|
||||
result: createMergeResult(),
|
||||
settings: { ...DEFAULT_SETTINGS },
|
||||
}, "ours")).resolves.toBe(true);
|
||||
|
||||
expect(store.appendAgentLog).toHaveBeenCalledWith(
|
||||
"FN-4073",
|
||||
"file-scope invariant bypassed via scopeOverride — reason: hotfix",
|
||||
"text",
|
||||
undefined,
|
||||
"merger",
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks verification-fix finalization when staged files are out of scope", async () => {
|
||||
const store = createInvariantStore(["packages/engine/src/merger.ts"]);
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr === "git diff --cached --name-only") return "packages/core/src/store.ts";
|
||||
if (cmdStr === "git diff --name-only") return "";
|
||||
if (cmdStr === "git status -z --porcelain") return "";
|
||||
if (cmdStr === "git diff --cached --raw") return "";
|
||||
if (cmdStr === "git rev-parse HEAD") return "head-before";
|
||||
if (cmdStr.includes("git commit ")) return "";
|
||||
return "";
|
||||
});
|
||||
|
||||
await expect(commitOrAmendMergeWithFixes(
|
||||
"/tmp/root",
|
||||
"FN-4073",
|
||||
"fn/fn-4073",
|
||||
"feat: branch work",
|
||||
true,
|
||||
"head-before",
|
||||
"",
|
||||
"1 file changed",
|
||||
{ ...DEFAULT_SETTINGS },
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
new Set(),
|
||||
store as never,
|
||||
)).rejects.toBeInstanceOf(FileScopeViolationError);
|
||||
|
||||
expect(store.appendAgentLog).toHaveBeenCalledWith(
|
||||
"FN-4073",
|
||||
expect.stringContaining("File-scope invariant violation"),
|
||||
"tool_error",
|
||||
expect.stringContaining("stagedFiles:"),
|
||||
"merger",
|
||||
);
|
||||
expect(mockedExecSync).toHaveBeenCalledWith("git reset --merge", expect.objectContaining({ cwd: "/tmp/root" }));
|
||||
});
|
||||
});
|
||||
@@ -131,6 +131,8 @@ import {
|
||||
parseDiffStat,
|
||||
extractFileScope,
|
||||
validateDiffScope,
|
||||
assertSquashOverlapsFileScope,
|
||||
FileScopeViolationError,
|
||||
shouldSyncDependenciesForMerge,
|
||||
summarizeVerificationOutput,
|
||||
inferDefaultTestCommand,
|
||||
@@ -165,6 +167,8 @@ export {
|
||||
parseDiffStat,
|
||||
extractFileScope,
|
||||
validateDiffScope,
|
||||
assertSquashOverlapsFileScope,
|
||||
FileScopeViolationError,
|
||||
shouldSyncDependenciesForMerge,
|
||||
summarizeVerificationOutput,
|
||||
inferDefaultTestCommand,
|
||||
@@ -211,6 +215,7 @@ export function createMockStore(taskOverrides: Partial<Task> = {}, allTasks: Tas
|
||||
updateSettings: vi.fn().mockResolvedValue({}),
|
||||
getSettings: vi.fn().mockResolvedValue({ ...DEFAULT_SETTINGS }),
|
||||
getActiveMergingTask: vi.fn().mockReturnValue(null),
|
||||
parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]),
|
||||
emit: vi.fn(),
|
||||
on: vi.fn(),
|
||||
clearStaleExecutionStartBranchReferences: vi.fn().mockReturnValue([]),
|
||||
|
||||
@@ -3415,6 +3415,15 @@ export async function commitOrAmendMergeWithFixes(
|
||||
// This is the phantom-merge fix: previously the code blindly amended
|
||||
// HEAD (the previous task's commit), silently dropping the current
|
||||
// task's branch and inheriting the prior task's stats.
|
||||
if (store) {
|
||||
await enforceSquashFileScopeInvariant({
|
||||
store,
|
||||
taskId,
|
||||
rootDir,
|
||||
task: await store.getTask(taskId),
|
||||
resetLabel: "file-scope invariant violation",
|
||||
});
|
||||
}
|
||||
await runDiffVolumeGate({
|
||||
rootDir,
|
||||
branch,
|
||||
@@ -3448,6 +3457,15 @@ export async function commitOrAmendMergeWithFixes(
|
||||
// HEAD moved — AI agent committed already. Amend with deterministic
|
||||
// message + any new staged fixes folded in. `--amend -m` replaces both
|
||||
// the message and includes any newly-staged content.
|
||||
if (store) {
|
||||
await enforceSquashFileScopeInvariant({
|
||||
store,
|
||||
taskId,
|
||||
rootDir,
|
||||
task: await store.getTask(taskId),
|
||||
resetLabel: "file-scope invariant violation",
|
||||
});
|
||||
}
|
||||
await runDiffVolumeGate({
|
||||
rootDir,
|
||||
branch,
|
||||
@@ -3477,7 +3495,7 @@ export async function commitOrAmendMergeWithFixes(
|
||||
mergerLog.log(`${taskId}: amended merge commit with verification fixes (deterministic message)`);
|
||||
return { ok: true, reason: "completed" };
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof DiffVolumeRegressionError) {
|
||||
if (err instanceof DiffVolumeRegressionError || err instanceof FileScopeViolationError) {
|
||||
throw err;
|
||||
}
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
@@ -3582,6 +3600,104 @@ function matchesScope(filePath: string, scopePatterns: string[]): boolean {
|
||||
* When `strict` is true, throws an error on scope violations instead of
|
||||
* just returning warnings (hard guardrail that blocks merge).
|
||||
*/
|
||||
|
||||
export class FileScopeViolationError extends Error {
|
||||
taskId: string;
|
||||
stagedFiles: string[];
|
||||
declaredScope: string[];
|
||||
|
||||
constructor(taskId: string, stagedFiles: string[], declaredScope: string[]) {
|
||||
const stagedList = stagedFiles.length > 0 ? stagedFiles.join(", ") : "<none outside .changeset/>";
|
||||
const scopeList = declaredScope.join(", ");
|
||||
super(
|
||||
`File-scope invariant violation for ${taskId}: staged files [${stagedList}] have zero overlap with declared File Scope [${scopeList}]. Refile genuinely out-of-scope work as a follow-up task via fn_task_create before retrying this merge.`,
|
||||
);
|
||||
this.name = "FileScopeViolationError";
|
||||
this.taskId = taskId;
|
||||
this.stagedFiles = stagedFiles;
|
||||
this.declaredScope = declaredScope;
|
||||
}
|
||||
}
|
||||
|
||||
export async function assertSquashOverlapsFileScope(params: {
|
||||
store: TaskStore;
|
||||
taskId: string;
|
||||
rootDir: string;
|
||||
task: Task;
|
||||
}): Promise<void> {
|
||||
const { store, taskId, rootDir, task } = params;
|
||||
|
||||
if (task.scopeOverride === true) {
|
||||
const reasonSuffix = task.scopeOverrideReason?.trim()
|
||||
? ` — reason: ${task.scopeOverrideReason.trim()}`
|
||||
: "";
|
||||
await store.appendAgentLog(
|
||||
taskId,
|
||||
`file-scope invariant bypassed via scopeOverride${reasonSuffix}`,
|
||||
"text",
|
||||
undefined,
|
||||
"merger",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof (store as Partial<TaskStore>).parseFileScopeFromPrompt !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
const declaredScope = await store.parseFileScopeFromPrompt(taskId);
|
||||
if (declaredScope.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { stdout } = await execAsync("git diff --cached --name-only", {
|
||||
cwd: rootDir,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
const stagedFiles = stdout.split("\n").map((line) => line.trim()).filter(Boolean);
|
||||
const scopedStagedFiles = stagedFiles.filter((file) => !file.startsWith(".changeset/"));
|
||||
const hasOverlap = scopedStagedFiles.some((file) => matchesScope(file, declaredScope));
|
||||
if (!hasOverlap) {
|
||||
throw new FileScopeViolationError(taskId, stagedFiles, declaredScope);
|
||||
}
|
||||
}
|
||||
|
||||
function formatFileScopeViolationAgentLog(error: FileScopeViolationError): string {
|
||||
const stagedFiles = error.stagedFiles.length > 0 ? error.stagedFiles.join("\n") : "<none>";
|
||||
return [
|
||||
`taskId: ${error.taskId}`,
|
||||
"declaredScope:",
|
||||
...error.declaredScope.map((entry) => `- ${entry}`),
|
||||
"stagedFiles:",
|
||||
...stagedFiles.split("\n").map((entry) => `- ${entry}`),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function enforceSquashFileScopeInvariant(params: {
|
||||
store: TaskStore;
|
||||
taskId: string;
|
||||
rootDir: string;
|
||||
task: Task;
|
||||
resetLabel: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
await assertSquashOverlapsFileScope(params);
|
||||
} catch (error: unknown) {
|
||||
if (!(error instanceof FileScopeViolationError)) {
|
||||
throw error;
|
||||
}
|
||||
await params.store.appendAgentLog(
|
||||
params.taskId,
|
||||
error.message,
|
||||
"tool_error",
|
||||
formatFileScopeViolationAgentLog(error),
|
||||
"merger",
|
||||
);
|
||||
resetMergeWithWarn(params.rootDir, params.taskId, params.resetLabel);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function validateDiffScope(
|
||||
store: TaskStore,
|
||||
taskId: string,
|
||||
@@ -6106,7 +6222,11 @@ export async function aiMergeTask(
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (error instanceof DiffVolumeRegressionError || error?.name === "DiffVolumeRegressionError") {
|
||||
if (
|
||||
error instanceof DiffVolumeRegressionError
|
||||
|| error?.name === "DiffVolumeRegressionError"
|
||||
|| error?.name === "FileScopeViolationError"
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -7187,6 +7307,13 @@ export async function executeMergeAttempt(
|
||||
aiSummary: safeBody,
|
||||
aiSubject,
|
||||
});
|
||||
await enforceSquashFileScopeInvariant({
|
||||
store,
|
||||
taskId,
|
||||
rootDir,
|
||||
task: await store.getTask(taskId),
|
||||
resetLabel: "file-scope invariant violation",
|
||||
});
|
||||
await runDiffVolumeGate({
|
||||
rootDir,
|
||||
branch,
|
||||
@@ -7317,6 +7444,13 @@ export async function executeMergeAttempt(
|
||||
// Spawn AI agent
|
||||
throwIfAborted(options.signal, taskId);
|
||||
aiTracker.aiWasInvoked = true; // Track that AI was invoked
|
||||
await enforceSquashFileScopeInvariant({
|
||||
store,
|
||||
taskId,
|
||||
rootDir,
|
||||
task: await store.getTask(taskId),
|
||||
resetLabel: "file-scope invariant violation",
|
||||
});
|
||||
const agentResult = await runAiAgentForCommit({
|
||||
store,
|
||||
rootDir,
|
||||
@@ -7444,7 +7578,11 @@ export async function executeMergeAttempt(
|
||||
// and trip the phantom-merge guard even though the task's content is
|
||||
// already on HEAD. Retrying with auto-conflict-resolution can't help a
|
||||
// verification failure anyway — there are no conflicts to resolve.
|
||||
if (error?.name === "VerificationError" || error?.name === "DiffVolumeRegressionError") {
|
||||
if (
|
||||
error?.name === "VerificationError"
|
||||
|| error?.name === "DiffVolumeRegressionError"
|
||||
|| error?.name === "FileScopeViolationError"
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -7492,7 +7630,7 @@ export async function attemptWithSideStrategy(
|
||||
|
||||
return finalizeSideStrategyAttempt(params, side, aiTracker);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && (error.name === "MergeAbortedError" || error.name === "DiffVolumeRegressionError")) {
|
||||
if (error instanceof Error && (error.name === "MergeAbortedError" || error.name === "DiffVolumeRegressionError" || error.name === "FileScopeViolationError")) {
|
||||
throw error;
|
||||
}
|
||||
mergerLog.error(`${taskId}: -X ${side} merge failed: ${error}`);
|
||||
@@ -7533,7 +7671,7 @@ async function attemptWithMixedSideStrategy(
|
||||
|
||||
return finalizeSideStrategyAttempt(params, strategy.defaultSide, aiTracker);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && (error.name === "MergeAbortedError" || error.name === "DiffVolumeRegressionError")) {
|
||||
if (error instanceof Error && (error.name === "MergeAbortedError" || error.name === "DiffVolumeRegressionError" || error.name === "FileScopeViolationError")) {
|
||||
throw error;
|
||||
}
|
||||
mergerLog.error(`${taskId}: overlap-aware merge failed: ${error}`);
|
||||
@@ -7593,6 +7731,13 @@ async function finalizeSideStrategyAttempt(
|
||||
aiSummary: aiSummary?.trim().length ? aiSummary : safeBody,
|
||||
aiSubject,
|
||||
});
|
||||
await enforceSquashFileScopeInvariant({
|
||||
store,
|
||||
taskId,
|
||||
rootDir,
|
||||
task: await store.getTask(taskId),
|
||||
resetLabel: "file-scope invariant violation",
|
||||
});
|
||||
await runDiffVolumeGate({
|
||||
rootDir,
|
||||
branch,
|
||||
|
||||
Reference in New Issue
Block a user