feat(HAI-016): add shared AgentSemaphore for concurrent agent limiting

- Create AgentSemaphore class in engine/src/concurrency.ts with configurable maxConcurrent
- Integrate semaphore into TriageProcessor and TaskExecutor to gate agent spawning
- Wire semaphore into merge path and dashboard engine components
- Add comprehensive tests for concurrency, executor, and triage integration
- Update JSDoc and types for maxConcurrent configuration option
This commit is contained in:
Dustin Byrne
2026-03-25 21:27:29 -04:00
12 changed files with 776 additions and 47 deletions

View File

@@ -6,6 +6,7 @@ import { Type } from "@mariozechner/pi-ai";
import { createHaiAgent } from "./pi.js";
import { reviewStep } from "./reviewer.js";
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
import type { AgentSemaphore } from "./concurrency.js";
const STEP_STATUSES: StepStatus[] = ["pending", "in-progress", "done", "skipped"];
@@ -79,6 +80,7 @@ echo "done" > .DONE
\`\`\``;
export interface TaskExecutorOptions {
semaphore?: AgentSemaphore;
onStart?: (task: Task, worktreePath: string) => void;
onComplete?: (task: Task) => void;
onError?: (task: Task, error: Error) => void;
@@ -152,33 +154,41 @@ export class TaskExecutor {
this.createReviewStepTool(task.id, worktreePath, detail.prompt),
];
const { session } = await createHaiAgent({
cwd: worktreePath,
systemPrompt: EXECUTOR_SYSTEM_PROMPT,
tools: "coding",
customTools,
onText: (delta) => this.options.onAgentText?.(task.id, delta),
onToolStart: (name) => this.options.onAgentTool?.(task.id, name),
});
const agentWork = async () => {
const { session } = await createHaiAgent({
cwd: worktreePath,
systemPrompt: EXECUTOR_SYSTEM_PROMPT,
tools: "coding",
customTools,
onText: (delta) => this.options.onAgentText?.(task.id, delta),
onToolStart: (name) => this.options.onAgentTool?.(task.id, name),
});
try {
const agentPrompt = buildExecutionPrompt(detail);
await session.prompt(agentPrompt);
try {
const agentPrompt = buildExecutionPrompt(detail);
await session.prompt(agentPrompt);
const doneCwd = join(worktreePath, ".DONE");
if (existsSync(doneCwd)) {
await this.store.logEntry(task.id, "Execution complete — .DONE created");
await this.store.moveTask(task.id, "in-review");
console.log(`[executor] ✓ ${task.id} completed → in-review`);
this.options.onComplete?.(task);
} else {
await this.store.logEntry(task.id, "Agent finished without .DONE — moved to in-review for inspection");
await this.store.moveTask(task.id, "in-review");
console.log(`[executor] ⚠ ${task.id} agent finished without .DONE → in-review`);
this.options.onComplete?.(task);
const doneCwd = join(worktreePath, ".DONE");
if (existsSync(doneCwd)) {
await this.store.logEntry(task.id, "Execution complete — .DONE created");
await this.store.moveTask(task.id, "in-review");
console.log(`[executor] ✓ ${task.id} completed → in-review`);
this.options.onComplete?.(task);
} else {
await this.store.logEntry(task.id, "Agent finished without .DONE — moved to in-review for inspection");
await this.store.moveTask(task.id, "in-review");
console.log(`[executor] ⚠ ${task.id} agent finished without .DONE → in-review`);
this.options.onComplete?.(task);
}
} finally {
session.dispose();
}
} finally {
session.dispose();
};
if (this.options.semaphore) {
await this.options.semaphore.run(agentWork);
} else {
await agentWork();
}
} catch (err: any) {
console.error(`[executor] ✗ ${task.id} execution failed:`, err.message);