diff --git a/index.ts b/index.ts index a24fd68..3b89580 100644 --- a/index.ts +++ b/index.ts @@ -18,6 +18,7 @@ import { formatReflections } from "./src/reflection"; import { verdictGlyph, verdictSummary, formatFindings } from "./src/review"; import type { ReviewResult } from "./src/types"; import { executeBatch, type SendChatMessage } from "./src/executor"; +import { cleanupStaleWorktrees } from "./src/worktree"; import { loadConfig, resolveTaskArg, @@ -256,6 +257,17 @@ async function executePlanBatches( autoReview: config.execution.autoReview, 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 diff --git a/src/executor.ts b/src/executor.ts index 53bcf2b..4ee895e 100644 --- a/src/executor.ts +++ b/src/executor.ts @@ -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 { 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 { // 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 ──────────────────────────────────────────────── diff --git a/src/types.ts b/src/types.ts index a3356e2..66147a0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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: "", diff --git a/src/worktree.ts b/src/worktree.ts new file mode 100644 index 0000000..591c537 --- /dev/null +++ b/src/worktree.ts @@ -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//`. */ + 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/` 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 `/.ralpi/worktrees/` on a new + * branch `ralpi//`, 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//` 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 ` 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; +}