/** * 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 { 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'; import type { ConflictResolutionService } from './conflict-resolution-service.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' }; }), }; } /** * Create a mock ConflictResolutionService. */ function createMockConflictResolutionService(): ConflictResolutionService { return { handleConflict: vi.fn(), }; } // ============================================================================= // Tests // ============================================================================= describe('DefaultCoordinationManager', () => { let db: DrizzleDatabase; let taskRepository: TaskRepository; let agentRepository: AgentRepository; let messageRepository: MessageRepository; let eventBus: EventBus & { emittedEvents: DomainEvent[] }; let worktreeManager: WorktreeManager; let conflictResolutionService: ConflictResolutionService; let manager: DefaultCoordinationManager; let testPhaseId: 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 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(); 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({ phaseId: testPhaseId, 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({ phaseId: testPhaseId, 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({ phaseId: testPhaseId, 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({ 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, }); // 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({ phaseId: testPhaseId, name: 'First Task', priority: 'medium', order: 1, }); const task2 = await taskRepository.create({ phaseId: testPhaseId, 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({ phaseId: testPhaseId, 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({ phaseId: testPhaseId, name: 'Low Priority', priority: 'low', order: 1, }); const highTask = await taskRepository.create({ phaseId: testPhaseId, 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({ phaseId: testPhaseId, 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({ phaseId: testPhaseId, 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.findByPhaseId(testPhaseId); 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({ phaseId: testPhaseId, 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({ phaseId: testPhaseId, name: 'Queued Task', priority: 'high', order: 1, }); const task2 = await taskRepository.create({ phaseId: testPhaseId, 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({ phaseId: testPhaseId, 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({ phaseId: testPhaseId, 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({ phaseId: testPhaseId, 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' ); }); }); });