feat: batch-level conflict resolution for worktree merges

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.
This commit is contained in:
2026-07-20 15:45:12 -04:00
parent 46da29ee22
commit c46f8f1783
4 changed files with 427 additions and 46 deletions

View File

@@ -17,6 +17,7 @@ import {
buildTaskPrompt, buildTaskPrompt,
buildReviewPrompt, buildReviewPrompt,
buildReviewPromptUncommitted, buildReviewPromptUncommitted,
buildConflictResolutionPrompt,
MAX_DIFF_BYTES, MAX_DIFF_BYTES,
} from "./prompts"; } from "./prompts";
import { extractReflection } from "./reflection"; import { extractReflection } from "./reflection";
@@ -26,7 +27,17 @@ import {
verdictGlyph, verdictGlyph,
verdictSummary, verdictSummary,
} from "./review"; } from "./review";
import { createWorktree, mergeWorktree, removeWorktree } from "./worktree"; import {
createWorktree,
mergeWorktree,
removeWorktree,
reattemptMerge,
abortMerge,
hasMergeConflicts,
completeMerge,
type WorktreeHandle,
type MergeResult,
} from "./worktree";
import { import {
runAgentSession, runAgentSession,
writeFileSafe, writeFileSafe,
@@ -62,6 +73,22 @@ export interface ToolCallEntry {
label: string; 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 ─────────────────────────────────────────────── // ─── Widget Expand/Collapse ───────────────────────────────────────────────
/** Max tool calls shown in a live widget before truncating. Widgets don't /** 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 useWorktree = shouldUseWorktrees(config, !!shouldParallel);
const conflicts: BatchConflict[] = [];
if (shouldParallel) { if (shouldParallel) {
await executeBatchParallel( await executeBatchParallel(
tasks, tasks,
@@ -471,42 +500,68 @@ export async function executeBatch(
projectDir, projectDir,
roundRobin, roundRobin,
useWorktree, 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) // ── Batch-level conflict resolution ──
for (const task of tasks) { // After all tasks in the batch finish, resolve any deferred merge conflicts
try { // by spawning resolution agent sessions. This doesn't block parallel slots.
await executeTask( if (conflicts.length > 0) {
task, ctx.ui.notify(
project, `Resolving ${conflicts.length} merge conflict(s) from batch...`,
config, "info",
progress, );
const dir = projectDir ?? project.sourceDir;
for (const c of conflicts) {
await resolveConflictsSession(
ctx, ctx,
config,
c.task,
project,
dir,
c.worktree,
config.model,
roundRobin,
progress,
sendChatMessage, 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, projectDir?: string,
roundRobin?: ModelRoundRobin | null, roundRobin?: ModelRoundRobin | null,
useWorktree?: boolean, useWorktree?: boolean,
conflicts?: BatchConflict[],
): Promise<void> { ): Promise<void> {
const maxParallel = config.execution.maxParallel; const maxParallel = config.execution.maxParallel;
const sharedState: ParallelWidgetState = new Map(); const sharedState: ParallelWidgetState = new Map();
@@ -636,6 +692,7 @@ async function executeBatchParallel(
roundRobin, roundRobin,
requestBatchRender, requestBatchRender,
useWorktree, useWorktree,
conflicts,
) )
.catch((error) => { .catch((error) => {
// Safety net: one task failure should never crash the batch. // Safety net: one task failure should never crash the batch.
@@ -694,6 +751,7 @@ async function executeTask(
roundRobin?: ModelRoundRobin | null, roundRobin?: ModelRoundRobin | null,
batchRender?: () => void, batchRender?: () => void,
useWorktree?: boolean, useWorktree?: boolean,
conflicts?: BatchConflict[],
): Promise<void> { ): Promise<void> {
// Model failover: when a provider/API is down, cycle through available models. // Model failover: when a provider/API is down, cycle through available models.
// Pi's built-in retry (via SettingsManager) handles transient errors with // Pi's built-in retry (via SettingsManager) handles transient errors with
@@ -1100,19 +1158,38 @@ async function executeTask(
// ── Merge worktree back to main ── // ── Merge worktree back to main ──
// After the commit lands on the worktree branch, merge it into the // After the commit lands on the worktree branch, merge it into the
// main repo so downstream tasks see the changes. On conflict, the task // main repo so downstream tasks see the changes. On conflict, the
// is marked failed and the worktree is retained for inspection. // conflict is deferred to batch-level resolution (the caller collects
// conflicted worktrees and spawns resolution sessions after the batch).
if (wt) { if (wt) {
const mergeResult = mergeWorktree(projectDir, wt.branch, task.id); const mergeResult = mergeWorktree(projectDir, wt.branch);
if (!mergeResult.success) { if (!mergeResult.success) {
sendChatMessage?.( 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); // Defer conflict resolution to the batch level.
try { if (conflicts) {
updateTaskInFile(project.sourcePath, task.id, "failed"); conflicts.push({
} catch { task,
// Best-effort 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); roundRobin?.release(task.id);
return; 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<void> {
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 * Strip control characters and newlines from a display label so it
* does not break TUI layout (tree branches, text width calculation). * does not break TUI layout (tree branches, text width calculation).

View File

@@ -426,3 +426,101 @@ export function buildPlanPrompt(project: Project): string {
return lines.join("\n"); 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 <files>`.",
);
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");
}

View File

@@ -278,7 +278,7 @@ export const DEFAULT_CONFIG: RalpiConfig = {
maxReviewRetries: 2, // 2 re-execution attempts on review rejection before giving up maxReviewRetries: 2, // 2 re-execution attempts on review rejection before giving up
reviewBlockOnFail: false, // false = commit anyway after retries exhausted reviewBlockOnFail: false, // false = commit anyway after retries exhausted
loopTimeoutMs: 0, // 0 = no limit loopTimeoutMs: 0, // 0 = no limit
worktrees: "never", // worktree isolation per task worktrees: "parallel", // worktree isolation for parallel tasks by default
}, },
prompts: { prompts: {
projectContext: "", projectContext: "",

View File

@@ -173,11 +173,7 @@ export function createWorktree(
* branch's history. On conflict, the merge is aborted and the conflicts * branch's history. On conflict, the merge is aborted and the conflicts
* are returned so the caller can mark the task as failed. * are returned so the caller can mark the task as failed.
*/ */
export function mergeWorktree( export function mergeWorktree(mainDir: string, branch: string): MergeResult {
mainDir: string,
branch: string,
taskId: string,
): MergeResult {
// Attempt the merge. // Attempt the merge.
const result = gitRaw(`merge --no-ff --no-edit "${branch}"`, mainDir); 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. * Remove a worktree and delete its branch.
* *