feat(FN-1496): harden semaphore lane-vs-utility boundary
- Add explicit boundary comments to serve.ts and dashboard.ts clarifying semaphore lane usage - Add regression tests for semaphore lane-vs-utility boundary in serve.test.ts and dashboard.test.ts - Add changeset for @gsxdsm/fusion patch release
This commit is contained in:
@@ -564,3 +564,125 @@ describe("runDashboard — Memory Insight Automation wiring", () => {
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("runDashboard — Semaphore boundary (task lanes only)", () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
mockDiscoverAndLoadExtensions.mockResolvedValue({
|
||||
runtime: { pendingProviderRegistrations: [] },
|
||||
errors: [],
|
||||
});
|
||||
const { TaskStore } = await import("@fusion/core");
|
||||
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => makeMockStore());
|
||||
});
|
||||
|
||||
it("passes semaphore to TriageProcessor (task lane)", async () => {
|
||||
const { TriageProcessor } = await import("@fusion/engine");
|
||||
|
||||
await runDashboard(0, {});
|
||||
|
||||
expect(TriageProcessor).toHaveBeenCalledTimes(1);
|
||||
const triageOptions = (TriageProcessor as ReturnType<typeof vi.fn>).mock.calls[0][2];
|
||||
expect(triageOptions).toHaveProperty("semaphore");
|
||||
expect(triageOptions.semaphore).toBeDefined();
|
||||
});
|
||||
|
||||
it("passes semaphore to TaskExecutor (task lane)", async () => {
|
||||
const { TaskExecutor } = await import("@fusion/engine");
|
||||
|
||||
await runDashboard(0, {});
|
||||
|
||||
expect(TaskExecutor).toHaveBeenCalledTimes(1);
|
||||
const executorOptions = (TaskExecutor as ReturnType<typeof vi.fn>).mock.calls[0][2];
|
||||
expect(executorOptions).toHaveProperty("semaphore");
|
||||
expect(executorOptions.semaphore).toBeDefined();
|
||||
});
|
||||
|
||||
it("passes semaphore to Scheduler (task lane)", async () => {
|
||||
const { Scheduler } = await import("@fusion/engine");
|
||||
|
||||
await runDashboard(0, {});
|
||||
|
||||
expect(Scheduler).toHaveBeenCalledTimes(1);
|
||||
const schedulerOptions = (Scheduler as ReturnType<typeof vi.fn>).mock.calls[0][1];
|
||||
expect(schedulerOptions).toHaveProperty("semaphore");
|
||||
expect(schedulerOptions.semaphore).toBeDefined();
|
||||
});
|
||||
|
||||
it("creates shared semaphore instance for task lanes", async () => {
|
||||
const { AgentSemaphore } = await import("@fusion/engine");
|
||||
const { TriageProcessor, TaskExecutor, Scheduler } = await import("@fusion/engine");
|
||||
|
||||
await runDashboard(0, {});
|
||||
|
||||
// Get the semaphore instance from each component
|
||||
const triageSemaphore = (TriageProcessor as ReturnType<typeof vi.fn>).mock.calls[0][2].semaphore;
|
||||
const executorSemaphore = (TaskExecutor as ReturnType<typeof vi.fn>).mock.calls[0][2].semaphore;
|
||||
const schedulerSemaphore = (Scheduler as ReturnType<typeof vi.fn>).mock.calls[0][1].semaphore;
|
||||
|
||||
// All should reference the same semaphore instance
|
||||
expect(triageSemaphore).toBe(executorSemaphore);
|
||||
expect(executorSemaphore).toBe(schedulerSemaphore);
|
||||
});
|
||||
|
||||
it("does NOT pass semaphore to HeartbeatMonitor (utility path)", async () => {
|
||||
const { HeartbeatMonitor } = await import("@fusion/engine");
|
||||
|
||||
await runDashboard(0, {});
|
||||
|
||||
expect(HeartbeatMonitor).toHaveBeenCalledTimes(1);
|
||||
const heartbeatOptions = (HeartbeatMonitor as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
expect(heartbeatOptions).not.toHaveProperty("semaphore");
|
||||
});
|
||||
|
||||
it("does NOT pass semaphore to HeartbeatTriggerScheduler (utility path)", async () => {
|
||||
const { HeartbeatTriggerScheduler } = await import("@fusion/engine");
|
||||
|
||||
await runDashboard(0, {});
|
||||
|
||||
expect(HeartbeatTriggerScheduler).toHaveBeenCalledTimes(1);
|
||||
// HeartbeatTriggerScheduler takes 2-3 args: (agentStore, callback, taskStore?)
|
||||
const triggerOptions = (HeartbeatTriggerScheduler as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
// Semaphore should NOT be in any of the arguments
|
||||
expect(triggerOptions).not.toContainEqual(expect.objectContaining({ _active: expect.any(Number) }));
|
||||
});
|
||||
|
||||
it("does NOT pass semaphore to CronRunner (utility path)", async () => {
|
||||
const { CronRunner } = await import("@fusion/engine");
|
||||
|
||||
await runDashboard(0, {});
|
||||
|
||||
expect(CronRunner).toHaveBeenCalledTimes(1);
|
||||
// CronRunner takes (taskStore, automationStore, options)
|
||||
const cronOptions = (CronRunner as ReturnType<typeof vi.fn>).mock.calls[0][2];
|
||||
expect(cronOptions).not.toHaveProperty("semaphore");
|
||||
});
|
||||
|
||||
it("calls createAiPromptExecutor with cwd only (no semaphore)", async () => {
|
||||
const { createAiPromptExecutor } = await import("@fusion/engine");
|
||||
|
||||
await runDashboard(0, {});
|
||||
|
||||
expect(createAiPromptExecutor).toHaveBeenCalledTimes(1);
|
||||
// createAiPromptExecutor takes only cwd parameter
|
||||
expect(createAiPromptExecutor).toHaveBeenCalledWith(expect.any(String));
|
||||
const calledWith = (createAiPromptExecutor as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
// Should be called with exactly one argument (cwd)
|
||||
expect(calledWith.length).toBe(1);
|
||||
});
|
||||
|
||||
it("onMerge uses semaphore.run() to gate merge execution (task lane)", async () => {
|
||||
const { createServer } = await import("@fusion/dashboard");
|
||||
|
||||
await runDashboard(0, {});
|
||||
|
||||
// The onMerge function is passed to createServer and should use semaphore.run()
|
||||
expect(createServer).toHaveBeenCalledTimes(1);
|
||||
const serverOpts = (createServer as ReturnType<typeof vi.fn>).mock.calls[0][1];
|
||||
expect(serverOpts).toHaveProperty("onMerge");
|
||||
expect(typeof serverOpts.onMerge).toBe("function");
|
||||
// The onMerge function should be a wrapper that uses semaphore.run()
|
||||
// We can't directly test the internals, but we verified semaphore is passed to
|
||||
// the same instance used by triage/executor/scheduler above
|
||||
});
|
||||
});
|
||||
|
||||
@@ -762,3 +762,161 @@ describe("runServe — Memory Insight Automation wiring", () => {
|
||||
await triggerSignal("SIGINT");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runServe — Semaphore boundary (task lanes only)", () => {
|
||||
const originalCwd = process.cwd;
|
||||
const originalOn = process.on;
|
||||
const originalExit = process.exit;
|
||||
|
||||
let signalHandlers: Record<"SIGINT" | "SIGTERM", Array<() => void>>;
|
||||
let cwdSpy: ReturnType<typeof vi.spyOn>;
|
||||
let processOnSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
async function triggerSignal(signal: "SIGINT" | "SIGTERM") {
|
||||
const handlers = signalHandlers[signal];
|
||||
expect(handlers.length).toBeGreaterThan(0);
|
||||
handlers[handlers.length - 1]();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.reset();
|
||||
|
||||
signalHandlers = { SIGINT: [], SIGTERM: [] };
|
||||
|
||||
cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/repo");
|
||||
processOnSpy = vi.spyOn(process, "on").mockImplementation(((event: string, listener: () => void) => {
|
||||
if (event === "SIGINT" || event === "SIGTERM") {
|
||||
signalHandlers[event].push(listener);
|
||||
}
|
||||
return process;
|
||||
}) as typeof process.on);
|
||||
process.exit = vi.fn() as never;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cwdSpy.mockRestore();
|
||||
processOnSpy.mockRestore();
|
||||
process.cwd = originalCwd;
|
||||
process.on = originalOn;
|
||||
process.exit = originalExit;
|
||||
});
|
||||
|
||||
it("passes semaphore to TriageProcessor (task lane)", async () => {
|
||||
await runServe(4040, {});
|
||||
|
||||
expect(mocks.triageCtor).toHaveBeenCalledTimes(1);
|
||||
const triageOptions = mocks.triageCtor.mock.calls[0][2];
|
||||
expect(triageOptions).toHaveProperty("semaphore");
|
||||
expect(triageOptions.semaphore).toBeDefined();
|
||||
|
||||
await triggerSignal("SIGINT");
|
||||
});
|
||||
|
||||
it("passes semaphore to TaskExecutor (task lane)", async () => {
|
||||
await runServe(4040, {});
|
||||
|
||||
expect(mocks.executorCtor).toHaveBeenCalledTimes(1);
|
||||
const executorOptions = mocks.executorCtor.mock.calls[0][2];
|
||||
expect(executorOptions).toHaveProperty("semaphore");
|
||||
expect(executorOptions.semaphore).toBeDefined();
|
||||
|
||||
await triggerSignal("SIGINT");
|
||||
});
|
||||
|
||||
it("passes semaphore to Scheduler (task lane)", async () => {
|
||||
await runServe(4040, {});
|
||||
|
||||
expect(mocks.schedulerCtor).toHaveBeenCalledTimes(1);
|
||||
const schedulerOptions = mocks.schedulerCtor.mock.calls[0][1];
|
||||
expect(schedulerOptions).toHaveProperty("semaphore");
|
||||
expect(schedulerOptions.semaphore).toBeDefined();
|
||||
|
||||
await triggerSignal("SIGINT");
|
||||
});
|
||||
|
||||
it("creates shared semaphore instance for task lanes", async () => {
|
||||
await runServe(4040, {});
|
||||
|
||||
// Get the semaphore instance from each component
|
||||
const triageSemaphore = mocks.triageCtor.mock.calls[0][2].semaphore;
|
||||
const executorSemaphore = mocks.executorCtor.mock.calls[0][2].semaphore;
|
||||
const schedulerSemaphore = mocks.schedulerCtor.mock.calls[0][1].semaphore;
|
||||
|
||||
// All should reference the same semaphore instance
|
||||
expect(triageSemaphore).toBe(executorSemaphore);
|
||||
expect(executorSemaphore).toBe(schedulerSemaphore);
|
||||
|
||||
await triggerSignal("SIGINT");
|
||||
});
|
||||
|
||||
it("does NOT pass semaphore to HeartbeatMonitor (utility path)", async () => {
|
||||
const { HeartbeatMonitor } = await import("@fusion/engine");
|
||||
|
||||
await runServe(4040, {});
|
||||
|
||||
expect(HeartbeatMonitor).toHaveBeenCalledTimes(1);
|
||||
const heartbeatOptions = HeartbeatMonitor.mock.calls[0][0];
|
||||
expect(heartbeatOptions).not.toHaveProperty("semaphore");
|
||||
|
||||
await triggerSignal("SIGINT");
|
||||
});
|
||||
|
||||
it("does NOT pass semaphore to HeartbeatTriggerScheduler (utility path)", async () => {
|
||||
const { HeartbeatTriggerScheduler } = await import("@fusion/engine");
|
||||
|
||||
await runServe(4040, {});
|
||||
|
||||
expect(HeartbeatTriggerScheduler).toHaveBeenCalledTimes(1);
|
||||
// HeartbeatTriggerScheduler takes 2-3 args: (agentStore, callback, taskStore?)
|
||||
const triggerArgs = HeartbeatTriggerScheduler.mock.calls[0];
|
||||
// Semaphore should NOT be in any of the arguments (it would have _active property)
|
||||
expect(triggerArgs).not.toContainEqual(expect.objectContaining({ _active: expect.any(Number) }));
|
||||
|
||||
await triggerSignal("SIGINT");
|
||||
});
|
||||
|
||||
it("does NOT pass semaphore to CronRunner (utility path)", async () => {
|
||||
await runServe(4040, {});
|
||||
|
||||
expect(mocks.cronRunnerCtor).toHaveBeenCalledTimes(1);
|
||||
// CronRunner takes (taskStore, automationStore, options)
|
||||
const cronOptions = mocks.cronRunnerCtor.mock.calls[0][2];
|
||||
expect(cronOptions).not.toHaveProperty("semaphore");
|
||||
|
||||
await triggerSignal("SIGINT");
|
||||
});
|
||||
|
||||
it("calls createAiPromptExecutor with cwd only (no semaphore)", async () => {
|
||||
const { createAiPromptExecutor } = await import("@fusion/engine");
|
||||
|
||||
await runServe(4040, {});
|
||||
|
||||
expect(createAiPromptExecutor).toHaveBeenCalledTimes(1);
|
||||
// createAiPromptExecutor takes only cwd parameter
|
||||
expect(createAiPromptExecutor).toHaveBeenCalledWith(expect.any(String));
|
||||
const calledWith = createAiPromptExecutor.mock.calls[0];
|
||||
// Should be called with exactly one argument (cwd)
|
||||
expect(calledWith.length).toBe(1);
|
||||
|
||||
await triggerSignal("SIGINT");
|
||||
});
|
||||
|
||||
it("onMerge uses semaphore.run() to gate merge execution (task lane)", async () => {
|
||||
const { createServer } = await import("@fusion/dashboard");
|
||||
|
||||
await runServe(4040, {});
|
||||
|
||||
// The onMerge function is passed to createServer and should use semaphore.run()
|
||||
expect(createServer).toHaveBeenCalledTimes(1);
|
||||
const serverOpts = createServer.mock.calls[0][1];
|
||||
expect(serverOpts).toHaveProperty("onMerge");
|
||||
expect(typeof serverOpts.onMerge).toBe("function");
|
||||
// The onMerge function should be a wrapper that uses semaphore.run()
|
||||
// We can't directly test the internals, but we verified semaphore is passed to
|
||||
// the same instance used by triage/executor/scheduler above
|
||||
|
||||
await triggerSignal("SIGINT");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -330,12 +330,17 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
taskStore: store,
|
||||
});
|
||||
|
||||
// ── HeartbeatMonitor: runtime monitoring and execution for agents ───
|
||||
// ── HeartbeatMonitor: runtime monitoring (UTILITY — NO semaphore) ───
|
||||
//
|
||||
// ⚠️ UTILITY PATH: This component does NOT receive the task-lane semaphore.
|
||||
//
|
||||
// Provides the Paperclip-style heartbeat execution engine:
|
||||
// wake → check inbox → work → exit
|
||||
//
|
||||
// Enables agent execution runs triggered by timers, assignments, or manual API calls.
|
||||
// Enables lightweight agent sessions for monitoring, not task-lane work.
|
||||
// By design, heartbeat sessions are independent of task concurrency limits
|
||||
// so they can run regardless of how busy the task lanes are.
|
||||
//
|
||||
// Passed to createServer to enable the dashboard's heartbeat routes.
|
||||
//
|
||||
let heartbeatMonitor: HeartbeatMonitor | undefined;
|
||||
@@ -355,7 +360,13 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
});
|
||||
heartbeatMonitor.start();
|
||||
|
||||
// HeartbeatTriggerScheduler manages timer and assignment-based triggers
|
||||
// HeartbeatTriggerScheduler: trigger scheduling (UTILITY — NO semaphore) ──
|
||||
//
|
||||
// ⚠️ UTILITY PATH: This scheduler does NOT receive the task-lane semaphore.
|
||||
//
|
||||
// Manages timer and assignment-based triggers for heartbeat execution.
|
||||
// By design, trigger scheduling is independent of task-lane concurrency limits.
|
||||
//
|
||||
triggerScheduler = new HeartbeatTriggerScheduler(
|
||||
agentStore,
|
||||
async (agentId, source, context: WakeContext) => {
|
||||
@@ -429,12 +440,24 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
console.log("[engine] Starting in paused mode — automation disabled");
|
||||
}
|
||||
|
||||
// ── Shared concurrency semaphore ──────────────────────────────────
|
||||
// ── Task-lane 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.
|
||||
// ⚠️ SEMAPHORE BOUNDARY: This semaphore governs ONLY task-lane agents.
|
||||
//
|
||||
// Governed components (task lanes):
|
||||
// - TriageProcessor: specification agents that produce PROMPT.md
|
||||
// - TaskExecutor: task execution agents that implement features
|
||||
// - Scheduler: coordinates which agent gets which task
|
||||
// - onMerge: AI-powered merge execution for completed tasks
|
||||
//
|
||||
// UTILITY WORKFLOWS — NOT governed by this semaphore:
|
||||
// - HeartbeatMonitor: lightweight heartbeat sessions for agent monitoring
|
||||
// - HeartbeatTriggerScheduler: timer/assignment-based trigger scheduling
|
||||
// - CronRunner (via createAiPromptExecutor): scheduled automation prompts
|
||||
// - Model sync, auth setup, plugin loading: bootstrap/setup workflows
|
||||
//
|
||||
// This boundary prevents utility workflows from being blocked by
|
||||
// task-lane saturation and ensures utility work is always available.
|
||||
//
|
||||
// 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
|
||||
@@ -489,9 +512,13 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
const usageLimitPauser = new UsageLimitPauser(store);
|
||||
const githubClient = new GitHubClient();
|
||||
|
||||
// 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.
|
||||
// ── onMerge: AI-powered merge (TASK LANE — semaphore-gated) ─────────────
|
||||
//
|
||||
// ⚠️ TASK LANE: aiMergeTask is wrapped with semaphore.run() to ensure
|
||||
// merge agents count toward settings.maxConcurrent alongside triage and execution.
|
||||
//
|
||||
// The raw aiMergeTask does NOT receive the semaphore directly;
|
||||
// the semaphore gating is applied at the onMerge wrapper level.
|
||||
//
|
||||
// Track the active merge session so it can be killed on global pause.
|
||||
let activeMergeSession: { dispose: () => void } | null = null;
|
||||
@@ -838,6 +865,11 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
},
|
||||
});
|
||||
|
||||
// ── TriageProcessor: task specification (TASK LANE — receives semaphore) ──
|
||||
//
|
||||
// Receives the task-lane semaphore to ensure specification agents
|
||||
// count toward settings.maxConcurrent alongside execution and merge.
|
||||
//
|
||||
const triage = new TriageProcessor(store, cwd, {
|
||||
semaphore,
|
||||
usageLimitPauser,
|
||||
@@ -849,6 +881,11 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
});
|
||||
triageRef.current = triage;
|
||||
|
||||
// ── TaskExecutor: task execution (TASK LANE — receives semaphore) ──────────
|
||||
//
|
||||
// Receives the task-lane semaphore to ensure execution agents
|
||||
// count toward settings.maxConcurrent alongside specification and merge.
|
||||
//
|
||||
const executor = new TaskExecutor(store, cwd, {
|
||||
semaphore,
|
||||
pool,
|
||||
@@ -872,6 +909,11 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
// The scheduler reference is set after construction via setScheduler()
|
||||
// to break the circular dependency.
|
||||
|
||||
// ── Scheduler: task coordination (TASK LANE — receives semaphore) ──────────
|
||||
//
|
||||
// Receives the task-lane semaphore to ensure task assignment decisions
|
||||
// respect the concurrency limit alongside running execution agents.
|
||||
//
|
||||
const scheduler = new Scheduler(store, {
|
||||
semaphore,
|
||||
prMonitor,
|
||||
@@ -936,6 +978,17 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
}
|
||||
};
|
||||
|
||||
// ── CronRunner: scheduled automation (UTILITY — NO semaphore) ──────────
|
||||
//
|
||||
// ⚠️ UTILITY PATH: CronRunner does NOT receive the task-lane semaphore.
|
||||
//
|
||||
// Uses createAiPromptExecutor (cwd-only factory) for AI execution in
|
||||
// scheduled tasks. By design, automation prompts are independent of
|
||||
// task concurrency limits so they can run regardless of task-lane saturation.
|
||||
//
|
||||
// createAiPromptExecutor takes only `cwd` (no semaphore parameter),
|
||||
// ensuring automation never competes with task-lane agents for slots.
|
||||
//
|
||||
const aiPromptExecutor = await createAiPromptExecutor(cwd);
|
||||
const cronRunner = new CronRunner(store, automationStore, {
|
||||
aiPromptExecutor,
|
||||
|
||||
@@ -102,12 +102,17 @@ export async function runServe(
|
||||
taskStore: store,
|
||||
});
|
||||
|
||||
// ── HeartbeatMonitor: runtime monitoring and execution for agents ───
|
||||
// ── HeartbeatMonitor: runtime monitoring (UTILITY — NO semaphore) ───
|
||||
//
|
||||
// ⚠️ UTILITY PATH: This component does NOT receive the task-lane semaphore.
|
||||
//
|
||||
// Provides the Paperclip-style heartbeat execution engine:
|
||||
// wake → check inbox → work → exit
|
||||
//
|
||||
// Enables agent execution runs triggered by timers, assignments, or manual API calls.
|
||||
// Enables lightweight agent sessions for monitoring, not task-lane work.
|
||||
// By design, heartbeat sessions are independent of task concurrency limits
|
||||
// so they can run regardless of how busy the task lanes are.
|
||||
//
|
||||
// Passed to createServer to enable the heartbeat routes.
|
||||
//
|
||||
let heartbeatMonitor: HeartbeatMonitor | undefined;
|
||||
@@ -127,7 +132,13 @@ export async function runServe(
|
||||
});
|
||||
heartbeatMonitor.start();
|
||||
|
||||
// HeartbeatTriggerScheduler manages timer and assignment-based triggers
|
||||
// HeartbeatTriggerScheduler: trigger scheduling (UTILITY — NO semaphore) ──
|
||||
//
|
||||
// ⚠️ UTILITY PATH: This scheduler does NOT receive the task-lane semaphore.
|
||||
//
|
||||
// Manages timer and assignment-based triggers for heartbeat execution.
|
||||
// By design, trigger scheduling is independent of task-lane concurrency limits.
|
||||
//
|
||||
triggerScheduler = new HeartbeatTriggerScheduler(
|
||||
agentStore,
|
||||
async (agentId, source, context: WakeContext) => {
|
||||
@@ -194,6 +205,29 @@ export async function runServe(
|
||||
console.log("[engine] Starting in paused mode — automation disabled");
|
||||
}
|
||||
|
||||
// ── Task-lane concurrency semaphore ────────────────────────────────
|
||||
//
|
||||
// ⚠️ SEMAPHORE BOUNDARY: This semaphore governs ONLY task-lane agents.
|
||||
//
|
||||
// Governed components (task lanes):
|
||||
// - TriageProcessor: specification agents that produce PROMPT.md
|
||||
// - TaskExecutor: task execution agents that implement features
|
||||
// - Scheduler: coordinates which agent gets which task
|
||||
// - onMerge: AI-powered merge execution for completed tasks
|
||||
//
|
||||
// UTILITY WORKFLOWS — NOT governed by this semaphore:
|
||||
// - HeartbeatMonitor: lightweight heartbeat sessions for agent monitoring
|
||||
// - HeartbeatTriggerScheduler: timer/assignment-based trigger scheduling
|
||||
// - CronRunner (via createAiPromptExecutor): scheduled automation prompts
|
||||
// - Model sync, auth setup, plugin loading: bootstrap/setup workflows
|
||||
//
|
||||
// This boundary prevents utility workflows from being blocked by
|
||||
// task-lane saturation and ensures utility work is always available.
|
||||
//
|
||||
// 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);
|
||||
@@ -216,6 +250,15 @@ export async function runServe(
|
||||
const usageLimitPauser = new UsageLimitPauser(store);
|
||||
const githubClient = new GitHubClient(process.env.GITHUB_TOKEN);
|
||||
|
||||
// ── onMerge: AI-powered merge (TASK LANE — semaphore-gated) ─────────────
|
||||
//
|
||||
// ⚠️ TASK LANE: aiMergeTask is wrapped with semaphore.run() to ensure
|
||||
// merge agents count toward settings.maxConcurrent alongside triage and execution.
|
||||
//
|
||||
// The raw aiMergeTask does NOT receive the semaphore directly;
|
||||
// the semaphore gating is applied at the onMerge wrapper level.
|
||||
//
|
||||
// Track the active merge session so it can be killed on global pause.
|
||||
let activeMergeSession: { dispose: () => void } | null = null;
|
||||
|
||||
const rawMerge = (taskId: string) =>
|
||||
@@ -547,6 +590,11 @@ export async function runServe(
|
||||
},
|
||||
});
|
||||
|
||||
// ── TriageProcessor: task specification (TASK LANE — receives semaphore) ──
|
||||
//
|
||||
// Receives the task-lane semaphore to ensure specification agents
|
||||
// count toward settings.maxConcurrent alongside execution and merge.
|
||||
//
|
||||
const triage = new TriageProcessor(store, cwd, {
|
||||
semaphore,
|
||||
usageLimitPauser,
|
||||
@@ -558,6 +606,11 @@ export async function runServe(
|
||||
});
|
||||
triageRef.current = triage;
|
||||
|
||||
// ── TaskExecutor: task execution (TASK LANE — receives semaphore) ──────────
|
||||
//
|
||||
// Receives the task-lane semaphore to ensure execution agents
|
||||
// count toward settings.maxConcurrent alongside specification and merge.
|
||||
//
|
||||
const executor = new TaskExecutor(store, cwd, {
|
||||
semaphore,
|
||||
pool,
|
||||
@@ -577,6 +630,11 @@ export async function runServe(
|
||||
prCommentHandler.handleNewComments(taskId, prInfo, comments),
|
||||
);
|
||||
|
||||
// ── Scheduler: task coordination (TASK LANE — receives semaphore) ──────────
|
||||
//
|
||||
// Receives the task-lane semaphore to ensure task assignment decisions
|
||||
// respect the concurrency limit alongside running execution agents.
|
||||
//
|
||||
const scheduler = new Scheduler(store, {
|
||||
semaphore,
|
||||
prMonitor,
|
||||
@@ -639,6 +697,17 @@ export async function runServe(
|
||||
}
|
||||
};
|
||||
|
||||
// ── CronRunner: scheduled automation (UTILITY — NO semaphore) ──────────
|
||||
//
|
||||
// ⚠️ UTILITY PATH: CronRunner does NOT receive the task-lane semaphore.
|
||||
//
|
||||
// Uses createAiPromptExecutor (cwd-only factory) for AI execution in
|
||||
// scheduled tasks. By design, automation prompts are independent of
|
||||
// task concurrency limits so they can run regardless of task-lane saturation.
|
||||
//
|
||||
// createAiPromptExecutor takes only `cwd` (no semaphore parameter),
|
||||
// ensuring automation never competes with task-lane agents for slots.
|
||||
//
|
||||
const aiPromptExecutor = await createAiPromptExecutor(cwd);
|
||||
const cronRunner = new CronRunner(store, automationStore, {
|
||||
aiPromptExecutor,
|
||||
|
||||
Reference in New Issue
Block a user