diff --git a/packages/engine/src/__tests__/executor-worktree-conflict.test.ts b/packages/engine/src/__tests__/executor-worktree-conflict.test.ts index f2f0ff07e6..10b2710af3 100644 --- a/packages/engine/src/__tests__/executor-worktree-conflict.test.ts +++ b/packages/engine/src/__tests__/executor-worktree-conflict.test.ts @@ -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"); diff --git a/packages/engine/src/__tests__/executor-worktree.test.ts b/packages/engine/src/__tests__/executor-worktree.test.ts index 296b5db82d..2929359825 100644 --- a/packages/engine/src/__tests__/executor-worktree.test.ts +++ b/packages/engine/src/__tests__/executor-worktree.test.ts @@ -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 diff --git a/packages/engine/src/__tests__/self-healing.test.ts b/packages/engine/src/__tests__/self-healing.test.ts index 8d86a9dd49..f08c3cf31c 100644 --- a/packages/engine/src/__tests__/self-healing.test.ts +++ b/packages/engine/src/__tests__/self-healing.test.ts @@ -91,6 +91,7 @@ vi.mock("../worktree-pool.js", () => ({ getRegisteredWorktreePaths: vi.fn().mockResolvedValue(new Set()), getRegisteredWorktreeBranchMap: vi.fn().mockResolvedValue(new Map()), 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); diff --git a/packages/engine/src/__tests__/worktree-pinning.test.ts b/packages/engine/src/__tests__/worktree-pinning.test.ts index b35159d834..310c9e4d60 100644 --- a/packages/engine/src/__tests__/worktree-pinning.test.ts +++ b/packages/engine/src/__tests__/worktree-pinning.test.ts @@ -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"); + }); + }); }); diff --git a/packages/engine/src/__tests__/worktree-reclaim-placement.real-git.test.ts b/packages/engine/src/__tests__/worktree-reclaim-placement.real-git.test.ts new file mode 100644 index 0000000000..b1a4dd85f7 --- /dev/null +++ b/packages/engine/src/__tests__/worktree-reclaim-placement.real-git.test.ts @@ -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); + }); +}); diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index b0fd1a3242..226d74ff56 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -9,9 +9,9 @@ const execFileAsync = promisify(execFile); const WORKFLOW_THINKING_LEVEL_SET: ReadonlySet = 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, ): Promise { - 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, ): Promise { - 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: { diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index f6e4751856..b27cb8e6c1 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -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", diff --git a/packages/engine/src/worktree-pinning.ts b/packages/engine/src/worktree-pinning.ts index a4c6828e54..c4053fbacb 100644 --- a/packages/engine/src/worktree-pinning.ts +++ b/packages/engine/src/worktree-pinning.ts @@ -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 | undefined, + rootDir: string, +): string { + return isTaskPinnedWorktreeNaming(settings) + ? pinnedWorktreePathForTask(taskId, settings, rootDir) + : resolveTaskWorktreePath(rootDir, settings, basename(sourcePath)); +} diff --git a/packages/engine/src/worktree-pool.ts b/packages/engine/src/worktree-pool.ts index b8f1669822..f26132dac6 100644 --- a/packages/engine/src/worktree-pool.ts +++ b/packages/engine/src/worktree-pool.ts @@ -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; + isPathActive: (path: string) => boolean | Promise; +} + +/** + * 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 { + 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. *