Unify live-agent capacity accounting across engine and dashboard. - Derive Running and Waiting from workflow traits and durable agent liveness. - Apply unified limits to planner, executor, and merge admission while updating dashboard indicators. - Remove duplicate concurrency controls and document the unified operator model. Files changed: .changeset/fn-8453-unified-concurrency.md | 7 + docs/agent-tool-surface-full-loop.md | 4 +- docs/architecture.md | 2 +- docs/dashboard-guide.md | 4 +- docs/settings-reference.md | 4 +- .../skill/fusion/references/fusion-capabilities.md | 4 +- .../core/src/__tests__/live-agent-count.test.ts | 91 ++++---- packages/core/src/index.gate.ts | 6 + packages/core/src/index.ts | 6 + packages/core/src/live-agent-count.ts | 107 ++++++--- packages/dashboard/app/App.tsx | 28 ++- packages/dashboard/app/api/board-workflows.ts | 2 + packages/dashboard/app/components/Column.tsx | 6 +- .../dashboard/app/components/EngineControlMenu.tsx | 26 --- .../dashboard/app/components/ExecutorStatusBar.tsx | 38 ++- .../dashboard/app/components/SettingsModal.tsx | 1 - .../app/components/__tests__/Column.test.tsx | 6 +- .../__tests__/EngineControlMenu.test.tsx | 10 +- .../__tests__/ExecutorStatusBar.test.tsx | 32 ++- .../command-center/CommandCenterControls.tsx | 26 --- .../settings/sections/SchedulingSection.search.ts | 9 - .../settings/sections/SchedulingSection.tsx | 13 -- .../app/hooks/__tests__/useExecutorStats.test.ts | 12 +- packages/dashboard/app/hooks/useExecutorStats.ts | 50 ++-- .../src/__tests__/project-store-resolver.test.ts | 11 +- packages/dashboard/src/project-store-resolver.ts | 14 +- .../register-config-mcp-pi-settings-routes.ts | 3 +- packages/engine/src/__tests__/concurrency.test.ts | 123 +++++++++- .../engine/src/__tests__/project-engine.test.ts | 34 +++ packages/engine/src/__tests__/triage.test.ts | 7 +- packages/engine/src/concurrency.ts | 207 ++++++++++++++++- packages/engine/src/project-engine.ts | 151 ++++++++++-- packages/engine/src/scheduler.ts | 82 ++++++- packages/engine/src/triage.ts | 254 +++++++++++++-------- .../lib/dashboard-browser-safe-core-modules.json | 5 + 35 files changed, 991 insertions(+), 394 deletions(-) Fusion-Task-Id: FN-8453 Fusion-Task-Lineage: 12cfa5df-675d-4fce-b17e-932376544239 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
3726 lines
137 KiB
TypeScript
3726 lines
137 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||
import type { Task } from "@fusion/core";
|
||
import { ProjectEngine, __resetDeterministicMergerModeDeprecationWarned } from "../project-engine.js";
|
||
import { AgentSemaphore } from "../concurrency.js";
|
||
// Resolves to the vi.mock factory above (the mocked merger-ai exports the real-shaped
|
||
// workspace land error classes so the dispatch's `instanceof` matching is exercised).
|
||
import { WorkspacePartialLandError, WorkspaceRepoLandBusyError } from "../merger-ai.js";
|
||
import { runtimeLog } from "../logger.js";
|
||
import { TunnelProcessManager } from "../remote-access/tunnel-process-manager.js";
|
||
import { NtfyNotifier } from "../notifier.js";
|
||
import { NotificationService, OAuthAlertStateStore, 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),
|
||
getSelfHealingManager: vi.fn(() => undefined),
|
||
runAiMerge: vi.fn(),
|
||
landWorkspaceTask: 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(),
|
||
// FNXC:EngineOAuth 2026-07-07-08:25: FN-7574 added OAuthRefreshScheduler (proactive access-token refresh) to the notification module; mirror its start/stop seams here alongside the sibling OAuth monitors.
|
||
oauthRefreshSchedulerStart: vi.fn(async () => undefined),
|
||
oauthRefreshSchedulerStop: vi.fn(),
|
||
runtimeConfigurePrMonitoring: vi.fn(),
|
||
deliverPostgresMigrationCompleteNotice: vi.fn(async () => "no-migration"),
|
||
prHandlerCreateFollowUpTask: vi.fn(async () => undefined),
|
||
}));
|
||
|
||
vi.mock("../postgres-migration-notice.js", async (importOriginal) => {
|
||
const actual = await importOriginal<typeof import("../postgres-migration-notice.js")>();
|
||
return {
|
||
...actual,
|
||
deliverPostgresMigrationCompleteNoticeIfNeeded: mocks.deliverPostgresMigrationCompleteNotice,
|
||
};
|
||
});
|
||
|
||
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(function () {
|
||
return {
|
||
start: mocks.cronRunnerStart,
|
||
stop: mocks.cronRunnerStop,
|
||
};
|
||
}),
|
||
createAiPromptExecutor: mocks.createAiPromptExecutor,
|
||
};
|
||
});
|
||
|
||
// FNXC:MergerUnification 2026-06-21-19:05: master-plan U0 unified the merge
|
||
// dispatch onto runAiMerge (merger-ai.js). project-engine no longer imports
|
||
// aiMergeTask; the merge seam these tests mock/assert is now runAiMerge.
|
||
vi.mock("../merger.js", () => ({
|
||
sweepStaleAutostashes: vi.fn(async () => ({ dropped: 0, retained: 0 })),
|
||
VerificationError: class VerificationError extends Error {},
|
||
}));
|
||
|
||
// FNXC:Workspace 2026-06-22-05:10 (Phase C review B7): the dispatch now matches the
|
||
// workspace land errors via `instanceof`, and routes workspace tasks through
|
||
// `landWorkspaceTask`. The mock must export REAL error classes (so `instanceof` is callable)
|
||
// and a mockable `landWorkspaceTask`; otherwise `err instanceof WorkspacePartialLandError`
|
||
// throws "not callable" and the workspace dispatch can't be exercised. The classes are
|
||
// declared INSIDE the (hoisted) factory so they exist when the mock is evaluated.
|
||
vi.mock("../merger-ai.js", () => {
|
||
class WorkspaceRepoLandBusyError extends Error {
|
||
public readonly retryable = true;
|
||
constructor(
|
||
public readonly repoRel: string,
|
||
public readonly holderTaskId: string,
|
||
public readonly requestingTaskId: string,
|
||
) {
|
||
super(`workspace sub-repo ${repoRel} land is in progress for task ${holderTaskId}`);
|
||
this.name = "WorkspaceRepoLandBusyError";
|
||
}
|
||
}
|
||
class WorkspacePartialLandError extends Error {
|
||
public readonly retryable = true;
|
||
constructor(
|
||
public readonly landedCount: number,
|
||
public readonly failedRepos: string[],
|
||
message: string,
|
||
) {
|
||
super(message);
|
||
this.name = "WorkspacePartialLandError";
|
||
}
|
||
}
|
||
return {
|
||
runAiMerge: mocks.runAiMerge,
|
||
landWorkspaceTask: mocks.landWorkspaceTask,
|
||
WorkspaceRepoLandBusyError,
|
||
WorkspacePartialLandError,
|
||
};
|
||
});
|
||
|
||
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(function () {
|
||
return {
|
||
onNewComments: vi.fn(),
|
||
};
|
||
}),
|
||
}));
|
||
|
||
vi.mock("../pr-comment-handler.js", () => ({
|
||
PrCommentHandler: vi.fn().mockImplementation(function () {
|
||
return {
|
||
handleNewComments: vi.fn(),
|
||
createFollowUpTask: mocks.prHandlerCreateFollowUpTask,
|
||
};
|
||
}),
|
||
}));
|
||
|
||
vi.mock("../notifier.js", () => ({
|
||
NtfyNotifier: vi.fn().mockImplementation(function () {
|
||
return {
|
||
start: mocks.notifierStart,
|
||
stop: mocks.notifierStop,
|
||
notifyGridlock: mocks.notifierNotifyGridlock,
|
||
};
|
||
}),
|
||
}));
|
||
|
||
vi.mock("../notification/index.js", () => ({
|
||
NotificationService: vi.fn().mockImplementation(function () {
|
||
return {
|
||
start: mocks.notificationServiceStart,
|
||
stop: mocks.notificationServiceStop,
|
||
};
|
||
}),
|
||
OAuthAlertStateStore: vi.fn().mockImplementation(function () {
|
||
return {};
|
||
}),
|
||
OAuthExpiryMonitor: vi.fn().mockImplementation(function () {
|
||
return {
|
||
start: mocks.oauthExpiryMonitorStart,
|
||
stop: mocks.oauthExpiryMonitorStop,
|
||
};
|
||
}),
|
||
// FNXC:EngineOAuth 2026-07-07-08:25: FN-7574 constructs `new OAuthRefreshScheduler({ authStorage })` then awaits `.start()`/`.stop()` in ProjectEngine.start/stop. Export a constructable mock (function impl returning {start,stop}) so `new` works and the canonical-listener wiring tests run instead of failing on a missing mock export.
|
||
OAuthRefreshScheduler: vi.fn().mockImplementation(function () {
|
||
return {
|
||
start: mocks.oauthRefreshSchedulerStart,
|
||
stop: mocks.oauthRefreshSchedulerStop,
|
||
};
|
||
}),
|
||
OAuthValidityLogger: vi.fn().mockImplementation(function () {
|
||
return {
|
||
start: mocks.oauthValidityLoggerStart,
|
||
stop: mocks.oauthValidityLoggerStop,
|
||
};
|
||
}),
|
||
}));
|
||
|
||
vi.mock("../auth-storage.js", () => ({
|
||
createFusionAuthStorage: vi.fn(() => ({
|
||
reload: vi.fn(),
|
||
getOAuthProviders: vi.fn(() => []),
|
||
get: vi.fn(() => undefined),
|
||
})),
|
||
getFusionOAuthAlertStatePath: vi.fn(() => "/tmp/oauth-alert-state.json"),
|
||
}));
|
||
|
||
vi.mock("../runtimes/in-process-runtime.js", () => ({
|
||
InProcessRuntime: vi.fn().mockImplementation(function () {
|
||
return {
|
||
start: mocks.runtimeStart,
|
||
stop: mocks.runtimeStop,
|
||
resumeAfterUnpause: mocks.runtimeResumeAfterUnpause,
|
||
getTaskStore: () => mocks.currentStore,
|
||
getPluginRunner: vi.fn(() => undefined),
|
||
getAgentStore: vi.fn(),
|
||
getMessageStore: vi.fn(),
|
||
getRoutineStore: vi.fn(),
|
||
getRoutineRunner: vi.fn(),
|
||
getHeartbeatMonitor: vi.fn(),
|
||
getTriggerScheduler: vi.fn(),
|
||
getSelfHealingManager: mocks.getSelfHealingManager,
|
||
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),
|
||
getAsyncLayer: vi.fn(() => ({ kind: "test-async-layer" })),
|
||
/*
|
||
FNXC:OverlapSelfHealing 2026-06-26-12:00:
|
||
ProjectEngine's broad TaskStore fake is shared by maintenance wiring tests, so it carries clearStaleBlockedBy's overlap-path seam even when individual cases only exercise merge orchestration.
|
||
*/
|
||
parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]),
|
||
getCompletionHandoffAcceptedMarker: vi.fn().mockReturnValue(null),
|
||
emit: vi.fn(),
|
||
addTaskComment: vi.fn(async () => undefined),
|
||
getActiveMergingTask: vi.fn(() => null),
|
||
getBranchGroup: 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,
|
||
// FNXC:MergerUnification 2026-06-21-19:05: U0 unified merges onto runAiMerge;
|
||
// the onMerge tests mock/assert runAiMerge. The old `merger.mode` pin is gone
|
||
// (the dispatch ignores it) — a dedicated test below covers the inert-mode +
|
||
// one-time deprecation-warning behavior.
|
||
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.deliverPostgresMigrationCompleteNotice.mockReset();
|
||
mocks.deliverPostgresMigrationCompleteNotice.mockResolvedValue("no-migration");
|
||
mocks.runtimeResumeAfterUnpause.mockClear();
|
||
mocks.getSelfHealingManager.mockReset();
|
||
mocks.getSelfHealingManager.mockReturnValue(undefined);
|
||
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.oauthRefreshSchedulerStart.mockClear();
|
||
mocks.oauthRefreshSchedulerStop.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();
|
||
await vi.waitFor(() => expect(mocks.notifierStart).toHaveBeenCalledTimes(1));
|
||
|
||
expect(NotificationService).toHaveBeenCalledTimes(1);
|
||
expect(OAuthAlertStateStore).toHaveBeenCalledTimes(1);
|
||
expect(OAuthExpiryMonitor).toHaveBeenCalledTimes(1);
|
||
expect(OAuthValidityLogger).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);
|
||
const alertStateInstance = vi.mocked(OAuthAlertStateStore).mock.results[0]?.value;
|
||
expect(vi.mocked(OAuthExpiryMonitor).mock.calls[0]?.[0]).toEqual(
|
||
expect.objectContaining({ alertState: alertStateInstance }),
|
||
);
|
||
expect(vi.mocked(OAuthValidityLogger).mock.calls[0]?.[0]).toEqual(
|
||
expect.objectContaining({ alertState: alertStateInstance }),
|
||
);
|
||
|
||
expect(mocks.notificationServiceStart).toHaveBeenCalledTimes(1);
|
||
expect(mocks.oauthExpiryMonitorStart).toHaveBeenCalledTimes(1);
|
||
expect(mocks.notifierStart).toHaveBeenCalledTimes(1);
|
||
|
||
// FNXC:ClaudeOAuth 2026-07-08-12:10: the proactive refresher must start BEFORE the
|
||
// refresh-blind expiry monitor's first (awaited) check, or a stale-but-refreshable
|
||
// access token fires a false "OAuth token expired" ntfy push on startup. Lock the order.
|
||
expect(mocks.oauthRefreshSchedulerStart).toHaveBeenCalledTimes(1);
|
||
expect(mocks.oauthRefreshSchedulerStart.mock.invocationCallOrder[0]).toBeLessThan(
|
||
mocks.oauthExpiryMonitorStart.mock.invocationCallOrder[0],
|
||
);
|
||
|
||
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();
|
||
await vi.waitFor(() => expect(mocks.notifierStart).toHaveBeenCalledTimes(1));
|
||
|
||
// 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(OAuthAlertStateStore).toHaveBeenCalledTimes(1);
|
||
expect(OAuthExpiryMonitor).toHaveBeenCalledTimes(1);
|
||
expect(OAuthValidityLogger).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(OAuthAlertStateStore).not.toHaveBeenCalled();
|
||
expect(OAuthExpiryMonitor).not.toHaveBeenCalled();
|
||
expect(OAuthValidityLogger).not.toHaveBeenCalled();
|
||
expect(NtfyNotifier).not.toHaveBeenCalled();
|
||
|
||
await engine.stop();
|
||
});
|
||
|
||
/*
|
||
FNXC:PostgresMigrationInbox 2026-07-14-12:10:
|
||
Post-migration inbox delivery is informational background work. Project startup must complete even while the database-backed notice operation is still pending.
|
||
*/
|
||
it("does not block startup on post-migration inbox delivery", async () => {
|
||
const mockStore = createMockStore({
|
||
...baseSettings,
|
||
sqliteMigrationNotice: {
|
||
migratedAt: "2026-07-14T18:00:00.000Z",
|
||
migratedRows: 10,
|
||
tables: 2,
|
||
sqliteBackups: ["/tmp/fusion.db"],
|
||
dismissed: false,
|
||
},
|
||
});
|
||
mocks.currentStore = mockStore.store;
|
||
mocks.deliverPostgresMigrationCompleteNotice.mockImplementation(() => new Promise(() => {}));
|
||
|
||
const engine = createEngine();
|
||
await expect(engine.start()).resolves.toBeUndefined();
|
||
expect(mocks.deliverPostgresMigrationCompleteNotice).toHaveBeenCalledOnce();
|
||
|
||
await engine.stop();
|
||
});
|
||
});
|
||
|
||
describe("ProjectEngine planner overseer observation wiring", () => {
|
||
it("awaits the audit persistence promise through the monitor registered by start()", async () => {
|
||
const mockStore = createMockStore(baseSettings);
|
||
mocks.currentStore = mockStore.store;
|
||
const engine = createEngine();
|
||
let releasePersistence!: () => void;
|
||
const persistence = new Promise<void>((resolve) => {
|
||
releasePersistence = resolve;
|
||
});
|
||
const engineInternals = engine as unknown as {
|
||
emitOverseerObservationDeduped: (store: unknown, observation: unknown) => Promise<void>;
|
||
};
|
||
const emitSpy = vi.spyOn(engineInternals, "emitOverseerObservationDeduped").mockReturnValue(persistence);
|
||
|
||
await engine.start();
|
||
try {
|
||
const monitor = engine.getPlannerOverseer();
|
||
expect(monitor).toBeDefined();
|
||
|
||
/*
|
||
FNXC:PlannerOversight 2026-07-17-16:35:
|
||
ProjectEngine.start() must return the audit-persistence Promise from its
|
||
monitor callback. Awaiting only a test-local callback can hide dropped
|
||
PostgreSQL audit writes in the production wiring.
|
||
*/
|
||
const observed = monitor!.observeTask({ id: "FN-observation", column: "in-progress" } as Task, "autonomous");
|
||
await vi.waitFor(() => expect(emitSpy).toHaveBeenCalledOnce());
|
||
|
||
let settled = false;
|
||
void observed.then(() => { settled = true; });
|
||
await Promise.resolve();
|
||
expect(settled).toBe(false);
|
||
|
||
releasePersistence();
|
||
await expect(observed).resolves.toEqual(expect.objectContaining({ taskId: "FN-observation" }));
|
||
} finally {
|
||
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 calls = mocks.runtimeConfigurePrMonitoring.mock.calls;
|
||
const configArg = calls[calls.length - 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.runAiMerge.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();
|
||
await vi.waitFor(() => expect(mocks.syncScheduledEvalBatchAutomation).toHaveBeenCalledTimes(1));
|
||
|
||
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();
|
||
await vi.waitFor(() => expect(mocks.syncAutoSummarizeAutomation).toHaveBeenCalledTimes(1));
|
||
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();
|
||
await vi.waitFor(() => expect(mocks.syncMemoryDreamsAutomation).toHaveBeenCalledTimes(1));
|
||
|
||
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();
|
||
await vi.waitFor(() => expect(mocks.syncMemoryDreamsAutomation).toHaveBeenCalledTimes(1));
|
||
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();
|
||
await vi.waitFor(() => expect(mocks.syncMemoryDreamsAutomation).toHaveBeenCalledTimes(1));
|
||
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();
|
||
await vi.waitFor(() => expect(mocks.syncMemoryDreamsAutomation).toHaveBeenCalledTimes(1));
|
||
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();
|
||
await vi.waitFor(() => expect(warnSpy).toHaveBeenCalledWith(
|
||
expect.stringContaining("Memory dreams automation startup sync failed"),
|
||
));
|
||
|
||
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();
|
||
await vi.waitFor(() => expect(mocks.syncMemoryDreamsAutomation).toHaveBeenCalledTimes(1));
|
||
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.runAiMerge.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.runAiMerge).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.runAiMerge.mock.calls.length;
|
||
engine.enqueueMerge("FN-after-stop");
|
||
expect(privateEngine.mergeQueue).toHaveLength(0);
|
||
expect(mocks.runAiMerge).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 runAiMerge for onMerge requests", async () => {
|
||
mocks.runAiMerge.mockResolvedValue({ merged: true, task: { id: "FN-5438" } } as any);
|
||
|
||
const engine = createEngine();
|
||
await engine.start();
|
||
|
||
await engine.onMerge("FN-5438");
|
||
|
||
expect(mocks.runAiMerge).toHaveBeenCalledWith(
|
||
expect.anything(),
|
||
expect.any(String),
|
||
"FN-5438",
|
||
expect.objectContaining({ manual: true }),
|
||
);
|
||
|
||
await engine.stop();
|
||
});
|
||
});
|
||
|
||
// FNXC:MergerUnification 2026-06-21-19:05: master-plan U0 made runAiMerge the
|
||
// sole merge path. These tests pin the unified dispatch: every merger.mode value
|
||
// routes to runAiMerge, "deterministic" warns exactly once (never errors), and
|
||
// the R7 workspace guard rejects populated-workspaceWorktrees tasks at the engine
|
||
// merge entry point before any merge runs.
|
||
describe("ProjectEngine U0 merge unification dispatch", () => {
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
// FNXC:MergerUnification 2026-06-21-19:05: the deterministic-mode deprecation
|
||
// warning is gated by a per-project module-level ledger. Reset it before each
|
||
// test so the once-per-project-per-process assertion is deterministic regardless
|
||
// of which sibling test populated the ledger first (createEngine always uses the
|
||
// same project root, so without this a prior deterministic merge would suppress
|
||
// the warning here and the "fires once" test would see zero emissions).
|
||
__resetDeterministicMergerModeDeprecationWarned();
|
||
});
|
||
|
||
async function runOnMergeWithMode(mode: string | undefined) {
|
||
const settings = { ...baseSettings, autoMerge: true } as Record<string, unknown>;
|
||
if (mode === undefined) {
|
||
delete settings.merger;
|
||
} else {
|
||
settings.merger = { mode };
|
||
}
|
||
const mockStore = createMockStore(settings);
|
||
mockStore.store.getTask.mockResolvedValue({
|
||
id: "FN-U0",
|
||
column: "in-review",
|
||
paused: false,
|
||
mergeRetries: 0,
|
||
status: "queued",
|
||
} as any);
|
||
mocks.currentStore = mockStore.store;
|
||
mocks.runAiMerge.mockResolvedValue({ merged: true, task: { id: "FN-U0" } } as any);
|
||
|
||
const engine = createEngine();
|
||
await engine.start();
|
||
await engine.onMerge("FN-U0");
|
||
await engine.stop();
|
||
}
|
||
|
||
it.each([
|
||
["unset", undefined],
|
||
["ai", "ai"],
|
||
["deterministic", "deterministic"],
|
||
])("routes merger.mode=%s to runAiMerge (never aiMergeTask)", async (_label, mode) => {
|
||
await runOnMergeWithMode(mode as string | undefined);
|
||
expect(mocks.runAiMerge).toHaveBeenCalledWith(
|
||
expect.anything(),
|
||
expect.any(String),
|
||
"FN-U0",
|
||
expect.anything(),
|
||
);
|
||
});
|
||
|
||
it('logs the merger.mode "deterministic" deprecation warning exactly once per project per process (warn, not error)', async () => {
|
||
const warnSpy = vi.spyOn(runtimeLog, "warn").mockImplementation(() => undefined);
|
||
const deprecationWarnings = () =>
|
||
warnSpy.mock.calls.filter((call) =>
|
||
String(call[0]).includes("merger.mode") && String(call[0]).includes("deprecated"),
|
||
);
|
||
try {
|
||
// First deterministic merge: the warning must fire EXACTLY once.
|
||
await runOnMergeWithMode("deterministic");
|
||
expect(deprecationWarnings()).toHaveLength(1);
|
||
|
||
// A SECOND deterministic merge in the same process (same project root) must
|
||
// NOT warn again — the per-project ledger suppresses the repeat. Total stays 1.
|
||
await runOnMergeWithMode("deterministic");
|
||
expect(deprecationWarnings()).toHaveLength(1);
|
||
|
||
// The warning is a warn (never an error), and the merge still proceeds via
|
||
// runAiMerge despite the deprecated value.
|
||
expect(deprecationWarnings()).toHaveLength(1);
|
||
expect(mocks.runAiMerge).toHaveBeenCalled();
|
||
} finally {
|
||
warnSpy.mockRestore();
|
||
}
|
||
});
|
||
|
||
// FNXC:Workspace 2026-06-22-05:10 (Phase C U1/U2 routing — supersedes the old R7 throw test):
|
||
// A workspace-mode task no longer throws WorkspaceTaskMergeError at the engine dispatch; it
|
||
// ROUTES to the per-repo land loop `landWorkspaceTask` (runAiMerge's R7 chokepoint stays as
|
||
// defense-in-depth but is not the primary path). On a full land, the merge reports merged=true.
|
||
it("routes a workspace-mode task to landWorkspaceTask (not runAiMerge) on full land", async () => {
|
||
const mockStore = createMockStore({ ...baseSettings, autoMerge: true });
|
||
mockStore.store.getTask.mockResolvedValue({
|
||
id: "FN-WS",
|
||
column: "in-review",
|
||
paused: false,
|
||
mergeRetries: 0,
|
||
status: "queued",
|
||
branch: "fusion/fn-ws",
|
||
workspaceWorktrees: {
|
||
"repo-a": { worktreePath: "/tmp/a", branch: "fusion/fn-ws-a" },
|
||
"repo-b": { worktreePath: "/tmp/b", branch: "fusion/fn-ws-b" },
|
||
},
|
||
} as any);
|
||
mocks.currentStore = mockStore.store;
|
||
mocks.landWorkspaceTask.mockResolvedValue({
|
||
allLanded: true,
|
||
repos: [
|
||
{ repo: "repo-a", status: "landed", landedSha: "aaaa1111", integrationBranch: "main" },
|
||
{ repo: "repo-b", status: "landed", landedSha: "bbbb2222", integrationBranch: "main" },
|
||
],
|
||
} as any);
|
||
|
||
const engine = createEngine();
|
||
await engine.start();
|
||
const result = await engine.onMerge("FN-WS");
|
||
expect(mocks.landWorkspaceTask).toHaveBeenCalled();
|
||
expect(mocks.runAiMerge).not.toHaveBeenCalled();
|
||
expect(result.merged).toBe(true);
|
||
await engine.stop();
|
||
});
|
||
|
||
// FNXC:Workspace 2026-07-05-00:00 (FN-7610):
|
||
// A workspace-mode task must route to landWorkspaceTask EVEN WHEN the project
|
||
// is configured with mergeStrategy:"pull-request" (getMergeStrategy resolves
|
||
// "pull-request") — the engine dispatch hoists an isWorkspaceTask check before
|
||
// the mergeStrategy branch so processPullRequestMerge (which would call
|
||
// getCurrentRepo against the non-git workspace root and throw "could not
|
||
// determine repository") is never reached for workspace tasks. Covers
|
||
// multi-repo, single-repo, and true-zero-commit no-op variants, plus asserts
|
||
// no regression to the legacy singular-worktree PR path.
|
||
describe("workspace tasks bypass PR-merge strategy (FN-7610)", () => {
|
||
it("multi-repo workspaceWorktrees + mergeStrategy=pull-request routes to landWorkspaceTask, never processPullRequestMerge", async () => {
|
||
const mockStore = createMockStore({ ...baseSettings, autoMerge: true });
|
||
mockStore.store.getTask.mockResolvedValue({
|
||
id: "FN-WS-PR-MULTI",
|
||
column: "in-review",
|
||
paused: false,
|
||
mergeRetries: 0,
|
||
status: "queued",
|
||
branch: "fusion/fn-ws-pr-multi",
|
||
workspaceWorktrees: {
|
||
"repo-a": { worktreePath: "/tmp/a", branch: "fusion/fn-ws-pr-multi-a" },
|
||
"repo-b": { worktreePath: "/tmp/b", branch: "fusion/fn-ws-pr-multi-b" },
|
||
},
|
||
} as any);
|
||
mocks.currentStore = mockStore.store;
|
||
mocks.landWorkspaceTask.mockResolvedValue({
|
||
allLanded: true,
|
||
repos: [
|
||
{ repo: "repo-a", status: "landed", landedSha: "aaaa1111", integrationBranch: "main" },
|
||
{ repo: "repo-b", status: "landed", landedSha: "bbbb2222", integrationBranch: "main" },
|
||
],
|
||
} as any);
|
||
|
||
const processPullRequestMerge = vi.fn(async () => "merged" as const);
|
||
const engine = createEngine({ processPullRequestMerge, getMergeStrategy: () => "pull-request" });
|
||
await engine.start();
|
||
const result = await engine.onMerge("FN-WS-PR-MULTI");
|
||
expect(processPullRequestMerge).not.toHaveBeenCalled();
|
||
expect(mocks.landWorkspaceTask).toHaveBeenCalled();
|
||
expect(result.merged).toBe(true);
|
||
await engine.stop();
|
||
});
|
||
|
||
it("single-key workspaceWorktrees + mergeStrategy=pull-request routes to landWorkspaceTask, never processPullRequestMerge", async () => {
|
||
const mockStore = createMockStore({ ...baseSettings, autoMerge: true });
|
||
mockStore.store.getTask.mockResolvedValue({
|
||
id: "FN-WS-PR-SINGLE",
|
||
column: "in-review",
|
||
paused: false,
|
||
mergeRetries: 0,
|
||
status: "queued",
|
||
branch: "fusion/fn-ws-pr-single",
|
||
workspaceWorktrees: {
|
||
"repo-c": { worktreePath: "/tmp/c", branch: "fusion/fn-ws-pr-single-c" },
|
||
},
|
||
} as any);
|
||
mocks.currentStore = mockStore.store;
|
||
mocks.landWorkspaceTask.mockResolvedValue({
|
||
allLanded: true,
|
||
repos: [{ repo: "repo-c", status: "landed", landedSha: "cccc3333", integrationBranch: "main" }],
|
||
} as any);
|
||
|
||
const processPullRequestMerge = vi.fn(async () => "merged" as const);
|
||
const engine = createEngine({ processPullRequestMerge, getMergeStrategy: () => "pull-request" });
|
||
await engine.start();
|
||
const result = await engine.onMerge("FN-WS-PR-SINGLE");
|
||
expect(processPullRequestMerge).not.toHaveBeenCalled();
|
||
expect(mocks.landWorkspaceTask).toHaveBeenCalled();
|
||
expect(result.merged).toBe(true);
|
||
await engine.stop();
|
||
});
|
||
|
||
it("true-zero-commit no-op workspace task under mergeStrategy=pull-request finalizes without calling processPullRequestMerge and does not park failed", async () => {
|
||
const mockStore = createMockStore({ ...baseSettings, autoMerge: true });
|
||
mockStore.store.getTask.mockResolvedValue({
|
||
id: "FN-WS-PR-NOOP",
|
||
column: "in-review",
|
||
paused: false,
|
||
mergeRetries: 0,
|
||
status: "queued",
|
||
branch: "fusion/fn-ws-pr-noop",
|
||
noCommitsExpected: true,
|
||
workspaceWorktrees: {
|
||
"repo-d": { worktreePath: "/tmp/d", branch: "fusion/fn-ws-pr-noop-d" },
|
||
},
|
||
} as any);
|
||
mocks.currentStore = mockStore.store;
|
||
// All repos land with no real commit (empty/no-op) — landWorkspaceTask still
|
||
// reports allLanded:true and finalizes gracefully.
|
||
mocks.landWorkspaceTask.mockResolvedValue({
|
||
allLanded: true,
|
||
repos: [{ repo: "repo-d", status: "empty", integrationBranch: "main" }],
|
||
} as any);
|
||
|
||
const processPullRequestMerge = vi.fn(async () => "merged" as const);
|
||
const engine = createEngine({ processPullRequestMerge, getMergeStrategy: () => "pull-request" });
|
||
await engine.start();
|
||
const result = await engine.onMerge("FN-WS-PR-NOOP");
|
||
expect(processPullRequestMerge).not.toHaveBeenCalled();
|
||
expect(mocks.landWorkspaceTask).toHaveBeenCalled();
|
||
expect(result.ok).not.toBe(false);
|
||
await engine.stop();
|
||
});
|
||
|
||
it("non-workspace task under mergeStrategy=pull-request still uses the PR path (no regression)", async () => {
|
||
const mockStore = createMockStore({ ...baseSettings, autoMerge: true });
|
||
mockStore.store.getTask
|
||
.mockResolvedValueOnce({
|
||
id: "FN-WS-PR-LEGACY",
|
||
column: "in-review",
|
||
paused: false,
|
||
mergeRetries: 0,
|
||
status: null,
|
||
branch: "fusion/fn-ws-pr-legacy",
|
||
})
|
||
.mockResolvedValue({
|
||
id: "FN-WS-PR-LEGACY",
|
||
column: "done",
|
||
paused: false,
|
||
mergeRetries: 0,
|
||
status: null,
|
||
branch: "fusion/fn-ws-pr-legacy",
|
||
mergeDetails: { mergeConfirmed: true, mergedAt: "2026-07-05T00: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-WS-PR-LEGACY");
|
||
|
||
await vi.waitFor(() => {
|
||
expect(processPullRequestMerge).toHaveBeenCalled();
|
||
});
|
||
expect(mocks.landWorkspaceTask).not.toHaveBeenCalled();
|
||
|
||
await engine.stop();
|
||
});
|
||
});
|
||
});
|
||
|
||
/*
|
||
FNXC:Workspace 2026-06-22-05:10 (Phase C review B1/B2/B4/B5):
|
||
Merge DISPATCH hardening for workspace tasks. These drive the REAL ProjectEngine dispatch
|
||
catch via the mocked merger-ai seam (landWorkspaceTask + the real-shaped error classes),
|
||
asserting the failure modes the review flagged: fail-closed on getTask null (B1), the
|
||
merge-confirmed reachability fast-path skipping workspace tasks (B2), busy-contention not
|
||
burning the merge-retry quota (B4), and the capped backoff (B5). No real AI, no real git
|
||
for the fast-path (the gate's git is asserted NOT to run for workspace tasks).
|
||
*/
|
||
describe("ProjectEngine workspace merge dispatch hardening (Phase C review)", () => {
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
});
|
||
|
||
const workspaceTask = (overrides: Record<string, unknown> = {}) => ({
|
||
id: "FN-WSH",
|
||
column: "in-review",
|
||
paused: false,
|
||
mergeRetries: 0,
|
||
status: "queued",
|
||
branch: "fusion/fn-wsh",
|
||
workspaceWorktrees: {
|
||
"repo-a": { worktreePath: "/tmp/a", branch: "fusion/fn-wsh-a" },
|
||
},
|
||
...overrides,
|
||
});
|
||
|
||
// B1: getTask returning null in the partial-land catch must FAIL CLOSED — no retry timer.
|
||
it("B1: partial land with getTask null fails closed (parks failed, no retry timer)", async () => {
|
||
vi.useFakeTimers();
|
||
try {
|
||
const mockStore = createMockStore({ ...baseSettings, autoMerge: true });
|
||
// FNXC:Workspace 2026-07-07-08:30 (FN-7610 regression):
|
||
// FN-7610 hoisted an isWorkspaceTask getTask read (mergeCandidate) into the
|
||
// dispatch ahead of landWorkspaceTask. A fixed mockResolvedValueOnce(2x)+null
|
||
// sequence no longer lands the null on the catch read — an earlier routing read
|
||
// consumes it and the workspace task never reaches landWorkspaceTask, so the
|
||
// WorkspacePartialLandError catch (and its fail-closed updateTask) never runs.
|
||
// Flip a flag inside the landWorkspaceTask mock and return the workspace task from
|
||
// getTask until that flag is set, so the null lands deterministically on the
|
||
// catch-block read (DB outage) regardless of how many routing reads precede the throw.
|
||
let landInvoked = false;
|
||
mocks.landWorkspaceTask.mockImplementation(async () => {
|
||
landInvoked = true;
|
||
throw new WorkspacePartialLandError(0, ["repo-a"], "Workspace partial land for FN-WSH: 0 landed, 1 failed");
|
||
});
|
||
mocks.currentStore = mockStore.store;
|
||
// getTask is typed to return a workspace task Record, but the real TaskStore
|
||
// signature yields Task | null; cast through unknown so the DB-outage null is
|
||
// expressible without `any`.
|
||
mockStore.store.getTask.mockImplementation(async () =>
|
||
(landInvoked ? null : workspaceTask()) as unknown as Record<string, unknown>,
|
||
);
|
||
|
||
const engine = createEngine();
|
||
await engine.start();
|
||
const enqueueSpy = vi.spyOn(
|
||
engine as unknown as { internalEnqueueMerge: (id: string) => void },
|
||
"internalEnqueueMerge",
|
||
);
|
||
engine.enqueueMerge("FN-WSH");
|
||
|
||
// Drain microtasks until the catch parks the task (fail-closed path).
|
||
await vi.waitFor(
|
||
() => {
|
||
expect(mockStore.store.updateTask).toHaveBeenCalledWith(
|
||
"FN-WSH",
|
||
expect.objectContaining({ status: "failed" }),
|
||
);
|
||
},
|
||
{ timeout: 2000, interval: 5 },
|
||
);
|
||
|
||
// No retry timer was scheduled, and no re-enqueue happened: advancing all timers
|
||
// must not trigger another internalEnqueueMerge.
|
||
enqueueSpy.mockClear();
|
||
await vi.advanceTimersByTimeAsync(120_000);
|
||
expect(enqueueSpy).not.toHaveBeenCalled();
|
||
// It must NOT have incremented mergeRetries (it couldn't even read the row).
|
||
expect(mockStore.store.updateTask).not.toHaveBeenCalledWith(
|
||
"FN-WSH",
|
||
expect.objectContaining({ mergeRetries: expect.anything(), status: null }),
|
||
);
|
||
|
||
await engine.stop();
|
||
} finally {
|
||
vi.useRealTimers();
|
||
}
|
||
});
|
||
|
||
// B2: a merged workspace task (mergeConfirmed + sub-repo commitSha) must SKIP the root-cwd
|
||
// reachability fast-path so it is finalized, not demoted/parked.
|
||
it("B2: merge-confirmed workspace task skips the root-cwd reachability gate (not demoted)", async () => {
|
||
const mockStore = createMockStore({ ...baseSettings, autoMerge: true });
|
||
mockStore.store.getTask.mockResolvedValue(
|
||
workspaceTask({
|
||
status: null,
|
||
mergeDetails: {
|
||
mergeConfirmed: true,
|
||
// A sub-repo squash sha — unreachable from the workspace ROOT cwd; the gate would
|
||
// (wrongly) clear mergeConfirmed and demote the task if it ran here.
|
||
commitSha: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
|
||
mergeTargetBranch: "main",
|
||
mergedAt: "2026-06-22T00:00:00.000Z",
|
||
},
|
||
}) as any,
|
||
);
|
||
mockStore.store.moveTask.mockResolvedValue(
|
||
workspaceTask({ column: "done" }) as any,
|
||
);
|
||
mocks.currentStore = mockStore.store;
|
||
// If the gate ran, it would invoke `git cat-file`. Make any git call fail so a gate
|
||
// run would be observable (and would demote). We assert it is NOT called.
|
||
mocks.execFile.mockImplementation((
|
||
_file: string,
|
||
_args: string[],
|
||
optionsOrCb: unknown,
|
||
callback?: (e: Error | null, r: { stdout: string; stderr: string }) => void,
|
||
) => {
|
||
const cb = (typeof optionsOrCb === "function" ? optionsOrCb : callback) as (
|
||
e: Error | null,
|
||
r: { stdout: string; stderr: string },
|
||
) => void;
|
||
cb(new Error("git should not be called for workspace fast-path"), { stdout: "", stderr: "" });
|
||
return {} as never;
|
||
});
|
||
|
||
const engine = createEngine();
|
||
await engine.start();
|
||
engine.enqueueMerge("FN-WSH");
|
||
|
||
await vi.waitFor(() => {
|
||
expect(mockStore.store.emit).toHaveBeenCalledWith(
|
||
"task:merged",
|
||
expect.objectContaining({ merged: true }),
|
||
);
|
||
});
|
||
|
||
// The reachability gate's `git cat-file` must NOT have run (workspace skip).
|
||
const gitCatFileCalls = (mocks.execFile.mock.calls as Array<[string, string[]]>).filter(
|
||
(c) => Array.isArray(c[1]) && c[1][0] === "cat-file",
|
||
);
|
||
expect(gitCatFileCalls).toHaveLength(0);
|
||
// The task must NOT have been demoted (mergeConfirmed cleared / status failed).
|
||
expect(mockStore.store.updateTask).not.toHaveBeenCalledWith(
|
||
"FN-WSH",
|
||
expect.objectContaining({ status: "failed" }),
|
||
);
|
||
await engine.stop();
|
||
});
|
||
|
||
// B4 + B5: repeated WorkspaceRepoLandBusyError re-enqueues with capped backoff WITHOUT
|
||
// consuming mergeRetries (pure contention does not park a never-failed task).
|
||
it("B4/B5: busy contention re-enqueues with capped backoff, never burns mergeRetries", async () => {
|
||
vi.useFakeTimers();
|
||
try {
|
||
const mockStore = createMockStore({ ...baseSettings, autoMerge: true });
|
||
mockStore.store.getTask.mockResolvedValue(workspaceTask() as any);
|
||
mocks.currentStore = mockStore.store;
|
||
mocks.landWorkspaceTask.mockRejectedValue(
|
||
new WorkspaceRepoLandBusyError("repo-a", "FN-OTHER", "FN-WSH"),
|
||
);
|
||
|
||
const engine = createEngine();
|
||
await engine.start();
|
||
const enqueueSpy = vi.spyOn(
|
||
engine as unknown as { internalEnqueueMerge: (id: string) => void },
|
||
"internalEnqueueMerge",
|
||
);
|
||
engine.enqueueMerge("FN-WSH");
|
||
|
||
// The busy catch logs a WorkspaceRepoLandBusy entry then schedules a backoff timer.
|
||
await vi.waitFor(
|
||
() => {
|
||
expect(mockStore.store.logEntry).toHaveBeenCalledWith(
|
||
"FN-WSH",
|
||
expect.stringContaining("busy"),
|
||
"WorkspaceRepoLandBusy",
|
||
);
|
||
},
|
||
{ timeout: 2000, interval: 5 },
|
||
);
|
||
|
||
// It must NOT have written any mergeRetries increment (busy ≠ real failure).
|
||
const burnedRetries = (mockStore.store.updateTask.mock.calls as Array<[string, Record<string, unknown>]>)
|
||
.some((c) => c[0] === "FN-WSH" && typeof c[1]?.mergeRetries === "number");
|
||
expect(burnedRetries).toBe(false);
|
||
|
||
/*
|
||
FNXC:Workspace 2026-06-22-09:30 (Phase C review B5b — assert the 60s CAP, not just the first retry):
|
||
Advancing 60s once only proves the first 5s timer fired; an UNcapped exponential
|
||
(5s,10s,20s,40s,80s,160s,…) would still pass that. Capture EVERY scheduled busy backoff delay
|
||
across enough cycles to pass the cap point (busyCount=4 → 5000*2^4 = 80_000ms, clamped to 60_000)
|
||
and assert no delay exceeds 60_000 AND the cap is actually reached. Each advance fires the pending
|
||
timer → re-enqueue → landWorkspaceTask rejects busy again → next backoff is scheduled.
|
||
*/
|
||
const scheduledBusyDelays: number[] = [];
|
||
// `globalThis.setTimeout` is already the fake-timer impl here (vi.useFakeTimers above).
|
||
// Wrap it to record the requested delay, then delegate to the SAME fake timer so the
|
||
// fake clock still drives the callback — no real-timer leakage.
|
||
const fakeSetTimeout = globalThis.setTimeout;
|
||
const setTimeoutSpy = vi
|
||
.spyOn(globalThis, "setTimeout")
|
||
.mockImplementation(((cb: (...a: unknown[]) => void, ms?: number, ...rest: unknown[]) => {
|
||
if (typeof ms === "number") scheduledBusyDelays.push(ms);
|
||
return (fakeSetTimeout as (...a: unknown[]) => unknown)(cb, ms, ...rest);
|
||
}) as typeof setTimeout);
|
||
|
||
try {
|
||
// Drive enough busy cycles to climb past the cap point (busyCount 0..5 = 6 cycles).
|
||
for (let i = 0; i < 6; i++) {
|
||
await vi.advanceTimersByTimeAsync(60_000);
|
||
}
|
||
} finally {
|
||
setTimeoutSpy.mockRestore();
|
||
}
|
||
|
||
// The exponential climbed (more than one distinct delay) AND every delay is capped at 60s.
|
||
expect(scheduledBusyDelays.length).toBeGreaterThanOrEqual(5);
|
||
expect(Math.max(...scheduledBusyDelays)).toBe(60_000);
|
||
expect(scheduledBusyDelays.every((d) => d <= 60_000)).toBe(true);
|
||
// The cap was actually exercised: at least one delay sits at the 60s ceiling.
|
||
expect(scheduledBusyDelays).toContain(60_000);
|
||
// Each fired backoff re-enqueued the merge (the contention retry loop is live).
|
||
expect(enqueueSpy).toHaveBeenCalledWith("FN-WSH");
|
||
|
||
await engine.stop();
|
||
} finally {
|
||
vi.useRealTimers();
|
||
}
|
||
});
|
||
});
|
||
|
||
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.runAiMerge.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.runAiMerge.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();
|
||
const semaphore = new AgentSemaphore(1);
|
||
(engine as unknown as { runtime: { projectSemaphore?: AgentSemaphore } }).runtime.projectSemaphore = semaphore;
|
||
engine.enqueueMerge("FN-pr");
|
||
|
||
await vi.waitFor(() => {
|
||
expect(mockStore.store.emit).toHaveBeenCalledWith(
|
||
"task:merged",
|
||
expect.objectContaining({
|
||
merged: true,
|
||
task: expect.objectContaining({ id: "FN-pr" }),
|
||
}),
|
||
);
|
||
});
|
||
|
||
expect(semaphore.activeCount).toBe(0);
|
||
await engine.stop();
|
||
});
|
||
|
||
it("runs a sole dequeued merge when coordinator capacity is available", async () => {
|
||
const mockStore = createMockStore({ ...baseSettings, autoMerge: true, maxConcurrent: 1 });
|
||
mockStore.store.getTask.mockResolvedValue({
|
||
id: "FN-sole-merge",
|
||
column: "in-review",
|
||
paused: false,
|
||
mergeRetries: 0,
|
||
status: null,
|
||
branch: "fusion/fn-sole-merge",
|
||
createdAt: "2026-01-01T00:00:00.000Z",
|
||
});
|
||
mocks.currentStore = mockStore.store;
|
||
|
||
const engine = createEngine();
|
||
await engine.start();
|
||
// Exercise the production reservation path: this task has already been
|
||
// dequeued, so it must add itself as the one-shot admission candidate.
|
||
(engine as unknown as { runtime: { projectSemaphore?: AgentSemaphore } }).runtime.projectSemaphore = new AgentSemaphore(1);
|
||
engine.enqueueMerge("FN-sole-merge");
|
||
|
||
await vi.waitFor(() => expect(mocks.runAiMerge).toHaveBeenCalledWith(
|
||
mockStore.store,
|
||
"/tmp/proj_test",
|
||
"FN-sole-merge",
|
||
expect.any(Object),
|
||
));
|
||
expect((engine as unknown as { mergeQueue: string[] }).mergeQueue).toEqual([]);
|
||
await engine.stop();
|
||
});
|
||
|
||
it("records an audit event (not silent) when auto-promotion of a branch-group member fails (Fix #4)", async () => {
|
||
const mockStore = createMockStore({ ...baseSettings, autoMerge: true });
|
||
// The dequeued + merged task is a shared branch-group member, so the engine
|
||
// attempts branch-group promotion after the PR merges.
|
||
const mergedMember = {
|
||
id: "FN-bgfail",
|
||
column: "done",
|
||
paused: false,
|
||
mergeRetries: 0,
|
||
status: null,
|
||
branch: "fusion/fn-bgfail",
|
||
branchContext: { groupId: "BG-FAIL-1", source: "planning", assignmentMode: "shared" },
|
||
mergeDetails: { mergeConfirmed: true, mergedAt: "2026-06-03T00:00:00.000Z", mergeTargetBranch: "fusion/groups/x" },
|
||
};
|
||
mockStore.store.getTask
|
||
.mockResolvedValueOnce({
|
||
id: "FN-bgfail",
|
||
column: "in-review",
|
||
paused: false,
|
||
mergeRetries: 0,
|
||
status: null,
|
||
branch: "fusion/fn-bgfail",
|
||
branchContext: { groupId: "BG-FAIL-1", source: "planning", assignmentMode: "shared" },
|
||
})
|
||
.mockResolvedValue(mergedMember);
|
||
|
||
const recordRunAuditEvent = vi.fn(async () => undefined);
|
||
// Drive promoteBranchGroup into throwing: getBranchGroup returns a complete-
|
||
// looking group, but listTasksByBranchGroup rejects, so promotion throws and
|
||
// the engine's catch must record the failure audit instead of swallowing it.
|
||
(mockStore.store as any).getBranchGroup = vi.fn(() => ({
|
||
id: "BG-FAIL-1",
|
||
sourceType: "planning",
|
||
sourceId: "planning:x",
|
||
branchName: "fusion/groups/x",
|
||
autoMerge: true,
|
||
prState: "none",
|
||
status: "open",
|
||
createdAt: Date.now(),
|
||
updatedAt: Date.now(),
|
||
}));
|
||
(mockStore.store as any).getBranchGroupByBranchName = vi.fn(() => null);
|
||
(mockStore.store as any).listTasksByBranchGroup = vi.fn(async () => {
|
||
throw new Error("boom: store unavailable");
|
||
});
|
||
(mockStore.store as any).updateBranchGroup = vi.fn();
|
||
(mockStore.store as any).recordRunAuditEvent = recordRunAuditEvent;
|
||
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-bgfail");
|
||
|
||
await vi.waitFor(() => {
|
||
expect(recordRunAuditEvent).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
mutationType: "merge:branch-group-promotion-failed",
|
||
target: "BG-FAIL-1",
|
||
metadata: expect.objectContaining({ groupId: "BG-FAIL-1", taskId: "FN-bgfail" }),
|
||
}),
|
||
);
|
||
});
|
||
|
||
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.runAiMerge).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.runAiMerge.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.runAiMerge).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 });
|
||
const inReviewTasks = [
|
||
{ id: "FN-paused", column: "in-review", paused: true, mergeRetries: 0, status: null },
|
||
{ id: "FN-ready", column: "in-review", paused: false, mergeRetries: 0, status: null },
|
||
];
|
||
// Critical stale-status cleanup reads first; deferred startup then evaluates eligibility.
|
||
mockStore.store.listTasks.mockResolvedValueOnce([]).mockResolvedValueOnce(inReviewTasks);
|
||
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();
|
||
await vi.waitFor(() => expect(enqueueSpy).toHaveBeenCalledWith("FN-ready"));
|
||
|
||
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.getBranchGroup.mockReturnValue({ id: "BG-5819", status: "open", branchName: "fusion/groups/bg-5819" });
|
||
const inReviewTasks = [
|
||
{
|
||
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 },
|
||
];
|
||
// Critical stale-status cleanup reads first; deferred startup then evaluates eligibility.
|
||
mockStore.store.listTasks.mockResolvedValueOnce([]).mockResolvedValueOnce(inReviewTasks);
|
||
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();
|
||
await vi.waitFor(() => expect(enqueueSpy).toHaveBeenCalledWith("FN-shared"));
|
||
|
||
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("reconciles active timing exactly once when both pause sources clear together", async () => {
|
||
const mockStore = createMockStore({ ...baseSettings, autoMerge: true });
|
||
mocks.currentStore = mockStore.store;
|
||
const reconcileEngineDowntimeActiveTiming = vi.fn(async () => ({ shiftedTaskIds: [], downtimeMs: 120_000 }));
|
||
mocks.getSelfHealingManager.mockReturnValue({ reconcileEngineDowntimeActiveTiming });
|
||
const engine = createEngine();
|
||
|
||
await engine.start();
|
||
await mockStore.emitSettingsUpdated(
|
||
{ ...baseSettings, autoMerge: true, globalPause: false, enginePaused: false },
|
||
{
|
||
...baseSettings,
|
||
autoMerge: true,
|
||
globalPause: true,
|
||
enginePaused: true,
|
||
engineLastActiveAt: "2026-07-15T11:58:00.000Z",
|
||
},
|
||
);
|
||
|
||
expect(reconcileEngineDowntimeActiveTiming).toHaveBeenCalledTimes(1);
|
||
expect(reconcileEngineDowntimeActiveTiming).toHaveBeenCalledWith({
|
||
engineLastActiveAtOverride: "2026-07-15T11:58:00.000Z",
|
||
});
|
||
await engine.stop();
|
||
});
|
||
|
||
it("waits for active-timing reconciliation before resuming agentic work", async () => {
|
||
const mockStore = createMockStore({ ...baseSettings, autoMerge: true });
|
||
mocks.currentStore = mockStore.store;
|
||
let resolveReconcile!: () => void;
|
||
const reconcileEngineDowntimeActiveTiming = vi.fn(() => new Promise<{ shiftedTaskIds: string[]; downtimeMs: number }>((resolve) => {
|
||
resolveReconcile = () => resolve({ shiftedTaskIds: ["FN-active"], downtimeMs: 120_000 });
|
||
}));
|
||
mocks.getSelfHealingManager.mockReturnValue({ reconcileEngineDowntimeActiveTiming });
|
||
const engine = createEngine();
|
||
await engine.start();
|
||
|
||
const unpause = mockStore.emitSettingsUpdated(
|
||
{ ...baseSettings, autoMerge: true, globalPause: false },
|
||
{ ...baseSettings, autoMerge: true, globalPause: true, engineLastActiveAt: "2026-07-15T11:58:00.000Z" },
|
||
);
|
||
await vi.waitFor(() => expect(reconcileEngineDowntimeActiveTiming).toHaveBeenCalledTimes(1));
|
||
expect(mocks.runtimeResumeAfterUnpause).not.toHaveBeenCalled();
|
||
|
||
resolveReconcile();
|
||
await unpause;
|
||
expect(mocks.runtimeResumeAfterUnpause).toHaveBeenCalledTimes(1);
|
||
await engine.stop();
|
||
});
|
||
|
||
it("reconciles once for either individual unpause, but not while another pause remains", async () => {
|
||
const mockStore = createMockStore({ ...baseSettings, autoMerge: true });
|
||
mocks.currentStore = mockStore.store;
|
||
const reconcileEngineDowntimeActiveTiming = vi.fn(async () => ({ shiftedTaskIds: [], downtimeMs: 0 }));
|
||
mocks.getSelfHealingManager.mockReturnValue({ reconcileEngineDowntimeActiveTiming });
|
||
const engine = createEngine();
|
||
|
||
await engine.start();
|
||
await mockStore.emitSettingsUpdated(
|
||
{ ...baseSettings, autoMerge: true, globalPause: false, enginePaused: true },
|
||
{ ...baseSettings, autoMerge: true, globalPause: true, enginePaused: true },
|
||
);
|
||
expect(reconcileEngineDowntimeActiveTiming).not.toHaveBeenCalled();
|
||
|
||
await mockStore.emitSettingsUpdated(
|
||
{ ...baseSettings, autoMerge: true, globalPause: false, enginePaused: false },
|
||
{ ...baseSettings, autoMerge: true, globalPause: false, enginePaused: true, engineLastActiveAt: "engine-only" },
|
||
);
|
||
await mockStore.emitSettingsUpdated(
|
||
{ ...baseSettings, autoMerge: true, globalPause: false, enginePaused: false },
|
||
{ ...baseSettings, autoMerge: true, globalPause: true, enginePaused: false, engineLastActiveAt: "global-only" },
|
||
);
|
||
|
||
expect(reconcileEngineDowntimeActiveTiming).toHaveBeenCalledTimes(2);
|
||
expect(reconcileEngineDowntimeActiveTiming).toHaveBeenNthCalledWith(1, { engineLastActiveAtOverride: "engine-only" });
|
||
expect(reconcileEngineDowntimeActiveTiming).toHaveBeenNthCalledWith(2, { engineLastActiveAtOverride: "global-only" });
|
||
await engine.stop();
|
||
});
|
||
|
||
it("fails soft when timing reconciliation rejects or its manager is unavailable", async () => {
|
||
const mockStore = createMockStore({ ...baseSettings, autoMerge: true });
|
||
mocks.currentStore = mockStore.store;
|
||
const reconcileEngineDowntimeActiveTiming = vi.fn(async () => {
|
||
throw new Error("timing unavailable");
|
||
});
|
||
mocks.getSelfHealingManager.mockReturnValue({ reconcileEngineDowntimeActiveTiming });
|
||
const warn = vi.spyOn(runtimeLog, "warn").mockImplementation(() => undefined);
|
||
const engine = createEngine();
|
||
await engine.start();
|
||
const resume = vi.fn();
|
||
Object.defineProperty(engine.getRuntime(), "stuckTaskDetector", { get: () => ({ resume }), configurable: true });
|
||
|
||
await mockStore.emitSettingsUpdated(
|
||
{ ...baseSettings, autoMerge: true, enginePaused: false },
|
||
{ ...baseSettings, autoMerge: true, enginePaused: true },
|
||
);
|
||
await Promise.resolve();
|
||
expect(resume).toHaveBeenCalledTimes(1);
|
||
expect(warn).toHaveBeenCalledWith(expect.stringContaining("failed to reconcile engine downtime active timing"));
|
||
|
||
mocks.getSelfHealingManager.mockReturnValue(undefined);
|
||
await expect(mockStore.emitSettingsUpdated(
|
||
{ ...baseSettings, autoMerge: true, globalPause: false },
|
||
{ ...baseSettings, autoMerge: true, globalPause: true },
|
||
)).resolves.toBeUndefined();
|
||
expect(resume).toHaveBeenCalledTimes(2);
|
||
warn.mockRestore();
|
||
await engine.stop();
|
||
});
|
||
|
||
it("passes the frozen heartbeat once so paused task time is discounted once", async () => {
|
||
const mockStore = createMockStore({ ...baseSettings, autoMerge: true });
|
||
mocks.currentStore = mockStore.store;
|
||
const startedMs = Date.parse("2026-07-15T11:50:00.000Z");
|
||
const capturedHeartbeat = "2026-07-15T11:58:00.000Z";
|
||
let executionStartedAt = new Date(startedMs).toISOString();
|
||
const reconcileEngineDowntimeActiveTiming = vi.fn(async ({ engineLastActiveAtOverride }: { engineLastActiveAtOverride?: string }) => {
|
||
const downtimeMs = Date.parse("2026-07-15T12:00:00.000Z") - Date.parse(engineLastActiveAtOverride ?? "");
|
||
executionStartedAt = new Date(startedMs + downtimeMs).toISOString();
|
||
return { shiftedTaskIds: ["FN-active"], downtimeMs };
|
||
});
|
||
mocks.getSelfHealingManager.mockReturnValue({ reconcileEngineDowntimeActiveTiming });
|
||
const engine = createEngine();
|
||
await engine.start();
|
||
|
||
await mockStore.emitSettingsUpdated(
|
||
{ ...baseSettings, autoMerge: true, globalPause: false, enginePaused: false, engineLastActiveAt: "2026-07-15T12:00:00.000Z" },
|
||
{ ...baseSettings, autoMerge: true, globalPause: true, enginePaused: true, engineLastActiveAt: capturedHeartbeat },
|
||
);
|
||
|
||
expect(reconcileEngineDowntimeActiveTiming).toHaveBeenCalledTimes(1);
|
||
expect(executionStartedAt).toBe("2026-07-15T11:52:00.000Z");
|
||
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();
|
||
await vi.waitFor(() => expect(mockStore.store.listTasks).toHaveBeenCalledTimes(2));
|
||
|
||
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;
|
||
// The critical stale-status read precedes the deferred enqueue sweep.
|
||
mockStore.store.listTasks
|
||
.mockResolvedValueOnce([])
|
||
.mockRejectedValueOnce(new Error("connection lost"));
|
||
|
||
const engine = createEngine();
|
||
await engine.start();
|
||
await vi.waitFor(() => expect(warnSpy).toHaveBeenCalledWith(
|
||
expect.stringContaining("Auto-merge startup enqueue failed: connection lost"),
|
||
));
|
||
|
||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Auto-merge startup enqueue failed: connection lost"));
|
||
|
||
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();
|
||
await vi.waitFor(() => expect(mockStore.store.listTasks).toHaveBeenCalledTimes(2));
|
||
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();
|
||
await vi.waitFor(() => expect(mockStore.store.listTasks).toHaveBeenCalledTimes(2));
|
||
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();
|
||
});
|
||
});
|
||
|
||
describe("allowInReviewMergeProcessing per-task autoMerge override", () => {
|
||
const gate = (task: Partial<Task>, settings: { autoMerge: boolean }, branchGroup: { status: "open" | "finalized" | "abandoned" } | null = null): Promise<boolean> =>
|
||
(createEngine() as any).allowInReviewMergeProcessing(task, settings, { getBranchGroup: vi.fn(() => branchGroup) });
|
||
|
||
it("lets an explicit per-task autoMerge:true through when the global setting is off", async () => {
|
||
await expect(gate({ autoMerge: true }, { autoMerge: false })).resolves.toBe(true);
|
||
});
|
||
|
||
it("blocks tasks without a per-task override when the global setting is off", async () => {
|
||
await expect(gate({}, { autoMerge: false })).resolves.toBe(false);
|
||
await expect(gate({ autoMerge: false }, { autoMerge: false })).resolves.toBe(false);
|
||
});
|
||
|
||
it("keeps everything flowing when the global setting is on — explicit autoMerge:false is parked manual-required downstream", async () => {
|
||
await expect(gate({}, { autoMerge: true })).resolves.toBe(true);
|
||
await expect(gate({ autoMerge: false }, { autoMerge: true })).resolves.toBe(true);
|
||
});
|
||
|
||
it("still exempts live shared-branch-group member integration when the global setting is off", async () => {
|
||
await expect(gate(
|
||
{ branchContext: { assignmentMode: "shared", groupId: "grp-1" } as Task["branchContext"] },
|
||
{ autoMerge: false },
|
||
{ status: "open" },
|
||
)).resolves.toBe(true);
|
||
});
|
||
|
||
it.each([
|
||
["missing", null],
|
||
["finalized", { status: "finalized" as const }],
|
||
["abandoned", { status: "abandoned" as const }],
|
||
])("blocks shared-branch-group member integration for %s groups when global autoMerge is off", async (_label, branchGroup) => {
|
||
await expect(gate(
|
||
{ branchContext: { assignmentMode: "shared", groupId: "grp-1" } as Task["branchContext"] },
|
||
{ autoMerge: false },
|
||
branchGroup,
|
||
)).resolves.toBe(false);
|
||
});
|
||
|
||
it.each([
|
||
["api", { sourceType: "api" }],
|
||
["user-created", { sourceType: undefined }],
|
||
["engine-created", { sourceType: "unknown", sourceMetadata: { fusionBranchContext: { assignmentMode: "shared", groupId: "grp-1", source: "mission" } } }],
|
||
])("applies the dissolved-group manual hold regardless of %s provenance", async (_label, provenance) => {
|
||
await expect(gate(
|
||
{
|
||
...provenance,
|
||
autoMerge: undefined,
|
||
branchContext: { assignmentMode: "shared", groupId: "grp-1", source: "mission" } as Task["branchContext"],
|
||
},
|
||
{ autoMerge: false },
|
||
null,
|
||
)).resolves.toBe(false);
|
||
});
|
||
});
|
||
|
||
// ## Surface Enumeration
|
||
//
|
||
// Known in-review merge entry surfaces in ProjectEngine, and how each enforces
|
||
// the per-task `autoMerge` override invariant (a task with `autoMerge:true` must
|
||
// still be enqueued for merge even when the global `autoMerge` setting is off):
|
||
//
|
||
// 1. Startup merge sweep (project-engine.ts ~:2857) ─┐
|
||
// 2. Periodic merge retry sweep (project-engine.ts ~:2916) ─┼─ all call
|
||
// 3. Resume-after-unpause sweep (project-engine.ts ~:2977) ─┘ enqueueEligibleInReviewTasks(...)
|
||
// 4. task:moved fast path (project-engine.ts ~:1506) ─── inline allowInReviewMergeProcessing(...)
|
||
//
|
||
// Surfaces 1–3 funnel through `enqueueEligibleInReviewTasks`, whose filter is
|
||
// `!t.paused && canMergeTask(t) && allowInReviewMergeProcessing(t, settings)`.
|
||
// The behavior tests below exercise that shared funnel directly on a real engine
|
||
// instance (with `internalEnqueueMerge` stubbed), so a regression in any of the
|
||
// three sweep wrappers (wireAutoMerge / startupMergeSweep / scheduleMergeRetry /
|
||
// resumeAfterUnpauseAndSweepInReview) that still routes through the funnel is
|
||
// caught. Surface 4 (the task:moved fast path) shares the same
|
||
// `allowInReviewMergeProcessing` gate, which is covered by the direct helper
|
||
// tests above.
|
||
|
||
describe("enqueueEligibleInReviewTasks honors per-task autoMerge override (shared sweep funnel)", () => {
|
||
const inReview = (id: string, overrides: Partial<Task> = {}): Task =>
|
||
({
|
||
id,
|
||
column: "in-review",
|
||
paused: false,
|
||
mergeRetries: 0,
|
||
status: null,
|
||
...overrides,
|
||
}) as unknown as Task;
|
||
|
||
const setup = () => {
|
||
const engine = createEngine() as any;
|
||
const enqueueSpy = vi
|
||
.spyOn(engine, "internalEnqueueMerge")
|
||
.mockImplementation(() => true);
|
||
const run = (tasks: Task[], settings: { autoMerge: boolean }): Promise<number> =>
|
||
engine.enqueueEligibleInReviewTasks(tasks, settings);
|
||
return { engine, enqueueSpy, run };
|
||
};
|
||
|
||
it("enqueues an in-review task with autoMerge:true even when the global setting is off", async () => {
|
||
const { enqueueSpy, run } = setup();
|
||
const count = await run([inReview("FN-override", { autoMerge: true })], { autoMerge: false });
|
||
expect(count).toBe(1);
|
||
expect(enqueueSpy).toHaveBeenCalledWith("FN-override");
|
||
});
|
||
|
||
it("does not enqueue a sibling task without an override in the same sweep when the global setting is off", async () => {
|
||
const { enqueueSpy, run } = setup();
|
||
const count = await run(
|
||
[inReview("FN-override", { autoMerge: true }), inReview("FN-plain")],
|
||
{ autoMerge: false },
|
||
);
|
||
expect(count).toBe(1);
|
||
expect(enqueueSpy).toHaveBeenCalledWith("FN-override");
|
||
expect(enqueueSpy).not.toHaveBeenCalledWith("FN-plain");
|
||
});
|
||
|
||
it("still enqueues a task with autoMerge:false when the global setting is on (parked manual-required downstream)", async () => {
|
||
const { enqueueSpy, run } = setup();
|
||
const count = await run([inReview("FN-explicit-false", { autoMerge: false })], { autoMerge: true });
|
||
expect(count).toBe(1);
|
||
expect(enqueueSpy).toHaveBeenCalledWith("FN-explicit-false");
|
||
});
|
||
});
|