import { spawnSync } from "node:child_process"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { resolveAuthorityDecision, type AuthorityPolicyConfig } from "../../policy/authority.ts"; export interface WorktreeSetup { cwd: string; worktrees: WorktreeInfo[]; baseCommit: string; capturedDiffs?: WorktreeDiff[]; } export interface WorktreeInfo { path: string; agentCwd: string; branch: string; index: number; nodeModulesLinked: boolean; syntheticPaths: string[]; } export interface WorktreeDiff { index: number; agent: string; branch: string; diffStat: string; filesChanged: number; insertions: number; deletions: number; patchPath: string; error?: string; } export interface WorktreeCleanupTask { index: number; path: string; branch: string; worktreeRemoved: boolean; branchRemoved: boolean; preserved?: boolean; reason?: string; errors?: string[]; } export type WorktreeCleanupIntent = | { kind: "preserve"; capturedDiffs?: WorktreeDiff[]; handoffManifestPath?: string } | { kind: "discard"; authorization: | { kind: "policy"; policy?: AuthorityPolicyConfig } | { kind: "confirmed"; policy?: AuthorityPolicyConfig }; } | { kind: "setup-rollback" }; export interface WorktreeCleanupReport { state: "complete" | "partial"; tasks: WorktreeCleanupTask[]; pruned: boolean; errors?: string[]; } interface WorktreeTaskCwdConflict { index: number; agent: string; cwd: string; } interface WorktreeSetupHookConfig { hookPath: string; timeoutMs?: number; } interface CreateWorktreesOptions { agents?: string[]; setupHook?: WorktreeSetupHookConfig; baseDir?: string; } interface ResolvedWorktreeSetupHook { hookPath: string; timeoutMs: number; } interface WorktreeSetupHookInput { version: 1; repoRoot: string; worktreePath: string; agentCwd: string; branch: string; index: number; runId: string; baseCommit: string; agent?: string; } interface WorktreeSetupHookOutput { syntheticPaths?: string[]; } interface GitResult { stdout: string; stderr: string; status: number | null; } interface RepoState { toplevel: string; cwdRelative: string; baseCommit: string; } const DEFAULT_WORKTREE_SETUP_HOOK_TIMEOUT_MS = 30000; function runGit(cwd: string, args: string[]): GitResult { const result = spawnSync("git", ["-C", cwd, ...args], { encoding: "utf-8", windowsHide: true }); return { stdout: result.stdout ?? "", stderr: result.stderr ?? "", status: result.status, }; } function runGitChecked(cwd: string, args: string[]): string { const result = runGit(cwd, args); if (result.status !== 0) { const command = `git -C ${cwd} ${args.join(" ")}`; const message = result.stderr.trim() || result.stdout.trim() || `${command} failed`; throw new Error(message); } return result.stdout; } function resolveRepoState(cwd: string): RepoState { const cwdRelative = resolveRepoCwdRelative(cwd); const toplevel = runGitChecked(cwd, ["rev-parse", "--show-toplevel"]).trim(); // pi-subagents writes durable runtime state under .pi-subagents/ by default; // that state must not make managed isolation unusable for later runs. const status = runGitChecked(toplevel, ["status", "--porcelain", "--", ":!.pi-subagents"]); if (status.trim().length > 0) { throw new Error("worktree isolation requires a clean git working tree. Commit or stash changes first."); } const baseCommit = runGitChecked(toplevel, ["rev-parse", "HEAD"]).trim(); return { toplevel, cwdRelative, baseCommit }; } function normalizeComparableCwd(cwd: string): string { const resolved = path.resolve(cwd); try { return fs.realpathSync(resolved); } catch { // Use the unresolved absolute path when realpath resolution is unavailable. return resolved; } } export function findWorktreeTaskCwdConflict( tasks: ReadonlyArray<{ agent: string; cwd?: string }>, sharedCwd: string, ): WorktreeTaskCwdConflict | undefined { const normalizedSharedCwd = normalizeComparableCwd(sharedCwd); for (let index = 0; index < tasks.length; index++) { const task = tasks[index]!; if (!task.cwd) continue; const taskCwd = path.isAbsolute(task.cwd) ? task.cwd : path.resolve(sharedCwd, task.cwd); if (normalizeComparableCwd(taskCwd) === normalizedSharedCwd) continue; return { index, agent: task.agent, cwd: task.cwd }; } return undefined; } export function formatWorktreeTaskCwdConflict( conflict: WorktreeTaskCwdConflict, sharedCwd: string, ): string { return `worktree isolation uses the shared cwd (${sharedCwd}); task ${conflict.index + 1} (${conflict.agent}) sets cwd to ${conflict.cwd}. Remove task-level cwd overrides or disable worktree.`; } function safePatchAgentName(agent: string): string { return agent.replace(/[^\w.-]/g, "_"); } function buildWorktreeBranch(runId: string, index: number): string { return `pi-parallel-${runId}-${index}`; } function resolveWorktreeBaseDir(configuredBaseDir: string | undefined, repoRoot: string): string { const rawBaseDir = configuredBaseDir ?? process.env.SELESAI_SUBAGENTS_WORKTREE_DIR; if (rawBaseDir === undefined) return os.tmpdir(); const trimmed = rawBaseDir.trim(); if (!trimmed) throw new Error("worktree base directory cannot be empty"); const expanded = trimmed.startsWith("~/") ? path.join(os.homedir(), trimmed.slice(2)) : trimmed; const resolved = path.isAbsolute(expanded) ? expanded : path.resolve(repoRoot, expanded); try { fs.mkdirSync(resolved, { recursive: true }); } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error(`failed to create worktree base directory ${resolved}: ${message}`); } return resolved; } function buildWorktreePath(baseDir: string, runId: string, index: number): string { return path.join(baseDir, `pi-worktree-${runId}-${index}`); } function resolveRepoCwdRelative(cwd: string): string { const repoCheck = runGit(cwd, ["rev-parse", "--is-inside-work-tree"]); if (repoCheck.status !== 0 || repoCheck.stdout.trim() !== "true") { throw new Error("worktree isolation requires a git repository"); } const rawPrefix = runGitChecked(cwd, ["rev-parse", "--show-prefix"]).trim(); const normalizedPrefix = rawPrefix ? path.normalize(rawPrefix.replace(/[\\/]+$/, "")) : ""; return normalizedPrefix === "." ? "" : normalizedPrefix; } export function resolveExpectedWorktreeAgentCwd(cwd: string, runId: string, index: number, baseDir?: string): string { const cwdRelative = resolveRepoCwdRelative(cwd); const repoRoot = runGitChecked(cwd, ["rev-parse", "--show-toplevel"]).trim(); const worktreePath = buildWorktreePath(resolveWorktreeBaseDir(baseDir, repoRoot), runId, index); return cwdRelative ? path.join(worktreePath, cwdRelative) : worktreePath; } function linkNodeModulesIfPresent(toplevel: string, worktreePath: string): boolean { const nodeModulesPath = path.join(toplevel, "node_modules"); const nodeModulesLinkPath = path.join(worktreePath, "node_modules"); if (!fs.existsSync(nodeModulesPath) || fs.existsSync(nodeModulesLinkPath)) return false; try { fs.symlinkSync(nodeModulesPath, nodeModulesLinkPath); return true; } catch { // Symlink creation is optional (e.g., unsupported filesystems on CI runners). return false; } } function parseHookTimeout(timeoutMs: number | undefined): number { if (timeoutMs === undefined) return DEFAULT_WORKTREE_SETUP_HOOK_TIMEOUT_MS; if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) { throw new Error("worktree setup hook timeout must be an integer greater than 0"); } return timeoutMs; } function resolveWorktreeSetupHook( repoRoot: string, config: WorktreeSetupHookConfig | undefined, ): ResolvedWorktreeSetupHook | undefined { if (!config) return undefined; const hookPath = config.hookPath.trim(); if (!hookPath) { throw new Error("worktree setup hook path cannot be empty"); } const expandedHookPath = hookPath.startsWith("~/") ? path.join(os.homedir(), hookPath.slice(2)) : hookPath; let resolvedPath: string; if (path.isAbsolute(expandedHookPath)) { resolvedPath = expandedHookPath; } else if (expandedHookPath.includes("/") || expandedHookPath.includes("\\")) { resolvedPath = path.resolve(repoRoot, expandedHookPath); } else { throw new Error("worktree setup hook must be an absolute path or a repo-relative path"); } if (!fs.existsSync(resolvedPath)) { throw new Error(`worktree setup hook not found: ${resolvedPath}`); } if (fs.statSync(resolvedPath).isDirectory()) { throw new Error(`worktree setup hook must be a file, got directory: ${resolvedPath}`); } return { hookPath: resolvedPath, timeoutMs: parseHookTimeout(config.timeoutMs), }; } function normalizeSyntheticPath(worktreePath: string, rawPath: string): string { const trimmed = rawPath.trim(); if (!trimmed) throw new Error("synthetic path cannot be empty"); if (path.isAbsolute(trimmed)) throw new Error(`synthetic path must be relative: ${rawPath}`); const resolved = path.resolve(worktreePath, trimmed); const relative = path.relative(worktreePath, resolved); if (!relative || relative === ".") { throw new Error(`synthetic path cannot target the worktree root: ${rawPath}`); } if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { throw new Error(`synthetic path escapes the worktree root: ${rawPath}`); } return path.normalize(relative); } function hasTrackedEntries(worktreePath: string, relativePath: string): boolean { const result = runGit(worktreePath, ["ls-files", "--", relativePath]); return result.status === 0 && result.stdout.trim().length > 0; } function parseWorktreeSetupHookOutput(rawStdout: string): WorktreeSetupHookOutput { const trimmed = rawStdout.trim(); if (!trimmed) { throw new Error("worktree setup hook returned empty stdout; expected JSON object"); } let parsed: unknown; try { parsed = JSON.parse(trimmed); } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error(`worktree setup hook returned invalid JSON: ${message}`); } if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { throw new Error("worktree setup hook stdout must be a JSON object"); } return parsed as WorktreeSetupHookOutput; } function runWorktreeSetupHook( hook: ResolvedWorktreeSetupHook, input: WorktreeSetupHookInput, ): string[] { const result = spawnSync(hook.hookPath, [], { cwd: input.worktreePath, encoding: "utf-8", input: JSON.stringify(input), timeout: hook.timeoutMs, shell: false, windowsHide: true, }); if (result.error) { const code = "code" in result.error ? result.error.code : undefined; if (code === "ETIMEDOUT") { throw new Error(`worktree setup hook timed out after ${hook.timeoutMs}ms`); } throw new Error(`worktree setup hook failed: ${result.error.message}`); } if (result.status !== 0) { const details = result.stderr.trim() || result.stdout.trim() || "no output"; throw new Error(`worktree setup hook failed with exit code ${result.status}: ${details}`); } const output = parseWorktreeSetupHookOutput(result.stdout); if (output.syntheticPaths === undefined) return []; if (!Array.isArray(output.syntheticPaths)) { throw new Error("worktree setup hook output field 'syntheticPaths' must be an array of relative paths"); } const uniquePaths = new Set(); for (const candidate of output.syntheticPaths) { if (typeof candidate !== "string") { throw new Error("worktree setup hook output field 'syntheticPaths' must contain only strings"); } const normalizedPath = normalizeSyntheticPath(input.worktreePath, candidate); if (hasTrackedEntries(input.worktreePath, normalizedPath)) { throw new Error(`worktree setup hook cannot mark tracked paths as synthetic: ${normalizedPath}`); } uniquePaths.add(normalizedPath); } return [...uniquePaths]; } function createSingleWorktree( toplevel: string, cwdRelative: string, runId: string, index: number, baseCommit: string, setupHook: ResolvedWorktreeSetupHook | undefined, agent: string | undefined, baseDir: string, ): WorktreeInfo { const branch = buildWorktreeBranch(runId, index); const worktreePath = buildWorktreePath(baseDir, runId, index); const add = runGit(toplevel, ["worktree", "add", worktreePath, "-b", branch, "HEAD"]); if (add.status !== 0) { const message = add.stderr.trim() || add.stdout.trim() || `failed to create worktree ${worktreePath}`; throw new Error(message); } const agentCwd = cwdRelative ? path.join(worktreePath, cwdRelative) : worktreePath; try { const nodeModulesLinked = linkNodeModulesIfPresent(toplevel, worktreePath); const syntheticPaths = nodeModulesLinked ? ["node_modules"] : []; if (setupHook) { const hookSyntheticPaths = runWorktreeSetupHook(setupHook, { version: 1, repoRoot: toplevel, worktreePath, agentCwd, branch, index, runId, baseCommit, agent, }); syntheticPaths.push(...hookSyntheticPaths); } return { path: worktreePath, agentCwd, branch, index, nodeModulesLinked, syntheticPaths, }; } catch (error) { try { runGitChecked(toplevel, ["worktree", "remove", "--force", worktreePath]); } catch { // Best-effort rollback; preserve the original setup failure. } try { runGitChecked(toplevel, ["branch", "-D", branch]); } catch { // Best-effort rollback; preserve the original setup failure. } throw error; } } function removeSyntheticPath(worktree: WorktreeInfo, syntheticPath: string): void { const resolved = path.resolve(worktree.path, syntheticPath); const relative = path.relative(worktree.path, resolved); if (!relative || relative === "." || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { return; } let stat: fs.Stats; try { stat = fs.lstatSync(resolved); } catch (error) { const code = error && typeof error === "object" && "code" in error ? (error as { code?: unknown }).code : undefined; if (code === "ENOENT") return; throw error; } if (stat.isSymbolicLink()) { fs.unlinkSync(resolved); return; } if (stat.isDirectory()) { fs.rmSync(resolved, { recursive: true, force: true }); return; } fs.rmSync(resolved, { force: true }); } function removeSyntheticPathsBeforeDiff(worktree: WorktreeInfo): void { if (worktree.syntheticPaths.length === 0) return; const seen = new Set(); for (const syntheticPath of worktree.syntheticPaths) { if (seen.has(syntheticPath)) continue; seen.add(syntheticPath); removeSyntheticPath(worktree, syntheticPath); } } function emptyDiff(index: number, agent: string, branch: string, patchPath: string, error?: string): WorktreeDiff { return { index, agent, branch, diffStat: "", filesChanged: 0, insertions: 0, deletions: 0, patchPath, ...(error ? { error } : {}), }; } function parseNumstat(numstat: string): { filesChanged: number; insertions: number; deletions: number } { const lines = numstat .split("\n") .map((line) => line.trim()) .filter(Boolean); let filesChanged = 0; let insertions = 0; let deletions = 0; for (const line of lines) { const [rawInsertions, rawDeletions] = line.split("\t"); if (rawInsertions === undefined || rawDeletions === undefined) continue; filesChanged++; if (/^\d+$/.test(rawInsertions)) insertions += parseInt(rawInsertions, 10); if (/^\d+$/.test(rawDeletions)) deletions += parseInt(rawDeletions, 10); } return { filesChanged, insertions, deletions }; } function captureWorktreeDiff( setup: WorktreeSetup, worktree: WorktreeInfo, agent: string, patchPath: string, ): WorktreeDiff { removeSyntheticPathsBeforeDiff(worktree); runGitChecked(worktree.path, ["add", "-A"]); const diffStat = runGitChecked(worktree.path, ["diff", "--cached", "--stat", setup.baseCommit]).trim(); const patch = runGitChecked(worktree.path, ["diff", "--cached", setup.baseCommit]); const numstat = runGitChecked(worktree.path, ["diff", "--cached", "--numstat", setup.baseCommit]); fs.writeFileSync(patchPath, patch, "utf-8"); if (!patch.trim()) { return emptyDiff(worktree.index, agent, worktree.branch, patchPath); } const parsed = parseNumstat(numstat); return { index: worktree.index, agent, branch: worktree.branch, diffStat, filesChanged: parsed.filesChanged, insertions: parsed.insertions, deletions: parsed.deletions, patchPath, }; } function writeEmptyPatch(patchPath: string): void { try { fs.writeFileSync(patchPath, "", "utf-8"); } catch { // Diff artifact writing is best-effort in error paths. } } function handoffRecordsPatch(manifestPath: string | undefined, patchPath: string): boolean { if (!manifestPath || !fs.existsSync(manifestPath)) return false; try { const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8")) as { version?: unknown; groups?: Array<{ children?: Array<{ patch?: { path?: unknown; error?: unknown } }> }>; }; if (manifest.version !== 1 || !Array.isArray(manifest.groups)) return false; const resolvedPatchPath = path.resolve(patchPath); return manifest.groups.some((group) => Array.isArray(group.children) && group.children.some((child) => child.patch?.error === undefined && typeof child.patch?.path === "string" && path.resolve(child.patch.path) === resolvedPatchPath, )); } catch { return false; } } function cleanupSingleWorktree( setup: WorktreeSetup, worktree: WorktreeInfo, intent: WorktreeCleanupIntent, ): WorktreeCleanupTask { const errors: string[] = []; let worktreeRemoved = false; let branchRemoved = false; if (intent.kind !== "setup-rollback") { try { removeSyntheticPathsBeforeDiff(worktree); } catch (error) { errors.push(`synthetic path cleanup failed: ${error instanceof Error ? error.message : String(error)}`); } const status = runGit(worktree.path, ["status", "--porcelain"]); const baseDiff = runGit(worktree.path, ["diff", "--quiet", setup.baseCommit, "--"]); if (status.status !== 0 || (baseDiff.status !== 0 && baseDiff.status !== 1)) { const reason = status.status !== 0 ? status.stderr.trim() || status.stdout.trim() || "git status failed" : baseDiff.stderr.trim() || baseDiff.stdout.trim() || "git diff check failed"; return { index: worktree.index, path: worktree.path, branch: worktree.branch, worktreeRemoved: false, branchRemoved: false, preserved: true, reason: "cleanup safety check failed", errors: [...errors, `cleanup refused: ${reason}`], }; } const hasWork = status.stdout.trim().length > 0 || baseDiff.status === 1; if (hasWork && intent.kind === "preserve") { const captured = (intent.capturedDiffs ?? setup.capturedDiffs)?.find((diff) => diff.index === worktree.index); const patchCaptured = captured !== undefined && captured.error === undefined && fs.existsSync(captured.patchPath) && fs.statSync(captured.patchPath).size > 0 && handoffRecordsPatch(intent.handoffManifestPath, captured.patchPath); if (!patchCaptured) { const reason = "worktree contains changes that are not represented by a captured handoff patch"; return { index: worktree.index, path: worktree.path, branch: worktree.branch, worktreeRemoved: false, branchRemoved: false, preserved: true, reason, errors: [...errors, `cleanup refused: ${reason}; preserved ${worktree.path}`], }; } } if (hasWork && intent.kind === "discard") { const decision = resolveAuthorityDecision({ action: "discardWorktree", policy: intent.authorization.policy }); const authorized = decision === "auto" || (decision === "confirm" && intent.authorization.kind === "confirmed"); if (!authorized) { const reason = decision === "forbid" ? "authority policy forbids worktree discard" : "worktree discard requires explicit user confirmation"; return { index: worktree.index, path: worktree.path, branch: worktree.branch, worktreeRemoved: false, branchRemoved: false, preserved: true, reason, errors: [...errors, `cleanup refused: ${reason}; preserved ${worktree.path}`], }; } } } try { runGitChecked(setup.cwd, ["worktree", "remove", "--force", worktree.path]); worktreeRemoved = true; } catch (error) { errors.push(`worktree removal failed: ${error instanceof Error ? error.message : String(error)}`); } if (worktreeRemoved) { try { runGitChecked(setup.cwd, ["branch", "-D", worktree.branch]); branchRemoved = true; } catch (error) { errors.push(`branch removal failed: ${error instanceof Error ? error.message : String(error)}`); } } return { index: worktree.index, path: worktree.path, branch: worktree.branch, worktreeRemoved, branchRemoved, ...(errors.length ? { errors } : {}), }; } function hasWorktreeChanges(diff: WorktreeDiff): boolean { return diff.filesChanged > 0 || diff.insertions > 0 || diff.deletions > 0 || diff.diffStat.trim().length > 0; } export function createWorktrees(cwd: string, runId: string, count: number, options?: CreateWorktreesOptions): WorktreeSetup { const repo = resolveRepoState(cwd); const setupHook = resolveWorktreeSetupHook(repo.toplevel, options?.setupHook); const baseDir = resolveWorktreeBaseDir(options?.baseDir, repo.toplevel); const worktrees: WorktreeInfo[] = []; try { for (let index = 0; index < count; index++) { worktrees.push(createSingleWorktree( repo.toplevel, repo.cwdRelative, runId, index, repo.baseCommit, setupHook, options?.agents?.[index], baseDir, )); } } catch (error) { cleanupWorktrees({ cwd: repo.toplevel, worktrees, baseCommit: repo.baseCommit, }, { kind: "setup-rollback" }); throw error; } return { cwd: repo.toplevel, worktrees, baseCommit: repo.baseCommit, }; } export function diffWorktrees(setup: WorktreeSetup, agents: string[], diffsDir: string): WorktreeDiff[] { try { fs.mkdirSync(diffsDir, { recursive: true }); } catch { // Returning no diffs is safer than failing the whole command on artifact-dir issues. return []; } const diffs: WorktreeDiff[] = []; for (let index = 0; index < setup.worktrees.length; index++) { const worktree = setup.worktrees[index]!; const agent = agents[index] ?? `task-${index + 1}`; const patchPath = path.join(diffsDir, `task-${index}-${safePatchAgentName(agent)}.patch`); try { diffs.push(captureWorktreeDiff(setup, worktree, agent, patchPath)); } catch (error) { // Preserve execution flow while retaining the failed capture as handoff evidence. writeEmptyPatch(patchPath); diffs.push(emptyDiff(index, agent, worktree.branch, patchPath, error instanceof Error ? error.message : String(error))); } } setup.capturedDiffs = diffs; return diffs; } export function cleanupWorktrees( setup: WorktreeSetup, intent: WorktreeCleanupIntent = { kind: "preserve", ...(setup.capturedDiffs ? { capturedDiffs: setup.capturedDiffs } : {}) }, ): WorktreeCleanupReport { const tasks: WorktreeCleanupTask[] = []; for (let index = setup.worktrees.length - 1; index >= 0; index--) { tasks.push(cleanupSingleWorktree(setup, setup.worktrees[index]!, intent)); } tasks.sort((left, right) => left.index - right.index); const errors: string[] = []; let pruned = false; try { runGitChecked(setup.cwd, ["worktree", "prune"]); pruned = true; } catch (error) { errors.push(`worktree prune failed: ${error instanceof Error ? error.message : String(error)}`); } const state = tasks.every((task) => task.worktreeRemoved && task.branchRemoved) && pruned ? "complete" : "partial"; return { state, tasks, pruned, ...(errors.length ? { errors } : {}), }; } export function formatWorktreeDiffSummary(diffs: WorktreeDiff[]): string { const changed = diffs.filter(hasWorktreeChanges); if (changed.length === 0) return ""; const lines: string[] = ["=== Worktree Changes ===", ""]; for (const diff of changed) { lines.push( `--- Task ${diff.index + 1} (${diff.agent}): ${diff.filesChanged} files changed, +${diff.insertions} -${diff.deletions} ---`, ); if (diff.diffStat.trim().length > 0) { lines.push(diff.diffStat); } lines.push(""); } const patchesDir = path.dirname(changed[0]!.patchPath); lines.push(`Full patches: ${patchesDir}`); return lines.join("\n").trimEnd(); }