feat(FN-5063): merge fusion/fn-5063

This commit is contained in:
gsxdsm
2026-05-18 15:51:01 -07:00
parent aad1219037
commit 6fc0f5cebc
16 changed files with 617 additions and 11 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Engine: self-heal `git worktree add` failures classified as "missing but already registered worktree" by pruning the stale registration and retrying once. Recovery is observable via new run-audit events `worktree:stale-registration-detected`, `worktree:stale-registration-recovered`, `worktree:stale-registration-recovery-failed`.

View File

@@ -190,6 +190,7 @@ Detailed mechanism logs live in `docs/architecture.md` and `docs/design/`. The c
- **Worktrunk-managed lifecycles**: when `worktrunk.enabled`, self-healing defers prune/idle/worktree-cap sweeps to the worktrunk backend; branch-level reclaim and orphan rescue stay native. - **Worktrunk-managed lifecycles**: when `worktrunk.enabled`, self-healing defers prune/idle/worktree-cap sweeps to the worktrunk backend; branch-level reclaim and orphan rescue stay native.
- **Post-finalize verification no-op (FN-4944)**: when auto-merge receives a delayed `VerificationError` after a task is already `done` with `mergeDetails.mergeConfirmed === true` (already-on-main fast-path), it must log one `[verification] ... no action` diagnostic and must not bounce the task back to `in-progress` / `merging-fix`. Defense-in-depth now re-checks the done+mergeConfirmed condition immediately before each verification-failure status write site, and emits `task:post-finalize-verification-no-op` database audit events with failure metadata for forensics. - **Post-finalize verification no-op (FN-4944)**: when auto-merge receives a delayed `VerificationError` after a task is already `done` with `mergeDetails.mergeConfirmed === true` (already-on-main fast-path), it must log one `[verification] ... no action` diagnostic and must not bounce the task back to `in-progress` / `merging-fix`. Defense-in-depth now re-checks the done+mergeConfirmed condition immediately before each verification-failure status write site, and emits `task:post-finalize-verification-no-op` database audit events with failure metadata for forensics.
- **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. - **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.
- **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). - **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. - **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.
- **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. - **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.

View File

@@ -1541,7 +1541,7 @@ The GitHub tracking state listener now attaches to every registered project stor
- Classifier gates are deterministic: the lock must exist, be older than the stale threshold (default 30s), not be owned by a live `activeSessionRegistry` session, and resolve to a normalized lock/worktree path. - Classifier gates are deterministic: the lock must exist, be older than the stale threshold (default 30s), not be owned by a live `activeSessionRegistry` session, and resolve to a normalized lock/worktree path.
- If classified `stale`, Fusion removes the lock and retries create exactly once. - If classified `stale`, Fusion removes the lock and retries create exactly once.
- If staleness cannot be proven, lock removal is refused and the flow raises `StaleWorktreeIndexLockError` so task failure messaging can escalate with manual remediation guidance. - If staleness cannot be proven, lock removal is refused and the flow raises `StaleWorktreeIndexLockError` so task failure messaging can escalate with manual remediation guidance.
- Run-audit events emitted by the create path: `worktree:stale-lock-detected`, `worktree:stale-lock-recovered`, `worktree:stale-lock-recovery-failed`, `worktree:stale-lock-refused`. - Run-audit events emitted by the create path: `worktree:stale-lock-detected`, `worktree:stale-lock-recovered`, `worktree:stale-lock-recovery-failed`, `worktree:stale-lock-refused`, `worktree:stale-registration-detected`, `worktree:stale-registration-recovered`, `worktree:stale-registration-recovery-failed`.
#### Branch-conflict inspection and auto-reclaim #### Branch-conflict inspection and auto-reclaim
- `inspectBranchConflict` classifies branch collisions as `stale`, `stale-resolved`, `reclaimable`, or `live-foreign`. - `inspectBranchConflict` classifies branch collisions as `stale`, `stale-resolved`, `reclaimable`, or `live-foreign`.

View File

@@ -50,7 +50,7 @@ describe("TaskStore", () => {
} finally { } finally {
harness.store().stopWatching(); harness.store().stopWatching();
} }
}, 60_000); }, 120_000);
it("cache is updated when polling is active even without fs.watch", async () => { it("cache is updated when polling is active even without fs.watch", async () => {
await harness.store().watch(); await harness.store().watch();

View File

@@ -80,5 +80,5 @@ describeIfGit("captureBaseCommitSha (real git)", () => {
const distance = Number(git(repo, `git rev-list --count ${firstBase}..HEAD`)); const distance = Number(git(repo, `git rev-list --count ${firstBase}..HEAD`));
expect(distance).toBeGreaterThan(0); expect(distance).toBeGreaterThan(0);
}); }, 30_000);
}); });

View File

