/** * DefaultDispatchManager Tests * * Tests for the DispatchManager adapter with dependency checking * and queue management. */ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { DefaultDispatchManager } from './manager.js'; import { DrizzleTaskRepository } from '../db/repositories/drizzle/task.js'; import { DrizzleMessageRepository } from '../db/repositories/drizzle/message.js'; import { DrizzlePhaseRepository } from '../db/repositories/drizzle/phase.js'; import { DrizzleInitiativeRepository } from '../db/repositories/drizzle/initiative.js'; import { createTestDatabase } from '../db/repositories/drizzle/test-helpers.js'; import type { DrizzleDatabase } from '../db/index.js'; import type { EventBus, DomainEvent } from '../events/types.js'; import type { AgentManager, AgentInfo } from '../agent/types.js'; import type { TaskRepository } from '../db/repositories/task-repository.js'; import type { MessageRepository } from '../db/repositories/message-repository.js'; // ============================================================================= // Test Helpers // ============================================================================= /** * Create a mock EventBus that captures emitted events. */ function createMockEventBus(): EventBus & { emittedEvents: DomainEvent[] } { const emittedEvents: DomainEvent[] = []; return { emittedEvents, emit(event: T): void { emittedEvents.push(event); }, on: vi.fn(), off: vi.fn(), once: vi.fn(), }; } /** * Create a mock AgentManager. */ function createMockAgentManager( agents: AgentInfo[] = [] ): AgentManager { const mockAgents = [...agents]; return { list: vi.fn().mockResolvedValue(mockAgents), get: vi.fn().mockImplementation(async (id: string) => { return mockAgents.find((a) => a.id === id) || null; }), getByName: vi.fn().mockImplementation(async (name: string) => { return mockAgents.find((a) => a.name === name) || null; }), spawn: vi.fn().mockImplementation(async (options) => { const newAgent: AgentInfo = { id: `agent-${Date.now()}`, name: options.name ?? `mock-agent-${Date.now()}`, taskId: options.taskId, initiativeId: options.initiativeId ?? null, sessionId: null, worktreeId: 'worktree-test', status: 'running', mode: options.mode ?? 'execute', provider: options.provider ?? 'claude', accountId: null, createdAt: new Date(), updatedAt: new Date(), }; mockAgents.push(newAgent); return newAgent; }), stop: vi.fn().mockResolvedValue(undefined), delete: vi.fn().mockResolvedValue(undefined), dismiss: vi.fn().mockResolvedValue(undefined), resume: vi.fn().mockResolvedValue(undefined), getResult: vi.fn().mockResolvedValue(null), getPendingQuestions: vi.fn().mockResolvedValue(null), }; } /** * Create an idle agent for testing. */ function createIdleAgent(id: string, name: string): AgentInfo { return { id, name, taskId: 'task-123', initiativeId: null, sessionId: 'session-abc', worktreeId: 'worktree-xyz', status: 'idle', mode: 'execute', provider: 'claude', accountId: null, createdAt: new Date(), updatedAt: new Date(), }; } // ============================================================================= // Tests // ============================================================================= describe('DefaultDispatchManager', () => { let db: DrizzleDatabase; let taskRepository: TaskRepository; let messageRepository: MessageRepository; let eventBus: EventBus & { emittedEvents: DomainEvent[] }; let agentManager: AgentManager; let dispatchManager: DefaultDispatchManager; let testPhaseId: string; beforeEach(async () => { // Set up test database db = createTestDatabase(); taskRepository = new DrizzleTaskRepository(db); messageRepository = new DrizzleMessageRepository(db); // Create required hierarchy for tasks const initiativeRepo = new DrizzleInitiativeRepository(db); const phaseRepo = new DrizzlePhaseRepository(db); const initiative = await initiativeRepo.create({ name: 'Test Initiative', }); const phase = await phaseRepo.create({ initiativeId: initiative.id, name: 'Test Phase', }); testPhaseId = phase.id; // Create mocks eventBus = createMockEventBus(); agentManager = createMockAgentManager(); // Create dispatch manager dispatchManager = new DefaultDispatchManager( taskRepository, messageRepository, agentManager, eventBus ); }); // =========================================================================== // queue() Tests // =========================================================================== describe('queue', () => { it('should add task to queue and emit TaskQueuedEvent', async () => { const task = await taskRepository.create({ phaseId: testPhaseId, name: 'Test Task', priority: 'high', order: 1, }); await dispatchManager.queue(task.id); const state = await dispatchManager.getQueueState(); expect(state.queued.length).toBe(1); expect(state.queued[0].taskId).toBe(task.id); expect(state.queued[0].priority).toBe('high'); // Check event was emitted expect(eventBus.emittedEvents.length).toBe(1); expect(eventBus.emittedEvents[0].type).toBe('task:queued'); expect((eventBus.emittedEvents[0] as any).payload.taskId).toBe(task.id); }); it('should throw error when task not found', async () => { await expect(dispatchManager.queue('non-existent-id')).rejects.toThrow( 'Task not found' ); }); }); // =========================================================================== // getNextDispatchable() Tests // =========================================================================== describe('getNextDispatchable', () => { it('should return null when queue is empty', async () => { const next = await dispatchManager.getNextDispatchable(); expect(next).toBeNull(); }); it('should return task when dependencies are complete', async () => { const task = await taskRepository.create({ phaseId: testPhaseId, name: 'Dispatchable Task', priority: 'medium', order: 1, }); await dispatchManager.queue(task.id); const next = await dispatchManager.getNextDispatchable(); expect(next).not.toBeNull(); expect(next!.taskId).toBe(task.id); }); it('should respect priority ordering (high > medium > low)', async () => { // Create tasks in different priority order const lowTask = await taskRepository.create({ phaseId: testPhaseId, name: 'Low Priority', priority: 'low', order: 1, }); const highTask = await taskRepository.create({ phaseId: testPhaseId, name: 'High Priority', priority: 'high', order: 2, }); const mediumTask = await taskRepository.create({ phaseId: testPhaseId, name: 'Medium Priority', priority: 'medium', order: 3, }); // Queue in wrong order (low, high, medium) await dispatchManager.queue(lowTask.id); await dispatchManager.queue(highTask.id); await dispatchManager.queue(mediumTask.id); // Should get high priority first const next = await dispatchManager.getNextDispatchable(); expect(next).not.toBeNull(); expect(next!.taskId).toBe(highTask.id); expect(next!.priority).toBe('high'); }); it('should order by queuedAt within same priority (oldest first)', async () => { const task1 = await taskRepository.create({ phaseId: testPhaseId, name: 'First Task', priority: 'medium', order: 1, }); const task2 = await taskRepository.create({ phaseId: testPhaseId, name: 'Second Task', priority: 'medium', order: 2, }); // Queue first task, wait, then queue second await dispatchManager.queue(task1.id); await new Promise((resolve) => setTimeout(resolve, 10)); await dispatchManager.queue(task2.id); // Should get the first queued task const next = await dispatchManager.getNextDispatchable(); expect(next).not.toBeNull(); expect(next!.taskId).toBe(task1.id); }); }); // =========================================================================== // completeTask() Tests // =========================================================================== describe('completeTask', () => { it('should update task status and emit TaskCompletedEvent', async () => { const task = await taskRepository.create({ phaseId: testPhaseId, name: 'Task to Complete', priority: 'medium', order: 1, }); await dispatchManager.queue(task.id); await dispatchManager.completeTask(task.id); // Check task status updated const updatedTask = await taskRepository.findById(task.id); expect(updatedTask!.status).toBe('completed'); // Check removed from queue const state = await dispatchManager.getQueueState(); expect(state.queued.length).toBe(0); // Check event was emitted (2 events: queued + completed) expect(eventBus.emittedEvents.length).toBe(2); expect(eventBus.emittedEvents[1].type).toBe('task:completed'); }); }); // =========================================================================== // blockTask() Tests // =========================================================================== describe('blockTask', () => { it('should update task status and emit TaskBlockedEvent', async () => { const task = await taskRepository.create({ phaseId: testPhaseId, name: 'Task to Block', priority: 'medium', order: 1, }); await dispatchManager.queue(task.id); await dispatchManager.blockTask(task.id, 'Waiting for user input'); // Check task status updated const updatedTask = await taskRepository.findById(task.id); expect(updatedTask!.status).toBe('blocked'); // Check moved to blocked list const state = await dispatchManager.getQueueState(); expect(state.queued.length).toBe(0); expect(state.blocked.length).toBe(1); expect(state.blocked[0].taskId).toBe(task.id); expect(state.blocked[0].reason).toBe('Waiting for user input'); // Check event was emitted (2 events: queued + blocked) expect(eventBus.emittedEvents.length).toBe(2); expect(eventBus.emittedEvents[1].type).toBe('task:blocked'); expect((eventBus.emittedEvents[1] as any).payload.reason).toBe( 'Waiting for user input' ); }); }); // =========================================================================== // dispatchNext() Tests // =========================================================================== describe('dispatchNext', () => { it('should return failure when no tasks ready', async () => { const result = await dispatchManager.dispatchNext(); expect(result.success).toBe(false); expect(result.reason).toBe('No dispatchable tasks'); }); it('should return failure when no agents available', async () => { const task = await taskRepository.create({ phaseId: testPhaseId, name: 'Task needing agent', priority: 'high', order: 1, }); await dispatchManager.queue(task.id); // Agent manager returns empty list (no idle agents) const result = await dispatchManager.dispatchNext(); expect(result.success).toBe(false); expect(result.taskId).toBe(task.id); expect(result.reason).toBe('No available agents'); }); it('should dispatch task to available agent', async () => { // Create task const task = await taskRepository.create({ phaseId: testPhaseId, name: 'Task for dispatch', description: 'Do the thing', priority: 'high', order: 1, }); await dispatchManager.queue(task.id); // Set up agent manager with an idle agent const idleAgent = createIdleAgent('agent-1', 'gastown'); agentManager = createMockAgentManager([idleAgent]); dispatchManager = new DefaultDispatchManager( taskRepository, messageRepository, agentManager, eventBus ); // Re-queue since we created a new dispatch manager await dispatchManager.queue(task.id); const result = await dispatchManager.dispatchNext(); expect(result.success).toBe(true); expect(result.taskId).toBe(task.id); expect(result.agentId).toBeDefined(); // Check task status updated to in_progress const updatedTask = await taskRepository.findById(task.id); expect(updatedTask!.status).toBe('in_progress'); // Check spawn was called with correct params (prompt is wrapped by buildExecutePrompt) expect(agentManager.spawn).toHaveBeenCalledWith( expect.objectContaining({ taskId: task.id, prompt: expect.stringContaining('Do the thing'), }) ); }); it('should emit TaskDispatchedEvent on successful dispatch', async () => { const task = await taskRepository.create({ phaseId: testPhaseId, name: 'Dispatch event test', priority: 'medium', order: 1, }); const idleAgent = createIdleAgent('agent-1', 'yaletown'); agentManager = createMockAgentManager([idleAgent]); dispatchManager = new DefaultDispatchManager( taskRepository, messageRepository, agentManager, eventBus ); await dispatchManager.queue(task.id); await dispatchManager.dispatchNext(); // Find TaskDispatchedEvent const dispatchedEvent = eventBus.emittedEvents.find( (e) => e.type === 'task:dispatched' ); expect(dispatchedEvent).toBeDefined(); expect((dispatchedEvent as any).payload.taskId).toBe(task.id); expect((dispatchedEvent as any).payload.agentId).toBeDefined(); }); }); // =========================================================================== // getQueueState() Tests // =========================================================================== describe('getQueueState', () => { it('should return correct state', async () => { // Create and queue tasks const task1 = await taskRepository.create({ phaseId: testPhaseId, name: 'Ready Task', priority: 'high', order: 1, }); const task2 = await taskRepository.create({ phaseId: testPhaseId, name: 'Another Ready Task', priority: 'low', order: 2, }); const task3 = await taskRepository.create({ phaseId: testPhaseId, name: 'Blocked Task', priority: 'medium', order: 3, }); await dispatchManager.queue(task1.id); await dispatchManager.queue(task2.id); await dispatchManager.queue(task3.id); await dispatchManager.blockTask(task3.id, 'Manual block'); const state = await dispatchManager.getQueueState(); // Queued should have 2 (task3 was moved to blocked) expect(state.queued.length).toBe(2); expect(state.queued.map((t) => t.taskId)).toContain(task1.id); expect(state.queued.map((t) => t.taskId)).toContain(task2.id); // Ready should have same 2 tasks (no dependencies) expect(state.ready.length).toBe(2); // Blocked should have 1 expect(state.blocked.length).toBe(1); expect(state.blocked[0].taskId).toBe(task3.id); expect(state.blocked[0].reason).toBe('Manual block'); }); }); // =========================================================================== // Dependency Scenario Test // =========================================================================== describe('dependency scenario', () => { it('should handle task dependencies correctly', async () => { // This test verifies the basic flow described in the plan: // - Task A (no deps) - should be dispatchable // - Task B (depends on A) - not dispatchable until A completes // - Task C (depends on A) - not dispatchable until A completes // For v1, dependencies are empty (we'd need to enhance queue() to fetch from task_dependencies table) // This test verifies the priority and queue ordering work correctly const taskA = await taskRepository.create({ phaseId: testPhaseId, name: 'Task A - Foundation', priority: 'high', order: 1, }); const taskB = await taskRepository.create({ phaseId: testPhaseId, name: 'Task B - Build on A', priority: 'medium', order: 2, }); const taskC = await taskRepository.create({ phaseId: testPhaseId, name: 'Task C - Also build on A', priority: 'medium', order: 3, }); // Queue all three await dispatchManager.queue(taskA.id); await dispatchManager.queue(taskB.id); await dispatchManager.queue(taskC.id); const state = await dispatchManager.getQueueState(); expect(state.queued.length).toBe(3); // With no dependencies set, all should be ready // (In a full implementation, B and C would have dependsOn: [taskA.id]) expect(state.ready.length).toBe(3); // A should come first (highest priority) const next = await dispatchManager.getNextDispatchable(); expect(next!.taskId).toBe(taskA.id); // Complete A await dispatchManager.completeTask(taskA.id); // Now B and C should be next (both medium priority, B queued first) const stateAfterA = await dispatchManager.getQueueState(); expect(stateAfterA.queued.length).toBe(2); // Get next - should be B (queued before C) const nextAfterA = await dispatchManager.getNextDispatchable(); expect(nextAfterA!.taskId).toBe(taskB.id); }); }); });