import { execFile, execFileSync, spawnSync } from "node:child_process"; import { randomBytes } from "node:crypto"; import * as fs from "node:fs"; import * as path from "node:path"; import { promisify } from "node:util"; import { loadConfig } from "../config/config.ts"; import { DEFAULT_PATHS } from "../config/defaults.ts"; import { writeArtifact } from "../state/stores/artifact-store.ts"; import type { TeamRunManifest, TeamTaskState } from "../state/types.ts"; import { WINDOWS_ESSENTIAL_ENV_VARS } from "../utils/env-allowlist.ts"; import { sanitizeEnvSecrets } from "../utils/env-filter.ts"; import { logInternalError } from "../utils/internal-error.ts"; import { projectCrewRoot } from "../utils/paths.ts"; export interface PreparedTaskWorkspace { cwd: string; worktreePath?: string; branch?: string; reused?: boolean; nodeModulesLinked?: boolean; syntheticPaths?: string[]; } /** * Options for prepareTaskWorkspace / prepareTaskWorkspaceAsync (RR-010). */ export interface PrepareTaskWorkspaceOptions { /** * RR-010 / AGENTS.md rule 40: allow discarding a dirty reused worktree EVEN * WHEN the recovery snapshot is incomplete (truncated/skipped entries, failed * tracked diff). Explicit per-call escape hatch — deliberately NOT a global * config flag, so approval cannot silently become permanent. */ force?: boolean; } const execFileAsync = promisify(execFile); export interface WorktreeDiffStat { filesChanged: number; insertions: number; deletions: number; diffStat: string; } function git(cwd: string, args: string[]): string { // SECURITY: PI_* and PI_CREW_* wildcards removed — they could match secret vars like PI_PASSWORD. // Git operations do not need PI_CREW_* execution-control vars. return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: { ...sanitizeEnvSecrets(process.env, { allowList: [ "PATH", "HOME", "USER", ...WINDOWS_ESSENTIAL_ENV_VARS, "SHELL", "TERM", "LANG", "LC_ALL", "LC_COLLATE", "LC_CTYPE", "LC_MESSAGES", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "NVM_BIN", "NVM_DIR", "NODE_PATH", "GIT_CONFIG_GLOBAL", "GIT_CONFIG_SYSTEM", "GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL", ], }), LANG: "en_US.UTF-8", LC_ALL: "en_US.UTF-8", }, windowsHide: true, }).trim(); } /** Build the sanitized env object shared by all git operations. */ function gitEnv(): Record { return { ...sanitizeEnvSecrets(process.env, { allowList: [ "PATH", "HOME", "USER", ...WINDOWS_ESSENTIAL_ENV_VARS, "SHELL", "TERM", "LANG", "LC_ALL", "LC_COLLATE", "LC_CTYPE", "LC_MESSAGES", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "NVM_BIN", "NVM_DIR", "NODE_PATH", "GIT_CONFIG_GLOBAL", "GIT_CONFIG_SYSTEM", "GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL", ], }), LANG: "en_US.UTF-8", LC_ALL: "en_US.UTF-8", }; } async function gitAsync(cwd: string, args: string[]): Promise { const { stdout } = await execFileAsync("git", args, { cwd, encoding: "utf-8", env: gitEnv(), windowsHide: true, }); return stdout.trim(); } // Dots are removed from branch names since they are used in path construction, // and dots could cause ambiguity with relative path handling on some platforms. // Branch names themselves support dots in git, but we strip them for safe path use. function sanitizeBranchPart(value: string): string { return ( value .toLowerCase() .replace(/[^a-z0-9_/-]+/g, "-") .replace(/^-+|-+$/g, "") || "task" ); } // PERF (2026-08-24): the sync path (prepareTaskWorkspace — per task!) ran // findGitRoot (rev-parse spawn) + assertCleanLeader (status --porcelain spawn) // on every call, and the reuse path ran assertCleanLeader twice. Mirror the // async caches below (_gitRootCache/_cleanLeaderCache): same repoRoot → same // answer within a run. // // Mirrored invalidation semantics (from the async caches below): // - gitRoot: plain Map, NO TTL — the async _gitRootCache is process-lifetime with // a FIFO cap; a cwd's repo root cannot change without moving .git itself. // - cleanLeader: ONLY clean verdicts are cached — the async _cleanLeaderCache is // a Set of verified-clean repoRoots and a dirty verdict throws BEFORE caching, // so every call while dirty re-probes (mirrored here). The async cache has NO // TTL (a clean verdict is trusted for the whole process), so the 30s TTL below // is strictly MORE conservative than the async path: a leader dirtied after a // cached clean verdict can slip through for at most 30s here vs. forever on // the async path. The reuse-path re-check in prepareTaskWorkspace deletes the // entry first, mirroring `_cleanLeaderCache.delete(repoRoot)` in the async variant. const SYNC_WT_CACHE_TTL_MS = 30_000; const MAX_SYNC_GIT_ROOT_CACHE = 256; const MAX_SYNC_CLEAN_LEADER_CACHE = 256; const syncGitRootCache = new Map(); const syncCleanLeaderCache = new Map(); export function findGitRoot(cwd: string): string { const cached = syncGitRootCache.get(cwd); if (cached) return cached; const root = git(cwd, ["rev-parse", "--show-toplevel"]); syncGitRootCache.set(cwd, root); if (syncGitRootCache.size > MAX_SYNC_GIT_ROOT_CACHE) { const oldest = syncGitRootCache.keys().next().value; if (oldest !== undefined) syncGitRootCache.delete(oldest); } return root; } export function assertCleanLeader(repoRoot: string): void { const cached = syncCleanLeaderCache.get(repoRoot); if (cached?.clean && Date.now() < cached.expiresAt) return; // H-7 follow-up: --untracked-files=no so pi-crew's own auto-created .gitignore // (and any other untracked files the user hasn't staged) doesn't block worktree mode. // The worktree contract is "no tracked changes" — untracked files are safe since // they're either in .gitignore or the user can decide later. const status = git(repoRoot, ["status", "--porcelain", "--untracked-files=no"]); if (status.trim()) { // Dirty verdicts are never cached (async mirror): the user may commit/stash // seconds later, so the next call must re-probe. throw new Error("Worktree mode requires a clean leader repository. Commit/stash changes or use workspaceMode: 'single'."); } syncCleanLeaderCache.set(repoRoot, { clean: true, expiresAt: Date.now() + SYNC_WT_CACHE_TTL_MS }); if (syncCleanLeaderCache.size > MAX_SYNC_CLEAN_LEADER_CACHE) { const oldest = syncCleanLeaderCache.keys().next().value; if (oldest !== undefined) syncCleanLeaderCache.delete(oldest); } } // --- Async versions --- // R5-L1 (Round 5 LOW-1): the exported clear*() fns below stay unwired by // leader decision (rewiring run-start/lock-release paths is riskier than the // leak); the FIFO caps bound these caches instead. const MAX_GIT_ROOT_CACHE = 256; const MAX_CLEAN_LEADER_CACHE = 256; /** Cache for findGitRoot results keyed by cwd. Cleared per-run. */ const _gitRootCache = new Map(); /** Clear the findGitRoot cache. Call at the start of each team run. */ export function clearGitRootCache(): void { _gitRootCache.clear(); } export async function findGitRootAsync(cwd: string): Promise { const cached = _gitRootCache.get(cwd); if (cached) return cached; const root = await gitAsync(cwd, ["rev-parse", "--show-toplevel"]); _gitRootCache.set(cwd, root); if (_gitRootCache.size > MAX_GIT_ROOT_CACHE) { const oldest = _gitRootCache.keys().next().value; if (oldest !== undefined) _gitRootCache.delete(oldest); } return root; } /** Cache for assertCleanLeader results keyed by repoRoot. Cleared per-run. */ const _cleanLeaderCache = new Set(); /** Clear the assertCleanLeader cache. Call at the start of each team run. */ export function clearCleanLeaderCache(): void { _cleanLeaderCache.clear(); } export async function assertCleanLeaderAsync(repoRoot: string): Promise { if (_cleanLeaderCache.has(repoRoot)) return; // H-7 follow-up: --untracked-files=no so pi-crew's own auto-created .gitignore // (and any other untracked files the user hasn't staged) doesn't block worktree mode. const status = await gitAsync(repoRoot, ["status", "--porcelain", "--untracked-files=no"]); if (status.trim()) { throw new Error("Worktree mode requires a clean leader repository. Commit/stash changes or use workspaceMode: 'single'."); } _cleanLeaderCache.add(repoRoot); if (_cleanLeaderCache.size > MAX_CLEAN_LEADER_CACHE) { const oldest = _cleanLeaderCache.values().next().value; if (oldest !== undefined) _cleanLeaderCache.delete(oldest); } } function linkNodeModulesIfPresent(repoRoot: string, worktreePath: string): boolean { const source = path.join(repoRoot, "node_modules"); const target = path.join(worktreePath, "node_modules"); let sourceStat: fs.Stats; try { sourceStat = fs.statSync(source); } catch { return false; } if (!sourceStat.isDirectory()) return false; if (fs.existsSync(target)) return false; // M5 fix: log symlink failure reason, especially on Windows non-admin. try { fs.symlinkSync(source, target, process.platform === "win32" ? "junction" : "dir"); return true; } catch (error) { const isWindows = process.platform === "win32"; logInternalError( "worktree.symlink-fail", error, isWindows ? "Windows non-admin: SeCreateSymbolicLinkPrivilege needed for node_modules symlink" : String(error), ); return false; } } function normalizeSyntheticPath(worktreePath: string, rawPath: string): string { const resolved = path.resolve(worktreePath, rawPath); const relative = path.relative(worktreePath, resolved); if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) throw new Error(`synthetic path escapes worktree: ${rawPath}`); return path.normalize(relative); } /** * Validates that a worktree setupHook script path is within an allowed directory. * Allowed paths: * - Relative paths starting with ".hooks/" (case-sensitive) * - Absolute paths under $HOME/.pi/hooks/ * Rejects all other paths to prevent arbitrary script execution. * @param hookPath - The hook script path to validate * @returns true if the path is allowed, false otherwise */ function isAllowedSetupHook(hookPath: string): boolean { if (!hookPath || hookPath.trim().length === 0) return false; if (!path.isAbsolute(hookPath)) { // Use path.posix.normalize for consistent forward-slash handling on all platforms. const normalized = path.posix.normalize(hookPath); return normalized === ".hooks" || normalized.startsWith(".hooks/"); } // Normalize to forward slashes for consistent cross-platform comparison. const normalizedHookPath = hookPath.replace(/\\/g, "/"); const homeHooksNormalized = (process.env.HOME ?? "").replace(/\\/g, "/") + "/.pi/hooks"; return normalizedHookPath === homeHooksNormalized || normalizedHookPath.startsWith(homeHooksNormalized + "/"); } /** * SECURITY: Verify a hook script path remains within the allowed directory after * real-path resolution. This prevents symlink-based escape where repoRoot is a * symlink and the hook path would resolve outside the repository. * @param repoRoot - The repository root (resolved to real path) * @param hookPath - The resolved absolute hook path * @returns true if the hook is safely contained within repoRoot */ function isHookPathContainedInRepoRoot(repoRoot: string, hookPath: string): boolean { try { const realRepoRoot = fs.realpathSync(repoRoot); const realHookPath = fs.realpathSync(path.dirname(hookPath)); return realHookPath.startsWith(realRepoRoot + path.sep) || realHookPath === realRepoRoot; } catch { return false; } } function runSetupHook(manifest: TeamRunManifest, task: TeamTaskState, repoRoot: string, worktreePath: string, branch: string): string[] { const cfg = loadConfig(manifest.cwd).config.worktree; if (!cfg?.setupHook) return []; const rawHookPath = cfg.setupHook; if (!isAllowedSetupHook(rawHookPath)) { logInternalError("worktree.setupHook.rejected", new Error("hook path not allowed: " + rawHookPath), `cwd=${manifest.cwd}`); return []; } // SECURITY WARNING: Home directory hooks (~/.pi/hooks/) are user-writable and not project-scoped. // A rogue npm postinstall script could place malicious hooks there. Log for visibility. // // SECURITY ASSUMPTION: This function trusts that the hook scripts themselves are not malicious. // Hook scripts are executed with the same privileges as the Pi process. The caller is responsible // for ensuring that only trusted hook scripts are configured. Path containment validation // (isAllowedSetupHook, isHookPathContainedInRepoRoot) prevents hook scripts from writing outside // the worktree, but cannot prevent a trusted hook from performing harmful operations within it. if (path.isAbsolute(rawHookPath)) { logInternalError( "worktree.setupHook.homeHook", new Error("Home directory hook used — ensure ~/.pi/hooks/ is trusted"), `hookPath=${rawHookPath}`, ); } const hookPath = path.isAbsolute(rawHookPath) ? rawHookPath : path.resolve(repoRoot, rawHookPath); // SECURITY: Verify the resolved hook path is contained within the real repoRoot. // This prevents symlink-based escape where repoRoot is a symlink. if (!path.isAbsolute(rawHookPath) && !isHookPathContainedInRepoRoot(repoRoot, hookPath)) { logInternalError( "worktree.setupHook.contained", new Error("hook path escapes repoRoot after realpath resolution: " + hookPath), `repoRoot=${repoRoot}`, ); return []; } try { const hookStat = fs.lstatSync(hookPath); if (!hookStat.isFile()) { logInternalError("worktree.setupHook.missing", new Error("hook not found or is directory: " + hookPath), `cwd=${manifest.cwd}`); return []; } } catch { logInternalError("worktree.setupHook.missing", new Error("hook not found: " + hookPath), `cwd=${manifest.cwd}`); return []; } const nodeHook = hookPath.endsWith(".js") || hookPath.endsWith(".cjs") || hookPath.endsWith(".mjs"); // For .bat/.cmd files on Windows, execute via cmd.exe /c directly const isBatchFile = hookPath.endsWith(".bat") || hookPath.endsWith(".cmd"); // SECURITY: Never use shell:true — prevents command injection from untrusted hooks. // Non-node, non-batch hooks on Windows will fail to execute rather than // running through a shell that could interpret malicious filenames. const useShell = false; if (process.platform === "win32" && !nodeHook && !isBatchFile) { logInternalError( "worktree.setupHook.windowsNoShell", new Error("Non-node, non-batch hook skipped on Windows (shell:true disabled for security)"), `hook=${hookPath}`, ); } // SECURITY: Resolve the hook to its real path before execution to close the TOCTOU window. // This prevents a symlink swap between the containment check and actual execution. // KNOWN LIMITATION: There is a residual TOCTOU window between realpathSync validation // (line 166) and spawnSync execution (line 183). A sufficiently fast attacker could // theoretically swap the symlink between these two operations. The realpathSync + O_NOFOLLOW // approach minimizes but does not eliminate this window. To fully close it, consider // opening the hook file once via a file descriptor and executing via fd passing (not available // in Node.js spawnSync). This is documented as a known limitation rather than a bug. let realHookPath: string; try { realHookPath = fs.realpathSync(hookPath); } catch { logInternalError("worktree.setupHook.realpath", new Error("hook realpath resolution failed: " + hookPath), `cwd=${manifest.cwd}`); return []; } const result = isBatchFile ? spawnSync("cmd.exe", ["/c", realHookPath], { cwd: worktreePath, encoding: "utf-8", input: JSON.stringify({ version: 1, repoRoot, worktreePath, agentCwd: worktreePath, branch, runId: manifest.runId, taskId: task.id, agent: task.agent, }), timeout: cfg.setupHookTimeoutMs ?? 30_000, shell: false, // cmd.exe /c handles batch files safely env: sanitizeEnvSecrets(process.env, { allowList: ["PATH", "HOME", ...WINDOWS_ESSENTIAL_ENV_VARS, "TMPDIR", "LANG", "LC_ALL"], }), windowsHide: true, }) : spawnSync(nodeHook ? process.execPath : realHookPath, nodeHook ? [realHookPath] : [], { cwd: worktreePath, encoding: "utf-8", input: JSON.stringify({ version: 1, repoRoot, worktreePath, agentCwd: worktreePath, branch, runId: manifest.runId, taskId: task.id, agent: task.agent, }), timeout: cfg.setupHookTimeoutMs ?? 30_000, shell: false, env: sanitizeEnvSecrets(process.env, { allowList: ["PATH", "HOME", ...WINDOWS_ESSENTIAL_ENV_VARS, "TMPDIR", "LANG", "LC_ALL"], }), windowsHide: true, }); if (result.error) throw new Error(`worktree setup hook failed: ${result.error.message}`); if (result.status !== 0) throw new Error(`worktree setup hook failed with exit code ${result.status}: ${result.stderr || result.stdout || "no output"}`); const trimmed = result.stdout.trim(); if (!trimmed) return []; try { // Extract JSON — hooks may output debug logging before JSON. // M4 fix: try full trimmed (multi-line JSON object) before falling back to last line. const lines = trimmed.split(/\r?\n/); let parsed: { syntheticPaths?: unknown } | null = null; try { parsed = JSON.parse(trimmed) as { syntheticPaths?: unknown }; } catch { /* fall through — try last line */ } if (!parsed && lines.length > 0) { const lastLine = lines[lines.length - 1]; try { parsed = JSON.parse(lastLine) as { syntheticPaths?: unknown }; } catch { /* give up */ } } if (!parsed || !Array.isArray(parsed.syntheticPaths)) return []; return [ ...new Set( parsed.syntheticPaths .filter((entry): entry is string => typeof entry === "string") .map((entry) => normalizeSyntheticPath(worktreePath, entry)), ), ]; } catch (error) { logInternalError("worktree.setupHook.parse", error, `lastLine=${(trimmed.split(/\r?\n/).pop() ?? "").slice(0, 200)}`); return []; } } function branchExists(repoRoot: string, branch: string): { local: boolean; remoteOnly: boolean } { let local = false; try { git(repoRoot, ["rev-parse", "--verify", `refs/heads/${branch}`]); local = true; } catch (error) { logInternalError("worktree.branch-local.sync", error, `branch=${branch}`, "debug"); } if (local) return { local: true, remoteOnly: false }; // Check remote-tracking branch try { const out = execFileSync("git", ["for-each-ref", "--format=%(refname)", `refs/remotes/*/${branch}`], { cwd: repoRoot, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], windowsHide: true, }).trim(); return { local: false, remoteOnly: out.length > 0 }; } catch { return { local: false, remoteOnly: false }; } } function pruneStaleWorktrees(repoRoot: string): void { try { execFileSync("git", ["worktree", "prune"], { cwd: repoRoot, stdio: "ignore", }); } catch { /* best-effort */ } } // PERF (2026-08-24): worktree prune is a repo-level write; running it per // task in a 50-task run is 50 prunes. At most once per repoRoot per minute. // (The async variant memoizes at-most-once per process — see P1-11 above — // so a 60s window prunes strictly more often than the async path already does.) // R5-L2 mirror: FIFO-cap the key set so the map cannot grow unbounded. const PRUNE_MIN_INTERVAL_MS = 60_000; const MAX_PRUNE_THROTTLE_KEYS = 128; const lastPruneAt = new Map(); function pruneStaleWorktreesThrottled(repoRoot: string): void { const last = lastPruneAt.get(repoRoot) ?? 0; if (Date.now() - last < PRUNE_MIN_INTERVAL_MS) return; lastPruneAt.set(repoRoot, Date.now()); if (lastPruneAt.size > MAX_PRUNE_THROTTLE_KEYS) { const oldest = lastPruneAt.keys().next().value; if (oldest !== undefined) lastPruneAt.delete(oldest); } pruneStaleWorktrees(repoRoot); // original body — best-effort, failures swallowed } async function branchExistsAsync(repoRoot: string, branch: string): Promise<{ local: boolean; remoteOnly: boolean }> { let local = false; try { await gitAsync(repoRoot, ["rev-parse", "--verify", `refs/heads/${branch}`]); local = true; } catch (error) { logInternalError("worktree.branch-local.async", error, `branch=${branch}`, "debug"); } if (local) return { local: true, remoteOnly: false }; // Check remote-tracking branch try { const out = ( await execFileAsync("git", ["for-each-ref", "--format=%(refname)", `refs/remotes/*/${branch}`], { cwd: repoRoot, encoding: "utf-8", windowsHide: true, }) ).stdout.trim(); return { local: false, remoteOnly: out.length > 0 }; } catch { return { local: false, remoteOnly: false }; } } // P1-11: `git worktree prune` is a repo-level write that cleans ALL stale // worktrees — running it per task is pure waste. Coalesce concurrent calls // (N tasks in a batch → 1 prune) and memoize per repo per process (within a // run no new stale worktrees appear, so one prune suffices). const _prunedRepos = new Set(); const _pruneInFlight = new Map>(); // R5-L2 (Round 5 LOW-2): FIFO cap — dedupe-only Set, never cleared in // production; eviction just means a re-prune for that repo later, which is // harmless (prune is idempotent and coalesced). const MAX_PRUNED_REPOS = 128; async function pruneStaleWorktreesAsync(repoRoot: string): Promise { if (_prunedRepos.has(repoRoot)) return; const inFlight = _pruneInFlight.get(repoRoot); if (inFlight) return inFlight; const p = (async () => { try { await execFileAsync("git", ["worktree", "prune"], { cwd: repoRoot, windowsHide: true, }); } catch { /* best-effort */ } _prunedRepos.add(repoRoot); if (_prunedRepos.size > MAX_PRUNED_REPOS) { const oldest = _prunedRepos.values().next().value; if (oldest !== undefined) _prunedRepos.delete(oldest); } _pruneInFlight.delete(repoRoot); })(); _pruneInFlight.set(repoRoot, p); return p; } /** * Normalize and validate seed paths — ensure all paths stay within repoRoot. * Rejects path traversal (../) and absolute paths. */ export function normalizeSeedPaths(seedPaths: string[], repoRoot: string): string[] { const resolvedRepoRoot = path.resolve(repoRoot); const entries = Array.isArray(seedPaths) ? seedPaths : []; const seen = new Set(); const normalized: string[] = []; for (const entry of entries) { if (typeof entry !== "string" || entry.trim().length === 0) continue; const absolutePath = path.resolve(resolvedRepoRoot, entry); const relativePath = path.relative(resolvedRepoRoot, absolutePath); if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) { throw new Error(`seedPaths entries must stay inside repoRoot: ${entry}`); } // Reject symlinks to prevent escape via symlink-based path traversal. // This check is also performed in overlaySeedPaths for defense-in-depth. // ENOENT is acceptable — seed paths may reference files that don't exist yet // (they are validated at copy time by overlaySeedPaths). try { const stat = fs.lstatSync(absolutePath); if (stat.isSymbolicLink()) { throw new Error(`seedPaths entries cannot be symlinks: ${entry}`); } } catch (error) { if (error instanceof Error && error.message.startsWith("seedPaths entries")) throw error; // ENOENT is acceptable — seed paths may reference files that don't exist yet. // Skip symlink check but still include the path in results. if (!(error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT")) { throw new Error(`seedPaths entries must be accessible: ${entry}`); } } const normalizedPath = relativePath.split(path.sep).join("/"); if (seen.has(normalizedPath)) continue; seen.add(normalizedPath); normalized.push(normalizedPath); } return normalized; } /** * Overlay seed paths from repoRoot into worktreePath. * Copies files and directories, creating parent dirs as needed. * Skips non-existent sources with logInternalError (non-fatal). */ export function overlaySeedPaths(repoRoot: string, worktreePath: string, seedPaths: string[]): void { const normalized = normalizeSeedPaths(seedPaths, repoRoot); for (const seedPath of normalized) { const sourcePath = path.join(repoRoot, seedPath); const destinationPath = path.join(worktreePath, seedPath); let sourceStat: fs.Stats; try { sourceStat = fs.lstatSync(sourcePath); } catch { logInternalError("worktree.seedPaths.missing", new Error(`Seed path does not exist: ${seedPath}`)); continue; } // Reject symlinks in seed paths to prevent copying symlinks into worktree (which could point outside repoRoot). if (sourceStat.isSymbolicLink()) { logInternalError("worktree.seedPaths.symlink", new Error(`Seed path is a symlink — rejected: ${seedPath}`)); continue; } if (!sourceStat.isFile() && !sourceStat.isDirectory()) { logInternalError("worktree.seedPaths.invalid", new Error(`Seed path is neither file nor directory: ${seedPath}`)); continue; } fs.mkdirSync(path.dirname(destinationPath), { recursive: true }); fs.rmSync(destinationPath, { force: true, recursive: true }); fs.cpSync(sourcePath, destinationPath, { dereference: true, force: true, preserveTimestamps: true, recursive: true, }); } } /** * Per-file byte cap for the recovery-snapshot PREVIEW (ST-1, kept at 256 KiB so * the artifact stays readable). RR-010 (F01): the cap now ALSO bounds how many * bytes are read (allocation bound) and marks over-cap entries as an * INCOMPLETE backup — completeness, not preview size, gates the destructive * reuse cleanup (AGENTS.md rule 40). */ export const SNAPSHOT_MAX_FILE_BYTES = 256 * 1024; /** Entry whose backup was cut at SNAPSHOT_MAX_FILE_BYTES (partial capture). */ export interface WorktreeSnapshotTruncatedEntry { /** Repo-relative path of the entry. */ path: string; /** On-disk size in bytes BEFORE truncation (not the truncated size). */ originalSize: number; } /** Entry that could not be backed up at all — absent from the artifact content. */ export interface WorktreeSnapshotSkippedEntry { /** Repo-relative path of the entry. */ path: string; /** Why the entry was skipped (unreadable, unstat-able, missing, directory...). */ reason: string; } /** * Structured result of a dirty-worktree recovery snapshot (RR-010 / F01). * * A bare boolean could not distinguish "artifact written" from "backup * complete": truncation at the cap, unreadable entries and a failed tracked * diff all used to return `true`, so the reuse path ran `git checkout -- .` + * `git clean -fd` and destroyed data the artifact never contained. `complete` * is the invariant `truncated/skipped empty && no diff/write error` — callers * must gate the destructive cleanup on it via shouldDiscardDirtyWorktree. */ export interface WorktreeSnapshotResult { /** True ONLY when every dirty entry was fully backed up and the tracked diff (if any) was captured. */ complete: boolean; /** Entries backed up only partially (cut at the preview cap). */ truncated: WorktreeSnapshotTruncatedEntry[]; /** Entries that could not be backed up at all. */ skipped: WorktreeSnapshotSkippedEntry[]; /** Set when `git diff HEAD --binary` failed — tracked changes may be lost. */ trackedDiffError?: string; /** Set when writing the recovery artifact itself failed (the old `false`). */ writeError?: string; } /** * Pure, git-free gate for the destructive reuse cleanup (RR-010 AC4): discard * dirty worktree content ONLY when the snapshot is complete, or when the caller * explicitly set `force` (AGENTS.md rule 40). Fail CLOSED — when in doubt, * preserve the worktree. */ export function shouldDiscardDirtyWorktree(result: WorktreeSnapshotResult, force: boolean): boolean { return force || result.complete; } /** * Allocation-bounded file read for the recovery snapshot (RR-010 AC5). * Opens the file and reads at most `cap` bytes — `readFileSync` would allocate * the WHOLE file in memory just to truncate it afterwards, so a multi-GiB * untracked file could OOM the snapshot path. A short read (file shrank * mid-read) is reported as truncated so it can never count as a complete * backup (fail closed). */ export function readFileCappedForSnapshot(abs: string, cap: number): { data: Buffer; originalSize: number; truncated: boolean } { const fd = fs.openSync(abs, "r"); try { const originalSize = fs.fstatSync(fd).size; const want = Math.min(originalSize, cap); const data = Buffer.alloc(want); let read = 0; while (read < want) { const n = fs.readSync(fd, data, read, want - read, read); if (n <= 0) break; read += n; } return { data: read === want ? data : data.subarray(0, read), originalSize, truncated: read < want || originalSize > cap, }; } finally { fs.closeSync(fd); } } /** Human-readable incompleteness summary for the dirtyPreserved log (RR-010). */ function describeIncompleteSnapshot(result: WorktreeSnapshotResult): string { const reasons: string[] = []; if (result.writeError !== undefined) reasons.push(`snapshot write failed: ${result.writeError}`); if (result.trackedDiffError !== undefined) reasons.push(`tracked diff capture failed: ${result.trackedDiffError}`); if (result.truncated.length > 0) reasons.push( `${result.truncated.length} file(s) exceeded the ${SNAPSHOT_MAX_FILE_BYTES}-byte cap: ${result.truncated .map((e) => `${e.path} (${e.originalSize} bytes)`) .join(", ")}`, ); if (result.skipped.length > 0) reasons.push( `${result.skipped.length} unreadable/unstat-able entries: ${result.skipped.map((e) => `${e.path} (${e.reason})`).join(", ")}`, ); return reasons.length > 0 ? ` — ${reasons.join("; ")}` : ""; } /** * Snapshot uncommitted work in a reused worktree to a recovery artifact BEFORE * discarding it, so the data is never silently lost. Captures tracked changes * (git diff HEAD) and inlines untracked file contents so the work can be * recovered via `git apply` / manual restore. * * RR-010 (F01) — returns a structured WorktreeSnapshotResult. An artifact that * was written is NOT proof the backup is complete: truncated (over-cap / * short-read) entries, unreadable entries and a failed tracked diff all surface * in the result so callers can refuse the destructive cleanup * (shouldDiscardDirtyWorktree). Design choice (RR-010 design.md §3–§4): * structured result + pure predicate over raising the cap (moves the boundary, * does not remove it), louder warnings (violates AGENTS.md rule 40), full * base64 (artifact size explodes), dropping cleanup entirely (breaks * clean-slate reuse; `force` stays as the escape hatch) and `git stash` (also a * destructive git op with no artifact trail). The 256 KiB PREVIEW cap is kept * on purpose — backup completeness, not preview size, gates cleanup. */ export function snapshotDirtyWorktree( manifest: TeamRunManifest, task: TeamTaskState, worktreePath: string, dirtyStatus: string, ): WorktreeSnapshotResult { const truncated: WorktreeSnapshotTruncatedEntry[] = []; const skipped: WorktreeSnapshotSkippedEntry[] = []; let trackedDiffError: string | undefined; let writeError: string | undefined; try { const parts: string[] = [ `# Worktree recovery snapshot`, `runId: ${manifest.runId} taskId: ${task.id} path: ${worktreePath}`, `capturedAt: ${new Date().toISOString()}`, "", ]; // ST-1: use --binary so tracked binary modifications are recoverable via `git apply`. let trackedDiff = ""; try { trackedDiff = git(worktreePath, ["diff", "HEAD", "--binary"]); } catch (err) { // RR-010: "" stays reserved for the legitimate "no tracked changes" outcome — // a FAILED diff is an incomplete backup (tracked edits would be destroyed by // `git checkout -- .` with no copy anywhere). trackedDiff = ""; trackedDiffError = err instanceof Error ? err.message : String(err); } if (trackedDiff.trim()) { parts.push("## Tracked changes (`git diff HEAD --binary`)", "```diff", trackedDiff, "```", ""); } for (const line of dirtyStatus.split("\n")) { if (!line.startsWith("?? ")) continue; // Strip the "?? " prefix and surrounding git quotes. const rel = line.slice(3).replace(/^"|"$/g, ""); if (!rel) continue; try { const abs = path.join(worktreePath, rel); if (!fs.existsSync(abs)) { // RR-010: record instead of silently `continue` — a vanished entry is // unexplained and must fail CLOSED (preserve, do not clean). skipped.push({ path: rel, reason: "not found (deleted between status and snapshot?)" }); continue; } if (fs.statSync(abs).isDirectory()) { skipped.push({ path: rel, reason: "directory entry — individual files must be captured instead" }); continue; } // ST-1: read raw bytes (not utf-8) so binary files are not corrupted. // RR-010: bounded read — never allocate more than the cap for a file that // is only going to be truncated anyway (AC5). const { data, originalSize, truncated: overCap } = readFileCappedForSnapshot(abs, SNAPSHOT_MAX_FILE_BYTES); let note = ""; if (overCap) { truncated.push({ path: rel, originalSize }); note = ` (truncated: ${originalSize} → ${data.byteLength} bytes)`; } // ST-1: detect non-UTF-8 via fatal TextDecoder; base64-encode binary files. let isUtf8 = true; try { new TextDecoder("utf-8", { fatal: true }).decode(data); } catch { isUtf8 = false; } if (isUtf8) { parts.push(`## Untracked file: ${rel}${note}`, "```", data.toString("utf-8"), "```", ""); } else { parts.push(`## Untracked file: ${rel}${note} (base64-encoded binary)`, "```base64", data.toString("base64"), "```", ""); } } catch (err) { // RR-010: unreadable/unstat-able entries are recorded (and named in the // artifact below) instead of being silently dropped — they used to be // destroyed by `git clean -fd` with ZERO recovery. skipped.push({ path: rel, reason: err instanceof Error ? err.message : String(err) }); } } // RR-010 (AC3): skipped entries MUST be named in the artifact so operators // can see exactly which files were NOT backed up before deciding to force a // cleanup — an artifact silent about them is false evidence. if (skipped.length > 0) { parts.push("## Skipped entries (NOT backed up)", ...skipped.map((s) => `- ${s.path} — ${s.reason}`), ""); } if (trackedDiffError !== undefined) { parts.push("## Tracked diff capture FAILED — tracked modifications are NOT backed up", "```", trackedDiffError, "```", ""); } writeArtifact(manifest.artifactsRoot, { kind: "diff", relativePath: `worktree-recovery/${task.id}-${Date.now()}.md`, content: parts.join("\n"), producer: "worktree-manager.snapshotDirtyWorktree", retention: "run", }); } catch (err) { logInternalError( "worktree.recovery.snapshotFailed", err instanceof Error ? err : new Error(String(err)), `runId=${manifest.runId}, taskId=${task.id}`, ); writeError = err instanceof Error ? err.message : String(err); } const complete = truncated.length === 0 && skipped.length === 0 && trackedDiffError === undefined && writeError === undefined; return { complete, truncated, skipped, trackedDiffError, writeError }; } /** * Best-effort removal of a just-created worktree + its branch. Used when a * post-creation step (setup hook, node_modules link, seed overlay) fails, so * the run does not leak an orphaned worktree dir + branch that would block the * next run with an "already checked out" error. */ function cleanupCreatedWorktree(repoRoot: string, worktreePath: string, branch: string): void { try { git(repoRoot, ["worktree", "remove", "--force", worktreePath]); } catch { try { if (fs.existsSync(worktreePath)) fs.rmSync(worktreePath, { recursive: true, force: true }); } catch { /* best-effort */ } } try { git(repoRoot, ["branch", "-D", branch]); } catch { /* best-effort */ } } async function cleanupCreatedWorktreeAsync(repoRoot: string, worktreePath: string, branch: string): Promise { try { await gitAsync(repoRoot, ["worktree", "remove", "--force", worktreePath]); } catch { try { if (fs.existsSync(worktreePath)) fs.rmSync(worktreePath, { recursive: true, force: true }); } catch { /* best-effort */ } } try { await gitAsync(repoRoot, ["branch", "-D", branch]); } catch { /* best-effort */ } } export function prepareTaskWorkspace( manifest: TeamRunManifest, task: TeamTaskState, stepSeedPaths?: string[], options?: PrepareTaskWorkspaceOptions, ): PreparedTaskWorkspace { if (manifest.workspaceMode !== "worktree") return { cwd: task.cwd }; const repoRoot = findGitRoot(manifest.cwd); const loadedConfig = loadConfig(manifest.cwd); if (loadedConfig.config.requireCleanWorktreeLeader !== false) assertCleanLeader(repoRoot); const sanitizedRunId = manifest.runId.replace(/[^a-zA-Z0-9._-]/g, "-").replace(/^-+|-+$/g, "") || "run"; const worktreeRoot = path.join(projectCrewRoot(manifest.cwd), DEFAULT_PATHS.state.worktreesSubdir, sanitizedRunId); fs.mkdirSync(worktreeRoot, { recursive: true }); // Resolve through realpathSync.native to get long-name form on Windows. // git worktree uses long-name paths, so we must match. If .native fails, // fall back to non-native (preserves input form). let resolvedWorktreeRoot = worktreeRoot; try { const r = fs.realpathSync.native(worktreeRoot); resolvedWorktreeRoot = r.startsWith("\\\\?\\") ? r.slice(4) : r; } catch { try { resolvedWorktreeRoot = fs.realpathSync(worktreeRoot); } catch { /* keep as-is */ } } const sanitizedTaskId = sanitizeBranchPart(task.id); const worktreePath = path.join(resolvedWorktreeRoot, sanitizedTaskId); const branch = `pi-crew/${sanitizeBranchPart(manifest.runId)}/${sanitizeBranchPart(task.id)}`; // Use `git worktree list --porcelain` to atomically verify the worktree exists. // This avoids a TOCTOU race between fs.existsSync and git branch verification. let worktreeExists = false; try { const worktreeList = git(repoRoot, ["worktree", "list", "--porcelain"]); // `git worktree list --porcelain` outputs "worktree /path" per entry. // We must compare against the path part (after "worktree "). // On Windows, git may return forward-slash long-name paths while // worktreePath uses short-name backslash form. Resolve both through // realpathSync.native (which always returns long-name on Windows) // for consistent comparison. const normalizedWtPath = process.platform === "win32" ? (() => { try { const r = fs.realpathSync.native(worktreePath); return r.startsWith("\\\\?\\") ? r.slice(4) : r; } catch { return worktreePath; } })() .replace(/\\/g, "/") .toLowerCase() : worktreePath; worktreeExists = worktreeList.split("\n").some((line) => { const trimmed = line.trim(); const matchPath = trimmed.startsWith("worktree ") ? trimmed.slice(9) : trimmed; if (process.platform === "win32") { return matchPath.replace(/\\/g, "/").toLowerCase() === normalizedWtPath; } return matchPath === worktreePath; }); } catch { worktreeExists = false; } if (worktreeExists) { let currentBranch: string; try { currentBranch = git(worktreePath, ["rev-parse", "--abbrev-ref", "HEAD"]); } catch (gitError) { throw new Error( `Existing worktree at ${worktreePath} is not a valid git repository; cannot verify branch: ${gitError instanceof Error ? gitError.message : String(gitError)}`, ); } if (currentBranch !== branch) { throw new Error(`Existing worktree branch mismatch at ${worktreePath}: expected '${branch}', got '${currentBranch}'.`); } // ST-1: use -uall so untracked directories are expanded to individual files. const dirtyStatus = git(worktreePath, ["-c", "core.quotePath=false", "status", "--porcelain", "-uall"]); if (dirtyStatus.trim()) { // Snapshot uncommitted work to a recovery artifact BEFORE discarding, so the // previous run's changes are never silently destroyed on reuse. // RR-010 (F01): discard only when the snapshot is COMPLETE (or the caller // explicitly forced it) — AGENTS.md rule 40. An artifact that was written // is not proof the backup is complete; fail CLOSED and preserve. const snapshotResult = snapshotDirtyWorktree(manifest, task, worktreePath, dirtyStatus); if (shouldDiscardDirtyWorktree(snapshotResult, options?.force === true)) { logInternalError( "worktree.reused.dirty", new Error( `Discarding uncommitted changes in reused worktree at ${worktreePath} (snapshot saved to artifacts)` + (snapshotResult.complete ? "" : " — SNAPSHOT INCOMPLETE, discarded because caller set force=true"), ), `runId=${manifest.runId}, taskId=${task.id}, dirtyStatus=${dirtyStatus.trim()}`, ); git(worktreePath, ["checkout", "--", "."]); git(worktreePath, ["clean", "-fd"]); } else { logInternalError( "worktree.reused.dirtyPreserved", new Error( `Snapshot incomplete — preserving dirty worktree at ${worktreePath} (skipping git checkout/clean to prevent data loss)${describeIncompleteSnapshot(snapshotResult)}`, ), `runId=${manifest.runId}, taskId=${task.id}, dirtyStatus=${dirtyStatus.trim()}`, ); } } // Overlay seed paths from config + step-level seedPaths (reused worktree) const globalSeedPaths = loadedConfig.config.worktree?.seedPaths ?? []; const mergedReused = normalizeSeedPaths([...globalSeedPaths, ...(stepSeedPaths ?? [])], repoRoot); if (mergedReused.length > 0) { overlaySeedPaths(repoRoot, worktreePath, mergedReused); } // Re-validate leader is still clean before reusing — leader state may have changed since first preparation. // Mirrors prepareTaskWorkspaceAsync: delete the cached verdict so this re-check probes live state. syncCleanLeaderCache.delete(repoRoot); assertCleanLeader(repoRoot); return { cwd: worktreePath, worktreePath, branch, reused: true }; } pruneStaleWorktreesThrottled(repoRoot); const exists = branchExists(repoRoot, branch); let worktreeCreated = false; try { if (exists.local) { git(repoRoot, ["worktree", "add", worktreePath, branch]); } else { if (exists.remoteOnly) { logInternalError( "worktree.branchRemoteOnly", new Error(`Branch '${branch}' exists only on remote; creating local from HEAD instead of tracking remote.`), `branch=${branch}`, ); } git(repoRoot, ["worktree", "add", "-b", branch, worktreePath, "HEAD"]); } worktreeCreated = true; } catch (error) { // Clean up orphaned worktree directory if git worktree add failed if (fs.existsSync(worktreePath)) { try { fs.rmSync(worktreePath, { recursive: true, force: true }); } catch { /* best-effort cleanup */ } } const msg = error instanceof Error ? error.message : String(error); if (/already checked out|is already used by worktree/i.test(msg)) { throw new Error( `Branch '${branch}' is checked out at another worktree. Run \`team cleanup runId=${manifest.runId} force=true\` or manually remove the conflicting worktree.`, ); } throw error; } try { const syntheticPaths = runSetupHook(manifest, task, repoRoot, worktreePath, branch); const nodeModulesLinked = loadedConfig.config.worktree?.linkNodeModules === true ? linkNodeModulesIfPresent(repoRoot, worktreePath) : false; // Overlay seed paths from config + step-level seedPaths const globalSeedPaths = loadedConfig.config.worktree?.seedPaths ?? []; const merged = normalizeSeedPaths([...globalSeedPaths, ...(stepSeedPaths ?? [])], repoRoot); if (merged.length > 0) { overlaySeedPaths(repoRoot, worktreePath, merged); } return { cwd: worktreePath, worktreePath, branch, reused: false, nodeModulesLinked, syntheticPaths, }; } catch (setupError) { // Hook/link/seed failure AFTER worktree+branch creation: clean up to avoid // an orphaned worktree dir + branch that would block the next run. cleanupCreatedWorktree(repoRoot, worktreePath, branch); throw setupError; } } /** Async version of prepareTaskWorkspace — yields the event loop during git operations. */ export async function prepareTaskWorkspaceAsync( manifest: TeamRunManifest, task: TeamTaskState, stepSeedPaths?: string[], options?: PrepareTaskWorkspaceOptions, ): Promise { if (manifest.workspaceMode !== "worktree") return { cwd: task.cwd }; const repoRoot = await findGitRootAsync(manifest.cwd); const loadedConfig = loadConfig(manifest.cwd); if (loadedConfig.config.requireCleanWorktreeLeader !== false) await assertCleanLeaderAsync(repoRoot); const sanitizedRunId = manifest.runId.replace(/[^a-zA-Z0-9._-]/g, "-").replace(/^-+|-+$/g, "") || "run"; const worktreeRoot = path.join(projectCrewRoot(manifest.cwd), DEFAULT_PATHS.state.worktreesSubdir, sanitizedRunId); fs.mkdirSync(worktreeRoot, { recursive: true }); let resolvedWorktreeRoot = worktreeRoot; try { const r = fs.realpathSync.native(worktreeRoot); resolvedWorktreeRoot = r.startsWith("\\\\?\\") ? r.slice(4) : r; } catch { try { resolvedWorktreeRoot = fs.realpathSync(worktreeRoot); } catch { /* keep as-is */ } } const sanitizedTaskId = sanitizeBranchPart(task.id); const worktreePath = path.join(resolvedWorktreeRoot, sanitizedTaskId); const branch = `pi-crew/${sanitizeBranchPart(manifest.runId)}/${sanitizeBranchPart(task.id)}`; let worktreeExists = false; try { const worktreeList = await gitAsync(repoRoot, ["worktree", "list", "--porcelain"]); const normalizedWtPath = process.platform === "win32" ? (() => { try { const r = fs.realpathSync.native(worktreePath); return r.startsWith("\\\\?\\") ? r.slice(4) : r; } catch { return worktreePath; } })() .replace(/\\/g, "/") .toLowerCase() : worktreePath; worktreeExists = worktreeList.split("\n").some((line) => { const trimmed = line.trim(); const matchPath = trimmed.startsWith("worktree ") ? trimmed.slice(9) : trimmed; if (process.platform === "win32") { return matchPath.replace(/\\/g, "/").toLowerCase() === normalizedWtPath; } return matchPath === worktreePath; }); } catch { worktreeExists = false; } if (worktreeExists) { let currentBranch: string; try { currentBranch = await gitAsync(worktreePath, ["rev-parse", "--abbrev-ref", "HEAD"]); } catch (gitError) { throw new Error( `Existing worktree at ${worktreePath} is not a valid git repository; cannot verify branch: ${gitError instanceof Error ? gitError.message : String(gitError)}`, ); } if (currentBranch !== branch) { throw new Error(`Existing worktree branch mismatch at ${worktreePath}: expected '${branch}', got '${currentBranch}'.`); } // ST-1: use -uall so untracked directories are expanded to individual files. const dirtyStatus = await gitAsync(worktreePath, ["-c", "core.quotePath=false", "status", "--porcelain", "-uall"]); if (dirtyStatus.trim()) { // RR-010 (F01): mirror of the sync gate — discard only when the snapshot is // COMPLETE (or explicitly forced). AGENTS.md rule 40. Fail CLOSED. const snapshotResult = snapshotDirtyWorktree(manifest, task, worktreePath, dirtyStatus); if (shouldDiscardDirtyWorktree(snapshotResult, options?.force === true)) { logInternalError( "worktree.reused.dirty", new Error( `Discarding uncommitted changes in reused worktree at ${worktreePath} (snapshot saved to artifacts)` + (snapshotResult.complete ? "" : " — SNAPSHOT INCOMPLETE, discarded because caller set force=true"), ), `runId=${manifest.runId}, taskId=${task.id}, dirtyStatus=${dirtyStatus.trim()}`, ); await gitAsync(worktreePath, ["checkout", "--", "."]); await gitAsync(worktreePath, ["clean", "-fd"]); } else { logInternalError( "worktree.reused.dirtyPreserved", new Error( `Snapshot incomplete — preserving dirty worktree at ${worktreePath} (skipping git checkout/clean to prevent data loss)${describeIncompleteSnapshot(snapshotResult)}`, ), `runId=${manifest.runId}, taskId=${task.id}, dirtyStatus=${dirtyStatus.trim()}`, ); } } const globalSeedPaths = loadedConfig.config.worktree?.seedPaths ?? []; const mergedReused = normalizeSeedPaths([...globalSeedPaths, ...(stepSeedPaths ?? [])], repoRoot); if (mergedReused.length > 0) { overlaySeedPaths(repoRoot, worktreePath, mergedReused); } // Re-validate leader is still clean before reusing // Note: this intentionally skips the cache to re-check the live state. _cleanLeaderCache.delete(repoRoot); await assertCleanLeaderAsync(repoRoot); return { cwd: worktreePath, worktreePath, branch, reused: true }; } await pruneStaleWorktreesAsync(repoRoot); const exists = await branchExistsAsync(repoRoot, branch); let worktreeCreated = false; try { if (exists.local) { await gitAsync(repoRoot, ["worktree", "add", worktreePath, branch]); } else { if (exists.remoteOnly) { logInternalError( "worktree.branchRemoteOnly", new Error(`Branch '${branch}' exists only on remote; creating local from HEAD instead of tracking remote.`), `branch=${branch}`, ); } await gitAsync(repoRoot, ["worktree", "add", "-b", branch, worktreePath, "HEAD"]); } worktreeCreated = true; } catch (error) { if (fs.existsSync(worktreePath)) { try { fs.rmSync(worktreePath, { recursive: true, force: true }); } catch { /* best-effort cleanup */ } } const msg = error instanceof Error ? error.message : String(error); if (/already checked out|is already used by worktree/i.test(msg)) { throw new Error( `Branch '${branch}' is checked out at another worktree. Run \`team cleanup runId=${manifest.runId} force=true\` or manually remove the conflicting worktree.`, ); } throw error; } try { const syntheticPaths = runSetupHook(manifest, task, repoRoot, worktreePath, branch); const nodeModulesLinked = loadedConfig.config.worktree?.linkNodeModules === true ? linkNodeModulesIfPresent(repoRoot, worktreePath) : false; const globalSeedPaths = loadedConfig.config.worktree?.seedPaths ?? []; const merged = normalizeSeedPaths([...globalSeedPaths, ...(stepSeedPaths ?? [])], repoRoot); if (merged.length > 0) { overlaySeedPaths(repoRoot, worktreePath, merged); } return { cwd: worktreePath, worktreePath, branch, reused: false, nodeModulesLinked, syntheticPaths, }; } catch (setupError) { // Hook/link/seed failure AFTER worktree+branch creation: clean up to avoid // an orphaned worktree dir + branch that would block the next run. await cleanupCreatedWorktreeAsync(repoRoot, worktreePath, branch); throw setupError; } } export function captureWorktreeDiffStat(worktreePath: string): WorktreeDiffStat { try { const diffStat = git(worktreePath, ["diff", "--stat"]); const numstat = git(worktreePath, ["diff", "--numstat"]); let filesChanged = 0; let insertions = 0; let deletions = 0; for (const line of numstat.split(/\r?\n/).filter(Boolean)) { const [add, del] = line.split(/\s+/); filesChanged += 1; insertions += Number(add) || 0; deletions += Number(del) || 0; } return { filesChanged, insertions, deletions, diffStat }; } catch { return { filesChanged: 0, insertions: 0, deletions: 0, diffStat: "" }; } } export async function captureWorktreeDiffStatAsync(worktreePath: string): Promise { try { const diffStat = await gitAsync(worktreePath, ["diff", "--stat"]); const numstat = await gitAsync(worktreePath, ["diff", "--numstat"]); let filesChanged = 0; let insertions = 0; let deletions = 0; for (const line of numstat.split(/\r?\n/).filter(Boolean)) { const [add, del] = line.split(/\s+/); filesChanged += 1; insertions += Number(add) || 0; deletions += Number(del) || 0; } return { filesChanged, insertions, deletions, diffStat }; } catch { return { filesChanged: 0, insertions: 0, deletions: 0, diffStat: "" }; } } export function captureWorktreeDiff(worktreePath: string): string { try { return git(worktreePath, ["diff", "--stat"]) + "\n\n" + git(worktreePath, ["diff"]); } catch (error) { const message = error instanceof Error ? error.message : String(error); return `Failed to capture worktree diff: ${message}`; } } export async function captureWorktreeDiffAsync(worktreePath: string): Promise { try { const [stat, full] = await Promise.all([gitAsync(worktreePath, ["diff", "--stat"]), gitAsync(worktreePath, ["diff"])]); return stat + "\n\n" + full; } catch (error) { const message = error instanceof Error ? error.message : String(error); return `Failed to capture worktree diff: ${message}`; } } /** * round-17 P2-4: Create an isolated git worktree for a single DWF agent call. * * Lightweight — does NOT require a TeamTaskState and does NOT depend on * `manifest.workspaceMode === "worktree"` (DWF manifests use `single`). It * reuses the same internal helpers as `prepareTaskWorkspace` (git, findGitRoot, * assertCleanLeader, pruneStaleWorktrees, sanitizeBranchPart, * linkNodeModulesIfPresent) but with a minimal, task-free signature. * * Returns `undefined` when worktree isolation is genuinely unavailable (no git * repo, dirty leader) so the caller (`ctx.agent`) can fall back gracefully. * ST-15: when creation was attempted but failed (e.g. disk full), this THROWS * instead of returning undefined so the caller never silently runs in the * leader repo. The branch + path suffix is non-deterministic so a 2nd call for * the same agentId always produces a distinct worktree. */ export function prepareAgentWorktree(manifest: TeamRunManifest, agentId: string): PreparedTaskWorkspace | undefined { // ST-15: Split preconditions from worktree creation. // Precondition failures (no git repo, dirty leader) → return undefined so the // caller falls back gracefully (worktree isolation is genuinely unavailable). let repoRoot: string; let loadedConfig: ReturnType; try { repoRoot = findGitRoot(manifest.cwd); loadedConfig = loadConfig(manifest.cwd); if (loadedConfig.config.requireCleanWorktreeLeader !== false) assertCleanLeader(repoRoot); } catch { // Graceful fallback: no git repo or dirty leader → run normally in cwd. return undefined; } // ST-15: NON-DETERMINISTIC branch + path suffix. The OLD deterministic name // (`pi-crew//`) made a 2nd call for the same agent collide // ("already exists") → catch → returned undefined → the run silently fell // back to the leader repo (isolation lost, no error). The timestamp + // short-uuid suffix makes every call produce a distinct worktree. // Creation failures here THROW (not undefined) so callers never silently lose // isolation when worktree creation was requested but failed. const sanitizedRunId = manifest.runId.replace(/[^a-zA-Z0-9._-]/g, "-").replace(/^-+|-+$/g, "") || "run"; const worktreeRoot = path.join(projectCrewRoot(manifest.cwd), DEFAULT_PATHS.state.worktreesSubdir, sanitizedRunId); fs.mkdirSync(worktreeRoot, { recursive: true }); const sanitizedAgentId = sanitizeBranchPart(agentId); const stamp = `${Date.now()}-${randomBytes(4).toString("hex")}`; const worktreePath = path.join(worktreeRoot, `${sanitizedAgentId}-${stamp}`); const branch = `pi-crew/${sanitizedRunId}/${sanitizedAgentId}-${stamp}`; pruneStaleWorktreesThrottled(repoRoot); git(repoRoot, ["worktree", "add", "-b", branch, worktreePath, "HEAD"]); const nodeModulesLinked = loadedConfig.config.worktree?.linkNodeModules === true ? linkNodeModulesIfPresent(repoRoot, worktreePath) : false; return { cwd: worktreePath, worktreePath, branch, nodeModulesLinked }; } /** Async version of prepareAgentWorktree — yields the event loop during git operations. */ export async function prepareAgentWorktreeAsync(manifest: TeamRunManifest, agentId: string): Promise { // ST-15: Split preconditions from worktree creation (see prepareAgentWorktree). let repoRoot: string; let loadedConfig: ReturnType; try { repoRoot = await findGitRootAsync(manifest.cwd); loadedConfig = loadConfig(manifest.cwd); if (loadedConfig.config.requireCleanWorktreeLeader !== false) await assertCleanLeaderAsync(repoRoot); } catch { // Graceful fallback: no git repo or dirty leader → run normally in cwd. return undefined; } // ST-15: NON-DETERMINISTIC branch + path suffix — a 2nd call for the same // agentId creates a DISTINCT worktree instead of colliding. Creation failures // here THROW so callers never silently fall back to the leader repo. const sanitizedRunId = manifest.runId.replace(/[^a-zA-Z0-9._-]/g, "-").replace(/^-+|-+$/g, "") || "run"; const worktreeRoot = path.join(projectCrewRoot(manifest.cwd), DEFAULT_PATHS.state.worktreesSubdir, sanitizedRunId); fs.mkdirSync(worktreeRoot, { recursive: true }); const sanitizedAgentId = sanitizeBranchPart(agentId); const stamp = `${Date.now()}-${randomBytes(4).toString("hex")}`; const worktreePath = path.join(worktreeRoot, `${sanitizedAgentId}-${stamp}`); const branch = `pi-crew/${sanitizedRunId}/${sanitizedAgentId}-${stamp}`; await pruneStaleWorktreesAsync(repoRoot); await gitAsync(repoRoot, ["worktree", "add", "-b", branch, worktreePath, "HEAD"]); const nodeModulesLinked = loadedConfig.config.worktree?.linkNodeModules === true ? linkNodeModulesIfPresent(repoRoot, worktreePath) : false; return { cwd: worktreePath, worktreePath, branch, nodeModulesLinked }; } /** * round-17 P2-4: Remove a DWF agent worktree after the agent completes. * * Captures the worktree diff as an artifact before removal (best-effort), then * removes the worktree, deletes the ephemeral branch, and prunes stale refs. * NEVER throws — cleanup failures are logged via `logInternalError` so a * worktree/branch leak never crashes a workflow. */ export function cleanupAgentWorktree(manifest: TeamRunManifest, worktreePath: string, branch?: string): void { // Capture diff as artifact (best-effort). try { const diff = captureWorktreeDiff(worktreePath); if (diff.trim() && !diff.startsWith("Failed to capture worktree diff")) { writeArtifact(manifest.artifactsRoot, { kind: "diff", relativePath: `wf/worktree-diff-${Date.now()}-${randomBytes(2).toString("hex")}.diff`, content: diff, producer: "dynamic-workflow", }); } } catch (error) { logInternalError("worktree.agent-cleanup.diff", error, `worktreePath=${worktreePath}`); } // Remove worktree (best-effort). Try git first, then fall back to fs.rm. try { const repoRoot = findGitRoot(manifest.cwd); git(repoRoot, ["worktree", "remove", "--force", worktreePath]); } catch (error) { logInternalError("worktree.agent-cleanup.remove", error, `worktreePath=${worktreePath}`); try { fs.rmSync(worktreePath, { recursive: true, force: true }); } catch (rmError) { logInternalError("worktree.agent-cleanup.rm", rmError, `worktreePath=${worktreePath}`); } } // Delete the ephemeral agent branch (best-effort) to avoid accumulation across // many agent calls. The diff is already captured above; the branch holds no value. if (branch) { try { const repoRoot = findGitRoot(manifest.cwd); git(repoRoot, ["branch", "-D", branch]); } catch (error) { logInternalError("worktree.agent-cleanup.branch", error, `branch=${branch}`); } } // Prune stale worktree refs (best-effort). try { const repoRoot = findGitRoot(manifest.cwd); git(repoRoot, ["worktree", "prune"]); } catch (error) { logInternalError("worktree.agent-cleanup.prune", error, `worktreePath=${worktreePath}`); } } /** Async version of cleanupAgentWorktree — caches findGitRoot and yields during git operations. */ export async function cleanupAgentWorktreeAsync(manifest: TeamRunManifest, worktreePath: string, branch?: string): Promise { // Capture diff as artifact (best-effort). try { const diff = await captureWorktreeDiffAsync(worktreePath); if (diff.trim() && !diff.startsWith("Failed to capture worktree diff")) { writeArtifact(manifest.artifactsRoot, { kind: "diff", relativePath: `wf/worktree-diff-${Date.now()}-${randomBytes(2).toString("hex")}.diff`, content: diff, producer: "dynamic-workflow", }); } } catch (error) { logInternalError("worktree.agent-cleanup.diff", error, `worktreePath=${worktreePath}`); } // Resolve repoRoot once and reuse (cache optimization). let repoRoot: string | undefined; try { repoRoot = await findGitRootAsync(manifest.cwd); } catch { // Cannot resolve repoRoot — fall back to fs.rm for the worktree. try { fs.rmSync(worktreePath, { recursive: true, force: true }); } catch (rmError) { logInternalError("worktree.agent-cleanup.rm", rmError, `worktreePath=${worktreePath}`); } return; } // Remove worktree (best-effort). Try git first, then fall back to fs.rm. try { await gitAsync(repoRoot, ["worktree", "remove", "--force", worktreePath]); } catch (error) { logInternalError("worktree.agent-cleanup.remove", error, `worktreePath=${worktreePath}`); try { fs.rmSync(worktreePath, { recursive: true, force: true }); } catch (rmError) { logInternalError("worktree.agent-cleanup.rm", rmError, `worktreePath=${worktreePath}`); } } // Delete the ephemeral agent branch (best-effort). if (branch) { try { await gitAsync(repoRoot, ["branch", "-D", branch]); } catch (error) { logInternalError("worktree.agent-cleanup.branch", error, `branch=${branch}`); } } // Prune stale worktree refs (best-effort). try { await gitAsync(repoRoot, ["worktree", "prune"]); } catch (error) { logInternalError("worktree.agent-cleanup.prune", error, `worktreePath=${worktreePath}`); } }