@@ -134,6 +134,15 @@ vi.mock("../worktree-stale-lock.js", async () => {
}; };
}); });
vi.mock("../worktree-stale-registration.js", async () => {
const actual = await vi.importActual<typeof import("../worktree-stale-registration.js")>("../worktree-stale-registration.js");
return {
...actual,
parseStaleRegistrationPath: vi.fn(actual.parseStaleRegistrationPath),
recoverStaleRegistration: vi.fn(),
};
});
vi.mock("node:child_process", async () => { vi.mock("node:child_process", async () => {
const { promisify } = await import("node:util"); const { promisify } = await import("node:util");
const { EventEmitter } = await import("node:events"); const { EventEmitter } = await import("node:events");
@@ -259,6 +268,7 @@ import { existsSync, realpathSync } from "node:fs";
import { hydrateWorktreeDb } from "../worktree-db-hydrate.js"; import { hydrateWorktreeDb } from "../worktree-db-hydrate.js";
import { classifyTaskWorktree, describeRegisteredWorktrees, isUsableTaskWorktree } from "../worktree-pool.js"; import { classifyTaskWorktree, describeRegisteredWorktrees, isUsableTaskWorktree } from "../worktree-pool.js";
import { classifyStaleLock, tryRemoveStaleLock } from "../worktree-stale-lock.js"; import { classifyStaleLock, tryRemoveStaleLock } from "../worktree-stale-lock.js";
import { parseStaleRegistrationPath, recoverStaleRegistration } from "../worktree-stale-registration.js";
import { executingTaskLock } from "../active-session-registry.js"; import { executingTaskLock } from "../active-session-registry.js";
export const mockedCreateFnAgent = vi.mocked(createFnAgent); export const mockedCreateFnAgent = vi.mocked(createFnAgent);
@@ -277,6 +287,8 @@ export const mockedDescribeRegisteredWorktrees = vi.mocked(describeRegisteredWor
export const mockedIsUsableTaskWorktree = vi.mocked(isUsableTaskWorktree); export const mockedIsUsableTaskWorktree = vi.mocked(isUsableTaskWorktree);
export const mockedClassifyStaleLock = vi.mocked(classifyStaleLock); export const mockedClassifyStaleLock = vi.mocked(classifyStaleLock);
export const mockedTryRemoveStaleLock = vi.mocked(tryRemoveStaleLock); export const mockedTryRemoveStaleLock = vi.mocked(tryRemoveStaleLock);
export const mockedParseStaleRegistrationPath = vi.mocked(parseStaleRegistrationPath);
export const mockedRecoverStaleRegistration = vi.mocked(recoverStaleRegistration);
export const mockedInstallTaskWorktreeIdentityGuard = vi.mocked(installTaskWorktreeIdentityGuard); export const mockedInstallTaskWorktreeIdentityGuard = vi.mocked(installTaskWorktreeIdentityGuard);
export type EventListener = (...args: unknown[]) => void; export type EventListener = (...args: unknown[]) => void;
@@ -359,8 +371,16 @@ export function resetExecutorMocks() {
}); });
mockedClassifyStaleLock.mockReset(); mockedClassifyStaleLock.mockReset();
mockedTryRemoveStaleLock.mockReset(); mockedTryRemoveStaleLock.mockReset();
mockedParseStaleRegistrationPath.mockReset();
mockedRecoverStaleRegistration.mockReset();
mockedInstallTaskWorktreeIdentityGuard.mockReset(); mockedInstallTaskWorktreeIdentityGuard.mockReset();
mockedClassifyStaleLock.mockResolvedValue({ kind: "fresh", reason: "fresh" } as any); mockedClassifyStaleLock.mockResolvedValue({ kind: "fresh", reason: "fresh" } as any);
mockedParseStaleRegistrationPath.mockImplementation((value) => {
if (!value) return null;
const match = /'([^']+)'\s+is a missing but already registered worktree/i.exec(String(value));
return match?.[1] ?? null;
});
mockedRecoverStaleRegistration.mockResolvedValue({ recovered: true, actions: ["prune"] });
mockedInstallTaskWorktreeIdentityGuard.mockResolvedValue(undefined); mockedInstallTaskWorktreeIdentityGuard.mockResolvedValue(undefined);
mockedTryRemoveStaleLock.mockResolvedValue({ removed: true }); mockedTryRemoveStaleLock.mockResolvedValue({ removed: true });
mockExecuteAll.mockResolvedValue([]); mockExecuteAll.mockResolvedValue([]);

View File

