diff --git a/packages/engine/src/__tests__/project-engine.test.ts b/packages/engine/src/__tests__/project-engine.test.ts index 2404f6228e..05b113f4be 100644 --- a/packages/engine/src/__tests__/project-engine.test.ts +++ b/packages/engine/src/__tests__/project-engine.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Task } from "@fusion/core"; import { ProjectEngine, __resetDeterministicMergerModeDeprecationWarned } from "../project-engine.js"; -import { AgentSemaphore } from "../concurrency.js"; +import { AgentSemaphore, projectAdmissionCoordinator } from "../concurrency.js"; // Resolves to the vi.mock factory above (the mocked merger-ai exports the real-shaped // workspace land error classes so the dispatch's `instanceof` matching is exercised). import { WorkspacePartialLandError, WorkspaceRepoLandBusyError } from "../merger-ai.js"; @@ -3723,3 +3723,96 @@ describe("enqueueEligibleInReviewTasks honors per-task autoMerge override (share expect(enqueueSpy).toHaveBeenCalledWith("FN-explicit-false"); }); }); + +/* +FNXC:MergeSafeguards 2026-07-28-19:40 (U9): +The user-pause filter on merge admission had ZERO test coverage: deleting it +produced no new failure across project-engine, merge-*, concurrency, or +merge-single-flight-invariant. The guard works correctly today — what was missing +is anything that would notice if it stopped. U9 moves merge behind graph nodes, so +it must be pinned BEFORE the conversion, not after. + +(An earlier draft also added a single-flight test here. That was redundant — +merge-single-flight-invariant.test.ts already covers capacity, verified by +mutation. It is admitted to the gate instead.) + +The test asserts BOTH directions (guard blocks / guard permits) so it fails if the +guard is removed AND if the filter stops discriminating — a one-sided assertion +would still pass against a guard that rejects everything. +*/ +describe("U9 merge safeguards without prior coverage", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + /* + Safeguard 1 — user pause. The pause invariant re-ratified in #2486: never MUTATE + lifecycle state of a user-paused card. The merge admission provider is the seam + that decides which queued in-review cards are offered to the merge pump; without + its `paused || userPaused` filter a user-paused card is admitted and merged. + */ + it("merge admission excludes a user-paused card and admits the same card once unpaused", async () => { + const registered = new Map Promise }>(); + const registerSpy = vi + .spyOn(projectAdmissionCoordinator, "registerProvider") + .mockImplementation((providerId: string, provider: never) => { + registered.set(providerId, provider as unknown as { refresh: () => Promise }); + return () => {}; + }); + + const mockStore = createMockStore({ ...baseSettings, autoMerge: true }); + mocks.currentStore = mockStore.store; + + const engine = createEngine(); + await engine.start(); + + const mergeProvider = [...registered.entries()].find(([id]) => id.startsWith("merge:"))?.[1]; + if (!mergeProvider) throw new Error("merge admission provider was not registered"); + + const privateEngine = engine as unknown as { mergeQueue: string[]; coordinatorAdmittedMergeTaskIds: Set }; + privateEngine.mergeQueue = ["FN-paused"]; + privateEngine.coordinatorAdmittedMergeTaskIds.clear(); + + // User-paused: must NOT be offered for merge admission. + mockStore.store.getTask.mockResolvedValue({ + id: "FN-paused", + column: "in-review", + paused: false, + userPaused: true, + status: null, + mergeRetries: 0, + createdAt: new Date(0).toISOString(), + }); + await expect(mergeProvider.refresh()).resolves.toEqual([]); + + // Same card, same queue, pause cleared: must now be offered. This half proves + // the exclusion above came from the pause flag and not from an unrelated gate. + mockStore.store.getTask.mockResolvedValue({ + id: "FN-paused", + column: "in-review", + paused: false, + userPaused: false, + status: null, + mergeRetries: 0, + createdAt: new Date(0).toISOString(), + }); + const admitted = (await mergeProvider.refresh()) as Array<{ taskId: string }>; + expect(admitted.map((c) => c.taskId)).toEqual(["FN-paused"]); + + // Engine-level `paused` is the sibling half of the same filter. + mockStore.store.getTask.mockResolvedValue({ + id: "FN-paused", + column: "in-review", + paused: true, + userPaused: false, + status: null, + mergeRetries: 0, + createdAt: new Date(0).toISOString(), + }); + await expect(mergeProvider.refresh()).resolves.toEqual([]); + + registerSpy.mockRestore(); + await engine.stop(); + }); + +}); diff --git a/packages/engine/vitest.config.ts b/packages/engine/vitest.config.ts index a01b6fc964..29f41ac36d 100644 --- a/packages/engine/vitest.config.ts +++ b/packages/engine/vitest.config.ts @@ -183,6 +183,16 @@ export default defineConfig({ "src/__tests__/merger-landed-files-capture.test.ts", "src/__tests__/branch-attribution.test.ts", /* + FNXC:EngineTests 2026-07-28-20:10: + Gate admission evidence (U9 safeguard baseline). This one file proves FIVE of the merge lane's safeguards: user pause on merge admission, autoMerge:false, capacity single-flight, the pre-enqueue merge-proof consult, and at-most-once enqueue. A U9 mutation audit found NONE of them defended by blocking CI — and two (user pause, single-flight) had no test at all until this change. Merge is where irreversible work happens and U9 is about to move it behind graph nodes, so these must fail the gate, not a non-blocking run hours after the merge. Deterministic: the store, runtime, merger, and notifier are all mocked; no real git, no network. Measured 5.02s standalone / 103 tests. + */ + "src/__tests__/project-engine.test.ts", + /* + FNXC:EngineTests 2026-07-28-21:05 (#2520 review — greptile P1): + Capacity single-flight IS covered — by this purpose-built file, not by anything in project-engine.test.ts. It was outside blocking CI, which is the real gap. Removing `if (this.mergeRunning) return;` fails "refuses a second concurrent drain while one merge is in flight" here and nowhere else. Deterministic, 3.69s / 3 tests. + */ + "src/__tests__/merge-single-flight-invariant.test.ts", + /* FNXC:EngineTests 2026-07-28-10:20: Gate admission evidence (U9): this pins which authority actually decides merge-region policy — the built-in IR declares `merge-retry.maxAttempts` / `manual-merge-hold.release` that no handler reads, while the live budgets sit in `settings.maxAutoMergeRetries` and `ProjectEngine.MAX_AUTO_MERGE_TRANSIENT_RETRIES`. Merge is where irreversible work happens, and the drift it guards is SILENT: a handler-only edit can quietly make the dead IR config live (or move the live budget) with no other test failing. Outside the gate the ratchet cannot fire on the defect it exists for. Deterministic and pure — no git subprocesses, no timers, no network, no store; 3 ms of assertions. */