- Remove original task blocking in handleConflict (task is already completed by handleAgentStopped) - Return created conflict task from handleConflict so orchestrator can queue it for dispatch - Add dedup check to prevent duplicate resolution tasks on crash retries - Queue conflict resolution task via dispatchManager in mergeTaskIntoPhase - Add recovery for erroneously blocked tasks in recoverDispatchQueues - Update tests and docs
403 lines
14 KiB
TypeScript
403 lines
14 KiB
TypeScript
/**
|
|
* ConflictResolutionService Tests
|
|
*
|
|
* Tests for the conflict resolution service that handles merge conflicts
|
|
* by creating resolution tasks, updating statuses, and notifying agents.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import { DefaultConflictResolutionService } from './conflict-resolution-service.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 { 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<T extends DomainEvent>(event: T): void {
|
|
emittedEvents.push(event);
|
|
},
|
|
on: vi.fn(),
|
|
off: vi.fn(),
|
|
once: vi.fn(),
|
|
};
|
|
}
|
|
|
|
// =============================================================================
|
|
// Tests
|
|
// =============================================================================
|
|
|
|
describe('DefaultConflictResolutionService', () => {
|
|
let db: DrizzleDatabase;
|
|
let taskRepository: TaskRepository;
|
|
let agentRepository: AgentRepository;
|
|
let messageRepository: MessageRepository;
|
|
let eventBus: EventBus & { emittedEvents: DomainEvent[] };
|
|
let service: DefaultConflictResolutionService;
|
|
let testPhaseId: string;
|
|
let testInitiativeId: 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',
|
|
});
|
|
testInitiativeId = initiative.id;
|
|
|
|
const phase = await phaseRepo.create({
|
|
initiativeId: initiative.id,
|
|
name: 'Test Phase',
|
|
});
|
|
testPhaseId = phase.id;
|
|
|
|
// Create mocks
|
|
eventBus = createMockEventBus();
|
|
|
|
// Create service
|
|
service = new DefaultConflictResolutionService(
|
|
taskRepository,
|
|
agentRepository,
|
|
messageRepository,
|
|
eventBus
|
|
);
|
|
});
|
|
|
|
// ===========================================================================
|
|
// handleConflict() Tests
|
|
// ===========================================================================
|
|
|
|
describe('handleConflict', () => {
|
|
it('should create conflict resolution task with correct properties', async () => {
|
|
// Create original task
|
|
const originalTask = await taskRepository.create({
|
|
phaseId: testPhaseId,
|
|
initiativeId: testInitiativeId,
|
|
name: 'Original Task',
|
|
description: 'Original task description',
|
|
priority: 'medium',
|
|
order: 1,
|
|
});
|
|
|
|
// Create agent for task
|
|
const agent = await agentRepository.create({
|
|
name: 'agent-test',
|
|
taskId: originalTask.id,
|
|
worktreeId: 'wt-test',
|
|
});
|
|
|
|
const conflicts = ['src/file1.ts', 'src/file2.ts'];
|
|
|
|
const result = await service.handleConflict(originalTask.id, conflicts);
|
|
|
|
// Should return the created task
|
|
expect(result).toBeDefined();
|
|
expect(result!.name).toBe('Resolve conflicts: Original Task');
|
|
|
|
// Check resolution task was created
|
|
const tasks = await taskRepository.findByPhaseId(testPhaseId);
|
|
const resolutionTask = tasks.find(t => t.name.startsWith('Resolve conflicts:'));
|
|
|
|
expect(resolutionTask).toBeDefined();
|
|
expect(resolutionTask!.name).toBe('Resolve conflicts: Original Task');
|
|
expect(resolutionTask!.priority).toBe('high');
|
|
expect(resolutionTask!.type).toBe('auto');
|
|
expect(resolutionTask!.status).toBe('pending');
|
|
expect(resolutionTask!.order).toBe(originalTask.order + 1);
|
|
expect(resolutionTask!.phaseId).toBe(testPhaseId);
|
|
expect(resolutionTask!.initiativeId).toBe(testInitiativeId);
|
|
expect(resolutionTask!.parentTaskId).toBe(originalTask.parentTaskId);
|
|
|
|
// Check description contains conflict files
|
|
expect(resolutionTask!.description).toContain('src/file1.ts');
|
|
expect(resolutionTask!.description).toContain('src/file2.ts');
|
|
expect(resolutionTask!.description).toContain('Original Task');
|
|
});
|
|
|
|
it('should NOT block original task (it stays at its current status)', async () => {
|
|
const originalTask = await taskRepository.create({
|
|
phaseId: testPhaseId,
|
|
initiativeId: testInitiativeId,
|
|
name: 'Task To Not Block',
|
|
status: 'completed',
|
|
order: 1,
|
|
});
|
|
|
|
await agentRepository.create({
|
|
name: 'agent-block',
|
|
taskId: originalTask.id,
|
|
worktreeId: 'wt-block',
|
|
});
|
|
|
|
await service.handleConflict(originalTask.id, ['conflict.ts']);
|
|
|
|
// Original task should remain completed (not blocked)
|
|
const updatedTask = await taskRepository.findById(originalTask.id);
|
|
expect(updatedTask!.status).toBe('completed');
|
|
});
|
|
|
|
it('should return null and skip creation if duplicate resolution task exists', async () => {
|
|
const originalTask = await taskRepository.create({
|
|
phaseId: testPhaseId,
|
|
initiativeId: testInitiativeId,
|
|
name: 'Dedup Task',
|
|
order: 1,
|
|
});
|
|
|
|
await agentRepository.create({
|
|
name: 'agent-dedup',
|
|
taskId: originalTask.id,
|
|
worktreeId: 'wt-dedup',
|
|
});
|
|
|
|
// First call creates the resolution task
|
|
const first = await service.handleConflict(originalTask.id, ['conflict.ts']);
|
|
expect(first).toBeDefined();
|
|
|
|
// Second call should return null (dedup)
|
|
const second = await service.handleConflict(originalTask.id, ['conflict.ts']);
|
|
expect(second).toBeNull();
|
|
|
|
// Only one resolution task should exist
|
|
const tasks = await taskRepository.findByPhaseId(testPhaseId);
|
|
const resolutionTasks = tasks.filter(t => t.name.startsWith('Resolve conflicts:'));
|
|
expect(resolutionTasks.length).toBe(1);
|
|
});
|
|
|
|
it('should create message to agent about conflict', async () => {
|
|
const originalTask = await taskRepository.create({
|
|
phaseId: testPhaseId,
|
|
initiativeId: testInitiativeId,
|
|
name: 'Message Task',
|
|
order: 1,
|
|
});
|
|
|
|
const agent = await agentRepository.create({
|
|
name: 'agent-msg',
|
|
taskId: originalTask.id,
|
|
worktreeId: 'wt-msg',
|
|
});
|
|
|
|
const conflicts = ['conflict.ts'];
|
|
|
|
await service.handleConflict(originalTask.id, conflicts);
|
|
|
|
// 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].type).toBe('info');
|
|
expect(messages[0].requiresResponse).toBe(false);
|
|
|
|
// Check message content
|
|
expect(messages[0].content).toContain('Merge conflict detected');
|
|
expect(messages[0].content).toContain('Message Task');
|
|
expect(messages[0].content).toContain('conflict.ts');
|
|
expect(messages[0].content).toContain('Resolve conflicts: Message Task');
|
|
});
|
|
|
|
it('should emit TaskQueuedEvent for resolution task', async () => {
|
|
const originalTask = await taskRepository.create({
|
|
phaseId: testPhaseId,
|
|
initiativeId: testInitiativeId,
|
|
name: 'Event Task',
|
|
order: 1,
|
|
});
|
|
|
|
await agentRepository.create({
|
|
name: 'agent-event',
|
|
taskId: originalTask.id,
|
|
worktreeId: 'wt-event',
|
|
});
|
|
|
|
await service.handleConflict(originalTask.id, ['event.ts']);
|
|
|
|
// Check TaskQueuedEvent was emitted
|
|
expect(eventBus.emittedEvents.length).toBe(1);
|
|
expect(eventBus.emittedEvents[0].type).toBe('task:queued');
|
|
|
|
const event = eventBus.emittedEvents[0] as any;
|
|
expect(event.payload.priority).toBe('high');
|
|
expect(event.payload.dependsOn).toEqual([]);
|
|
|
|
// Check taskId matches the created resolution task
|
|
const tasks = await taskRepository.findByPhaseId(testPhaseId);
|
|
const resolutionTask = tasks.find(t => t.name.startsWith('Resolve conflicts:'));
|
|
expect(event.payload.taskId).toBe(resolutionTask!.id);
|
|
});
|
|
|
|
it('should work without messageRepository', async () => {
|
|
// Create service without messageRepository
|
|
const serviceNoMsg = new DefaultConflictResolutionService(
|
|
taskRepository,
|
|
agentRepository,
|
|
undefined, // No message repository
|
|
eventBus
|
|
);
|
|
|
|
const originalTask = await taskRepository.create({
|
|
phaseId: testPhaseId,
|
|
initiativeId: testInitiativeId,
|
|
name: 'No Message Task',
|
|
order: 1,
|
|
});
|
|
|
|
await agentRepository.create({
|
|
name: 'agent-no-msg',
|
|
taskId: originalTask.id,
|
|
worktreeId: 'wt-no-msg',
|
|
});
|
|
|
|
// Should not throw and should return the created task
|
|
const result = await serviceNoMsg.handleConflict(originalTask.id, ['test.ts']);
|
|
expect(result).toBeDefined();
|
|
|
|
// Check resolution task was still created
|
|
const tasks = await taskRepository.findByPhaseId(testPhaseId);
|
|
const resolutionTask = tasks.find(t => t.name.startsWith('Resolve conflicts:'));
|
|
expect(resolutionTask).toBeDefined();
|
|
});
|
|
|
|
it('should work without eventBus', async () => {
|
|
// Create service without eventBus
|
|
const serviceNoEvents = new DefaultConflictResolutionService(
|
|
taskRepository,
|
|
agentRepository,
|
|
messageRepository,
|
|
undefined // No event bus
|
|
);
|
|
|
|
const originalTask = await taskRepository.create({
|
|
phaseId: testPhaseId,
|
|
initiativeId: testInitiativeId,
|
|
name: 'No Events Task',
|
|
order: 1,
|
|
});
|
|
|
|
await agentRepository.create({
|
|
name: 'agent-no-events',
|
|
taskId: originalTask.id,
|
|
worktreeId: 'wt-no-events',
|
|
});
|
|
|
|
// Should not throw and should return the created task
|
|
const result = await serviceNoEvents.handleConflict(originalTask.id, ['test.ts']);
|
|
expect(result).toBeDefined();
|
|
|
|
// Check resolution task was still created
|
|
const tasks = await taskRepository.findByPhaseId(testPhaseId);
|
|
const resolutionTask = tasks.find(t => t.name.startsWith('Resolve conflicts:'));
|
|
expect(resolutionTask).toBeDefined();
|
|
});
|
|
|
|
it('should throw error when task not found', async () => {
|
|
await expect(service.handleConflict('non-existent-id', ['test.ts']))
|
|
.rejects.toThrow('Original task not found: non-existent-id');
|
|
});
|
|
|
|
it('should throw error when no agent found for task', async () => {
|
|
const task = await taskRepository.create({
|
|
phaseId: testPhaseId,
|
|
initiativeId: testInitiativeId,
|
|
name: 'Orphan Task',
|
|
order: 1,
|
|
});
|
|
|
|
await expect(service.handleConflict(task.id, ['test.ts']))
|
|
.rejects.toThrow(`No agent found for task: ${task.id}`);
|
|
});
|
|
|
|
it('should handle multiple conflict files correctly', async () => {
|
|
const originalTask = await taskRepository.create({
|
|
phaseId: testPhaseId,
|
|
initiativeId: testInitiativeId,
|
|
name: 'Multi-Conflict Task',
|
|
order: 1,
|
|
});
|
|
|
|
await agentRepository.create({
|
|
name: 'agent-multi',
|
|
taskId: originalTask.id,
|
|
worktreeId: 'wt-multi',
|
|
});
|
|
|
|
const conflicts = [
|
|
'src/components/Header.tsx',
|
|
'src/utils/helpers.ts',
|
|
'package.json',
|
|
'README.md'
|
|
];
|
|
|
|
await service.handleConflict(originalTask.id, conflicts);
|
|
|
|
// Check all conflict files are in the description
|
|
const tasks = await taskRepository.findByPhaseId(testPhaseId);
|
|
const resolutionTask = tasks.find(t => t.name.startsWith('Resolve conflicts:'));
|
|
|
|
expect(resolutionTask!.description).toContain('src/components/Header.tsx');
|
|
expect(resolutionTask!.description).toContain('src/utils/helpers.ts');
|
|
expect(resolutionTask!.description).toContain('package.json');
|
|
expect(resolutionTask!.description).toContain('README.md');
|
|
});
|
|
|
|
it('should preserve parentTaskId from original task', async () => {
|
|
// Create parent task first
|
|
const parentTask = await taskRepository.create({
|
|
phaseId: testPhaseId,
|
|
initiativeId: testInitiativeId,
|
|
name: 'Parent Task',
|
|
order: 1,
|
|
});
|
|
|
|
// Create child task
|
|
const childTask = await taskRepository.create({
|
|
phaseId: testPhaseId,
|
|
initiativeId: testInitiativeId,
|
|
parentTaskId: parentTask.id,
|
|
name: 'Child Task',
|
|
order: 2,
|
|
});
|
|
|
|
await agentRepository.create({
|
|
name: 'agent-child',
|
|
taskId: childTask.id,
|
|
worktreeId: 'wt-child',
|
|
});
|
|
|
|
await service.handleConflict(childTask.id, ['conflict.ts']);
|
|
|
|
// Check resolution task has same parentTaskId
|
|
const tasks = await taskRepository.findByPhaseId(testPhaseId);
|
|
const resolutionTask = tasks.find(t => t.name.startsWith('Resolve conflicts:'));
|
|
|
|
expect(resolutionTask!.parentTaskId).toBe(parentTask.id);
|
|
});
|
|
});
|
|
}); |