import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Task } from "@fusion/core"; import { ProjectEngine } from "../project-engine.js"; import { runtimeLog } from "../logger.js"; import { TunnelProcessManager } from "../remote-access/tunnel-process-manager.js"; import { NtfyNotifier } from "../notifier.js"; import { NotificationService, OAuthExpiryMonitor, OAuthValidityLogger } from "../notification/index.js"; const mocks = vi.hoisted(() => ({ syncInsightExtractionAutomation: vi.fn(), syncAutoSummarizeAutomation: vi.fn(), syncMemoryDreamsAutomation: vi.fn(), syncScheduledEvalBatchAutomation: vi.fn(), automationStoreInit: vi.fn(async () => undefined), createAiPromptExecutor: vi.fn(async () => vi.fn()), cronRunnerStart: vi.fn(), cronRunnerStop: vi.fn(), runtimeStart: vi.fn(async () => undefined), runtimeStop: vi.fn(async () => undefined), runtimeResumeAfterUnpause: vi.fn(async () => undefined), aiMergeTask: vi.fn(), execFile: vi.fn(), currentStore: null as Record | null, notifierStart: vi.fn(async () => undefined), notifierStop: vi.fn(), notifierNotifyGridlock: vi.fn(), notificationServiceStart: vi.fn(async () => undefined), notificationServiceStop: vi.fn(), oauthExpiryMonitorStart: vi.fn(async () => undefined), oauthExpiryMonitorStop: vi.fn(), oauthValidityLoggerStart: vi.fn(async () => undefined), oauthValidityLoggerStop: vi.fn(), runtimeConfigurePrMonitoring: vi.fn(), prHandlerCreateFollowUpTask: vi.fn(async () => undefined), })); vi.mock("@fusion/core", async (importOriginal) => { const { createEngineCoreMock } = await import("../test/mockCore.js"); return createEngineCoreMock(() => importOriginal(), { AutomationStore: class MockAutomationStore { constructor(_cwd: string) {} init = mocks.automationStoreInit; }, syncInsightExtractionAutomation: mocks.syncInsightExtractionAutomation, syncAutoSummarizeAutomation: mocks.syncAutoSummarizeAutomation, syncMemoryDreamsAutomation: mocks.syncMemoryDreamsAutomation, syncScheduledEvalBatchAutomation: mocks.syncScheduledEvalBatchAutomation, }); }); vi.mock("../cron-runner.js", () => { return { CronRunner: vi.fn().mockImplementation(() => ({ start: mocks.cronRunnerStart, stop: mocks.cronRunnerStop, })), createAiPromptExecutor: mocks.createAiPromptExecutor, }; }); vi.mock("../merger.js", () => ({ aiMergeTask: mocks.aiMergeTask, })); vi.mock("node:child_process", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, execFile: mocks.execFile, }; }); vi.mock("../pr-monitor.js", () => ({ PrMonitor: vi.fn().mockImplementation(() => ({ onNewComments: vi.fn(), })), })); vi.mock("../pr-comment-handler.js", () => ({ PrCommentHandler: vi.fn().mockImplementation(() => ({ handleNewComments: vi.fn(), createFollowUpTask: mocks.prHandlerCreateFollowUpTask, })), })); vi.mock("../notifier.js", () => ({ NtfyNotifier: vi.fn().mockImplementation(() => ({ start: mocks.notifierStart, stop: mocks.notifierStop, notifyGridlock: mocks.notifierNotifyGridlock, })), })); vi.mock("../notification/index.js", () => ({ NotificationService: vi.fn().mockImplementation(() => ({ start: mocks.notificationServiceStart, stop: mocks.notificationServiceStop, })), OAuthExpiryMonitor: vi.fn().mockImplementation(() => ({ start: mocks.oauthExpiryMonitorStart, stop: mocks.oauthExpiryMonitorStop, })), OAuthValidityLogger: vi.fn().mockImplementation(() => ({ start: mocks.oauthValidityLoggerStart, stop: mocks.oauthValidityLoggerStop, })), })); vi.mock("../auth-storage.js", () => ({ createFusionAuthStorage: vi.fn(() => ({ reload: vi.fn(), getOAuthProviders: vi.fn(() => []), get: vi.fn(() => undefined), })), })); vi.mock("../runtimes/in-process-runtime.js", () => ({ InProcessRuntime: vi.fn().mockImplementation(() => ({ start: mocks.runtimeStart, stop: mocks.runtimeStop, resumeAfterUnpause: mocks.runtimeResumeAfterUnpause, getTaskStore: () => mocks.currentStore, getAgentStore: vi.fn(), getMessageStore: vi.fn(), getRoutineStore: vi.fn(), getRoutineRunner: vi.fn(), getHeartbeatMonitor: vi.fn(), getTriggerScheduler: vi.fn(), configurePrMonitoring: mocks.runtimeConfigurePrMonitoring, })), })); type SettingsHandlerPayload = { settings: Record; previous: Record; }; function createMockStore(initialSettings: Record) { let settings = structuredClone(initialSettings); const settingsHandlers = new Set<(payload: SettingsHandlerPayload) => void | Promise>(); const store = { getSettings: vi.fn(async () => structuredClone(settings)), listTasks: vi.fn(async (): Promise>> => []), getTask: vi.fn(async (taskId: string): Promise> => ({ id: taskId, column: "in-review", paused: false, mergeRetries: 0, status: null, })), updateTask: vi.fn(async () => undefined), moveTask: vi.fn(async () => undefined), updateSettings: vi.fn(async (patch: Record) => { settings = { ...settings, ...patch, }; return structuredClone(settings); }), logEntry: vi.fn(async () => undefined), emit: vi.fn(), addTaskComment: vi.fn(async () => undefined), getActiveMergingTask: vi.fn(() => null), on: vi.fn((event: string, handler: (...args: unknown[]) => void | Promise) => { if (event === "settings:updated") { settingsHandlers.add(handler as (payload: SettingsHandlerPayload) => void | Promise); } }), off: vi.fn((event: string, handler: (...args: unknown[]) => void | Promise) => { if (event === "settings:updated") { settingsHandlers.delete( handler as (payload: SettingsHandlerPayload) => void | Promise, ); } }), }; const emitSettingsUpdated = async ( next: Record, previous: Record, ) => { settings = structuredClone(next); for (const handler of settingsHandlers) { await handler({ settings: structuredClone(next), previous: structuredClone(previous) }); } }; const getCurrentSettings = () => structuredClone(settings); return { store, emitSettingsUpdated, getCurrentSettings }; } const baseRemoteAccess = { enabled: true, activeProvider: "cloudflare" as const, providers: { tailscale: { enabled: true, hostname: "tail.example.ts.net", targetPort: 4040, acceptRoutes: false, }, cloudflare: { enabled: true, quickTunnel: false, tunnelName: "demo", tunnelToken: "cf-secret-token", ingressUrl: "https://remote.example.com", }, }, tokenStrategy: { persistent: { enabled: true, token: "frt_persistent", }, shortLived: { enabled: true, ttlMs: 120_000, maxTtlMs: 86_400_000, }, }, lifecycle: { rememberLastRunning: true, wasRunningOnShutdown: false, lastRunningProvider: null, }, }; const baseSettings: Record = { autoMerge: false, globalPause: false, enginePaused: false, pollIntervalMs: 15_000, // onMerge tests mock + assert aiMergeTask (legacy path); pin legacy mode. merger: { mode: "deterministic" }, taskStuckTimeoutMs: undefined, memoryAutoSummarizeEnabled: false, memoryAutoSummarizeThresholdChars: 50_000, memoryAutoSummarizeSchedule: "0 3 * * *", memoryDreamsEnabled: false, memoryDreamsSchedule: "0 4 * * *", insightExtractionEnabled: false, insightExtractionSchedule: "0 3 * * *", insightExtractionMinIntervalMs: 0, remoteAccess: baseRemoteAccess, }; function createEngine(options?: ConstructorParameters[2]) { return new ProjectEngine( { projectId: "proj_test", workingDirectory: "/tmp/proj_test", isolationMode: "in-process", maxConcurrent: 2, maxWorktrees: 2, }, {} as never, { skipNotifier: true, ...options }, ); } beforeEach(() => { mocks.runtimeResumeAfterUnpause.mockClear(); mocks.notifierStart.mockClear(); mocks.notifierStop.mockClear(); mocks.notifierNotifyGridlock.mockClear(); mocks.notificationServiceStart.mockClear(); mocks.notificationServiceStop.mockClear(); mocks.oauthExpiryMonitorStart.mockClear(); mocks.oauthExpiryMonitorStop.mockClear(); mocks.oauthValidityLoggerStart.mockClear(); mocks.oauthValidityLoggerStop.mockClear(); mocks.execFile.mockImplementation(( _file: string, _args: string[], _options: unknown, callback?: (error: Error | null, result: { stdout: string; stderr: string }) => void, ) => { if (typeof _options === "function") { (_options as (error: Error | null, result: { stdout: string; stderr: string }) => void)(null, { stdout: "/usr/bin/mock\n", stderr: "", }); return {} as never; } callback?.(null, { stdout: "/usr/bin/mock\n", stderr: "" }); return {} as never; }); }); describe("ProjectEngine notification ownership wiring", () => { beforeEach(() => { vi.clearAllMocks(); const mockStore = createMockStore(baseSettings); mocks.currentStore = mockStore.store; }); it("constructs NtfyNotifier with the same NotificationService instance and starts canonical listeners once", async () => { const engine = createEngine({ skipNotifier: false, projectId: "proj_for_notifier" }); await engine.start(); expect(NotificationService).toHaveBeenCalledTimes(1); expect(OAuthExpiryMonitor).toHaveBeenCalledTimes(1); expect(NtfyNotifier).toHaveBeenCalledTimes(1); const notifierCtorArgs = vi.mocked(NtfyNotifier).mock.calls[0]; expect(notifierCtorArgs?.[2]).toBe(vi.mocked(NotificationService).mock.results[0]?.value); expect(mocks.notificationServiceStart).toHaveBeenCalledTimes(1); expect(mocks.oauthExpiryMonitorStart).toHaveBeenCalledTimes(1); expect(mocks.notifierStart).toHaveBeenCalledTimes(1); await engine.stop(); expect(mocks.oauthExpiryMonitorStop).toHaveBeenCalledTimes(1); expect(mocks.notificationServiceStop).toHaveBeenCalledTimes(1); expect(mocks.notifierStop).toHaveBeenCalledTimes(1); }); it("does not recreate notification listeners on repeated start calls, preventing duplicate merged delivery", async () => { const engine = createEngine({ skipNotifier: false, projectId: "proj_for_notifier" }); await engine.start(); await engine.start(); // Root cause guard: if ProjectEngine.start is called more than once, it should not // wire a second NotificationService/NtfyNotifier pair for the same store. expect(NotificationService).toHaveBeenCalledTimes(1); expect(OAuthExpiryMonitor).toHaveBeenCalledTimes(1); expect(NtfyNotifier).toHaveBeenCalledTimes(1); expect(mocks.notificationServiceStart).toHaveBeenCalledTimes(1); expect(mocks.oauthExpiryMonitorStart).toHaveBeenCalledTimes(1); expect(mocks.notifierStart).toHaveBeenCalledTimes(1); await engine.stop(); }); it("does not create OAuth expiry monitor when notifier is skipped", async () => { const engine = createEngine({ skipNotifier: true, projectId: "proj_for_notifier" }); await engine.start(); expect(NotificationService).not.toHaveBeenCalled(); expect(OAuthExpiryMonitor).not.toHaveBeenCalled(); expect(NtfyNotifier).not.toHaveBeenCalled(); await engine.stop(); }); }); describe("ProjectEngine accessors", () => { it("returns configured project id", () => { const engine = new ProjectEngine( { projectId: "proj_accessor", workingDirectory: "/tmp/proj_accessor", isolationMode: "in-process", maxConcurrent: 2, maxWorktrees: 2, }, {} as never, { skipNotifier: true }, ); expect(engine.getProjectId()).toBe("proj_accessor"); }); }); describe("ProjectEngine PR monitoring wiring", () => { it("wires runtime scheduler PR monitoring with closed-PR follow-up handler", async () => { const { store } = createMockStore(baseSettings); mocks.currentStore = store; const engine = createEngine(); await engine.start(); expect(mocks.runtimeConfigurePrMonitoring).toHaveBeenCalled(); const configArg = mocks.runtimeConfigurePrMonitoring.mock.calls.at(-1)?.[0] as { onClosedPrFeedback?: (taskId: string, prInfo: Record, comments: unknown[]) => Promise | void; }; expect(typeof configArg.onClosedPrFeedback).toBe("function"); await configArg.onClosedPrFeedback?.( "FN-3202", { number: 12, status: "merged", url: "https://example/pr/12" } as never, [{ id: 1, body: "please fix", user: { login: "reviewer" } }] as never, ); expect(mocks.prHandlerCreateFollowUpTask).toHaveBeenCalledWith( "FN-3202", expect.objectContaining({ number: 12 }), expect.any(Array), ); await engine.stop(); }); }); describe("ProjectEngine auto-summarize wiring", () => { beforeEach(() => { vi.clearAllMocks(); const mockStore = createMockStore(baseSettings); mocks.currentStore = mockStore.store; mocks.aiMergeTask.mockResolvedValue({ task: { id: "FN-001", column: "done" }, branch: "fusion/fn-001", merged: true, worktreeRemoved: false, branchDeleted: true, }); }); it("syncs startup memory automations using one settings snapshot", async () => { const engine = createEngine(); await engine.start(); expect(mocks.syncInsightExtractionAutomation).toHaveBeenCalledTimes(1); expect(mocks.syncAutoSummarizeAutomation).toHaveBeenCalledTimes(1); expect(mocks.syncMemoryDreamsAutomation).toHaveBeenCalledTimes(1); expect(mocks.syncScheduledEvalBatchAutomation).toHaveBeenCalledTimes(1); const insightSettings = mocks.syncInsightExtractionAutomation.mock.calls[0][1]; const autoSummarizeSettings = mocks.syncAutoSummarizeAutomation.mock.calls[0][1]; const memoryDreamsSettings = mocks.syncMemoryDreamsAutomation.mock.calls[0][1]; const scheduledEvalSettings = mocks.syncScheduledEvalBatchAutomation.mock.calls[0][1]; expect(autoSummarizeSettings).toBe(insightSettings); expect(memoryDreamsSettings).toBe(insightSettings); expect(scheduledEvalSettings).toBe(insightSettings); const cronRunnerStartOrder = mocks.cronRunnerStart.mock.invocationCallOrder[0]; expect(mocks.syncInsightExtractionAutomation.mock.invocationCallOrder[0]).toBeLessThan( cronRunnerStartOrder, ); expect(mocks.syncAutoSummarizeAutomation.mock.invocationCallOrder[0]).toBeLessThan( cronRunnerStartOrder, ); expect(mocks.syncMemoryDreamsAutomation.mock.invocationCallOrder[0]).toBeLessThan( cronRunnerStartOrder, ); expect(mocks.syncScheduledEvalBatchAutomation.mock.invocationCallOrder[0]).toBeLessThan( cronRunnerStartOrder, ); await engine.stop(); }); it("re-syncs auto-summarize automation only when related settings change", async () => { const mockStore = createMockStore(baseSettings); mocks.currentStore = mockStore.store; const engine = createEngine(); await engine.start(); mocks.syncAutoSummarizeAutomation.mockClear(); const previous = { ...baseSettings }; const nextEnabled = { ...previous, memoryAutoSummarizeEnabled: true, }; await mockStore.emitSettingsUpdated(nextEnabled, previous); expect(mocks.syncAutoSummarizeAutomation).toHaveBeenCalledTimes(1); const unrelatedChange = { ...nextEnabled, pollIntervalMs: 30_000, }; await mockStore.emitSettingsUpdated(unrelatedChange, nextEnabled); expect(mocks.syncAutoSummarizeAutomation).toHaveBeenCalledTimes(1); const disabled = { ...unrelatedChange, memoryAutoSummarizeEnabled: false, }; await mockStore.emitSettingsUpdated(disabled, unrelatedChange); expect(mocks.syncAutoSummarizeAutomation).toHaveBeenCalledTimes(2); await engine.stop(); }); }); describe("ProjectEngine memory dreams wiring", () => { beforeEach(() => { vi.clearAllMocks(); const mockStore = createMockStore(baseSettings); mocks.currentStore = mockStore.store; }); it("starts cron after memory dreams startup sync", async () => { const engine = createEngine(); await engine.start(); const cronRunnerStartOrder = mocks.cronRunnerStart.mock.invocationCallOrder[0]; expect(mocks.syncMemoryDreamsAutomation).toHaveBeenCalledTimes(1); expect(mocks.syncMemoryDreamsAutomation.mock.invocationCallOrder[0]).toBeLessThan( cronRunnerStartOrder, ); await engine.stop(); }); it("re-syncs memory dreams automation when memoryDreamsEnabled changes", async () => { const mockStore = createMockStore(baseSettings); mocks.currentStore = mockStore.store; const engine = createEngine(); await engine.start(); mocks.syncMemoryDreamsAutomation.mockClear(); const previous = { ...baseSettings }; const next = { ...previous, memoryDreamsEnabled: true, }; await mockStore.emitSettingsUpdated(next, previous); expect(mocks.syncMemoryDreamsAutomation).toHaveBeenCalledTimes(1); expect(mocks.syncMemoryDreamsAutomation).toHaveBeenCalledWith(expect.anything(), next); await engine.stop(); }); it("re-syncs memory dreams automation when memoryDreamsSchedule changes", async () => { const mockStore = createMockStore(baseSettings); mocks.currentStore = mockStore.store; const engine = createEngine(); await engine.start(); mocks.syncMemoryDreamsAutomation.mockClear(); const previous = { ...baseSettings }; const next = { ...previous, memoryDreamsSchedule: "0 */8 * * *", }; await mockStore.emitSettingsUpdated(next, previous); expect(mocks.syncMemoryDreamsAutomation).toHaveBeenCalledTimes(1); await engine.stop(); }); it("does not re-sync memory dreams automation on unrelated settings changes", async () => { const mockStore = createMockStore(baseSettings); mocks.currentStore = mockStore.store; const engine = createEngine(); await engine.start(); mocks.syncMemoryDreamsAutomation.mockClear(); const previous = { ...baseSettings }; const next = { ...previous, pollIntervalMs: 30_000, }; await mockStore.emitSettingsUpdated(next, previous); expect(mocks.syncMemoryDreamsAutomation).not.toHaveBeenCalled(); await engine.stop(); }); it("logs warning and continues startup when memory dreams startup sync fails", async () => { const warnSpy = vi.spyOn(runtimeLog, "warn").mockImplementation(() => {}); mocks.syncMemoryDreamsAutomation.mockRejectedValueOnce(new Error("dream startup sync failed")); const engine = createEngine(); await expect(engine.start()).resolves.toBeUndefined(); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining("Memory dreams automation startup sync failed"), ); expect(engine.getAutomationSubsystemHealth()).toMatchObject({ status: "degraded", }); expect(engine.getCronRunner()).toBeDefined(); await engine.stop(); warnSpy.mockRestore(); }); it("catches and logs memory dreams sync failures on settings changes", async () => { const warnSpy = vi.spyOn(runtimeLog, "warn").mockImplementation(() => {}); const mockStore = createMockStore(baseSettings); mocks.currentStore = mockStore.store; const engine = createEngine(); await engine.start(); warnSpy.mockClear(); mocks.syncMemoryDreamsAutomation.mockRejectedValueOnce(new Error("dream settings sync failed")); await expect( mockStore.emitSettingsUpdated( { ...baseSettings, memoryDreamsEnabled: true, }, { ...baseSettings, memoryDreamsEnabled: false, }, ), ).resolves.toBeUndefined(); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining("Failed to sync memory maintenance automation"), ); await engine.stop(); warnSpy.mockRestore(); }); }); describe("ProjectEngine remote tunnel manager wiring", () => { beforeEach(() => { vi.clearAllMocks(); const mockStore = createMockStore(baseSettings); mocks.currentStore = mockStore.store; }); it("is unavailable before start and available after start", async () => { const engine = createEngine(); expect(engine.getRemoteTunnelManager()).toBeUndefined(); await engine.start(); expect(engine.getRemoteTunnelManager()).toBeInstanceOf(TunnelProcessManager); await engine.stop(); expect(engine.getRemoteTunnelManager()).toBeUndefined(); }); it("calls tunnel manager stop once during shutdown", async () => { const stopSpy = vi.spyOn(TunnelProcessManager.prototype, "stop").mockResolvedValueOnce(undefined); const engine = createEngine(); await engine.start(); await engine.stop(); expect(stopSpy).toHaveBeenCalledTimes(1); stopSpy.mockRestore(); }); it("warns when tunnel manager shutdown fails and clears manager reference", async () => { const warnSpy = vi.spyOn(runtimeLog, "warn").mockImplementation(() => {}); const stopSpy = vi.spyOn(TunnelProcessManager.prototype, "stop").mockRejectedValueOnce(new Error("tunnel stop failed")); const engine = createEngine(); await engine.start(); await engine.stop(); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining("Tunnel process manager stop failed"), ); expect(engine.getRemoteTunnelManager()).toBeUndefined(); stopSpy.mockRestore(); warnSpy.mockRestore(); }); }); describe("ProjectEngine remote lifecycle restore policy", () => { beforeEach(() => { vi.clearAllMocks(); const mockStore = createMockStore(baseSettings); mocks.currentStore = mockStore.store; }); it("attempts restore on startup when rememberLastRunning and prior-running markers are set", async () => { const restoreSettings = { ...baseSettings, remoteAccess: { ...baseRemoteAccess, lifecycle: { ...baseRemoteAccess.lifecycle, rememberLastRunning: true, wasRunningOnShutdown: true, lastRunningProvider: "cloudflare" as const, }, }, }; const mockStore = createMockStore(restoreSettings); mocks.currentStore = mockStore.store; const startSpy = vi.spyOn(TunnelProcessManager.prototype, "start").mockResolvedValue(undefined); const engine = createEngine(); await engine.start(); expect(startSpy).toHaveBeenCalledTimes(1); expect(startSpy.mock.calls[0]?.[0]).toBe("cloudflare"); expect(engine.getRemoteTunnelRestoreDiagnostics()).toMatchObject({ outcome: "applied", reason: "restore_started", provider: "cloudflare", }); await engine.stop(); startSpy.mockRestore(); }); it("skips restore when rememberLastRunning is disabled", async () => { const restoreSettings = { ...baseSettings, remoteAccess: { ...baseRemoteAccess, lifecycle: { ...baseRemoteAccess.lifecycle, rememberLastRunning: false, wasRunningOnShutdown: true, lastRunningProvider: "cloudflare" as const, }, }, }; const mockStore = createMockStore(restoreSettings); mocks.currentStore = mockStore.store; const startSpy = vi.spyOn(TunnelProcessManager.prototype, "start").mockResolvedValue(undefined); const engine = createEngine(); await engine.start(); expect(startSpy).not.toHaveBeenCalled(); expect(engine.getRemoteTunnelRestoreDiagnostics()).toMatchObject({ outcome: "skipped", reason: "remember_last_running_disabled", provider: null, }); expect(mockStore.store.updateSettings).toHaveBeenCalledWith(expect.objectContaining({ remoteAccess: expect.objectContaining({ lifecycle: expect.objectContaining({ wasRunningOnShutdown: false, lastRunningProvider: null, }), }), })); await engine.stop(); startSpy.mockRestore(); }); it("skips restore with explicit reason and clears stale marker when prerequisites are missing", async () => { const restoreSettings = { ...baseSettings, remoteAccess: { ...baseRemoteAccess, providers: { ...baseRemoteAccess.providers, cloudflare: { ...baseRemoteAccess.providers.cloudflare, tunnelToken: null, }, }, lifecycle: { ...baseRemoteAccess.lifecycle, rememberLastRunning: true, wasRunningOnShutdown: true, lastRunningProvider: "cloudflare" as const, }, }, }; const mockStore = createMockStore(restoreSettings); mocks.currentStore = mockStore.store; const startSpy = vi.spyOn(TunnelProcessManager.prototype, "start").mockResolvedValue(undefined); const engine = createEngine(); await engine.start(); expect(startSpy).not.toHaveBeenCalled(); expect(engine.getRemoteTunnelRestoreDiagnostics()).toMatchObject({ outcome: "skipped", reason: "provider_not_configured", provider: "cloudflare", }); expect(mockStore.store.updateSettings).toHaveBeenCalledWith(expect.objectContaining({ remoteAccess: expect.objectContaining({ lifecycle: expect.objectContaining({ wasRunningOnShutdown: false, lastRunningProvider: null, }), }), })); await engine.stop(); startSpy.mockRestore(); }); it("persists shutdown lifecycle markers and deterministically restores on next engine start", async () => { const restoreSettings = { ...baseSettings, remoteAccess: { ...baseRemoteAccess, activeProvider: "cloudflare" as const, lifecycle: { ...baseRemoteAccess.lifecycle, rememberLastRunning: true, wasRunningOnShutdown: false, lastRunningProvider: null, }, }, }; const mockStore = createMockStore(restoreSettings); mocks.currentStore = mockStore.store; const startSpy = vi.spyOn(TunnelProcessManager.prototype, "start").mockResolvedValue(undefined); const stopSpy = vi.spyOn(TunnelProcessManager.prototype, "stop").mockResolvedValue(undefined); const getStatusSpy = vi.spyOn(TunnelProcessManager.prototype, "getStatus") .mockReturnValueOnce({ provider: "cloudflare", state: "running", pid: 4321, startedAt: "2026-04-26T12:00:00.000Z", stoppedAt: null, url: "https://remote.example.com", lastError: null, }) .mockReturnValue({ provider: null, state: "stopped", pid: null, startedAt: null, stoppedAt: "2026-04-26T12:05:00.000Z", url: null, lastError: null, }); const firstEngine = createEngine(); await firstEngine.start(); await firstEngine.stop(); const persistedSettings = mockStore.getCurrentSettings() as { remoteAccess?: { lifecycle?: { wasRunningOnShutdown?: boolean; lastRunningProvider?: string | null } }; }; expect(persistedSettings.remoteAccess?.lifecycle).toMatchObject({ wasRunningOnShutdown: true, lastRunningProvider: "cloudflare", }); const secondEngine = createEngine(); await secondEngine.start(); expect(startSpy).toHaveBeenCalled(); expect(secondEngine.getRemoteTunnelRestoreDiagnostics()).toMatchObject({ outcome: "applied", reason: "restore_started", provider: "cloudflare", }); await secondEngine.stop(); expect(stopSpy).toHaveBeenCalled(); startSpy.mockRestore(); stopSpy.mockRestore(); getStatusSpy.mockRestore(); }); it("reconciles stale persisted running marker to avoid restore loops", async () => { const restoreSettings = { ...baseSettings, remoteAccess: { ...baseRemoteAccess, providers: { ...baseRemoteAccess.providers, cloudflare: { ...baseRemoteAccess.providers.cloudflare, tunnelToken: null, }, }, lifecycle: { ...baseRemoteAccess.lifecycle, rememberLastRunning: true, wasRunningOnShutdown: true, lastRunningProvider: "cloudflare" as const, }, }, }; const mockStore = createMockStore(restoreSettings); mocks.currentStore = mockStore.store; const startSpy = vi.spyOn(TunnelProcessManager.prototype, "start").mockResolvedValue(undefined); const firstEngine = createEngine(); await firstEngine.start(); await firstEngine.stop(); const secondEngine = createEngine(); await secondEngine.start(); await secondEngine.stop(); expect(startSpy).not.toHaveBeenCalled(); expect(secondEngine.getRemoteTunnelRestoreDiagnostics()).toMatchObject({ outcome: "skipped", reason: "no_prior_running_marker", }); startSpy.mockRestore(); }); it("does not auto-start on settings updates and manual stop clears future restore intent", async () => { const mockStore = createMockStore(baseSettings); mocks.currentStore = mockStore.store; const startSpy = vi.spyOn(TunnelProcessManager.prototype, "start").mockResolvedValue(undefined); const stopSpy = vi.spyOn(TunnelProcessManager.prototype, "stop").mockResolvedValue(undefined); const statusSpy = vi.spyOn(TunnelProcessManager.prototype, "getStatus").mockReturnValue({ provider: null, state: "stopped", pid: null, startedAt: null, stoppedAt: null, url: null, lastError: null, }); const engine = createEngine(); await engine.start(); await mockStore.emitSettingsUpdated( { ...baseSettings, remoteAccess: { ...baseRemoteAccess, activeProvider: "tailscale" as const, }, }, baseSettings, ); const startsBeforeManualAction = startSpy.mock.calls.length; await engine.startRemoteTunnel(); await engine.stopRemoteTunnel(); expect(startSpy.mock.calls.length).toBe(startsBeforeManualAction + 1); expect(stopSpy).toHaveBeenCalled(); expect(mockStore.store.updateSettings).toHaveBeenCalledWith(expect.objectContaining({ remoteAccess: expect.objectContaining({ lifecycle: expect.objectContaining({ wasRunningOnShutdown: false, lastRunningProvider: null, }), }), })); await engine.stop(); startSpy.mockRestore(); stopSpy.mockRestore(); statusSpy.mockRestore(); }); }); describe("ProjectEngine remote lifecycle quick tunnel mode", () => { it("starts cloudflare quick tunnel without manual tunnel fields", async () => { const quickTunnelSettings = { ...baseSettings, remoteAccess: { ...baseRemoteAccess, providers: { ...baseRemoteAccess.providers, cloudflare: { ...baseRemoteAccess.providers.cloudflare, quickTunnel: true, tunnelName: "", tunnelToken: null, ingressUrl: "", }, }, }, }; const mockStore = createMockStore(quickTunnelSettings); mocks.currentStore = mockStore.store; const startSpy = vi.spyOn(TunnelProcessManager.prototype, "start").mockResolvedValue(undefined); const engine = createEngine(); await engine.start(); await engine.startRemoteTunnel(); expect(startSpy).toHaveBeenCalledWith( "cloudflare", expect.objectContaining({ provider: "cloudflare", quickTunnel: true, executablePath: "cloudflared", args: ["tunnel", "--url", "http://localhost:4040"], }), ); await engine.stop(); startSpy.mockRestore(); }); it("surfaces runtime prerequisite missing when cloudflared is unavailable in quick tunnel mode", async () => { mocks.execFile.mockImplementation(( _file: string, _args: string[], _options: unknown, callback?: (error: Error | null, result: { stdout: string; stderr: string }) => void, ) => { const err = new Error("cloudflared not found"); if (typeof _options === "function") { (_options as (error: Error, result: { stdout: string; stderr: string }) => void)(err, { stdout: "", stderr: "", }); return {} as never; } callback?.(err, { stdout: "", stderr: "" }); return {} as never; }); const quickTunnelSettings = { ...baseSettings, remoteAccess: { ...baseRemoteAccess, providers: { ...baseRemoteAccess.providers, cloudflare: { ...baseRemoteAccess.providers.cloudflare, quickTunnel: true, tunnelName: "", tunnelToken: null, ingressUrl: "", }, }, }, }; const mockStore = createMockStore(quickTunnelSettings); mocks.currentStore = mockStore.store; const engine = createEngine(); await engine.start(); await expect(engine.startRemoteTunnel()).rejects.toThrow( "runtime_prerequisite_missing:cloudflared is not available on PATH", ); await engine.stop(); }); it("keeps manual cloudflare validation unchanged when quick tunnel is disabled", async () => { const manualSettings = { ...baseSettings, remoteAccess: { ...baseRemoteAccess, providers: { ...baseRemoteAccess.providers, cloudflare: { ...baseRemoteAccess.providers.cloudflare, quickTunnel: false, tunnelToken: null, }, }, }, }; const mockStore = createMockStore(manualSettings); mocks.currentStore = mockStore.store; const engine = createEngine(); await engine.start(); await expect(engine.startRemoteTunnel()).rejects.toThrow( "provider_not_configured:Cloudflare tunnel token is required", ); await engine.stop(); }); }); describe("ProjectEngine shutdown merge handling", () => { beforeEach(() => { vi.clearAllMocks(); const mockStore = createMockStore({ ...baseSettings, autoMerge: true }); mocks.currentStore = mockStore.store; }); it("aborts active merges, clears pending queue, and blocks new merges after stop", async () => { const engine = createEngine(); await engine.start(); const privateEngine = engine as unknown as { mergeQueue: string[]; mergeActive: Set; mergeAbortController: AbortController | null; mergeRetryTimer: ReturnType | null; activeMergeSession: { dispose: () => void } | null; }; let capturedSignal: AbortSignal | undefined; mocks.aiMergeTask.mockImplementationOnce(async (...args: unknown[]) => { const options = args[3] as { signal?: AbortSignal } | undefined; capturedSignal = options?.signal; await new Promise((_, reject) => { options?.signal?.addEventListener("abort", () => { const abortError = new Error("merge aborted"); abortError.name = "MergeAbortedError"; reject(abortError); }, { once: true }); }); }); const manualMergePromise = engine.onMerge("FN-123"); engine.enqueueMerge("FN-queued"); await vi.waitFor(() => { expect(mocks.aiMergeTask).toHaveBeenCalledTimes(1); }); expect(capturedSignal?.aborted).toBe(false); await engine.stop(); await expect(manualMergePromise).rejects.toThrow("Engine shutting down"); expect(capturedSignal?.aborted).toBe(true); expect(privateEngine.mergeQueue).toHaveLength(0); expect(privateEngine.mergeRetryTimer).toBeNull(); expect(privateEngine.activeMergeSession).toBeNull(); await vi.waitFor(() => { expect(privateEngine.mergeActive.has("FN-123")).toBe(false); }); expect(privateEngine.mergeAbortController).toBeNull(); const mergeCallsBeforeRequeue = mocks.aiMergeTask.mock.calls.length; engine.enqueueMerge("FN-after-stop"); expect(privateEngine.mergeQueue).toHaveLength(0); expect(mocks.aiMergeTask).toHaveBeenCalledTimes(mergeCallsBeforeRequeue); }); }); describe("ProjectEngine manual merge plumbing", () => { beforeEach(() => { vi.clearAllMocks(); const mockStore = createMockStore({ ...baseSettings, autoMerge: true }); mockStore.store.getTask.mockResolvedValue({ id: "FN-5438", column: "in-review", paused: false, mergeRetries: 0, status: "queued", } as any); mocks.currentStore = mockStore.store; }); it("passes manual=true to aiMergeTask for onMerge requests", async () => { mocks.aiMergeTask.mockResolvedValue({ merged: true, task: { id: "FN-5438" } } as any); const engine = createEngine(); await engine.start(); await engine.onMerge("FN-5438"); expect(mocks.aiMergeTask).toHaveBeenCalledWith( expect.anything(), expect.any(String), "FN-5438", expect.objectContaining({ manual: true }), ); await engine.stop(); }); }); describe("ProjectEngine merge queue priority ordering", () => { beforeEach(() => { vi.clearAllMocks(); }); it("merges higher-priority tasks before lower-priority ones regardless of enqueue order", async () => { const mockStore = createMockStore({ ...baseSettings, autoMerge: true }); const tasksById: Record> = { "FN-low": { id: "FN-low", column: "in-review", paused: false, mergeRetries: 0, status: null, priority: "low", createdAt: "2026-04-01T00:00:00.000Z", }, "FN-urgent": { id: "FN-urgent", column: "in-review", paused: false, mergeRetries: 0, status: null, priority: "urgent", createdAt: "2026-04-02T00:00:00.000Z", }, "FN-normal": { id: "FN-normal", column: "in-review", paused: false, mergeRetries: 0, status: null, priority: "normal", createdAt: "2026-04-03T00:00:00.000Z", }, }; mockStore.store.getTask.mockImplementation(async (id: string) => tasksById[id] ?? null); mocks.currentStore = mockStore.store; const mergeOrder: string[] = []; mocks.aiMergeTask.mockImplementation(async (...args: unknown[]) => { mergeOrder.push(args[2] as string); return { merged: true } as never; }); const engine = createEngine(); await engine.start(); // Enqueue lowest priority first, urgent last. Priority-aware dequeue must // still surface FN-urgent before FN-normal regardless of enqueue order. engine.enqueueMerge("FN-low"); engine.enqueueMerge("FN-normal"); engine.enqueueMerge("FN-urgent"); await vi.waitFor(() => { expect(mergeOrder).toHaveLength(3); }); // FN-low may merge first if drainMergeQueue picked it up before the other // enqueues landed (single-item fast path). The contract is that once 2+ // tasks are queued together, the higher-priority one wins — so FN-urgent // (enqueued last) must merge before FN-normal (enqueued before it). const urgentIdx = mergeOrder.indexOf("FN-urgent"); const normalIdx = mergeOrder.indexOf("FN-normal"); expect(urgentIdx).toBeGreaterThanOrEqual(0); expect(normalIdx).toBeGreaterThanOrEqual(0); expect(urgentIdx).toBeLessThan(normalIdx); await engine.stop(); }); it("startup sweep merges higher-priority tasks first even though listTasks returns oldest-first", async () => { const mockStore = createMockStore({ ...baseSettings, autoMerge: true }); // Tasks returned in createdAt ASC order (matches store.listTasks contract). // Priority order is interleaved so a naive iteration would merge FN-low // first; priority-aware sorting must reorder to urgent → normal → low. const sweptTasks = [ { id: "FN-low", column: "in-review", paused: false, mergeRetries: 0, status: null, priority: "low", createdAt: "2026-04-01T00:00:00.000Z", }, { id: "FN-urgent", column: "in-review", paused: false, mergeRetries: 0, status: null, priority: "urgent", createdAt: "2026-04-02T00:00:00.000Z", }, { id: "FN-normal", column: "in-review", paused: false, mergeRetries: 0, status: null, priority: "normal", createdAt: "2026-04-03T00:00:00.000Z", }, ]; const tasksById: Record> = Object.fromEntries( sweptTasks.map((t) => [t.id, t]), ); mockStore.store.listTasks.mockResolvedValue(sweptTasks); mockStore.store.getTask.mockImplementation(async (id: string) => tasksById[id] ?? null); mocks.currentStore = mockStore.store; const mergeOrder: string[] = []; mocks.aiMergeTask.mockImplementation(async (...args: unknown[]) => { mergeOrder.push(args[2] as string); return { merged: true } as never; }); const engine = createEngine(); await engine.start(); await vi.waitFor(() => { expect(mergeOrder).toHaveLength(3); }); expect(mergeOrder).toEqual(["FN-urgent", "FN-normal", "FN-low"]); await engine.stop(); }); // Direct unit-tests of pickNextMergeTaskId to exercise the multi-item // priority path with concurrent queue mutations during getTask awaits. // These are unreachable through enqueueMerge alone because the first // enqueue always takes the single-item fast path. it("picker falls back to next-priority task when the chosen one is removed from the queue during getTask awaits", async () => { const mockStore = createMockStore({ ...baseSettings, autoMerge: true }); const tasksById: Record> = { "FN-urgent": { id: "FN-urgent", column: "in-review", paused: false, mergeRetries: 0, status: null, priority: "urgent", createdAt: "2026-04-01T00:00:00.000Z" }, "FN-normal-a": { id: "FN-normal-a", column: "in-review", paused: false, mergeRetries: 0, status: null, priority: "normal", createdAt: "2026-04-02T00:00:00.000Z" }, "FN-normal-b": { id: "FN-normal-b", column: "in-review", paused: false, mergeRetries: 0, status: null, priority: "normal", createdAt: "2026-04-03T00:00:00.000Z" }, }; let releaseUrgent: (() => void) = () => {}; const urgentHeld = new Promise((resolve) => { releaseUrgent = resolve; }); let urgentRequested = false; mockStore.store.getTask.mockImplementation(async (id: string) => { if (id === "FN-urgent") { urgentRequested = true; await urgentHeld; } return tasksById[id] ?? null; }); mocks.currentStore = mockStore.store; const engine = createEngine(); await engine.start(); const privateEngine = engine as unknown as { mergeQueue: string[]; mergeActive: Set; pickNextMergeTaskId: (store: unknown) => Promise; }; privateEngine.mergeQueue = ["FN-urgent", "FN-normal-a", "FN-normal-b"]; privateEngine.mergeActive = new Set(["FN-urgent", "FN-normal-a", "FN-normal-b"]); const pickPromise = privateEngine.pickNextMergeTaskId(mockStore.store); await vi.waitFor(() => { expect(urgentRequested).toBe(true); }); // Simulate pause-handler removing FN-urgent mid-pick. privateEngine.mergeQueue = privateEngine.mergeQueue.filter((id) => id !== "FN-urgent"); privateEngine.mergeActive.delete("FN-urgent"); releaseUrgent(); const chosen = await pickPromise; // FN-urgent was yanked; picker must fall back to next-priority survivor. // Both surviving tasks are "normal"; FN-normal-a wins by older createdAt. expect(chosen).toBe("FN-normal-a"); expect(privateEngine.mergeQueue).toEqual(["FN-normal-b"]); await engine.stop(); }); it("picker returns undefined when shutdown lands during getTask awaits", async () => { const mockStore = createMockStore({ ...baseSettings, autoMerge: true }); const tasksById: Record> = { "FN-a": { id: "FN-a", column: "in-review", paused: false, mergeRetries: 0, status: null, priority: "high", createdAt: "2026-04-01T00:00:00.000Z" }, "FN-b": { id: "FN-b", column: "in-review", paused: false, mergeRetries: 0, status: null, priority: "normal", createdAt: "2026-04-02T00:00:00.000Z" }, }; let release: (() => void) = () => {}; const held = new Promise((resolve) => { release = resolve; }); let firstCallSeen = false; mockStore.store.getTask.mockImplementation(async (id: string) => { if (!firstCallSeen) { firstCallSeen = true; await held; } return tasksById[id] ?? null; }); mocks.currentStore = mockStore.store; const engine = createEngine(); await engine.start(); const privateEngine = engine as unknown as { mergeQueue: string[]; mergeActive: Set; shuttingDown: boolean; pickNextMergeTaskId: (store: unknown) => Promise; }; privateEngine.mergeQueue = ["FN-a", "FN-b"]; privateEngine.mergeActive = new Set(["FN-a", "FN-b"]); const pickPromise = privateEngine.pickNextMergeTaskId(mockStore.store); await vi.waitFor(() => { expect(firstCallSeen).toBe(true); }); // Simulate shutdown while picker is awaiting getTask. privateEngine.shuttingDown = true; privateEngine.mergeQueue = []; release(); const chosen = await pickPromise; expect(chosen).toBeUndefined(); // Reset so engine.stop() teardown runs cleanly. privateEngine.shuttingDown = false; await engine.stop(); }); }); describe("ProjectEngine paused in-review auto-merge behavior", () => { beforeEach(() => { vi.clearAllMocks(); }); it("does not enqueue paused tasks from task:moved into in-review", async () => { const mockStore = createMockStore({ ...baseSettings, autoMerge: true }); mocks.currentStore = mockStore.store; const engine = createEngine(); const privateEngine = engine as unknown as { internalEnqueueMerge: (taskId: string) => void }; const enqueueSpy = vi.spyOn(privateEngine, "internalEnqueueMerge"); await engine.start(); const taskMovedHandler = mockStore.store.on.mock.calls.find((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"); await taskMovedHandler({ task: { id: "FN-paused", column: "in-review", paused: true }, to: "in-review", }); expect(enqueueSpy).not.toHaveBeenCalledWith("FN-paused"); await engine.stop(); }); it("re-enqueues an in-review task when it is unpaused", async () => { const mockStore = createMockStore({ ...baseSettings, autoMerge: true }); mocks.currentStore = mockStore.store; const engine = createEngine(); const privateEngine = engine as unknown as { internalEnqueueMerge: (taskId: string) => void }; const enqueueSpy = vi.spyOn(privateEngine, "internalEnqueueMerge"); await engine.start(); enqueueSpy.mockClear(); const taskUpdatedHandler = mockStore.store.on.mock.calls.find((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"); await taskUpdatedHandler({ id: "FN-unpause", column: "in-review", paused: true, status: "paused" }); await taskUpdatedHandler({ id: "FN-unpause", column: "in-review", paused: false, status: null }); expect(enqueueSpy).toHaveBeenCalledWith("FN-unpause"); await engine.stop(); }); it("emits task:merged when mergeConfirmed fast-path finalizes to done", async () => { const mockStore = createMockStore({ ...baseSettings, autoMerge: true }); mockStore.store.getTask.mockResolvedValueOnce({ id: "FN-merged", column: "in-review", paused: false, mergeRetries: 0, status: null, branch: "fusion/fn-merged", mergeDetails: { mergeConfirmed: true, mergedAt: "2026-05-18T00:00:00.000Z", mergeTargetBranch: "main" }, }); mockStore.store.moveTask.mockResolvedValueOnce({ id: "FN-merged", column: "done", branch: "fusion/fn-merged", mergeDetails: { mergeConfirmed: true, mergedAt: "2026-05-18T00:00:00.000Z", mergeTargetBranch: "main" }, } as any); mocks.currentStore = mockStore.store; const engine = createEngine(); await engine.start(); engine.enqueueMerge("FN-merged"); await vi.waitFor(() => { expect(mockStore.store.emit).toHaveBeenCalledWith( "task:merged", expect.objectContaining({ merged: true, task: expect.objectContaining({ id: "FN-merged", column: "done" }), }), ); }); await engine.stop(); }); it("FN-5627: auto-recovers fast-path refusal by clearing poisoned mergeDetails + re-enqueueing (mergeRetries < budget)", async () => { // Repro for the FN-5625/FN-5623 false-positive done class: the merger // has a TOCTOU between writing `mergeConfirmed: true` and `git update-ref` // succeeding. When the ref-advance fails after the optimistic write, the // task row is poisoned. The gate detects this, clears the lies, and // re-enqueues for a fresh aiMergeTask attempt — no human intervention // required as long as the retry budget isn't exhausted. const mockStore = createMockStore({ ...baseSettings, autoMerge: true }); mockStore.store.getTask.mockResolvedValueOnce({ id: "FN-poisoned", column: "in-review", paused: false, mergeRetries: 0, status: null, branch: "fusion/fn-poisoned", baseBranch: "main", mergeDetails: { mergeConfirmed: true, commitSha: "abc123abc123abc123abc123abc123abc1234567", mergeTargetBranch: "main", mergedAt: "2026-05-28T19:34:17.022Z", landedFiles: ["packages/foo/bar.ts"], filesChanged: 1, insertions: 10, deletions: 2, }, }); mocks.currentStore = mockStore.store; // Simulate the gate: `git cat-file -e` succeeds (commit exists locally // on the orphan task branch), but `git merge-base --is-ancestor` fails // with exit code 1 (commit is NOT reachable from main). mocks.execFile.mockImplementation(( _file: string, args: string[], _options: unknown, callback?: (error: (Error & { code?: number }) | null, result: { stdout: string; stderr: string }) => void, ) => { const cb = (typeof _options === "function" ? _options : callback) as ( error: (Error & { code?: number }) | null, result: { stdout: string; stderr: string }, ) => void; if (args[0] === "cat-file") { cb(null, { stdout: "", stderr: "" }); return {} as never; } if (args[0] === "merge-base" && args[1] === "--is-ancestor") { const err = new Error("Command failed: git merge-base --is-ancestor") as Error & { code?: number }; err.code = 1; cb(err, { stdout: "", stderr: "" }); return {} as never; } // Default success for any other git call. cb(null, { stdout: "/usr/bin/mock\n", stderr: "" }); return {} as never; }); const engine = createEngine(); await engine.start(); engine.enqueueMerge("FN-poisoned"); // Auto-recovery path: the task row is updated with cleared poisoned // fields, mergeRetries incremented, status null (NOT failed). await vi.waitFor(() => { const calls = (mockStore.store.updateTask as ReturnType).mock.calls as unknown as Array<[string, Record]>; const recoveryCall = calls.find((call) => call[0] === "FN-poisoned" && call[1]?.mergeRetries === 1 && call[1]?.status === null, ); expect(recoveryCall).toBeDefined(); const updates = recoveryCall![1] as { mergeDetails?: { mergeConfirmed?: boolean; commitSha?: string; mergedAt?: string; landedFiles?: string[]; filesChanged?: number; }; }; // Poisoned fields cleared. expect(updates.mergeDetails?.mergeConfirmed).toBe(false); expect(updates.mergeDetails?.commitSha).toBeUndefined(); expect(updates.mergeDetails?.mergedAt).toBeUndefined(); expect(updates.mergeDetails?.landedFiles).toBeUndefined(); expect(updates.mergeDetails?.filesChanged).toBeUndefined(); }); // Critical invariant: status is NOT failed (this is auto-recoverable). const failedCall = (mockStore.store.updateTask as ReturnType).mock.calls .find((call: unknown[]) => call[0] === "FN-poisoned" && (call[1] as { status?: string })?.status === "failed"); expect(failedCall).toBeUndefined(); // moveTask("done") was NOT called (no false-positive completion). expect(mockStore.store.moveTask).not.toHaveBeenCalledWith("FN-poisoned", "done"); // task:merged was NOT emitted for the poisoned task. const emitCalls = (mockStore.store.emit as ReturnType).mock.calls as unknown as Array<[string, { task?: { id: string } }]>; const mergedCalls = emitCalls.filter((call) => call[0] === "task:merged"); const poisonedEmit = mergedCalls.find((call) => call[1]?.task?.id === "FN-poisoned"); expect(poisonedEmit).toBeUndefined(); await engine.stop(); }); it("FN-5627: fast-path refusal parks task as failed when mergeRetries budget is exhausted", async () => { // When auto-recovery has already cycled through 3 attempts without // landing, the next refusal is terminal. The task is parked with // status=failed for manual review, with no further re-enqueue. The // downstream FN-5488 fast-path on `clearStaleBlockedBy` recognizes this // as a permanent blocker so dependents aren't held forever. const mockStore = createMockStore({ ...baseSettings, autoMerge: true }); mockStore.store.getTask.mockResolvedValueOnce({ id: "FN-exhausted", column: "in-review", paused: false, mergeRetries: 3, // already at budget status: null, branch: "fusion/fn-exhausted", baseBranch: "main", mergeDetails: { mergeConfirmed: true, commitSha: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", mergeTargetBranch: "main", mergedAt: "2026-05-28T19:34:17.022Z", }, }); mocks.currentStore = mockStore.store; mocks.execFile.mockImplementation(( _file: string, args: string[], _options: unknown, callback?: (error: (Error & { code?: number }) | null, result: { stdout: string; stderr: string }) => void, ) => { const cb = (typeof _options === "function" ? _options : callback) as ( error: (Error & { code?: number }) | null, result: { stdout: string; stderr: string }, ) => void; if (args[0] === "cat-file") { cb(null, { stdout: "", stderr: "" }); return {} as never; } if (args[0] === "merge-base" && args[1] === "--is-ancestor") { const err = new Error("Command failed: git merge-base --is-ancestor") as Error & { code?: number }; err.code = 1; cb(err, { stdout: "", stderr: "" }); return {} as never; } cb(null, { stdout: "/usr/bin/mock\n", stderr: "" }); return {} as never; }); const engine = createEngine(); await engine.start(); engine.enqueueMerge("FN-exhausted"); await vi.waitFor(() => { const calls = (mockStore.store.updateTask as ReturnType).mock.calls as unknown as Array<[string, Record]>; const terminalCall = calls.find((call) => call[0] === "FN-exhausted" && call[1]?.status === "failed" && typeof call[1]?.error === "string" && /retry budget exhausted|after 3 attempts/.test(call[1].error as string), ); expect(terminalCall).toBeDefined(); const updates = terminalCall![1] as { mergeDetails?: { mergeConfirmed?: boolean; commitSha?: string } }; expect(updates.mergeDetails?.mergeConfirmed).toBe(false); expect(updates.mergeDetails?.commitSha).toBeUndefined(); }); expect(mockStore.store.moveTask).not.toHaveBeenCalledWith("FN-exhausted", "done"); await engine.stop(); }); it("FN-5627: fast-path still works when mergeConfirmed has no commitSha (verified-no-op path)", async () => { // Legitimate no-op merges have mergeConfirmed=true with no commitSha // (verified-short-circuit / proven-no-op / already-on-main paths). The // reachability gate must not break those. const mockStore = createMockStore({ ...baseSettings, autoMerge: true }); mockStore.store.getTask.mockResolvedValueOnce({ id: "FN-noop", column: "in-review", paused: false, mergeRetries: 0, status: null, branch: "fusion/fn-noop", mergeDetails: { mergeConfirmed: true, noOpMerge: true, mergedAt: "2026-05-28T19:34:17.022Z", mergeTargetBranch: "main", // No commitSha — verified no-op. }, }); mockStore.store.moveTask.mockResolvedValueOnce({ id: "FN-noop", column: "done", branch: "fusion/fn-noop", mergeDetails: { mergeConfirmed: true, noOpMerge: true, mergedAt: "2026-05-28T19:34:17.022Z", mergeTargetBranch: "main", }, } as any); mocks.currentStore = mockStore.store; const engine = createEngine(); await engine.start(); engine.enqueueMerge("FN-noop"); await vi.waitFor(() => { expect(mockStore.store.emit).toHaveBeenCalledWith( "task:merged", expect.objectContaining({ merged: true, task: expect.objectContaining({ id: "FN-noop", column: "done" }), }), ); }); await engine.stop(); }); it("emits task:merged when PR merge strategy returns merged", async () => { const mockStore = createMockStore({ ...baseSettings, autoMerge: true }); mockStore.store.getTask .mockResolvedValueOnce({ id: "FN-pr", column: "in-review", paused: false, mergeRetries: 0, status: null, branch: "fusion/fn-pr", }) .mockResolvedValue({ id: "FN-pr", column: "done", paused: false, mergeRetries: 0, status: null, branch: "fusion/fn-pr", mergeDetails: { mergeConfirmed: true, mergedAt: "2026-05-18T00:00:00.000Z", mergeTargetBranch: "main" }, }); mocks.currentStore = mockStore.store; const processPullRequestMerge = vi.fn(async () => "merged" as const); const engine = createEngine({ processPullRequestMerge, getMergeStrategy: () => "pull-request" }); await engine.start(); engine.enqueueMerge("FN-pr"); await vi.waitFor(() => { expect(mockStore.store.emit).toHaveBeenCalledWith( "task:merged", expect.objectContaining({ merged: true, task: expect.objectContaining({ id: "FN-pr" }), }), ); }); await engine.stop(); }); it("logs and skips paused tasks dequeued for auto-merge", async () => { const mockStore = createMockStore({ ...baseSettings, autoMerge: true }); mockStore.store.getTask.mockResolvedValueOnce({ id: "FN-paused", column: "in-review", paused: true, mergeRetries: 0, status: null, }); mocks.currentStore = mockStore.store; const logSpy = vi.spyOn(runtimeLog, "log").mockImplementation(() => {}); const engine = createEngine(); await engine.start(); engine.enqueueMerge("FN-paused"); await vi.waitFor(() => { expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Auto-merge skipping FN-paused — task is paused")); }); expect(mocks.aiMergeTask).not.toHaveBeenCalled(); logSpy.mockRestore(); await engine.stop(); }); it("aborts and disposes active merge session when an in-review task is paused", async () => { const mockStore = createMockStore({ ...baseSettings, autoMerge: true }); mockStore.store.getTask.mockResolvedValue({ id: "FN-active", column: "in-review", paused: false, mergeRetries: 0, status: null, }); mocks.currentStore = mockStore.store; let capturedSignal: AbortSignal | undefined; const disposeSession = vi.fn(); mocks.aiMergeTask.mockImplementationOnce(async (...args: unknown[]) => { const options = args[3] as { signal?: AbortSignal; onSession?: (session: { dispose: () => void }) => void }; capturedSignal = options.signal; options.onSession?.({ dispose: disposeSession }); await new Promise((_, reject) => { options.signal?.addEventListener( "abort", () => { const abortError = new Error("merge aborted"); abortError.name = "MergeAbortedError"; reject(abortError); }, { once: true }, ); }); }); const engine = createEngine(); const privateEngine = engine as unknown as { mergeQueue: string[]; mergeActive: Set; activeMergeSession: { dispose: () => void } | null; mergeAbortController: AbortController | null; activeMergeTaskId: string | null; }; await engine.start(); engine.enqueueMerge("FN-active"); await vi.waitFor(() => { expect(mocks.aiMergeTask).toHaveBeenCalledTimes(1); }); const taskUpdatedHandler = mockStore.store.on.mock.calls.find((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"); taskUpdatedHandler({ id: "FN-active", column: "in-review", paused: true }); await vi.waitFor(() => { expect(capturedSignal?.aborted).toBe(true); }); expect(disposeSession).toHaveBeenCalledTimes(1); await vi.waitFor(() => { expect(privateEngine.mergeQueue).not.toContain("FN-active"); expect(privateEngine.mergeActive.has("FN-active")).toBe(false); expect(privateEngine.activeMergeSession).toBeNull(); expect(privateEngine.mergeAbortController).toBeNull(); expect(privateEngine.activeMergeTaskId).toBeNull(); }); await engine.stop(); }); it("startup merge sweep skips paused in-review tasks", async () => { const mockStore = createMockStore({ ...baseSettings, autoMerge: true }); mockStore.store.listTasks.mockResolvedValueOnce([ { id: "FN-paused", column: "in-review", paused: true, mergeRetries: 0, status: null }, { id: "FN-ready", column: "in-review", paused: false, mergeRetries: 0, status: null }, ]); mocks.currentStore = mockStore.store; const engine = createEngine(); const privateEngine = engine as unknown as { internalEnqueueMerge: (taskId: string) => void }; const enqueueSpy = vi.spyOn(privateEngine, "internalEnqueueMerge"); await engine.start(); expect(enqueueSpy).toHaveBeenCalledWith("FN-ready"); expect(enqueueSpy).not.toHaveBeenCalledWith("FN-paused"); await engine.stop(); }); it("startup merge sweep enqueues shared-group members when autoMerge is false", async () => { const mockStore = createMockStore({ ...baseSettings, autoMerge: false }); mockStore.store.listTasks.mockResolvedValueOnce([ { id: "FN-shared", column: "in-review", paused: false, mergeRetries: 0, status: null, branchContext: { assignmentMode: "shared", groupId: "BG-5819", source: "planning" }, }, { id: "FN-plain", column: "in-review", paused: false, mergeRetries: 0, status: null }, ]); mocks.currentStore = mockStore.store; const engine = createEngine(); const privateEngine = engine as unknown as { internalEnqueueMerge: (taskId: string) => void }; const enqueueSpy = vi.spyOn(privateEngine, "internalEnqueueMerge"); await engine.start(); expect(enqueueSpy).toHaveBeenCalledWith("FN-shared"); expect(enqueueSpy).not.toHaveBeenCalledWith("FN-plain"); await engine.stop(); }); it("task:moved handoff keeps shared members blocked by global or engine pause", async () => { vi.useFakeTimers(); for (const settings of [ { ...baseSettings, autoMerge: false, globalPause: true, enginePaused: false }, { ...baseSettings, autoMerge: false, globalPause: false, enginePaused: true }, ]) { const mockStore = createMockStore(settings); mocks.currentStore = mockStore.store; const engine = createEngine(); const privateEngine = engine as unknown as { internalEnqueueMerge: (taskId: string) => void }; const enqueueSpy = vi.spyOn(privateEngine, "internalEnqueueMerge"); await engine.start(); enqueueSpy.mockClear(); const movedHandler = mockStore.store.on.mock.calls.find((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"); movedHandler({ task: { id: "FN-shared", column: "in-review", paused: false, steps: [], branchContext: { assignmentMode: "shared", groupId: "BG-5819", source: "planning" }, } as unknown as Task, to: "in-review", }); await vi.advanceTimersByTimeAsync(350); expect(enqueueSpy).not.toHaveBeenCalledWith("FN-shared"); await engine.stop(); } vi.useRealTimers(); }); it("global unpause sweep does not enqueue paused in-review tasks", async () => { const mockStore = createMockStore({ ...baseSettings, autoMerge: true }); mocks.currentStore = mockStore.store; const engine = createEngine(); const privateEngine = engine as unknown as { internalEnqueueMerge: (taskId: string) => void }; const enqueueSpy = vi.spyOn(privateEngine, "internalEnqueueMerge"); await engine.start(); enqueueSpy.mockClear(); mockStore.store.listTasks.mockResolvedValueOnce([ { id: "FN-paused", column: "in-review", paused: true, mergeRetries: 0, status: null }, { id: "FN-ready", column: "in-review", paused: false, mergeRetries: 0, status: null }, ]); await mockStore.emitSettingsUpdated( { ...baseSettings, autoMerge: true, globalPause: false }, { ...baseSettings, autoMerge: true, globalPause: true }, ); expect(enqueueSpy).toHaveBeenCalledWith("FN-ready"); expect(enqueueSpy).not.toHaveBeenCalledWith("FN-paused"); await engine.stop(); }); it("periodic merge sweep does not re-enqueue failed in-review tasks", async () => { vi.useFakeTimers(); const mockStore = createMockStore({ ...baseSettings, autoMerge: true, pollIntervalMs: 15_000 }); mocks.currentStore = mockStore.store; const engine = createEngine(); const privateEngine = engine as unknown as { internalEnqueueMerge: (taskId: string) => void }; const enqueueSpy = vi.spyOn(privateEngine, "internalEnqueueMerge"); await engine.start(); enqueueSpy.mockClear(); mockStore.store.listTasks.mockResolvedValueOnce([ // Retry exhausted + failed (FN-2997 observed state after merge error) { id: "FN-failed", column: "in-review", paused: false, mergeRetries: 3, status: "failed", updatedAt: new Date(Date.now() - 60 * 60_000).toISOString() }, // Failed status must block even when retries are below the cap. { id: "FN-failed-low-retries", column: "in-review", paused: false, mergeRetries: 0, status: "failed", updatedAt: new Date().toISOString() }, { id: "FN-ready", column: "in-review", paused: false, mergeRetries: 0, status: null, updatedAt: new Date().toISOString() }, ]); await vi.advanceTimersByTimeAsync(15_000); expect(enqueueSpy).toHaveBeenCalledWith("FN-ready"); expect(enqueueSpy).not.toHaveBeenCalledWith("FN-failed"); expect(enqueueSpy).not.toHaveBeenCalledWith("FN-failed-low-retries"); await engine.stop(); vi.useRealTimers(); }); it("engine unpause sweep re-enqueues only merge-eligible in-review tasks", async () => { const mockStore = createMockStore({ ...baseSettings, autoMerge: true }); mocks.currentStore = mockStore.store; const engine = createEngine({ getTaskMergeBlocker: (task) => (task.id === "FN-blocked" ? "blocked" : null), }); const privateEngine = engine as unknown as { internalEnqueueMerge: (taskId: string) => void }; const enqueueSpy = vi.spyOn(privateEngine, "internalEnqueueMerge"); await engine.start(); enqueueSpy.mockClear(); mockStore.store.listTasks.mockResolvedValueOnce([ { id: "FN-paused", column: "in-review", paused: true, mergeRetries: 0, status: null }, { id: "FN-failed", column: "in-review", paused: false, mergeRetries: 0, status: "failed" }, { id: "FN-blocked", column: "in-review", paused: false, mergeRetries: 0, status: null }, { id: "FN-ready", column: "in-review", paused: false, mergeRetries: 0, status: null }, ]); await mockStore.emitSettingsUpdated( { ...baseSettings, autoMerge: true, enginePaused: false }, { ...baseSettings, autoMerge: true, enginePaused: true }, ); expect(enqueueSpy).toHaveBeenCalledWith("FN-ready"); expect(enqueueSpy).not.toHaveBeenCalledWith("FN-paused"); expect(enqueueSpy).not.toHaveBeenCalledWith("FN-failed"); expect(enqueueSpy).not.toHaveBeenCalledWith("FN-blocked"); await engine.stop(); }); it("stamps engineActiveSinceMs on global and engine unpause transitions", async () => { const mockStore = createMockStore({ ...baseSettings, autoMerge: true }); mocks.currentStore = mockStore.store; const engine = createEngine(); await engine.start(); mockStore.store.updateSettings.mockClear(); await mockStore.emitSettingsUpdated( { ...baseSettings, autoMerge: true, globalPause: false }, { ...baseSettings, autoMerge: true, globalPause: true }, ); await mockStore.emitSettingsUpdated( { ...baseSettings, autoMerge: true, enginePaused: false }, { ...baseSettings, autoMerge: true, enginePaused: true }, ); const activationStampCalls = mockStore.store.updateSettings.mock.calls.filter( ([patch]) => patch && typeof patch === "object" && "engineActiveSinceMs" in patch, ); expect(activationStampCalls).toHaveLength(2); for (const [patch] of activationStampCalls) { expect((patch as { engineActiveSinceMs: unknown }).engineActiveSinceMs).toEqual(expect.any(Number)); } await engine.stop(); }); it("resumes deferred startup recovery on engine unpause", async () => { const mockStore = createMockStore(baseSettings); mocks.currentStore = mockStore.store; const engine = createEngine(); await engine.start(); mocks.runtimeResumeAfterUnpause.mockClear(); await mockStore.emitSettingsUpdated( { ...baseSettings, enginePaused: false }, { ...baseSettings, enginePaused: true }, ); expect(mocks.runtimeResumeAfterUnpause).toHaveBeenCalledTimes(1); await engine.stop(); }); it("calls stuck detector pause/resume hooks for enginePaused transitions", async () => { const mockStore = createMockStore(baseSettings); mocks.currentStore = mockStore.store; const engine = createEngine(); await engine.start(); const pause = vi.fn(); const resume = vi.fn(); const runtime = engine.getRuntime() as unknown as object; Object.defineProperty(runtime, "stuckTaskDetector", { get: () => ({ pause, resume }), configurable: true, }); await mockStore.emitSettingsUpdated( { ...baseSettings, enginePaused: true }, { ...baseSettings, enginePaused: false }, ); expect(pause).toHaveBeenCalledTimes(1); expect(resume).not.toHaveBeenCalled(); await mockStore.emitSettingsUpdated( { ...baseSettings, enginePaused: false }, { ...baseSettings, enginePaused: true }, ); expect(resume).toHaveBeenCalledTimes(1); await engine.stop(); }); it("calls stuck detector pause/resume hooks for globalPause transitions", async () => { const mockStore = createMockStore(baseSettings); mocks.currentStore = mockStore.store; const engine = createEngine(); await engine.start(); const pause = vi.fn(); const resume = vi.fn(); const runtime = engine.getRuntime() as unknown as object; Object.defineProperty(runtime, "stuckTaskDetector", { get: () => ({ pause, resume }), configurable: true, }); await mockStore.emitSettingsUpdated( { ...baseSettings, globalPause: true }, { ...baseSettings, globalPause: false }, ); expect(pause).toHaveBeenCalledTimes(1); await mockStore.emitSettingsUpdated( { ...baseSettings, globalPause: false }, { ...baseSettings, globalPause: true }, ); expect(resume).toHaveBeenCalledTimes(1); await engine.stop(); }); it("does not resume stuck detector until both global and engine pause are cleared", async () => { const mockStore = createMockStore(baseSettings); mocks.currentStore = mockStore.store; const engine = createEngine(); await engine.start(); const pause = vi.fn(); const resume = vi.fn(); const runtime = engine.getRuntime() as unknown as object; Object.defineProperty(runtime, "stuckTaskDetector", { get: () => ({ pause, resume }), configurable: true, }); await mockStore.emitSettingsUpdated( { ...baseSettings, globalPause: true, enginePaused: true }, { ...baseSettings, globalPause: false, enginePaused: false }, ); expect(pause).toHaveBeenCalledTimes(1); await mockStore.emitSettingsUpdated( { ...baseSettings, globalPause: false, enginePaused: true }, { ...baseSettings, globalPause: true, enginePaused: true }, ); expect(resume).not.toHaveBeenCalled(); await mockStore.emitSettingsUpdated( { ...baseSettings, globalPause: false, enginePaused: false }, { ...baseSettings, globalPause: false, enginePaused: true }, ); expect(resume).toHaveBeenCalledTimes(1); await engine.stop(); }); it("reserves stuck-detector checkNow for timeout-setting changes", async () => { const mockStore = createMockStore(baseSettings); mocks.currentStore = mockStore.store; const engine = createEngine(); await engine.start(); const checkNow = vi.fn(async () => undefined); const runtime = engine.getRuntime() as unknown as object; Object.defineProperty(runtime, "stuckTaskDetector", { get: () => ({ pause: vi.fn(), resume: vi.fn(), checkNow }), configurable: true, }); await mockStore.emitSettingsUpdated( { ...baseSettings, enginePaused: true }, { ...baseSettings, enginePaused: false }, ); await mockStore.emitSettingsUpdated( { ...baseSettings, enginePaused: false }, { ...baseSettings, enginePaused: true }, ); await mockStore.emitSettingsUpdated( { ...baseSettings, globalPause: true }, { ...baseSettings, globalPause: false }, ); await mockStore.emitSettingsUpdated( { ...baseSettings, globalPause: false }, { ...baseSettings, globalPause: true }, ); expect(checkNow).not.toHaveBeenCalled(); await mockStore.emitSettingsUpdated( { ...baseSettings, taskStuckTimeoutMs: 600_000 }, { ...baseSettings, taskStuckTimeoutMs: 300_000 }, ); expect(checkNow).toHaveBeenCalledTimes(1); await engine.stop(); }); }); describe("ProjectEngine swallowed error hardening", () => { let warnSpy: ReturnType; beforeEach(() => { vi.clearAllMocks(); const mockStore = createMockStore(baseSettings); mocks.currentStore = mockStore.store; warnSpy = vi.spyOn(runtimeLog, "warn").mockImplementation(() => {}); }); afterEach(() => { warnSpy.mockRestore(); vi.useRealTimers(); }); it("warns when settings read fails during task:moved auto-merge check", async () => { const mockStore = createMockStore({ ...baseSettings, autoMerge: true }); mocks.currentStore = mockStore.store; const engine = createEngine(); await engine.start(); mockStore.store.getSettings.mockRejectedValueOnce(new Error("db locked")); const handler = mockStore.store.on.mock.calls.find((c: unknown[]) => c[0] === "task:moved")?.[1] as | ((payload: { task: { id: string; column: string }; to: string }) => Promise) | undefined; expect(handler).toBeTypeOf("function"); if (!handler) throw new Error("task:moved handler was not registered"); // Auto-merge enqueue runs inside a setTimeout grace period (~300ms) to // let the executor's finally block complete before the merger starts. // Use fake timers so the test doesn't actually sleep 300ms. vi.useFakeTimers(); await handler({ task: { id: "FN-001", column: "in-review" }, to: "in-review", }); await vi.advanceTimersByTimeAsync(500); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining("Auto-merge handoff (FN-001) failed: db locked"), ); await engine.stop(); }); it("warns when startup merge sweep fails", async () => { const mockStore = createMockStore({ ...baseSettings, autoMerge: true }); mocks.currentStore = mockStore.store; mockStore.store.listTasks.mockRejectedValueOnce(new Error("connection lost")); const engine = createEngine(); await engine.start(); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Auto-merge startup sweep failed")); await engine.stop(); }); it("warns when periodic merge sweep fails", async () => { vi.useFakeTimers(); const mockStore = createMockStore({ ...baseSettings, autoMerge: true }); mocks.currentStore = mockStore.store; const engine = createEngine(); await engine.start(); warnSpy.mockClear(); mockStore.store.listTasks.mockRejectedValueOnce(new Error("sweep db error")); await vi.advanceTimersByTimeAsync(15_000); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Auto-merge periodic sweep failed")); await engine.stop(); }); it("warns and uses 15s fallback when pollIntervalMs read fails during retry scheduling", async () => { vi.useFakeTimers(); const mockStore = createMockStore({ ...baseSettings, autoMerge: true }); mocks.currentStore = mockStore.store; const engine = createEngine(); await engine.start(); warnSpy.mockClear(); mockStore.store.getSettings .mockResolvedValueOnce({ ...baseSettings, autoMerge: true }) .mockRejectedValueOnce(new Error("settings read failed")); await vi.advanceTimersByTimeAsync(15_000); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining("Auto-merge retry: failed to read pollIntervalMs"), ); await engine.stop(); }); it("warns when resumeAfterUnpause dispatch fails during global unpause", async () => { const mockStore = createMockStore(baseSettings); mocks.currentStore = mockStore.store; const engine = createEngine(); await engine.start(); warnSpy.mockClear(); const runtime = engine.getRuntime() as unknown as object; Object.defineProperty(runtime, "resumeAfterUnpause", { get() { throw new Error("resume hook broken"); }, configurable: true, }); await mockStore.emitSettingsUpdated( { ...baseSettings, globalPause: false }, { ...baseSettings, globalPause: true }, ); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining("Global unpause: failed to dispatch resumeAfterUnpause"), ); await engine.stop(); }); it("warns when in-review task listing fails during global unpause", async () => { const mockStore = createMockStore({ ...baseSettings, autoMerge: true }); mocks.currentStore = mockStore.store; const engine = createEngine(); await engine.start(); warnSpy.mockClear(); mockStore.store.listTasks.mockRejectedValueOnce(new Error("list failed")); await mockStore.emitSettingsUpdated( { ...baseSettings, autoMerge: true, globalPause: false }, { ...baseSettings, autoMerge: true, globalPause: true }, ); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining("Global unpause: failed to scan in-review tasks"), ); await engine.stop(); }); it("warns when resumeAfterUnpause dispatch fails during engine unpause", async () => { const mockStore = createMockStore(baseSettings); mocks.currentStore = mockStore.store; const engine = createEngine(); await engine.start(); warnSpy.mockClear(); const runtime = engine.getRuntime() as unknown as object; Object.defineProperty(runtime, "resumeAfterUnpause", { get() { throw new Error("resume hook broken"); }, configurable: true, }); await mockStore.emitSettingsUpdated( { ...baseSettings, enginePaused: false }, { ...baseSettings, enginePaused: true }, ); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining("Engine unpause: failed to dispatch resumeAfterUnpause"), ); await engine.stop(); }); it("warns when in-review task listing fails during engine unpause", async () => { const mockStore = createMockStore({ ...baseSettings, autoMerge: true }); mocks.currentStore = mockStore.store; const engine = createEngine(); await engine.start(); warnSpy.mockClear(); mockStore.store.listTasks.mockRejectedValueOnce(new Error("list failed")); await mockStore.emitSettingsUpdated( { ...baseSettings, autoMerge: true, enginePaused: false }, { ...baseSettings, autoMerge: true, enginePaused: true }, ); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining("Engine unpause: failed to scan in-review tasks"), ); await engine.stop(); }); it("warns when stuck-detector checkNow fails on timeout change", async () => { const mockStore = createMockStore(baseSettings); mocks.currentStore = mockStore.store; const engine = createEngine(); await engine.start(); warnSpy.mockClear(); const runtime = engine.getRuntime() as unknown as object; Object.defineProperty(runtime, "stuckTaskDetector", { get() { return { checkNow: async () => { throw new Error("detector stuck"); }, }; }, configurable: true, }); await mockStore.emitSettingsUpdated( { ...baseSettings, taskStuckTimeoutMs: 600_000 }, { ...baseSettings, taskStuckTimeoutMs: 300_000 }, ); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining("Stuck-timeout change: detector.checkNow() failed"), ); await engine.stop(); }); it("warns when stuck-detector pause/resume hooks throw", async () => { const mockStore = createMockStore(baseSettings); mocks.currentStore = mockStore.store; const engine = createEngine(); await engine.start(); warnSpy.mockClear(); const runtime = engine.getRuntime() as unknown as object; Object.defineProperty(runtime, "stuckTaskDetector", { get() { return { pause: () => { throw new Error("pause hook failed"); }, resume: () => { throw new Error("resume hook failed"); }, }; }, configurable: true, }); await mockStore.emitSettingsUpdated( { ...baseSettings, enginePaused: true }, { ...baseSettings, enginePaused: false }, ); await mockStore.emitSettingsUpdated( { ...baseSettings, enginePaused: false }, { ...baseSettings, enginePaused: true }, ); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining("Engine pause: stuck detector pause hook failed"), ); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining("Engine unpause: stuck detector resume hook failed"), ); await engine.stop(); }); }); describe("ProjectEngine stale mergeActive rescue (FN-3900)", () => { beforeEach(() => { vi.clearAllMocks(); }); afterEach(() => { vi.useRealTimers(); }); it("task:moved into in-review clears leaked mergeActive entry before enqueue", async () => { vi.useFakeTimers(); const mockStore = createMockStore({ ...baseSettings, autoMerge: true }); mockStore.store.getTask.mockResolvedValue({ id: "FN-leaked", column: "in-review", paused: false, status: null, mergeRetries: 0, }); mocks.currentStore = mockStore.store; const engine = createEngine(); const privateEngine = engine as unknown as { mergeQueue: string[]; mergeActive: Set; activeMergeTaskId: string | null; internalEnqueueMerge: (taskId: string) => void; }; await engine.start(); privateEngine.mergeActive = new Set(["FN-leaked"]); privateEngine.mergeQueue = []; privateEngine.activeMergeTaskId = null; const originalEnqueue = privateEngine.internalEnqueueMerge.bind(privateEngine); const enqueueSpy = vi.spyOn(privateEngine, "internalEnqueueMerge").mockImplementation((taskId: string) => { expect(privateEngine.mergeActive.has("FN-leaked")).toBe(false); return originalEnqueue(taskId); }); const warnSpy = vi.spyOn(runtimeLog, "warn").mockImplementation(() => {}); const taskMovedHandler = mockStore.store.on.mock.calls.find((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"); await taskMovedHandler({ task: { id: "FN-leaked", column: "in-review", paused: false }, to: "in-review", }); await vi.advanceTimersByTimeAsync(350); expect(enqueueSpy).toHaveBeenCalledWith("FN-leaked"); expect(warnSpy).toHaveBeenCalledWith(expect.stringMatching(/clearing stale mergeActive before enqueue/)); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("FN-leaked")); await engine.stop(); }); it("FN-4084: internalEnqueueMerge reconciles leaked mergeActive entries", async () => { const mockStore = createMockStore({ ...baseSettings, autoMerge: true }); mocks.currentStore = mockStore.store; const engine = createEngine(); const privateEngine = engine as unknown as { mergeQueue: string[]; mergeActive: Set; activeMergeTaskId: string | null; mergeRunning: boolean; internalEnqueueMerge: (taskId: string) => void; }; const warnSpy = vi.spyOn(runtimeLog, "warn").mockImplementation(() => {}); await engine.start(); privateEngine.mergeActive = new Set(["FN-leaked2"]); privateEngine.mergeQueue = []; privateEngine.activeMergeTaskId = null; privateEngine.mergeRunning = true; privateEngine.internalEnqueueMerge("FN-leaked2"); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("mergeActive entry is leaked")); expect(privateEngine.mergeQueue).toEqual(["FN-leaked2"]); expect(privateEngine.mergeActive.has("FN-leaked2")).toBe(true); await engine.stop(); }); it.each([ { scenario: "queued", mergeQueue: ["FN-live"], activeMergeTaskId: null }, { scenario: "active", mergeQueue: [], activeMergeTaskId: "FN-live" }, ])( "internalEnqueueMerge does not warn for live mergeActive entry ($scenario)", async ({ mergeQueue, activeMergeTaskId }) => { const mockStore = createMockStore({ ...baseSettings, autoMerge: true }); mocks.currentStore = mockStore.store; const engine = createEngine(); const privateEngine = engine as unknown as { mergeQueue: string[]; mergeActive: Set; activeMergeTaskId: string | null; internalEnqueueMerge: (taskId: string) => void; }; const warnSpy = vi.spyOn(runtimeLog, "warn").mockImplementation(() => {}); await engine.start(); privateEngine.mergeActive = new Set(["FN-live"]); privateEngine.mergeQueue = [...mergeQueue]; privateEngine.activeMergeTaskId = activeMergeTaskId; const queueBefore = [...privateEngine.mergeQueue]; privateEngine.internalEnqueueMerge("FN-live"); expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining("mergeActive entry is leaked")); expect(privateEngine.mergeQueue).toEqual(queueBefore); await engine.stop(); }, ); it("task:moved rescue does not clear legitimate active mergeActive entry", async () => { vi.useFakeTimers(); const mockStore = createMockStore({ ...baseSettings, autoMerge: true }); mockStore.store.getTask.mockResolvedValue({ id: "FN-busy", column: "in-review", paused: false, status: null, mergeRetries: 0, }); mocks.currentStore = mockStore.store; const engine = createEngine(); const privateEngine = engine as unknown as { mergeQueue: string[]; mergeActive: Set; activeMergeTaskId: string | null; internalEnqueueMerge: (taskId: string) => void; }; await engine.start(); privateEngine.mergeActive = new Set(["FN-busy"]); privateEngine.activeMergeTaskId = "FN-busy"; privateEngine.mergeQueue = []; 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 | ((payload: { task: { id: string; column: string; paused?: boolean }; to: string }) => Promise) | undefined; if (!taskMovedHandler) throw new Error("task:moved handler was not registered"); await taskMovedHandler({ task: { id: "FN-busy", column: "in-review", paused: false }, to: "in-review", }); await vi.advanceTimersByTimeAsync(350); expect(enqueueSpy).toHaveBeenCalledWith("FN-busy"); expect(privateEngine.mergeActive.has("FN-busy")).toBe(true); expect(privateEngine.activeMergeTaskId).toBe("FN-busy"); expect(warnSpy).not.toHaveBeenCalledWith(expect.stringMatching(/clearing stale mergeActive/)); await engine.stop(); }); });