feat: separate workspaces per parallel execution

This commit is contained in:
2026-07-20 12:27:22 -04:00
parent 519b12b3d9
commit 46da29ee22
4 changed files with 367 additions and 12 deletions

View File

@@ -18,6 +18,7 @@ import { formatReflections } from "./src/reflection";
import { verdictGlyph, verdictSummary, formatFindings } from "./src/review"; import { verdictGlyph, verdictSummary, formatFindings } from "./src/review";
import type { ReviewResult } from "./src/types"; import type { ReviewResult } from "./src/types";
import { executeBatch, type SendChatMessage } from "./src/executor"; import { executeBatch, type SendChatMessage } from "./src/executor";
import { cleanupStaleWorktrees } from "./src/worktree";
import { import {
loadConfig, loadConfig,
resolveTaskArg, resolveTaskArg,
@@ -256,6 +257,17 @@ async function executePlanBatches(
autoReview: config.execution.autoReview, autoReview: config.execution.autoReview,
saveReviews: config.execution.saveReviews, saveReviews: config.execution.saveReviews,
}); });
// Clean up stale worktrees from interrupted runs before starting.
if (config.execution.worktrees !== "never" && projectDir) {
const removed = cleanupStaleWorktrees(projectDir, progress.getKey());
if (removed.length > 0) {
ctx.ui.notify(
`Cleaned up ${removed.length} stale worktree(s) from previous run.`,
"info",
);
}
}
} }
// Track failed task IDs across batches to block downstream tasks // Track failed task IDs across batches to block downstream tasks

View File

