From 37a90db28cf55257231d9589c22e5df693f98ac7 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 30 Jan 2026 21:09:29 +0100 Subject: [PATCH] test(06-02): add comprehensive CoordinationManager tests 18 tests covering: - Queue management (queueMerge, getQueueState) - Dependency ordering (getNextMergeable with priority) - Success path (processMerges with clean merges) - Conflict handling (detect conflicts, create resolution task, send message) - Error handling (missing repositories, missing agent) Exports DefaultCoordinationManager from coordination module index. --- src/coordination/index.ts | 3 + src/coordination/manager.test.ts | 689 +++++++++++++++++++++++++++++++ 2 files changed, 692 insertions(+) create mode 100644 src/coordination/manager.test.ts diff --git a/src/coordination/index.ts b/src/coordination/index.ts index ac31679..531013a 100644 --- a/src/coordination/index.ts +++ b/src/coordination/index.ts @@ -21,3 +21,6 @@ export type { CoordinationManager } from './types.js'; // Domain types export type { MergeQueueItem, MergeStatus, MergeResult } from './types.js'; + +// Adapters +export { DefaultCoordinationManager } from './manager.js'; diff --git a/src/coordination/manager.test.ts b/src/coordination/manager.test.ts new file mode 100644 index 0000000..138944a --- /dev/null +++ b/src/coordination/manager.test.ts @@ -0,0 +1,689 @@ +/** + * DefaultCoordinationManager Tests + * + * Tests for the CoordinationManager adapter with dependency-ordered + * merging and conflict handling. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { DefaultCoordinationManager } from './manager.js'; +import { DrizzleTaskRepository } from '../db/repositories/drizzle/task.js'; +import { DrizzleAgentRepository } from '../db/repositories/drizzle/agent.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 { WorktreeManager, MergeResult as GitMergeResult } from '../git/types.js'; +import type { TaskRepository } from '../db/repositories/task-repository.js'; +import type { AgentRepository } from '../db/repositories/agent-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 WorktreeManager with configurable merge results. + */ +function createMockWorktreeManager( + mergeResults: Map = new Map() +): WorktreeManager { + return { + create: vi.fn(), + remove: vi.fn(), + list: vi.fn().mockResolvedValue([]), + get: vi.fn(), + diff: vi.fn(), + merge: vi.fn().mockImplementation(async (worktreeId: string) => { + const result = mergeResults.get(worktreeId); + if (result) return result; + // Default: successful merge + return { success: true, message: 'Merged successfully' }; + }), + }; +} + +// ============================================================================= +// Tests +// ============================================================================= + +describe('DefaultCoordinationManager', () => { + let db: DrizzleDatabase; + let taskRepository: TaskRepository; + let agentRepository: AgentRepository; + let messageRepository: MessageRepository; + let eventBus: EventBus & { emittedEvents: DomainEvent[] }; + let worktreeManager: WorktreeManager; + let manager: DefaultCoordinationManager; + let testPlanId: string; + + beforeEach(async () => { + // Set up test database + db = createTestDatabase(); + taskRepository = new DrizzleTaskRepository(db); + agentRepository = new DrizzleAgentRepository(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(); + worktreeManager = createMockWorktreeManager(); + + // Create coordination manager + manager = new DefaultCoordinationManager( + worktreeManager, + taskRepository, + agentRepository, + messageRepository, + eventBus + ); + }); + + // =========================================================================== + // queueMerge() Tests + // =========================================================================== + + describe('queueMerge', () => { + it('should add task to queue and emit MergeQueuedEvent', async () => { + // Create task + const task = await taskRepository.create({ + planId: testPlanId, + name: 'Test Task', + priority: 'high', + order: 1, + }); + + // Create agent for task + const agent = await agentRepository.create({ + name: 'agent-test', + taskId: task.id, + worktreeId: 'worktree-123', + }); + + await manager.queueMerge(task.id); + + const state = await manager.getQueueState(); + expect(state.queued.length).toBe(1); + expect(state.queued[0].taskId).toBe(task.id); + expect(state.queued[0].agentId).toBe(agent.id); + expect(state.queued[0].worktreeId).toBe('worktree-123'); + expect(state.queued[0].priority).toBe('high'); + + // Check event was emitted + expect(eventBus.emittedEvents.length).toBe(1); + expect(eventBus.emittedEvents[0].type).toBe('merge:queued'); + expect((eventBus.emittedEvents[0] as any).payload.taskId).toBe(task.id); + }); + + it('should throw error when task not found', async () => { + await expect(manager.queueMerge('non-existent-id')).rejects.toThrow( + 'Task not found' + ); + }); + + it('should throw error when no agent assigned to task', async () => { + const task = await taskRepository.create({ + planId: testPlanId, + name: 'Orphan Task', + order: 1, + }); + + await expect(manager.queueMerge(task.id)).rejects.toThrow( + 'No agent found for task' + ); + }); + }); + + // =========================================================================== + // getNextMergeable() Tests + // =========================================================================== + + describe('getNextMergeable', () => { + it('should return null when queue is empty', async () => { + const next = await manager.getNextMergeable(); + expect(next).toBeNull(); + }); + + it('should return item when all dependencies merged', async () => { + const task = await taskRepository.create({ + planId: testPlanId, + name: 'Mergeable Task', + priority: 'medium', + order: 1, + }); + + await agentRepository.create({ + name: 'agent-merge', + taskId: task.id, + worktreeId: 'worktree-merge', + }); + + await manager.queueMerge(task.id); + + const next = await manager.getNextMergeable(); + 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, + }); + + // Create agents for all tasks + await agentRepository.create({ + name: 'agent-low', + taskId: lowTask.id, + worktreeId: 'wt-low', + }); + await agentRepository.create({ + name: 'agent-high', + taskId: highTask.id, + worktreeId: 'wt-high', + }); + await agentRepository.create({ + name: 'agent-medium', + taskId: mediumTask.id, + worktreeId: 'wt-medium', + }); + + // Queue in wrong order (low, high, medium) + await manager.queueMerge(lowTask.id); + await manager.queueMerge(highTask.id); + await manager.queueMerge(mediumTask.id); + + // Should get high priority first + const next = await manager.getNextMergeable(); + 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, + }); + + await agentRepository.create({ + name: 'agent-1', + taskId: task1.id, + worktreeId: 'wt-1', + }); + await agentRepository.create({ + name: 'agent-2', + taskId: task2.id, + worktreeId: 'wt-2', + }); + + // Queue first task, wait, then queue second + await manager.queueMerge(task1.id); + await new Promise((resolve) => setTimeout(resolve, 10)); + await manager.queueMerge(task2.id); + + // Should get the first queued task + const next = await manager.getNextMergeable(); + expect(next).not.toBeNull(); + expect(next!.taskId).toBe(task1.id); + }); + }); + + // =========================================================================== + // processMerges() Tests - Success Path + // =========================================================================== + + describe('processMerges - success path', () => { + it('should complete clean merges and emit MergeCompletedEvent', async () => { + const task = await taskRepository.create({ + planId: testPlanId, + name: 'Mergeable Task', + priority: 'high', + order: 1, + }); + + const agent = await agentRepository.create({ + name: 'agent-merge', + taskId: task.id, + worktreeId: 'worktree-success', + }); + + await manager.queueMerge(task.id); + + const results = await manager.processMerges('main'); + + expect(results.length).toBe(1); + expect(results[0].taskId).toBe(task.id); + expect(results[0].success).toBe(true); + + // Check state updated + const state = await manager.getQueueState(); + expect(state.queued.length).toBe(0); + expect(state.merged).toContain(task.id); + + // Check events emitted: queued, started, completed + const eventTypes = eventBus.emittedEvents.map((e) => e.type); + expect(eventTypes).toContain('merge:queued'); + expect(eventTypes).toContain('merge:started'); + expect(eventTypes).toContain('merge:completed'); + }); + + it('should process multiple tasks in priority order', async () => { + 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, + }); + + await agentRepository.create({ + name: 'agent-low', + taskId: lowTask.id, + worktreeId: 'wt-low', + }); + await agentRepository.create({ + name: 'agent-high', + taskId: highTask.id, + worktreeId: 'wt-high', + }); + + await manager.queueMerge(lowTask.id); + await manager.queueMerge(highTask.id); + + const results = await manager.processMerges('main'); + + // Should process high priority first + expect(results.length).toBe(2); + expect(results[0].taskId).toBe(highTask.id); + expect(results[1].taskId).toBe(lowTask.id); + }); + }); + + // =========================================================================== + // processMerges() Tests - Conflict Handling + // =========================================================================== + + describe('processMerges - conflict handling', () => { + it('should detect conflicts and emit MergeConflictedEvent', async () => { + const task = await taskRepository.create({ + planId: testPlanId, + name: 'Conflicting Task', + priority: 'high', + order: 1, + }); + + await agentRepository.create({ + name: 'agent-conflict', + taskId: task.id, + worktreeId: 'wt-conflict', + }); + + // Configure merge to fail with conflicts + const mergeResults = new Map(); + mergeResults.set('wt-conflict', { + success: false, + conflicts: ['file1.ts', 'file2.ts'], + message: 'Merge conflicts detected', + }); + + worktreeManager = createMockWorktreeManager(mergeResults); + manager = new DefaultCoordinationManager( + worktreeManager, + taskRepository, + agentRepository, + messageRepository, + eventBus + ); + + await manager.queueMerge(task.id); + const results = await manager.processMerges('main'); + + expect(results.length).toBe(1); + expect(results[0].taskId).toBe(task.id); + expect(results[0].success).toBe(false); + expect(results[0].conflicts).toEqual(['file1.ts', 'file2.ts']); + + // Check state updated + const state = await manager.getQueueState(); + expect(state.conflicted.length).toBe(1); + expect(state.conflicted[0].taskId).toBe(task.id); + expect(state.conflicted[0].conflicts).toEqual(['file1.ts', 'file2.ts']); + + // Check MergeConflictedEvent emitted + const conflictEvent = eventBus.emittedEvents.find( + (e) => e.type === 'merge:conflicted' + ); + expect(conflictEvent).toBeDefined(); + expect((conflictEvent as any).payload.conflictingFiles).toEqual([ + 'file1.ts', + 'file2.ts', + ]); + }); + + it('should create resolution task on conflict', async () => { + const task = await taskRepository.create({ + planId: testPlanId, + name: 'Original Task', + priority: 'medium', + order: 1, + }); + + await agentRepository.create({ + name: 'agent-conflict', + taskId: task.id, + worktreeId: 'wt-conflict', + }); + + // Configure merge to fail + const mergeResults = new Map(); + mergeResults.set('wt-conflict', { + success: false, + conflicts: ['src/index.ts'], + message: 'Merge conflicts detected', + }); + + worktreeManager = createMockWorktreeManager(mergeResults); + manager = new DefaultCoordinationManager( + worktreeManager, + taskRepository, + agentRepository, + messageRepository, + eventBus + ); + + await manager.queueMerge(task.id); + await manager.processMerges('main'); + + // Check new task was created + const tasks = await taskRepository.findByPlanId(testPlanId); + const conflictTask = tasks.find((t) => + t.name.startsWith('Resolve conflicts:') + ); + + expect(conflictTask).toBeDefined(); + expect(conflictTask!.name).toBe('Resolve conflicts: Original Task'); + expect(conflictTask!.priority).toBe('high'); + expect(conflictTask!.description).toContain('src/index.ts'); + + // Check original task blocked + const updatedOriginal = await taskRepository.findById(task.id); + expect(updatedOriginal!.status).toBe('blocked'); + + // Check TaskQueuedEvent emitted for conflict task + const queuedEvent = eventBus.emittedEvents.find( + (e) => + e.type === 'task:queued' && + (e as any).payload.taskId === conflictTask!.id + ); + expect(queuedEvent).toBeDefined(); + }); + + it('should create message to agent on conflict', async () => { + const task = await taskRepository.create({ + planId: testPlanId, + name: 'Task with Message', + priority: 'medium', + order: 1, + }); + + const agent = await agentRepository.create({ + name: 'agent-msg', + taskId: task.id, + worktreeId: 'wt-msg', + }); + + // Configure merge to fail + const mergeResults = new Map(); + mergeResults.set('wt-msg', { + success: false, + conflicts: ['conflict.ts'], + message: 'Merge conflicts detected', + }); + + worktreeManager = createMockWorktreeManager(mergeResults); + manager = new DefaultCoordinationManager( + worktreeManager, + taskRepository, + agentRepository, + messageRepository, + eventBus + ); + + await manager.queueMerge(task.id); + await manager.processMerges('main'); + + // Check message was created + const messages = await messageRepository.findByRecipient('agent', agent.id); + expect(messages.length).toBe(1); + expect(messages[0].recipientType).toBe('agent'); + expect(messages[0].recipientId).toBe(agent.id); + expect(messages[0].senderType).toBe('user'); + expect(messages[0].content).toContain('Merge conflict detected'); + expect(messages[0].content).toContain('conflict.ts'); + }); + }); + + // =========================================================================== + // getQueueState() Tests + // =========================================================================== + + describe('getQueueState', () => { + it('should return correct counts for all states', async () => { + // Create tasks + const task1 = await taskRepository.create({ + planId: testPlanId, + name: 'Queued Task', + priority: 'high', + order: 1, + }); + const task2 = await taskRepository.create({ + planId: testPlanId, + name: 'Conflict Task', + priority: 'medium', + order: 2, + }); + + await agentRepository.create({ + name: 'agent-1', + taskId: task1.id, + worktreeId: 'wt-1', + }); + await agentRepository.create({ + name: 'agent-2', + taskId: task2.id, + worktreeId: 'wt-2', + }); + + // Configure task2 to conflict + const mergeResults = new Map(); + mergeResults.set('wt-2', { + success: false, + conflicts: ['test.ts'], + message: 'Conflict', + }); + + worktreeManager = createMockWorktreeManager(mergeResults); + manager = new DefaultCoordinationManager( + worktreeManager, + taskRepository, + agentRepository, + messageRepository, + eventBus + ); + + // Queue both, process task2 (will conflict), leave task1 queued + await manager.queueMerge(task2.id); + await manager.processMerges('main'); + await manager.queueMerge(task1.id); + + const state = await manager.getQueueState(); + + // task1 should be queued, task2 should be conflicted + expect(state.queued.length).toBe(1); + expect(state.queued[0].taskId).toBe(task1.id); + expect(state.inProgress.length).toBe(0); + expect(state.merged.length).toBe(0); + expect(state.conflicted.length).toBe(1); + expect(state.conflicted[0].taskId).toBe(task2.id); + }); + }); + + // =========================================================================== + // handleConflict() Tests + // =========================================================================== + + describe('handleConflict', () => { + it('should throw error when task not found', async () => { + await expect( + manager.handleConflict('non-existent', ['file.ts']) + ).rejects.toThrow('Original task not found'); + }); + + it('should throw error when no agent for task', async () => { + const task = await taskRepository.create({ + planId: testPlanId, + name: 'Orphan Task', + order: 1, + }); + + await expect(manager.handleConflict(task.id, ['file.ts'])).rejects.toThrow( + 'No agent found for task' + ); + }); + }); + + // =========================================================================== + // Error Handling Tests + // =========================================================================== + + describe('error handling', () => { + it('should throw when TaskRepository not configured', async () => { + const managerNoRepo = new DefaultCoordinationManager( + worktreeManager, + undefined, // No task repo + agentRepository, + messageRepository, + eventBus + ); + + await expect(managerNoRepo.queueMerge('task-id')).rejects.toThrow( + 'TaskRepository not configured' + ); + }); + + it('should throw when AgentRepository not configured', async () => { + const managerNoAgentRepo = new DefaultCoordinationManager( + worktreeManager, + taskRepository, + undefined, // No agent repo + messageRepository, + eventBus + ); + + const task = await taskRepository.create({ + planId: testPlanId, + name: 'Test', + order: 1, + }); + + await expect(managerNoAgentRepo.queueMerge(task.id)).rejects.toThrow( + 'AgentRepository not configured' + ); + }); + + it('should throw when WorktreeManager not configured', async () => { + const managerNoWorktree = new DefaultCoordinationManager( + undefined, // No worktree manager + taskRepository, + agentRepository, + messageRepository, + eventBus + ); + + const task = await taskRepository.create({ + planId: testPlanId, + name: 'Test', + order: 1, + }); + + await agentRepository.create({ + name: 'agent-test', + taskId: task.id, + worktreeId: 'wt-test', + }); + + await managerNoWorktree.queueMerge(task.id); + await expect(managerNoWorktree.processMerges('main')).rejects.toThrow( + 'WorktreeManager not configured' + ); + }); + }); +});