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:
74
apps/server/agent/prompts/conflict-resolution.ts
Normal file
74
apps/server/agent/prompts/conflict-resolution.ts
Normal file
@@ -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 `<role>
|
||||
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.
|
||||
</role>
|
||||
|
||||
<conflict_details>
|
||||
**Source branch (initiative):** \`${sourceBranch}\`
|
||||
**Target branch (default):** \`${targetBranch}\`
|
||||
|
||||
**Conflicting files:**
|
||||
${conflictList}
|
||||
</conflict_details>
|
||||
${SIGNAL_FORMAT}
|
||||
${SESSION_STARTUP}
|
||||
|
||||
<resolution_protocol>
|
||||
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}:<file>\`
|
||||
- \`git show ${targetBranch}:<file>\`
|
||||
|
||||
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 <file>\` (never \`git add .\`), then \`git commit --no-edit\` to complete the merge commit.
|
||||
|
||||
7. **Signal done**: Write signal.json with status "done".
|
||||
</resolution_protocol>
|
||||
${GIT_WORKFLOW}
|
||||
${CONTEXT_MANAGEMENT}
|
||||
|
||||
<important>
|
||||
- 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.
|
||||
</important>`;
|
||||
}
|
||||
|
||||
export function buildConflictResolutionDescription(
|
||||
sourceBranch: string,
|
||||
targetBranch: string,
|
||||
conflicts: string[],
|
||||
): string {
|
||||
return `Resolve ${conflicts.length} merge conflict(s) between ${sourceBranch} and ${targetBranch}: ${conflicts.join(', ')}`;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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<void>;
|
||||
|
||||
/**
|
||||
* 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<MergeabilityResult>;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<MergeabilityResult> {
|
||||
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 <path>"
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// =============================================================================
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user