Files
fusion/packages/engine/src/__tests__/project-engine.test.ts
gsxdsm 35ce9bd244 FN-5819: allow shared-group members to merge into group branch with auto-merge off
Allow shared branch-group members to continue member→group integration even when project auto-merge is disabled.

- add core helper to detect shared branch-group member integrations and export it
- update engine in-review enqueue/handoff/startup flows to permit shared-member integration while still honoring global/engine pause gates
- keep self-healing from pulling shared-member in-review tasks backward during auto-merge-off maintenance
- expand project-engine and reliability-interaction tests for shared-member autoMerge-off behavior and add FN-5819 backstop docs updates

Files changed:
 AGENTS.md                                          |  2 +
 docs/architecture.md                               |  3 +-
 packages/core/src/__tests__/task-merge.test.ts     | 37 +++++++++
 packages/core/src/index.ts                         |  1 +
 packages/core/src/task-merge.ts                    | 13 +++
 packages/engine/src/__tests__/project-engine.test.ts    | 64 ++++++++++++++
 packages/engine/src/__tests__/reliability-interactions/branch-group-merge-routing.test.ts             | 48 +++++++++++
 packages/engine/src/__tests__/reliability-interactions/shared-group-member-integration.test.ts        | 97 ++++++++++++++++++++++
 packages/engine/src/project-engine.ts              | 35 ++++----
 packages/engine/src/self-healing.ts                |  3 +-
 10 files changed, 284 insertions(+), 19 deletions(-)

Fusion-Task-Id: FN-5819

Fusion-Task-Lineage: b5eeced7-7c47-4573-9ab4-ca577bec65e1
2026-06-01 02:42:36 -07:00

2658 lines
89 KiB
TypeScript

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<string, unknown> | 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<typeof import("@fusion/core")>(), {
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<typeof import("node:child_process")>();
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<string, unknown>;
previous: Record<string, unknown>;
};
function createMockStore(initialSettings: Record<string, unknown>) {
let settings = structuredClone(initialSettings);
const settingsHandlers = new Set<(payload: SettingsHandlerPayload) => void | Promise<void>>();
const store = {
getSettings: vi.fn(async () => structuredClone(settings)),
listTasks: vi.fn(async (): Promise<Array<Record<string, unknown>>> => []),
getTask: vi.fn(async (taskId: string): Promise<Record<string, unknown>> => ({
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<string, unknown>) => {
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<void>) => {
if (event === "settings:updated") {
settingsHandlers.add(handler as (payload: SettingsHandlerPayload) => void | Promise<void>);
}
}),
off: vi.fn((event: string, handler: (...args: unknown[]) => void | Promise<void>) => {
if (event === "settings:updated") {
settingsHandlers.delete(
handler as (payload: SettingsHandlerPayload) => void | Promise<void>,
);
}
}),
};
const emitSettingsUpdated = async (
next: Record<string, unknown>,
previous: Record<string, unknown>,
) => {
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<string, unknown> = {
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<typeof ProjectEngine>[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<string, unknown>, comments: unknown[]) => Promise<void> | 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<string>;
mergeAbortController: AbortController | null;
mergeRetryTimer: ReturnType<typeof setTimeout> | 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<never>((_, 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<string, Record<string, unknown>> = {
"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<string, Record<string, unknown>> = 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<string, Record<string, unknown>> = {
"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<void>((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<string>;
pickNextMergeTaskId: (store: unknown) => Promise<string | undefined>;
};
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<string, Record<string, unknown>> = {
"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<void>((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<string>;
shuttingDown: boolean;
pickNextMergeTaskId: (store: unknown) => Promise<string | undefined>;
};
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<void>)
| 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<void>)
| 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<typeof vi.fn>).mock.calls as unknown as Array<[string, Record<string, unknown>]>;
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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mock.calls as unknown as Array<[string, Record<string, unknown>]>;
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<never>((_, 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<string>;
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<typeof vi.spyOn>;
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<void>)
| 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<string>;
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<void>)
| 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<string>;
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<string>;
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<string>;
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<void>)
| 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();
});
});