diff --git a/docs/testing.md b/docs/testing.md index 6fab94fd91..11a8c2daa3 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -395,6 +395,9 @@ Flags: **2026-08-10 Planning Mode disposition (FN-8936):** Rescued `PlanningModeModal.planning-flow.test.tsx` before its 2026-08-20 deadline. Investigation found no product state-machine race: Proceed snapshots stable session/summary refs and takes its single-flight guard before create. The loaded failure was a test harness detached-node race when session hydration replaced an action-bar button returned by `findByRole`. All unsafe direct Proceed handoffs now settle hydration and query a live button before clicking, while retaining strict create arguments, task-created/onTaskCreated, desktop/mobile handoff, claim-retry, retry, and multi-task assertions. Exact and loaded-file runs passed, and the matching ledger entry and dashboard Vitest exclude were removed together without changing timeouts or adding retries. + +**2026-08-10 project-engine disposition (FN-8937):** Rescued `project-engine.test.ts` before its 2026-08-20 deadline. The suite's un-mocked `exec`-based integration-branch probe spawned real git, while the shared subprocess guard watchdog used fakeable timers; a duplicate-registration path could also orphan a watchdog handle. The resolver is now a deterministic suite seam, and watchdogs use captured real timers with owner-scoped failure draining, preserving sibling-test failure ownership. The ledger claim that runtime schedules 120s was a misread of `FUSION_TEST_SUBPROCESS_TIMEOUT_MS`: production retains its correct 60s ladder at `packages/engine/src/project-engine.ts:4551`, and the test correctly forbids its uncapped 120000ms rung. Thus the request to update the assertion to 120s is a documented deviation; no timeout was widened, retry added, or assertion weakened. The ledger/config exclusions were removed together, while the file remains outside `engine-core` pending separate gate-admission evidence. + ### Validate before excluding and preserve timeout budgets Capture **full runner output** before recording or filing a ledger entry—for example, tee it to a file. Never pipe a dot reporter through `tail`: the summary remains but the `FAIL` identity lines needed for evidence are truncated. diff --git a/packages/core/src/__test-utils__/vitest-setup.ts b/packages/core/src/__test-utils__/vitest-setup.ts index 2703f727a2..36b2cc12e1 100644 --- a/packages/core/src/__test-utils__/vitest-setup.ts +++ b/packages/core/src/__test-utils__/vitest-setup.ts @@ -88,6 +88,14 @@ const DEFAULT_TEST_SUBPROCESS_TIMEOUT_MS = Math.max( 1_000, Number.parseInt(process.env.FUSION_TEST_SUBPROCESS_TIMEOUT_MS ?? "30000", 10) || 30_000, ); +let currentSubprocessTimeoutMs = DEFAULT_TEST_SUBPROCESS_TIMEOUT_MS; +/* +FNXC:TestSubprocessGuard 2026-08-10-09:35: +FN-8937 requires watchdogs to measure real elapsed time even when a test advances +Vitest's virtual clock. Capture timer APIs before tests can install fake timers. +*/ +const realSetTimeout = globalThis.setTimeout.bind(globalThis); +const realClearTimeout = globalThis.clearTimeout.bind(globalThis); const BLOCKED_TEST_CLI_PATTERN = /(^|[\s"'\\/])(?:claude|droid|paperclipai|hermes|openclaw)(?:\.(?:cmd|bat|ps1|exe))?(?=$|[\s"'\\/])/i; @@ -885,7 +893,7 @@ function withDefaultTimeout(options: } return { ...(options ?? {}), - timeout: DEFAULT_TEST_SUBPROCESS_TIMEOUT_MS, + timeout: currentSubprocessTimeoutMs, } as T; } @@ -893,13 +901,19 @@ function cleanupTrackedSubprocess(proc: ChildProcess): void { const tracked = trackedSubprocesses.get(proc); if (!tracked) return; if (tracked.timeoutTimer) { - clearTimeout(tracked.timeoutTimer); + realClearTimeout(tracked.timeoutTimer); tracked.timeoutTimer = null; } trackedSubprocesses.delete(proc); } function registerTrackedSubprocess(proc: ChildProcess, commandLine: string): void { + /* + FNXC:TestSubprocessGuard 2026-08-10-09:35: + FN-8937 requires duplicate registration to cancel its prior watchdog; map size + alone cannot expose the otherwise orphaned timer that later fabricates a timeout. + */ + cleanupTrackedSubprocess(proc); const tracked: TrackedSubprocess = { commandLine, startedAt: Date.now(), @@ -909,18 +923,19 @@ function registerTrackedSubprocess(proc: ChildProcess, commandLine: string): voi }; trackedSubprocesses.set(proc, tracked); - tracked.timeoutTimer = setTimeout(() => { + tracked.timeoutTimer = realSetTimeout(() => { tracked.timedOut = true; completedSubprocessFailures.push({ ownerTestName: tracked.testName, - message: `Timed out after ${DEFAULT_TEST_SUBPROCESS_TIMEOUT_MS}ms: ${tracked.commandLine}${tracked.testName ? ` (${tracked.testName})` : ""}`, + message: `Timed out after ${currentSubprocessTimeoutMs}ms: ${tracked.commandLine}${tracked.testName ? ` (${tracked.testName})` : ""}`, }); try { proc.kill("SIGKILL"); } catch { // Ignore — the process may have already exited. } - }, DEFAULT_TEST_SUBPROCESS_TIMEOUT_MS); + }, currentSubprocessTimeoutMs); + tracked.timeoutTimer.unref?.(); const finish = () => cleanupTrackedSubprocess(proc); proc.once("close", finish); @@ -1195,6 +1210,55 @@ function removeSelfMintedWorkerRootWithRetry( } } +/* +FNXC:TestSubprocessGuard 2026-08-10-09:35: +FN-8937 exposes a deliberately narrow owner-scoped harness: fake timers must not +fabricate timeouts, real hangs must still report, and sibling failures stay queued +for their own afterEach. Never add a blanket drain or suppression path here. +*/ +export const __fusionSubprocessGuardTestHooks = { + getSubprocessTimeoutMs: (): number => currentSubprocessTimeoutMs, + setSubprocessTimeoutMsForTests: (ms: number | null): void => { + currentSubprocessTimeoutMs = ms ?? DEFAULT_TEST_SUBPROCESS_TIMEOUT_MS; + }, + takeOwnedSubprocessFailures: (): string[] => { + const ownerTestName = currentTestName(); + const owned: string[] = []; + const remaining: CompletedSubprocessFailure[] = []; + for (const failure of completedSubprocessFailures) { + if (failure.ownerTestName === ownerTestName) owned.push(failure.message); + else remaining.push(failure); + } + completedSubprocessFailures.length = 0; + completedSubprocessFailures.push(...remaining); + return owned; + }, + peekForeignSubprocessFailureCount: (): number => + completedSubprocessFailures.filter((failure) => failure.ownerTestName !== currentTestName()).length, + listForeignSubprocessFailureMessages: (): readonly string[] => + completedSubprocessFailures + .filter((failure) => failure.ownerTestName !== currentTestName()) + .map((failure) => failure.message), + recordSubprocessFailureForTests: (ownerTestName: string | null, message: string): void => { + completedSubprocessFailures.push({ ownerTestName, message }); + }, + /* + FNXC:TestSubprocessGuard 2026-08-10-09:35: + This removes only synthetic entries staged by the calling test so cleanup never + discards a real recorded guard failure or leaks a foreign entry to another test. + */ + removeStagedFailureForTests: (message: string): boolean => { + const index = completedSubprocessFailures.findIndex((failure) => failure.message === message); + if (index < 0) return false; + completedSubprocessFailures.splice(index, 1); + return true; + }, + registerTrackedSubprocessForTests: (proc: ChildProcess, commandLine: string): void => { + registerTrackedSubprocess(proc, commandLine); + }, + getTrackedSubprocessCount: (): number => trackedSubprocesses.size, +}; + export const __fusionWorkerRootCleanupTestHooks = { removeSelfMintedWorkerRootWithRetry, writeWorkerRootOwnerMarker, diff --git a/packages/core/src/__tests__/subprocess-guard-fake-timers.test.ts b/packages/core/src/__tests__/subprocess-guard-fake-timers.test.ts new file mode 100644 index 0000000000..eb989327a5 --- /dev/null +++ b/packages/core/src/__tests__/subprocess-guard-fake-timers.test.ts @@ -0,0 +1,140 @@ +import { exec, execFile, fork, spawn, type ChildProcess } from "node:child_process"; +import { once } from "node:events"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { __fusionSubprocessGuardTestHooks as guard } from "../__test-utils__/vitest-setup"; + +const temporaryPaths: string[] = []; + +function waitForClose(proc: ChildProcess): Promise { + return once(proc, "close").then(() => undefined); +} + +function waitForRealTime(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function startShortChild(): ChildProcess { + return spawn(process.execPath, ["-e", "0"]); +} + +function startHangingChild(): ChildProcess { + return spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"]); +} + +afterEach(() => { + vi.useRealTimers(); + guard.setSubprocessTimeoutMsForTests(null); + expect(guard.peekForeignSubprocessFailureCount()).toBe(0); + expect(guard.takeOwnedSubprocessFailures()).toEqual([]); + for (const path of temporaryPaths.splice(0)) rmSync(path, { recursive: true, force: true }); +}); + +describe("subprocess guard watchdog timers", () => { + it.each([ + ["spawn", () => spawn(process.execPath, ["-e", "0"])], + ["exec", () => exec(`${JSON.stringify(process.execPath)} -e "0"`)], + ["execFile", () => execFile(process.execPath, ["-e", "0"])], + ["fork", () => { + const directory = mkdtempSync(join(tmpdir(), "fn-subprocess-guard-")); + temporaryPaths.push(directory); + const modulePath = join(directory, "child.cjs"); + writeFileSync(modulePath, "process.exit(0);\n"); + return fork(modulePath); + }], + ])("S1 ignores virtual elapsed time for %s", async (_name, start) => { + vi.useFakeTimers(); + const before = vi.getTimerCount(); + const proc = start(); + const after = vi.getTimerCount(); + + await vi.advanceTimersByTimeAsync(guard.getSubprocessTimeoutMs() * 2); + vi.useRealTimers(); + await waitForClose(proc); + + expect(guard.takeOwnedSubprocessFailures()).toEqual([]); + expect(after).toBe(before); + }); + + it("S2 ignores virtual elapsed time installed after spawn", async () => { + const proc = startShortChild(); + vi.useFakeTimers(); + await vi.advanceTimersByTimeAsync(guard.getSubprocessTimeoutMs() * 2); + vi.useRealTimers(); + await waitForClose(proc); + + expect(guard.takeOwnedSubprocessFailures()).toEqual([]); + expect(guard.getTrackedSubprocessCount()).toBe(0); + }); + + it("S3 retains the real-timer baseline", async () => { + const proc = startShortChild(); + await waitForClose(proc); + + expect(guard.takeOwnedSubprocessFailures()).toEqual([]); + expect(guard.getTrackedSubprocessCount()).toBe(0); + expect(guard.getSubprocessTimeoutMs()).toSatisfy((ms: number) => Number.isFinite(ms) && ms >= 1_000); + }); + + it("S4 reports and kills a genuinely hung child", async () => { + guard.setSubprocessTimeoutMsForTests(150); + const proc = startHangingChild(); + await waitForClose(proc); + + expect(guard.takeOwnedSubprocessFailures()).toEqual([expect.stringMatching(/Timed out after 150ms/)]); + expect(proc.signalCode).toBe("SIGKILL"); + expect(guard.getTrackedSubprocessCount()).toBe(0); + }); + + it("S5 cleanup cancels its real watchdog", async () => { + guard.setSubprocessTimeoutMsForTests(150); + const proc = startShortChild(); + await waitForClose(proc); + await waitForRealTime(450); + + expect(guard.takeOwnedSubprocessFailures()).toEqual([]); + expect(guard.getTrackedSubprocessCount()).toBe(0); + }); + + it("S6 duplicate registration does not orphan a watchdog", async () => { + guard.setSubprocessTimeoutMsForTests(150); + const proc = startShortChild(); + guard.registerTrackedSubprocessForTests(proc, "duplicate-registration-probe"); + await waitForClose(proc); + await waitForRealTime(450); + + // The map is keyed by proc, so only absence of a fabricated failure proves cleanup. + expect(guard.takeOwnedSubprocessFailures()).toEqual([]); + expect(guard.getTrackedSubprocessCount()).toBe(0); + }); + + it("S7 leaves a foreign-only failure queued", () => { + expect(guard.takeOwnedSubprocessFailures()).toEqual([]); + expect(guard.peekForeignSubprocessFailureCount()).toBe(0); + + const message = "Timed out after 1ms: synthetic-foreign"; + guard.recordSubprocessFailureForTests("FN-8937 synthetic sibling test", message); + expect(guard.takeOwnedSubprocessFailures()).toEqual([]); + expect(guard.peekForeignSubprocessFailureCount()).toBe(1); + expect(guard.removeStagedFailureForTests(message)).toBe(true); + expect(guard.peekForeignSubprocessFailureCount()).toBe(0); + }); + + it("S8 drains owned failures while preserving foreign order", async () => { + guard.setSubprocessTimeoutMsForTests(150); + guard.recordSubprocessFailureForTests("FN-8937 synthetic sibling A", "synthetic-F1"); + const proc = startHangingChild(); + await waitForClose(proc); + expect(guard.peekForeignSubprocessFailureCount()).toBe(1); + guard.recordSubprocessFailureForTests("FN-8937 synthetic sibling B", "synthetic-F2"); + + expect(guard.takeOwnedSubprocessFailures()).toEqual([expect.stringMatching(/Timed out after 150ms/)]); + expect(guard.peekForeignSubprocessFailureCount()).toBe(2); + expect(guard.listForeignSubprocessFailureMessages()).toEqual(["synthetic-F1", "synthetic-F2"]); + expect(guard.removeStagedFailureForTests("synthetic-F1")).toBe(true); + expect(guard.removeStagedFailureForTests("synthetic-F2")).toBe(true); + expect(guard.peekForeignSubprocessFailureCount()).toBe(0); + }); +}); diff --git a/packages/engine/src/__tests__/project-engine.test.ts b/packages/engine/src/__tests__/project-engine.test.ts index 9e0a48071d..2224f2a10e 100644 --- a/packages/engine/src/__tests__/project-engine.test.ts +++ b/packages/engine/src/__tests__/project-engine.test.ts @@ -132,6 +132,19 @@ vi.mock("node:child_process", async (importOriginal) => { }; }); +/* +FNXC:EngineTests 2026-08-10-09:35: +FN-8937 seals the exec-based integration-branch probe so this ProjectEngine suite +never spawns host git while fake timers exercise workspace dispatch. Resolver data +states remain owned by integration-branch.test.ts; this seam supplies only a stable branch. +*/ +vi.mock("../merge/integration-branch.js", async (importOriginal) => ({ + ...(await importOriginal()), + resolveIntegrationBranch: vi.fn().mockResolvedValue("main"), + resolveIntegrationBranchSync: vi.fn().mockReturnValue("main"), + __resetIntegrationBranchCacheForTests: vi.fn(), +})); + vi.mock("../merge/pr-monitor.js", () => ({ PrMonitor: vi.fn().mockImplementation(function () { return { @@ -2119,7 +2132,12 @@ describe("ProjectEngine paused in-review auto-merge behavior", () => { await engine.start(); - const taskMovedHandler = mockStore.store.on.mock.calls.find((c: unknown[]) => c[0] === "task:moved")?.[1] as + /* + FNXC:EngineTests 2026-08-10-10:34: + FN-8937 must invoke the auto-merge listener, not spec-drift's earlier observer; + the latest registration owns the merge handoff assertions below. + */ + const taskMovedHandler = mockStore.store.on.mock.calls.findLast((c: unknown[]) => c[0] === "task:moved")?.[1] as | ((payload: { task: { id: string; column: string; paused?: boolean }; to: string }) => Promise) | undefined; @@ -2145,7 +2163,7 @@ describe("ProjectEngine paused in-review auto-merge behavior", () => { await engine.start(); enqueueSpy.mockClear(); - const taskUpdatedHandler = mockStore.store.on.mock.calls.find((c: unknown[]) => c[0] === "task:updated")?.[1] as + const taskUpdatedHandler = mockStore.store.on.mock.calls.findLast((c: unknown[]) => c[0] === "task:updated")?.[1] as | ((task: { id: string; column: string; paused?: boolean; status?: string | null }) => Promise) | undefined; if (!taskUpdatedHandler) throw new Error("task:updated handler was not registered"); @@ -2703,7 +2721,7 @@ describe("ProjectEngine paused in-review auto-merge behavior", () => { expect(mocks.runAiMerge).toHaveBeenCalledTimes(1); }); - const taskUpdatedHandler = mockStore.store.on.mock.calls.find((c: unknown[]) => c[0] === "task:updated")?.[1] as + const taskUpdatedHandler = mockStore.store.on.mock.calls.findLast((c: unknown[]) => c[0] === "task:updated")?.[1] as | ((task: { id: string; column: string; paused?: boolean }) => void) | undefined; if (!taskUpdatedHandler) throw new Error("task:updated handler was not registered"); @@ -2731,8 +2749,15 @@ describe("ProjectEngine paused in-review auto-merge behavior", () => { { id: "FN-paused", column: "in-review", paused: true, mergeRetries: 0, status: null }, { id: "FN-ready", column: "in-review", paused: false, mergeRetries: 0, status: null }, ]; - // Critical stale-status cleanup reads first; deferred startup then evaluates eligibility. - mockStore.store.listTasks.mockResolvedValueOnce([]).mockResolvedValueOnce(inReviewTasks); + /* + FNXC:EngineTests 2026-08-10-10:34: + FN-8937 keeps this rescue suite aligned with the startup ownership contract: + spec-drift seeds first, stale-status cleanup reads second, then deferred merge admission reads the candidates. + */ + mockStore.store.listTasks + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce(inReviewTasks); mocks.currentStore = mockStore.store; const engine = createEngine(); const privateEngine = engine as unknown as { internalEnqueueMerge: (taskId: string) => void }; @@ -2768,8 +2793,14 @@ describe("ProjectEngine paused in-review auto-merge behavior", () => { { id: "FN-opted-in", column: "in-review", paused: false, mergeRetries: 0, status: null, autoMerge: true, branchContext: { assignmentMode: "shared", groupId: "BG-5819", source: "planning" } }, { id: "FN-plain", column: "in-review", paused: false, mergeRetries: 0, status: null }, ]; - // Critical stale-status cleanup reads first; deferred startup then evaluates eligibility. - mockStore.store.listTasks.mockResolvedValueOnce([]).mockResolvedValueOnce(inReviewTasks); + /* + FNXC:EngineTests 2026-08-10-10:34: + FN-8937 preserves the three startup readers: spec-drift seed, stale-status cleanup, then deferred merge admission. + */ + mockStore.store.listTasks + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce(inReviewTasks); mocks.currentStore = mockStore.store; const engine = createEngine(); const privateEngine = engine as unknown as { internalEnqueueMerge: (taskId: string) => void }; @@ -2799,7 +2830,7 @@ describe("ProjectEngine paused in-review auto-merge behavior", () => { await engine.start(); enqueueSpy.mockClear(); - const movedHandler = mockStore.store.on.mock.calls.find((c: unknown[]) => c[0] === "task:moved")?.[1] as + const movedHandler = mockStore.store.on.mock.calls.findLast((c: unknown[]) => c[0] === "task:moved")?.[1] as | ((event: { task: Task; to: string }) => void) | undefined; if (!movedHandler) throw new Error("task:moved handler was not registered"); @@ -3247,11 +3278,12 @@ describe("ProjectEngine swallowed error hardening", () => { const engine = createEngine(); await engine.start(); - await vi.waitFor(() => expect(mockStore.store.listTasks).toHaveBeenCalledTimes(2)); + // Spec-drift's initial scan precedes stale-status cleanup and deferred merge admission. + await vi.waitFor(() => expect(mockStore.store.listTasks).toHaveBeenCalledTimes(3)); mockStore.store.getSettings.mockRejectedValueOnce(new Error("db locked")); - const handler = mockStore.store.on.mock.calls.find((c: unknown[]) => c[0] === "task:moved")?.[1] as + const handler = mockStore.store.on.mock.calls.findLast((c: unknown[]) => c[0] === "task:moved")?.[1] as | ((payload: { task: { id: string; column: string }; to: string }) => Promise) | undefined; expect(handler).toBeTypeOf("function"); @@ -3279,8 +3311,9 @@ describe("ProjectEngine swallowed error hardening", () => { it("warns when startup merge sweep fails", async () => { const mockStore = createMockStore({ ...baseSettings, autoMerge: true }); mocks.currentStore = mockStore.store; - // The critical stale-status read precedes the deferred enqueue sweep. + // Spec-drift scans first, stale-status cleanup is second, and deferred admission must fail third. mockStore.store.listTasks + .mockResolvedValueOnce([]) .mockResolvedValueOnce([]) .mockRejectedValueOnce(new Error("connection lost")); @@ -3301,7 +3334,7 @@ describe("ProjectEngine swallowed error hardening", () => { mocks.currentStore = mockStore.store; const engine = createEngine(); await engine.start(); - await vi.waitFor(() => expect(mockStore.store.listTasks).toHaveBeenCalledTimes(2)); + await vi.waitFor(() => expect(mockStore.store.listTasks).toHaveBeenCalledTimes(3)); warnSpy.mockClear(); mockStore.store.listTasks.mockRejectedValueOnce(new Error("sweep db error")); @@ -3319,7 +3352,7 @@ describe("ProjectEngine swallowed error hardening", () => { mocks.currentStore = mockStore.store; const engine = createEngine(); await engine.start(); - await vi.waitFor(() => expect(mockStore.store.listTasks).toHaveBeenCalledTimes(2)); + await vi.waitFor(() => expect(mockStore.store.listTasks).toHaveBeenCalledTimes(3)); warnSpy.mockClear(); mockStore.store.getSettings @@ -3546,7 +3579,7 @@ describe("ProjectEngine stale mergeActive rescue (FN-3900)", () => { }); const warnSpy = vi.spyOn(runtimeLog, "warn").mockImplementation(() => {}); - const taskMovedHandler = mockStore.store.on.mock.calls.find((c: unknown[]) => c[0] === "task:moved")?.[1] as + const taskMovedHandler = mockStore.store.on.mock.calls.findLast((c: unknown[]) => c[0] === "task:moved")?.[1] as | ((payload: { task: { id: string; column: string; paused?: boolean }; to: string }) => Promise) | undefined; if (!taskMovedHandler) throw new Error("task:moved handler was not registered"); @@ -3658,7 +3691,7 @@ describe("ProjectEngine stale mergeActive rescue (FN-3900)", () => { const enqueueSpy = vi.spyOn(privateEngine, "internalEnqueueMerge"); const warnSpy = vi.spyOn(runtimeLog, "warn").mockImplementation(() => {}); - const taskMovedHandler = mockStore.store.on.mock.calls.find((c: unknown[]) => c[0] === "task:moved")?.[1] as + const taskMovedHandler = mockStore.store.on.mock.calls.findLast((c: unknown[]) => c[0] === "task:moved")?.[1] as | ((payload: { task: { id: string; column: string; paused?: boolean }; to: string }) => Promise) | undefined; if (!taskMovedHandler) throw new Error("task:moved handler was not registered"); diff --git a/packages/engine/vitest.config.ts b/packages/engine/vitest.config.ts index 7fdf631d46..0f08aee134 100644 --- a/packages/engine/vitest.config.ts +++ b/packages/engine/vitest.config.ts @@ -250,11 +250,10 @@ export default defineConfig({ "src/__tests__/merger-landed-files-capture.test.ts", "src/__tests__/branch-attribution.test.ts", /* - FNXC:EngineTests 2026-08-06-00:04: - FN-8811 quarantines project-engine.test.ts after its workspace-busy case - contradicted its 60s cap with a real 120s retry and left a timed-out git - subprocess. The paired ledger entry owns its 14-day deletion ratchet; keep - this gate allow-list free of the file until a root-cause fix rescues it. + FNXC:EngineTests 2026-08-10-09:35: + FN-8937 rescued project-engine.test.ts into engine-default by sealing its + git resolver seam and using real guard watchdog timers. It remains outside + engine-core: merge-gate admission requires separate deterministic evidence. */ /* FNXC:EngineTests 2026-07-28-21:05 (#2520 review — greptile P1): @@ -388,9 +387,6 @@ export default defineConfig({ Quarantined on sight per AGENTS.md; mirrored in scripts/lib/test-quarantine.json. */ // SQLite-path gate test evicted + quarantined (see engine-core comment + ledger). - // FNXC:EngineTests 2026-08-06-00:04: paired with the ledger's FN-8811 - // workspace-busy quarantine; do not appease its timing assertion. - "src/__tests__/project-engine.test.ts", "node_modules/**", "dist/**", // FNXC:PgMigrationQuarantine 2026-07-18-04:30: FN-8270 rescued the final seven VAL-REMOVAL-005 holdouts by awaiting PG audit reads and modeling async collaborators. Their paired ledger entries and excludes were removed only after targeted green runs. diff --git a/scripts/lib/test-quarantine.json b/scripts/lib/test-quarantine.json index c00b945342..06ccff151c 100644 --- a/scripts/lib/test-quarantine.json +++ b/scripts/lib/test-quarantine.json @@ -1,10 +1,4 @@ { "$comment": "Flaky-test quarantine ledger (deletion ratchet — see AGENTS.md 'Flaky tests: quarantine on sight' and docs/testing.md 'Quarantine ledger and the deletion ratchet'). A test observed failing without a corresponding real bug is quarantined ON SIGHT: add an entry here AND a matching one-line `exclude` entry in that package's vitest config, in the same commit. Every entry needs a non-empty `reason` (link the failing run) and a `quarantinedAt` date — the entry expires 14 days later, at which point the test file is DELETED unless someone rescues it with evidence it catches real regressions plus a root-cause fix (never appeasement). There is deliberately no loader module and no automation around this file: it is a dated record, the vitest config is the enforcement.", - "entries": [ - { - "file": "packages/engine/src/__tests__/project-engine.test.ts", - "reason": "FN-8811 observed B4/B5 workspace busy contention asserting a stale 60s cap while runtime schedules 120s, then the subprocess guard timed out on git remote; reproduced in isolation on 2026-08-06. Do not widen timing or weaken the assertion; restore only with a root-cause fix before the deletion deadline.", - "quarantinedAt": "2026-08-06" - } - ] + "entries": [] }