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
This commit is contained in:
Lukas May
2026-01-31 15:43:56 +01:00
parent e318a92413
commit 878f2a28d7

View File

@@ -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);
});
});
});