Files
fusion/packages/engine/src/worktree-pool.ts
gsxdsm 478f226a54 test: green full-suite CI after main drift (#2229)
## Summary
Restores green **Full Suite (non-blocking)** runs on `main`. Recent main
merges left i18n key parity, schema baseline bookkeeping (0011→0012),
heartbeat tool inventory (FN-8058 `fn_task_logs_read`), and merger
whitespace-classification mocks (execFile `git diff -p -w :2: :3:`) out
of date, so all four test shards failed.

## Root causes observed on main
- **Shard 4 / `@fusion/i18n`**: missing `skipConfirmationDialogs*` +
`reviewBudgetExhausted` in non-en locales; orphan
`awaitingApprovalPlanReviewReplanCap`
- **Shard 3 / `@fusion/core`**: `SCHEMA_BASELINE_VERSION` advanced to
`0012` while tests still equated it with
`OWNER_PROJECT_ID_SPLIT_VERSION` (`0011`) and omitted `0012` from
applied-migration lists
- **Shards 1–2 / `@fusion/engine`**: tool count/snapshot drift for
`fn_task_logs_read`; merger tests still mocked `git diff-tree` for
trivial classification after the execFile `:2:`/`:3:` cutover; mock
provider `updateTask` arity drift

## Changes
- Locale catalogs: add missing keys, drop orphan key
- Schema applier tests: immutable 0011 identity + baseline 0012 lists
- Heartbeat + gating snapshots: include `fn_task_logs_read`
- Merger unit mocks: recognize `git diff -p -w :2:path :3:path`
- Mock provider: accept optional third `updateTask` arg

## Test plan
- [x] `pnpm --filter @fusion/i18n exec vitest run` — 23/23
- [x] `pnpm --filter @fusion/core exec vitest run
src/__tests__/postgres/schema-applier.test.ts` (immutable + automation
upgrade) — pass
- [x] `pnpm --filter @fusion/core exec vitest run` project-identity +
satellite-fusiondir — pass
- [x] Engine suites from failed CI shards (file-scoped,
hermes/openclaw/paperclip/grok, reliability post-finalize/mission,
heartbeat, gating, merger recovery/prompt, mock-provider, etc.) — pass
- [ ] Full Suite workflow green on merge to main

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
  - Improved project data isolation across backend operations.
- Added safer optional toast handling when UI components render outside
the full application shell.
  - Added support for reading task logs during agent heartbeat sessions.

- **Bug Fixes**
- Prevented runtime probes from hanging and avoided scanning large
binary files.
  - Improved path handling for workspaces with missing descendants.
- Corrected task retry state resets and GitHub import/issue-close
behavior.

- **Style**
  - Improved chat, terminal, and settings spacing.
  - Added clearer accessibility labeling for the auto-merge control.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-16 16:01:29 -07:00

1112 lines
43 KiB
TypeScript

import { exec } from "node:child_process";
import { promisify } from "node:util";
import { existsSync, lstatSync, readdirSync, readFileSync, rmSync, realpathSync } from "node:fs";
import { basename, dirname, join, relative, resolve, isAbsolute } from "node:path";
import type { ColumnId, SecretsStore, Settings, TaskStore, WorktrunkSettings } from "@fusion/core";
import { assertCleanBranchAtBase, inspectBranchConflict } from "./branch-conflicts.js";
import { worktreePoolLog } from "./logger.js";
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";
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);
// ── 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<string, { binaryPath: string; resolvedAt: number }>();
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<GitRepoDetection> {
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<boolean> {
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<Set<string>> {
const { canonicalized } = await describeRegisteredWorktrees(rootDir);
return new Set(canonicalized);
}
export async function getRegisteredWorktreeBranchMap(rootDir: string): Promise<Map<string, string>> {
const branchMap = new Map<string, string>();
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 <branch>`;
* 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<Array<{ branch: string; worktreePath: string }>> {
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<boolean> {
return (await getRegisteredWorktreePaths(rootDir)).has(canonicalizePath(worktreePath));
}
export function hasRequiredWorktreeFiles(worktreePath: string): boolean {
return existsSync(join(worktreePath, ".git"));
}
export async function isInsideGitWorkTree(worktreePath: string): Promise<boolean> {
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.log(`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<Settings, "worktreesDir">,
): Promise<NestedWorktreeRootDetectionResult> {
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<TaskWorktreeClassificationResult> {
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<boolean> {
const result = await classifyTaskWorktree(rootDir, worktreePath);
return result.ok;
}
export function isInsideWorktreesDir(
rootDir: string,
worktreePath: string,
settings?: Pick<Settings, "worktreesDir">,
): boolean {
return isInsideConfiguredWorktreesDir(rootDir, settings, worktreePath);
}
/**
* 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<RunAuditor, "filesystem">;
secretsStore?: Pick<SecretsStore, "listEnvExportable">;
}
export class WorktreePool {
private idle = new Set<string>();
private leased = new Map<string, string>();
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.log(`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<string, string> {
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.log(`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 <startPoint>` — move HEAD to the latest base commit
* 5. `git checkout -B <branchName> <startPoint>` — 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<PrepareForTaskResult> {
// Clean tracked modifications
try {
await execAsync("git checkout -- .", { cwd: worktreePath });
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
worktreePoolLog.log(`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
* `<rootDir>/.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<Settings, "worktreesDir">,
): Promise<string[]> {
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<string>();
for (const task of tasks) {
if (task.worktree && task.column !== "done" && registeredWorktrees.has(resolve(task.worktree))) {
activeWorktrees.add(resolve(task.worktree));
} else if (task.worktree && task.column !== "done") {
worktreePoolLog.log(`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 `<rootDir>/.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<Settings, "worktreesDir">,
): Promise<number> {
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
* `<projectRoot>/.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 `<projectRoot>/.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/<name>` 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: <path>` 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<Settings, "worktreesDir">,
): Promise<number> {
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.log(`reapOrphanWorktrees: skipping ${name} (has .git entry but not in registered list — may be partially registered)`);
continue;
}
worktreePoolLog.log(`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. */
const MERGER_MANAGED_COLUMNS: ReadonlySet<ColumnId> = new Set<ColumnId>(["in-review", "done"]);
/**
* 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<string[]> {
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 });
const activeBranches = new Set<string>();
for (const task of tasks) {
if (MERGER_MANAGED_COLUMNS.has(task.column)) continue;
if (task.column === "archived") continue;
if (task.branch) activeBranches.add(task.branch);
activeBranches.add(canonicalFusionBranchName(task.id));
}
return allBranches.filter((branch) => !activeBranches.has(branch));
}