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) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7385-active-worktree-fresh-fallback.md
Normal file
7
.changeset/fn-7385-active-worktree-fresh-fallback.md
Normal file
@@ -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.
|
||||
@@ -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:<classification>` 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).
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<Settings> = {},
|
||||
): 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<Settings>;
|
||||
}): 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<boolean> {
|
||||
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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user