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

@@ -26,6 +26,7 @@ import {
verdictGlyph,
verdictSummary,
} from "./review";
import { createWorktree, mergeWorktree, removeWorktree } from "./worktree";
import {
runAgentSession,
writeFileSafe,
@@ -388,6 +389,20 @@ export async function runTask(
/**
* 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(
tasks: Task[],
project: Project,
@@ -443,6 +458,8 @@ export async function executeBatch(
const shouldParallel =
options?.parallel && tasks.length > 0 && config.execution.maxParallel > 0;
const useWorktree = shouldUseWorktrees(config, !!shouldParallel);
if (shouldParallel) {
await executeBatchParallel(
tasks,
@@ -453,6 +470,7 @@ export async function executeBatch(
sendChatMessage,
projectDir,
roundRobin,
useWorktree,
);
return;
}
@@ -468,6 +486,11 @@ export async function executeBatch(
ctx,
sendChatMessage,
projectDir,
undefined, // parallelState
undefined, // assignedModel
undefined, // roundRobin
undefined, // batchRender
useWorktree,
);
} catch (error) {
// Task failed — stop the batch. Dependent tasks are blocked by
@@ -500,6 +523,7 @@ async function executeBatchParallel(
sendChatMessage?: SendChatMessage,
projectDir?: string,
roundRobin?: ModelRoundRobin | null,
useWorktree?: boolean,
): Promise<void> {
const maxParallel = config.execution.maxParallel;
const sharedState: ParallelWidgetState = new Map();
@@ -611,6 +635,7 @@ async function executeBatchParallel(
assignedModel,
roundRobin,
requestBatchRender,
useWorktree,
)
.catch((error) => {
// Safety net: one task failure should never crash the batch.
@@ -668,6 +693,7 @@ async function executeTask(
assignedModel?: unknown,
roundRobin?: ModelRoundRobin | null,
batchRender?: () => void,
useWorktree?: boolean,
): 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
@@ -683,6 +709,21 @@ async function executeTask(
);
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) {
// On subsequent model attempts, advance to the next model.
// Uses advance() instead of assign() so we don't get stuck on
@@ -714,7 +755,7 @@ async function executeTask(
depReflections,
ctx,
sendChatMessage,
projectDir,
worktreeDir,
parallelState,
currentModel,
batchRender,
@@ -736,9 +777,9 @@ async function executeTask(
let attempt = 0;
try {
while (hasUncommittedChanges(projectDir)) {
const status = getGitStatusPorcelain(projectDir);
const reviewDiff = getGitDiff(projectDir);
while (hasUncommittedChanges(worktreeDir)) {
const status = getGitStatusPorcelain(worktreeDir);
const reviewDiff = getGitDiff(worktreeDir);
if (!reviewDiff && !status) break;
const reviewPrompt = buildReviewPromptUncommitted(
@@ -761,7 +802,7 @@ async function executeTask(
ctx,
config,
reviewPrompt,
projectDir,
worktreeDir,
`review for ${task.id} · ${task.title}${
attempt > 0 ? ` (attempt ${attempt + 1})` : ""
}`,
@@ -871,7 +912,7 @@ async function executeTask(
depReflections,
ctx,
sendChatMessage,
projectDir,
worktreeDir,
parallelState,
currentModel,
batchRender,
@@ -897,12 +938,12 @@ async function executeTask(
}
// ── Commit (after review passes or retries exhausted) ──
if (hasUncommittedChanges(projectDir)) {
if (hasUncommittedChanges(worktreeDir)) {
const commitResult = await runCommitSession(
ctx,
config,
task,
projectDir,
worktreeDir,
currentModel,
roundRobin,
sendChatMessage,
@@ -927,12 +968,12 @@ async function executeTask(
} else if (config.execution.autoCommit) {
// ── Commit only (no review) — legacy path ──
try {
if (hasUncommittedChanges(projectDir)) {
if (hasUncommittedChanges(worktreeDir)) {
const commitResult = await runCommitSession(
ctx,
config,
task,
projectDir,
worktreeDir,
currentModel,
roundRobin,
sendChatMessage,
@@ -957,7 +998,7 @@ async function executeTask(
} else if (config.execution.autoReview) {
// ── Review only (no commit) — reviews latest commit — legacy path ──
try {
const commitInfo = getLatestCommitDiff(projectDir);
const commitInfo = getLatestCommitDiff(worktreeDir);
if (commitInfo && commitInfo.diff) {
const reviewPrompt = buildReviewPrompt(
task,
@@ -980,7 +1021,7 @@ async function executeTask(
ctx,
config,
reviewPrompt,
projectDir,
worktreeDir,
`review for ${task.id} · ${task.title}`,
`review-${task.id}`,
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
progress.markCompleted(
task.id,
@@ -1103,6 +1168,7 @@ async function executeTask(
}`,
"error",
);
if (wt) removeWorktree(projectDir, wt);
roundRobin?.release(task.id);
return;
} catch (error) {
@@ -1118,6 +1184,7 @@ async function executeTask(
}
sendChatMessage?.(`${task.id} · ${task.title}${errorMsg}`);
ctx.ui.notify(`Task ${task.id} failed: ${errorMsg}`, "error");
if (wt) removeWorktree(projectDir, wt);
return;
}
}
@@ -1133,6 +1200,7 @@ async function executeTask(
`Task ${task.id} failed: all configured models exhausted`,
"error",
);
if (wt) removeWorktree(projectDir, wt);
}
// ─── Save Reflection to File ────────────────────────────────────────────────

View File

@@ -238,6 +238,12 @@ export interface RalpiConfig {
reviewBlockOnFail: boolean;
/** Maximum total duration for the entire loop execution in milliseconds (0 = no limit). Checked between batches — in-progress tasks finish naturally. */
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: {
/** 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
reviewBlockOnFail: false, // false = commit anyway after retries exhausted
loopTimeoutMs: 0, // 0 = no limit
worktrees: "never", // worktree isolation per task
},
prompts: {
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;
}