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:
gsxdsm
2026-05-22 21:23:45 -07:00
parent 0363876109
commit d02cd38d7b
3 changed files with 238 additions and 1 deletions

View File

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