feat(FN-2571): enforce merger aborts through shutdown and git flow

- Add merger abort primitives and track active merge runs for coordinated cancellation
- Abort in-flight merges during engine shutdown and propagate AbortError through fallback catch paths
- Honor abort signals before commit, push, and dependency sync to prevent post-cancel side effects
- Expand merger and project-engine tests to cover abort propagation and merge-abort-on-stop behavior
This commit is contained in:
Fusion
2026-04-25 21:39:35 -07:00
committed by gsxdsm
parent 473b53d306
commit 9aff532787
4 changed files with 364 additions and 19 deletions

View File

@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ProjectEngine } from "../project-engine.js";
import { runtimeLog } from "../logger.js";
import { aiMergeTask } from "../merger.js";
const mocks = vi.hoisted(() => ({
syncInsightExtractionAutomation: vi.fn(),
@@ -12,6 +13,7 @@ const mocks = vi.hoisted(() => ({
cronRunnerStop: vi.fn(),
runtimeStart: vi.fn(async () => undefined),
runtimeStop: vi.fn(async () => undefined),
aiMergeTask: vi.fn(),
currentStore: null as Record<string, unknown> | null,
}));
@@ -40,6 +42,10 @@ vi.mock("../cron-runner.js", () => {
};
});
vi.mock("../merger.js", () => ({
aiMergeTask: mocks.aiMergeTask,
}));
vi.mock("../pr-monitor.js", () => ({
PrMonitor: vi.fn().mockImplementation(() => ({
onNewComments: vi.fn(),
@@ -85,6 +91,12 @@ function createMockStore(initialSettings: Record<string, unknown>) {
const store = {
getSettings: vi.fn(async () => ({ ...settings })),
listTasks: vi.fn(async () => []),
getTask: vi.fn(async (taskId: string) => ({ id: taskId, column: "in-review", mergeRetries: 0, status: null })),
updateTask: vi.fn(async () => undefined),
moveTask: vi.fn(async () => undefined),
logEntry: vi.fn(async () => undefined),
addTaskComment: vi.fn(async () => undefined),
getActiveMergingTask: vi.fn(() => null),
on: vi.fn((event: string, handler: (...args: unknown[]) => void | Promise<void>) => {
if (event === "settings:updated") {
settingsHandlers.add(handler as (payload: SettingsHandlerPayload) => void | Promise<void>);
@@ -147,6 +159,13 @@ describe("ProjectEngine auto-summarize wiring", () => {
vi.clearAllMocks();
const mockStore = createMockStore(baseSettings);
mocks.currentStore = mockStore.store;
mocks.aiMergeTask.mockResolvedValue({
task: { id: "FN-001", column: "done" },
branch: "fusion/fn-001",
merged: true,
worktreeRemoved: false,
branchDeleted: true,
});
});
it("syncs startup memory automations using one settings snapshot", async () => {
@@ -215,6 +234,67 @@ describe("ProjectEngine auto-summarize wiring", () => {
});
});
describe("ProjectEngine shutdown merge handling", () => {
beforeEach(() => {
vi.clearAllMocks();
const mockStore = createMockStore({ ...baseSettings, autoMerge: true });
mocks.currentStore = mockStore.store;
});
it("aborts active merges, clears pending queue, and blocks new merges after stop", async () => {
const engine = createEngine();
await engine.start();
const privateEngine = engine as unknown as {
mergeQueue: string[];
mergeActive: Set<string>;
mergeAbortController: AbortController | null;
mergeRetryTimer: ReturnType<typeof setTimeout> | null;
activeMergeSession: { dispose: () => void } | null;
};
let capturedSignal: AbortSignal | undefined;
mocks.aiMergeTask.mockImplementationOnce(async (...args: unknown[]) => {
const options = args[3] as { signal?: AbortSignal } | undefined;
capturedSignal = options?.signal;
await new Promise<never>((_, reject) => {
options?.signal?.addEventListener("abort", () => {
const abortError = new Error("merge aborted");
abortError.name = "MergeAbortedError";
reject(abortError);
}, { once: true });
});
});
const manualMergePromise = engine.onMerge("FN-123");
engine.enqueueMerge("FN-queued");
await vi.waitFor(() => {
expect(mocks.aiMergeTask).toHaveBeenCalledTimes(1);
});
expect(capturedSignal?.aborted).toBe(false);
await engine.stop();
await expect(manualMergePromise).rejects.toThrow("Engine shutting down");
expect(capturedSignal?.aborted).toBe(true);
expect(privateEngine.mergeQueue).toHaveLength(0);
expect(privateEngine.mergeRetryTimer).toBeNull();
expect(privateEngine.activeMergeSession).toBeNull();
await vi.waitFor(() => {
expect(privateEngine.mergeActive.has("FN-123")).toBe(false);
});
expect(privateEngine.mergeAbortController).toBeNull();
const mergeCallsBeforeRequeue = mocks.aiMergeTask.mock.calls.length;
engine.enqueueMerge("FN-after-stop");
expect(privateEngine.mergeQueue).toHaveLength(0);
expect(mocks.aiMergeTask).toHaveBeenCalledTimes(mergeCallsBeforeRequeue);
});
});
describe("ProjectEngine swallowed error hardening", () => {
let warnSpy: ReturnType<typeof vi.spyOn>;