feat(HAI-016): complete Step 4 — integrate semaphore into merge path

This commit is contained in:
Dustin Byrne
2026-03-25 21:21:42 -04:00
parent d487ebee51
commit 6a45531a79
2 changed files with 69 additions and 3 deletions

View File

@@ -1,7 +1,7 @@
import { exec } from "node:child_process";
import { TaskStore } from "@hai/core";
import { createServer } from "@hai/dashboard";
import { TriageProcessor, TaskExecutor, Scheduler, aiMergeTask } from "@hai/engine";
import { TriageProcessor, TaskExecutor, Scheduler, AgentSemaphore, aiMergeTask } from "@hai/engine";
function openBrowser(url: string): void {
const cmd =
@@ -17,13 +17,32 @@ export async function runDashboard(port: number, opts: { engine?: boolean; open?
await store.init();
await store.watch();
// AI-powered merge handler (used by the web UI for manual merges)
const onMerge = (taskId: string) =>
// ── Shared concurrency semaphore ──────────────────────────────────
//
// Gates all agentic activities (triage, execution, merge) behind a
// single slot limit so they collectively respect settings.maxConcurrent.
// Created eagerly so the merge queue can reference it; the engine block
// below passes it to triage/executor/scheduler as well.
//
// The limit is read from a cached value that is refreshed from the store
// on each scheduler poll cycle (see engine block below). This avoids
// async I/O in the synchronous getter while still picking up live changes.
//
const initialSettings = await store.getSettings();
let cachedMaxConcurrent = initialSettings.maxConcurrent;
const semaphore = new AgentSemaphore(() => cachedMaxConcurrent);
// AI-powered merge handler (used by the web UI for manual merges).
// Wrapped with the shared semaphore so merges count toward the global
// concurrency limit alongside triage and execution agents.
const rawMerge = (taskId: string) =>
aiMergeTask(store, cwd, taskId, {
onAgentText: (delta) => process.stdout.write(delta),
onAgentTool: (name) => console.log(`[merger] tool: ${name}`),
});
const onMerge = (taskId: string) => semaphore.run(() => rawMerge(taskId));
// ── Serialized auto-merge queue ─────────────────────────────────────
//
// Three paths feed into this queue:

View File

@@ -180,6 +180,53 @@ describe("AgentSemaphore", () => {
expect(sem.activeCount).toBe(0);
});
it("integration: simulates merge-like usage with semaphore.run()", async () => {
const sem = new AgentSemaphore(1);
let concurrent = 0;
let maxConcurrent = 0;
// Simulate serialized merge queue where each merge also goes through semaphore
const rawMerge = async () => {
concurrent++;
maxConcurrent = Math.max(maxConcurrent, concurrent);
await new Promise((r) => setTimeout(r, 10));
concurrent--;
};
const onMerge = () => sem.run(rawMerge);
await Promise.all([onMerge(), onMerge(), onMerge()]);
expect(maxConcurrent).toBe(1);
expect(sem.activeCount).toBe(0);
});
it("integration: shared semaphore limits triage + execution + merge together", async () => {
const sem = new AgentSemaphore(2);
let concurrent = 0;
let maxConcurrent = 0;
const simulateAgent = () =>
sem.run(async () => {
concurrent++;
maxConcurrent = Math.max(maxConcurrent, concurrent);
await new Promise((r) => setTimeout(r, 10));
concurrent--;
});
// Simulate mixed activity: 2 triage + 2 execution + 2 merge = 6 total
await Promise.all([
simulateAgent(), // triage
simulateAgent(), // triage
simulateAgent(), // execution
simulateAgent(), // execution
simulateAgent(), // merge
simulateAgent(), // merge
]);
// Should never exceed 2 concurrent despite 6 tasks
expect(maxConcurrent).toBe(2);
expect(sem.activeCount).toBe(0);
});
it("integration: semaphore is optional (no-op when absent)", async () => {
const semaphore: AgentSemaphore | undefined = undefined;
let ran = false;