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:
252
src/executor.ts
252
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,10 +500,9 @@ export async function executeBatch(
|
||||
projectDir,
|
||||
roundRobin,
|
||||
useWorktree,
|
||||
conflicts,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
} else {
|
||||
// Execute sequentially (no round-robin — inherit parent model)
|
||||
for (const task of tasks) {
|
||||
try {
|
||||
@@ -491,6 +519,7 @@ export async function executeBatch(
|
||||
undefined, // roundRobin
|
||||
undefined, // batchRender
|
||||
useWorktree,
|
||||
conflicts,
|
||||
);
|
||||
} catch (error) {
|
||||
// Task failed — stop the batch. Dependent tasks are blocked by
|
||||
@@ -509,6 +538,32 @@ export async function executeBatch(
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -524,6 +579,7 @@ async function executeBatchParallel(
|
||||
projectDir?: string,
|
||||
roundRobin?: ModelRoundRobin | null,
|
||||
useWorktree?: boolean,
|
||||
conflicts?: BatchConflict[],
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
// Model failover: when a provider/API is down, cycle through available models.
|
||||
// Pi's built-in retry (via SettingsManager) handles transient errors with
|
||||
@@ -1100,20 +1158,39 @@ 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}`,
|
||||
);
|
||||
// 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<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
|
||||
* does not break TUI layout (tree branches, text width calculation).
|
||||
|
||||
@@ -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 <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");
|
||||
}
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user