@@ -36,6 +36,7 @@ import {
mockedIsUsableTaskWorktree, mockedIsUsableTaskWorktree,
mockedClassifyStaleLock, mockedClassifyStaleLock,
mockedTryRemoveStaleLock, mockedTryRemoveStaleLock,
mockedRecoverStaleRegistration,
mockedInstallTaskWorktreeIdentityGuard, mockedInstallTaskWorktreeIdentityGuard,
mockExecuteAll, mockExecuteAll,
mockTerminateAllSessions, mockTerminateAllSessions,
@@ -1299,6 +1300,63 @@ describe("TaskExecutor worktree recovery", () => {
}); });
}); });
describe("stale registration recovery", () => {
it("recovers stale registration and retries git worktree add", async () => {
const store = createMockStore();
let addCalls = 0;
mockedRecoverStaleRegistration.mockResolvedValue({ recovered: true, actions: ["prune", "remove-force"] });
mockedExecSync.mockImplementation((cmd: string | string[]) => {
const command = typeof cmd === "string" ? cmd : cmd[0];
if (command.includes("git worktree add") && addCalls++ === 0) {
const error: any = new Error("fatal: '/tmp/test/.worktrees/swift-falcon' is a missing but already registered worktree");
error.stderr = Buffer.from("fatal: '/tmp/test/.worktrees/swift-falcon' is a missing but already registered worktree");
throw error;
}
return Buffer.from("");
});
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute(makeTask());
expect(mockedRecoverStaleRegistration).toHaveBeenCalled();
expect(store.logEntry).toHaveBeenCalledWith(
"FN-050",
"Recovered stale worktree registration and retrying",
"/tmp/test/.worktrees/swift-falcon",
expect.anything(),
);
const worktreeAddCalls = mockedExecSync.mock.calls
.map((call) => String(call[0]))
.filter((command) => command.includes("git worktree add -b"));
expect(worktreeAddCalls.length).toBeGreaterThanOrEqual(2);
});
it("preserves existing failure path when stale registration persists", async () => {
const store = createMockStore();
mockedRecoverStaleRegistration.mockResolvedValue({ recovered: false, actions: ["prune"], reason: "still registered" });
mockedExecSync.mockImplementation((cmd: string | string[]) => {
const command = typeof cmd === "string" ? cmd : cmd[0];
if (command.includes("git worktree add")) {
const error: any = new Error("fatal: '/tmp/test/.worktrees/swift-falcon' is a missing but already registered worktree");
error.stderr = Buffer.from("fatal: '/tmp/test/.worktrees/swift-falcon' is a missing but already registered worktree");
throw error;
}
return Buffer.from("");
});
const executor = new TaskExecutor(store, "/tmp/test");
(executor as any).MAX_WORKTREE_RETRIES = 1;
await expect(
(executor as any).createWorktree("fusion/fn-050", "/tmp/test/.worktrees/swift-falcon", "FN-050"),
).rejects.toThrow("Failed to create worktree after 1 attempts");
expect(mockedRecoverStaleRegistration).toHaveBeenCalled();
}, 20000);
});
it("removes stale branch and retries when branch exists without worktree", async () => { it("removes stale branch and retries when branch exists without worktree", async () => {
const store = createMockStore(); const store = createMockStore();
let callCount = 0; let callCount = 0;

View File

@@ -367,7 +367,7 @@ describe("aiMergeTask overlap-aware fallback integration", () => {
expect(result.resolutionMethod).toBe("mixed"); expect(result.resolutionMethod).toBe("mixed");
expect(git(dir, "git show HEAD:store.ts")).toContain("branch hardening"); expect(git(dir, "git show HEAD:store.ts")).toContain("branch hardening");
expect(git(dir, "git show HEAD:store.ts")).not.toContain("main fallback"); expect(git(dir, "git show HEAD:store.ts")).not.toContain("main fallback");
}, 20_000); }, 60_000);
it("keeps legacy main-wins behavior when the conflicting main edit is outside the overlap lookback window", async () => { it("keeps legacy main-wins behavior when the conflicting main edit is outside the overlap lookback window", async () => {
commitFile(dir, "store.ts", "export const mode = 'base';\n", "feat: add store"); commitFile(dir, "store.ts", "export const mode = 'base';\n", "feat: add store");
@@ -389,7 +389,7 @@ describe("aiMergeTask overlap-aware fallback integration", () => {
expect(result.resolutionMethod).toBe("ours"); expect(result.resolutionMethod).toBe("ours");
expect(git(dir, "git show HEAD:store.ts")).toContain("main fallback"); expect(git(dir, "git show HEAD:store.ts")).toContain("main fallback");
expect(git(dir, "git show HEAD:store.ts")).not.toContain("branch hardening"); expect(git(dir, "git show HEAD:store.ts")).not.toContain("branch hardening");
}, 20_000); }, 60_000);
it("warn-only logs overlap but preserves main-wins behavior", async () => { it("warn-only logs overlap but preserves main-wins behavior", async () => {
commitFile(dir, "store.ts", "export const mode = 'base';\n", "feat: add store"); commitFile(dir, "store.ts", "export const mode = 'base';\n", "feat: add store");
@@ -410,7 +410,7 @@ describe("aiMergeTask overlap-aware fallback integration", () => {
expect( expect(
vi.mocked(store.appendAgentLog).mock.calls.some(([, message]) => String(message).includes("Overlap guard detected 1 recent-main overlap file(s)")), vi.mocked(store.appendAgentLog).mock.calls.some(([, message]) => String(message).includes("Overlap guard detected 1 recent-main overlap file(s)")),
).toBe(true); ).toBe(true);
}, 20_000); }, 60_000);
it("ignore preserves legacy behavior without overlap logging", async () => { it("ignore preserves legacy behavior without overlap logging", async () => {
commitFile(dir, "store.ts", "export const mode = 'base';\n", "feat: add store"); commitFile(dir, "store.ts", "export const mode = 'base';\n", "feat: add store");
@@ -431,7 +431,7 @@ describe("aiMergeTask overlap-aware fallback integration", () => {
expect( expect(
vi.mocked(store.appendAgentLog).mock.calls.some(([, message]) => String(message).includes("Overlap guard detected")), vi.mocked(store.appendAgentLog).mock.calls.some(([, message]) => String(message).includes("Overlap guard detected")),
).toBe(false); ).toBe(false);
}, 20_000); }, 60_000);
it( it(
"replays FN-3936 through the merger so branch hardening survives the final squash commit", "replays FN-3936 through the merger so branch hardening survives the final squash commit",

View File

@@ -74,5 +74,5 @@ describe("pre-commit identity guard (real git)", () => {
} finally { } finally {
rmSync(rootDir, { recursive: true, force: true }); rmSync(rootDir, { recursive: true, force: true });
} }
}); }, 30_000);
}); });

View File

@@ -0,0 +1,85 @@
import { mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { NativeWorktreeBackend } from "../../worktree-backend.js";
import { WorktreePool } from "../../worktree-pool.js";
import { git, hasGit } from "./_helpers.js";
describe.skipIf(!hasGit)("reliability interactions: worktree stale registration recovery", () => {
const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true })));
roots.length = 0;
});
async function setupRepo() {
const root = await mkdtemp(join(tmpdir(), "fusion-stale-registration-"));
roots.push(root);
git(root, "git init -b main");
git(root, 'git config user.email "test@example.com"');
git(root, 'git config user.name "Test User"');
await writeFile(join(root, "README.md"), "# repo\n", "utf-8");
git(root, "git add README.md");
git(root, 'git commit -m "init"');
return root;
}
it("recovers missing-but-registered worktree via prune and retries add", async () => {
const root = await setupRepo();
const worktreePath = join(root, ".worktrees", "fn-test-1");
const backendAuditEvents: string[] = [];
git(root, `git worktree add -b feature ${JSON.stringify(worktreePath)}`);
await rm(worktreePath, { recursive: true, force: true });
const backend = new NativeWorktreeBackend({
audit: {
git: async (event) => {
backendAuditEvents.push(event.type);
},
},
});
const result = await backend.create({
rootDir: root,
taskId: "FN-TEST-1",
worktreePath,
branch: "fusion/fn-test-1",
startPoint: "main",
});
expect(result).toEqual({ path: worktreePath, branch: "fusion/fn-test-1" });
expect(backendAuditEvents).toContain("worktree:stale-registration-detected");
expect(backendAuditEvents).toContain("worktree:stale-registration-recovered");
const porcelain = git(root, "git worktree list --porcelain");
const resolvedWorktreePath = await realpath(worktreePath);
expect(porcelain).toContain(`worktree ${resolvedWorktreePath}`);
});
it("recovered path composes with FN-4954 pool acquire/release contract", async () => {
const root = await setupRepo();
const worktreePath = join(root, ".worktrees", "fn-test-2");
git(root, `git worktree add -b feature ${JSON.stringify(worktreePath)}`);
await rm(worktreePath, { recursive: true, force: true });
const backend = new NativeWorktreeBackend();
await backend.create({
rootDir: root,
taskId: "FN-TEST-2",
worktreePath,
branch: "fusion/fn-test-2",
startPoint: "main",
});
const pool = new WorktreePool();
pool.release(worktreePath, "FN-TEST-2");
const acquired = pool.acquire("FN-TEST-3");
expect(acquired).toBe(worktreePath);
expect(pool.getLeasedPaths().get(worktreePath)).toBe("FN-TEST-3");
});
});

