fix(HAI-010): serialize auto-merge with queue, startup sweep, and periodic retry

- Add serialized merge queue to prevent concurrent git merge operations
- Enqueue in-review tasks on startup so pre-existing tasks are not missed
- Add periodic retry interval to re-attempt failed merges automatically
- Re-check autoMerge setting and task column before each merge attempt
- Clean up merge retry interval on SIGINT for graceful shutdown
This commit is contained in:
Dustin Byrne
2026-03-25 21:01:30 -04:00

View File

@@ -17,25 +17,84 @@ export async function runDashboard(port: number, opts: { engine?: boolean; open?
await store.init();
await store.watch();
// AI-powered merge handler
// AI-powered merge handler (used by the web UI for manual merges)
const onMerge = (taskId: string) =>
aiMergeTask(store, cwd, taskId, {
onAgentText: (delta) => process.stdout.write(delta),
onAgentTool: (name) => console.log(`[merger] tool: ${name}`),
});
// Auto-merge: when a task lands in "in-review" and autoMerge is enabled, merge it
// ── Serialized auto-merge queue ─────────────────────────────────────
//
// Three paths feed into this queue:
// 1. Event-driven: `task:moved` → "in-review" (immediate reaction)
// 2. Startup sweep: tasks already in "in-review" when the engine starts
// 3. Periodic retry: a setInterval catches tasks stuck in "in-review"
// after a previous merge attempt failed
//
// The queue ensures only one `aiMergeTask` runs at a time, preventing
// concurrent git merge operations in rootDir. Task IDs in the queue or
// actively being processed are tracked in `mergeActive` so the periodic
// sweep doesn't re-enqueue them.
//
const mergeQueue: string[] = [];
const mergeActive = new Set<string>(); // IDs queued or currently merging
let mergeRunning = false;
/** Enqueue a task for auto-merge if not already queued/active. */
function enqueueMerge(taskId: string): void {
if (mergeActive.has(taskId)) return;
mergeActive.add(taskId);
mergeQueue.push(taskId);
drainMergeQueue();
}
/** Process the merge queue sequentially. */
async function drainMergeQueue(): Promise<void> {
if (mergeRunning) return;
mergeRunning = true;
try {
while (mergeQueue.length > 0) {
const taskId = mergeQueue.shift()!;
try {
// Re-check autoMerge before each merge (setting may have been toggled)
const settings = await store.getSettings();
if (!settings.autoMerge) {
console.log(`[auto-merge] Skipping ${taskId} — autoMerge disabled`);
continue;
}
// Verify the task is still in-review (it may have been manually moved)
const task = await store.getTask(taskId);
if (task.column !== "in-review") {
continue;
}
console.log(`[auto-merge] Merging ${taskId}...`);
await onMerge(taskId);
console.log(`[auto-merge] ✓ ${taskId} merged`);
} catch (err: any) {
console.log(`[auto-merge] ✗ ${taskId}: ${err.message ?? err}`);
// Reset task status so it doesn't appear stuck as "merging" in the UI
try {
await store.updateTask(taskId, { status: null });
} catch { /* best-effort */ }
} finally {
mergeActive.delete(taskId);
}
}
} finally {
mergeRunning = false;
}
}
// Auto-merge: when a task lands in "in-review" and autoMerge is enabled,
// enqueue it for serialized merge processing.
store.on("task:moved", async ({ task, to }) => {
if (to !== "in-review") return;
try {
const settings = await store.getSettings();
if (!settings.autoMerge) return;
console.log(`[auto-merge] Merging ${task.id}...`);
await onMerge(task.id);
console.log(`[auto-merge] ✓ ${task.id} merged`);
} catch (err: any) {
console.log(`[auto-merge] ✗ ${task.id}: ${err.message ?? err}`);
}
enqueueMerge(task.id);
} catch { /* ignore settings read errors */ }
});
// Start the web server with AI merge wired in
@@ -75,9 +134,40 @@ export async function runDashboard(port: number, opts: { engine?: boolean; open?
triage.start();
scheduler.start();
// ── Startup sweep: enqueue any tasks already in "in-review" ───────
if (settings.autoMerge) {
const existing = await store.listTasks();
const inReview = existing.filter((t) => t.column === "in-review");
if (inReview.length > 0) {
console.log(
`[auto-merge] Startup sweep: enqueueing ${inReview.length} in-review task(s)`,
);
for (const t of inReview) {
enqueueMerge(t.id);
}
}
}
// ── Periodic retry: catch failed merges on each poll cycle ────────
// Uses the same interval as the scheduler so failed merges are
// retried at a predictable cadence without adding extra timers.
const mergeRetryInterval = setInterval(async () => {
try {
const currentSettings = await store.getSettings();
if (!currentSettings.autoMerge) return;
const tasks = await store.listTasks();
for (const t of tasks) {
if (t.column === "in-review") {
enqueueMerge(t.id);
}
}
} catch { /* ignore errors in periodic sweep */ }
}, settings.pollIntervalMs ?? 15_000);
process.on("SIGINT", () => {
triage.stop();
scheduler.stop();
clearInterval(mergeRetryInterval);
store.stopWatching();
process.exit(0);
});