From af6e671e72e0cca6942663e6b07e55090e357811 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 1 Jul 2026 10:55:57 -0700 Subject: [PATCH] FN-7385: preserve live worktrees with fresh fallback Preserve active worktree owners by retrying acquisition on a fresh sibling checkout. - Add executor fallback that detects active cleanup refusals and creates bounded sibling branches in fresh generated worktrees.\n- Cover DB-only, same-task workflow-step, existing-branch, and exhausted-suffix conflict paths with regression tests.\n- Document the live worktree conflict fallback and add a patch changeset for the published CLI package.\n\nFiles changed:\n .../fn-7385-active-worktree-fresh-fallback.md | 7 +\n docs/architecture.md | 1 +\n .../__tests__/executor-worktree-conflict.test.ts | 103 +++++++++++++-\n .../engine/src/__tests__/executor-worktree.test.ts | 153 ++++++++++++++++++---\n packages/engine/src/executor.ts | 90 ++++++++----\n 5 files changed, 312 insertions(+), 42 deletions(-) Fusion-Task-Id: FN-7385 Fusion-Task-Lineage: 02c31656-1248-49c0-9063-0750cc8e41c6 Co-authored-by: Fusion (runfusion.ai) --- .../fn-7385-active-worktree-fresh-fallback.md | 7 + docs/architecture.md | 1 + .../executor-worktree-conflict.test.ts | 103 +++++++++++- .../src/__tests__/executor-worktree.test.ts | 153 +++++++++++++++--- packages/engine/src/executor.ts | 90 ++++++++--- 5 files changed, 312 insertions(+), 42 deletions(-) create mode 100644 .changeset/fn-7385-active-worktree-fresh-fallback.md diff --git a/.changeset/fn-7385-active-worktree-fresh-fallback.md b/.changeset/fn-7385-active-worktree-fresh-fallback.md new file mode 100644 index 0000000000..b44a86b5e0 --- /dev/null +++ b/.changeset/fn-7385-active-worktree-fresh-fallback.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Recover live worktree conflicts by retrying with a fresh task worktree. +category: fix +dev: Executor worktree acquisition now preserves active-session conflict owners and retries bounded sibling branches instead of surfacing automatic cleanup failure. diff --git a/docs/architecture.md b/docs/architecture.md index 6915c88c2d..0efd85934b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1818,6 +1818,7 @@ This section preserves the detailed lifecycle/self-healing contracts that were f - **Executor pre-session liveness gate (FN-4935/FN-6861)**: the gate now skips for fresh acquisitions (`acquisition.source === "fresh"`), emits structured `not_usable_task_worktree:` diagnostics (including canonicalized registered-path snapshots) and a `worktree:incomplete-detected` audit event with `source: "executor-liveness-gate"`, while preserving the existing `taskDoneRetryCount` / `MAX_TASK_DONE_REQUEUE_RETRIES` requeue contract. The project repo root is never a usable task worktree even though it is a legitimately registered Git worktree; `classifyTaskWorktree` returns `repo-root` for canonical root-equal paths, and resume acquisition treats that as self-healable stale metadata by clearing `task.worktree` and creating a fresh checkout under the configured worktrees directory. FN-5772 adds a bounded nested-root self-heal: when `task.worktree` points at a strict descendant of a registered worktree root inside the configured worktrees dir, executor re-anchors `task.worktree` to the git top-level, emits `worktree:reanchored` (`fromPath`, `toPath`, `source`), and proceeds; repo-root/outside-dir/unregistered top-level mismatches still fail. FN-4651 `worktreeSessionRetryCount` remains scoped to the in-review/session-start recovery path. - **Stale self-owned active-session reconcile on conflict cleanup (FN-4973)**: when executor worktree-conflict cleanup finds only a same-task stale `activeSessionRegistry` entry and no live in-memory `activeWorktrees` binding for that task/path, it must unregister the stale entry before `removeWorktree` (plus one-shot backstop reconcile on same-task `ActiveSessionWorktreeRemovalError` races). Foreign-task entries remain protected by FN-4811 and must never be reconciled by the requesting task. - **Same-task stale removal canonical helper (FN-5346)**: executor same-task cleanup paths now route pre-removal reconciliation through `reconcileSelfOwnedActiveSessionForRemoval` (via executor helper wiring), so stale self-owned `activeSessionRegistry` residues are cleared only when no live in-memory binding exists, while FN-4811 foreign-owner refusals and live-owner protections remain intact. +- **Live worktree conflict fallback (FN-7385)**: when branch-conflict cleanup is refused because the conflicting path belongs to an active executor/workflow-step session (including same-task process-active sessions, foreign `activeWorktrees` owners, or DB-only live owners), executor acquisition must preserve that path and retry with a fresh generated worktree plus bounded sibling branch. Stale/non-live conflicts still use the existing cleanup/reclaim path, and unrecoverable non-active cleanup failures remain actionable errors. - **Task title/ID drift (FN-4898)**: active and archived title writes normalize foreign embedded `FN-NNN` tokens via `packages/core/src/task-title-id-drift.ts`. Empty placeholder groups (`()`, `[]`, `{}`) left behind by token stripping are also removed in both `normalizeTitleForTaskId` and `sanitizeTitle` (FN-4978). Lineage is preserved in `sourceParentTaskId` / description markers, not title embeds. FN-5077 extends drift normalization to reject dangling-connector fragments (`"Close as duplicate of"`) so token-stripped residuals never persist as task titles. - **PR-conflict reclaim wiring (FN-4763)**: GitHub PR refresh now persists normalized `prInfo.mergeable` conflict state and, when conflicting, funnels tasks into self-healing’s existing reclaim machinery (`reclaimPrConflictForTask` / `reclaim-pr-conflicts` stage) so branch-conflict handling stays centralized with existing `inspectBranchConflict` outcomes and unrecoverable pause semantics. PR refresh also captures `prInfo.conflictDiagnostics` (conflicting files + suggested local recovery commands) for dashboard surfacing. - **Worktrunk-managed lifecycles**: when `worktrunk.enabled`, self-healing defers prune/idle/worktree-cap sweeps to the worktrunk backend; branch-level stale/ conflict reclaim stays native. Orphan `fusion/*` branches are operator-managed via standard git tooling (no auto-rescue task filing). diff --git a/packages/engine/src/__tests__/executor-worktree-conflict.test.ts b/packages/engine/src/__tests__/executor-worktree-conflict.test.ts index adbc691c86..f2f0ff07e6 100644 --- a/packages/engine/src/__tests__/executor-worktree-conflict.test.ts +++ b/packages/engine/src/__tests__/executor-worktree-conflict.test.ts @@ -4,7 +4,7 @@ import { TaskExecutor } from "../executor.js"; import { activeSessionRegistry } from "../active-session-registry.js"; import { ActiveSessionWorktreeRemovalError } from "../worktree-backend.js"; import * as worktreePoolModule from "../worktree-pool.js"; -import { createMockStore, resetExecutorMocks } from "./executor-test-helpers.js"; +import { createMockStore, mockedGenerateWorktreeName, resetExecutorMocks } from "./executor-test-helpers.js"; const CONFLICT_PATH = "/tmp/test/.worktrees/stale-self-owned"; @@ -122,6 +122,107 @@ describe("FN-4973: executor worktree conflict cleanup", () => { ); }); + it("falls back to a fresh sibling branch for a DB-only live owner", async () => { + const store = createMockStore(); + store.getSettings.mockResolvedValue({ + maxConcurrent: 2, + maxWorktrees: 4, + pollIntervalMs: 15000, + groupOverlappingFiles: false, + autoMerge: false, + executorAllowSiblingBranchRename: true, + }); + store.listTasks.mockResolvedValue([ + { id: "FN-LIVE", worktree: CONFLICT_PATH, column: "in-progress", paused: false }, + ]); + mockedGenerateWorktreeName.mockReturnValueOnce("fresh-eagle"); + const executor = new TaskExecutor(store, "/tmp/test"); + const createSpy = vi.spyOn(executor as any, "tryCreateWorktree").mockResolvedValue({ + path: "/tmp/test/.worktrees/fresh-eagle", + branch: "fusion/fn-4973-2", + }); + + const result = await (executor as any).handleWorktreeConflict( + CONFLICT_PATH, + "fusion/fn-4973", + "/tmp/test/.worktrees/stale-self-owned", + "FN-4973", + "main", + 0, + true, + await store.getSettings(), + ); + + expect(result).toEqual({ path: "/tmp/test/.worktrees/fresh-eagle", branch: "fusion/fn-4973-2" }); + expect(createSpy).toHaveBeenCalledWith( + "fusion/fn-4973-2", + "/tmp/test/.worktrees/fresh-eagle", + "FN-4973", + "fusion/fn-4973", + 0, + 0, + true, + expect.any(Object), + ); + }); + + it("keeps non-live cleanup failures unrecoverable", async () => { + const store = createMockStore(); + const executor = new TaskExecutor(store, "/tmp/test"); + store.listTasks.mockResolvedValue([]); + vi.spyOn(executor as any, "cleanupConflictingWorktree").mockResolvedValue(false); + + const result = await (executor as any).handleWorktreeConflict( + CONFLICT_PATH, + "fusion/fn-4973", + "/tmp/test/.worktrees/stale-self-owned", + "FN-4973", + "main", + 0, + true, + await store.getSettings(), + ); + + expect(result).toBeNull(); + }); + + it("bounds fresh sibling fallback when every generated branch is already used", async () => { + const store = createMockStore(); + const executor = new TaskExecutor(store, "/tmp/test"); + store.listTasks.mockResolvedValue([]); + activeSessionRegistry.registerPath(CONFLICT_PATH, { taskId: "FN-4973", kind: "workflow-step", ownerKey: "FN-4973/workflow-step" }); + (executor as any).addActiveWorktree("FN-4973", CONFLICT_PATH); + mockedGenerateWorktreeName + .mockReturnValueOnce("fresh-2") + .mockReturnValueOnce("fresh-3") + .mockReturnValueOnce("fresh-4") + .mockReturnValueOnce("fresh-5") + .mockReturnValueOnce("fresh-6"); + vi.spyOn(worktreePoolModule, "removeWorktree").mockRejectedValue( + new ActiveSessionWorktreeRemovalError({ + worktreePath: CONFLICT_PATH, + taskId: "FN-4973", + kind: "workflow-step", + ownerKey: "FN-4973/workflow-step", + reason: worktreePoolModule.RemovalReason.ExecutorDispose, + }), + ); + vi.spyOn(executor as any, "tryCreateWorktree").mockImplementation(async (branch: string) => { + throw new Error(`fatal: '${branch}' is already used by worktree at '/tmp/test/.worktrees/other'`); + }); + + await expect((executor as any).handleWorktreeConflict( + CONFLICT_PATH, + "fusion/fn-4973", + "/tmp/test/.worktrees/stale-self-owned", + "FN-4973", + "main", + 0, + true, + await store.getSettings(), + )).rejects.toThrow(/live conflicting worktree .* was preserved and suffixes -2 through -6/); + }); + it("reconciles once on race-window ActiveSessionWorktreeRemovalError then retries removal", async () => { const store = createMockStore(); const executor = new TaskExecutor(store, "/tmp/test"); diff --git a/packages/engine/src/__tests__/executor-worktree.test.ts b/packages/engine/src/__tests__/executor-worktree.test.ts index c7e24d7a7e..82e23ff4c8 100644 --- a/packages/engine/src/__tests__/executor-worktree.test.ts +++ b/packages/engine/src/__tests__/executor-worktree.test.ts @@ -13,6 +13,8 @@ import { WorktreePool } from "../worktree-pool.js"; import * as worktreePoolModule from "../worktree-pool.js"; import { BranchConflictError } from "../branch-conflicts.js"; import * as branchConflictModule from "../branch-conflicts.js"; +import { activeSessionRegistry } from "../active-session-registry.js"; +import { ActiveSessionWorktreeRemovalError } from "../worktree-backend.js"; import { generateWorktreeName, slugify } from "../worktree-names.js"; import type { Task, TaskDetail } from "@fusion/core"; import { SessionManager } from "@earendil-works/pi-coding-agent"; @@ -136,7 +138,6 @@ describe("TaskExecutor with semaphore", () => { }); expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "failed", error: expect.any(String) }); - expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review"); expect(onError).toHaveBeenCalled(); }); @@ -1173,6 +1174,127 @@ describe("TaskExecutor worktree recovery", () => { ); }); + it("falls back to a fresh worktree when same-task workflow-step cleanup is refused", async () => { + const store = createMockStore(); + store.getSettings.mockResolvedValue({ + maxConcurrent: 2, + maxWorktrees: 4, + pollIntervalMs: 15000, + groupOverlappingFiles: false, + autoMerge: false, + executorAllowSiblingBranchRename: true, + }); + store.listTasks.mockResolvedValue([]); + + const conflictPath = "/tmp/test/.worktrees/keen-eagle"; + const freshPath = "/tmp/test/.worktrees/maple-delta"; + activeSessionRegistry.registerPath(conflictPath, { taskId: "FN-050", kind: "workflow-step", ownerKey: "FN-050/workflow-step" }); + mockedGenerateWorktreeName + .mockReturnValueOnce("swift-falcon") + .mockReturnValueOnce("maple-delta"); + mockedExistsSync.mockImplementation((path) => path === conflictPath); + + const removeSpy = vi.spyOn(worktreePoolModule, "removeWorktree").mockRejectedValue( + new ActiveSessionWorktreeRemovalError({ + worktreePath: conflictPath, + taskId: "FN-050", + kind: "workflow-step", + ownerKey: "FN-050/workflow-step", + reason: worktreePoolModule.RemovalReason.ExecutorDispose, + }), + ); + + mockedExecSync.mockImplementation((cmd: string | string[]) => { + const command = typeof cmd === "string" ? cmd : cmd[0]; + if (command.includes('git worktree add -b "fusion/fn-050"')) { + const error: any = new Error( + `fatal: 'fusion/fn-050' is already used by worktree at '${conflictPath}'`, + ); + error.stderr = Buffer.from(error.message); + throw error; + } + return Buffer.from(""); + }); + + const executor = new TaskExecutor(store, "/tmp/test"); + await executor.execute(makeTask()); + + expect(removeSpy).toHaveBeenCalledTimes(1); + expect(activeSessionRegistry.lookupByPath(conflictPath)?.taskId).toBe("FN-050"); + expect(store.updateTask).toHaveBeenCalledWith( + "FN-050", + expect.objectContaining({ worktree: freshPath, branch: "fusion/fn-050-2" }), + ); + expect(store.updateTask).not.toHaveBeenCalledWith( + "FN-050", + expect.objectContaining({ error: expect.stringContaining("automatic cleanup failed") }), + ); + expect(store.logEntry).toHaveBeenCalledWith( + "FN-050", + expect.stringContaining("Preserved active conflicting worktree"), + `${conflictPath} -> ${freshPath}`, + ); + }); + + it("falls back to a fresh worktree when existing-branch add hits an active conflict", async () => { + const store = createMockStore(); + store.getSettings.mockResolvedValue({ + maxConcurrent: 2, + maxWorktrees: 4, + pollIntervalMs: 15000, + groupOverlappingFiles: false, + autoMerge: false, + executorAllowSiblingBranchRename: true, + }); + store.listTasks.mockResolvedValue([]); + + const conflictPath = "/tmp/test/.worktrees/keen-eagle"; + const freshPath = "/tmp/test/.worktrees/opal-otter"; + activeSessionRegistry.registerPath(conflictPath, { taskId: "FN-050", kind: "workflow-step", ownerKey: "FN-050/workflow-step" }); + mockedGenerateWorktreeName + .mockReturnValueOnce("swift-falcon") + .mockReturnValueOnce("opal-otter"); + mockedExistsSync.mockImplementation((path) => path === conflictPath); + + vi.spyOn(worktreePoolModule, "removeWorktree").mockRejectedValue( + new ActiveSessionWorktreeRemovalError({ + worktreePath: conflictPath, + taskId: "FN-050", + kind: "workflow-step", + ownerKey: "FN-050/workflow-step", + reason: worktreePoolModule.RemovalReason.ExecutorDispose, + }), + ); + + mockedExecSync.mockImplementation((cmd: string | string[]) => { + const command = typeof cmd === "string" ? cmd : cmd[0]; + if (command.includes('git worktree add -b "fusion/fn-050"')) { + const error: any = new Error("fatal: A branch named 'fusion/fn-050' already exists."); + error.stderr = Buffer.from(error.message); + throw error; + } + if (command.includes(`git worktree add "/tmp/test/.worktrees/swift-falcon" "fusion/fn-050"`)) { + const error: any = new Error( + `fatal: 'fusion/fn-050' is already used by worktree at '${conflictPath}'`, + ); + error.stderr = Buffer.from(error.message); + throw error; + } + return Buffer.from(""); + }); + + const executor = new TaskExecutor(store, "/tmp/test"); + await executor.execute(makeTask()); + + expect(activeSessionRegistry.lookupByPath(conflictPath)?.kind).toBe("workflow-step"); + expect(store.updateTask).toHaveBeenCalledWith( + "FN-050", + expect.objectContaining({ worktree: freshPath, branch: "fusion/fn-050-2" }), + ); + const logMessages = store.logEntry.mock.calls.map((call: any[]) => String(call[1] ?? "")); + expect(logMessages.some((message: string) => message.includes("automatic cleanup failed"))).toBe(false); + }); + it("generates new worktree name when conflicting worktree belongs to active task in legacy rename mode (FN-4811: refuses force-removal of active worktree)", async () => { // FN-4811: When the conflicting worktree is bound to a live in-progress task, the // executor MUST NOT force-remove it (doing so yanks the active session's filesystem @@ -1751,6 +1873,7 @@ describe("TaskExecutor worktree recovery", () => { }); it("handles locked worktree by unlocking before removal", async () => { + vi.useRealTimers(); const store = createMockStore(); let callCount = 0; @@ -1824,7 +1947,7 @@ describe("TaskExecutor dependency-based worktree creation", () => { expect(worktreeAddCalls[0][0]).toContain("fusion/fn-059"); }); - it("creates worktree from HEAD when baseBranch is not set", async () => { + it("creates worktree from integration branch when baseBranch is not set", async () => { const store = createMockStore(); const executor = new TaskExecutor(store, "/tmp/test"); @@ -1833,16 +1956,12 @@ describe("TaskExecutor dependency-based worktree creation", () => { // no baseBranch })); - // The git worktree add command should NOT include a startPoint const worktreeAddCalls = mockedExecSync.mock.calls.filter( (c) => typeof c[0] === "string" && (c[0] as string).includes("worktree add -b"), ); expect(worktreeAddCalls.length).toBeGreaterThan(0); - // Command format: git worktree add -b "branch" "path" (no extra ref after path) const cmd = worktreeAddCalls[0][0] as string; - // Count quoted segments: branch + path = 2 quoted args - const quoted = cmd.match(/"[^"]+"/g) || []; - expect(quoted).toHaveLength(2); + expect(cmd).toContain('"main"'); }); it("logs base branch in worktree creation log entry", async () => { @@ -1862,7 +1981,7 @@ describe("TaskExecutor dependency-based worktree creation", () => { ); }); - it("does not mention base branch in log when baseBranch is not set", async () => { + it("logs integration branch in worktree creation log when baseBranch is not set", async () => { const store = createMockStore(); const executor = new TaskExecutor(store, "/tmp/test"); @@ -1870,12 +1989,11 @@ describe("TaskExecutor dependency-based worktree creation", () => { id: "FN-063", })); - // Check that log entry does NOT mention "based on" const logCalls = store.logEntry.mock.calls.filter( (call: any[]) => typeof call[1] === "string" && call[1].includes("Worktree created"), ); expect(logCalls.length).toBeGreaterThan(0); - expect(logCalls[0][1]).not.toContain("based on"); + expect(logCalls[0][1]).toContain("based on main"); }); it("retries worktree creation after cleaning up conflicting worktree", async () => { @@ -1909,11 +2027,6 @@ describe("TaskExecutor dependency-based worktree creation", () => { settings: expect.any(Object), }), ); - expect(mockedExecSync).toHaveBeenCalledWith( - 'git branch -D "fusion/fn-064"', - expect.objectContaining({ cwd: "/tmp/test" }), - ); - const worktreeCreateCalls = mockedExecSync.mock.calls.filter( (call) => typeof call[0] === "string" && call[0].includes('git worktree add') && call[0].includes("-b"), ); @@ -1990,7 +2103,7 @@ describe("TaskExecutor dependency-based worktree creation", () => { ); }); - it("passes undefined to pool prepareForTask when no baseBranch", async () => { + it("passes integration branch to pool prepareForTask when no baseBranch", async () => { const pool = new WorktreePool(); pool.release("/tmp/test/.worktrees/idle-wt"); mockedExistsSync.mockImplementation( @@ -2018,7 +2131,7 @@ describe("TaskExecutor dependency-based worktree creation", () => { expect(prepareSpy).toHaveBeenCalledWith( "/tmp/test/.worktrees/idle-wt", "fusion/fn-065", - undefined, + "main", { allowSiblingBranchRename: false, repoDir: "/tmp/test", requestingTaskId: "FN-065" }, ); }); @@ -2374,7 +2487,11 @@ describe("TaskExecutor worktree pool integration", () => { paused: true, }), ); - expect(store.moveTask).not.toHaveBeenCalled(); + expect(store.moveTask).toHaveBeenCalledWith( + "FN-020", + "todo", + expect.objectContaining({ preserveProgress: true, preserveResumeState: true, preserveWorktree: false }), + ); }); }); diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 331620dd14..c85547ebaf 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -15845,7 +15845,7 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB * 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 */ private async handleWorktreeConflict( @@ -15858,6 +15858,15 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB allowSiblingBranchRename = false, settings: Partial = {}, ): Promise<{ path: string; branch: string } | null> { + const tryFreshFallback = () => this.tryFreshWorktreeAfterLiveConflict({ + conflictPath, + branch, + taskId, + startPoint, + attemptNumber, + allowSiblingBranchRename, + settings, + }); const shouldGenerateNewName = await this.shouldGenerateNewWorktreeName( conflictPath, taskId, @@ -15922,28 +15931,7 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB throw new Error(`Branch ${branch} conflict could not be auto-resolved`); } - const conflictStartPoint = branch; - const newPath = resolveTaskWorktreePath(this.rootDir, settings, generateWorktreeName(this.rootDir, settings)); - for (let suffix = 2; suffix <= 6; suffix++) { - const suffixedBranch = `${branch}-${suffix}`; - try { - await this.store.logEntry( - taskId, - `Conflicting worktree in use by active task, trying new path with branch ${suffixedBranch}`, - newPath, - ); - return await this.tryCreateWorktree(suffixedBranch, newPath, taskId, conflictStartPoint, attemptNumber, 0, true, settings); - } catch (suffixErr: unknown) { - const info = this.extractWorktreeConflictInfo(suffixErr); - if (info.type === "already-used") { - continue; - } - throw suffixErr; - } - } - throw new Error( - `Cannot create branch for task: "${branch}" and suffixes -2 through -6 are all in use by other worktrees`, - ); + return tryFreshFallback(); } const cleanupSuccess = await this.cleanupConflictingWorktree(conflictPath, branch, taskId); @@ -15952,9 +15940,65 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB return this.tryCreateWorktree(branch, path, taskId, startPoint, attemptNumber, 0, allowSiblingBranchRename, settings); } + if (await this.isLiveCleanupRefusal(conflictPath, taskId)) { + return tryFreshFallback(); + } + return null; } + private async tryFreshWorktreeAfterLiveConflict(input: { + conflictPath: string; + branch: string; + taskId: string; + startPoint?: string; + attemptNumber?: number; + allowSiblingBranchRename: boolean; + settings: Partial; + }): Promise<{ path: string; branch: string }> { + const { conflictPath, branch, taskId, attemptNumber, allowSiblingBranchRename, settings } = input; + if (!allowSiblingBranchRename) { + throw new Error(`Branch ${branch} conflict could not be auto-resolved`); + } + + const conflictStartPoint = branch; + for (let suffix = 2; suffix <= 6; suffix++) { + const suffixedBranch = `${branch}-${suffix}`; + const newPath = resolveTaskWorktreePath(this.rootDir, settings, generateWorktreeName(this.rootDir, settings)); + try { + await this.store.logEntry( + taskId, + `Preserved active conflicting worktree and retrying with fresh worktree branch ${suffixedBranch}`, + `${conflictPath} -> ${newPath}`, + ); + /* + * FNXC:ExecutorWorktree 2026-07-01-00:00: + * Active-session cleanup refusal must allocate a fresh worktree/branch instead of bubbling automatic cleanup failure. Removing the live conflicting path violates the FN-4811 invariant, so bounded sibling branches preserve the owner while letting the requesting task continue. + */ + return await this.tryCreateWorktree(suffixedBranch, newPath, taskId, conflictStartPoint, attemptNumber, 0, true, settings); + } catch (suffixErr: unknown) { + const info = this.extractWorktreeConflictInfo(suffixErr); + if (info.type === "already-used") { + continue; + } + throw suffixErr; + } + } + throw new Error( + `Cannot create branch for task: "${branch}"; live conflicting worktree ${conflictPath} was preserved and suffixes -2 through -6 are all in use by other worktrees`, + ); + } + + private async isLiveCleanupRefusal(worktreePath: string, taskId: string): Promise { + const activeOwner = await this.findActiveWorktreeOwner(worktreePath, taskId); + if (activeOwner !== null) return true; + + const activeRecord = activeSessionRegistry.lookupByPath(worktreePath); + if (!activeRecord) return false; + if (activeRecord.taskId !== taskId) return true; + return executingTaskLock.has(taskId) || this.hasActiveWorktreeBinding(taskId, worktreePath); + } + /** * Check if a path is registered as a git worktree. */