fix: preserve orphaned pinned worktrees before recovery (#3380)
## Summary - recover task-ID-pinned worktrees when their directory remains but Git metadata/registration is gone - preserve the orphan directory under `.fusion/recovery/worktrees` before recreating the worktree - retain existing fail-closed behavior for active, foreign, repo-root, or out-of-root paths ## Problem A task-pinned worktree can lose `.git` metadata while leaving build artifacts behind. Fusion classifies that path as incomplete or unregistered, but then calls `git worktree remove --force`. Git cannot remove a directory it no longer recognizes as a worktree, so acquisition aborts and the scheduler can repeat the same recovery indefinitely. The observed reproduction left `.build` and `.swiftpm` under the pinned path after Git registration was gone. ## Fix For an inactive path that is both: 1. inside the configured worktree root, and 2. classified as incomplete or unregistered, hold the shared worktree-path reservation across classification, preservation, and recreation. Recovery directories are created one canonical, project-contained component at a time, then the orphan is atomically moved into `.fusion/recovery/worktrees` and the pinned worktree is recreated. Moving rather than deleting preserves any unknown task artifacts for operator inspection. Other classifications continue through the existing guarded Git-removal path. ## Verification - `pnpm --filter @fusion/engine exec vitest run src/__tests__/worktree-acquisition.test.ts --silent=passed-only --reporter=dot` — 30 passed - `pnpm --filter @fusion/engine typecheck` - `pnpm --filter @fusion/engine build` - `pnpm test:gate:static` - `pnpm check:changesets` - `git diff --check` <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **Bug Fixes** - Improved recovery of task-pinned worktrees when incomplete or stale directories occupy the expected location. - Preserves eligible inactive or unregistered worktree contents in a recovery area instead of deleting them. - Prevents unsafe recovery through symbolic links and handles concurrent recovery attempts reliably. - Supports recovery when directories span different storage devices. - Ensures interrupted or invalid worktree states can be recreated safely without disrupting active sessions. - **Documentation** - Added a patch changeset documenting the improved worktree recovery behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
7
.changeset/task-pinned-orphan-recovery.md
Normal file
7
.changeset/task-pinned-orphan-recovery.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Recover task-pinned worktrees when an incomplete directory occupies the pinned path.
|
||||||
|
category: fix
|
||||||
|
dev: Preserve inactive incomplete or unregistered directories under .fusion/recovery before recreating the task worktree.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
@@ -8,8 +8,10 @@ import { acquireTaskWorktree, RepoRootWorktreeError, WorktreeBaseRefreshError }
|
|||||||
import { classifyTaskWorktree, PoolDoubleLeaseError } from "../worktree/worktree-pool.js";
|
import { classifyTaskWorktree, PoolDoubleLeaseError } from "../worktree/worktree-pool.js";
|
||||||
import * as desktopArtifacts from "../worktree/worktree-desktop-artifacts.js";
|
import * as desktopArtifacts from "../worktree/worktree-desktop-artifacts.js";
|
||||||
import * as branchConflicts from "../execution/branch-conflicts.js";
|
import * as branchConflicts from "../execution/branch-conflicts.js";
|
||||||
|
import { activeSessionRegistry } from "../agents/active-session-registry.js";
|
||||||
import { NativeWorktreeBackend } from "../worktree/worktree-backend.js";
|
import { NativeWorktreeBackend } from "../worktree/worktree-backend.js";
|
||||||
|
|
||||||
|
|
||||||
vi.mock("../worktree/worktree-pool.js", async () => {
|
vi.mock("../worktree/worktree-pool.js", async () => {
|
||||||
const actual = await vi.importActual<any>("../worktree/worktree-pool.js");
|
const actual = await vi.importActual<any>("../worktree/worktree-pool.js");
|
||||||
return {
|
return {
|
||||||
@@ -77,6 +79,7 @@ function makeRepo(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
for (const path of cleanupPaths.splice(0)) {
|
for (const path of cleanupPaths.splice(0)) {
|
||||||
rmSync(path, { recursive: true, force: true });
|
rmSync(path, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
@@ -345,6 +348,202 @@ describe("acquireTaskWorktree", () => {
|
|||||||
expect(store.updateTask).toHaveBeenCalledWith("FN-1", { worktree: null, branch: null, sessionFile: null });
|
expect(store.updateTask).toHaveBeenCalledWith("FN-1", { worktree: null, branch: null, sessionFile: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ classification: "incomplete" as const, reason: "missing .git metadata" },
|
||||||
|
{ classification: "unregistered" as const, reason: "not registered in git worktree list" },
|
||||||
|
])("reclaims a $classification task-pinned directory before recreating the worktree", async ({ classification, reason }) => {
|
||||||
|
const rootDir = makeRepo();
|
||||||
|
const pinnedPath = join(rootDir, ".worktrees", "fn-1");
|
||||||
|
mkdirSync(join(pinnedPath, ".build"), { recursive: true });
|
||||||
|
mkdirSync(join(pinnedPath, ".swiftpm"), { recursive: true });
|
||||||
|
writeFileSync(join(pinnedPath, ".build", "cache"), "stale\n", "utf-8");
|
||||||
|
vi.mocked(classifyTaskWorktree).mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
classification,
|
||||||
|
reason,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await acquireTaskWorktree({
|
||||||
|
task: { ...task, worktree: pinnedPath, branch: "fusion/fn-1" },
|
||||||
|
rootDir,
|
||||||
|
store,
|
||||||
|
settings: { worktreeNaming: "task-id", recycleWorktrees: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
worktreePath: pinnedPath,
|
||||||
|
branch: "fusion/fn-1",
|
||||||
|
source: "fresh",
|
||||||
|
isResume: false,
|
||||||
|
});
|
||||||
|
expect(existsSync(join(pinnedPath, ".git"))).toBe(true);
|
||||||
|
expect(git(rootDir, "git worktree list --porcelain")).toContain(pinnedPath);
|
||||||
|
const recoveryRoot = join(rootDir, ".fusion", "recovery", "worktrees");
|
||||||
|
const preserved = readdirSync(recoveryRoot);
|
||||||
|
expect(preserved).toHaveLength(1);
|
||||||
|
expect(readFileSync(join(recoveryRoot, preserved[0], ".build", "cache"), "utf-8")).toBe("stale\n");
|
||||||
|
expect(store.logEntry).toHaveBeenCalledWith(
|
||||||
|
"FN-1",
|
||||||
|
expect.stringContaining("Preserved orphaned task-pinned directory"),
|
||||||
|
expect.stringContaining(join(".fusion", "recovery", "worktrees")),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves an orphan beside an external worktree root when project recovery is cross-device", async () => {
|
||||||
|
const rootDir = makeRepo();
|
||||||
|
const externalWorktrees = track(mkdtempSync(join(tmpdir(), "fn-external-worktrees-")));
|
||||||
|
const pinnedPath = join(externalWorktrees, "fn-1");
|
||||||
|
mkdirSync(join(pinnedPath, ".build"), { recursive: true });
|
||||||
|
writeFileSync(join(pinnedPath, ".build", "cache"), "stale\n", "utf-8");
|
||||||
|
vi.mocked(classifyTaskWorktree).mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
classification: "incomplete",
|
||||||
|
reason: "missing .git metadata",
|
||||||
|
});
|
||||||
|
const actualFs = await vi.importActual<typeof import("node:fs/promises")>("node:fs/promises");
|
||||||
|
const renameWorktreeDirectory = vi.fn()
|
||||||
|
.mockRejectedValueOnce(Object.assign(new Error("cross-device link"), { code: "EXDEV" }))
|
||||||
|
.mockImplementationOnce(actualFs.rename);
|
||||||
|
|
||||||
|
const result = await acquireTaskWorktree({
|
||||||
|
task: { ...task, worktree: pinnedPath, branch: "fusion/fn-1" },
|
||||||
|
rootDir,
|
||||||
|
store,
|
||||||
|
settings: { worktreeNaming: "task-id", worktreesDir: externalWorktrees, recycleWorktrees: false },
|
||||||
|
renameWorktreeDirectory,
|
||||||
|
});
|
||||||
|
|
||||||
|
const recoveryRoot = join(externalWorktrees, ".fusion-recovery", "worktrees");
|
||||||
|
const preservedPath = join(recoveryRoot, readdirSync(recoveryRoot)[0]);
|
||||||
|
expect(result.worktreePath).toBe(pinnedPath);
|
||||||
|
expect(readFileSync(join(preservedPath, ".build", "cache"), "utf-8")).toBe("stale\n");
|
||||||
|
expect(renameWorktreeDirectory).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the orphan in place when its path becomes active during recovery", async () => {
|
||||||
|
const rootDir = makeRepo();
|
||||||
|
const pinnedPath = join(rootDir, ".worktrees", "fn-1");
|
||||||
|
mkdirSync(join(pinnedPath, ".build"), { recursive: true });
|
||||||
|
writeFileSync(join(pinnedPath, ".build", "cache"), "stale\n", "utf-8");
|
||||||
|
vi.mocked(classifyTaskWorktree).mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
classification: "incomplete",
|
||||||
|
reason: "missing .git metadata",
|
||||||
|
});
|
||||||
|
let becameActive = false;
|
||||||
|
vi.spyOn(activeSessionRegistry, "isPathActive").mockImplementation((path: string) => {
|
||||||
|
if (path !== pinnedPath) return false;
|
||||||
|
if (!becameActive) {
|
||||||
|
becameActive = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(acquireTaskWorktree({
|
||||||
|
task: { ...task, worktree: pinnedPath, branch: "fusion/fn-1" },
|
||||||
|
rootDir,
|
||||||
|
store,
|
||||||
|
settings: { worktreeNaming: "task-id", recycleWorktrees: false },
|
||||||
|
})).rejects.toThrow(/became active/);
|
||||||
|
|
||||||
|
expect(readFileSync(join(pinnedPath, ".build", "cache"), "utf-8")).toBe("stale\n");
|
||||||
|
expect(existsSync(join(rootDir, ".fusion", "recovery", "worktrees"))).toBe(true);
|
||||||
|
expect(readdirSync(join(rootDir, ".fusion", "recovery", "worktrees"))).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to preserve an orphan through a recovery-directory symlink", async () => {
|
||||||
|
const rootDir = makeRepo();
|
||||||
|
const pinnedPath = join(rootDir, ".worktrees", "fn-1");
|
||||||
|
const outside = track(mkdtempSync(join(tmpdir(), "fn-orphan-recovery-outside-")));
|
||||||
|
mkdirSync(join(pinnedPath, ".build"), { recursive: true });
|
||||||
|
writeFileSync(join(pinnedPath, ".build", "cache"), "stale\n", "utf-8");
|
||||||
|
mkdirSync(join(rootDir, ".fusion", "recovery"), { recursive: true });
|
||||||
|
symlinkSync(outside, join(rootDir, ".fusion", "recovery", "worktrees"));
|
||||||
|
vi.mocked(classifyTaskWorktree).mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
classification: "incomplete",
|
||||||
|
reason: "missing .git metadata",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(acquireTaskWorktree({
|
||||||
|
task: { ...task, worktree: pinnedPath, branch: "fusion/fn-1" },
|
||||||
|
rootDir,
|
||||||
|
store,
|
||||||
|
settings: { worktreeNaming: "task-id", recycleWorktrees: false },
|
||||||
|
})).rejects.toThrow(/outside the project root/);
|
||||||
|
|
||||||
|
expect(readFileSync(join(pinnedPath, ".build", "cache"), "utf-8")).toBe("stale\n");
|
||||||
|
expect(readdirSync(outside)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to create recovery contents through an ancestor symlink", async () => {
|
||||||
|
const rootDir = makeRepo();
|
||||||
|
const pinnedPath = join(rootDir, ".worktrees", "fn-1");
|
||||||
|
const outside = track(mkdtempSync(join(tmpdir(), "fn-orphan-recovery-ancestor-outside-")));
|
||||||
|
mkdirSync(join(pinnedPath, ".build"), { recursive: true });
|
||||||
|
writeFileSync(join(pinnedPath, ".build", "cache"), "stale\n", "utf-8");
|
||||||
|
mkdirSync(join(rootDir, ".fusion"), { recursive: true });
|
||||||
|
symlinkSync(outside, join(rootDir, ".fusion", "recovery"));
|
||||||
|
vi.mocked(classifyTaskWorktree).mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
classification: "incomplete",
|
||||||
|
reason: "missing .git metadata",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(acquireTaskWorktree({
|
||||||
|
task: { ...task, worktree: pinnedPath, branch: "fusion/fn-1" },
|
||||||
|
rootDir,
|
||||||
|
store,
|
||||||
|
settings: { worktreeNaming: "task-id", recycleWorktrees: false },
|
||||||
|
})).rejects.toThrow(/outside the project root/);
|
||||||
|
|
||||||
|
expect(readFileSync(join(pinnedPath, ".build", "cache"), "utf-8")).toBe("stale\n");
|
||||||
|
expect(readdirSync(outside)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serializes concurrent orphan recovery through fresh recreation", async () => {
|
||||||
|
const rootDir = makeRepo();
|
||||||
|
const pinnedPath = join(rootDir, ".worktrees", "fn-1");
|
||||||
|
mkdirSync(join(pinnedPath, ".build"), { recursive: true });
|
||||||
|
writeFileSync(join(pinnedPath, ".build", "cache"), "stale\n", "utf-8");
|
||||||
|
const actualPool = await vi.importActual<typeof import("../worktree/worktree-pool.js")>("../worktree/worktree-pool.js");
|
||||||
|
vi.mocked(classifyTaskWorktree).mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
classification: "incomplete",
|
||||||
|
reason: "missing .git metadata",
|
||||||
|
}).mockImplementation(actualPool.classifyTaskWorktree);
|
||||||
|
let signalCreateStarted!: () => void;
|
||||||
|
const createStarted = new Promise<void>((resolve) => { signalCreateStarted = resolve; });
|
||||||
|
let allowCreate!: () => void;
|
||||||
|
const createGate = new Promise<void>((resolve) => { allowCreate = resolve; });
|
||||||
|
const createWorktree = vi.fn(async (branch: string, path: string) => {
|
||||||
|
signalCreateStarted();
|
||||||
|
await createGate;
|
||||||
|
git(rootDir, `git worktree add -b ${JSON.stringify(branch)} ${JSON.stringify(path)} main`);
|
||||||
|
return { path, branch };
|
||||||
|
});
|
||||||
|
const input = {
|
||||||
|
task: { ...task, worktree: pinnedPath, branch: "fusion/fn-1" },
|
||||||
|
rootDir,
|
||||||
|
store,
|
||||||
|
settings: { worktreeNaming: "task-id", recycleWorktrees: false },
|
||||||
|
createWorktree,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const first = acquireTaskWorktree(input);
|
||||||
|
await createStarted;
|
||||||
|
const second = acquireTaskWorktree(input);
|
||||||
|
allowCreate();
|
||||||
|
|
||||||
|
const [firstResult, secondResult] = await Promise.all([first, second]);
|
||||||
|
expect(firstResult.worktreePath).toBe(pinnedPath);
|
||||||
|
expect(secondResult.worktreePath).toBe(pinnedPath);
|
||||||
|
expect(createWorktree).toHaveBeenCalledTimes(1);
|
||||||
|
expect(readFileSync(join(rootDir, ".fusion", "recovery", "worktrees", readdirSync(join(rootDir, ".fusion", "recovery", "worktrees"))[0], ".build", "cache"), "utf-8")).toBe("stale\n");
|
||||||
|
});
|
||||||
|
|
||||||
it("refreshes a recreated existing task branch after its dependency branch is deleted", async () => {
|
it("refreshes a recreated existing task branch after its dependency branch is deleted", async () => {
|
||||||
const rootDir = makeRepo();
|
const rootDir = makeRepo();
|
||||||
const staleBase = git(rootDir, "git rev-parse HEAD");
|
const staleBase = git(rootDir, "git rev-parse HEAD");
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import { readFile, writeFile } from "node:fs/promises";
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { mkdir, readFile, realpath, rename, stat, writeFile } from "node:fs/promises";
|
||||||
import { exec } from "node:child_process";
|
import { exec } from "node:child_process";
|
||||||
import { isAbsolute, resolve } from "node:path";
|
import { isAbsolute, join, relative, resolve } from "node:path";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import {acquireWorktreePathReservation, canonicalizeWorktreePath, type RunMutationContext, type Settings, type Task, type TaskStore, type SecretsStore} from "@fusion/core";
|
import {acquireWorktreePathReservation, canonicalizeWorktreePath, type RunMutationContext, type Settings, type Task, type TaskStore, type SecretsStore} from "@fusion/core";
|
||||||
import { generateWorktreeName, resolveTaskWorkingBranch, slugify } from "./worktree-names.js";
|
import { generateWorktreeName, resolveTaskWorkingBranch, slugify } from "./worktree-names.js";
|
||||||
@@ -109,6 +110,8 @@ export interface AcquireTaskWorktreeOptions {
|
|||||||
}>;
|
}>;
|
||||||
taskEnv?: NodeJS.ProcessEnv;
|
taskEnv?: NodeJS.ProcessEnv;
|
||||||
backend?: WorktreeBackend;
|
backend?: WorktreeBackend;
|
||||||
|
/** Test seam for filesystem-device recovery behavior. */
|
||||||
|
renameWorktreeDirectory?: typeof rename;
|
||||||
/** Execution callers opt in; planning, review, and merge reuse remain unchanged. */
|
/** Execution callers opt in; planning, review, and merge reuse remain unchanged. */
|
||||||
refreshStaleBase?: boolean;
|
refreshStaleBase?: boolean;
|
||||||
}
|
}
|
||||||
@@ -145,6 +148,24 @@ export class RepoRootWorktreeError extends Error {
|
|||||||
|
|
||||||
const INIT_OUTCOME_MAX_CHARS = 2_000;
|
const INIT_OUTCOME_MAX_CHARS = 2_000;
|
||||||
|
|
||||||
|
async function ensureContainedDirectory(parentCanonicalPath: string, name: string): Promise<string> {
|
||||||
|
const candidate = join(parentCanonicalPath, name);
|
||||||
|
try {
|
||||||
|
await mkdir(candidate);
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
||||||
|
}
|
||||||
|
const canonicalCandidate = await realpath(candidate);
|
||||||
|
const candidateRelative = relative(parentCanonicalPath, canonicalCandidate);
|
||||||
|
if (candidateRelative === "" || candidateRelative.startsWith("..") || isAbsolute(candidateRelative)) {
|
||||||
|
throw new Error(`Refusing to use recovery directory outside the project root: ${canonicalCandidate}`);
|
||||||
|
}
|
||||||
|
if (!(await stat(canonicalCandidate)).isDirectory()) {
|
||||||
|
throw new Error(`Refusing to use non-directory recovery path: ${canonicalCandidate}`);
|
||||||
|
}
|
||||||
|
return canonicalCandidate;
|
||||||
|
}
|
||||||
|
|
||||||
function configuredCommandErrorMessage(result: { spawnError?: string | Error; timedOut?: boolean; exitCode?: number | null }): string {
|
function configuredCommandErrorMessage(result: { spawnError?: string | Error; timedOut?: boolean; exitCode?: number | null }): string {
|
||||||
if (result.spawnError) return `Failed to start command: ${result.spawnError}`;
|
if (result.spawnError) return `Failed to start command: ${result.spawnError}`;
|
||||||
if (result.timedOut) return "Command timed out";
|
if (result.timedOut) return "Command timed out";
|
||||||
@@ -240,6 +261,7 @@ async function pinnedWorktreeBranchMatches(rootDir: string, worktreePath: string
|
|||||||
|
|
||||||
export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Promise<AcquireTaskWorktreeResult> {
|
export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Promise<AcquireTaskWorktreeResult> {
|
||||||
const { task, rootDir, store, settings, pool, logger, audit, runContext, createWorktree, runConfiguredCommand, runInitCommand, taskEnv, secretsStore } = opts;
|
const { task, rootDir, store, settings, pool, logger, audit, runContext, createWorktree, runConfiguredCommand, runInitCommand, taskEnv, secretsStore } = opts;
|
||||||
|
const renameWorktreeDirectory = opts.renameWorktreeDirectory ?? rename;
|
||||||
const refreshExistingWorktree = async (
|
const refreshExistingWorktree = async (
|
||||||
path: string,
|
path: string,
|
||||||
backendKind: WorktreeBackend["kind"],
|
backendKind: WorktreeBackend["kind"],
|
||||||
@@ -422,77 +444,89 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
|
|||||||
Acquisition delegates branch creation to the isolated-worktree primitive. The project root remains
|
Acquisition delegates branch creation to the isolated-worktree primitive. The project root remains
|
||||||
on its current branch; task branch selection must never use a root-checkout `git checkout` or `git switch`.
|
on its current branch; task branch selection must never use a root-checkout `git checkout` or `git switch`.
|
||||||
*/
|
*/
|
||||||
const createWorktreeImpl = async (
|
const createWorktreeWithoutReservation = async (
|
||||||
createBranch: string,
|
createBranch: string,
|
||||||
createPath: string,
|
createPath: string,
|
||||||
createTaskId: string,
|
createTaskId: string,
|
||||||
startPoint?: string,
|
startPoint?: string,
|
||||||
allowRename?: boolean,
|
allowRename?: boolean,
|
||||||
): Promise<{ path: string; branch: string; backendKind: WorktreeBackend["kind"] }> => {
|
): Promise<{ path: string; branch: string; backendKind: WorktreeBackend["kind"] }> => {
|
||||||
if (createWorktree) {
|
try {
|
||||||
const created = await createWorktree(createBranch, createPath, createTaskId, startPoint, allowRename);
|
const created = await backend.create({
|
||||||
return { ...created, backendKind: opts.createWorktreeBackendKind ?? backend.kind };
|
|
||||||
}
|
|
||||||
const reservation = await acquireWorktreePathReservation({
|
|
||||||
canonicalPath: await canonicalizeWorktreePath(createPath),
|
|
||||||
worktreesDir: resolveWorktreesDir(rootDir, settings),
|
|
||||||
rootDir,
|
rootDir,
|
||||||
/*
|
branch: createBranch,
|
||||||
FNXC:WorkflowLifecycle 2026-07-16-10:00:
|
worktreePath: createPath,
|
||||||
A failed archive removal leaves a durable quarantine record. The next
|
startPoint,
|
||||||
owner must reconcile that old pinned path while it exclusively holds
|
taskId: createTaskId,
|
||||||
the reservation, rather than colliding with it during creation.
|
allowSiblingBranchRename: allowRename,
|
||||||
*/
|
|
||||||
reconcileQuarantined: async () => {
|
|
||||||
await removeWorktree({
|
|
||||||
worktreePath: createPath,
|
|
||||||
rootDir,
|
|
||||||
settings,
|
|
||||||
taskId: createTaskId,
|
|
||||||
reason: RemovalReason.ExecutorDispose,
|
|
||||||
force: true,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
try {
|
if (backend.kind === "worktrunk") {
|
||||||
const created = await backend.create({
|
await audit?.git({
|
||||||
rootDir,
|
type: "worktree:worktrunk-create",
|
||||||
branch: createBranch,
|
target: created.path,
|
||||||
worktreePath: createPath,
|
metadata: { branch: created.branch },
|
||||||
startPoint,
|
|
||||||
taskId: createTaskId,
|
|
||||||
allowSiblingBranchRename: allowRename,
|
|
||||||
});
|
});
|
||||||
if (backend.kind === "worktrunk") {
|
|
||||||
await audit?.git({
|
|
||||||
type: "worktree:worktrunk-create",
|
|
||||||
target: created.path,
|
|
||||||
metadata: { branch: created.branch },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await persistWorktreeBackendKind(created.path, backend.kind);
|
|
||||||
return { ...created, backendKind: backend.kind };
|
|
||||||
} catch (error) {
|
|
||||||
if (backend.kind === "worktrunk" && error instanceof WorktrunkOperationError) {
|
|
||||||
// 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,
|
|
||||||
worktreePath: createPath,
|
|
||||||
startPoint,
|
|
||||||
taskId: createTaskId,
|
|
||||||
allowSiblingBranchRename: allowRename,
|
|
||||||
});
|
|
||||||
const created = await handleWorktrunkFailure("create", error, fallback) as { path: string; branch: string };
|
|
||||||
await persistWorktreeBackendKind(created.path, "native");
|
|
||||||
return { ...created, backendKind: "native" };
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
if (reservation.state === "held") await reservation.release();
|
|
||||||
}
|
}
|
||||||
};
|
await persistWorktreeBackendKind(created.path, backend.kind);
|
||||||
|
return { ...created, backendKind: backend.kind };
|
||||||
|
} catch (error) {
|
||||||
|
if (backend.kind !== "worktrunk" || !(error instanceof WorktrunkOperationError)) throw error;
|
||||||
|
// 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,
|
||||||
|
worktreePath: createPath,
|
||||||
|
startPoint,
|
||||||
|
taskId: createTaskId,
|
||||||
|
allowSiblingBranchRename: allowRename,
|
||||||
|
});
|
||||||
|
const created = await handleWorktrunkFailure("create", error, fallback) as { path: string; branch: string };
|
||||||
|
await persistWorktreeBackendKind(created.path, "native");
|
||||||
|
return { ...created, backendKind: "native" };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createWorktreeImpl = async (
|
||||||
|
createBranch: string,
|
||||||
|
createPath: string,
|
||||||
|
createTaskId: string,
|
||||||
|
startPoint?: string,
|
||||||
|
allowRename?: boolean,
|
||||||
|
reservationHeld = false,
|
||||||
|
): Promise<{ path: string; branch: string; backendKind: WorktreeBackend["kind"] }> => {
|
||||||
|
if (createWorktree) {
|
||||||
|
const created = await createWorktree(createBranch, createPath, createTaskId, startPoint, allowRename);
|
||||||
|
return { ...created, backendKind: opts.createWorktreeBackendKind ?? backend.kind };
|
||||||
|
}
|
||||||
|
if (reservationHeld) return createWorktreeWithoutReservation(createBranch, createPath, createTaskId, startPoint, allowRename);
|
||||||
|
const reservation = await acquireWorktreePathReservation({
|
||||||
|
canonicalPath: await canonicalizeWorktreePath(createPath),
|
||||||
|
worktreesDir: resolveWorktreesDir(rootDir, settings),
|
||||||
|
rootDir,
|
||||||
|
/*
|
||||||
|
FNXC:WorkflowLifecycle 2026-07-16-10:00:
|
||||||
|
A failed archive removal leaves a durable quarantine record. The next
|
||||||
|
owner must reconcile that old pinned path while it exclusively holds
|
||||||
|
the reservation, rather than colliding with it during creation.
|
||||||
|
*/
|
||||||
|
reconcileQuarantined: async () => {
|
||||||
|
await removeWorktree({
|
||||||
|
worktreePath: createPath,
|
||||||
|
rootDir,
|
||||||
|
settings,
|
||||||
|
taskId: createTaskId,
|
||||||
|
reason: RemovalReason.ExecutorDispose,
|
||||||
|
force: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
return await createWorktreeWithoutReservation(createBranch, createPath, createTaskId, startPoint, allowRename);
|
||||||
|
} finally {
|
||||||
|
if (reservation.state === "held") await reservation.release();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const logConfiguredCopyFileResults = async (results: WorktreeCopyFileResult[], source: "fresh" | "pool") => {
|
const logConfiguredCopyFileResults = async (results: WorktreeCopyFileResult[], source: "fresh" | "pool") => {
|
||||||
if (results.length === 0) return;
|
if (results.length === 0) return;
|
||||||
@@ -685,8 +719,18 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
|
|||||||
await store.updateTask(task.id, { worktree: pinnedPath });
|
await store.updateTask(task.id, { worktree: pinnedPath });
|
||||||
}
|
}
|
||||||
|
|
||||||
worktreePath = pinnedPath;
|
const reservation = await acquireWorktreePathReservation({
|
||||||
branch = branchName;
|
canonicalPath: await canonicalizeWorktreePath(pinnedPath),
|
||||||
|
worktreesDir: resolveWorktreesDir(rootDir, settings),
|
||||||
|
rootDir,
|
||||||
|
isLiveWorktree: async () => {
|
||||||
|
if (activeSessionRegistry.isPathActive(pinnedPath)) return true;
|
||||||
|
return (await classifyTaskWorktree(rootDir, pinnedPath)).ok;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
worktreePath = pinnedPath;
|
||||||
|
branch = branchName;
|
||||||
|
|
||||||
if (existsSync(pinnedPath)) {
|
if (existsSync(pinnedPath)) {
|
||||||
const classification = await classifyTaskWorktree(rootDir, pinnedPath);
|
const classification = await classifyTaskWorktree(rootDir, pinnedPath);
|
||||||
@@ -729,14 +773,62 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
|
|||||||
);
|
);
|
||||||
if (isInsideWorktreesDir(rootDir, pinnedPath, settings)) {
|
if (isInsideWorktreesDir(rootDir, pinnedPath, settings)) {
|
||||||
try {
|
try {
|
||||||
await removeWorktree({
|
const preserveAsOrphanDirectory = !classification.ok
|
||||||
rootDir,
|
&& (classification.classification === "incomplete" || classification.classification === "unregistered")
|
||||||
worktreePath: pinnedPath,
|
&& !activeSessionRegistry.isPathActive(pinnedPath);
|
||||||
settings,
|
if (preserveAsOrphanDirectory) {
|
||||||
reason: RemovalReason.PoolPrune,
|
const canonicalRoot = await realpath(rootDir);
|
||||||
taskId: task.id,
|
const fusionRoot = await ensureContainedDirectory(canonicalRoot, ".fusion");
|
||||||
audit: undefined,
|
const recoveryRoot = await ensureContainedDirectory(fusionRoot, "recovery");
|
||||||
});
|
const canonicalRecoveryRoot = await ensureContainedDirectory(recoveryRoot, "worktrees");
|
||||||
|
// The path reservation serializes classification, preservation, and
|
||||||
|
// recreation across processes; this final probe also protects against
|
||||||
|
// an in-process owner that registered before the reservation was held.
|
||||||
|
if (activeSessionRegistry.isPathActive(pinnedPath)) {
|
||||||
|
throw new Error(`Task-pinned worktree ${pinnedPath} became active during orphan recovery`);
|
||||||
|
}
|
||||||
|
let preservedPath = join(canonicalRecoveryRoot, `${task.id.toLowerCase()}-${randomUUID()}`);
|
||||||
|
try {
|
||||||
|
await renameWorktreeDirectory(pinnedPath, preservedPath);
|
||||||
|
} catch (renameError) {
|
||||||
|
if ((renameError as NodeJS.ErrnoException).code !== "EXDEV") throw renameError;
|
||||||
|
/*
|
||||||
|
* FNXC:TaskPinnedWorktrees 2026-08-09-03:20:
|
||||||
|
* Configured worktrees may live on another filesystem. Preserve atomically beside the
|
||||||
|
* configured worktree root instead of weakening recovery to recursive copy-and-delete.
|
||||||
|
*/
|
||||||
|
const canonicalWorktreesRoot = await realpath(resolveWorktreesDir(rootDir, settings));
|
||||||
|
const localRecoveryRoot = await ensureContainedDirectory(canonicalWorktreesRoot, ".fusion-recovery");
|
||||||
|
const localRecoveryWorktrees = await ensureContainedDirectory(localRecoveryRoot, "worktrees");
|
||||||
|
preservedPath = join(localRecoveryWorktrees, `${task.id.toLowerCase()}-${randomUUID()}`);
|
||||||
|
await renameWorktreeDirectory(pinnedPath, preservedPath);
|
||||||
|
}
|
||||||
|
await audit?.filesystem({
|
||||||
|
type: "file:write",
|
||||||
|
target: preservedPath,
|
||||||
|
metadata: {
|
||||||
|
taskId: task.id,
|
||||||
|
classification: classification.classification,
|
||||||
|
reason: "task-pinned-orphan-preserved",
|
||||||
|
sourcePath: pinnedPath,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await store.logEntry(
|
||||||
|
task.id,
|
||||||
|
`Preserved orphaned task-pinned directory ${pinnedPath} before recreation`,
|
||||||
|
preservedPath,
|
||||||
|
runContext,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await removeWorktree({
|
||||||
|
rootDir,
|
||||||
|
worktreePath: pinnedPath,
|
||||||
|
settings,
|
||||||
|
reason: RemovalReason.PoolPrune,
|
||||||
|
taskId: task.id,
|
||||||
|
audit: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (removeErr) {
|
} catch (removeErr) {
|
||||||
/*
|
/*
|
||||||
* FNXC:TaskPinnedWorktrees 2026-07-16-12:30:
|
* FNXC:TaskPinnedWorktrees 2026-07-16-12:30:
|
||||||
@@ -756,8 +848,11 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
|
|||||||
await store.updateTask(task.id, { sessionFile: null });
|
await store.updateTask(task.id, { sessionFile: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
const created = await createWorktreeImpl(branchName, pinnedPath, task.id, freshStartPoint, allowSiblingBranchRename);
|
const created = await createWorktreeImpl(branchName, pinnedPath, task.id, freshStartPoint, allowSiblingBranchRename, true);
|
||||||
return finalizeCreatedWorktree(created, "fresh", "normal");
|
return await finalizeCreatedWorktree(created, "fresh", "normal");
|
||||||
|
} finally {
|
||||||
|
if (reservation.state === "held") await reservation.release();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (pinned) {
|
if (pinned) {
|
||||||
|
|||||||
Reference in New Issue
Block a user