Files
fusion/packages/engine/src/executor/worktree-create-conflict.ts
gsxdsm e40bcebc27 FN-9161: support operator workspace branches
Allow workspace tasks to use one operator-selected branch consistently across every sub-repository while preserving ownership during recovery.

- validate and persist operator branch provenance at task creation and update boundaries
- reuse custom branches across workspace worktrees, PR flows, cleanup, and self-healing without deleting operator-owned refs
- expose branch validation in task forms and document the workspace behavior
- add coverage and a release changeset for shared custom branch handling

Files changed:
 .changeset/fn-9161-workspace-custom-branch.md      |  7 ++
 docs/workspaces.md                                 |  6 ++
 packages/cli/src/commands/pr.ts                    | 10 ++-
 packages/cli/src/commands/task-lifecycle.ts        | 10 +--
 packages/cli/src/commands/task.ts                  |  4 +-
 packages/cli/src/extension.ts                      |  2 +-
 .../core/src/__tests__/branch-assignment.test.ts   | 37 ++++++++++
 .../task-update-awaiting-approval-reason.test.ts   |  8 +++
 .../core/src/async-stores/async-mission-store.ts   |  1 +
 packages/core/src/branch/branch-assignment.ts      | 64 +++++++++++++++++
 packages/core/src/index.gate.ts                    |  7 ++
 packages/core/src/index.ts                         |  7 ++
 packages/core/src/missions/mission-store.ts        |  1 +
 packages/core/src/store.ts                         |  2 +-
 packages/core/src/task-store/branch-context.ts     | 35 +++++++--
 packages/core/src/task-store/merge-queue-ops.ts    | 31 ++++----
 packages/core/src/task-store/task-creation.ts      | 30 ++++++++
 packages/core/src/task-store/task-mutation-ops.ts  | 18 ++++-
 packages/core/src/task-store/task-update.ts        | 60 ++++++++++++++--
 packages/core/src/types.ts                         |  5 ++
 packages/core/src/types/task/task-core.ts          | 20 +++++-
 packages/dashboard/app/components/NewTaskModal.tsx | 25 +++++--
 packages/dashboard/app/components/TaskForm.tsx     | 18 ++++-
 .../app/components/__tests__/NewTaskModal.test.tsx | 12 ++++
 packages/dashboard/src/pr-conflict-resolver.ts     | 14 ++--
 packages/dashboard/src/routes/branch-selection.ts  |  7 ++
 .../dashboard/src/routes/register-git-github.ts    | 18 +++--
 .../src/routes/register-task-workflow-routes.ts    | 17 ++++-
 .../engine/src/__tests__/worktree-hooks.test.ts    |  6 ++
 .../engine/src/__tests__/worktree-pool.test.ts     |  8 +++
 .../src/auto-recovery-handlers/branch-worktree.ts  | 20 +++---
 .../engine/src/execution/step-session-executor.ts  |  6 +-
 .../src/executor/build-foreach-worktree-deps.ts    |  5 +-
 .../engine/src/executor/create-task-done-tool.ts   |  8 +--
 packages/engine/src/executor/dep-abort-cleanup.ts  |  4 +-
 packages/engine/src/executor/mark-stuck-aborted.ts |  2 +-
 .../src/executor/release-pre-execution-worktree.ts |  2 +-
 packages/engine/src/executor/run-implementation.ts | 14 ++--
 .../src/executor/task-done-refusal-handler.ts      |  4 +-
 .../executor/worktree-branch-conflict-handle.ts    | 10 +--
 .../src/executor/worktree-cleanup-conflicting.ts   | 39 +++++-----
 .../src/executor/worktree-create-conflict.ts       |  1 +
 .../engine/src/executor/worktree-stale-branch.ts   |  9 +++
 .../src/healing/restart-recovery-coordinator.ts    |  2 +-
 packages/engine/src/merger.ts                      | 33 +++++----
 .../src/recovery/foreign-only-contamination.ts     |  8 ++-
 packages/engine/src/self-healing.ts                | 82 +++++++++++++++-------
 .../engine/src/worktree/worktree-acquisition.ts    | 52 ++++++++++----
 packages/engine/src/worktree/worktree-backend.ts   | 42 ++++++++---
 packages/engine/src/worktree/worktree-hooks.ts     | 24 +++++--
 packages/engine/src/worktree/worktree-names.ts     | 13 ++++
 packages/engine/src/worktree/worktree-pool.ts      | 17 ++++-
 52 files changed, 708 insertions(+), 179 deletions(-)

Fusion-Task-Id: FN-9161

Fusion-Task-Lineage: 5b0b9ecc-bead-471f-96f7-bec6698b69fc

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-08-19 21:42:29 -07:00

451 lines
18 KiB
TypeScript

/**
* FNXC:CodeOrganization 2026-08-03-15:10:
* tryCreateWorktree + handleWorktreeConflict peeled from TaskExecutor (U4 Slice B).
* Circular call graph is expressed via deps callbacks (thin class facades wire them).
*/
import { exec } from "node:child_process";
import { promisify } from "node:util";
import { existsSync } from "node:fs";
import { rm } from "node:fs/promises";
import type { Settings } from "@fusion/core";
import { installTaskWorktreeIdentityGuard } from "../worktree/worktree-hooks.js";
import { isInsideWorktreesDir } from "../worktree/worktree-pool.js";
import { inspectBranchConflict } from "../execution/branch-conflicts.js";
import { resolveIntegrationBranch } from "../merge/integration-branch.js";
import { executorLog } from "../logger.js";
import { extractWorktreeConflictInfo } from "./worktree-conflict-info.js";
import { assertWorktreePathNotNested, isRegisteredWorktree, NonRetryableWorktreeError } from "./worktree-registry-helpers.js";
const execAsync = promisify(exec);
export type WorktreeCreateConflictDeps = {
rootDir: string;
store: {
logEntry: (taskId: string, action: string, outcome?: string) => Promise<unknown>;
};
maxWorktreeRetries: number;
recoverIndexLockIfStale: (taskId: string, path: string, conflictInfo: { lockPath?: string; message?: string }) => Promise<boolean>;
recoverStaleRegistration: (taskId: string, path: string, conflictInfo: { path?: string; message?: string }) => Promise<boolean>;
cleanupStaleBranch: (branch: string, taskId: string) => Promise<boolean>;
handleWorktreeConflict: (
conflictPath: string,
branch: string,
path: string,
taskId: string,
startPoint?: string,
attemptNumber?: number,
allowSiblingBranchRename?: boolean,
settings?: Partial<Settings>,
) => Promise<{ path: string; branch: string } | null>;
tryCreateWorktree: (
branch: string,
path: string,
taskId: string,
startPoint?: string,
attemptNumber?: number,
recoveryDepth?: number,
allowSiblingBranchRename?: boolean,
settings?: Partial<Settings>,
) => Promise<{ path: string; branch: string }>;
tryFreshWorktreeAfterLiveConflict: (input: {
conflictPath: string;
branch: string;
taskId: string;
startPoint?: string;
attemptNumber?: number;
allowSiblingBranchRename: boolean;
settings: Partial<Settings>;
}) => Promise<{ path: string; branch: string }>;
shouldGenerateNewWorktreeName: (conflictPath: string, currentTaskId: string) => Promise<boolean>;
cleanupConflictingWorktree: (worktreePath: string, branch: string, taskId: string) => Promise<boolean>;
normalizeReclaimableWorktreePath: (
sourcePath: string,
targetPath: string,
taskId: string,
settings: Partial<Settings>,
) => Promise<string>;
isLiveCleanupRefusal: (worktreePath: string, taskId: string) => Promise<boolean>;
};
export async function tryCreateWorktree(
deps: WorktreeCreateConflictDeps,
branch: string,
path: string,
taskId: string,
startPoint?: string,
attemptNumber = 0,
recoveryDepth = 0,
allowSiblingBranchRename = false,
settings: Partial<Settings> = {},
): Promise<{ path: string; branch: string }> {
// Guard: refuse to create a worktree nested inside another worktree.
// Nested worktrees happen when the executor is launched with rootDir pointed
// at a worktree directory instead of the main repo — produces paths like
// `.worktrees/green-finch/.worktrees/amber-panda` that bloat the filesystem
// and confuse every tool that walks git state.
await assertWorktreePathNotNested(deps.rootDir, deps.store, path, taskId);
const installGuardOrCleanup = async () => {
try {
await installTaskWorktreeIdentityGuard({
worktreePath: path,
taskId,
expectedBranch: branch,
commitMsgHookEnabled: settings.commitMsgHookEnabled,
taskPrefix: settings.taskPrefix,
taskAttributionTrailerName: settings.taskAttributionTrailerNames?.[0],
commitAuthorEnabled: settings.commitAuthorEnabled,
commitAuthorName: settings.commitAuthorName,
commitAuthorEmail: settings.commitAuthorEmail,
});
} catch (error) {
try {
await rm(path, { recursive: true, force: true });
} catch {
executorLog.log(`Warning: failed to remove worktree after identity-guard install failure: ${path}`);
}
throw error;
}
};
// If directory exists but is not a registered worktree, remove it first
if (existsSync(path)) {
const isRegistered = await isRegisteredWorktree(deps.rootDir, path);
if (!isRegistered) {
await deps.store.logEntry(
taskId,
`Removing existing directory (not a registered worktree): ${path}`,
);
try {
await rm(path, { recursive: true, force: true });
} catch (e: unknown) {
const eMessage = e instanceof Error ? e.message : String(e);
throw new Error(`Failed to remove existing directory ${path}: ${eMessage}`);
}
} else {
executorLog.debug(`Worktree already exists: ${path}`);
await installGuardOrCleanup();
return { path, branch };
}
}
const createWithBranch = async (branchToCreate: string) => {
const cmd = startPoint
? `git worktree add -b "${branchToCreate}" "${path}" "${startPoint}"`
: `git worktree add -b "${branchToCreate}" "${path}"`;
try {
await execAsync(cmd, { cwd: deps.rootDir });
} catch (err) {
// Remove any partial directory left behind so the invariant holds:
// "if .worktrees/<slug> exists on disk, it is a fully registered git worktree."
try {
await rm(path, { recursive: true, force: true });
} catch {
// best-effort cleanup; log but don't mask the original error
executorLog.log(`Warning: failed to remove partial worktree directory after creation failure: ${path}`);
}
throw err;
}
};
const createFromExistingBranch = async () => {
try {
await execAsync(`git worktree add "${path}" "${branch}"`, { cwd: deps.rootDir });
} catch (err) {
// Remove any partial directory left behind so the invariant holds:
// "if .worktrees/<slug> exists on disk, it is a fully registered git worktree."
try {
await rm(path, { recursive: true, force: true });
} catch {
// best-effort cleanup; log but don't mask the original error
executorLog.log(`Warning: failed to remove partial worktree directory after creation failure: ${path}`);
}
throw err;
}
};
let staleLockRecoveryAttempted = false;
let staleRegistrationRecoveryAttempted = false;
try {
await createWithBranch(branch);
executorLog.log(`Worktree created: ${path}${startPoint ? ` (from ${startPoint})` : ""}`);
if (attemptNumber > 0) {
await deps.store.logEntry(taskId, `Worktree created on attempt ${attemptNumber + 1}`, path);
}
await installGuardOrCleanup();
return { path, branch };
} catch (initialError: unknown) {
const conflictInfo = extractWorktreeConflictInfo(initialError);
if (conflictInfo.type === "index-lock-contention" && !staleLockRecoveryAttempted) {
staleLockRecoveryAttempted = true;
const recovered = await deps.recoverIndexLockIfStale(taskId, path, conflictInfo);
if (recovered) {
await createWithBranch(branch);
executorLog.log(`Worktree created after stale lock recovery: ${path}`);
await installGuardOrCleanup();
return { path, branch };
}
}
if (conflictInfo.type === "stale-registration" && !staleRegistrationRecoveryAttempted) {
staleRegistrationRecoveryAttempted = true;
const recovered = await deps.recoverStaleRegistration(taskId, path, conflictInfo);
if (recovered) {
await createWithBranch(branch);
executorLog.log(`Worktree created after stale registration recovery: ${path}`);
await installGuardOrCleanup();
return { path, branch };
}
}
if (conflictInfo.type === "not-git-repo") {
throw new NonRetryableWorktreeError(
"Project directory is not a Git repository. Fusion requires a Git repository for worktree creation. Initialize with 'git init' or run from a Git project directory.",
);
}
// Handle "already used by worktree" conflict
if (conflictInfo.type === "already-used" && conflictInfo.path) {
const result = await deps.handleWorktreeConflict(
conflictInfo.path,
branch,
path,
taskId,
startPoint,
attemptNumber,
allowSiblingBranchRename,
settings,
);
if (result) {
return result;
}
throw new Error(
`Worktree conflict at ${conflictInfo.path}: automatic cleanup failed`,
);
}
// Handle "invalid reference" - stale branch that doesn't exist
if (conflictInfo.type === "invalid-reference") {
if (recoveryDepth >= deps.maxWorktreeRetries - 1) {
throw new NonRetryableWorktreeError(
`Stale branch reference for ${branch} remained invalid after ${deps.maxWorktreeRetries} cleanup attempts`,
);
}
const branchCleaned = await deps.cleanupStaleBranch(branch, taskId);
if (branchCleaned) {
await deps.store.logEntry(taskId, `Removed stale branch reference, retrying`);
return deps.tryCreateWorktree(branch, path, taskId, startPoint, attemptNumber, recoveryDepth + 1, allowSiblingBranchRename, settings);
}
throw new Error(
`Invalid reference for branch ${branch}: unable to clean up stale reference`,
);
}
// Handle "could not create leading directories" - permission/path issues
if (conflictInfo.type === "leading-directories") {
throw new Error(
`Cannot create worktree at ${path}: permission or path issue. ` +
`Check that parent directories are writable.`,
);
}
// Try creating from existing branch (branch might already exist)
try {
await createFromExistingBranch();
executorLog.log(`Worktree created from existing branch: ${path}`);
await installGuardOrCleanup();
return { path, branch };
} catch (fallbackError: unknown) {
const fallbackErrorMessage = fallbackError instanceof Error ? fallbackError.message : String(fallbackError);
// Check if the fallback also hit an "already used" conflict
const fallbackConflictInfo = extractWorktreeConflictInfo(fallbackError);
if (fallbackConflictInfo.type === "index-lock-contention" && !staleLockRecoveryAttempted) {
staleLockRecoveryAttempted = true;
const recovered = await deps.recoverIndexLockIfStale(taskId, path, fallbackConflictInfo);
if (recovered) {
await createFromExistingBranch();
executorLog.log(`Worktree created from existing branch after stale lock recovery: ${path}`);
await installGuardOrCleanup();
return { path, branch };
}
}
if (fallbackConflictInfo.type === "stale-registration" && !staleRegistrationRecoveryAttempted) {
staleRegistrationRecoveryAttempted = true;
const recovered = await deps.recoverStaleRegistration(taskId, path, fallbackConflictInfo);
if (recovered) {
await createFromExistingBranch();
executorLog.log(`Worktree created from existing branch after stale registration recovery: ${path}`);
await installGuardOrCleanup();
return { path, branch };
}
}
if (fallbackConflictInfo.type === "not-git-repo") {
throw new NonRetryableWorktreeError(
"Project directory is not a Git repository. Fusion requires a Git repository for worktree creation. Initialize with 'git init' or run from a Git project directory.",
);
}
if (fallbackConflictInfo.type === "already-used" && fallbackConflictInfo.path) {
const result = await deps.handleWorktreeConflict(
fallbackConflictInfo.path,
branch,
path,
taskId,
startPoint,
attemptNumber,
allowSiblingBranchRename,
settings,
);
if (result) {
return result;
}
throw new Error(
`Worktree conflict at ${fallbackConflictInfo.path}: automatic cleanup failed`,
);
}
// Handle stale reference in fallback path too
if (fallbackConflictInfo.type === "invalid-reference") {
if (recoveryDepth >= deps.maxWorktreeRetries - 1) {
throw new NonRetryableWorktreeError(
`Stale branch reference for ${branch} remained invalid after ${deps.maxWorktreeRetries} cleanup attempts`,
);
}
const branchCleaned = await deps.cleanupStaleBranch(branch, taskId);
if (branchCleaned) {
await deps.store.logEntry(taskId, `Cleaned up stale reference in fallback, retrying`);
return deps.tryCreateWorktree(branch, path, taskId, startPoint, attemptNumber, recoveryDepth + 1, allowSiblingBranchRename, settings);
}
}
throw new Error(`Failed to create worktree: ${fallbackErrorMessage}`);
}
}
}
/**
* Handle "already used by worktree" conflict.
* Either generates a new worktree name (if conflicting worktree is in use by active task)
* or cleans up the conflicting worktree and retries.
*
* @returns The worktree path if recovery succeeded, null if recovery failed
*/
export async function handleWorktreeConflict(
deps: WorktreeCreateConflictDeps,
conflictPath: string,
branch: string,
path: string,
taskId: string,
startPoint?: string,
attemptNumber?: number,
allowSiblingBranchRename = false,
settings: Partial<Settings> = {},
): Promise<{ path: string; branch: string } | null> {
const tryFreshFallback = () => deps.tryFreshWorktreeAfterLiveConflict({
conflictPath,
branch,
taskId,
startPoint,
attemptNumber,
allowSiblingBranchRename,
settings,
});
const shouldGenerateNewName = await deps.shouldGenerateNewWorktreeName(
conflictPath,
taskId,
);
/*
* FNXC:ExecutorWorktree 2026-07-18-17:20:
* Inspect every branch/worktree collision before cleanup, including inactive
* same-task bindings. The old inactive path skipped inspection and called
* cleanupConflictingWorktree directly, which force-deleted a branch carrying
* completed task commits during workflow-node recovery. Liveness determines
* whether a sibling checkout is needed; it must never determine whether task
* history is disposable.
*/
const inspection = await inspectBranchConflict({
repoDir: deps.rootDir,
branchName: branch,
conflictingWorktreePath: conflictPath,
requestingTaskId: taskId,
ownerTaskId: taskId,
startPoint,
integrationRef: await resolveIntegrationBranch(deps.rootDir, settings),
});
if (inspection.kind === "reclaimable") {
const livePath = isInsideWorktreesDir(deps.rootDir, inspection.livePath, settings)
? inspection.livePath
: await deps.normalizeReclaimableWorktreePath(inspection.livePath, path, taskId, settings);
await deps.store.logEntry(
taskId,
`[recovery] reclaimed existing worktree for ${taskId} at ${livePath} (${inspection.taskAttributedCommitCount} commits preserved)`,
inspection.tipSha,
);
return { path: livePath, branch };
}
if (inspection.kind === "fully-subsumed") {
const livePath = isInsideWorktreesDir(deps.rootDir, inspection.livePath, settings)
? inspection.livePath
: await deps.normalizeReclaimableWorktreePath(inspection.livePath, path, taskId, settings);
await deps.store.logEntry(
taskId,
`[recovery] reclaimed existing worktree for ${taskId} at ${livePath} (0 commits preserved)`,
inspection.tipSha,
);
return { path: livePath, branch };
}
if (shouldGenerateNewName) {
if (inspection.kind === "stale" || inspection.kind === "stale-resolved" || inspection.kind === "tip-already-merged") {
const cleanupSuccess = await deps.cleanupConflictingWorktree(conflictPath, branch, taskId);
if (cleanupSuccess) {
await deps.store.logEntry(taskId, `Cleaned up conflicting worktree, retrying`, path);
return deps.tryCreateWorktree(branch, path, taskId, startPoint, attemptNumber, 0, allowSiblingBranchRename, settings);
}
// FN-4811: When git classifies a worktree as stale but the DB liveness gate refuses
// removal (an active task still has this worktree bound), fall through to the
// sibling-rename path rather than failing the whole conflict-recovery attempt. This
// preserves the live task while letting the requesting task proceed with a fresh
// worktree name.
}
if (inspection.kind === "live-foreign") {
const cleanupSuccess = await deps.cleanupConflictingWorktree(inspection.livePath, branch, taskId);
if (cleanupSuccess) {
await deps.store.logEntry(taskId, `Removed foreign conflicting worktree and retrying`, inspection.livePath);
return deps.tryCreateWorktree(branch, path, taskId, startPoint, attemptNumber, 0, allowSiblingBranchRename, settings);
}
// FN-4811: Cleanup was refused because the foreign worktree is actively bound to a
// live session. Force-removing would yank an active task's filesystem. Fall through
// to the sibling-rename path (suffix-2 through suffix-6) so the requesting task can
// proceed without disturbing the live owner. If sibling-rename is disabled, the
// generic conflict error below will trigger the caller's auto-recovery dispatcher.
}
if (!allowSiblingBranchRename) {
throw new Error(`Branch ${branch} conflict could not be auto-resolved`);
}
return tryFreshFallback();
}
const cleanupSuccess = await deps.cleanupConflictingWorktree(conflictPath, branch, taskId);
if (cleanupSuccess) {
await deps.store.logEntry(taskId, `Cleaned up conflicting worktree, retrying`, path);
return deps.tryCreateWorktree(branch, path, taskId, startPoint, attemptNumber, 0, allowSiblingBranchRename, settings);
}
if (await deps.isLiveCleanupRefusal(conflictPath, taskId)) {
return tryFreshFallback();
}
return null;
}