From 878f2a28d7f6cf1d8aff5ed1fa7a5134c060eba9 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Sat, 31 Jan 2026 15:43:56 +0100 Subject: [PATCH] test(09-01): create conflict hand-back round-trip E2E tests - Full conflict cycle: detect conflict -> agent resolves -> merge succeeds - Conflict resolution preserves original task context - Multiple sequential conflicts resolved in order - 3 conflict round-trip tests passing --- src/test/e2e/extended-scenarios.test.ts | 551 ++++++++++++++++++++++++ 1 file changed, 551 insertions(+) create mode 100644 src/test/e2e/extended-scenarios.test.ts diff --git a/src/test/e2e/extended-scenarios.test.ts b/src/test/e2e/extended-scenarios.test.ts new file mode 100644 index 0000000..76d3bc7 --- /dev/null +++ b/src/test/e2e/extended-scenarios.test.ts @@ -0,0 +1,551 @@ +/** + * 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 marked blocked + const originalTask = await harness.taskRepository.findById(taskAId); + expect(originalTask?.status).toBe('blocked'); + + // Note: CoordinationManager.handleConflict updates task status to blocked + // but does not emit task:blocked event (that's emitted by DispatchManager.blockTask) + + // 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 vi.runAllTimersAsync(); + 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); + + // Use vi.runAllTimersAsync() to complete all 3 agents + await vi.runAllTimersAsync(); + + // 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 vi.runAllTimersAsync(); + 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 vi.runAllTimersAsync(); + + // 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 vi.runAllTimersAsync(); + + // 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); + }); + }); +});