From 364ffc357b78c6fa099718451fd4b39abb524d61 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 30 Jan 2026 20:42:38 +0100 Subject: [PATCH] test(05-04): add DispatchManager tests and wire up exports - Add 14 comprehensive tests for DefaultDispatchManager - Test queue(), getNextDispatchable(), completeTask(), blockTask() - Test dispatchNext() with agent dispatch flow - Test getQueueState() for queue visibility - Test priority ordering and dependency scenario - Export DefaultDispatchManager from dispatch module --- src/dispatch/index.ts | 3 + src/dispatch/manager.test.ts | 543 +++++++++++++++++++++++++++++++++++ 2 files changed, 546 insertions(+) create mode 100644 src/dispatch/manager.test.ts diff --git a/src/dispatch/index.ts b/src/dispatch/index.ts index 28f9002..01aac0b 100644 --- a/src/dispatch/index.ts +++ b/src/dispatch/index.ts @@ -7,3 +7,6 @@ // Port interface (what consumers depend on) export type { DispatchManager, QueuedTask, DispatchResult } from './types.js'; + +// Adapter implementation +export { DefaultDispatchManager } from './manager.js'; diff --git a/src/dispatch/manager.test.ts b/src/dispatch/manager.test.ts new file mode 100644 index 0000000..a8b466b --- /dev/null +++ b/src/dispatch/manager.test.ts @@ -0,0 +1,543 @@ +/** + * 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 { DrizzlePlanRepository } from '../db/repositories/drizzle/plan.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, + taskId: options.taskId, + sessionId: null, + worktreeId: 'worktree-test', + status: 'running', + createdAt: new Date(), + updatedAt: new Date(), + }; + mockAgents.push(newAgent); + return newAgent; + }), + stop: vi.fn().mockResolvedValue(undefined), + resume: vi.fn().mockResolvedValue(undefined), + getResult: vi.fn().mockResolvedValue(null), + }; +} + +/** + * Create an idle agent for testing. + */ +function createIdleAgent(id: string, name: string): AgentInfo { + return { + id, + name, + taskId: 'task-123', + sessionId: 'session-abc', + worktreeId: 'worktree-xyz', + status: 'idle', + 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 testPlanId: 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 planRepo = new DrizzlePlanRepository(db); + + const initiative = await initiativeRepo.create({ + name: 'Test Initiative', + }); + const phase = await phaseRepo.create({ + initiativeId: initiative.id, + number: 1, + name: 'Test Phase', + }); + const plan = await planRepo.create({ + phaseId: phase.id, + number: 1, + name: 'Test Plan', + }); + testPlanId = plan.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({ + planId: testPlanId, + 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({ + planId: testPlanId, + 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({ + planId: testPlanId, + name: 'Low Priority', + priority: 'low', + order: 1, + }); + const highTask = await taskRepository.create({ + planId: testPlanId, + name: 'High Priority', + priority: 'high', + order: 2, + }); + const mediumTask = await taskRepository.create({ + planId: testPlanId, + 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({ + planId: testPlanId, + name: 'First Task', + priority: 'medium', + order: 1, + }); + const task2 = await taskRepository.create({ + planId: testPlanId, + 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({ + planId: testPlanId, + 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({ + planId: testPlanId, + 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({ + planId: testPlanId, + 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({ + planId: testPlanId, + 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 + expect(agentManager.spawn).toHaveBeenCalledWith( + expect.objectContaining({ + taskId: task.id, + prompt: 'Do the thing', + }) + ); + }); + + it('should emit TaskDispatchedEvent on successful dispatch', async () => { + const task = await taskRepository.create({ + planId: testPlanId, + 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({ + planId: testPlanId, + name: 'Ready Task', + priority: 'high', + order: 1, + }); + const task2 = await taskRepository.create({ + planId: testPlanId, + name: 'Another Ready Task', + priority: 'low', + order: 2, + }); + const task3 = await taskRepository.create({ + planId: testPlanId, + 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({ + planId: testPlanId, + name: 'Task A - Foundation', + priority: 'high', + order: 1, + }); + const taskB = await taskRepository.create({ + planId: testPlanId, + name: 'Task B - Build on A', + priority: 'medium', + order: 2, + }); + const taskC = await taskRepository.create({ + planId: testPlanId, + 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); + }); + }); +});