From c46f8f1783368034569de89172e0d8008a16d288 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Mon, 20 Jul 2026 15:45:12 -0400 Subject: [PATCH] feat: batch-level conflict resolution for worktree merges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add reattemptMerge/abortMerge/completeMerge/hasMergeConflicts helpers to worktree.ts. When a worktree merge conflicts, executeTask now defers to batch-level resolution instead of immediately marking the task failed. After all tasks in a batch finish, resolveConflictsSession re-attempts the merge to recreate the conflict state, spawns an agent session with buildConflictResolutionPrompt to resolve conflict markers and commit, then verifies completion. This keeps parallel task slots unblocked — conflicts are resolved sequentially at the batch boundary. --- src/executor.ts | 316 ++++++++++++++++++++++++++++++++++++++++++------ src/prompts.ts | 98 +++++++++++++++ src/types.ts | 2 +- src/worktree.ts | 57 ++++++++- 4 files changed, 427 insertions(+), 46 deletions(-) diff --git a/src/executor.ts b/src/executor.ts index 4ee895e..d527f5e 100644 --- a/src/executor.ts +++ b/src/executor.ts @@ -17,6 +17,7 @@ import { buildTaskPrompt, buildReviewPrompt, buildReviewPromptUncommitted, + buildConflictResolutionPrompt, MAX_DIFF_BYTES, } from "./prompts"; import { extractReflection } from "./reflection"; @@ -26,7 +27,17 @@ import { verdictGlyph, verdictSummary, } from "./review"; -import { createWorktree, mergeWorktree, removeWorktree } from "./worktree"; +import { + createWorktree, + mergeWorktree, + removeWorktree, + reattemptMerge, + abortMerge, + hasMergeConflicts, + completeMerge, + type WorktreeHandle, + type MergeResult, +} from "./worktree"; import { runAgentSession, writeFileSafe, @@ -62,6 +73,22 @@ export interface ToolCallEntry { label: string; } +/** A merge conflict deferred from executeTask to batch-level resolution. */ +export interface BatchConflict { + task: Task; + worktree: WorktreeHandle; + mergeResult: MergeResult; + /** The task's run result (reflection, commits, etc.) from executeTask. */ + result: { + reflection?: Reflection; + toolUsage?: ToolUsage; + outputPreview?: string; + commitMessages?: string[]; + commitSummary?: string; + durationMs: number; + }; +} + // ─── Widget Expand/Collapse ─────────────────────────────────────────────── /** Max tool calls shown in a live widget before truncating. Widgets don't @@ -460,6 +487,8 @@ export async function executeBatch( const useWorktree = shouldUseWorktrees(config, !!shouldParallel); + const conflicts: BatchConflict[] = []; + if (shouldParallel) { await executeBatchParallel( tasks, @@ -471,42 +500,68 @@ export async function executeBatch( projectDir, roundRobin, useWorktree, + conflicts, ); - return; + } else { + // Execute sequentially (no round-robin — inherit parent model) + for (const task of tasks) { + try { + await executeTask( + task, + project, + config, + progress, + ctx, + sendChatMessage, + projectDir, + undefined, // parallelState + undefined, // assignedModel + undefined, // roundRobin + undefined, // batchRender + useWorktree, + conflicts, + ); + } catch (error) { + // Task failed — stop the batch. Dependent tasks are blocked by + // the DAG layer (getBlockedTasks) so they won't appear in this batch. + + const errorMsg = error instanceof Error ? error.message : String(error); + progress.markFailed(task.id, errorMsg); + // Auto-update the PRD source file checkbox + try { + updateTaskInFile(project.sourcePath, task.id, "failed"); + } catch { + // Best-effort + } + sendChatMessage?.(`✗ ${task.id} · ${task.title} — ${errorMsg}`); + ctx.ui.notify(`Task ${task.id} failed: ${errorMsg}`, "error"); + break; + } + } } - // Execute sequentially (no round-robin — inherit parent model) - for (const task of tasks) { - try { - await executeTask( - task, - project, - config, - progress, + // ── Batch-level conflict resolution ── + // After all tasks in the batch finish, resolve any deferred merge conflicts + // by spawning resolution agent sessions. This doesn't block parallel slots. + if (conflicts.length > 0) { + ctx.ui.notify( + `Resolving ${conflicts.length} merge conflict(s) from batch...`, + "info", + ); + const dir = projectDir ?? project.sourceDir; + for (const c of conflicts) { + await resolveConflictsSession( ctx, + config, + c.task, + project, + dir, + c.worktree, + config.model, + roundRobin, + progress, sendChatMessage, - projectDir, - undefined, // parallelState - undefined, // assignedModel - undefined, // roundRobin - undefined, // batchRender - useWorktree, ); - } catch (error) { - // Task failed — stop the batch. Dependent tasks are blocked by - // the DAG layer (getBlockedTasks) so they won't appear in this batch. - - const errorMsg = error instanceof Error ? error.message : String(error); - progress.markFailed(task.id, errorMsg); - // Auto-update the PRD source file checkbox - try { - updateTaskInFile(project.sourcePath, task.id, "failed"); - } catch { - // Best-effort - } - sendChatMessage?.(`✗ ${task.id} · ${task.title} — ${errorMsg}`); - ctx.ui.notify(`Task ${task.id} failed: ${errorMsg}`, "error"); - break; } } } @@ -524,6 +579,7 @@ async function executeBatchParallel( projectDir?: string, roundRobin?: ModelRoundRobin | null, useWorktree?: boolean, + conflicts?: BatchConflict[], ): Promise { const maxParallel = config.execution.maxParallel; const sharedState: ParallelWidgetState = new Map(); @@ -636,6 +692,7 @@ async function executeBatchParallel( roundRobin, requestBatchRender, useWorktree, + conflicts, ) .catch((error) => { // Safety net: one task failure should never crash the batch. @@ -694,6 +751,7 @@ async function executeTask( roundRobin?: ModelRoundRobin | null, batchRender?: () => void, useWorktree?: boolean, + conflicts?: BatchConflict[], ): Promise { // Model failover: when a provider/API is down, cycle through available models. // Pi's built-in retry (via SettingsManager) handles transient errors with @@ -1100,19 +1158,38 @@ async function executeTask( // ── Merge worktree back to main ── // After the commit lands on the worktree branch, merge it into the - // main repo so downstream tasks see the changes. On conflict, the task - // is marked failed and the worktree is retained for inspection. + // main repo so downstream tasks see the changes. On conflict, the + // conflict is deferred to batch-level resolution (the caller collects + // conflicted worktrees and spawns resolution sessions after the batch). if (wt) { - const mergeResult = mergeWorktree(projectDir, wt.branch, task.id); + const mergeResult = mergeWorktree(projectDir, wt.branch); if (!mergeResult.success) { sendChatMessage?.( - `✗ ${task.id} · ${task.title} — merge conflict, worktree retained at ${wt.dir}\n ${mergeResult.message}`, + `⚠ ${task.id} · ${task.title} — merge conflict, deferring to batch resolution\n ${mergeResult.message}`, ); - progress.markFailed(task.id, mergeResult.message); - try { - updateTaskInFile(project.sourcePath, task.id, "failed"); - } catch { - // Best-effort + // Defer conflict resolution to the batch level. + if (conflicts) { + conflicts.push({ + task, + worktree: wt, + mergeResult, + result: { + reflection: result.reflection, + toolUsage: result.toolUsage, + outputPreview: result.outputPreview, + commitMessages: finalCommitMessages, + commitSummary: finalCommitSummary, + durationMs: result.durationMs, + }, + }); + } else { + // No conflict collector — mark failed as fallback. + progress.markFailed(task.id, mergeResult.message); + try { + updateTaskInFile(project.sourcePath, task.id, "failed"); + } catch { + // Best-effort + } } roundRobin?.release(task.id); return; @@ -1498,6 +1575,165 @@ async function runCommitSession( }; } +// ─── Batch Conflict Resolution ─────────────────────────────────────────────── + +/** + * Resolve a merge conflict by spawning an agent session in the main repo. + * + * The merge was already attempted (and aborted) by `mergeWorktree` during + * `executeTask`. This function re-attempts the merge to recreate the conflict + * state, spawns an agent to resolve all conflict markers, stage, and commit, + * then verifies completion. On success, the worktree is cleaned up and the + * task is marked completed. On failure, the merge is aborted and the task + * is marked failed (the worktree is retained for inspection). + * + * Runs after all tasks in a batch finish, so parallel task slots aren't + * blocked waiting for conflict resolution. + */ +async function resolveConflictsSession( + ctx: ExtensionContext, + config: RalpiConfig, + task: Task, + project: Project, + projectDir: string, + worktree: WorktreeHandle, + currentModel: unknown, + roundRobin: ModelRoundRobin | null | undefined, + progress: ProgressTracker, + sendChatMessage?: SendChatMessage, +): Promise { + const { branch } = worktree; + + // Re-attempt the merge to recreate the conflict state in the main repo. + const attempt = reattemptMerge(projectDir, branch); + if (attempt.clean) { + // No conflicts on re-attempt — complete the merge directly. + if (completeMerge(projectDir)) { + sendChatMessage?.( + `✓ conflicts auto-resolved for ${task.id} · ${task.title}`, + ); + removeWorktree(projectDir, worktree); + progress.markCompleted( + task.id, + 0, // duration already tracked in executeTask + undefined, + undefined, + undefined, + [], + "", + undefined, + 0, + ); + try { + updateTaskInFile(project.sourcePath, task.id, "completed"); + } catch { + // Best-effort + } + return; + } + // completeMerge failed — fall through to mark failed. + abortMerge(projectDir); + progress.markFailed(task.id, `Failed to complete merge of ${branch}`); + try { + updateTaskInFile(project.sourcePath, task.id, "failed"); + } catch { + // Best-effort + } + return; + } + + // Conflicts exist — spawn a resolution agent session. + const prompt = buildConflictResolutionPrompt( + task, + project, + attempt.conflicts, + branch, + config.prompts.projectContext, + ); + + const commitModel = resolveFollowUpModel( + ctx, + config.execution.commitModel, + currentModel, + ); + const models = buildFailoverModels(commitModel, roundRobin); + + sendChatMessage?.( + `⚑ resolving ${attempt.conflicts.length} conflict(s) for ${task.id} · ${task.title}...`, + ); + + const { result, toolCalls } = await runFollowUpSession( + ctx, + config, + prompt, + projectDir, + `resolve conflicts for ${task.id}`, + `resolve-${task.id}`, + config.execution.commitTimeoutMs, + models, + ); + + if (!result.success) { + sendChatMessage?.( + `~ conflict resolution for ${task.id} · ${task.title} — session failed: ${result.error}`, + { toolCalls }, + ); + abortMerge(projectDir); + progress.markFailed( + task.id, + `Conflict resolution session failed: ${result.error}`, + ); + try { + updateTaskInFile(project.sourcePath, task.id, "failed"); + } catch { + // Best-effort + } + return; + } + + // Check if the agent actually resolved all conflicts and committed. + if (hasMergeConflicts(projectDir)) { + // Agent didn't resolve everything — abort and fail. + sendChatMessage?.( + `✗ ${task.id} · ${task.title} — conflict resolution incomplete, ${attempt.conflicts.length} file(s) still conflicted`, + { toolCalls }, + ); + abortMerge(projectDir); + progress.markFailed( + task.id, + `Conflict resolution incomplete — unresolved conflicts remaining`, + ); + try { + updateTaskInFile(project.sourcePath, task.id, "failed"); + } catch { + // Best-effort + } + return; + } + + // Success — conflicts resolved and merge committed. + sendChatMessage?.(`✓ conflicts resolved for ${task.id} · ${task.title}`, { + toolCalls, + }); + removeWorktree(projectDir, worktree); + progress.markCompleted( + task.id, + 0, + undefined, + undefined, + undefined, + [], + "", + undefined, + 0, + ); + try { + updateTaskInFile(project.sourcePath, task.id, "completed"); + } catch { + // Best-effort + } +} + /** * Strip control characters and newlines from a display label so it * does not break TUI layout (tree branches, text width calculation). diff --git a/src/prompts.ts b/src/prompts.ts index c7e69a1..05c8804 100644 --- a/src/prompts.ts +++ b/src/prompts.ts @@ -426,3 +426,101 @@ export function buildPlanPrompt(project: Project): string { return lines.join("\n"); } + +// ─── Conflict Resolution Prompt ───────────────────────────────────────────── + +/** + * Build the prompt for a conflict-resolution agent session. + * + * The main repo is in a merge-conflict state (from `reattemptMerge`). The + * agent must resolve all conflict markers in the conflicted files, stage the + * resolved files, and commit to complete the merge. + */ +export function buildConflictResolutionPrompt( + task: Task, + project: Project, + conflicts: string[], + branch: string, + projectContext?: string, +): string { + const parts: string[] = []; + + parts.push(`# Merge Conflict Resolution: Task ${task.id}: ${task.title}`); + parts.push(""); + parts.push( + `A merge of branch \`${branch}\` into the current branch produced conflicts.`, + ); + parts.push("You must resolve all conflicts and complete the merge."); + parts.push(""); + + // ── Task Context ── + + parts.push("## Task Description"); + if (task.description) { + parts.push(task.description); + } else { + parts.push(task.title); + } + parts.push(""); + + // ── Task Specification ── + + if (task.file) { + const spec = readTaskSpec(project.sourceDir, task.file); + if (spec) { + parts.push("## Task Specification"); + parts.push(`Full details from \`${task.file}\`:`); + parts.push(""); + parts.push(spec); + parts.push(""); + } + } + + // ── Conflicted Files ── + + parts.push("## Conflicted Files"); + parts.push( + "The following files have unresolved merge conflicts (conflict markers `<<<<<<<`, `=======`, `>>>>>>>`):", + ); + parts.push(""); + for (const f of conflicts) { + parts.push(`- \`${f}\``); + } + parts.push(""); + + // ── Project Context ── + + if (projectContext) { + parts.push("## Additional Context"); + parts.push(projectContext); + parts.push(""); + } + + // ── Resolution Instructions ── + + parts.push("## Resolution Instructions"); + parts.push( + "1. Read each conflicted file to understand both sides of the conflict.", + ); + parts.push( + "2. Edit each file to remove all conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`).", + ); + parts.push( + " Keep the correct changes from both sides — do NOT blindly pick one side.", + ); + parts.push( + " The goal is a correct union of both the task's changes and the main branch.", + ); + parts.push( + "3. After resolving all conflicts, stage the resolved files with `git add `.", + ); + parts.push( + "4. Complete the merge with `git commit` — use the default merge message.", + ); + parts.push(""); + parts.push( + "Resolve ALL conflicts. Do NOT abort the merge. Do NOT leave any conflict markers.", + ); + + return parts.join("\n"); +} diff --git a/src/types.ts b/src/types.ts index 66147a0..2cfd65e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -278,7 +278,7 @@ export const DEFAULT_CONFIG: RalpiConfig = { maxReviewRetries: 2, // 2 re-execution attempts on review rejection before giving up reviewBlockOnFail: false, // false = commit anyway after retries exhausted loopTimeoutMs: 0, // 0 = no limit - worktrees: "never", // worktree isolation per task + worktrees: "parallel", // worktree isolation for parallel tasks by default }, prompts: { projectContext: "", diff --git a/src/worktree.ts b/src/worktree.ts index 591c537..50d90c2 100644 --- a/src/worktree.ts +++ b/src/worktree.ts @@ -173,11 +173,7 @@ export function createWorktree( * branch's history. On conflict, the merge is aborted and the conflicts * are returned so the caller can mark the task as failed. */ -export function mergeWorktree( - mainDir: string, - branch: string, - taskId: string, -): MergeResult { +export function mergeWorktree(mainDir: string, branch: string): MergeResult { // Attempt the merge. const result = gitRaw(`merge --no-ff --no-edit "${branch}"`, mainDir); @@ -209,6 +205,57 @@ export function mergeWorktree( }; } +/** + * Re-attempt a merge WITHOUT aborting on conflict. + * + * Unlike `mergeWorktree`, this leaves the main repo in a merge-conflict + * state so a conflict-resolution agent can see the conflict markers in the + * working tree and resolve them manually. The caller is responsible for + * committing the resolved merge or aborting it. + * + * Returns: + * - `clean: true` → merge succeeded (nothing staged to commit yet; the + * caller should `git commit` or `git merge --abort` to finalise) + * - `clean: false` → conflicts; working tree has conflict markers + */ +export function reattemptMerge( + mainDir: string, + branch: string, +): { clean: boolean; conflicts: string[] } { + // Use --no-commit so even a clean merge doesn't auto-commit — the caller + // controls when the merge commit lands. + const result = gitRaw(`merge --no-ff --no-commit "${branch}"`, mainDir); + + if (result.ok) { + return { clean: true, conflicts: [] }; + } + + // Merge produced conflicts — collect them but DO NOT abort. + const status = git("diff --name-only --diff-filter=U", mainDir) ?? ""; + const conflicts = status + .split("\n") + .map((l) => l.trim()) + .filter(Boolean); + return { clean: false, conflicts }; +} + +/** Abort an in-progress merge in the main repo. */ +export function abortMerge(mainDir: string): void { + git("merge --abort", mainDir); +} + +/** Check if there are unmerged paths (conflicts) in the working tree. */ +export function hasMergeConflicts(mainDir: string): boolean { + const status = git("diff --name-only --diff-filter=U", mainDir) ?? ""; + return status.trim().length > 0; +} + +/** Complete the in-progress merge by committing. Returns true on success. */ +export function completeMerge(mainDir: string): boolean { + const result = gitRaw("commit --no-edit", mainDir); + return result.ok; +} + /** * Remove a worktree and delete its branch. *