/** * E2E Tests for Extended Scenarios * * Tests extended scenarios in dispatch/coordination flow: * - Conflict hand-back round-trip (conflict -> agent resolves -> merge succeeds) * - Multi-agent parallel work and completion * * Uses TestHarness from src/test/ for full system wiring. */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { createTestHarness, SIMPLE_FIXTURE, PARALLEL_FIXTURE, COMPLEX_FIXTURE, type TestHarness, } from '../index.js'; import type { MergeConflictedEvent, MergeCompletedEvent, TaskQueuedEvent, AgentStoppedEvent, AgentCrashedEvent, } from '../../events/types.js'; describe('E2E Extended Scenarios', () => { let harness: TestHarness; beforeEach(() => { harness = createTestHarness(); }); afterEach(() => { harness.cleanup(); vi.useRealTimers(); }); // =========================================================================== // Conflict Hand-back Round-trip // =========================================================================== describe('Conflict hand-back round-trip', () => { it('conflict triggers resolution task, agent resolves, merge succeeds', async () => { vi.useFakeTimers(); const seeded = await harness.seedFixture(SIMPLE_FIXTURE); const taskAId = seeded.tasks.get('Task A')!; // Step 1: Complete Task A await harness.taskRepository.update(taskAId, { status: 'completed' }); // Step 2: Create agent in agentRepository with worktreeId const worktreeId = `wt-${taskAId.slice(0, 6)}`; await harness.agentRepository.create({ name: `agent-${taskAId.slice(0, 6)}`, worktreeId, taskId: taskAId, status: 'idle', }); // Step 3: Create worktree via MockWorktreeManager await harness.worktreeManager.create(worktreeId, 'feature-task-a'); // Step 4: Set merge conflict result for first merge attempt harness.worktreeManager.setMergeResult(worktreeId, { success: false, conflicts: ['src/shared.ts', 'src/types.ts'], message: 'Merge conflict in 2 files', }); // Step 5: Queue and process merge (should fail with conflict) await harness.coordinationManager.queueMerge(taskAId); harness.clearEvents(); const conflictResults = await harness.coordinationManager.processMerges('main'); // Verify: merge failed with conflict expect(conflictResults.length).toBe(1); expect(conflictResults[0].success).toBe(false); expect(conflictResults[0].conflicts).toEqual(['src/shared.ts', 'src/types.ts']); // Verify: merge:conflicted event emitted const conflictedEvents = harness.getEventsByType('merge:conflicted'); expect(conflictedEvents.length).toBe(1); const conflictPayload = (conflictedEvents[0] as MergeConflictedEvent).payload; expect(conflictPayload.taskId).toBe(taskAId); expect(conflictPayload.conflictingFiles).toEqual(['src/shared.ts', 'src/types.ts']); // Verify: original task is NOT blocked (stays completed — the pending // resolution task prevents premature phase completion) const originalTask = await harness.taskRepository.findById(taskAId); expect(originalTask?.status).toBe('completed'); // Verify: task:queued event emitted for resolution task const queuedEvents = harness.getEventsByType('task:queued'); const resolutionTaskEvent = queuedEvents.find( (e) => (e as TaskQueuedEvent).payload.taskId !== taskAId ); expect(resolutionTaskEvent).toBeDefined(); // Step 6: Clear the merge conflict (setMergeResult to success) harness.worktreeManager.setMergeResult(worktreeId, { success: true, message: 'Merged successfully', }); // Step 7: Re-queue original task for merge (simulating resolution completed) // In a real system, the resolution task would fix conflicts and re-queue // Here we simulate by clearing conflict and re-queuing await harness.taskRepository.update(taskAId, { status: 'completed' }); harness.clearEvents(); await harness.coordinationManager.queueMerge(taskAId); const successResults = await harness.coordinationManager.processMerges('main'); // Verify: merge succeeded expect(successResults.length).toBe(1); expect(successResults[0].taskId).toBe(taskAId); expect(successResults[0].success).toBe(true); // Verify: merge:completed event for original task const completedEvents = harness.getEventsByType('merge:completed'); expect(completedEvents.length).toBe(1); const completedPayload = (completedEvents[0] as MergeCompletedEvent).payload; expect(completedPayload.taskId).toBe(taskAId); }); it('conflict resolution preserves original task context', async () => { vi.useFakeTimers(); const seeded = await harness.seedFixture(SIMPLE_FIXTURE); const taskAId = seeded.tasks.get('Task A')!; // Complete Task A await harness.taskRepository.update(taskAId, { status: 'completed' }); // Create agent and worktree const worktreeId = `wt-${taskAId.slice(0, 6)}`; await harness.agentRepository.create({ name: `agent-${taskAId.slice(0, 6)}`, worktreeId, taskId: taskAId, status: 'idle', }); await harness.worktreeManager.create(worktreeId, 'feature-task-a'); // Set conflict harness.worktreeManager.setMergeResult(worktreeId, { success: false, conflicts: ['src/conflict-file.ts'], message: 'Merge conflict', }); // Process merge to trigger conflict handling await harness.coordinationManager.queueMerge(taskAId); harness.clearEvents(); await harness.coordinationManager.processMerges('main'); // Get the resolution task from task:queued events const queuedEvents = harness.getEventsByType('task:queued'); expect(queuedEvents.length).toBeGreaterThan(0); // Find resolution task (the one that isn't the original task) const resolutionTaskQueuedEvent = queuedEvents.find( (e) => (e as TaskQueuedEvent).payload.taskId !== taskAId ); expect(resolutionTaskQueuedEvent).toBeDefined(); // Resolution task should exist and link back to original task const resolutionTaskId = (resolutionTaskQueuedEvent as TaskQueuedEvent).payload.taskId; const resolutionTask = await harness.taskRepository.findById(resolutionTaskId); expect(resolutionTask).toBeDefined(); // Resolution task description should contain conflict file info expect(resolutionTask?.description).toContain('conflict'); }); it('multiple sequential conflicts resolved in order', async () => { vi.useFakeTimers(); const seeded = await harness.seedFixture(SIMPLE_FIXTURE); const taskAId = seeded.tasks.get('Task A')!; const taskBId = seeded.tasks.get('Task B')!; // Complete both tasks await harness.taskRepository.update(taskAId, { status: 'completed' }); await harness.taskRepository.update(taskBId, { status: 'completed' }); // Set up worktrees and agents for both tasks const worktreeIdA = `wt-${taskAId.slice(0, 6)}`; const worktreeIdB = `wt-${taskBId.slice(0, 6)}`; await harness.agentRepository.create({ name: `agent-${taskAId.slice(0, 6)}`, worktreeId: worktreeIdA, taskId: taskAId, status: 'idle', }); await harness.agentRepository.create({ name: `agent-${taskBId.slice(0, 6)}`, worktreeId: worktreeIdB, taskId: taskBId, status: 'idle', }); await harness.worktreeManager.create(worktreeIdA, 'feature-task-a'); await harness.worktreeManager.create(worktreeIdB, 'feature-task-b'); // Set conflicts for both harness.worktreeManager.setMergeResult(worktreeIdA, { success: false, conflicts: ['src/shared-a.ts'], message: 'Conflict A', }); harness.worktreeManager.setMergeResult(worktreeIdB, { success: false, conflicts: ['src/shared-b.ts'], message: 'Conflict B', }); // Queue both for merge await harness.coordinationManager.queueMerge(taskAId); await harness.coordinationManager.queueMerge(taskBId); harness.clearEvents(); // Process merges - both should fail const conflictResults = await harness.coordinationManager.processMerges('main'); expect(conflictResults.filter((r) => !r.success).length).toBe(2); // Verify both are in conflicted state const queueState = await harness.coordinationManager.getQueueState(); expect(queueState.conflicted.length).toBe(2); // Resolve Task A's conflict harness.worktreeManager.setMergeResult(worktreeIdA, { success: true, message: 'Merged A', }); await harness.taskRepository.update(taskAId, { status: 'completed' }); await harness.coordinationManager.queueMerge(taskAId); harness.clearEvents(); const resultA = await harness.coordinationManager.processMerges('main'); expect(resultA.length).toBe(1); expect(resultA[0].taskId).toBe(taskAId); expect(resultA[0].success).toBe(true); // Verify merge:completed for A const completedEventsA = harness.getEventsByType('merge:completed'); expect(completedEventsA.length).toBe(1); expect((completedEventsA[0] as MergeCompletedEvent).payload.taskId).toBe(taskAId); // Resolve Task B's conflict harness.worktreeManager.setMergeResult(worktreeIdB, { success: true, message: 'Merged B', }); await harness.taskRepository.update(taskBId, { status: 'completed' }); await harness.coordinationManager.queueMerge(taskBId); harness.clearEvents(); const resultB = await harness.coordinationManager.processMerges('main'); expect(resultB.length).toBe(1); expect(resultB[0].taskId).toBe(taskBId); expect(resultB[0].success).toBe(true); // Verify merge:completed for B const completedEventsB = harness.getEventsByType('merge:completed'); expect(completedEventsB.length).toBe(1); expect((completedEventsB[0] as MergeCompletedEvent).payload.taskId).toBe(taskBId); // Verify final merged list has both const finalState = await harness.coordinationManager.getQueueState(); expect(finalState.merged).toContain(taskAId); expect(finalState.merged).toContain(taskBId); }); }); // =========================================================================== // Multi-agent Parallel Work // =========================================================================== describe('Multi-agent parallel work', () => { it('multiple agents complete tasks in parallel', async () => { vi.useFakeTimers(); const seeded = await harness.seedFixture(PARALLEL_FIXTURE); const taskXId = seeded.tasks.get('Task X')!; const taskYId = seeded.tasks.get('Task Y')!; const taskPId = seeded.tasks.get('Task P')!; const taskQId = seeded.tasks.get('Task Q')!; // Pre-seed 3 idle agents await harness.agentManager.spawn({ name: 'pool-agent-1', taskId: 'placeholder-1', prompt: 'placeholder', }); await harness.agentManager.spawn({ name: 'pool-agent-2', taskId: 'placeholder-2', prompt: 'placeholder', }); await harness.agentManager.spawn({ name: 'pool-agent-3', taskId: 'placeholder-3', prompt: 'placeholder', }); await harness.advanceTimers(); harness.clearEvents(); // Queue all 4 tasks await harness.dispatchManager.queue(taskXId); await harness.dispatchManager.queue(taskYId); await harness.dispatchManager.queue(taskPId); await harness.dispatchManager.queue(taskQId); harness.clearEvents(); // Dispatch 3 tasks in parallel (3 agents working) const result1 = await harness.dispatchManager.dispatchNext(); const result2 = await harness.dispatchManager.dispatchNext(); const result3 = await harness.dispatchManager.dispatchNext(); expect(result1.success).toBe(true); expect(result2.success).toBe(true); expect(result3.success).toBe(true); // All 3 should be dispatched to different agents const dispatchedIds = [result1.agentId, result2.agentId, result3.agentId]; expect(new Set(dispatchedIds).size).toBe(3); // Advance timers to complete all 3 agents await harness.advanceTimers(); // Verify: 3 agent:stopped events const stoppedEvents = harness.getEventsByType('agent:stopped'); expect(stoppedEvents.length).toBe(3); // Complete all 3 tasks await harness.dispatchManager.completeTask(result1.taskId!); await harness.dispatchManager.completeTask(result2.taskId!); await harness.dispatchManager.completeTask(result3.taskId!); // Dispatch remaining task (Task Q) const result4 = await harness.dispatchManager.dispatchNext(); expect(result4.success).toBe(true); await harness.advanceTimers(); await harness.dispatchManager.completeTask(result4.taskId!); // Verify: all 4 tasks completed in database const tasks = await Promise.all([ harness.taskRepository.findById(taskXId), harness.taskRepository.findById(taskYId), harness.taskRepository.findById(taskPId), harness.taskRepository.findById(taskQId), ]); expect(tasks.every((t) => t?.status === 'completed')).toBe(true); }); it('parallel merges process in correct dependency order', async () => { vi.useFakeTimers(); const seeded = await harness.seedFixture(COMPLEX_FIXTURE); const task1AId = seeded.tasks.get('Task 1A')!; const task1BId = seeded.tasks.get('Task 1B')!; const task2AId = seeded.tasks.get('Task 2A')!; const task3AId = seeded.tasks.get('Task 3A')!; const task4AId = seeded.tasks.get('Task 4A')!; // Complete Task 1A and Task 1B (no dependencies) await harness.taskRepository.update(task1AId, { status: 'completed' }); await harness.taskRepository.update(task1BId, { status: 'completed' }); // Set up worktrees and agents for both const wt1A = `wt-${task1AId.slice(0, 6)}`; const wt1B = `wt-${task1BId.slice(0, 6)}`; await harness.agentRepository.create({ name: `agent-${task1AId.slice(0, 6)}`, worktreeId: wt1A, taskId: task1AId, status: 'idle', }); await harness.agentRepository.create({ name: `agent-${task1BId.slice(0, 6)}`, worktreeId: wt1B, taskId: task1BId, status: 'idle', }); await harness.worktreeManager.create(wt1A, 'feature-1a'); await harness.worktreeManager.create(wt1B, 'feature-1b'); // Queue both for merge await harness.coordinationManager.queueMerge(task1AId); await harness.coordinationManager.queueMerge(task1BId); harness.clearEvents(); // Process merges - both should succeed (no dependencies between them) const results1 = await harness.coordinationManager.processMerges('main'); expect(results1.length).toBe(2); expect(results1.every((r) => r.success)).toBe(true); // Verify: merge:completed for both in same batch const completed1 = harness.getEventsByType('merge:completed'); expect(completed1.length).toBe(2); // Complete Task 2A (depends on 1A) and Task 3A (depends on 1B) await harness.taskRepository.update(task2AId, { status: 'completed' }); await harness.taskRepository.update(task3AId, { status: 'completed' }); const wt2A = `wt-${task2AId.slice(0, 6)}`; const wt3A = `wt-${task3AId.slice(0, 6)}`; await harness.agentRepository.create({ name: `agent-${task2AId.slice(0, 6)}`, worktreeId: wt2A, taskId: task2AId, status: 'idle', }); await harness.agentRepository.create({ name: `agent-${task3AId.slice(0, 6)}`, worktreeId: wt3A, taskId: task3AId, status: 'idle', }); await harness.worktreeManager.create(wt2A, 'feature-2a'); await harness.worktreeManager.create(wt3A, 'feature-3a'); // Queue and merge await harness.coordinationManager.queueMerge(task2AId); await harness.coordinationManager.queueMerge(task3AId); harness.clearEvents(); const results2 = await harness.coordinationManager.processMerges('main'); expect(results2.length).toBe(2); expect(results2.every((r) => r.success)).toBe(true); // Complete Task 4A (depends on 2A and 3A) await harness.taskRepository.update(task4AId, { status: 'completed' }); const wt4A = `wt-${task4AId.slice(0, 6)}`; await harness.agentRepository.create({ name: `agent-${task4AId.slice(0, 6)}`, worktreeId: wt4A, taskId: task4AId, status: 'idle', }); await harness.worktreeManager.create(wt4A, 'feature-4a'); // Queue and merge await harness.coordinationManager.queueMerge(task4AId); harness.clearEvents(); const results3 = await harness.coordinationManager.processMerges('main'); expect(results3.length).toBe(1); expect(results3[0].taskId).toBe(task4AId); expect(results3[0].success).toBe(true); // Verify: final merge order respects dependency graph const finalState = await harness.coordinationManager.getQueueState(); expect(finalState.merged).toContain(task1AId); expect(finalState.merged).toContain(task1BId); expect(finalState.merged).toContain(task2AId); expect(finalState.merged).toContain(task3AId); expect(finalState.merged).toContain(task4AId); }); it('parallel dispatch with mixed outcomes', async () => { vi.useFakeTimers(); const seeded = await harness.seedFixture(PARALLEL_FIXTURE); const taskXId = seeded.tasks.get('Task X')!; const taskYId = seeded.tasks.get('Task Y')!; // Pre-seed 2 agents await harness.agentManager.spawn({ name: 'pool-agent-1', taskId: 'placeholder-1', prompt: 'placeholder', }); await harness.agentManager.spawn({ name: 'pool-agent-2', taskId: 'placeholder-2', prompt: 'placeholder', }); await harness.advanceTimers(); // Set Task X to succeed, Task Y to crash harness.setAgentDone(`agent-${taskXId.slice(0, 6)}`, 'Task X completed'); harness.setAgentError(`agent-${taskYId.slice(0, 6)}`, 'Out of memory error'); // Queue both tasks await harness.dispatchManager.queue(taskXId); await harness.dispatchManager.queue(taskYId); harness.clearEvents(); // Dispatch both tasks const result1 = await harness.dispatchManager.dispatchNext(); const result2 = await harness.dispatchManager.dispatchNext(); // Both should dispatch successfully expect(result1.success).toBe(true); expect(result2.success).toBe(true); // Run timers to complete agents await harness.advanceTimers(); // Verify: one agent:stopped, one agent:crashed const stoppedEvents = harness.getEventsByType('agent:stopped'); const crashedEvents = harness.getEventsByType('agent:crashed'); expect(stoppedEvents.length).toBe(1); expect(crashedEvents.length).toBe(1); // Identify which task succeeded and which crashed const stoppedPayload = (stoppedEvents[0] as AgentStoppedEvent).payload; const crashedPayload = (crashedEvents[0] as AgentCrashedEvent).payload; // Find the successful task const successTaskId = stoppedPayload.taskId; const crashedTaskId = crashedPayload.taskId; // Complete the successful task await harness.dispatchManager.completeTask(successTaskId!); // Verify: completed task is actually completed const completedTask = await harness.taskRepository.findById(successTaskId!); expect(completedTask?.status).toBe('completed'); // Verify: crashed task stays in_progress const inProgressTask = await harness.taskRepository.findById(crashedTaskId!); expect(inProgressTask?.status).toBe('in_progress'); // Verify: completed task can merge (set up infrastructure) const wtSuccess = `wt-${successTaskId!.slice(0, 6)}`; await harness.agentRepository.create({ name: `merge-agent-${successTaskId!.slice(0, 6)}`, worktreeId: wtSuccess, taskId: successTaskId!, status: 'idle', }); await harness.worktreeManager.create(wtSuccess, 'feature-success'); await harness.coordinationManager.queueMerge(successTaskId!); const mergeResults = await harness.coordinationManager.processMerges('main'); expect(mergeResults.length).toBe(1); expect(mergeResults[0].success).toBe(true); }); }); });