fix(#338): Add max concurrent agents limit

- Add MAX_CONCURRENT_AGENTS configuration (default: 20)
- Check current agent count before spawning
- Reject spawn requests with 429 Too Many Requests when limit reached
- Add comprehensive tests for limit enforcement

Refs #338
This commit is contained in:
Jason Woltje
2026-02-05 18:30:42 -06:00
parent ce7fb27c46
commit 3b80e9c396
4 changed files with 211 additions and 2 deletions

View File

@@ -1,4 +1,5 @@
import { ConfigService } from "@nestjs/config";
import { HttpException, HttpStatus } from "@nestjs/common";
import { describe, it, expect, beforeEach, vi } from "vitest";
import { AgentSpawnerService } from "./agent-spawner.service";
import { SpawnAgentRequest } from "./types/agent-spawner.types";
@@ -14,6 +15,9 @@ describe("AgentSpawnerService", () => {
if (key === "orchestrator.claude.apiKey") {
return "test-api-key";
}
if (key === "orchestrator.spawner.maxConcurrentAgents") {
return 20;
}
return undefined;
}),
} as unknown as ConfigService;
@@ -252,4 +256,149 @@ describe("AgentSpawnerService", () => {
expect(sessions[1].agentType).toBe("reviewer");
});
});
describe("max concurrent agents limit", () => {
const createValidRequest = (taskId: string): SpawnAgentRequest => ({
taskId,
agentType: "worker",
context: {
repository: "https://github.com/test/repo.git",
branch: "main",
workItems: ["Implement feature X"],
},
});
it("should allow spawning when under the limit", () => {
// Default limit is 20, spawn 5 agents
for (let i = 0; i < 5; i++) {
const response = service.spawnAgent(createValidRequest(`task-${i}`));
expect(response.agentId).toBeDefined();
}
expect(service.listAgentSessions()).toHaveLength(5);
});
it("should reject spawn when at the limit", () => {
// Create service with low limit for testing
const limitedConfigService = {
get: vi.fn((key: string) => {
if (key === "orchestrator.claude.apiKey") {
return "test-api-key";
}
if (key === "orchestrator.spawner.maxConcurrentAgents") {
return 3;
}
return undefined;
}),
} as unknown as ConfigService;
const limitedService = new AgentSpawnerService(limitedConfigService);
// Spawn up to the limit
limitedService.spawnAgent(createValidRequest("task-1"));
limitedService.spawnAgent(createValidRequest("task-2"));
limitedService.spawnAgent(createValidRequest("task-3"));
// Next spawn should throw 429 Too Many Requests
expect(() => limitedService.spawnAgent(createValidRequest("task-4"))).toThrow(HttpException);
try {
limitedService.spawnAgent(createValidRequest("task-5"));
} catch (error) {
expect(error).toBeInstanceOf(HttpException);
expect((error as HttpException).getStatus()).toBe(HttpStatus.TOO_MANY_REQUESTS);
expect((error as HttpException).message).toContain("Maximum concurrent agents limit");
}
});
it("should provide appropriate error message when limit reached", () => {
const limitedConfigService = {
get: vi.fn((key: string) => {
if (key === "orchestrator.claude.apiKey") {
return "test-api-key";
}
if (key === "orchestrator.spawner.maxConcurrentAgents") {
return 2;
}
return undefined;
}),
} as unknown as ConfigService;
const limitedService = new AgentSpawnerService(limitedConfigService);
// Spawn up to the limit
limitedService.spawnAgent(createValidRequest("task-1"));
limitedService.spawnAgent(createValidRequest("task-2"));
// Next spawn should throw with appropriate message
try {
limitedService.spawnAgent(createValidRequest("task-3"));
expect.fail("Should have thrown");
} catch (error) {
expect(error).toBeInstanceOf(HttpException);
const httpError = error as HttpException;
expect(httpError.getStatus()).toBe(HttpStatus.TOO_MANY_REQUESTS);
expect(httpError.message).toContain("2");
}
});
it("should use default limit of 20 when not configured", () => {
const defaultConfigService = {
get: vi.fn((key: string) => {
if (key === "orchestrator.claude.apiKey") {
return "test-api-key";
}
// Return undefined for maxConcurrentAgents to test default
return undefined;
}),
} as unknown as ConfigService;
const defaultService = new AgentSpawnerService(defaultConfigService);
// Should be able to spawn 20 agents
for (let i = 0; i < 20; i++) {
const response = defaultService.spawnAgent(createValidRequest(`task-${i}`));
expect(response.agentId).toBeDefined();
}
// 21st should fail
expect(() => defaultService.spawnAgent(createValidRequest("task-21"))).toThrow(HttpException);
});
it("should return current and max count in error response", () => {
const limitedConfigService = {
get: vi.fn((key: string) => {
if (key === "orchestrator.claude.apiKey") {
return "test-api-key";
}
if (key === "orchestrator.spawner.maxConcurrentAgents") {
return 5;
}
return undefined;
}),
} as unknown as ConfigService;
const limitedService = new AgentSpawnerService(limitedConfigService);
// Spawn 5 agents
for (let i = 0; i < 5; i++) {
limitedService.spawnAgent(createValidRequest(`task-${i}`));
}
try {
limitedService.spawnAgent(createValidRequest("task-6"));
expect.fail("Should have thrown");
} catch (error) {
expect(error).toBeInstanceOf(HttpException);
const httpError = error as HttpException;
const response = httpError.getResponse() as {
message: string;
currentCount: number;
maxLimit: number;
};
expect(response.currentCount).toBe(5);
expect(response.maxLimit).toBe(5);
}
});
});
});