fix(merger): short-circuit out-of-scope fix loop to prevent limbo recovery cycle
When the in-merge fix agent makes no changes AND all failing test files are outside the branch's diff, the merger now throws OutOfScopeVerificationError and marks the task status: "failed" with a clear error message: "Merge verification failed in files outside branch scope — likely pre-existing flake on main. Fix the base-branch test breakage separately and retry." This prevents the task from entering the completion-handoff-limbo recovery cycle (which would retry the merge endlessly) when the verification failure is caused by pre-existing flakiness in an unrelated package (e.g. engine reliability-interaction tests failing while only dashboard was changed). Failing file paths are parsed from vitest/jest output (FAIL lines and ❯ summary lines). If parsing yields no file list, the existing retry behavior is preserved. The OutOfScopeVerificationError propagates through the catch block so it does not count toward completionHandoffLimboRecoveryCount. New exports: OutOfScopeVerificationError, parseFailingFilesFromOutput, getBranchChangedFiles. Tests added: parseFailingFilesFromOutput (4), getBranchChangedFiles (3), OutOfScopeVerificationError constructor (1). All 58 merger-verification tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -146,10 +146,13 @@ import {
|
||||
resolveTaskDiffBaseRef,
|
||||
commitOrAmendMergeWithFixes,
|
||||
MergeAbortedError,
|
||||
OutOfScopeVerificationError,
|
||||
parsePnpmWorkspaceGlobs,
|
||||
resolveWorkspacePackageRoots,
|
||||
mapChangedFilesToPackageNames,
|
||||
deriveScopedPnpmTestCommand,
|
||||
parseFailingFilesFromOutput,
|
||||
getBranchChangedFiles,
|
||||
type ConflictCategory,
|
||||
} from "../merger.js";
|
||||
import { mergerLog } from "../logger.js";
|
||||
@@ -2955,3 +2958,81 @@ describe("inferDefaultTestCommand — pnpm workspace scoping", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── parseFailingFilesFromOutput ──────────────────────────────────────────
|
||||
|
||||
describe("parseFailingFilesFromOutput", () => {
|
||||
it("parses FAIL lines from jest/vitest output", () => {
|
||||
const output = [
|
||||
"FAIL packages/engine/src/__tests__/reliability-interactions/foo.test.ts",
|
||||
"FAIL packages/engine/src/__tests__/bar.test.ts",
|
||||
"● some test name",
|
||||
].join("\n");
|
||||
const files = parseFailingFilesFromOutput(output);
|
||||
expect(files).toContain("packages/engine/src/__tests__/reliability-interactions/foo.test.ts");
|
||||
expect(files).toContain("packages/engine/src/__tests__/bar.test.ts");
|
||||
expect(files.length).toBe(2);
|
||||
});
|
||||
|
||||
it("parses vitest summary ❯ lines", () => {
|
||||
const output = [
|
||||
" ❯ packages/engine/src/__tests__/merger.test.ts (5 tests | 2 failed)",
|
||||
].join("\n");
|
||||
const files = parseFailingFilesFromOutput(output);
|
||||
expect(files).toContain("packages/engine/src/__tests__/merger.test.ts");
|
||||
});
|
||||
|
||||
it("returns empty array when output has no file paths", () => {
|
||||
const output = "● some test title\n● another test\n";
|
||||
expect(parseFailingFilesFromOutput(output)).toEqual([]);
|
||||
});
|
||||
|
||||
it("deduplicates repeated file paths", () => {
|
||||
const output = [
|
||||
"FAIL packages/engine/src/__tests__/foo.test.ts",
|
||||
"FAIL packages/engine/src/__tests__/foo.test.ts",
|
||||
].join("\n");
|
||||
expect(parseFailingFilesFromOutput(output)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getBranchChangedFiles ────────────────────────────────────────────────
|
||||
|
||||
describe("getBranchChangedFiles", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns changed files from git diff output", () => {
|
||||
mockedExecSync.mockReturnValue("packages/dashboard/src/a.ts\npackages/dashboard/src/b.ts\n" as any);
|
||||
const files = getBranchChangedFiles("/repo", "main", "fusion/fn-123");
|
||||
expect(files).toEqual(["packages/dashboard/src/a.ts", "packages/dashboard/src/b.ts"]);
|
||||
});
|
||||
|
||||
it("returns empty array when git diff fails", () => {
|
||||
mockedExecSync.mockImplementation(() => { throw new Error("not a git repo"); });
|
||||
const files = getBranchChangedFiles("/repo", "main", "fusion/fn-123");
|
||||
expect(files).toEqual([]);
|
||||
});
|
||||
|
||||
it("filters out empty lines", () => {
|
||||
mockedExecSync.mockReturnValue("\npackages/engine/src/merger.ts\n\n" as any);
|
||||
const files = getBranchChangedFiles("/repo", "main", "fusion/fn-123");
|
||||
expect(files).toEqual(["packages/engine/src/merger.ts"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── OutOfScopeVerificationError ─────────────────────────────────────────
|
||||
|
||||
describe("OutOfScopeVerificationError", () => {
|
||||
it("is constructable with message, failingFiles, and branchFiles", () => {
|
||||
const err = new OutOfScopeVerificationError(
|
||||
"test failure outside branch scope",
|
||||
["packages/engine/src/__tests__/reliability-interactions/foo.test.ts"],
|
||||
["packages/dashboard/src/index.ts"],
|
||||
);
|
||||
expect(err.name).toBe("OutOfScopeVerificationError");
|
||||
expect(err.message).toContain("outside branch scope");
|
||||
expect(err.failingFiles).toHaveLength(1);
|
||||
expect(err.branchFiles).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user