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>
168 lines
5.6 KiB
TypeScript
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,
|
|
}));
|
|
});
|
|
});
|