test(#226,#227,#228): Add E2E integration tests for agent orchestration
Add comprehensive E2E test suites covering: - Full agent lifecycle (spawn → running → completed/failed) - 7 tests - Killswitch emergency stop mechanism (single/all/partial) - 5 tests - Concurrent agent spawning and isolation - 5 tests Includes vitest config for integration test runner with 30s timeout. Fixes #226 Fixes #227 Fixes #228 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* E2E Test: Concurrent Agents
|
||||
*
|
||||
* Tests multiple agents running concurrently with proper isolation.
|
||||
* Verifies agent-level isolation, queue management, and concurrent operations.
|
||||
*
|
||||
* Covers issue #228 (ORCH-127)
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { AgentSpawnerService } from "../../src/spawner/agent-spawner.service";
|
||||
import { AgentsController } from "../../src/api/agents/agents.controller";
|
||||
import { QueueService } from "../../src/queue/queue.service";
|
||||
import { AgentLifecycleService } from "../../src/spawner/agent-lifecycle.service";
|
||||
import { KillswitchService } from "../../src/killswitch/killswitch.service";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
|
||||
describe("E2E: Concurrent Agents", () => {
|
||||
let controller: AgentsController;
|
||||
let spawnerService: AgentSpawnerService;
|
||||
|
||||
const mockConfigService = {
|
||||
get: vi.fn((key: string, defaultValue?: unknown) => {
|
||||
const config: Record<string, unknown> = {
|
||||
"orchestrator.claude.apiKey": "test-api-key",
|
||||
};
|
||||
return config[key] ?? defaultValue;
|
||||
}),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
spawnerService = new AgentSpawnerService(mockConfigService as unknown as ConfigService);
|
||||
|
||||
const queueService = {
|
||||
addTask: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as QueueService;
|
||||
|
||||
const lifecycleService = {
|
||||
getAgentLifecycleState: vi.fn(),
|
||||
} as unknown as AgentLifecycleService;
|
||||
|
||||
const killswitchService = {
|
||||
killAgent: vi.fn(),
|
||||
killAllAgents: vi.fn(),
|
||||
} as unknown as KillswitchService;
|
||||
|
||||
controller = new AgentsController(
|
||||
queueService,
|
||||
spawnerService,
|
||||
lifecycleService,
|
||||
killswitchService
|
||||
);
|
||||
});
|
||||
|
||||
describe("Concurrent spawning", () => {
|
||||
it("should spawn multiple agents simultaneously without conflicts", async () => {
|
||||
// Spawn 5 agents in parallel
|
||||
const spawnPromises = Array.from({ length: 5 }, (_, i) =>
|
||||
controller.spawn({
|
||||
taskId: `concurrent-task-${String(i)}`,
|
||||
agentType: "worker",
|
||||
context: {
|
||||
repository: "https://git.example.com/repo.git",
|
||||
branch: `feature/task-${String(i)}`,
|
||||
workItems: [`US-${String(i).padStart(3, "0")}`],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const results = await Promise.all(spawnPromises);
|
||||
|
||||
// All should succeed
|
||||
expect(results).toHaveLength(5);
|
||||
results.forEach((result) => {
|
||||
expect(result.agentId).toBeDefined();
|
||||
expect(result.status).toBe("spawning");
|
||||
});
|
||||
|
||||
// All IDs should be unique
|
||||
const ids = new Set(results.map((r) => r.agentId));
|
||||
expect(ids.size).toBe(5);
|
||||
|
||||
// All should appear in the list
|
||||
const agents = spawnerService.listAgentSessions();
|
||||
expect(agents).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("should assign unique IDs to every agent even under concurrent load", async () => {
|
||||
const allIds = new Set<string>();
|
||||
const batchSize = 10;
|
||||
|
||||
// Spawn agents in batches
|
||||
for (let batch = 0; batch < 3; batch++) {
|
||||
const promises = Array.from({ length: batchSize }, (_, i) =>
|
||||
controller.spawn({
|
||||
taskId: `batch-${String(batch)}-task-${String(i)}`,
|
||||
agentType: "worker",
|
||||
context: {
|
||||
repository: "https://git.example.com/repo.git",
|
||||
branch: "main",
|
||||
workItems: [`US-${String(batch * batchSize + i)}`],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
results.forEach((r) => allIds.add(r.agentId));
|
||||
}
|
||||
|
||||
// All 30 IDs should be unique
|
||||
expect(allIds.size).toBe(30);
|
||||
|
||||
// All 30 should be listed
|
||||
const agents = spawnerService.listAgentSessions();
|
||||
expect(agents).toHaveLength(30);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Mixed agent types concurrently", () => {
|
||||
it("should handle mixed worker/reviewer/tester agents concurrently", async () => {
|
||||
const types = ["worker", "reviewer", "tester"] as const;
|
||||
|
||||
const promises = types.flatMap((agentType, typeIndex) =>
|
||||
Array.from({ length: 3 }, (_, i) =>
|
||||
controller.spawn({
|
||||
taskId: `mixed-${agentType}-${String(i)}`,
|
||||
agentType,
|
||||
context: {
|
||||
repository: "https://git.example.com/repo.git",
|
||||
branch: `branch-${String(typeIndex * 3 + i)}`,
|
||||
workItems: [`US-${String(typeIndex * 3 + i)}`],
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
expect(results).toHaveLength(9);
|
||||
|
||||
const agents = spawnerService.listAgentSessions();
|
||||
expect(agents).toHaveLength(9);
|
||||
|
||||
// Verify type distribution
|
||||
const typeCounts = agents.reduce(
|
||||
(acc, a) => {
|
||||
acc[a.agentType] = (acc[a.agentType] ?? 0) + 1;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>
|
||||
);
|
||||
|
||||
expect(typeCounts["worker"]).toBe(3);
|
||||
expect(typeCounts["reviewer"]).toBe(3);
|
||||
expect(typeCounts["tester"]).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Agent isolation", () => {
|
||||
it("should isolate agent contexts from each other", async () => {
|
||||
const agent1 = await controller.spawn({
|
||||
taskId: "isolated-task-1",
|
||||
agentType: "worker",
|
||||
context: {
|
||||
repository: "https://git.example.com/repo-a.git",
|
||||
branch: "main",
|
||||
workItems: ["US-001"],
|
||||
skills: ["typescript"],
|
||||
},
|
||||
});
|
||||
|
||||
const agent2 = await controller.spawn({
|
||||
taskId: "isolated-task-2",
|
||||
agentType: "reviewer",
|
||||
context: {
|
||||
repository: "https://git.example.com/repo-b.git",
|
||||
branch: "develop",
|
||||
workItems: ["US-002"],
|
||||
skills: ["python"],
|
||||
},
|
||||
});
|
||||
|
||||
// Verify sessions are independent
|
||||
const session1 = spawnerService.getAgentSession(agent1.agentId);
|
||||
const session2 = spawnerService.getAgentSession(agent2.agentId);
|
||||
|
||||
expect(session1?.context.repository).toBe("https://git.example.com/repo-a.git");
|
||||
expect(session2?.context.repository).toBe("https://git.example.com/repo-b.git");
|
||||
expect(session1?.context.branch).toBe("main");
|
||||
expect(session2?.context.branch).toBe("develop");
|
||||
});
|
||||
|
||||
it("should not leak state between concurrent agent operations", async () => {
|
||||
// Spawn agents with different task contexts
|
||||
const spawnPromises = Array.from({ length: 5 }, (_, i) =>
|
||||
controller.spawn({
|
||||
taskId: `leak-test-${String(i)}`,
|
||||
agentType: "worker",
|
||||
context: {
|
||||
repository: `https://git.example.com/repo-${String(i)}.git`,
|
||||
branch: `branch-${String(i)}`,
|
||||
workItems: [`US-${String(i).padStart(3, "0")}`],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const results = await Promise.all(spawnPromises);
|
||||
|
||||
// Verify each agent has its own isolated context
|
||||
results.forEach((result, i) => {
|
||||
const session = spawnerService.getAgentSession(result.agentId);
|
||||
expect(session?.taskId).toBe(`leak-test-${String(i)}`);
|
||||
expect(session?.context.repository).toBe(`https://git.example.com/repo-${String(i)}.git`);
|
||||
expect(session?.context.branch).toBe(`branch-${String(i)}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user