From 6cf6bd076f25482971ea6ec1829bccd48027e14f Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 11:17:25 +0100 Subject: [PATCH] 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. --- .../agent/prompts/conflict-resolution.ts | 74 ++++++ apps/server/agent/prompts/index.ts | 1 + apps/server/execution/orchestrator.test.ts | 1 + apps/server/git/branch-manager.ts | 9 +- apps/server/git/index.ts | 2 +- apps/server/git/simple-git-branch-manager.ts | 39 +++- apps/server/git/types.ts | 13 ++ apps/server/trpc/routers/agent.ts | 21 ++ apps/server/trpc/routers/initiative.ts | 125 +++++++++- .../review/ConflictResolutionPanel.tsx | 182 +++++++++++++++ .../components/review/InitiativeReview.tsx | 56 ++++- apps/web/src/hooks/index.ts | 8 +- apps/web/src/hooks/useConflictAgent.ts | 214 ++++++++++++++++++ docs/agent.md | 2 +- docs/frontend.md | 2 + docs/git-process-logging.md | 2 + docs/server-api.md | 3 + 17 files changed, 745 insertions(+), 9 deletions(-) create mode 100644 apps/server/agent/prompts/conflict-resolution.ts create mode 100644 apps/web/src/components/review/ConflictResolutionPanel.tsx create mode 100644 apps/web/src/hooks/useConflictAgent.ts diff --git a/apps/server/agent/prompts/conflict-resolution.ts b/apps/server/agent/prompts/conflict-resolution.ts new file mode 100644 index 0000000..876df7b --- /dev/null +++ b/apps/server/agent/prompts/conflict-resolution.ts @@ -0,0 +1,74 @@ +/** + * Conflict resolution prompt — spawned when initiative branch has merge conflicts + * with the target branch. + */ + +import { + SIGNAL_FORMAT, + SESSION_STARTUP, + GIT_WORKFLOW, + CONTEXT_MANAGEMENT, +} from './shared.js'; + +export function buildConflictResolutionPrompt( + sourceBranch: string, + targetBranch: string, + conflicts: string[], +): string { + const conflictList = conflicts.map(f => `- \`${f}\``).join('\n'); + + return ` +You are a Conflict Resolution agent. Your job is to merge \`${targetBranch}\` into \`${sourceBranch}\` and resolve all merge conflicts so the initiative branch is up to date with the target branch. + + + +**Source branch (initiative):** \`${sourceBranch}\` +**Target branch (default):** \`${targetBranch}\` + +**Conflicting files:** +${conflictList} + +${SIGNAL_FORMAT} +${SESSION_STARTUP} + + +Follow these steps in order: + +1. **Inspect divergence**: Run \`git log --oneline ${targetBranch}..${sourceBranch}\` and \`git log --oneline ${sourceBranch}..${targetBranch}\` to understand what each side changed. + +2. **Review conflicting files**: For each conflicting file, read both versions: + - \`git show ${sourceBranch}:\` + - \`git show ${targetBranch}:\` + +3. **Merge**: Run \`git merge ${targetBranch} --no-edit\`. This will produce conflict markers. + +4. **Resolve each file**: For each conflicting file: + - Read the file to see conflict markers (\`<<<<<<<\`, \`=======\`, \`>>>>>>>\`) + - Understand both sides' intent from step 1-2 + - Choose the correct resolution — keep both changes when they don't overlap, prefer the more complete version when they do + - If you genuinely cannot determine the correct resolution, signal "questions" explaining the ambiguity + +5. **Verify**: Run \`git diff --check\` to confirm no conflict markers remain. Run the test suite to confirm nothing is broken. + +6. **Commit**: Stage resolved files with \`git add \` (never \`git add .\`), then \`git commit --no-edit\` to complete the merge commit. + +7. **Signal done**: Write signal.json with status "done". + +${GIT_WORKFLOW} +${CONTEXT_MANAGEMENT} + + +- You are merging ${targetBranch} INTO ${sourceBranch} — bringing the initiative branch up to date, NOT the other way around. +- Do NOT force-push or rebase. A merge commit is the correct approach. +- If tests fail after resolution, fix the code — don't skip tests. +- If a conflict is genuinely ambiguous (e.g., both sides rewrote the same function differently), signal "questions" with the specific ambiguity and your proposed resolution. +`; +} + +export function buildConflictResolutionDescription( + sourceBranch: string, + targetBranch: string, + conflicts: string[], +): string { + return `Resolve ${conflicts.length} merge conflict(s) between ${sourceBranch} and ${targetBranch}: ${conflicts.join(', ')}`; +} diff --git a/apps/server/agent/prompts/index.ts b/apps/server/agent/prompts/index.ts index 7722872..2186994 100644 --- a/apps/server/agent/prompts/index.ts +++ b/apps/server/agent/prompts/index.ts @@ -15,3 +15,4 @@ export { buildChatPrompt } from './chat.js'; export type { ChatHistoryEntry } from './chat.js'; export { buildWorkspaceLayout } from './workspace.js'; export { buildPreviewInstructions } from './preview.js'; +export { buildConflictResolutionPrompt, buildConflictResolutionDescription } from './conflict-resolution.js'; diff --git a/apps/server/execution/orchestrator.test.ts b/apps/server/execution/orchestrator.test.ts index 57efcd5..698b9d2 100644 --- a/apps/server/execution/orchestrator.test.ts +++ b/apps/server/execution/orchestrator.test.ts @@ -48,6 +48,7 @@ function createMocks() { diffCommit: vi.fn().mockResolvedValue(''), getMergeBase: vi.fn().mockResolvedValue('abc123'), pushBranch: vi.fn(), + checkMergeability: vi.fn().mockResolvedValue({ mergeable: true }), }; const phaseRepository = { diff --git a/apps/server/git/branch-manager.ts b/apps/server/git/branch-manager.ts index 113ac7b..901413d 100644 --- a/apps/server/git/branch-manager.ts +++ b/apps/server/git/branch-manager.ts @@ -6,7 +6,7 @@ * a worktree to be checked out. */ -import type { MergeResult, BranchCommit } from './types.js'; +import type { MergeResult, MergeabilityResult, BranchCommit } from './types.js'; export interface BranchManager { /** @@ -68,4 +68,11 @@ export interface BranchManager { * Defaults to 'origin' if no remote specified. */ pushBranch(repoPath: string, branch: string, remote?: string): Promise; + + /** + * Dry-run merge check — determines if sourceBranch can be cleanly merged + * into targetBranch without actually performing the merge. + * Uses `git merge-tree --write-tree` (git 2.38+). + */ + checkMergeability(repoPath: string, sourceBranch: string, targetBranch: string): Promise; } diff --git a/apps/server/git/index.ts b/apps/server/git/index.ts index 41bd3b0..f7b9351 100644 --- a/apps/server/git/index.ts +++ b/apps/server/git/index.ts @@ -13,7 +13,7 @@ export type { WorktreeManager } from './types.js'; // Domain types -export type { Worktree, WorktreeDiff, MergeResult } from './types.js'; +export type { Worktree, WorktreeDiff, MergeResult, MergeabilityResult } from './types.js'; // Adapters export { SimpleGitWorktreeManager } from './manager.js'; diff --git a/apps/server/git/simple-git-branch-manager.ts b/apps/server/git/simple-git-branch-manager.ts index a7678e7..66c5754 100644 --- a/apps/server/git/simple-git-branch-manager.ts +++ b/apps/server/git/simple-git-branch-manager.ts @@ -11,7 +11,7 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { simpleGit } from 'simple-git'; import type { BranchManager } from './branch-manager.js'; -import type { MergeResult, BranchCommit } from './types.js'; +import type { MergeResult, MergeabilityResult, BranchCommit } from './types.js'; import { createModuleLogger } from '../logger/index.js'; const log = createModuleLogger('branch-manager'); @@ -164,4 +164,41 @@ export class SimpleGitBranchManager implements BranchManager { await git.push(remote, branch); log.info({ repoPath, branch, remote }, 'branch pushed to remote'); } + + async checkMergeability(repoPath: string, sourceBranch: string, targetBranch: string): Promise { + const git = simpleGit(repoPath); + + try { + // git merge-tree --write-tree merges source INTO target virtually. + // Exit 0 = clean merge, non-zero = conflicts. + await git.raw(['merge-tree', '--write-tree', targetBranch, sourceBranch]); + log.debug({ repoPath, sourceBranch, targetBranch }, 'merge-tree check: clean'); + return { mergeable: true }; + } catch (err) { + const stderr = err instanceof Error ? err.message : String(err); + + // Parse conflict file names from "CONFLICT (content): Merge conflict in " + const conflictPattern = /CONFLICT \([^)]+\): (?:Merge conflict in|.* -> )(.+)/g; + const conflicts: string[] = []; + let match: RegExpExecArray | null; + while ((match = conflictPattern.exec(stderr)) !== null) { + conflicts.push(match[1].trim()); + } + + if (conflicts.length > 0) { + log.debug({ repoPath, sourceBranch, targetBranch, conflicts }, 'merge-tree check: conflicts'); + return { mergeable: false, conflicts }; + } + + // If we couldn't parse conflicts but the command failed, it's still a conflict + // (could be add/add, rename conflicts, etc.) + if (stderr.includes('CONFLICT')) { + log.debug({ repoPath, sourceBranch, targetBranch }, 'merge-tree check: unparsed conflicts'); + return { mergeable: false, conflicts: ['(unable to parse conflict details)'] }; + } + + // Genuine error (not a conflict) + throw err; + } + } } diff --git a/apps/server/git/types.ts b/apps/server/git/types.ts index 17d56ae..8471b75 100644 --- a/apps/server/git/types.ts +++ b/apps/server/git/types.ts @@ -58,6 +58,19 @@ export interface MergeResult { message: string; } +// ============================================================================= +// Mergeability Check +// ============================================================================= + +/** + * Result of a dry-run merge check. + * No side effects — only tells you whether the merge would succeed. + */ +export interface MergeabilityResult { + mergeable: boolean; + conflicts?: string[]; +} + // ============================================================================= // Branch Commit Info // ============================================================================= diff --git a/apps/server/trpc/routers/agent.ts b/apps/server/trpc/routers/agent.ts index 74fdc50..a0c3660 100644 --- a/apps/server/trpc/routers/agent.ts +++ b/apps/server/trpc/routers/agent.ts @@ -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 => { + 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 => { diff --git a/apps/server/trpc/routers/initiative.ts b/apps/server/trpc/routers/initiative.ts index 37b77b3..373639c 100644 --- a/apps/server/trpc/routers/initiative.ts +++ b/apps/server/trpc/routers/initiative.ts @@ -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, + }); + }), }; } diff --git a/apps/web/src/components/review/ConflictResolutionPanel.tsx b/apps/web/src/components/review/ConflictResolutionPanel.tsx new file mode 100644 index 0000000..f4d99fd --- /dev/null +++ b/apps/web/src/components/review/ConflictResolutionPanel.tsx @@ -0,0 +1,182 @@ +import { Loader2, AlertCircle, GitMerge, CheckCircle2, ChevronDown, ChevronRight, Terminal } from 'lucide-react'; +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { QuestionForm } from '@/components/QuestionForm'; +import { useConflictAgent } from '@/hooks/useConflictAgent'; + +interface ConflictResolutionPanelProps { + initiativeId: string; + conflicts: string[]; + onResolved: () => void; +} + +export function ConflictResolutionPanel({ initiativeId, conflicts, onResolved }: ConflictResolutionPanelProps) { + const { state, agent, questions, spawn, resume, stop, dismiss } = useConflictAgent(initiativeId); + const [showManual, setShowManual] = useState(false); + + if (state === 'none') { + return ( +
+
+ +
+

+ {conflicts.length} merge conflict{conflicts.length !== 1 ? 's' : ''} detected +

+
    + {conflicts.map((file) => ( +
  • {file}
  • + ))} +
+
+ + +
+ {spawn.error && ( +

{spawn.error.message}

+ )} + {showManual && ( +
+

+ In your project clone, run: +

+
+{`git checkout 
+git merge 
+# Resolve conflicts in each file
+git add 
+git commit --no-edit`}
+                
+
+ )} +
+
+
+ ); + } + + if (state === 'running') { + return ( +
+
+
+ + Resolving merge conflicts... +
+ +
+
+ ); + } + + if (state === 'waiting' && questions) { + return ( +
+
+ +

Agent needs input

+
+ resume.mutate(answers)} + onCancel={() => {}} + onDismiss={() => stop.mutate()} + isSubmitting={resume.isPending} + isDismissing={stop.isPending} + /> +
+ ); + } + + if (state === 'completed') { + return ( +
+
+
+ + Conflicts resolved +
+
+ +
+
+
+ ); + } + + if (state === 'crashed') { + return ( +
+
+
+ + Conflict resolution agent crashed +
+
+ + +
+
+
+ ); + } + + return null; +} diff --git a/apps/web/src/components/review/InitiativeReview.tsx b/apps/web/src/components/review/InitiativeReview.tsx index a23a83a..1f28963 100644 --- a/apps/web/src/components/review/InitiativeReview.tsx +++ b/apps/web/src/components/review/InitiativeReview.tsx @@ -1,12 +1,14 @@ -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; -import { Loader2, GitBranch, ArrowRight, FileCode, Plus, Minus, Upload, GitMerge } from "lucide-react"; +import { Loader2, GitBranch, ArrowRight, FileCode, Plus, Minus, Upload, GitMerge, AlertTriangle, CheckCircle2 } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; import { trpc } from "@/lib/trpc"; import { parseUnifiedDiff } from "./parse-diff"; import { DiffViewer } from "./DiffViewer"; import { ReviewSidebar } from "./ReviewSidebar"; import { PreviewControls } from "./PreviewControls"; +import { ConflictResolutionPanel } from "./ConflictResolutionPanel"; interface InitiativeReviewProps { initiativeId: string; @@ -49,6 +51,26 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview { enabled: !!selectedCommit }, ); + // Mergeability check + const mergeabilityQuery = trpc.checkInitiativeMergeability.useQuery( + { initiativeId }, + { refetchInterval: 30_000 }, + ); + const mergeability = mergeabilityQuery.data ?? null; + + // Auto-refresh mergeability when a conflict agent completes + const conflictAgentQuery = trpc.getActiveConflictAgent.useQuery({ initiativeId }); + const conflictAgentStatus = conflictAgentQuery.data?.status; + const prevConflictStatusRef = useRef(conflictAgentStatus); + useEffect(() => { + const prev = prevConflictStatusRef.current; + prevConflictStatusRef.current = conflictAgentStatus; + // When agent transitions from running/waiting to idle (completed) + if (prev && ['running', 'waiting_for_input'].includes(prev) && conflictAgentStatus === 'idle') { + void mergeabilityQuery.refetch(); + } + }, [conflictAgentStatus, mergeabilityQuery]); + // Preview state const projectsQuery = trpc.getInitiativeProjects.useQuery({ initiativeId }); const firstProjectId = projectsQuery.data?.[0]?.id ?? null; @@ -184,6 +206,24 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview {totalDeletions} + + {/* Mergeability badge */} + {mergeabilityQuery.isLoading ? ( + + + Checking... + + ) : mergeability?.mergeable ? ( + + + Clean merge + + ) : mergeability && !mergeability.mergeable ? ( + + + {mergeability.conflictFiles.length} conflict{mergeability.conflictFiles.length !== 1 ? 's' : ''} + + ) : null} {/* Right: preview + action buttons */} @@ -206,7 +246,8 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview