From 2c3a777bcaefe5ca0e12e30bff3296b3bcaf3136 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 16 Jul 2026 13:48:00 -0700 Subject: [PATCH] FN-8132: recover bare worktree branch collisions Recover safe worktree creation when a dangling task branch already exists. - Classify bare branch collisions and preserve foreign or mixed commit history. - Reuse task-owned branches or recreate merged branches from the pinned start point. - Audit recovery outcomes and cover native, fallback, and workspace acquisition paths. Files changed: .../fn-8132-worktree-branch-collision-recovery.md | 7 ++ docs/architecture.md | 1 + .../__tests__/worktree-acquisition-backend.test.ts | 69 +++++++++++ .../worktree-acquisition-workspace.test.ts | 21 ++++ .../worktree-backend-branch-collision.test.ts | 132 +++++++++++++++++++++ packages/engine/src/branch-conflicts.ts | 121 +++++++++++++++++++ packages/engine/src/run-audit.ts | 1 + packages/engine/src/worktree-acquisition.ts | 3 +- packages/engine/src/worktree-backend.ts | 94 +++++++++++++++- 9 files changed, 447 insertions(+), 2 deletions(-) Fusion-Task-Id: FN-8132 Fusion-Task-Lineage: f09d140f-4fcd-48a6-99b1-a351630f37bd Co-authored-by: Fusion (runfusion.ai) --- ...8132-worktree-branch-collision-recovery.md | 7 + docs/architecture.md | 1 + .../worktree-acquisition-backend.test.ts | 69 +++++++++ .../worktree-acquisition-workspace.test.ts | 21 +++ .../worktree-backend-branch-collision.test.ts | 132 ++++++++++++++++++ packages/engine/src/branch-conflicts.ts | 121 ++++++++++++++++ packages/engine/src/run-audit.ts | 1 + packages/engine/src/worktree-acquisition.ts | 3 +- packages/engine/src/worktree-backend.ts | 94 ++++++++++++- 9 files changed, 447 insertions(+), 2 deletions(-) create mode 100644 .changeset/fn-8132-worktree-branch-collision-recovery.md create mode 100644 packages/engine/src/__tests__/worktree-backend-branch-collision.test.ts diff --git a/.changeset/fn-8132-worktree-branch-collision-recovery.md b/.changeset/fn-8132-worktree-branch-collision-recovery.md new file mode 100644 index 0000000000..8451e5d214 --- /dev/null +++ b/.changeset/fn-8132-worktree-branch-collision-recovery.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix tasks stalling when a leftover git branch collided with a new worktree. +category: fix +dev: NativeWorktreeBackend.create now runs a collision-specific classifier that reconciles a bare "branch already exists" collision (reuse reclaimable branches, recreate merged/orphaned branches from the pinned start point) instead of re-throwing; unmerged foreign/unattributed branches are preserved and live-foreign branches still raise BranchConflictError. diff --git a/docs/architecture.md b/docs/architecture.md index 87745c50a5..e979185f0e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2213,6 +2213,7 @@ This section preserves the detailed lifecycle/self-healing contracts that were f - **Merge-seam abort provenance (FN-6568/FN-6735/FN-7749)**: workflow graph merge-node failures must not be classified as pause/resume aborts merely because the merge seam hard-canceled an in-flight session. `TaskExecutor` tracks paused-abort provenance separately (`global-pause`, `merge-seam`, `hard-cancel`); genuine user/global pauses still preserve FN-6478/FN-5147 parking, while non-paused merge-seam graph failures (`merge`, `requestMerge`, built-in merge-region node ids, `merge-manual-hold`, and `merge-retry`) route back into the bounded auto-merge retry path instead of being parked `status:"failed"` with `mergeRetries=NULL`. Benign pause/resume aborts at these seams are retryable when the task is already `in-review`, has no durable failure/status, has not confirmed a merge, remains auto-merge eligible (or is a shared-branch local integration), and has merge retries remaining. When auto-merge is off (`settings.autoMerge:false` or an explicit task-level `autoMerge:false`), a benign hard-cancel at a non-terminal merge-region/manual-hold node is instead preserved cleanly in `in-review` for human Merge & Close; stale pause-abort status/error of this exact shape is cleared in place and never requeued. Conflict/contamination/foreign-work/retry-exhaustion values, pre-existing failures, global/user pauses, shared-branch member integrations, and post-confirmation partial landings retain their existing terminal/retry/finalize behavior. - **Worktree pool exclusivity (FN-4954)**: `WorktreePool.acquire(taskId)` / `release(path, taskId?)` track a `leased` map so every pooled path is either idle or leased, never both. Cross-task double-lease detection throws `PoolDoubleLeaseError` and emits `worktree:pool-double-lease-detected`; merger Step 8 now detaches HEAD and clears `task.worktree` / `task.branch` before releasing paths back to the pool. - **Stale registration recovery (FN-5056)**: `NativeWorktreeBackend.create` and `executor.tryCreateWorktree` detect `missing but already registered worktree` failures, run `git worktree prune` (plus `remove --force` / `add -f` fallbacks) before retrying, and emit `worktree:stale-registration-{detected,recovered,recovery-failed}` audit events. +- **Bare branch-collision recovery (FN-8132)**: after stale lock/registration recovery, `NativeWorktreeBackend.create` classifies a `git worktree add -b` “branch already exists” error even when its requested target path is absent. It attaches an unregistered branch only when every unique commit is attributed to the requesting task, recreates merged/subsumed or no-unique-work branches from the caller-pinned start point, and refuses foreign, unattributed, or mixed unique history without moving its ref. A live foreign worktree remains a `BranchConflictError`; recovery dispositions emit `worktree:branch-collision-recovery`. - **Raw worktree deletion must be paired with prune (FN-5058)**: any direct filesystem deletion of a worktree directory (`rm -rf` / `rmSync`) must be followed by best-effort `git worktree prune` via `pruneWorktreeAdminEntries` so `.git/worktrees/*` admin entries are not stranded in a missing-but-registered state (FN-5056 class). - **Meta-task auto-archive safety guards (FN-5064)**: `auto-archive-meta-resolved`/`auto-archive-meta-stalled` must skip archival (with `task:auto-archive-meta-*-skipped` audits) whenever guard checks detect substantive work signals such as unique branch commits, recent executor activity, pending `taskDoneRetryCount`, merge-in-progress state, or active worktree session. The corresponding `task:auto-archive-meta-resolved-skipped` and `task:auto-archive-meta-stalled-skipped` run-audit rows are transition-only per task+guard-reason signature: emit once on first skip, suppress repeated sweeps while the same reasons persist, clear when the skip no longer applies, and re-emit if a different reason later blocks archival. - **Scheduler fanout tiebreaker (FN-4969)**: within the same priority class, scheduler dispatch prefers runnable `todo` tasks with the highest active dependency-dependent fanout; `urgent` always outranks lower priorities regardless of fanout, and `overlapBlockedBy`/file-scope overlap blockers are excluded from unblock weight. diff --git a/packages/engine/src/__tests__/worktree-acquisition-backend.test.ts b/packages/engine/src/__tests__/worktree-acquisition-backend.test.ts index 573ac6df15..ba30aeeb75 100644 --- a/packages/engine/src/__tests__/worktree-acquisition-backend.test.ts +++ b/packages/engine/src/__tests__/worktree-acquisition-backend.test.ts @@ -123,6 +123,36 @@ describe("acquireTaskWorktree backend wiring", () => { ); }); + it("passes audit into worktrunk-to-native fallback collision recovery", async () => { + let nativeAddAttempts = 0; + execMock.mockImplementation((command: string) => { + if (command.includes('"wt" "switch" "--create"')) { + return Promise.reject({ stderr: "worktrunk create failed", status: 1 }); + } + if (command.startsWith("git worktree add -b")) { + nativeAddAttempts += 1; + return nativeAddAttempts === 1 + ? Promise.reject({ message: "branch collision", stderr: "fatal: a branch named 'fusion/fn-1' already exists" }) + : Promise.resolve({ stdout: "", stderr: "" }); + } + if (command === "git worktree list --porcelain") return Promise.resolve({ stdout: "worktree /repo\nbranch refs/heads/main\n", stderr: "" }); + if (command.startsWith("git cherry")) return Promise.resolve({ stdout: "", stderr: "" }); + return Promise.resolve({ stdout: "deadbeef\n", stderr: "" }); + }); + const audit = { git: vi.fn().mockResolvedValue(undefined) }; + + await acquireTaskWorktree({ + task: { ...task, executionStartBranch: "release" }, rootDir: "/repo", store, + settings: { worktreeNaming: "task-id", worktrunk: { enabled: true, binaryPath: "wt", onFailure: "fallback-native" } } as any, audit: audit as any, + }); + + expect(nativeAddAttempts).toBe(2); + expect(audit.git).toHaveBeenCalledWith(expect.objectContaining({ + type: "worktree:branch-collision-recovery", + metadata: expect.objectContaining({ taskId: "FN-1", disposition: "recreate-from-startpoint" }), + })); + }); + it("throws worktrunk_binary_missing with no binaryPath", async () => { await expect( acquireTaskWorktree({ @@ -169,6 +199,45 @@ describe("acquireTaskWorktree backend wiring", () => { expect(execMock.mock.calls.some((call) => String(call[0]).includes(`"${explicitBinaryPath}" "switch" "--create"`))).toBe(true); }); + it("forwards canonical branch and pinned execution start point to an injected backend", async () => { + const create = vi.fn().mockResolvedValue({ path: "/tmp/backend", branch: "fusion/fn-1" }); + const backend: WorktreeBackend = { + kind: "native", create, remove: vi.fn(), sync: vi.fn().mockResolvedValue({ skipped: true as const }), prune: vi.fn(), + resolveWorktreePath: vi.fn().mockResolvedValue("/tmp/backend"), + }; + + await acquireTaskWorktree({ + task: { ...task, executionStartBranch: "release" }, rootDir: "/repo", store, + settings: { worktreeNaming: "task-id" } as any, backend, + }); + + expect(create).toHaveBeenCalledWith(expect.objectContaining({ + branch: "fusion/fn-1", startPoint: "release", taskId: "FN-1", + })); + }); + + it("forwards canonical branch and pinned start point on pool fresh fallback", async () => { + const create = vi.fn().mockResolvedValue({ path: "/tmp/fresh", branch: "fusion/fn-1" }); + const backend: WorktreeBackend = { + kind: "native", create, remove: vi.fn(), sync: vi.fn().mockResolvedValue({ skipped: true as const }), prune: vi.fn(), + resolveWorktreePath: vi.fn().mockResolvedValue("/tmp/fresh"), + }; + const pool = { + acquire: vi.fn().mockReturnValue("/tmp/pooled"), + prepareForTask: vi.fn().mockRejectedValue(new Error("pool unavailable")), + release: vi.fn(), + }; + + await acquireTaskWorktree({ + task: { ...task, executionStartBranch: "release" }, rootDir: "/repo", store, + settings: { worktreeNaming: "task-id", recycleWorktrees: true } as any, backend, pool: pool as any, + }); + + expect(create).toHaveBeenCalledWith(expect.objectContaining({ + branch: "fusion/fn-1", startPoint: "release", taskId: "FN-1", + })); + }); + it("uses explicit backend override", async () => { const create = vi.fn().mockResolvedValue({ path: "/tmp/backend", branch: "fusion/fn-backend" }); const backend: WorktreeBackend = { diff --git a/packages/engine/src/__tests__/worktree-acquisition-workspace.test.ts b/packages/engine/src/__tests__/worktree-acquisition-workspace.test.ts index 3267157db7..e60e88609b 100644 --- a/packages/engine/src/__tests__/worktree-acquisition-workspace.test.ts +++ b/packages/engine/src/__tests__/worktree-acquisition-workspace.test.ts @@ -133,6 +133,27 @@ describeIfGit("acquireWorkspaceRepoWorktree (U2 per-repo hardening)", { timeout: expect(result.baseCommitSha).toBe(developTip); }); + it("reconciles a sub-repo dangling collision branch from its resolved integration tip", async () => { + fixture = await createWorkspaceFixture(["repo-a"]); + const repoA = fixture.repoPath("repo-a"); + const mainTip = git(repoA, "git rev-parse main"); + // Leave ambient HEAD on unrelated work to prove fresh acquisition uses the + // sub-repo integration branch, not whichever branch the root currently has checked out. + git(repoA, "git checkout -qb ambient-work"); + git(repoA, "git commit --allow-empty -m 'chore: ambient work'"); + git(repoA, "git branch fusion/fn-7 main"); + + const { store, current } = makeFakeStore(makeTask("FN-7")); + const result = await acquireWorkspaceRepoWorktree({ + repoRelPath: "repo-a", workspaceRootDir: fixture.rootDir, task: current(), store, + settings: SETTINGS, registry: new ActiveSessionRegistry(), + }); + + expect(result.branch).toBe("fusion/fn-7"); + expect(git(repoA, "git rev-parse fusion/fn-7")).toBe(mainTip); + expect(git(repoA, "git merge-base fusion/fn-7 main")).toBe(mainTip); + }); + it("installs the identity-guard hook so a commit on a non-fusion branch is rejected", async () => { fixture = await createWorkspaceFixture(["repo-a"]); const { store, current } = makeFakeStore(makeTask("FN-3")); diff --git a/packages/engine/src/__tests__/worktree-backend-branch-collision.test.ts b/packages/engine/src/__tests__/worktree-backend-branch-collision.test.ts new file mode 100644 index 0000000000..b59badd6de --- /dev/null +++ b/packages/engine/src/__tests__/worktree-backend-branch-collision.test.ts @@ -0,0 +1,132 @@ +import { execSync } from "node:child_process"; +import { existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { BranchConflictError } from "../branch-conflicts.js"; +import { NativeWorktreeBackend } from "../worktree-backend.js"; + +function git(repo: string, command: string): string { + return execSync(command, { cwd: repo, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim(); +} + +function assertRegisteredWorktree(repo: string, worktreePath: string, branch: string): void { + const porcelain = git(repo, "git worktree list --porcelain"); + expect(porcelain).toContain(`worktree ${realpathSync(worktreePath)}`); + expect(porcelain).toContain(`branch refs/heads/${branch}`); +} + +describe("NativeWorktreeBackend bare branch collision recovery", { timeout: 60_000 }, () => { + const dirs: string[] = []; + + afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); + }); + + function setup(): string { + const repo = mkdtempSync(join(tmpdir(), "fn-8132-collision-")); + dirs.push(repo); + git(repo, "git init -q -b main"); + git(repo, 'git config user.email "test@example.com"'); + git(repo, 'git config user.name "Test User"'); + writeFileSync(join(repo, "base.txt"), "base\n"); + git(repo, "git add base.txt && git commit -qm base"); + return repo; + } + + function commit(repo: string, file: string, message: string): void { + writeFileSync(join(repo, file), `${message}\n`); + git(repo, `git add ${JSON.stringify(file)} && git commit -m ${JSON.stringify(message)}`); + } + + async function create(repo: string, branch: string, taskId: string, allowSiblingBranchRename = false) { + const target = join(repo, ".worktrees", `target-${taskId.toLowerCase()}`); + const events: any[] = []; + const result = await new NativeWorktreeBackend({ audit: { git: async (event: any) => { events.push(event); } } as any }).create({ + rootDir: repo, + branch, + worktreePath: target, + startPoint: "main", + taskId, + allowSiblingBranchRename, + }); + return { result, target, events }; + } + + it("recreates a dangling canonical branch from main without creating a sibling", async () => { + const repo = setup(); + git(repo, "git branch fusion/fn-100 main"); + + const { result, target, events } = await create(repo, "fusion/fn-100", "FN-100"); + + expect(result).toEqual({ path: target, branch: "fusion/fn-100" }); + assertRegisteredWorktree(repo, target, "fusion/fn-100"); + expect(git(repo, "git branch --list fusion/fn-100-2")).toBe(""); + const recovery = events.find((event) => event.type === "worktree:branch-collision-recovery"); + expect(recovery).toMatchObject({ target, metadata: { taskId: "FN-100", disposition: "recreate-from-startpoint" } }); + expect(Object.keys(recovery.metadata).sort()).toEqual(["disposition", "taskId"]); + }); + + it("recreates fully subsumed branch history from the pinned start point", async () => { + const repo = setup(); + git(repo, "git checkout -qb fusion/fn-101 main"); + commit(repo, "subsumed.txt", "feat(FN-101): represented upstream"); + const branchTip = git(repo, "git rev-parse HEAD"); + git(repo, "git checkout -q main"); + git(repo, `git cherry-pick ${branchTip}`); + const mainTip = git(repo, "git rev-parse main"); + + const { target } = await create(repo, "fusion/fn-101", "FN-101"); + + expect(git(repo, "git rev-parse fusion/fn-101")).toBe(mainTip); + assertRegisteredWorktree(repo, target, "fusion/fn-101"); + }); + + it("attaches a reclaimable branch and preserves exclusively task-attributed commits", async () => { + const repo = setup(); + git(repo, "git checkout -qb fusion/fn-102 main"); + commit(repo, "own.txt", "feat(FN-102): preserve own work\n\nFusion-Task-Id: FN-102"); + const tip = git(repo, "git rev-parse HEAD"); + git(repo, "git checkout -q main"); + + const { target } = await create(repo, "fusion/fn-102", "FN-102", true); + + expect(git(repo, "git rev-parse fusion/fn-102")).toBe(tip); + expect(git(target, "git log -1 --format=%s")).toContain("feat(FN-102): preserve own work"); + expect(git(repo, "git branch --list fusion/fn-102-2")).toBe(""); + }); + + it("preserves foreign and mixed unmerged histories rather than attaching or deleting", async () => { + const repo = setup(); + for (const [branch, taskId, messages] of [ + ["fusion/next-1378", "FN-103", ["feat(FN-999): foreign work"]], + ["fusion/fn-104", "FN-104", ["feat(FN-104): own work\n\nFusion-Task-Id: FN-104", "feat(FN-999): mixed foreign work"]], + ] as const) { + git(repo, `git checkout -qb ${branch} main`); + for (const [index, message] of messages.entries()) commit(repo, `${taskId}-${index}.txt`, message); + const tip = git(repo, "git rev-parse HEAD"); + git(repo, "git checkout -q main"); + const target = join(repo, ".worktrees", `refused-${taskId}`); + await expect(new NativeWorktreeBackend().create({ + rootDir: repo, branch, worktreePath: target, startPoint: "main", taskId, allowSiblingBranchRename: false, + })).rejects.toBeInstanceOf(BranchConflictError); + expect(git(repo, `git rev-parse ${branch}`)).toBe(tip); + expect(existsSync(target)).toBe(false); + } + }); + + it("refuses a live foreign checkout when the requested target path is absent", async () => { + const repo = setup(); + git(repo, "git branch fusion/fn-105 main"); + const foreignPath = join(repo, ".worktrees", "foreign"); + git(repo, `git worktree add ${JSON.stringify(foreignPath)} fusion/fn-105`); + const tip = git(repo, "git rev-parse fusion/fn-105"); + const target = join(repo, ".worktrees", "missing-target"); + + await expect(new NativeWorktreeBackend().create({ + rootDir: repo, branch: "fusion/fn-105", worktreePath: target, startPoint: "main", taskId: "FN-105", allowSiblingBranchRename: false, + })).rejects.toBeInstanceOf(BranchConflictError); + expect(existsSync(target)).toBe(false); + expect(git(repo, "git rev-parse fusion/fn-105")).toBe(tip); + }); +}); diff --git a/packages/engine/src/branch-conflicts.ts b/packages/engine/src/branch-conflicts.ts index b707933928..b36b1e5785 100644 --- a/packages/engine/src/branch-conflicts.ts +++ b/packages/engine/src/branch-conflicts.ts @@ -101,6 +101,22 @@ export type BranchConflictInspectionResult = | { kind: "reclaimable"; livePath: string; tipSha: string; taskAttributedCommitCount: number; strandedCommits: BranchConflictCommit[] } | { kind: "live-foreign"; livePath: string; error: BranchConflictError }; +/** + * FNXC:WorktreeAcquisition 2026-07-16-00:00: + * FN-8132 / #2232 needs a classifier for a bare `git worktree add -b` collision, + * where the requested path normally does not exist. Unlike inspectBranchConflict, + * this path must enumerate live worktrees before considering branch recovery: + * only exclusively task-attributed unique commits are reclaimable; any foreign or + * unattributed unique commit, including mixed history, remains protected. + */ +export type BareBranchCollisionInspectionResult = + | { kind: "missing" } + | { kind: "tip-already-merged"; tipSha: string; integrationRef: string } + | { kind: "fully-subsumed"; tipSha: string } + | { kind: "reclaimable"; tipSha: string; taskAttributedCommitCount: number; uniqueCommitCount: number } + | { kind: "foreign-unmerged"; tipSha: string; uniqueCommitCount: number; error: BranchConflictError } + | { kind: "live-foreign"; tipSha: string; error: BranchConflictError }; + interface UniqueBranchCommitListResult { commits: BranchConflictCommit[]; mainRef: string; @@ -931,6 +947,111 @@ async function isZeroUniqueCommitBranchViaPatchIdFallback( return true; } +/** + * Inspect a branch-name collision from `git worktree add -b` without requiring + * the requested destination path to exist. Existing callers must use + * inspectBranchConflict, whose missing-path short-circuit is intentionally kept. + */ +export async function inspectBareBranchCollision( + input: InspectBranchConflictInput, +): Promise { + const startPoint = input.startPoint ?? "HEAD"; + + try { + await runGit(input.repoDir, "git worktree prune"); + } catch { + // Best-effort: the mapping check below still protects a registered live worktree. + } + + try { + await revParse(input.repoDir, `refs/heads/${input.branchName}`); + } catch { + return { kind: "missing" }; + } + + let worktreeMap = await getWorktreeBranchMap(input.repoDir); + let livePath = worktreeMap.get(input.branchName); + if (livePath && !existsSync(livePath)) { + try { + await runGit(input.repoDir, "git worktree prune"); + } catch { + // Best-effort: a still-present mapping is not considered live below. + } + worktreeMap = await getWorktreeBranchMap(input.repoDir); + livePath = worktreeMap.get(input.branchName); + } + + const tipSha = await revParse(input.repoDir, input.branchName); + const uniqueCommitResult = await listUniqueBranchCommits(input.repoDir, startPoint, input.branchName); + const requestedIntegrationRef = input.integrationRef ?? await resolveIntegrationBranch(input.repoDir, undefined); + const integrationRef = await resolveBranchComparisonRef(input.repoDir, requestedIntegrationRef, input.branchName); + + if (livePath && existsSync(livePath)) { + return { + kind: "live-foreign", + tipSha, + error: new BranchConflictError({ + branchName: input.branchName, + conflictingWorktreePath: livePath, + existingTipSha: tipSha, + strandedCommits: uniqueCommitResult.commits, + startPoint: uniqueCommitResult.mainRef, + recommendedAction: "Inspect the live conflicting worktree before retrying.", + }), + }; + } + + if (await isAncestor(input.repoDir, tipSha, integrationRef)) { + return { kind: "tip-already-merged", tipSha, integrationRef }; + } + + const zeroUnique = uniqueCommitResult.commits.length === 0 && ( + !uniqueCommitResult.degraded || await isZeroUniqueCommitBranchViaPatchIdFallback( + input.repoDir, + startPoint, + input.branchName, + uniqueCommitResult.mainRef, + ) + ); + if (zeroUnique) { + return { kind: "fully-subsumed", tipSha }; + } + + const attribution = await reportBranchAttribution( + input.repoDir, + input.branchName, + uniqueCommitResult.mainRef, + input.requestingTaskId, + ); + const taskAttributedCommitCount = attribution.ownTrailed + attribution.ownUntrailed.length; + const foreignOrUnattributedCount = attribution.foreign.length + attribution.unattributed.length; + if ( + taskAttributedCommitCount === uniqueCommitResult.commits.length + && foreignOrUnattributedCount === 0 + ) { + return { + kind: "reclaimable", + tipSha, + taskAttributedCommitCount, + uniqueCommitCount: uniqueCommitResult.commits.length, + }; + } + + return { + kind: "foreign-unmerged", + tipSha, + uniqueCommitCount: uniqueCommitResult.commits.length, + error: new BranchConflictError({ + branchName: input.branchName, + conflictingWorktreePath: input.conflictingWorktreePath, + existingTipSha: tipSha, + strandedCommits: uniqueCommitResult.commits, + startPoint: uniqueCommitResult.mainRef, + recommendedAction: "Preserve this unregistered branch and inspect its foreign or unattributed commits before retrying.", + }), + }; +} + export async function inspectBranchConflict( input: InspectBranchConflictInput, ): Promise { diff --git a/packages/engine/src/run-audit.ts b/packages/engine/src/run-audit.ts index ab1c0e32cb..6e9dea24b0 100644 --- a/packages/engine/src/run-audit.ts +++ b/packages/engine/src/run-audit.ts @@ -152,6 +152,7 @@ export type GitMutationType = | "worktree:stale-registration-detected" | "worktree:stale-registration-recovered" | "worktree:stale-registration-recovery-failed" + | "worktree:branch-collision-recovery" | "branch:create" | "branch:delete" | "branch:checkout" diff --git a/packages/engine/src/worktree-acquisition.ts b/packages/engine/src/worktree-acquisition.ts index 89159b3c09..575c9f9010 100644 --- a/packages/engine/src/worktree-acquisition.ts +++ b/packages/engine/src/worktree-acquisition.ts @@ -302,7 +302,8 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro return created; } catch (error) { if (backend.kind === "worktrunk" && error instanceof WorktrunkOperationError) { - const nativeBackend = new NativeWorktreeBackend({ logger: logger ?? undefined }); + // FNXC:WorktreeAcquisition 2026-07-16-00:00: FN-8132 requires native fallback collision dispositions to be audited just like direct native acquisition. + const nativeBackend = new NativeWorktreeBackend({ logger: logger ?? undefined, audit }); const fallback = () => nativeBackend.create({ rootDir, branch: createBranch, diff --git a/packages/engine/src/worktree-backend.ts b/packages/engine/src/worktree-backend.ts index 5f93543534..c5da44efad 100644 --- a/packages/engine/src/worktree-backend.ts +++ b/packages/engine/src/worktree-backend.ts @@ -12,7 +12,7 @@ import { } from "./active-session-registry.js"; import type { RunAuditor } from "./run-audit.js"; import { resolveTaskWorktreePath } from "./worktree-paths.js"; -import { inspectBranchConflict } from "./branch-conflicts.js"; +import { inspectBareBranchCollision, inspectBranchConflict } from "./branch-conflicts.js"; import { resolveIntegrationBranch } from "./integration-branch.js"; import { formatError } from "./logger.js"; import { installTaskWorktreeIdentityGuard } from "./worktree-hooks.js"; @@ -392,6 +392,25 @@ export class NativeWorktreeBackend implements WorktreeBackend { }); return { path: input.worktreePath, branch: branchName }; }; + const attachExistingBranch = async (): Promise => { + await execAsync(`git worktree add ${quoteShellArg(input.worktreePath)} ${quoteShellArg(input.branch)}`, { + cwd: input.rootDir, + encoding: "utf-8", + timeout: NATIVE_TIMEOUT_MS, + maxBuffer: MAX_BUFFER, + }); + return { path: input.worktreePath, branch: input.branch }; + }; + const cleanupPartialCollisionRecovery = async () => { + await rm(input.worktreePath, { recursive: true, force: true }).catch(() => undefined); + await pruneWorktreeAdminEntries({ + rootDir: input.rootDir, + auditor: this.deps.audit, + reason: "backend-branch-collision-recovery-failed", + target: input.worktreePath, + logger: this.deps.logger, + }).catch(() => undefined); + }; let staleLockRecoveryAttempted = false; let staleRegistrationRecoveryAttempted = false; @@ -520,6 +539,79 @@ export class NativeWorktreeBackend implements WorktreeBackend { }); } + const isBareBranchCollision = /(?:a\s+)?branch named ["']?.+["']? already exists|branch ["']?.+["']? already exists/i.test(combinedErrorOutput); + if (isBareBranchCollision) { + /* + * FNXC:WorktreeAcquisition 2026-07-16-00:00: + * FN-8132 / #2232 recovers only a bare branch-name collision after the + * stale-lock and stale-registration ladder. A live foreign checkout still + * fails even when this target path is absent. Unregistered branches are + * attached only when every unique commit belongs to this task; merged or + * empty branches are recreated from the caller-pinned startPoint, while + * any foreign/unattributed (including mixed) history is never deleted. + */ + const inspection = await inspectBareBranchCollision({ + repoDir: input.rootDir, + branchName: input.branch, + conflictingWorktreePath: input.worktreePath, + requestingTaskId: input.taskId, + startPoint: input.startPoint, + integrationRef: await resolveIntegrationBranch(input.rootDir, undefined), + }); + if (inspection.kind === "live-foreign") { + await this.deps.audit?.git({ + type: "worktree:branch-collision-recovery", + target: input.worktreePath, + metadata: { taskId: input.taskId, disposition: "threw-live-foreign" }, + }); + throw inspection.error; + } + if (inspection.kind === "foreign-unmerged") { + await this.deps.audit?.git({ + type: "worktree:branch-collision-recovery", + target: input.worktreePath, + metadata: { taskId: input.taskId, disposition: "refused-foreign-unmerged", uniqueCommitCount: inspection.uniqueCommitCount }, + }); + throw inspection.error; + } + if (inspection.kind === "reclaimable") { + try { + const created = await attachExistingBranch(); + await installGuardOrCleanup(created.path); + await this.deps.audit?.git({ + type: "worktree:branch-collision-recovery", + target: input.worktreePath, + metadata: { taskId: input.taskId, disposition: "reuse-existing-branch", uniqueCommitCount: inspection.uniqueCommitCount }, + }); + return created; + } catch (recoveryError) { + await cleanupPartialCollisionRecovery(); + throw recoveryError; + } + } + if (inspection.kind === "tip-already-merged" || inspection.kind === "fully-subsumed") { + try { + await execAsync(`git branch -D ${quoteShellArg(input.branch)}`, { + cwd: input.rootDir, + encoding: "utf-8", + timeout: NATIVE_TIMEOUT_MS, + maxBuffer: MAX_BUFFER, + }); + const created = await createWithBranch(input.branch); + await installGuardOrCleanup(created.path); + await this.deps.audit?.git({ + type: "worktree:branch-collision-recovery", + target: input.worktreePath, + metadata: { taskId: input.taskId, disposition: "recreate-from-startpoint" }, + }); + return created; + } catch (recoveryError) { + await cleanupPartialCollisionRecovery(); + throw recoveryError; + } + } + } + if (!input.allowSiblingBranchRename) { throw error; }