Files
fusion/packages/engine/src/__tests__/reliability-interactions/post-finalize-verification-noop-status-write.test.ts
gsxdsm 29ac58f8d0 feat(FN-5633): standalone AI merge path (clean-room merge + AI reviewer)
New default merge path (merger.mode="ai"), self-contained in merger-ai.ts and
dispatched from ProjectEngine.onMerge instead of the legacy aiMergeTask pipeline
(kept for merger.mode="deterministic").

Flow: clean-room detached worktree at the target branch tip → AI agent merges
the task branch + squashes (resolving conflicts) → fresh read-only AI reviewer
audits with corrective retries (blocking vs advisory; advisory lands, unfixable
correctness hard-fails via AiMergeBlockedError; fail-safe verdict parsing) →
land via `git merge --ff-only` when the checkout is on the target (else
update-ref CAS) → sync the local checkout (stash → ff → restore; AI reconciles
a conflicting restore and keeps the original edits in a backup stash;
un-stashable dirt advances the ref + warns) → finalize (delete task branch —
never the integration branch — task→done, remove temp worktree).

- Per-task target branch honored (falls back to the default integration
  branch); local checkout synced only when on that target.
- Structurally immune to the dirty-clobber and stale-base/non-FF bug classes of
  the legacy path (clean room + FF-by-construction).
- Progress surfaced on the task status pill + task log stream.
- Clear error when the target branch has no local ref.

Settings: merger.mode / merger.reviewerModel / merger.maxReviewPasses, surfaced
in Settings → Merge; legacy merge-mechanics settings hidden when AI mode is on.

Tests: merger-ai.test.ts (verdict parser, clean merge, blocking hard-fail,
advisory land, empty no-op, target-branch isolation, missing-target error,
landSquash clean/other-branch/dirty-restore/AI-resolved). Legacy
merge-orchestration tests pinned to deterministic mode.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 18:50:23 -07:00

168 lines
5.6 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import { EventEmitter } from "node:events";
import type { Settings, Task, TaskStore } from "@fusion/core";
const testState = vi.hoisted(() => ({
aiMergeTask: vi.fn(),
currentStore: null as (TaskStore & EventEmitter) | null,
}));
vi.mock("../../merger.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../merger.js")>();
return {
...actual,
aiMergeTask: testState.aiMergeTask,
};
});
vi.mock("../../runtimes/in-process-runtime.js", () => ({
InProcessRuntime: vi.fn().mockImplementation(() => ({
start: vi.fn(async () => undefined),
stop: vi.fn(async () => undefined),
getTaskStore: () => testState.currentStore,
getAgentStore: vi.fn(),
getMessageStore: vi.fn(),
getRoutineStore: vi.fn(),
getRoutineRunner: vi.fn(),
getHeartbeatMonitor: vi.fn(),
getTriggerScheduler: vi.fn(),
})),
}));
import { ProjectEngine } from "../../project-engine.js";
function makeTask(overrides: Partial<Task> = {}): Task {
return {
id: "FN-5003",
title: "t",
description: "d",
column: "in-review",
status: "merging",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
verificationFailureCount: 0,
...overrides,
} as Task;
}
function createStore(task: Task, sequence: Task[]) {
const emitter = new EventEmitter();
const logs: string[] = [];
const audits: Array<{ mutationType: string; metadata?: Record<string, unknown> }> = [];
let taskIdx = 0;
const store = Object.assign(emitter, {
getSettings: vi.fn(async () => ({
autoMerge: true,
autoResolveConflicts: true,
globalPause: false,
enginePaused: false,
pollIntervalMs: 15_000,
merger: { mode: "deterministic" },
} as Settings)),
listTasks: vi.fn(async () => [task]),
getTask: vi.fn(async () => {
const current = sequence[Math.min(taskIdx, sequence.length - 1)] ?? task;
taskIdx += 1;
return current;
}),
updateTask: vi.fn(async () => undefined),
addTaskComment: vi.fn(async () => undefined),
moveTask: vi.fn(async () => undefined),
logEntry: vi.fn(async (_id: string, message: string) => {
logs.push(message);
}),
getActiveMergingTask: vi.fn(() => null),
createTask: vi.fn(async () => ({ id: "FN-CHILD" })),
on: emitter.on.bind(emitter),
off: emitter.off.bind(emitter),
walCheckpoint: () => ({ busy: 0, log: 0, checkpointed: 0 }),
archiveTaskAndCleanup: async () => ({}),
clearStaleExecutionStartBranchReferences: () => [],
updateSettings: async () => ({}),
mergeTask: async () => undefined,
getRootDir: () => "",
recordRunAuditEvent: vi.fn(async (input: { mutationType: string; metadata?: Record<string, unknown> }) => {
audits.push({ mutationType: input.mutationType, metadata: input.metadata });
}),
}) as unknown as TaskStore & EventEmitter;
return { store, logs, audits };
}
async function runMergeCycle(engine: ProjectEngine, taskId: string): Promise<void> {
const privateEngine = engine as unknown as {
mergeQueue: string[];
mergeActive: Set<string>;
drainMergeQueue: () => Promise<void>;
};
privateEngine.mergeActive.add(taskId);
privateEngine.mergeQueue.push(taskId);
await privateEngine.drainMergeQueue();
}
describe("post-finalize verification noop status-write guard", () => {
beforeEach(() => {
vi.clearAllMocks();
testState.aiMergeTask.mockReset();
testState.currentStore = null;
});
it.each([
{ name: "under-cap", failureCount: 1, blockedStatus: "merging-fix" },
{ name: "at-cap", failureCount: 2, blockedStatus: "failed" },
])("keeps done task unchanged on $name write path", async ({ failureCount, blockedStatus }) => {
const verificationError = new Error("Deterministic test verification failed: no-op race");
verificationError.name = "VerificationError";
testState.aiMergeTask.mockRejectedValueOnce(verificationError);
const inReviewTask = makeTask({ verificationFailureCount: failureCount });
const doneTask = makeTask({
column: "done",
verificationFailureCount: failureCount,
mergeDetails: { mergeConfirmed: true, commitSha: "abcdef1234567890" },
});
const { store, logs, audits } = createStore(inReviewTask, [inReviewTask, inReviewTask, inReviewTask, doneTask]);
testState.currentStore = store;
const engine = new ProjectEngine(
{
projectId: "proj_test",
workingDirectory: process.cwd(),
isolationMode: "in-process",
maxConcurrent: 1,
maxWorktrees: 1,
},
{} as never,
{ skipNotifier: true },
);
await runMergeCycle(engine, inReviewTask.id);
expect(store.updateTask).not.toHaveBeenCalledWith(
inReviewTask.id,
expect.objectContaining({ status: blockedStatus }),
);
expect(store.moveTask).not.toHaveBeenCalledWith(inReviewTask.id, "in-progress");
expect(store.createTask).not.toHaveBeenCalledWith(
expect.objectContaining({ source: expect.objectContaining({ sourceType: "recovery" }) }),
);
const noopLogs = logs.filter((entry) =>
entry.includes("[verification] post-finalize VerificationError on already-done task — no action"),
);
expect(noopLogs).toHaveLength(1);
const noopAudits = audits.filter((event) => event.mutationType === "task:post-finalize-verification-no-op");
expect(noopAudits).toHaveLength(1);
expect(noopAudits[0]?.metadata).toEqual(expect.objectContaining({
failedCommand: null,
exitCode: null,
}));
});
});