fix(FN-8400): normalize preserved worktree recovery

Relocate idle native checkouts into the configured root across executor and self-healing recovery while preserving live, Worktrunk-managed, and task-pinned paths. Cover the invariant with real Git and focused recovery tests.

Fusion-Task-Id: FN-8400
This commit is contained in:
gsxdsm
2026-07-19 23:47:53 -07:00
parent b63ebe8fdb
commit 7cf030ddca
9 changed files with 445 additions and 37 deletions

View File

@@ -4,6 +4,7 @@ import { TaskExecutor } from "../executor.js";
import { activeSessionRegistry } from "../active-session-registry.js";
import { ActiveSessionWorktreeRemovalError } from "../worktree-backend.js";
import * as worktreePoolModule from "../worktree-pool.js";
import * as branchConflictModule from "../branch-conflicts.js";
import { createMockStore, mockedGenerateWorktreeName, resetExecutorMocks } from "./executor-test-helpers.js";
const CONFLICT_PATH = "/tmp/test/.worktrees/stale-self-owned";
@@ -57,6 +58,45 @@ describe("FN-4973: executor worktree conflict cleanup", () => {
expect(activeSessionRegistry.lookupByPath(CONFLICT_PATH)?.taskId).toBe("FN-4973");
});
it("defers out-of-root reclaim instead of moving a live same-task checkout", async () => {
const store = createMockStore();
const executor = new TaskExecutor(store, "/tmp/test");
const outsidePath = "/tmp/legacy-worktrees/recover-fn-8400";
const targetPath = "/tmp/test/.worktrees/recover-fn-8400";
(executor as any).addActiveWorktree("FN-8400", outsidePath);
vi.spyOn(branchConflictModule, "inspectBranchConflict").mockResolvedValueOnce({
kind: "reclaimable",
livePath: outsidePath,
tipSha: "abc123",
taskAttributedCommitCount: 1,
strandedCommits: [{ sha: "abc123", subject: "fix(FN-8400): preserve implementation" }],
} as any);
const relocate = vi.spyOn(worktreePoolModule, "relocateReclaimableWorktreeIntoRoot");
const result = await (executor as any).handleWorktreeConflict(
outsidePath,
"fusion/fn-8400",
targetPath,
"FN-8400",
"main",
0,
false,
{},
);
expect(result).toEqual({ path: outsidePath, branch: "fusion/fn-8400" });
expect(relocate).toHaveBeenCalledWith(expect.objectContaining({
sourcePath: outsidePath,
targetPath,
taskId: "FN-8400",
}));
expect(store.logEntry).toHaveBeenCalledWith(
"FN-8400",
expect.stringContaining("deferred relocation of active preserved worktree"),
outsidePath,
);
});
it("does not reconcile foreign-task registry entries and keeps refusal behavior", async () => {
const store = createMockStore();
const executor = new TaskExecutor(store, "/tmp/test");

View File

@@ -949,7 +949,7 @@ describe("TaskExecutor worktree recovery", () => {
const conflictPath = "/tmp/legacy-worktrees/recover-fn-8400";
const targetPath = "/tmp/test/.worktrees/pearl-otter";
vi.spyOn(executor as any, "shouldGenerateNewWorktreeName").mockResolvedValue(false);
const relocate = vi.spyOn(executor as any, "relocateReclaimableWorktree").mockResolvedValue(targetPath);
const relocate = vi.spyOn(executor as any, "normalizeReclaimableWorktreePath").mockResolvedValue(targetPath);
vi.spyOn(branchConflictModule, "inspectBranchConflict").mockResolvedValueOnce({
kind,
livePath: conflictPath,
@@ -981,6 +981,70 @@ describe("TaskExecutor worktree recovery", () => {
},
);
it("normalizes an out-of-root branch-conflict reclaim before persisting it", async () => {
const store = createMockStore();
store.getSettings.mockResolvedValue({ worktreesDir: ".worktrees" } as any);
const executor = new TaskExecutor(store, "/tmp/test");
const conflictPath = "/tmp/legacy-worktrees/recover-fn-8400";
const targetPath = "/tmp/test/.worktrees/recover-fn-8400";
vi.spyOn(branchConflictModule, "inspectBranchConflict").mockResolvedValueOnce({
kind: "reclaimable",
livePath: conflictPath,
tipSha: "70b47804bc6f27659638e17ac7cf279ed343ff6f",
taskAttributedCommitCount: 1,
strandedCommits: [{ sha: "70b47804bc6f27659638e17ac7cf279ed343ff6f", subject: "fix(FN-8400): preserve implementation" }],
} as any);
const normalize = vi.spyOn(executor as any, "normalizeReclaimableWorktreePath").mockResolvedValue(targetPath);
const result = await (executor as any).handleBranchConflict(
{ ...makeTask("FN-8400"), branch: "fusion/fn-8400", worktree: conflictPath },
new BranchConflictError({
branchName: "fusion/fn-8400",
conflictingWorktreePath: conflictPath,
existingTipSha: "70b47804bc6f27659638e17ac7cf279ed343ff6f",
strandedCommits: [],
startPoint: "main",
recommendedAction: "reclaim",
}),
);
expect(result).toBe("reclaimed");
expect(normalize).toHaveBeenCalledWith(conflictPath, targetPath, "FN-8400", expect.objectContaining({ worktreesDir: ".worktrees" }));
expect(store.updateTask).toHaveBeenCalledWith("FN-8400", expect.objectContaining({ worktree: targetPath }));
});
it("uses the task-pinned target when normalizing a branch-conflict reclaim", async () => {
const store = createMockStore();
store.getSettings.mockResolvedValue({ worktreesDir: ".worktrees", worktreeNaming: "task-id" } as any);
const executor = new TaskExecutor(store, "/tmp/test");
const conflictPath = "/tmp/legacy-worktrees/recover-fn-8400";
const pinnedPath = "/tmp/test/.worktrees/fn-8400";
vi.spyOn(branchConflictModule, "inspectBranchConflict").mockResolvedValueOnce({
kind: "reclaimable",
livePath: conflictPath,
tipSha: "70b47804bc6f27659638e17ac7cf279ed343ff6f",
taskAttributedCommitCount: 1,
strandedCommits: [{ sha: "70b47804bc6f27659638e17ac7cf279ed343ff6f", subject: "fix(FN-8400): preserve implementation" }],
} as any);
const normalize = vi.spyOn(executor as any, "normalizeReclaimableWorktreePath").mockResolvedValue(pinnedPath);
const result = await (executor as any).handleBranchConflict(
{ ...makeTask("FN-8400"), branch: "fusion/fn-8400", worktree: conflictPath },
new BranchConflictError({
branchName: "fusion/fn-8400",
conflictingWorktreePath: conflictPath,
existingTipSha: "70b47804bc6f27659638e17ac7cf279ed343ff6f",
strandedCommits: [],
startPoint: "main",
recommendedAction: "reclaim",
}),
);
expect(result).toBe("reclaimed");
expect(normalize).toHaveBeenCalledWith(conflictPath, pinnedPath, "FN-8400", expect.objectContaining({ worktreeNaming: "task-id" }));
expect(store.updateTask).toHaveBeenCalledWith("FN-8400", expect.objectContaining({ worktree: pinnedPath }));
});
it("records recovery context when handling a branch conflict (FN-4847: now discards + requeues instead of pausing)", async () => {
// FN-4847: branch-conflict-unrecoverable previously paused the task with
// status=failed + pausedReason="branch-conflict-unrecoverable". The user has

View File

@@ -91,6 +91,7 @@ vi.mock("../worktree-pool.js", () => ({
getRegisteredWorktreePaths: vi.fn().mockResolvedValue(new Set<string>()),
getRegisteredWorktreeBranchMap: vi.fn().mockResolvedValue(new Map<string, string>()),
removeWorktree: vi.fn().mockResolvedValue(undefined),
relocateReclaimableWorktreeIntoRoot: vi.fn(async ({ sourcePath }: { sourcePath: string }) => ({ kind: "ready", path: sourcePath, relocated: false })),
resolveWorktreeBackend: vi.fn(),
}));
@@ -120,7 +121,7 @@ import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { classifyTaskWorktree, getRegisteredWorktreeBranchMap, getRegisteredWorktreePaths, isUsableTaskWorktree, removeWorktree, resolveWorktreeBackend, scanIdleWorktrees, scanOrphanedBranches } from "../worktree-pool.js";
import { classifyTaskWorktree, getRegisteredWorktreeBranchMap, getRegisteredWorktreePaths, isUsableTaskWorktree, relocateReclaimableWorktreeIntoRoot, removeWorktree, resolveWorktreeBackend, scanIdleWorktrees, scanOrphanedBranches } from "../worktree-pool.js";
import { activeSessionRegistry, executingTaskLock } from "../active-session-registry.js";
import * as branchConflictModule from "../branch-conflicts.js";
import { createLogger } from "../logger.js";
@@ -10457,6 +10458,36 @@ describe("SelfHealingManager reclaimSelfOwnedBranchConflicts", () => {
expect(store.updateTask).toHaveBeenCalledWith("FN-500", expect.objectContaining({ worktree: "/tmp/fn-500", branch: "fusion/fn-500", status: null, paused: false }));
});
it("normalizes an out-of-root self-healing reclaim before persisting it", async () => {
const outsidePath = "/tmp/legacy-worktrees/recover-fn-8400";
const targetPath = "/tmp/test-project/.worktrees/recover-fn-8400";
(store.listTasks as any)
.mockResolvedValueOnce([{ id: "FN-8400", checkedOutBy: null, branch: "fusion/fn-8400", worktree: outsidePath, lineageId: "lin-8400" }])
.mockResolvedValueOnce([]);
vi.spyOn(branchConflictModule, "inspectBranchConflict").mockResolvedValueOnce({
kind: "reclaimable",
livePath: outsidePath,
tipSha: "abc123def456",
taskAttributedCommitCount: 1,
strandedCommits: [{ sha: "abc123", subject: "work" }],
} as any);
vi.mocked(relocateReclaimableWorktreeIntoRoot).mockResolvedValueOnce({ kind: "ready", path: targetPath, relocated: true });
const recovered = await manager.reclaimSelfOwnedBranchConflicts();
expect(recovered).toBe(1);
expect(relocateReclaimableWorktreeIntoRoot).toHaveBeenCalledWith(expect.objectContaining({
rootDir: "/tmp/test-project",
sourcePath: outsidePath,
targetPath,
taskId: "FN-8400",
}));
expect(store.updateTask).toHaveBeenCalledWith("FN-8400", expect.objectContaining({ worktree: targetPath }));
expect((store as any).recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({
metadata: expect.objectContaining({ worktreePath: targetPath }),
}));
});
it("skips blocked todo tasks with preserved branches", async () => {
(store.listTasks as any)
.mockResolvedValueOnce([{ id: "FN-516", column: "todo", blockedBy: "FN-216", checkedOutBy: null, branch: "fusion/fn-516", worktree: "/tmp/fn-516" }])
@@ -11189,7 +11220,7 @@ describe("FN-5335 triple-proof no-action unit coverage", () => {
});
const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
vi.spyOn(manager as any, "evaluateBackwardMoveTripleProof").mockResolvedValue({ ok: false, reason: "test" });
vi.spyOn(branchConflictModule, "inspectBranchConflict").mockResolvedValue({ kind: "reclaimable", tipSha: "abc", taskAttributedCommitCount: 1, strandedCommits: [{ sha: "abc", subject: "work" }] } as any);
vi.spyOn(branchConflictModule, "inspectBranchConflict").mockResolvedValue({ kind: "reclaimable", livePath: "/tmp/wt-rsbc", tipSha: "abc", taskAttributedCommitCount: 1, strandedCommits: [{ sha: "abc", subject: "work" }] } as any);
const result = await manager.reclaimSelfOwnedBranchConflicts();
expect(result).toBeGreaterThanOrEqual(0);

View File

@@ -5,6 +5,7 @@ import {
isTaskPinnedWorktreeNaming,
pinnedWorktreeSlug,
pinnedWorktreePathForTask,
preservedWorktreeTargetPathForTask,
} from "../worktree-pinning.js";
describe("worktree-pinning", () => {
@@ -50,4 +51,24 @@ describe("worktree-pinning", () => {
expect(a).toBe(b);
});
});
describe("preservedWorktreeTargetPathForTask", () => {
it("uses the task id for task-pinned naming", () => {
expect(preservedWorktreeTargetPathForTask(
"FN-8400",
"/legacy/recover-fn-8400",
{ worktreeNaming: "task-id" },
"/repo",
)).toBe("/repo/.worktrees/fn-8400");
});
it("preserves the legacy basename for non-pinned naming", () => {
expect(preservedWorktreeTargetPathForTask(
"FN-8400",
"/legacy/recover-fn-8400",
{ worktreeNaming: "random" },
"/repo",
)).toBe("/repo/.worktrees/recover-fn-8400");
});
});
});

View File

@@ -0,0 +1,134 @@
import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { relocateReclaimableWorktreeIntoRoot } from "../worktree-pool.js";
const cleanupPaths: string[] = [];
function git(cwd: string, args: string[]): string {
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
}
function createRepositoryFixture(): { rootDir: string; sourcePath: string; targetPath: string } {
const fixtureRoot = mkdtempSync(join(tmpdir(), "fn-8400-reclaim-placement-"));
cleanupPaths.push(fixtureRoot);
const rootDir = join(fixtureRoot, "repo");
const sourcePath = join(fixtureRoot, "legacy-worktrees", "recover-fn-8400");
const targetPath = join(rootDir, ".worktrees", "recover-fn-8400");
git(fixtureRoot, ["init", "repo"]);
git(rootDir, ["config", "user.name", "Fusion Test"]);
git(rootDir, ["config", "user.email", "fusion-test@example.com"]);
writeFileSync(join(rootDir, "README.md"), "base\n");
git(rootDir, ["add", "README.md"]);
git(rootDir, ["commit", "-m", "base"]);
git(rootDir, ["worktree", "add", "-b", "fusion/fn-8400", sourcePath, "HEAD"]);
writeFileSync(join(sourcePath, "preserved.txt"), "uncommitted task work\n");
return { rootDir, sourcePath, targetPath };
}
afterEach(() => {
while (cleanupPaths.length > 0) {
rmSync(cleanupPaths.pop()!, { recursive: true, force: true });
}
});
describe("reclaimable worktree placement", () => {
it("moves a registered legacy worktree into the configured root without losing task work", async () => {
const { rootDir, sourcePath, targetPath } = createRepositoryFixture();
const result = await relocateReclaimableWorktreeIntoRoot({
rootDir,
sourcePath,
targetPath,
taskId: "FN-8400",
settings: {},
isPathActive: async () => false,
});
expect(result).toEqual({ kind: "ready", path: targetPath, relocated: true });
expect(existsSync(sourcePath)).toBe(false);
expect(existsSync(targetPath)).toBe(true);
expect(readFileSync(join(targetPath, "preserved.txt"), "utf8")).toBe("uncommitted task work\n");
expect(git(rootDir, ["worktree", "list", "--porcelain"])).toContain(`worktree ${realpathSync(targetPath)}`);
expect(git(targetPath, ["branch", "--show-current"])).toBe("fusion/fn-8400");
expect(git(targetPath, ["status", "--porcelain"])).toContain("?? preserved.txt");
});
it("defers without moving when the exact legacy path is still active", async () => {
const { rootDir, sourcePath, targetPath } = createRepositoryFixture();
const result = await relocateReclaimableWorktreeIntoRoot({
rootDir,
sourcePath,
targetPath,
taskId: "FN-8400",
settings: {},
isPathActive: async (path) => path === sourcePath,
});
expect(result).toEqual({ kind: "deferred-live", path: sourcePath });
expect(existsSync(sourcePath)).toBe(true);
expect(existsSync(targetPath)).toBe(false);
expect(git(rootDir, ["worktree", "list", "--porcelain"])).toContain(`worktree ${realpathSync(sourcePath)}`);
});
it("preserves the backend-assigned path when Worktrunk owns the layout", async () => {
const { rootDir, sourcePath, targetPath } = createRepositoryFixture();
const result = await relocateReclaimableWorktreeIntoRoot({
rootDir,
sourcePath,
targetPath,
taskId: "FN-8400",
settings: { worktrunk: { enabled: true } },
isPathActive: async () => false,
});
expect(result).toEqual({ kind: "ready", path: sourcePath, relocated: false });
expect(existsSync(sourcePath)).toBe(true);
expect(existsSync(targetPath)).toBe(false);
expect(git(rootDir, ["worktree", "list", "--porcelain"])).toContain(`worktree ${realpathSync(sourcePath)}`);
});
it("chooses a task-scoped target when the legacy basename is occupied", async () => {
const { rootDir, sourcePath, targetPath } = createRepositoryFixture();
mkdirSync(targetPath, { recursive: true });
writeFileSync(join(targetPath, "owner.txt"), "unrelated path\n");
const disambiguatedPath = `${targetPath}-fn-8400`;
const result = await relocateReclaimableWorktreeIntoRoot({
rootDir,
sourcePath,
targetPath,
taskId: "FN-8400",
settings: { worktreeNaming: "random" },
isPathActive: async () => false,
});
expect(result).toEqual({ kind: "ready", path: disambiguatedPath, relocated: true });
expect(readFileSync(join(targetPath, "owner.txt"), "utf8")).toBe("unrelated path\n");
expect(readFileSync(join(disambiguatedPath, "preserved.txt"), "utf8")).toBe("uncommitted task work\n");
expect(git(rootDir, ["worktree", "list", "--porcelain"])).toContain(`worktree ${realpathSync(disambiguatedPath)}`);
});
it("rejects a relocation target outside the configured root before touching the source", async () => {
const { rootDir, sourcePath } = createRepositoryFixture();
const invalidTarget = join(dirname(rootDir), "still-outside", "recover-fn-8400");
await expect(relocateReclaimableWorktreeIntoRoot({
rootDir,
sourcePath,
targetPath: invalidTarget,
taskId: "FN-8400",
settings: {},
isPathActive: async () => false,
})).rejects.toThrow(/outside configured worktrees directory/);
expect(existsSync(sourcePath)).toBe(true);
expect(existsSync(invalidTarget)).toBe(false);
});
});

View File

@@ -9,9 +9,9 @@ const execFileAsync = promisify(execFile);
const WORKFLOW_THINKING_LEVEL_SET: ReadonlySet<string> = new Set(THINKING_LEVELS);
import { delimiter, dirname, isAbsolute, join, relative, resolve as resolvePath } from "node:path";
import { delimiter, isAbsolute, join, relative, resolve as resolvePath } from "node:path";
import { existsSync, lstatSync, realpathSync } from "node:fs";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { readFile, rm, writeFile } from "node:fs/promises";
import type { TaskStore, Task, TaskDetail, TaskTokenUsage, StepStatus, Settings, WorkflowStep, MissionStore, AsyncMissionStore, Slice, AgentState, AgentCapability, RunMutationContext, AgentHeartbeatConfig, Agent, AgentMemoryInclusionMode, ProjectSettings, MergeResult, WorkflowIrNode, WorkflowIrNodeKind, WorkflowStepResult as CoreWorkflowStepResult, ThinkingLevel } from "@fusion/core";
import { getUnmetSchedulingDependencies } from "./scheduler.js";
import { RetryStormError, serializeRetryStormError, evaluateCompletedPromotionFailureProvenance, evaluateSkipBypassTaint, resolveWorkflowIrForTask, resolveCompleteColumn, resolveMergeOrchestrationColumn, resolveReboundTarget, resolveColumnAgentBinding, resolveEffectiveAgent, instanceNodeId, getWorkflowExtensionRegistry, getBuiltinWorkflow, parseNoOpCompletionMarker, allowsAutoMergeProcessing, resolveEffectiveAutoMerge, isLiveSharedBranchGroupMemberIntegration, resolveMaxAutoMergeRetries, resolveMaxConsecutiveToolFailureRetries, resolveConsecutiveToolFailureRetryBackoffMs, resolveConsecutiveToolFailureThreshold, resolveExecutorEscalationTarget, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, COMPLETION_SUMMARY_NODE_ID, upsertWorkflowStepResult, AWAITING_APPROVAL_PAUSE_REASON, THINKING_LEVELS, AgentStore, resolveExecutorFallbackModel } from "@fusion/core";
@@ -125,7 +125,8 @@ import {
// filter reuses the SAME always-allowed/scope-match surface as the non-workspace path (F5). One-way
// executor→workspace-paths edge (workspace-paths imports nothing).
import { deriveRepoScopeSubset, normalizeRepoRelPath } from "./workspace-paths.js";
import { RemovalReason, classifyTaskWorktree, describeRegisteredWorktrees, detectGitRepository, detectNestedWorktreeRoot, getRegisteredWorktreePaths, isInsideWorktreesDir, isRegisteredGitWorktree, removeWorktree, type GitRepoDetection, type WorktreePool } from "./worktree-pool.js";
import { preservedWorktreeTargetPathForTask } from "./worktree-pinning.js";
import { RemovalReason, classifyTaskWorktree, describeRegisteredWorktrees, detectGitRepository, detectNestedWorktreeRoot, getRegisteredWorktreePaths, isInsideWorktreesDir, isRegisteredGitWorktree, relocateReclaimableWorktreeIntoRoot, removeWorktree, type GitRepoDetection, type WorktreePool } from "./worktree-pool.js";
import { attemptBranchAutocorrect } from "./branch-autocorrect.js";
import { ActiveSessionWorktreeRemovalError } from "./worktree-backend.js";
import {canonicalizeWorktreePath, registerArchiveWorkspaceWorktreeDisposer, registerArchiveWorktreeDisposer, registerTaskMoveDisposer} from "@fusion/core";
@@ -16851,14 +16852,17 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB
branch: string,
tipSha: string,
count: number,
settings: Partial<Settings>,
): Promise<void> {
await this.store.updateTask(task.id, { worktree: livePath, branch });
const targetPath = preservedWorktreeTargetPathForTask(task.id, livePath, settings, this.rootDir);
const normalizedPath = await this.normalizeReclaimableWorktreePath(livePath, targetPath, task.id, settings);
await this.store.updateTask(task.id, { worktree: normalizedPath, branch });
const latestTask = await this.store.getTask(task.id);
const baseRef = await this.resolveDiffBaseRef(livePath, latestTask.baseCommitSha);
const baseRef = await this.resolveDiffBaseRef(normalizedPath, latestTask.baseCommitSha);
if (baseRef) {
await assertCleanBranchAtBase(this.rootDir, branch, baseRef, task.id);
}
const message = `[recovery] reclaimed existing worktree for ${task.id} at ${livePath} (${count} commits preserved, tip ${tipSha.slice(0, 12)})`;
const message = `[recovery] reclaimed existing worktree for ${task.id} at ${normalizedPath} (${count} commits preserved, tip ${tipSha.slice(0, 12)})`;
await this.store.logEntry(task.id, message, undefined, this.getRunContextFor(task.id));
await this.store.appendAgentLog(task.id, "Branch conflict auto-recovery", "status", message, "executor");
}
@@ -16875,6 +16879,7 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB
await this.store.logEntry(task.id, refusalMessage, undefined, this.getRunContextFor(task.id));
return "sticky";
}
const settings = await mergeEffectiveSettings(this.store, task, await this.store.getSettings());
const integrationRef = task.mergeDetails?.mergeTargetBranch ?? task.baseBranch ?? task.executionStartBranch ?? await resolveIntegrationBranch(this.rootDir, undefined);
const inspection = await inspectBranchConflict({
@@ -16925,12 +16930,12 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB
}
if (inspection.kind === "reclaimable") {
await this.reclaimExistingWorktree(task, inspection.livePath, error.branchName, inspection.tipSha, inspection.taskAttributedCommitCount);
await this.reclaimExistingWorktree(task, inspection.livePath, error.branchName, inspection.tipSha, inspection.taskAttributedCommitCount, settings);
return "reclaimed";
}
if (inspection.kind === "fully-subsumed") {
await this.reclaimExistingWorktree(task, inspection.livePath, error.branchName, inspection.tipSha, 0);
await this.reclaimExistingWorktree(task, inspection.livePath, error.branchName, inspection.tipSha, 0, settings);
return "reclaimed";
}
@@ -17956,7 +17961,7 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB
if (inspection.kind === "reclaimable") {
const livePath = isInsideWorktreesDir(this.rootDir, inspection.livePath, settings)
? inspection.livePath
: await this.relocateReclaimableWorktree(inspection.livePath, path, taskId, settings);
: await this.normalizeReclaimableWorktreePath(inspection.livePath, path, taskId, settings);
await this.store.logEntry(
taskId,
`[recovery] reclaimed existing worktree for ${taskId} at ${livePath} (${inspection.taskAttributedCommitCount} commits preserved)`,
@@ -17968,7 +17973,7 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB
if (inspection.kind === "fully-subsumed") {
const livePath = isInsideWorktreesDir(this.rootDir, inspection.livePath, settings)
? inspection.livePath
: await this.relocateReclaimableWorktree(inspection.livePath, path, taskId, settings);
: await this.normalizeReclaimableWorktreePath(inspection.livePath, path, taskId, settings);
await this.store.logEntry(
taskId,
`[recovery] reclaimed existing worktree for ${taskId} at ${livePath} (0 commits preserved)`,
@@ -18024,25 +18029,40 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB
return null;
}
private async relocateReclaimableWorktree(
private async normalizeReclaimableWorktreePath(
sourcePath: string,
targetPath: string,
taskId: string,
settings: Partial<Settings>,
): Promise<string> {
if (!isInsideWorktreesDir(this.rootDir, targetPath, settings)) {
throw new NonRetryableWorktreeError(
`Refusing to relocate ${taskId} worktree to path outside configured worktrees directory: ${targetPath}`,
);
}
await mkdir(dirname(targetPath), { recursive: true });
const isRelocationActive = async (path: string) =>
this.hasActiveWorktreeBinding(taskId, path)
|| await this.isLiveCleanupRefusal(path, taskId);
try {
await execFileAsync("git", ["worktree", "move", sourcePath, targetPath], {
cwd: this.rootDir,
timeout: 120_000,
maxBuffer: 10 * 1024 * 1024,
const placement = await relocateReclaimableWorktreeIntoRoot({
rootDir: this.rootDir,
sourcePath,
targetPath,
taskId,
settings,
isPathActive: isRelocationActive,
});
if (placement.kind === "deferred-live") {
await this.store.logEntry(
taskId,
`[recovery] deferred relocation of active preserved worktree ${sourcePath}`,
sourcePath,
);
return placement.path;
}
if (placement.relocated) {
await this.store.logEntry(
taskId,
`[recovery] relocated preserved worktree from ${sourcePath} to ${placement.path}`,
placement.path,
);
}
return placement.path;
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
await this.store.logEntry(
@@ -18054,13 +18074,6 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB
`Could not relocate preserved ${taskId} worktree into the configured worktrees directory: ${detail}`,
);
}
await this.store.logEntry(
taskId,
`[recovery] relocated preserved worktree from ${sourcePath} to ${targetPath}`,
targetPath,
);
return targetPath;
}
private async tryFreshWorktreeAfterLiveConflict(input: {

View File

@@ -34,7 +34,7 @@ import { IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, IN_REV
import type { MeshLeaseManager } from "./mesh-lease-manager.js";
import { createLogger, schedulerLog } from "./logger.js";
import { mergeEffectiveSettings } from "./effective-settings.js";
import { RemovalReason, classifyTaskWorktree, getRegisteredWorktreeBranchMap, getRegisteredWorktreePaths, isUsableTaskWorktree, removeWorktree, resolveWorktreeBackend, scanIdleWorktrees, scanOrphanedBranches } from "./worktree-pool.js";
import { RemovalReason, classifyTaskWorktree, getRegisteredWorktreeBranchMap, getRegisteredWorktreePaths, isUsableTaskWorktree, relocateReclaimableWorktreeIntoRoot, removeWorktree, resolveWorktreeBackend, scanIdleWorktrees, scanOrphanedBranches } from "./worktree-pool.js";
import {
classifyMissingWorktreeSessionStartFailure,
extractMissingWorktreePathFromSessionStartFailure,
@@ -77,6 +77,7 @@ import { shouldReclaimWedgedMerge } from "./merge-reclaim-policy.js";
import { advanceIntegrationBranchRef } from "./merger-ref-update-advance.js";
import { isAiMergeContainerDir, resolveAiMergeRootPath, resolveLegacyAiMergeRootPath, resolveWorktreesDir } from "./worktree-paths.js";
import { canonicalFusionBranchName, resolveTaskWorkingBranch } from "./worktree-names.js";
import { preservedWorktreeTargetPathForTask } from "./worktree-pinning.js";
import { resolveIntegrationBranch } from "./integration-branch.js";
import { resolveBranchGroupMergeRouting } from "./group-merge-coordinator.js";
import type { OwnedLandedClassification } from "./merger.js";
@@ -3844,6 +3845,32 @@ export class SelfHealingManager {
const preservedCommitCount = inspection.kind === "fully-subsumed"
? 0
: inspection.taskAttributedCommitCount;
const placement = await relocateReclaimableWorktreeIntoRoot({
rootDir: this.options.rootDir,
sourcePath: inspection.livePath,
targetPath: preservedWorktreeTargetPathForTask(task.id, inspection.livePath, settings, this.options.rootDir),
taskId: task.id,
settings,
isPathActive: (path) =>
activeSessionRegistry.isPathActive(path)
|| executingTaskLock.has(task.id)
|| executingIds.has(task.id)
|| activeTaskIds.has(task.id.toUpperCase()),
});
if (placement.kind === "deferred-live") {
await this.store.logEntry(
task.id,
`[recovery] deferred relocation of active preserved worktree ${placement.path}`,
);
continue;
}
const reclaimedWorktreePath = placement.path;
if (placement.relocated) {
await this.store.logEntry(
task.id,
`[recovery] relocated preserved worktree from ${inspection.livePath} to ${reclaimedWorktreePath}`,
);
}
const stepSignature = buildResumeLimboStepSignature(task);
const hasActiveSessionSignal = Boolean(task.checkedOutBy) || activeTaskIds.has(task.id.toUpperCase());
const hasPriorSnapshot = typeof task.resumeLimboTipSha === "string" && typeof task.resumeLimboStepSignature === "string";
@@ -3868,6 +3895,7 @@ export class SelfHealingManager {
preserveResumeState: true,
});
await this.store.updateTask(task.id, {
...(placement.relocated ? { worktree: reclaimedWorktreePath } : {}),
resumeLimboCount: 0,
resumeLimboTipSha: inspection.tipSha,
resumeLimboStepSignature: stepSignature,
@@ -3908,7 +3936,7 @@ export class SelfHealingManager {
}
await this.store.updateTask(task.id, {
worktree: inspection.livePath,
worktree: reclaimedWorktreePath,
branch: task.branch,
paused: false,
pausedReason: undefined,
@@ -3920,7 +3948,7 @@ export class SelfHealingManager {
});
await this.store.logEntry(
task.id,
`[recovery] ${wasPausedBranchConflict ? "reclaim-paused-review" : "reclaim-self-owned"} ${task.id} at ${inspection.livePath} (${preservedCommitCount} commits preserved, tip ${inspection.tipSha.slice(0, 12)})`,
`[recovery] ${wasPausedBranchConflict ? "reclaim-paused-review" : "reclaim-self-owned"} ${task.id} at ${reclaimedWorktreePath} (${preservedCommitCount} commits preserved, tip ${inspection.tipSha.slice(0, 12)})`,
);
if (task.column === "in-review") {
@@ -3952,7 +3980,7 @@ export class SelfHealingManager {
metadata: {
taskId: task.id,
branch: task.branch,
worktreePath: inspection.livePath,
worktreePath: reclaimedWorktreePath,
existingTipSha: inspection.tipSha,
strandedCommitCount: inspection.kind === "fully-subsumed" ? 0 : inspection.strandedCommits.length,
subsumed: inspection.kind === "fully-subsumed",

View File

@@ -1,4 +1,5 @@
import type { Settings } from "@fusion/core";
import { basename } from "node:path";
import { resolveTaskWorktreePath } from "./worktree-paths.js";
/*
@@ -37,3 +38,15 @@ export function pinnedWorktreePathForTask(
): string {
return resolveTaskWorktreePath(rootDir, settings, pinnedWorktreeSlug(taskId));
}
/** Preserve the task-pinned naming invariant while normalizing legacy paths. */
export function preservedWorktreeTargetPathForTask(
taskId: string,
sourcePath: string,
settings: Pick<Settings, "worktreeNaming" | "worktreesDir"> | undefined,
rootDir: string,
): string {
return isTaskPinnedWorktreeNaming(settings)
? pinnedWorktreePathForTask(taskId, settings, rootDir)
: resolveTaskWorktreePath(rootDir, settings, basename(sourcePath));
}

View File

@@ -1,6 +1,7 @@
import { exec } from "node:child_process";
import { exec, execFile } from "node:child_process";
import { promisify } from "node:util";
import { existsSync, lstatSync, readdirSync, readFileSync, rmSync, realpathSync } from "node:fs";
import { mkdir } from "node:fs/promises";
import { basename, dirname, join, relative, resolve, isAbsolute } from "node:path";
import type { ColumnId, SecretsStore, Settings, TaskStore, WorktrunkSettings } from "@fusion/core";
import { assertCleanBranchAtBase, inspectBranchConflict } from "./branch-conflicts.js";
@@ -40,6 +41,7 @@ export {
} from "./worktrunk-installer.js";
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
// ── Worktrunk binary lazy resolver ─────────────────────────────────────────────
// Memoizes per (homedir, settings.binaryPath) so the resolution+install flow
@@ -384,6 +386,68 @@ export function isInsideWorktreesDir(
return isInsideConfiguredWorktreesDir(rootDir, settings, worktreePath);
}
export type ReclaimableWorktreePlacement =
| { kind: "ready"; path: string; relocated: boolean }
| { kind: "deferred-live"; path: string };
export interface RelocateReclaimableWorktreeInput {
rootDir: string;
sourcePath: string;
targetPath: string;
taskId: string;
settings?: Pick<Settings, "worktreeNaming" | "worktreesDir" | "worktrunk">;
isPathActive: (path: string) => boolean | Promise<boolean>;
}
/**
* Put a preserved, registered native checkout under the configured worktree
* root. Worktrunk-assigned paths remain backend-owned. The exact source path
* must be idle before it can move; callers treat a live result as deferred
* recovery rather than invalidating a running process cwd.
*/
export async function relocateReclaimableWorktreeIntoRoot(
input: RelocateReclaimableWorktreeInput,
): Promise<ReclaimableWorktreePlacement> {
const { rootDir, sourcePath, targetPath, taskId, settings, isPathActive } = input;
if (settings?.worktrunk?.enabled === true) {
return { kind: "ready", path: sourcePath, relocated: false };
}
if (isInsideWorktreesDir(rootDir, sourcePath, settings)) {
return { kind: "ready", path: sourcePath, relocated: false };
}
if (await isPathActive(sourcePath)) {
return { kind: "deferred-live", path: sourcePath };
}
if (!isInsideWorktreesDir(rootDir, targetPath, settings)) {
throw new Error(
`Refusing to relocate ${taskId} worktree to path outside configured worktrees directory: ${targetPath}`,
);
}
let resolvedTargetPath = targetPath;
if (existsSync(resolvedTargetPath) && settings?.worktreeNaming !== "task-id") {
const taskSuffix = taskId.toLowerCase();
const candidates = [
`${targetPath}-${taskSuffix}`,
...Array.from({ length: 5 }, (_, index) => `${targetPath}-${taskSuffix}-${index + 2}`),
];
const available = candidates.find((candidate) => !existsSync(candidate));
if (!available) {
throw new Error(`No available relocation target for ${taskId} worktree near ${targetPath}`);
}
resolvedTargetPath = available;
}
await mkdir(dirname(resolvedTargetPath), { recursive: true });
await execFileAsync("git", ["worktree", "move", sourcePath, resolvedTargetPath], {
cwd: rootDir,
timeout: 120_000,
maxBuffer: 10 * 1024 * 1024,
});
return { kind: "ready", path: resolvedTargetPath, relocated: true };
}
/**
* A pool of idle git worktrees that can be recycled across tasks.
*