@@ -26,6 +26,7 @@ import {
verdictGlyph, verdictGlyph,
verdictSummary, verdictSummary,
} from "./review"; } from "./review";
import { createWorktree, mergeWorktree, removeWorktree } from "./worktree";
import { import {
runAgentSession, runAgentSession,
writeFileSafe, writeFileSafe,
@@ -388,6 +389,20 @@ export async function runTask(
/** /**
* Execute a batch of tasks (sequentially or in parallel) * Execute a batch of tasks (sequentially or in parallel)
*/ */
// ─── Worktree Decision ──────────────────────────────────────────────────────
/** Determine if worktree isolation should be used based on config + mode. */
function shouldUseWorktrees(config: RalpiConfig, isParallel: boolean): boolean {
switch (config.execution.worktrees) {
case "always":
return true;
case "parallel":
return isParallel;
default:
return false; // "never"
}
}
export async function executeBatch( export async function executeBatch(
tasks: Task[], tasks: Task[],
project: Project, project: Project,
@@ -443,6 +458,8 @@ export async function executeBatch(
const shouldParallel = const shouldParallel =
options?.parallel && tasks.length > 0 && config.execution.maxParallel > 0; options?.parallel && tasks.length > 0 && config.execution.maxParallel > 0;
const useWorktree = shouldUseWorktrees(config, !!shouldParallel);
if (shouldParallel) { if (shouldParallel) {
await executeBatchParallel( await executeBatchParallel(
tasks, tasks,
@@ -453,6 +470,7 @@ export async function executeBatch(
sendChatMessage, sendChatMessage,
projectDir, projectDir,
roundRobin, roundRobin,
useWorktree,
); );
return; return;
} }
@@ -468,6 +486,11 @@ export async function executeBatch(
ctx, ctx,
sendChatMessage, sendChatMessage,
projectDir, projectDir,
undefined, // parallelState
undefined, // assignedModel
undefined, // roundRobin
undefined, // batchRender
useWorktree,
); );
} catch (error) { } catch (error) {
// Task failed — stop the batch. Dependent tasks are blocked by // Task failed — stop the batch. Dependent tasks are blocked by
@@ -500,6 +523,7 @@ async function executeBatchParallel(
sendChatMessage?: SendChatMessage, sendChatMessage?: SendChatMessage,
projectDir?: string, projectDir?: string,
roundRobin?: ModelRoundRobin | null, roundRobin?: ModelRoundRobin | null,
useWorktree?: boolean,
): Promise<void> { ): Promise<void> {
const maxParallel = config.execution.maxParallel; const maxParallel = config.execution.maxParallel;
const sharedState: ParallelWidgetState = new Map(); const sharedState: ParallelWidgetState = new Map();
@@ -611,6 +635,7 @@ async function executeBatchParallel(
assignedModel, assignedModel,
roundRobin, roundRobin,
requestBatchRender, requestBatchRender,
useWorktree,
) )
.catch((error) => { .catch((error) => {
// Safety net: one task failure should never crash the batch. // Safety net: one task failure should never crash the batch.
@@ -668,6 +693,7 @@ async function executeTask(
assignedModel?: unknown, assignedModel?: unknown,
roundRobin?: ModelRoundRobin | null, roundRobin?: ModelRoundRobin | null,
batchRender?: () => void, batchRender?: () => void,
useWorktree?: boolean,
): 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
@@ -683,6 +709,21 @@ async function executeTask(
); );
let currentModel: unknown = assignedModel ?? implModel ?? config.model; let currentModel: unknown = assignedModel ?? implModel ?? config.model;
// ── Worktree isolation ──
// When enabled, the task runs in a separate git worktree so parallel tasks
// can't stomp each other's files, and review/commit see a clean single-task
// diff. `worktreeDir` is used for agent cwd + git ops; `projectDir` stays as
// the main repo dir for state saves (reflections, reviews, progress.json).
const wt = useWorktree
? createWorktree(
projectDir,
config.paths.stateDir,
task.id,
progress.getKey(),
)
: null;
const worktreeDir = wt?.dir ?? projectDir;
while (modelAttempt < maxModelAttempts) { while (modelAttempt < maxModelAttempts) {
// On subsequent model attempts, advance to the next model. // On subsequent model attempts, advance to the next model.
// Uses advance() instead of assign() so we don't get stuck on // Uses advance() instead of assign() so we don't get stuck on
@@ -714,7 +755,7 @@ async function executeTask(
depReflections, depReflections,
ctx, ctx,
sendChatMessage, sendChatMessage,
projectDir, worktreeDir,
parallelState, parallelState,
currentModel, currentModel,
batchRender, batchRender,
@@ -736,9 +777,9 @@ async function executeTask(
let attempt = 0; let attempt = 0;
try { try {
while (hasUncommittedChanges(projectDir)) { while (hasUncommittedChanges(worktreeDir)) {
const status = getGitStatusPorcelain(projectDir); const status = getGitStatusPorcelain(worktreeDir);
const reviewDiff = getGitDiff(projectDir); const reviewDiff = getGitDiff(worktreeDir);
if (!reviewDiff && !status) break; if (!reviewDiff && !status) break;
const reviewPrompt = buildReviewPromptUncommitted( const reviewPrompt = buildReviewPromptUncommitted(
@@ -761,7 +802,7 @@ async function executeTask(
ctx, ctx,
config, config,
reviewPrompt, reviewPrompt,
projectDir, worktreeDir,
`review for ${task.id} · ${task.title}${ `review for ${task.id} · ${task.title}${
attempt > 0 ? ` (attempt ${attempt + 1})` : "" attempt > 0 ? ` (attempt ${attempt + 1})` : ""
}`, }`,
@@ -871,7 +912,7 @@ async function executeTask(
depReflections, depReflections,
ctx, ctx,
sendChatMessage, sendChatMessage,
projectDir, worktreeDir,
parallelState, parallelState,
currentModel, currentModel,
batchRender, batchRender,
@@ -897,12 +938,12 @@ async function executeTask(
} }
// ── Commit (after review passes or retries exhausted) ── // ── Commit (after review passes or retries exhausted) ──
if (hasUncommittedChanges(projectDir)) { if (hasUncommittedChanges(worktreeDir)) {
const commitResult = await runCommitSession( const commitResult = await runCommitSession(
ctx, ctx,
config, config,
task, task,
projectDir, worktreeDir,
currentModel, currentModel,
roundRobin, roundRobin,
sendChatMessage, sendChatMessage,
@@ -927,12 +968,12 @@ async function executeTask(
} else if (config.execution.autoCommit) { } else if (config.execution.autoCommit) {
// ── Commit only (no review) — legacy path ── // ── Commit only (no review) — legacy path ──
try { try {
if (hasUncommittedChanges(projectDir)) { if (hasUncommittedChanges(worktreeDir)) {
const commitResult = await runCommitSession( const commitResult = await runCommitSession(
ctx, ctx,
config, config,
task, task,
projectDir, worktreeDir,
currentModel, currentModel,
roundRobin, roundRobin,
sendChatMessage, sendChatMessage,
@@ -957,7 +998,7 @@ async function executeTask(
} else if (config.execution.autoReview) { } else if (config.execution.autoReview) {
// ── Review only (no commit) — reviews latest commit — legacy path ── // ── Review only (no commit) — reviews latest commit — legacy path ──
try { try {
const commitInfo = getLatestCommitDiff(projectDir); const commitInfo = getLatestCommitDiff(worktreeDir);
if (commitInfo && commitInfo.diff) { if (commitInfo && commitInfo.diff) {
const reviewPrompt = buildReviewPrompt( const reviewPrompt = buildReviewPrompt(
task, task,
@@ -980,7 +1021,7 @@ async function executeTask(
ctx, ctx,
config, config,
reviewPrompt, reviewPrompt,
projectDir, worktreeDir,
`review for ${task.id} · ${task.title}`, `review for ${task.id} · ${task.title}`,
`review-${task.id}`, `review-${task.id}`,
config.execution.reviewTimeoutMs, config.execution.reviewTimeoutMs,
@@ -1057,6 +1098,30 @@ 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.
if (wt) {
const mergeResult = mergeWorktree(projectDir, wt.branch, task.id);
if (!mergeResult.success) {
sendChatMessage?.(
`${task.id} · ${task.title} — merge conflict, worktree retained at ${wt.dir}\n ${mergeResult.message}`,
);
progress.markFailed(task.id, mergeResult.message);
try {
updateTaskInFile(project.sourcePath, task.id, "failed");
} catch {
// Best-effort
}
roundRobin?.release(task.id);
return;
}
// Merge succeeded — clean up the worktree.
removeWorktree(projectDir, wt);
sendChatMessage?.(`✓ merged worktree for ${task.id} into main`);
}
// Mark completed with all metadata // Mark completed with all metadata
progress.markCompleted( progress.markCompleted(
task.id, task.id,
@@ -1103,6 +1168,7 @@ async function executeTask(
}`, }`,
"error", "error",
); );
if (wt) removeWorktree(projectDir, wt);
roundRobin?.release(task.id); roundRobin?.release(task.id);
return; return;
} catch (error) { } catch (error) {
@@ -1118,6 +1184,7 @@ async function executeTask(
} }
sendChatMessage?.(`${task.id} · ${task.title}${errorMsg}`); sendChatMessage?.(`${task.id} · ${task.title}${errorMsg}`);
ctx.ui.notify(`Task ${task.id} failed: ${errorMsg}`, "error"); ctx.ui.notify(`Task ${task.id} failed: ${errorMsg}`, "error");
if (wt) removeWorktree(projectDir, wt);
return; return;
} }
} }
@@ -1133,6 +1200,7 @@ async function executeTask(
`Task ${task.id} failed: all configured models exhausted`, `Task ${task.id} failed: all configured models exhausted`,
"error", "error",
); );
if (wt) removeWorktree(projectDir, wt);
} }
// ─── Save Reflection to File ──────────────────────────────────────────────── // ─── Save Reflection to File ────────────────────────────────────────────────

View File

@@ -238,6 +238,12 @@ export interface RalpiConfig {
reviewBlockOnFail: boolean; reviewBlockOnFail: boolean;
/** Maximum total duration for the entire loop execution in milliseconds (0 = no limit). Checked between batches — in-progress tasks finish naturally. */ /** Maximum total duration for the entire loop execution in milliseconds (0 = no limit). Checked between batches — in-progress tasks finish naturally. */
loopTimeoutMs: number; loopTimeoutMs: number;
/** Isolate each task in a separate git worktree so parallel tasks can't
* stomp each other's files, and review/commit see a clean single-task diff.
* - "never": all tasks run in the shared working tree (default, backward compat)
* - "parallel": only when maxParallel > 1 and mode is parallel
* - "always": every task gets its own worktree */
worktrees: "always" | "parallel" | "never";
}; };
prompts: { prompts: {
/** Additional context injected into every task prompt */ /** Additional context injected into every task prompt */
@@ -272,6 +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
}, },
prompts: { prompts: {
projectContext: "", projectContext: "",

268
src/worktree.ts Normal file
View File

@@ -0,0 +1,268 @@
import * as path from "node:path";
import { ensureDir } from "./utils";
// ─── Types ───────────────────────────────────────────────────────────────────
export interface WorktreeHandle {
/** Absolute path to the worktree working directory. */
dir: string;
/** Branch name: `ralpi/<prdKey>/<taskId>`. */
branch: string;
/** Main repo directory (where the primary working tree lives). */
mainDir: string;
}
export interface MergeResult {
success: boolean;
/** File paths that conflicted (empty when merge succeeds). */
conflicts: string[];
/** Human-readable status message. */
message: string;
}
// ─── Git Helpers ─────────────────────────────────────────────────────────────
/** Run a git command, returning trimmed stdout. Returns null on failure. */
function git(args: string, cwd: string): string | null {
const { execSync } = require("node:child_process") as {
execSync: (cmd: string, opts: object) => string;
};
try {
return execSync(`git ${args}`, {
cwd,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
}).trim();
} catch {
return null;
}
}
/** Run a git command that may fail; returns { ok, stdout, stderr }. */
function gitRaw(
args: string,
cwd: string,
): { ok: boolean; stdout: string; stderr: string } {
const { execSync } = require("node:child_process") as {
execSync: (cmd: string, opts: object) => string;
};
try {
const stdout = execSync(`git ${args}`, {
cwd,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
});
return { ok: true, stdout: stdout.trim(), stderr: "" };
} catch (err: unknown) {
const e = err as {
stdout?: string;
stderr?: string;
message?: string;
};
return {
ok: false,
stdout: (e.stdout ?? "").toString().trim(),
stderr: (e.stderr ?? "").toString().trim(),
};
}
}
/** Check if a directory is inside a git repository. */
export function isGitRepo(dir: string): boolean {
return git("rev-parse --git-dir", dir) !== null;
}
/** Get the current HEAD commit hash of a directory. */
export function getGitHead(dir: string): string | null {
return git("rev-parse HEAD", dir);
}
/** Get the current branch name of a directory. */
export function getCurrentBranch(dir: string): string | null {
return git("rev-parse --abbrev-ref HEAD", dir);
}
// ─── Worktree Lifecycle ──────────────────────────────────────────────────────
/**
* Path to the worktree directory for a given task.
* Lives inside `.ralpi/worktrees/<taskId>` in the main repo so all ralpi
* state stays co-located. The directory itself is untracked git metadata
* (registered in `.git/worktrees/`), so it won't pollute `git status`
* in the main working tree.
*/
export function worktreePath(
mainDir: string,
stateDir: string,
taskId: string,
): string {
return path.join(mainDir, stateDir, "worktrees", taskId);
}
/**
* Normalise a task ID into a valid git branch suffix.
* Zero-padded IDs like "01" are already valid; this ensures any stray
* characters are replaced.
*/
function safeBranchSuffix(taskId: string): string {
return taskId.replace(/[^a-zA-Z0-9_-]/g, "-");
}
/**
* Create a git worktree for a task.
*
* The worktree is created at `<mainDir>/.ralpi/worktrees/<taskId>` on a new
* branch `ralpi/<prdKey>/<taskId>`, based at `baseRef` (defaults to the
* current HEAD of `mainDir`).
*
* Returns null if `mainDir` is not a git repo or the worktree creation fails.
*/
export function createWorktree(
mainDir: string,
stateDir: string,
taskId: string,
prdKey: string,
baseRef?: string,
): WorktreeHandle | null {
if (!isGitRepo(mainDir)) return null;
const ref = baseRef ?? getGitHead(mainDir);
if (!ref) return null;
const safeId = safeBranchSuffix(taskId);
const branch = `ralpi/${prdKey}/${safeId}`;
const wtDir = worktreePath(mainDir, stateDir, taskId);
// Ensure the parent directory exists so `git worktree add` can create
// the worktree directory inside it.
ensureDir(path.dirname(wtDir));
// Remove a stale worktree directory if one exists (e.g. from a crashed
// previous run). `git worktree add` fails if the path already exists.
// We prune first to clean up any metadata for removed-but-not-pruned dirs.
git("worktree prune", mainDir);
const existing = git(`worktree list --porcelain`, mainDir);
if (existing && existing.includes(`worktree ${wtDir}`)) {
// A worktree at this path is already registered — remove it.
git(`worktree remove --force "${wtDir}"`, mainDir);
}
// Also delete a stale branch if it exists from a previous run.
git(`branch -D "${branch}"`, mainDir);
const result = gitRaw(
`worktree add -b "${branch}" "${wtDir}" "${ref}"`,
mainDir,
);
if (!result.ok) {
// Fall back to detached HEAD worktree if branch creation fails
// (e.g. the branch name somehow conflicts).
const fallback = gitRaw(
`worktree add --detach "${wtDir}" "${ref}"`,
mainDir,
);
if (!fallback.ok) return null;
}
return { dir: wtDir, branch, mainDir };
}
/**
* Merge a worktree's branch back into the current branch of the main repo.
*
* Uses `--no-ff` to always create a merge commit, preserving the task
* 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 {
// Attempt the merge.
const result = gitRaw(`merge --no-ff --no-edit "${branch}"`, mainDir);
if (result.ok) {
return {
success: true,
conflicts: [],
message: `Merged ${branch} into ${getCurrentBranch(mainDir) ?? "HEAD"}`,
};
}
// Merge failed — likely conflicts. Collect the list of conflicting files.
const status = git("diff --name-only --diff-filter=U", mainDir) ?? "";
const conflicts = status
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
// Abort the merge so the main repo's working tree is left clean.
git("merge --abort", mainDir);
return {
success: false,
conflicts,
message:
conflicts.length > 0
? `Merge conflicts in: ${conflicts.join(", ")}`
: `Merge of ${branch} failed: ${result.stderr || result.stdout}`,
};
}
/**
* Remove a worktree and delete its branch.
*
* Called after a successful merge to clean up. Safe to call even if the
* worktree or branch no longer exists.
*/
export function removeWorktree(mainDir: string, wt: WorktreeHandle): void {
git(`worktree remove --force "${wt.dir}"`, mainDir);
git(`branch -D "${wt.branch}"`, mainDir);
git("worktree prune", mainDir);
}
/**
* Clean up stale worktrees from interrupted runs.
*
* Lists all worktrees whose branches start with `ralpi/<prdKey>/` and
* removes them. Called at the start of a loop to ensure a clean slate.
* Returns the list of removed worktree directories.
*/
export function cleanupStaleWorktrees(
mainDir: string,
prdKey: string,
): string[] {
const removed: string[] = [];
// Prune metadata for worktree directories that no longer exist on disk.
git("worktree prune", mainDir);
const list = git("worktree list --porcelain", mainDir);
if (!list) return removed;
// Parse worktree list: each entry is `worktree <path>` followed by metadata.
const wtLines = list
.split("\n")
.filter((l) => l.startsWith("worktree "))
.map((l) => l.slice("worktree ".length).trim());
for (const wtDir of wtLines) {
// Skip the main working tree (always first in the list).
if (path.resolve(wtDir) === path.resolve(mainDir)) continue;
// Check if this worktree is on a ralpi branch for this PRD.
const branch = git(`rev-parse --abbrev-ref HEAD`, wtDir);
if (!branch) continue;
if (!branch.startsWith(`ralpi/${prdKey}/`)) continue;
// Remove the worktree and its branch.
git(`worktree remove --force "${wtDir}"`, mainDir);
if (branch !== "HEAD" && branch !== "detached") {
git(`branch -D "${branch}"`, mainDir);
}
removed.push(wtDir);
}
git("worktree prune", mainDir);
return removed;
}