import { exec, execFile } from "node:child_process"; import { promisify } from "node:util"; import { existsSync, lstatSync, readdirSync, readFileSync, rmSync, realpathSync } from "node:fs"; import { mkdir } from "node:fs/promises"; import { basename, dirname, join, relative, resolve, isAbsolute } from "node:path"; import type { SecretsStore, Settings, TaskStore, WorktrunkSettings } from "@fusion/core"; import { assertCleanBranchAtBase, inspectBranchConflict } from "./branch-conflicts.js"; import { worktreePoolLog } from "./logger.js"; /* FNXC:EngineDiagnostics 2026-07-26-10:25: Worktree pool rehydrate-skip / prune-stale / orphan-skip / probe chatter is expected sweep noise — debug (FUSION_DEBUG=worktree-pool). Keep log for actual cleanup (removed orphan, cleaned path) and hard failures. */ import { isAiMergeContainerDir, isInsideConfiguredWorktreesDir, resolveWorktreesDir } from "./worktree-paths.js"; import { canonicalFusionBranchName } from "./worktree-names.js"; import { resolveWorktrunkBinary, } from "./worktrunk-installer.js"; import { RemovalReason, removeWorktree as removeWorktreeViaBackend, resolveWorktreeBackend as resolveWorktreeBackendViaSettings, } from "./worktree-backend.js"; import { cleanupSecretsEnvFile } from "./secrets-env-writer.js"; import { removeDesktopBuildArtifacts } from "./worktree-desktop-artifacts.js"; import { resolveIntegrationBranch } from "./integration-branch.js"; import type { RunAuditor } from "./run-audit.js"; import { pruneWorktreeAdminEntries } from "./worktree-prune.js"; import { resolveWorkflowIrForTask, columnsWithFlag } from "@fusion/core"; export { NativeWorktreeBackend, WorktrunkOperationError, WorktrunkWorktreeBackend, removeWorktree, resolveWorktreeBackend, } from "./worktree-backend.js"; export type { WorktreeBackend, WorktreeBackendKind } from "./worktree-backend.js"; export { RemovalReason } from "./worktree-backend.js"; // Re-export worktrunk installer types for convenience. export { resolveWorktrunkBinary as resolveWorktrunkBinaryOriginal, WorktrunkBinaryUnavailableError, WorktrunkInstallDeniedError, WorktrunkInstallFailedError, } from "./worktrunk-installer.js"; const execAsync = promisify(exec); const execFileAsync = promisify(execFile); // ── Worktrunk binary lazy resolver ───────────────────────────────────────────── // Memoizes per (homedir, settings.binaryPath) so the resolution+install flow // runs at most once per unique settings combination per process. const _worktrunkBinaryCache = new Map(); export async function getWorktrunkBinary( settings: WorktrunkSettings, ): Promise<{ binaryPath: string; source: "override" | "path" | "cached" | "installed-release" | "installed-cargo"; }> { const cacheKey = `${process.env.HOME ?? ""}::${settings.binaryPath ?? ""}`; const cached = _worktrunkBinaryCache.get(cacheKey); if (cached) { return { binaryPath: cached.binaryPath, source: "cached" }; } const result = await resolveWorktrunkBinary({ settings }); _worktrunkBinaryCache.set(cacheKey, { binaryPath: result.binaryPath, resolvedAt: Date.now() }); return result; } export function clearWorktrunkBinaryCache(): void { _worktrunkBinaryCache.clear(); } export function canonicalizePath(path: string): string { /* FNXC:WorktreeLiveness 2026-07-15-11:55: On macOS, /tmp is a symlink to /private/tmp. realpathSync of an existing worktrees root yields /private/tmp/... while resolve() of a not-yet-created child stays under /tmp/... — relative() then looks like a path escape and isInsideConfiguredWorktreesDir falsely reports outside_worktrees_dir (restart.integration resumeOrphaned). When the leaf is missing, realpath the nearest existing ancestor and rejoin the suffix. */ try { return realpathSync(path); } catch { let dir = resolve(path); const suffix: string[] = []; while (true) { try { return resolve(realpathSync(dir), ...suffix.reverse()); } catch { const parent = dirname(dir); if (parent === dir) break; suffix.push(basename(dir)); dir = parent; } } return resolve(path); } } export function isRepoRootPath(rootDir: string, candidate: string): boolean { return canonicalizePath(rootDir) === canonicalizePath(candidate); } function getExecStdout(result: unknown): string { if (typeof result === "string") return result; if (result && typeof result === "object" && "stdout" in result) { const stdout = (result as { stdout?: unknown }).stdout; return typeof stdout === "string" ? stdout : String(stdout ?? ""); } return ""; } function stringifyExecOutput(value: unknown): string { if (Buffer.isBuffer(value)) return value.toString("utf-8"); return typeof value === "string" ? value : String(value ?? ""); } function getExecErrorOutput(error: unknown): string { if (!error || typeof error !== "object") return String(error ?? ""); const record = error as { stderr?: unknown; message?: unknown }; const stderr = stringifyExecOutput(record.stderr).trim(); if (stderr) return stderr; return stringifyExecOutput(record.message).trim(); } export type GitRepoDetection = | { status: "repo" } | { status: "not-repo"; stderr: string } | { status: "error"; reason: "dubious-ownership" | "git-missing" | "timeout" | "unknown"; stderr: string }; function classifyGitRepoDetectionError(error: unknown): GitRepoDetection { const stderr = getExecErrorOutput(error); const output = stderr || String(error ?? ""); const errorRecord = (error && typeof error === "object") ? error as { code?: unknown; killed?: unknown; signal?: unknown } : {}; if (/not a git repo(sitory)?/i.test(output)) { return { status: "not-repo", stderr: output }; } if (/detected dubious ownership/i.test(output)) { return { status: "error", reason: "dubious-ownership", stderr: output }; } if (errorRecord.code === "ENOENT" || /(?:spawn\s+)?ENOENT/i.test(output) || /command not found/i.test(output)) { return { status: "error", reason: "git-missing", stderr: output }; } if (errorRecord.code === "ETIMEDOUT" || errorRecord.killed === true || /timed out|timeout/i.test(output)) { return { status: "error", reason: "timeout", stderr: output }; } return { status: "error", reason: "unknown", stderr: output }; } /* FNXC:Worktree 2026-07-10-00:00: FN-7799 requires Git repository detection to distinguish a positive non-repo verdict from environmental Git failures. Dubious ownership on OneDrive-backed Windows Documents paths, git-not-on-PATH, index locks, and timeouts must never be reported as "not a Git repository", because that false negative permanently blocks valid repos across engine restarts. */ export async function detectGitRepository(dir: string): Promise { try { await execAsync("git rev-parse --git-dir", { cwd: dir, encoding: "utf-8", timeout: 10_000, maxBuffer: 10 * 1024 * 1024, }); return { status: "repo" }; } catch (err: unknown) { const detection = classifyGitRepoDetectionError(err); const reasonText = detection.status === "error" ? ` reason=${detection.reason}` : ""; const stderrText = detection.status === "repo" ? "" : detection.stderr; worktreePoolLog.log( `detectGitRepository check failed for ${dir}: status=${detection.status}${reasonText} stderr=${stderrText}`, ); return detection; } } export async function isGitRepository(dir: string): Promise { return (await detectGitRepository(dir)).status === "repo"; } export async function describeRegisteredWorktrees(rootDir: string): Promise<{ rawOutput: string; canonicalized: string[] }> { try { const result = await execAsync("git worktree list --porcelain", { cwd: rootDir, encoding: "utf-8", timeout: 10_000, maxBuffer: 10 * 1024 * 1024, }); const stdout = getExecStdout(result); const canonicalized: string[] = []; for (const line of stdout.split("\n")) { if (line.startsWith("worktree ")) { canonicalized.push(canonicalizePath(line.slice("worktree ".length))); } } return { rawOutput: stdout, canonicalized }; } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : String(err); worktreePoolLog.warn(`[worktree-pool] Failed to list registered worktrees: ${errorMessage}`); return { rawOutput: "", canonicalized: [] }; } } export async function getRegisteredWorktreePaths(rootDir: string): Promise> { const { canonicalized } = await describeRegisteredWorktrees(rootDir); return new Set(canonicalized); } export async function getRegisteredWorktreeBranchMap(rootDir: string): Promise> { const branchMap = new Map(); for (const entry of await getRegisteredWorktreeBranches(rootDir)) { branchMap.set(entry.branch, entry.worktreePath); } return branchMap; } /** * Same source as `getRegisteredWorktreeBranchMap` but returns ALL * (branch, worktreePath) pairs rather than collapsing duplicates by branch. * Multiple worktrees can legitimately share a branch when the user has * created secondary checkouts via `git worktree add --force -b `; * callers that need to act on every such worktree (e.g. the merger's * post-advance auto-sync) must use this array form to avoid silently * skipping all but the last-iterated checkout. */ export async function getRegisteredWorktreeBranches(rootDir: string): Promise> { const { rawOutput } = await describeRegisteredWorktrees(rootDir); const entries: Array<{ branch: string; worktreePath: string }> = []; let currentWorktree: string | null = null; for (const line of rawOutput.split("\n")) { if (line.startsWith("worktree ")) { currentWorktree = canonicalizePath(line.slice("worktree ".length)); continue; } if (line.startsWith("branch ") && currentWorktree) { const branchRef = line.slice("branch ".length).trim(); const branchName = branchRef.startsWith("refs/heads/") ? branchRef.slice("refs/heads/".length) : branchRef; if (branchName) { entries.push({ branch: branchName, worktreePath: currentWorktree }); } } } return entries; } export async function isRegisteredGitWorktree(rootDir: string, worktreePath: string): Promise { return (await getRegisteredWorktreePaths(rootDir)).has(canonicalizePath(worktreePath)); } export function hasRequiredWorktreeFiles(worktreePath: string): boolean { return existsSync(join(worktreePath, ".git")); } /* FNXC:WorktreeLiveness 2026-07-26-08:20: SYNC, NON-SPAWNING liveness probe for callers that must not run git — specifically failure/recovery paths, where spawning git to decide how to recover from a git failure is both slow and fragile. `classifyTaskWorktree` stays the canonical classifier and MUST be preferred wherever an await and a subprocess are acceptable (see docs/solutions/logic-errors/repo-root-task-worktree-requeue-loop.md → Prevention: new worktree-liveness paths should call the shared classifier). This probe covers the classifier's filesystem gate (the path exists and carries `.git`) plus its `repo-root` gate when `rootDir` is supplied. It does NOT cover `unregistered` or `outside-work-tree`, so a directory whose `.git` pointer is stale but present still reads as usable here. Callers that treat "usable" as permission to reuse a checkout must tolerate that narrower guarantee; callers needing the full verdict must await `classifyTaskWorktree`. Keeping the fast probe HERE, beside the classifier, is what makes the difference between the two auditable instead of a duplicate check growing in an unrelated module. Pass `rootDir` whenever the caller has it. The project root is a registered git worktree carrying `.git`, so without that gate the main checkout reads as a usable TASK worktree — the FN-6861 acquisition→gate→requeue loop in docs/solutions/logic-errors/repo-root-task-worktree-requeue-loop.md. */ export function hasUsableWorktreeShape( worktreePath: string | undefined | null, rootDir?: string, ): boolean { if (!worktreePath) return false; // `.git` under a path that does not exist (or is a file) cannot exist either, so this single // filesystem probe subsumes the directory-existence check. if (!hasRequiredWorktreeFiles(worktreePath)) return false; if (rootDir && isRepoRootPath(rootDir, worktreePath)) return false; return true; } export async function isInsideGitWorkTree(worktreePath: string): Promise { try { const result = await execAsync("git rev-parse --is-inside-work-tree", { cwd: worktreePath, encoding: "utf-8", }); return getExecStdout(result).trim() === "true"; } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : String(err); worktreePoolLog.debug(`isInsideGitWorkTree check failed for ${worktreePath}: ${errorMessage}`); return false; } } export type TaskWorktreeClassification = "missing" | "incomplete" | "repo-root" | "unregistered" | "outside-work-tree"; export type TaskWorktreeClassificationResult = | { ok: true } | { ok: false; classification: TaskWorktreeClassification; reason: string }; export type NestedWorktreeRootDetectionResult = | { reanchored: true; root: string } | { reanchored: false; reason: string }; export async function detectNestedWorktreeRoot( rootDir: string, worktreePath: string, settings?: Pick, ): Promise { if (!existsSync(worktreePath)) { return { reanchored: false, reason: "worktree_missing" }; } if (!isInsideWorktreesDir(rootDir, worktreePath, settings)) { return { reanchored: false, reason: "worktree_outside_configured_dir" }; } const canonicalRootDir = canonicalizePath(rootDir); const canonicalWorktreePath = canonicalizePath(worktreePath); let topLevelRaw = ""; try { const result = await execAsync("git rev-parse --show-toplevel", { cwd: worktreePath, encoding: "utf-8", timeout: 10_000, maxBuffer: 1024 * 1024, }); topLevelRaw = getExecStdout(result).trim(); } catch (error) { return { reanchored: false, reason: `top_level_probe_failed:${error instanceof Error ? error.message : String(error)}` }; } if (!topLevelRaw) { return { reanchored: false, reason: "top_level_empty" }; } const canonicalTopLevel = canonicalizePath(topLevelRaw); if (canonicalTopLevel === canonicalWorktreePath) { return { reanchored: false, reason: "already_at_toplevel" }; } if (canonicalTopLevel === canonicalRootDir) { return { reanchored: false, reason: "toplevel_is_repo_root" }; } if (!isInsideWorktreesDir(rootDir, canonicalTopLevel, settings)) { return { reanchored: false, reason: "toplevel_outside_configured_dir" }; } const relFromTopLevel = relative(canonicalTopLevel, canonicalWorktreePath); const nestedUnderTopLevel = relFromTopLevel !== "" && !relFromTopLevel.startsWith("..") && !isAbsolute(relFromTopLevel); if (!nestedUnderTopLevel) { return { reanchored: false, reason: "not_nested_under_toplevel" }; } if (!await isRegisteredGitWorktree(rootDir, canonicalTopLevel)) { return { reanchored: false, reason: "toplevel_not_registered_worktree" }; } return { reanchored: true, root: canonicalTopLevel }; } /** * Language-agnostic liveness/classification gate for task worktrees. */ export async function classifyTaskWorktree(rootDir: string, worktreePath: string): Promise { if (!existsSync(worktreePath)) { return { ok: false, classification: "missing", reason: "worktree directory does not exist" }; } /* * FNXC:WorktreeLiveness 2026-06-21-11:10: * The project root is a legitimately registered git worktree, but it is never a usable task worktree. Tasks must execute inside the configured worktrees directory, so classification rejects root-equal paths here to stop the resume↔executor-gate requeue loop observed in FN-6861/FN-6709. */ if (isRepoRootPath(rootDir, worktreePath)) { return { ok: false, classification: "repo-root", reason: "worktree path is the project root, not a task worktree" }; } if (!hasRequiredWorktreeFiles(worktreePath)) { return { ok: false, classification: "incomplete", reason: "missing .git metadata" }; } if (!await isRegisteredGitWorktree(rootDir, worktreePath)) { return { ok: false, classification: "unregistered", reason: "not registered in git worktree list" }; } if (!await isInsideGitWorkTree(worktreePath)) { return { ok: false, classification: "outside-work-tree", reason: "git rev-parse --is-inside-work-tree returned false" }; } return { ok: true }; } /** * Language-agnostic liveness gate for task worktrees. */ export async function isUsableTaskWorktree(rootDir: string, worktreePath: string): Promise { const result = await classifyTaskWorktree(rootDir, worktreePath); return result.ok; } export function isInsideWorktreesDir( rootDir: string, worktreePath: string, settings?: Pick, ): boolean { return isInsideConfiguredWorktreesDir(rootDir, settings, worktreePath); } export type ReclaimableWorktreePlacement = | { kind: "ready"; path: string; relocated: boolean } | { kind: "deferred-live"; path: string }; export interface RelocateReclaimableWorktreeInput { rootDir: string; sourcePath: string; targetPath: string; taskId: string; settings?: Pick; isPathActive: (path: string) => boolean | Promise; } /** * Put a preserved, registered native checkout under the configured worktree * root. Worktrunk-assigned paths remain backend-owned. The exact source path * must be idle before it can move; callers treat a live result as deferred * recovery rather than invalidating a running process cwd. */ export async function relocateReclaimableWorktreeIntoRoot( input: RelocateReclaimableWorktreeInput, ): Promise { const { rootDir, sourcePath, targetPath, taskId, settings, isPathActive } = input; if (settings?.worktrunk?.enabled === true) { return { kind: "ready", path: sourcePath, relocated: false }; } if (isInsideWorktreesDir(rootDir, sourcePath, settings)) { return { kind: "ready", path: sourcePath, relocated: false }; } if (await isPathActive(sourcePath)) { return { kind: "deferred-live", path: sourcePath }; } if (!isInsideWorktreesDir(rootDir, targetPath, settings)) { throw new Error( `Refusing to relocate ${taskId} worktree to path outside configured worktrees directory: ${targetPath}`, ); } let resolvedTargetPath = targetPath; if (existsSync(resolvedTargetPath) && settings?.worktreeNaming !== "task-id") { const taskSuffix = taskId.toLowerCase(); const candidates = [ `${targetPath}-${taskSuffix}`, ...Array.from({ length: 5 }, (_, index) => `${targetPath}-${taskSuffix}-${index + 2}`), ]; const available = candidates.find((candidate) => !existsSync(candidate)); if (!available) { throw new Error(`No available relocation target for ${taskId} worktree near ${targetPath}`); } resolvedTargetPath = available; } await mkdir(dirname(resolvedTargetPath), { recursive: true }); await execFileAsync("git", ["worktree", "move", sourcePath, resolvedTargetPath], { cwd: rootDir, timeout: 120_000, maxBuffer: 10 * 1024 * 1024, }); return { kind: "ready", path: resolvedTargetPath, relocated: true }; } /** * A pool of idle git worktrees that can be recycled across tasks. * * When `recycleWorktrees` is enabled, completed task worktrees are returned * to this pool instead of being deleted. New tasks acquire a warm worktree * from the pool, preserving build caches (node_modules, target/, dist/). * * The pool only tracks *idle* worktrees — those not currently assigned to * any active task. The scheduler's `maxWorktrees` setting still governs * the total number of worktrees (active + idle). * * **Lifecycle across restarts:** The pool is in-memory only, but on engine * startup it can be rehydrated from disk state via {@link rehydrate} and * {@link scanIdleWorktrees}. When `recycleWorktrees` is true, the startup * sequence scans the `.worktrees/` directory, identifies idle worktrees * (those not assigned to any active task), and bulk-loads them into the * pool. When `recycleWorktrees` is false, orphaned worktrees are cleaned * up via {@link cleanupOrphanedWorktrees}. */ function deriveTaskIdFromBranch(branchName: string): string { const match = branchName.match(/^fusion\/(fn-\d+)(?:-\d+)?(?:-[a-z0-9._-]+)*$/i); return match ? match[1].toUpperCase() : branchName.toUpperCase(); } export type PrepareForTaskResult = { branch: string; worktreePath: string; reclaimed: boolean; existingTipSha?: string; strandedCommitCount?: number; }; export type PoolInvariantPhase = "acquire" | "rehydrate" | "release"; export type PoolInvariantViolation = { path: string; existingHolder: string; requestingTaskId: string; phase: PoolInvariantPhase; }; export class PoolDoubleLeaseError extends Error { constructor( public readonly path: string, public readonly existingHolder: string, public readonly requestingTaskId: string, public readonly phase: PoolInvariantPhase, ) { super(`Pool double lease detected for ${path}: held by ${existingHolder}, requested by ${requestingTaskId} during ${phase}`); this.name = "PoolDoubleLeaseError"; } } export interface WorktreePoolOptions { auditFactory?: (taskId: string) => Pick; secretsStore?: Pick; } export class WorktreePool { private idle = new Set(); private leased = new Map(); private invariantViolationHandler?: (violation: PoolInvariantViolation) => void; constructor(_options: WorktreePoolOptions = {}) {} /** * Acquire an idle worktree from the pool. * * Returns the absolute path of an idle worktree, or `null` if the pool * is empty. Before returning, verifies the directory still exists on disk * and prunes any stale entries. */ acquire(taskId: string): string | null { for (const path of this.idle) { this.assertNotDoubleLeased(path, taskId, "acquire"); this.idle.delete(path); this.leased.set(path, taskId); if (existsSync(path)) { return path; } this.leased.delete(path); worktreePoolLog.debug(`Pruned stale entry: ${path}`); } return null; } /** * Return a worktree to the idle pool after a task completes. * * The worktree directory is retained on disk with its build caches intact. * Call this instead of `git worktree remove` when recycling is enabled. * * @param worktreePath — Absolute path to the worktree directory */ release(worktreePath: string, releasingTaskId?: string): void { const existingHolder = this.leased.get(worktreePath); if (!existingHolder) { worktreePoolLog.warn(`release called for non-leased worktree: ${worktreePath}`); } else if (releasingTaskId && existingHolder !== releasingTaskId) { this.notifyInvariantViolation({ path: worktreePath, existingHolder, requestingTaskId: releasingTaskId, phase: "release", }); worktreePoolLog.warn( `release task mismatch for ${worktreePath}: leased holder=${existingHolder}, releasingTaskId=${releasingTaskId}`, ); } this.leased.delete(worktreePath); this.idle.add(worktreePath); } /** Number of idle worktrees currently in the pool. */ get size(): number { return this.idle.size; } /** Check whether a specific path is in the idle pool. */ has(path: string): boolean { return this.idle.has(path); } setInvariantViolationHandler(handler: (violation: PoolInvariantViolation) => void): void { this.invariantViolationHandler = handler; } /** @internal test-only visibility */ getLeasedPaths(): ReadonlyMap { return this.leased; } private notifyInvariantViolation(violation: PoolInvariantViolation): void { try { this.invariantViolationHandler?.(violation); } catch (error) { worktreePoolLog.warn(`Invariant violation handler failed: ${error instanceof Error ? error.message : String(error)}`); } } private assertNotDoubleLeased(path: string, requestingTaskId: string, phase: PoolInvariantPhase): void { const existingHolder = this.leased.get(path); if (!existingHolder || existingHolder === requestingTaskId) { return; } const violation: PoolInvariantViolation = { path, existingHolder, requestingTaskId, phase }; this.notifyInvariantViolation(violation); throw new PoolDoubleLeaseError(path, existingHolder, requestingTaskId, phase); } /** * Remove and return all idle worktree paths. * * Useful for shutdown/cleanup — the caller is responsible for * running `git worktree remove` on each returned path. */ drain(): string[] { const paths = Array.from(this.idle); this.idle.clear(); this.leased.clear(); return paths; } /** * Bulk-load known idle worktree paths into the pool. * * Called at engine startup to restore the pool from disk state. * Paths that no longer exist on disk are silently skipped. * * @param idlePaths — Absolute paths to idle worktree directories */ rehydrate(idlePaths: string[]): void { for (const path of idlePaths) { if (!existsSync(path)) { worktreePoolLog.debug(`Rehydrate skipped (not on disk): ${path}`); continue; } const existingHolder = this.leased.get(path); if (existingHolder) { this.notifyInvariantViolation({ path, existingHolder, requestingTaskId: existingHolder, phase: "rehydrate", }); worktreePoolLog.warn(`Rehydrate skipped leased worktree ${path} (holder=${existingHolder})`); continue; } this.idle.add(path); } } /** * Prepare a recycled worktree for a new task. * * Resets the working tree to a clean state, then creates (or force-resets) * the task's branch based on the given start point (or `main` by default). * This ensures the new task starts from the correct base with a clean * working directory, while preserving untracked build caches * (node_modules, target/, dist/). As an explicit carve-out, this * preparation removes `packages/desktop/dist` and * `packages/desktop/dist-electron`. * * Steps performed: * 1. `git checkout -- .` — discard tracked file modifications * 2. `git clean -fd` — remove untracked files (but not .gitignore'd caches) * 3. Remove `packages/desktop/dist` + `packages/desktop/dist-electron` if present * 4. `git checkout --detach ` — move HEAD to the latest base commit * 5. `git checkout -B ` — create/reset branch from start point * * Returns the actual branch name used. This may differ from `branchName` * when legacy conflict recovery is explicitly enabled and generates a suffixed * name (e.g., `fusion/fn-042-2`). * * @param worktreePath — Absolute path to the recycled worktree * @param branchName — Branch name for the new task (e.g., `fusion/fn-042`) * @param startPoint — Git ref to branch from (e.g., `fusion/fn-041`). Defaults to `main`. * @returns The actual branch name checked out in the worktree */ async prepareForTask( worktreePath: string, branchName: string, startPoint?: string, options?: { allowSiblingBranchRename?: boolean; repoDir?: string; requestingTaskId?: string }, ): Promise { // Clean tracked modifications try { await execAsync("git checkout -- .", { cwd: worktreePath }); } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : String(err); worktreePoolLog.debug(`git checkout -- . failed (may be clean): ${errorMessage}`); // May fail if worktree is already clean — that's fine } // Remove untracked files (but not .gitignore'd build caches) await execAsync("git clean -fd", { cwd: worktreePath }); await removeDesktopBuildArtifacts(worktreePath, worktreePoolLog); const base = startPoint || await resolveIntegrationBranch(options?.repoDir ?? worktreePath, undefined); // Reject base values that would cause the new branch to inherit the // worktree's current HEAD instead of the intended start point. Historical // contamination ("branch: Created from HEAD") landed FN-5472's tip on // freshly-created fn-5432/fn-5255 branches because the recycled worktree // was still pointing at the previous occupant's commit and base silently // collapsed onto HEAD. const trimmedBase = base?.trim() ?? ""; if (!trimmedBase || trimmedBase.toUpperCase() === "HEAD") { throw new Error( `prepareForTask: refusing to create branch ${branchName} from base ${JSON.stringify(base)} (worktree=${worktreePath}, startPoint=${String(startPoint)})`, ); } await execAsync(`git checkout --detach ${base}`, { cwd: worktreePath, }); // Create or force-reset the branch from the start point (or main) const checkoutCmd = `git checkout -B "${branchName}" ${base}`; const resolvedBase = (await execAsync(`git rev-parse --verify "${base}^{commit}"`, { cwd: worktreePath, encoding: "utf-8" })).stdout.trim(); // Verify HEAD actually landed at the resolved base after --detach. If // detach silently leaves HEAD elsewhere (e.g. the base ref didn't exist // and git fell through to current HEAD), creating the branch now would // pin it to the wrong tip — exactly the FN-5432 / FN-5255 contamination // pattern ("branch: Created from HEAD" pointing at the previous occupant's // tip). Only enforced when we have real SHAs to compare; mock-driven // unit tests that return empty buffers fall through harmlessly. if (/^[0-9a-f]{40}$/i.test(resolvedBase)) { const detachedHead = (await execAsync("git rev-parse HEAD", { cwd: worktreePath, encoding: "utf-8" })).stdout.trim(); if (detachedHead !== resolvedBase) { throw new Error( `prepareForTask: post-detach HEAD ${detachedHead} does not match resolved base ${resolvedBase} (${base}) for ${branchName} — refusing to create branch`, ); } } const taskId = deriveTaskIdFromBranch(branchName); try { await execAsync(checkoutCmd, { cwd: worktreePath, }); await assertCleanBranchAtBase(worktreePath, branchName, resolvedBase, taskId); return { branch: branchName, worktreePath, reclaimed: false }; } catch (err: unknown) { const execError = err instanceof Error ? err : new Error(String(err)); const stderr = "stderr" in execError ? String((execError as { stderr?: unknown }).stderr ?? execError.message) : execError.message; const match = stderr.match(/already used by worktree at '([^']+)'/); if (!match) { throw err; } // The branch is checked out in a different worktree. Keep stale-conflict // cleanup behavior for missing paths; otherwise either surface a typed // conflict or, when explicitly enabled, fall back to the legacy sibling // suffix flow. const conflictingPath = match[1]; const repoDir = options?.repoDir ?? worktreePath; const inspection = await inspectBranchConflict({ repoDir, branchName, conflictingWorktreePath: conflictingPath, requestingTaskId: options?.requestingTaskId ?? taskId, ownerTaskId: taskId, startPoint: base, integrationRef: await resolveIntegrationBranch(repoDir, undefined), }); if (inspection.kind === "stale" || inspection.kind === "stale-resolved" || inspection.kind === "tip-already-merged") { const backend = resolveWorktreeBackendViaSettings({}, { logger: worktreePoolLog }); await backend.prune({ rootDir: options?.repoDir ?? worktreePath }); if (inspection.kind === "tip-already-merged") { try { await execAsync(`git branch -D "${branchName}"`, { cwd: worktreePath }); } catch { // best-effort } } await execAsync(checkoutCmd, { cwd: worktreePath }); await assertCleanBranchAtBase(worktreePath, branchName, resolvedBase, taskId); return { branch: branchName, worktreePath, reclaimed: false }; } if (inspection.kind === "reclaimable") { worktreePoolLog.log( `reclaimed self-owned branch conflict for ${branchName}: tip=${inspection.tipSha} strandedSince${base}=${inspection.strandedCommits.length}`, ); return { branch: branchName, worktreePath: inspection.livePath, reclaimed: true, existingTipSha: inspection.tipSha, strandedCommitCount: inspection.strandedCommits.length, }; } if (inspection.kind === "fully-subsumed") { worktreePoolLog.log( `reclaimed fully-subsumed branch conflict for ${branchName}: tip=${inspection.tipSha} strandedSince${base}=0`, ); return { branch: branchName, worktreePath: inspection.livePath, reclaimed: true, existingTipSha: inspection.tipSha, strandedCommitCount: 0, }; } if (!options?.allowSiblingBranchRename) { if (inspection.kind === "live-foreign") { throw inspection.error; } throw new Error(`Branch ${branchName} is already in use at ${conflictingPath}`); } const conflictBase = branchName; for (let suffix = 2; suffix <= 6; suffix++) { const suffixedName = `${branchName}-${suffix}`; const suffixedCmd = `git checkout -B "${suffixedName}" ${conflictBase}`; try { await execAsync(suffixedCmd, { cwd: worktreePath }); await assertCleanBranchAtBase(worktreePath, suffixedName, resolvedBase, taskId); return { branch: suffixedName, worktreePath, reclaimed: false }; } catch (suffixErr: unknown) { const suffixExecError = suffixErr instanceof Error ? suffixErr : new Error(String(suffixErr)); const suffixStderr = "stderr" in suffixExecError && typeof suffixExecError.stderr === "string" ? suffixExecError.stderr.toString() : ""; if (!suffixStderr.includes("already used by worktree")) { throw suffixErr; } } } throw new Error( `Cannot create branch for task: "${branchName}" and suffixes -2 through -6 are all in use by other worktrees`, ); } } } /** * Scan the `.worktrees/` directory to find idle worktrees that can be * loaded into the pool on startup. * * A worktree is considered "idle" if it exists on disk under * `/.worktrees/` but is NOT assigned (via `task.worktree`) to * any non-done task. * * @param rootDir — Project root directory (parent of `.worktrees/`) * @param store — Task store for listing tasks and their worktree assignments * @returns Absolute paths of idle worktree directories */ export async function scanIdleWorktrees( rootDir: string, store: TaskStore, settings?: Pick, ): Promise { const worktreesDir = resolveWorktreesDir(rootDir, settings); if (!existsSync(worktreesDir)) { return []; } // List all subdirectories under .worktrees/ let dirs: string[]; try { const entries = readdirSync(worktreesDir, { withFileTypes: true }); dirs = entries .filter((e) => e.isDirectory() && !isAiMergeContainerDir(e.name)) .map((e) => join(worktreesDir, e.name)); } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : String(err); worktreePoolLog.warn(`Failed to read .worktrees/ directory: ${errorMessage}`); return []; } if (dirs.length === 0) { return []; } const registeredWorktrees = await getRegisteredWorktreePaths(rootDir); const registeredDirs = dirs.filter((dir) => registeredWorktrees.has(resolve(dir))); // Find worktree paths assigned to non-done tasks (active worktrees) const tasks = await store.listTasks({ slim: true, includeArchived: false, startupMemo: true }); const activeWorktrees = new Set(); /* FNXC:WorkflowResolvedColumns 2026-07-30-14:05 (batch-engine tail): "Still holding its worktree" excludes tasks that have FINISHED. Keyed on the id, a renamed complete lane kept every shipped task's worktree in the ACTIVE set, so this reclaim pass never returned it and the board walked into worktree exhaustion — a stall whose cause is invisible from the symptom. NOT the query-filter class: this listTasks call passes no `column`. Resolved per TASK (each may run its own workflow) and ONLY for tasks that actually record a worktree, with one IR cache for the pass. Unioned with the legacy id because `resolveWorkflowIrForTask` degrades to the BUILT-IN IR rather than throwing — without the union a degraded board would hold every worktree forever, which is this bug. */ const reclaimIrCache = new Map>>(); const completeByTaskId = new Map>(); for (const task of tasks) { if (!task.worktree) continue; const columns = new Set(["done"]); try { const ir = await resolveWorkflowIrForTask(store, task.id, reclaimIrCache); if (ir) for (const id of columnsWithFlag(ir, "complete")) columns.add(id); } catch { /* degraded: legacy id only */ } completeByTaskId.set(task.id, columns); } const isUnfinished = (task: { id: string; column: string }) => completeByTaskId.get(task.id)?.has(task.column) !== true; for (const task of tasks) { if (task.worktree && isUnfinished(task) && registeredWorktrees.has(resolve(task.worktree))) { activeWorktrees.add(resolve(task.worktree)); } else if (task.worktree && isUnfinished(task)) { worktreePoolLog.debug(`Ignoring task ${task.id} worktree metadata because it is not a registered git worktree: ${task.worktree}`); } } // Return registered worktrees on disk that are NOT active. Unregistered // directories are intentionally excluded here so recycle mode never adds a // broken directory to the warm pool; cleanup handles those separately. return registeredDirs.filter((dir) => !activeWorktrees.has(resolve(dir))); } /** * Clean up orphaned worktrees left behind from previous engine runs. * * Removes worktree directories under `/.worktrees/` that are NOT * assigned to any non-done task. Used on startup when `recycleWorktrees` * is false to avoid disk waste. * * Failures on individual worktree removals are logged but not fatal. * * @param rootDir — Project root directory (parent of `.worktrees/`) * @param store — Task store for listing tasks and their worktree assignments * @returns Number of worktrees cleaned up */ export async function cleanupOrphanedWorktrees( rootDir: string, store: TaskStore, settings?: Pick, ): Promise { const worktreesDir = resolveWorktreesDir(rootDir, settings); if (!existsSync(worktreesDir)) { return 0; } const orphaned = await scanIdleWorktrees(rootDir, store, settings); const registeredWorktrees = await getRegisteredWorktreePaths(rootDir); let dirs: string[] = []; if (existsSync(worktreesDir)) { try { dirs = readdirSync(worktreesDir, { withFileTypes: true }) .filter((e) => e.isDirectory() && !isAiMergeContainerDir(e.name)) .map((e) => join(worktreesDir, e.name)); } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : String(err); worktreePoolLog.warn(`Failed to read .worktrees/ directory for cleanup: ${errorMessage}`); dirs = []; } } const unregistered = dirs.filter((dir) => !registeredWorktrees.has(resolve(dir))); const candidates = [...orphaned, ...unregistered]; let cleaned = 0; for (const worktreePath of candidates) { try { if (registeredWorktrees.has(resolve(worktreePath))) { const orphanTaskId = `orphan:${basename(worktreePath)}`; try { await cleanupSecretsEnvFile({ worktreePath, taskId: orphanTaskId, expectedFingerprint: null, filename: ".env", audit: undefined, logger: worktreePoolLog, }); } catch (error) { worktreePoolLog.warn( `secrets-env cleanup failed for registered orphan ${worktreePath}: ${error instanceof Error ? error.message : String(error)}`, ); } await removeWorktreeViaBackend({ rootDir, worktreePath, settings: settings ?? {}, reason: RemovalReason.PoolPrune, }); } else { if (!isInsideWorktreesDir(rootDir, worktreePath, settings)) { throw new Error(`Refusing to remove path outside .worktrees: ${worktreePath}`); } rmSync(worktreePath, { recursive: true, force: true }); await pruneWorktreeAdminEntries({ rootDir, reason: "pool-cleanup-orphan", target: worktreePath, logger: worktreePoolLog, }).catch(() => undefined); } worktreePoolLog.log(`Cleaned up orphaned worktree: ${worktreePath}`); cleaned++; } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : String(err); worktreePoolLog.log(`Failed to remove orphaned worktree ${worktreePath}: ${errorMessage}`); } } return cleaned; } /** * Remove "half-initialized" worktree directories — directories that exist under * `/.worktrees/` on disk but were never fully registered with git * (i.e., `git worktree add` never completed successfully for them). * * This is the housekeeping path; it runs once at engine startup and is safe to * call repeatedly. The hot path (`assertValidWorktreeSession`) is deliberately * left untouched. * * Safety invariants enforced before any removal: * - Only removes direct children of `/.worktrees/` — never the * project root itself, a parent, or an arbitrary path. * - Skips symlinks (only removes real directories). * - Never removes a directory that is a registered git worktree. * - Never removes a directory that has a valid `.git` file pointing to an * existing gitdir (belt-and-suspenders: git would list it anyway, but guards * against stale porcelain output on broken repos). * * @param projectRoot - Absolute path to the project root (parent of `.worktrees/`) * @returns Number of orphan directories removed */ /** * Decide whether a worktree's `.git` pointer is *dangling* — present on disk but * referencing a `.git/worktrees/` admin entry that no longer exists. A * dangling pointer is FN-6782 leak residue: invisible to `git worktree list` / * `prune`, yet it collides with freshly generated worktree names. * * Returns `true` ONLY when the pointer is confidently classifiable as dangling: * a `gitdir: ` link file (relative targets resolved against the worktree * dir) whose target is confirmed missing. Returns `false` for everything else — * a real `.git` directory, a live gitdir target, an unparseable pointer, OR any * read/stat failure. The conservative default matters: callers reap on `true`, * so a transient read error (EACCES/EBUSY) on a genuinely-live worktree's `.git` * must never be misread as dangling and force-removed. */ function dotGitPointerIsDangling(dotGitPath: string): boolean { try { if (lstatSync(dotGitPath).isDirectory()) return false; const raw = readFileSync(dotGitPath, "utf8").trim(); const match = /^gitdir:\s*(.+)$/.exec(raw); if (!match) return false; const target = match[1].trim(); const resolved = isAbsolute(target) ? target : resolve(dirname(dotGitPath), target); return !existsSync(resolved); } catch { return false; } } export async function reapOrphanWorktrees( projectRoot: string, settings?: Pick, ): Promise { const worktreesDir = resolveWorktreesDir(projectRoot, settings); if (!existsSync(worktreesDir)) { return 0; } // List direct children of .worktrees/ let entries: { name: string; fullPath: string }[]; try { entries = readdirSync(worktreesDir, { withFileTypes: true }) .filter((e) => { // Only real directories — never symlinks; never the dedicated AI-merge container. if (!e.isDirectory() || isAiMergeContainerDir(e.name)) return false; try { return lstatSync(join(worktreesDir, e.name)).isDirectory() && !lstatSync(join(worktreesDir, e.name)).isSymbolicLink(); } catch { return false; } }) .map((e) => ({ name: e.name, fullPath: join(worktreesDir, e.name) })); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); worktreePoolLog.warn(`reapOrphanWorktrees: failed to read .worktrees/ — ${msg}`); return 0; } if (entries.length === 0) return 0; // Get the set of paths registered with git const registered = await getRegisteredWorktreePaths(projectRoot); let removed = 0; for (const { name, fullPath } of entries) { const resolvedFull = resolve(fullPath); // Safety: only operate on paths directly under .worktrees/ const rel = relative(resolve(worktreesDir), resolvedFull); if (!rel || rel.startsWith("..") || isAbsolute(rel)) { worktreePoolLog.warn(`reapOrphanWorktrees: skipping out-of-bounds path ${fullPath}`); continue; } // Skip registered worktrees — those are managed by the normal lifecycle if (registered.has(resolvedFull)) { continue; } // Belt-and-suspenders: skip if a .git file exists AND points to an existing gitdir. // This guards against races where git registered the worktree between our list // call and now, or against a broken repo whose porcelain is unreliable. // // FN-6782 follow-up: a *dangling* `.git` (file present, but the admin entry it // points to is gone) is NOT "partially registered" — it is leak residue from a // worktree whose admin entry was pruned while the directory survived. Such a dir // is invisible to `git worktree list`/`prune` yet collides with freshly generated // worktree names and breaks `execute` (cleanup can't `git worktree remove` a path // git never registered). Only skip when the gitdir target actually exists; reap // dangling pointers like any other half-initialized orphan. const dotGit = join(resolvedFull, ".git"); if (existsSync(dotGit)) { if (!dotGitPointerIsDangling(dotGit)) { // Valid registration, a real .git dir, or a pointer we couldn't positively classify as // dangling — leave it; assertValidWorktreeSession handles it on the next agent start. worktreePoolLog.debug(`reapOrphanWorktrees: skipping ${name} (has .git entry but not in registered list — may be partially registered)`); continue; } worktreePoolLog.debug(`reapOrphanWorktrees: ${name} has a dangling .git pointer (admin entry missing) — treating as orphan`); // fall through to removal } // This directory is on disk but has no valid .git entry and is not a registered // worktree — it is a half-initialized / leaked orphan. Remove it. try { try { await cleanupSecretsEnvFile({ worktreePath: resolvedFull, taskId: `orphan:${name}`, expectedFingerprint: null, filename: ".env", logger: worktreePoolLog, }); } catch (error) { worktreePoolLog.warn(`secrets-env cleanup failed for orphan ${name}: ${error instanceof Error ? error.message : String(error)}`); } rmSync(resolvedFull, { recursive: true, force: true }); await pruneWorktreeAdminEntries({ rootDir: projectRoot, reason: "pool-reap-orphan", target: resolvedFull, logger: worktreePoolLog, }).catch(() => undefined); worktreePoolLog.log(`reapOrphanWorktrees: removed half-initialized orphan ${name}`); removed++; } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); worktreePoolLog.warn(`reapOrphanWorktrees: failed to remove ${name} — ${msg}`); } } return removed; } /** Columns where merger/finalization owns branch lifecycle. */ /** * Return local `fusion/*` branches not associated with any active task. * Branches tied to merger-managed or archived tasks are excluded. */ export async function scanOrphanedBranches(rootDir: string, store: TaskStore): Promise { let allBranches: string[]; try { const result = await execAsync("git branch --list 'fusion/*'", { cwd: rootDir, encoding: "utf-8", }); const stdout = getExecStdout(result); allBranches = stdout .split("\n") .map((line) => line.trim().replace(/^\*?\s*/, "")) .filter((line) => line.startsWith("fusion/")); } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : String(err); worktreePoolLog.warn(`Failed to list fusion/* branches: ${errorMessage}`); return []; } if (allBranches.length === 0) return []; const tasks = await store.listTasks({ slim: true, includeArchived: false }); /* FNXC:WorkflowResolvedColumns 2026-07-31-08:20 (batch-engine — census-invisible membership, #2763 class): A branch is "active" (and so must not be reclaimed) unless the merger owns the card or it is archived. Both tests were hardcoded, so on a renamed board a card in review or complete was NOT recognised as merger-managed and its branch was treated as reclaimable — deleting a branch out from under an in-flight merge. One IR cache for the pass; the predicates below stay synchronous. */ const poolIrCache = new Map>>(); const poolLanes = new Map; archived: Set }>(); for (const task of tasks) { if (poolLanes.has(task.id)) continue; const managed = new Set(["in-review", "done"]); const archived = new Set(["archived"]); try { const ir = await resolveWorkflowIrForTask(store, task.id, poolIrCache); if (ir) { for (const flag of ["mergeOrchestration", "mergeBlocker", "humanReview", "complete"] as const) { for (const id of columnsWithFlag(ir, flag)) managed.add(id); } for (const id of columnsWithFlag(ir, "archived")) archived.add(id); } } catch { /* degraded: legacy ids */ } poolLanes.set(task.id, { managed, archived }); } const activeBranches = new Set(); for (const task of tasks) { if (poolLanes.get(task.id)?.managed.has(task.column) === true) continue; if (poolLanes.get(task.id)?.archived.has(task.column) === true) continue; if (task.branch) activeBranches.add(task.branch); activeBranches.add(canonicalFusionBranchName(task.id)); } return allBranches.filter((branch) => !activeBranches.has(branch)); }