feat(FN-5063): merge fusion/fn-5063
This commit is contained in:
@@ -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`.
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
88
packages/engine/src/worktree-stale-registration.ts
Normal file
88
packages/engine/src/worktree-stale-registration.ts
Normal 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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user