Files
Codewalkers/apps/server/coordination/conflict-resolution-service.ts
Lukas May d4a28713f6 fix: conflict resolution tasks now get dispatched instead of permanently blocking initiative
- 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
2026-03-06 20:37:29 +01:00

178 lines
6.1 KiB
TypeScript

/**
* ConflictResolutionService
*
* Service responsible for handling merge conflicts by:
* - Creating conflict resolution tasks
* - Updating original task status
* - Notifying agents via messages
* - Emitting appropriate events
*
* This service is used by the CoordinationManager when merge conflicts occur.
*/
import type { EventBus, TaskQueuedEvent } from '../events/index.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 { Task } from '../db/schema.js';
// =============================================================================
// ConflictResolutionService Interface (Port)
// =============================================================================
/**
* Service interface for handling merge conflicts.
* This is the PORT - implementations are ADAPTERS.
*/
/**
* Branch context for merge conflicts from the branch hierarchy.
*/
export interface MergeContext {
sourceBranch: string;
targetBranch: string;
}
export interface ConflictResolutionService {
/**
* Handle a merge conflict by creating resolution task and notifying agent.
*
* @param taskId - ID of the task that conflicted
* @param conflicts - List of conflicting file paths
* @param mergeContext - Optional branch context for branch hierarchy merges
* @returns The created conflict-resolution task, or null if a duplicate already exists
*/
handleConflict(taskId: string, conflicts: string[], mergeContext?: MergeContext): Promise<Task | null>;
}
// =============================================================================
// DefaultConflictResolutionService Implementation (Adapter)
// =============================================================================
/**
* Default implementation of ConflictResolutionService.
*
* Creates conflict resolution tasks, updates task statuses, sends messages
* to agents, and emits events when merge conflicts occur.
*/
export class DefaultConflictResolutionService implements ConflictResolutionService {
constructor(
private taskRepository: TaskRepository,
private agentRepository: AgentRepository,
private messageRepository?: MessageRepository,
private eventBus?: EventBus
) {}
/**
* Handle a merge conflict.
* Creates a conflict-resolution task and notifies the agent via message.
* Returns the created task, or null if a duplicate already exists.
*
* NOTE: The original task is NOT blocked. It was already completed by
* handleAgentStopped before this method is called. The pending resolution
* task prevents premature phase completion on its own.
*/
async handleConflict(taskId: string, conflicts: string[], mergeContext?: MergeContext): Promise<Task | null> {
// Get original task for context
const originalTask = await this.taskRepository.findById(taskId);
if (!originalTask) {
throw new Error(`Original task not found: ${taskId}`);
}
// Get agent that was working on the task
const agent = await this.agentRepository.findByTaskId(taskId);
if (!agent) {
throw new Error(`No agent found for task: ${taskId}`);
}
// Dedup: skip if a pending/in_progress resolution task already exists for this original task
if (originalTask.phaseId) {
const phaseTasks = await this.taskRepository.findByPhaseId(originalTask.phaseId);
const existingResolution = phaseTasks.find(
(t) =>
t.name === `Resolve conflicts: ${originalTask.name}` &&
(t.status === 'pending' || t.status === 'in_progress'),
);
if (existingResolution) {
return null;
}
}
// Build conflict description
const descriptionLines = [
'Merge conflicts detected. Resolve conflicts in the following files:',
'',
...conflicts.map((f) => `- ${f}`),
'',
`Original task: ${originalTask.name}`,
'',
];
if (mergeContext) {
descriptionLines.push(
`Resolve merge conflicts between branch "${mergeContext.sourceBranch}" and "${mergeContext.targetBranch}".`,
`Run: git merge ${mergeContext.sourceBranch} --no-edit`,
'Resolve all conflicts, then: git add . && git commit',
);
} else {
descriptionLines.push(
'Instructions: Resolve merge conflicts in the listed files, then mark task complete.',
);
}
const conflictDescription = descriptionLines.join('\n');
// Create new conflict-resolution task
const conflictTask = await this.taskRepository.create({
parentTaskId: originalTask.parentTaskId,
phaseId: originalTask.phaseId,
initiativeId: originalTask.initiativeId,
name: `Resolve conflicts: ${originalTask.name}`,
description: conflictDescription,
category: mergeContext ? 'merge' : 'execute',
type: 'auto',
priority: 'high',
status: 'pending',
order: originalTask.order + 1,
});
// Create message to agent if messageRepository is configured
if (this.messageRepository) {
const messageContent = [
`Merge conflict detected for task: ${originalTask.name}`,
'',
'Conflicting files:',
...conflicts.map((f) => `- ${f}`),
'',
`A new task has been created to resolve these conflicts: ${conflictTask.name}`,
'',
'Please resolve the merge conflicts in the listed files and mark the resolution task as complete.',
].join('\n');
await this.messageRepository.create({
senderType: 'user', // System-generated messages appear as from user
senderId: null,
recipientType: 'agent',
recipientId: agent.id,
type: 'info',
content: messageContent,
requiresResponse: false,
});
}
// Emit TaskQueuedEvent for the new conflict-resolution task
if (this.eventBus) {
const event: TaskQueuedEvent = {
type: 'task:queued',
timestamp: new Date(),
payload: {
taskId: conflictTask.id,
priority: 'high',
dependsOn: [],
},
};
this.eventBus.emit(event);
}
return conflictTask;
}
}