View File

@@ -8,7 +8,7 @@ import {
RemovalReason, RemovalReason,
} from "../worktree-backend.js"; } from "../worktree-backend.js";
const { execMock, accessMock, existsSyncMock, parseIndexLockPathMock, classifyStaleLockMock, tryRemoveStaleLockMock, installGuardMock } = vi.hoisted(() => { const { execMock, accessMock, existsSyncMock, parseIndexLockPathMock, classifyStaleLockMock, tryRemoveStaleLockMock, parseStaleRegistrationPathMock, recoverStaleRegistrationMock, installGuardMock } = vi.hoisted(() => {
const mock = vi.fn(); const mock = vi.fn();
(mock as any)[Symbol.for("nodejs.util.promisify.custom")] = mock; (mock as any)[Symbol.for("nodejs.util.promisify.custom")] = mock;
return { return {
@@ -18,6 +18,8 @@ const { execMock, accessMock, existsSyncMock, parseIndexLockPathMock, classifySt
parseIndexLockPathMock: vi.fn(), parseIndexLockPathMock: vi.fn(),
classifyStaleLockMock: vi.fn(), classifyStaleLockMock: vi.fn(),
tryRemoveStaleLockMock: vi.fn(), tryRemoveStaleLockMock: vi.fn(),
parseStaleRegistrationPathMock: vi.fn(),
recoverStaleRegistrationMock: vi.fn(),
installGuardMock: vi.fn(), installGuardMock: vi.fn(),
}; };
}); });
@@ -48,6 +50,10 @@ vi.mock("../worktree-stale-lock.js", () => ({
classifyStaleLock: classifyStaleLockMock, classifyStaleLock: classifyStaleLockMock,
tryRemoveStaleLock: tryRemoveStaleLockMock, tryRemoveStaleLock: tryRemoveStaleLockMock,
})); }));
vi.mock("../worktree-stale-registration.js", () => ({
parseStaleRegistrationPath: parseStaleRegistrationPathMock,
recoverStaleRegistration: recoverStaleRegistrationMock,
}));
beforeEach(() => { beforeEach(() => {
execMock.mockReset(); execMock.mockReset();
@@ -61,6 +67,10 @@ beforeEach(() => {
installGuardMock.mockReset(); installGuardMock.mockReset();
installGuardMock.mockResolvedValue(undefined); installGuardMock.mockResolvedValue(undefined);
parseIndexLockPathMock.mockReturnValue(null); parseIndexLockPathMock.mockReturnValue(null);
parseStaleRegistrationPathMock.mockReset();
parseStaleRegistrationPathMock.mockReturnValue(null);
recoverStaleRegistrationMock.mockReset();
recoverStaleRegistrationMock.mockResolvedValue({ recovered: true, actions: ["prune"] });
classifyStaleLockMock.mockResolvedValue({ kind: "fresh", reason: "fresh" }); classifyStaleLockMock.mockResolvedValue({ kind: "fresh", reason: "fresh" });
tryRemoveStaleLockMock.mockResolvedValue({ removed: true }); tryRemoveStaleLockMock.mockResolvedValue({ removed: true });
}); });
@@ -228,6 +238,136 @@ describe("NativeWorktreeBackend", () => {
); );
}); });
it("recovers stale registration and retries add", async () => {
const audit = { git: vi.fn().mockResolvedValue(undefined) };
const stalePath = "/repo/.worktrees/fn-1";
parseStaleRegistrationPathMock
.mockReturnValueOnce(stalePath)
.mockReturnValueOnce(null);
recoverStaleRegistrationMock.mockResolvedValue({ recovered: true, actions: ["prune", "remove-force"] });
execMock
.mockRejectedValueOnce({ message: "fatal", stderr: `fatal: '${stalePath}' is a missing but already registered worktree` })
.mockResolvedValueOnce({ stdout: "", stderr: "" });
const result = await new NativeWorktreeBackend({ audit }).create({
rootDir: "/repo",
worktreePath: stalePath,
branch: "fusion/fn-1",
taskId: "FN-1",
});
expect(result).toEqual({ path: stalePath, branch: "fusion/fn-1" });
expect(recoverStaleRegistrationMock).toHaveBeenCalledWith({
rootDir: "/repo",
worktreePath: stalePath,
logger: undefined,
});
expect(audit.git).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ type: "worktree:stale-registration-detected" }),
);
expect(audit.git).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ type: "worktree:stale-registration-recovered", metadata: { actions: ["prune", "remove-force"] } }),
);
expect(installGuardMock).toHaveBeenCalledTimes(1);
});
it("uses add -f retry when stale registration persists", async () => {
const audit = { git: vi.fn().mockResolvedValue(undefined) };
const stalePath = "/repo/.worktrees/fn-1";
parseStaleRegistrationPathMock.mockReturnValue(stalePath);
recoverStaleRegistrationMock.mockResolvedValue({ recovered: true, actions: ["prune"] });
execMock
.mockRejectedValueOnce({ message: "fatal", stderr: `fatal: '${stalePath}' is a missing but already registered worktree` })
.mockRejectedValueOnce({ message: "fatal", stderr: `fatal: '${stalePath}' is a missing but already registered worktree` })
.mockResolvedValueOnce({ stdout: "", stderr: "" });
const result = await new NativeWorktreeBackend({ audit }).create({
rootDir: "/repo",
worktreePath: stalePath,
branch: "fusion/fn-1",
taskId: "FN-1",
});
expect(result).toEqual({ path: stalePath, branch: "fusion/fn-1" });
expect(execMock).toHaveBeenNthCalledWith(
3,
'git worktree add -f "/repo/.worktrees/fn-1" "fusion/fn-1"',
expect.any(Object),
);
expect(audit.git).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
type: "worktree:stale-registration-recovered",
metadata: { actions: ["prune", "add-force-retry"] },
}),
);
});
it("emits recovery failed and throws when add -f also fails", async () => {
const audit = { git: vi.fn().mockResolvedValue(undefined) };
const stalePath = "/repo/.worktrees/fn-1";
parseStaleRegistrationPathMock.mockReturnValue(stalePath);
recoverStaleRegistrationMock.mockResolvedValue({ recovered: true, actions: ["prune"] });
const staleError = { message: "fatal", stderr: `fatal: '${stalePath}' is a missing but already registered worktree` };
execMock.mockRejectedValueOnce(staleError).mockRejectedValueOnce(staleError).mockRejectedValueOnce(staleError);
await expect(
new NativeWorktreeBackend({ audit }).create({
rootDir: "/repo",
worktreePath: stalePath,
branch: "fusion/fn-1",
taskId: "FN-1",
}),
).rejects.toMatchObject({ stderr: expect.stringContaining("missing but already registered worktree") });
expect(audit.git).toHaveBeenLastCalledWith(
expect.objectContaining({ type: "worktree:stale-registration-recovery-failed" }),
);
});
it("does not emit stale-registration events on healthy create", async () => {
const audit = { git: vi.fn().mockResolvedValue(undefined) };
execMock.mockResolvedValue({ stdout: "", stderr: "" });
await new NativeWorktreeBackend({ audit }).create({
rootDir: "/repo",
worktreePath: "/repo/.worktrees/fn-1",
branch: "fusion/fn-1",
taskId: "FN-1",
});
expect(recoverStaleRegistrationMock).not.toHaveBeenCalled();
expect(audit.git).not.toHaveBeenCalledWith(expect.objectContaining({ type: expect.stringMatching(/^worktree:stale-registration-/) }));
});
it("prefers stale-lock recovery when both stale-lock and stale-registration signatures appear", async () => {
const audit = { git: vi.fn().mockResolvedValue(undefined) };
parseIndexLockPathMock.mockReturnValue("/repo/.git/worktrees/fn-1/index.lock");
classifyStaleLockMock.mockResolvedValue({ kind: "stale", reason: "old-lock", ageMs: 60000 });
tryRemoveStaleLockMock.mockResolvedValue({ removed: true });
parseStaleRegistrationPathMock.mockReturnValue("/repo/.worktrees/fn-1");
execMock
.mockRejectedValueOnce({
message: "fatal",
stderr:
"fatal: unable to create '/repo/.git/worktrees/fn-1/index.lock': File exists\nfatal: '/repo/.worktrees/fn-1' is a missing but already registered worktree",
})
.mockResolvedValueOnce({ stdout: "", stderr: "" });
await new NativeWorktreeBackend({ audit }).create({
rootDir: "/repo",
worktreePath: "/repo/.worktrees/fn-1",
branch: "fusion/fn-1",
taskId: "FN-1",
});
expect(tryRemoveStaleLockMock).toHaveBeenCalledTimes(1);
expect(recoverStaleRegistrationMock).not.toHaveBeenCalled();
expect(audit.git).toHaveBeenNthCalledWith(1, expect.objectContaining({ type: "worktree:stale-lock-detected" }));
});
it("resolves native worktree path via configured worktreesDir", async () => { it("resolves native worktree path via configured worktreesDir", async () => {
const backend = new NativeWorktreeBackend({ settings: { worktreesDir: "../{repo}.worktrees" } as any }); const backend = new NativeWorktreeBackend({ settings: { worktreesDir: "../{repo}.worktrees" } as any });
await expect( await expect(

View File

@@ -0,0 +1,79 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { parseStaleRegistrationPath, recoverStaleRegistration } from "../worktree-stale-registration.js";
const { execMock } = vi.hoisted(() => {
const mock = vi.fn();
(mock as any)[Symbol.for("nodejs.util.promisify.custom")] = mock;
return { execMock: mock };
});
vi.mock("node:child_process", () => ({ exec: execMock }));
describe("worktree-stale-registration", () => {
beforeEach(() => {
execMock.mockReset();
});
it("parseStaleRegistrationPath parses FN-5056 fixture", () => {
const fixture = `Failed to create worktree: Command failed: git worktree add \"/Users/eclipxe/Projects/kb/.worktrees/fast-tiger/.worktrees/happy-olive\" \"fusion/fn-4995\"\nPreparing worktree (checking out 'fusion/fn-4995')\nfatal: '/Users/eclipxe/Projects/kb/.worktrees/fast-tiger/.worktrees/happy-olive' is a missing but already registered worktree; use 'add -f' to override, or 'prune' or 'remove' to clear`;
expect(parseStaleRegistrationPath(fixture)).toBe(
"/Users/eclipxe/Projects/kb/.worktrees/fast-tiger/.worktrees/happy-olive",
);
});
it("parseStaleRegistrationPath returns null for unrelated/empty input", () => {
expect(parseStaleRegistrationPath("fatal: not a git repository")).toBeNull();
expect(parseStaleRegistrationPath("")).toBeNull();
});
it("recoverStaleRegistration runs prune then remove-force when still registered", async () => {
execMock
.mockResolvedValueOnce({ stdout: "", stderr: "" })
.mockResolvedValueOnce({ stdout: "worktree /repo/.worktrees/fn-1\n", stderr: "" })
.mockResolvedValueOnce({ stdout: "", stderr: "" });
const result = await recoverStaleRegistration({ rootDir: "/repo", worktreePath: "/repo/.worktrees/fn-1" });
expect(result).toEqual({ recovered: true, actions: ["prune", "remove-force"] });
expect(execMock).toHaveBeenNthCalledWith(
1,
"git worktree prune",
expect.objectContaining({ cwd: "/repo", timeout: 30000, maxBuffer: 10485760 }),
);
expect(execMock).toHaveBeenNthCalledWith(2, "git worktree list --porcelain", expect.any(Object));
expect(execMock).toHaveBeenNthCalledWith(3, 'git worktree remove --force "/repo/.worktrees/fn-1"', expect.any(Object));
});
it("returns recovered false when prune fails", async () => {
execMock.mockRejectedValueOnce(new Error("prune failed"));
const result = await recoverStaleRegistration({ rootDir: "/repo", worktreePath: "/repo/.worktrees/fn-1" });
expect(result.recovered).toBe(false);
expect(result.actions).toEqual([]);
expect(result.reason).toContain("prune failed");
});
it("swallows remove-force errors when prune succeeds", async () => {
execMock
.mockResolvedValueOnce({ stdout: "", stderr: "" })
.mockResolvedValueOnce({ stdout: "worktree /repo/.worktrees/fn-1\n", stderr: "" })
.mockRejectedValueOnce(new Error("path missing"));
const result = await recoverStaleRegistration({ rootDir: "/repo", worktreePath: "/repo/.worktrees/fn-1" });
expect(result).toEqual({ recovered: true, actions: ["prune", "remove-force"] });
});
it("handles list --porcelain errors as non-fatal and still attempts remove-force", async () => {
execMock
.mockResolvedValueOnce({ stdout: "", stderr: "" })
.mockRejectedValueOnce(new Error("list failed"))
.mockResolvedValueOnce({ stdout: "", stderr: "" });
const result = await recoverStaleRegistration({ rootDir: "/repo", worktreePath: "/repo/.worktrees/fn-1" });
expect(result).toEqual({ recovered: true, actions: ["prune", "remove-force"] });
expect(execMock).toHaveBeenNthCalledWith(3, 'git worktree remove --force "/repo/.worktrees/fn-1"', expect.any(Object));
});
});

View File

@@ -52,6 +52,7 @@ import {
parseIndexLockPath, parseIndexLockPath,
tryRemoveStaleLock, tryRemoveStaleLock,
} from "./worktree-stale-lock.js"; } from "./worktree-stale-lock.js";
import { parseStaleRegistrationPath, recoverStaleRegistration } from "./worktree-stale-registration.js";
import { import {
BranchConflictError, BranchConflictError,
BranchCrossContaminationError, BranchCrossContaminationError,
@@ -8460,7 +8461,10 @@ Backward compat fallback: if JSON is unavailable, you may still begin output wit
| "worktree:stale-lock-detected" | "worktree:stale-lock-detected"
| "worktree:stale-lock-recovered" | "worktree:stale-lock-recovered"
| "worktree:stale-lock-recovery-failed" | "worktree:stale-lock-recovery-failed"
| "worktree:stale-lock-refused", | "worktree:stale-lock-refused"
| "worktree:stale-registration-detected"
| "worktree:stale-registration-recovered"
| "worktree:stale-registration-recovery-failed",
targetPath: string, targetPath: string,
metadata: Record<string, unknown>, metadata: Record<string, unknown>,
): Promise<void> { ): Promise<void> {
@@ -8533,6 +8537,34 @@ Backward compat fallback: if JSON is unavailable, you may still begin output wit
* Single attempt to create a worktree with conflict detection and recovery. * Single attempt to create a worktree with conflict detection and recovery.
* Returns the actual worktree path used (may differ from input if recovery generated new name). * Returns the actual worktree path used (may differ from input if recovery generated new name).
*/ */
private async recoverStaleRegistration(taskId: string, path: string, conflictInfo: { path?: string; message?: string }): Promise<boolean> {
const staleRegistrationPath = conflictInfo.path ?? path;
await this.emitStaleLockAudit(taskId, "worktree:stale-registration-detected", path, {
staleRegistrationPath,
worktreePath: path,
});
const recovery = await recoverStaleRegistration({
rootDir: this.rootDir,
worktreePath: path,
logger: executorLog,
});
if (recovery.recovered) {
await this.emitStaleLockAudit(taskId, "worktree:stale-registration-recovered", path, {
actions: recovery.actions,
});
await this.store.logEntry(taskId, "Recovered stale worktree registration and retrying", staleRegistrationPath, this.getRunContextFor(taskId));
return true;
}
await this.emitStaleLockAudit(taskId, "worktree:stale-registration-recovery-failed", path, {
actions: recovery.actions,
reason: recovery.reason ?? "unknown",
});
return false;
}
private async tryCreateWorktree( private async tryCreateWorktree(
branch: string, branch: string,
path: string, path: string,
@@ -8620,6 +8652,7 @@ Backward compat fallback: if JSON is unavailable, you may still begin output wit
}; };
let staleLockRecoveryAttempted = false; let staleLockRecoveryAttempted = false;
let staleRegistrationRecoveryAttempted = false;
try { try {
await createWithBranch(branch); await createWithBranch(branch);
executorLog.log(`Worktree created: ${path}${startPoint ? ` (from ${startPoint})` : ""}`); executorLog.log(`Worktree created: ${path}${startPoint ? ` (from ${startPoint})` : ""}`);
@@ -8642,6 +8675,17 @@ Backward compat fallback: if JSON is unavailable, you may still begin output wit
} }
} }
if (conflictInfo.type === "stale-registration" && !staleRegistrationRecoveryAttempted) {
staleRegistrationRecoveryAttempted = true;
const recovered = await this.recoverStaleRegistration(taskId, path, conflictInfo);
if (recovered) {
await createWithBranch(branch);
executorLog.log(`Worktree created after stale registration recovery: ${path}`);
await installGuardOrCleanup();
return { path, branch };
}
}
if (conflictInfo.type === "not-git-repo") { if (conflictInfo.type === "not-git-repo") {
throw new NonRetryableWorktreeError( throw new NonRetryableWorktreeError(
"Project directory is not a Git repository. Fusion requires a Git repository for worktree creation. Initialize with 'git init' or run from a Git project directory.", "Project directory is not a Git repository. Fusion requires a Git repository for worktree creation. Initialize with 'git init' or run from a Git project directory.",
@@ -8714,6 +8758,17 @@ Backward compat fallback: if JSON is unavailable, you may still begin output wit
} }
} }
if (fallbackConflictInfo.type === "stale-registration" && !staleRegistrationRecoveryAttempted) {
staleRegistrationRecoveryAttempted = true;
const recovered = await this.recoverStaleRegistration(taskId, path, fallbackConflictInfo);
if (recovered) {
await createFromExistingBranch();
executorLog.log(`Worktree created from existing branch after stale registration recovery: ${path}`);
await installGuardOrCleanup();
return { path, branch };
}
}
if (fallbackConflictInfo.type === "not-git-repo") { if (fallbackConflictInfo.type === "not-git-repo") {
throw new NonRetryableWorktreeError( throw new NonRetryableWorktreeError(
"Project directory is not a Git repository. Fusion requires a Git repository for worktree creation. Initialize with 'git init' or run from a Git project directory.", "Project directory is not a Git repository. Fusion requires a Git repository for worktree creation. Initialize with 'git init' or run from a Git project directory.",
@@ -9191,7 +9246,7 @@ Backward compat fallback: if JSON is unavailable, you may still begin output wit
* - "working tree already exists" * - "working tree already exists"
*/ */
private extractWorktreeConflictInfo(error: unknown): { private extractWorktreeConflictInfo(error: unknown): {
type: "already-used" | "invalid-reference" | "leading-directories" | "already-exists" | "not-git-repo" | "index-lock-contention" | "unknown"; type: "already-used" | "invalid-reference" | "leading-directories" | "already-exists" | "not-git-repo" | "index-lock-contention" | "stale-registration" | "unknown";
path?: string; path?: string;
lockPath?: string; lockPath?: string;
message?: string; message?: string;
@@ -9222,6 +9277,11 @@ Backward compat fallback: if JSON is unavailable, you may still begin output wit
return { type: "index-lock-contention", lockPath, message: output }; return { type: "index-lock-contention", lockPath, message: output };
} }
const staleRegistrationPath = parseStaleRegistrationPath(output);
if (staleRegistrationPath) {
return { type: "stale-registration", path: staleRegistrationPath, message: output };
}
// Pattern: invalid reference: 'branch-name' // Pattern: invalid reference: 'branch-name'
// Also covers: unable to resolve reference, stale file handle, not a valid ref // Also covers: unable to resolve reference, stale file handle, not a valid ref
if ( if (

View File

@@ -138,6 +138,9 @@ export type GitMutationType =
| "worktree:stale-lock-recovered" | "worktree:stale-lock-recovered"
| "worktree:stale-lock-recovery-failed" | "worktree:stale-lock-recovery-failed"
| "worktree:stale-lock-refused" | "worktree:stale-lock-refused"
| "worktree:stale-registration-detected"
| "worktree:stale-registration-recovered"
| "worktree:stale-registration-recovery-failed"
| "branch:create" | "branch:create"
| "branch:delete" | "branch:delete"
| "branch:checkout" | "branch:checkout"

View File

@@ -17,6 +17,7 @@ import {
parseIndexLockPath, parseIndexLockPath,
tryRemoveStaleLock, tryRemoveStaleLock,
} from "./worktree-stale-lock.js"; } from "./worktree-stale-lock.js";
import { parseStaleRegistrationPath, recoverStaleRegistration } from "./worktree-stale-registration.js";
const execAsync = promisify(exec); const execAsync = promisify(exec);
const NATIVE_TIMEOUT_MS = 120_000; const NATIVE_TIMEOUT_MS = 120_000;
@@ -211,7 +212,18 @@ export class NativeWorktreeBackend implements WorktreeBackend {
return { path: input.worktreePath, branch: branchName }; return { path: input.worktreePath, branch: branchName };
}; };
const createWithBranchForce = async (branchName: string): Promise<WorktreeCreateResult> => {
await execAsync(`git worktree add -f ${quoteShellArg(input.worktreePath)} ${quoteShellArg(branchName)}`, {
cwd: input.rootDir,
encoding: "utf-8",
timeout: NATIVE_TIMEOUT_MS,
maxBuffer: MAX_BUFFER,
});
return { path: input.worktreePath, branch: branchName };
};
let staleLockRecoveryAttempted = false; let staleLockRecoveryAttempted = false;
let staleRegistrationRecoveryAttempted = false;
try { try {
const created = await createWithBranch(input.branch); const created = await createWithBranch(input.branch);
await installGuardOrCleanup(created.path); await installGuardOrCleanup(created.path);
@@ -282,6 +294,61 @@ export class NativeWorktreeBackend implements WorktreeBackend {
} }
} }
const combinedErrorOutput = `${(error as { message?: string })?.message ?? ""}\n${getErrorStderr(error) ?? ""}`;
const staleRegistrationPath = parseStaleRegistrationPath(combinedErrorOutput);
if (staleRegistrationPath && !staleRegistrationRecoveryAttempted) {
staleRegistrationRecoveryAttempted = true;
await this.deps.audit?.git({
type: "worktree:stale-registration-detected",
target: input.worktreePath,
metadata: { staleRegistrationPath, worktreePath: input.worktreePath },
});
const recovery = await recoverStaleRegistration({
rootDir: input.rootDir,
worktreePath: input.worktreePath,
logger: this.deps.logger,
});
if (recovery.recovered) {
try {
const created = await createWithBranch(input.branch);
await this.deps.audit?.git({
type: "worktree:stale-registration-recovered",
target: input.worktreePath,
metadata: { actions: recovery.actions },
});
await installGuardOrCleanup(created.path);
return created;
} catch (retryError) {
const actionsWithForce = [...recovery.actions, "add-force-retry"];
try {
const created = await createWithBranchForce(input.branch);
await this.deps.audit?.git({
type: "worktree:stale-registration-recovered",
target: input.worktreePath,
metadata: { actions: actionsWithForce },
});
await installGuardOrCleanup(created.path);
return created;
} catch (forceError) {
await this.deps.audit?.git({
type: "worktree:stale-registration-recovery-failed",
target: input.worktreePath,
metadata: {
actions: actionsWithForce,
reason: `${formatError(retryError).detail}; force-retry: ${formatError(forceError).detail}`,
},
});
throw error;
}
}
}
await this.deps.audit?.git({
type: "worktree:stale-registration-recovery-failed",
target: input.worktreePath,
metadata: { actions: recovery.actions, reason: recovery.reason ?? "unknown" },
});
}
if (!input.allowSiblingBranchRename) { if (!input.allowSiblingBranchRename) {
throw error; throw error;
} }

View File

@@ -0,0 +1,88 @@
import { exec } from "node:child_process";
import { promisify } from "node:util";
const execAsync = promisify(exec);
const GIT_TIMEOUT_MS = 30_000;
const MAX_BUFFER = 10 * 1024 * 1024;
function quoteShellArg(value: string): string {
return JSON.stringify(value);
}
export function parseStaleRegistrationPath(stderrOrMessage: string): string | null {
if (!stderrOrMessage) return null;
const match = /'([^']+)'\s+is a missing but already registered worktree/i.exec(stderrOrMessage);
if (!match) return null;
return match[1]?.trim() || null;
}
function parseWorktreeListPorcelain(porcelain: string): string[] {
return porcelain
.split("\n")
.filter((line) => line.startsWith("worktree "))
.map((line) => line.slice("worktree ".length).trim())
.filter(Boolean);
}
function normalizePath(path: string): string {
return path.replace(/\\/g, "/");
}
export async function recoverStaleRegistration(input: {
rootDir: string;
worktreePath: string;
logger?: { log?: (message: string) => void; warn?: (message: string) => void };
}): Promise<{ recovered: boolean; actions: string[]; reason?: string }> {
const actions: string[] = [];
try {
await execAsync("git worktree prune", {
cwd: input.rootDir,
encoding: "utf-8",
timeout: GIT_TIMEOUT_MS,
maxBuffer: MAX_BUFFER,
});
actions.push("prune");
} catch (error) {
return {
recovered: false,
actions,
reason: error instanceof Error ? error.message : String(error),
};
}
try {
const { stdout } = await execAsync("git worktree list --porcelain", {
cwd: input.rootDir,
encoding: "utf-8",
timeout: GIT_TIMEOUT_MS,
maxBuffer: MAX_BUFFER,
});
const registered = parseWorktreeListPorcelain(stdout);
const targetPath = normalizePath(input.worktreePath);
if (!registered.some((path) => normalizePath(path) === targetPath)) {
input.logger?.warn?.("[worktree-stale-registration] worktree not listed after prune; attempting remove --force as safety fallback");
}
} catch (error) {
input.logger?.warn?.(`[worktree-stale-registration] failed to list worktrees before remove --force: ${error instanceof Error ? error.message : String(error)}`);
}
{
try {
await execAsync(`git worktree remove --force ${quoteShellArg(input.worktreePath)}`, {
cwd: input.rootDir,
encoding: "utf-8",
timeout: GIT_TIMEOUT_MS,
maxBuffer: MAX_BUFFER,
});
actions.push("remove-force");
} catch (error) {
actions.push("remove-force");
input.logger?.log?.(
`[worktree-stale-registration] remove --force failed (continuing): ${error instanceof Error ? error.message : String(error)}`,
);
}
}
return { recovered: true, actions };
}