feat: Add merge conflict detection and agent resolution in initiative review

Pre-merge mergeability check via `git merge-tree --write-tree` (dry-run, no
side effects). When conflicts exist the "Merge & Push" button is disabled and
a ConflictResolutionPanel shows conflict files with options to resolve manually
or spawn a conflict-resolution agent. Agent questions appear inline via
QuestionForm; on completion the mergeability re-checks automatically.

New server-side: MergeabilityResult type, BranchManager.checkMergeability,
conflict-resolution prompt, checkInitiativeMergeability query,
spawnConflictResolutionAgent mutation, getActiveConflictAgent query.

New frontend: useConflictAgent hook, ConflictResolutionPanel component,
mergeability badge + panel integration in InitiativeReview.
This commit is contained in:
Lukas May
2026-03-06 11:17:25 +01:00
parent 3a01b9e9ca
commit 6cf6bd076f
17 changed files with 745 additions and 9 deletions

View File

@@ -184,6 +184,27 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) {
return candidates[0] ?? null;
}),
getActiveConflictAgent: publicProcedure
.input(z.object({ initiativeId: z.string().min(1) }))
.query(async ({ ctx, input }): Promise<AgentInfo | null> => {
const agentManager = requireAgentManager(ctx);
const allAgents = await agentManager.list();
const candidates = allAgents
.filter(
(a) =>
a.mode === 'execute' &&
a.initiativeId === input.initiativeId &&
a.name?.startsWith('conflict-') &&
['running', 'waiting_for_input', 'idle', 'crashed'].includes(a.status) &&
!a.userDismissedAt,
)
.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
return candidates[0] ?? null;
}),
getAgentOutput: publicProcedure
.input(agentIdentifierSchema)
.query(async ({ ctx, input }): Promise<string> => {

View File

@@ -7,7 +7,7 @@ import { z } from 'zod';
import type { ProcedureBuilder } from '../trpc.js';
import { requireAgentManager, requireInitiativeRepository, requireProjectRepository, requireTaskRepository, requireBranchManager, requireExecutionOrchestrator } from './_helpers.js';
import { deriveInitiativeActivity } from './initiative-activity.js';
import { buildRefinePrompt } from '../../agent/prompts/index.js';
import { buildRefinePrompt, buildConflictResolutionPrompt, buildConflictResolutionDescription } from '../../agent/prompts/index.js';
import type { PageForSerialization } from '../../agent/content-serializer.js';
import { ensureProjectClone } from '../../git/project-clones.js';
@@ -349,5 +349,128 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
);
return { success: true, taskId: result.taskId };
}),
checkInitiativeMergeability: publicProcedure
.input(z.object({ initiativeId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const initiativeRepo = requireInitiativeRepository(ctx);
const projectRepo = requireProjectRepository(ctx);
const branchManager = requireBranchManager(ctx);
const initiative = await initiativeRepo.findById(input.initiativeId);
if (!initiative) {
throw new TRPCError({ code: 'NOT_FOUND', message: `Initiative '${input.initiativeId}' not found` });
}
if (!initiative.branch) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Initiative has no branch configured' });
}
const projects = await projectRepo.findProjectsByInitiativeId(input.initiativeId);
const allConflicts: string[] = [];
let mergeable = true;
for (const project of projects) {
const clonePath = await ensureProjectClone(project, ctx.workspaceRoot!);
const result = await branchManager.checkMergeability(clonePath, initiative.branch, project.defaultBranch);
if (!result.mergeable) {
mergeable = false;
if (result.conflicts) allConflicts.push(...result.conflicts);
}
}
return {
mergeable,
conflictFiles: allConflicts,
targetBranch: projects[0]?.defaultBranch ?? 'main',
};
}),
spawnConflictResolutionAgent: publicProcedure
.input(z.object({
initiativeId: z.string().min(1),
provider: z.string().optional(),
}))
.mutation(async ({ ctx, input }) => {
const agentManager = requireAgentManager(ctx);
const initiativeRepo = requireInitiativeRepository(ctx);
const projectRepo = requireProjectRepository(ctx);
const taskRepo = requireTaskRepository(ctx);
const branchManager = requireBranchManager(ctx);
const initiative = await initiativeRepo.findById(input.initiativeId);
if (!initiative) {
throw new TRPCError({ code: 'NOT_FOUND', message: `Initiative '${input.initiativeId}' not found` });
}
if (!initiative.branch) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Initiative has no branch configured' });
}
const projects = await projectRepo.findProjectsByInitiativeId(input.initiativeId);
if (projects.length === 0) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Initiative has no linked projects' });
}
// Auto-dismiss stale conflict agents
const allAgents = await agentManager.list();
const staleAgents = allAgents.filter(
(a) =>
a.mode === 'execute' &&
a.initiativeId === input.initiativeId &&
a.name?.startsWith('conflict-') &&
['crashed', 'idle'].includes(a.status) &&
!a.userDismissedAt,
);
for (const stale of staleAgents) {
await agentManager.dismiss(stale.id);
}
// Reject if active conflict agent already running
const activeConflictAgents = allAgents.filter(
(a) =>
a.mode === 'execute' &&
a.initiativeId === input.initiativeId &&
a.name?.startsWith('conflict-') &&
['running', 'waiting_for_input'].includes(a.status),
);
if (activeConflictAgents.length > 0) {
throw new TRPCError({
code: 'CONFLICT',
message: 'A conflict resolution agent is already running for this initiative',
});
}
// Re-check mergeability to get current conflict list
const project = projects[0];
const clonePath = await ensureProjectClone(project, ctx.workspaceRoot!);
const mergeCheck = await branchManager.checkMergeability(clonePath, initiative.branch, project.defaultBranch);
if (mergeCheck.mergeable) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'No merge conflicts detected — merge is clean' });
}
const conflicts = mergeCheck.conflicts ?? [];
const targetBranch = project.defaultBranch;
// Create task
const task = await taskRepo.create({
initiativeId: input.initiativeId,
name: `Resolve conflicts: ${initiative.name}`,
description: buildConflictResolutionDescription(initiative.branch, targetBranch, conflicts),
category: 'merge',
status: 'in_progress',
});
// Spawn agent
const prompt = buildConflictResolutionPrompt(initiative.branch, targetBranch, conflicts);
return agentManager.spawn({
name: `conflict-${Date.now()}`,
taskId: task.id,
prompt,
mode: 'execute',
provider: input.provider,
initiativeId: input.initiativeId,
baseBranch: targetBranch,
branchName: initiative.branch,
});
}),
};
}