fix(engine): add cross-process merge guard to prevent concurrent merges

Multiple engine processes (dashboard + serve) share the same SQLite database
but each has its own in-memory merge queue. Without a cross-process check,
two processes can start merging different tasks simultaneously.

Added store.getActiveMergingTask() as a DB-level check before any merge
starts. The drainMergeQueue defers with pollIntervalMs delay, and both
aiMergeTask and processPullRequestMergeTask have safety-net checks.
Also moved stale merge status cleanup to run regardless of autoMerge setting.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-13 20:21:58 -07:00
parent 827c0370c3
commit f9f7aff3ec
7 changed files with 70 additions and 3 deletions

View File

@@ -65,6 +65,7 @@ const mocks = vi.hoisted(() => {
emitter.off(event, handler);
}),
emit: emitter.emit.bind(emitter),
getActiveMergingTask: vi.fn().mockReturnValue(undefined),
};
}

View File

@@ -65,6 +65,7 @@ function makeMockStore() {
logEntry: vi.fn().mockResolvedValue(undefined),
updateTask: vi.fn().mockResolvedValue({}),
getFusionDir: vi.fn().mockReturnValue("/tmp/test/.fusion"),
getActiveMergingTask: vi.fn().mockReturnValue(undefined),
getMissionStore: vi.fn().mockReturnValue(mockMissionStore),
close: vi.fn(),
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {

View File

@@ -213,6 +213,12 @@ export async function processPullRequestMergeTask(
return "waiting";
}
// Cross-process safety net: abort if another task is already mid-merge.
const activeMerge = store.getActiveMergingTask(task.id);
if (activeMerge) {
await store.updateTask(task.id, { status: "awaiting-pr-checks" });
return "waiting";
}
await store.updateTask(task.id, { status: "merging-pr" });
const mergedPr = await github.mergePr({ number: prInfo.number, method: "squash" });
await store.updatePrInfo(task.id, { ...mergedPr, lastCheckedAt: new Date().toISOString() });

View File

@@ -1435,6 +1435,24 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
return sorted.slice(offset, offset + Math.max(0, limit));
}
/**
* Returns the ID of a task currently in an active merge status ("merging" or
* "merging-pr"), optionally excluding a specific task ID.
*
* This is a lightweight database-level check used as a cross-process guard:
* multiple engine processes share the same SQLite database, but each has its
* own in-memory merge queue. Without this check, two processes can start
* merging different tasks simultaneously.
*/
getActiveMergingTask(excludeTaskId?: string): string | undefined {
const sql = excludeTaskId
? `SELECT id FROM tasks WHERE status IN ('merging', 'merging-pr') AND id != ? LIMIT 1`
: `SELECT id FROM tasks WHERE status IN ('merging', 'merging-pr') LIMIT 1`;
const params = excludeTaskId ? [excludeTaskId] : [];
const row = this.db.prepare(sql).get(...params) as { id: string } | undefined;
return row?.id;
}
/**
* Search tasks by full-text query across title, ID, description, and comments.
* Uses SQLite FTS5 for fast tokenized matching with relevance ranking.

View File

@@ -1298,6 +1298,14 @@ export async function aiMergeTask(
}
// 5. Execute merge with retry logic
// Cross-process safety net: abort if another task is already mid-merge.
// The engine's drainMergeQueue also checks, but this catches direct callers.
const activeMerge = store.getActiveMergingTask(taskId);
if (activeMerge) {
throw new Error(
`Cannot merge ${taskId}: task ${activeMerge} is already merging (cross-process conflict)`,
);
}
await store.updateTask(taskId, { status: "merging" });
// Normalize explicit verification commands from settings

View File

@@ -471,6 +471,36 @@ export class ProjectEngine {
}
const settings = await store.getSettings();
// Cross-process guard: check if another process is already merging a
// task for this project. The in-memory mergeQueue serializes within
// this process, but multiple processes (e.g. dashboard + serve) share
// the same SQLite database and can race.
const activeMergingTask = store.getActiveMergingTask(taskId);
if (activeMergingTask) {
const retryMs = settings.pollIntervalMs ?? 15_000;
runtimeLog.log(
`Merge deferred for ${taskId} — ${activeMergingTask} is already merging (cross-process guard, retry in ${retryMs / 1000}s)`,
);
// Temporarily remove the manual resolver so the finally block
// doesn't prematurely resolve it. The re-enqueue will restore it.
if (manualResolver) {
this.manualMergeResolvers.delete(taskId);
}
// Re-queue after the poll interval so we retry once the other merge finishes
setTimeout(() => {
if (this.shuttingDown) {
manualResolver?.reject(new Error("Engine shutting down"));
return;
}
if (manualResolver) {
this.manualMergeResolvers.set(taskId, manualResolver);
}
this.internalEnqueueMerge(taskId);
}, retryMs);
continue;
}
const mergeStrategy = this.options.getMergeStrategy?.(settings) ?? "direct";
if (mergeStrategy === "pull-request" && this.options.processPullRequestMerge) {
@@ -679,14 +709,13 @@ export class ProjectEngine {
private async startupMergeSweep(store: TaskStore): Promise<void> {
try {
const settings = await store.getSettings();
if (!settings.autoMerge) return;
const tasks = await store.listTasks({ column: "in-review" });
// Clear stale "merging"/"merging-pr" statuses left by a prior crash.
// No merge is actually running at startup, so any task still marked
// as merging is a leftover from a previous engine lifecycle.
// This runs unconditionally (regardless of autoMerge setting) because
// stale statuses block manual merges too.
const staleStatuses = new Set(["merging", "merging-pr"]);
for (const t of tasks) {
if (t.status && staleStatuses.has(t.status)) {
@@ -697,6 +726,9 @@ export class ProjectEngine {
}
}
const settings = await store.getSettings();
if (!settings.autoMerge) return;
const eligible = tasks.filter((t) => this.canMergeTask(t as any));
if (eligible.length > 0) {
runtimeLog.log(`Auto-merge startup sweep: enqueueing ${eligible.length} task(s)`);

View File

@@ -145,6 +145,7 @@ function createMockStore(overrides: Record<string, any> = {}) {
return makeTask("FN-NEW", "triage");
}),
deleteTask: vi.fn().mockResolvedValue(undefined),
getActiveMergingTask: vi.fn().mockReturnValue(undefined),
_trigger(event: string, ...args: any[]) {
for (const fn of listeners.get(event) || []) fn(...args);
},