diff --git a/.changeset/fix-worktree-reclaim-branch-provenance.md b/.changeset/fix-worktree-reclaim-branch-provenance.md new file mode 100644 index 0000000000..3dc3b2ac1f --- /dev/null +++ b/.changeset/fix-worktree-reclaim-branch-provenance.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Preserve task branches when Fusion reclaims an existing task worktree. +category: fix +dev: Supplies engine branch-write provenance during branch-conflict reclamation. diff --git a/packages/engine/src/__tests__/executor-worktree.test.ts b/packages/engine/src/__tests__/executor-worktree.test.ts index 3c4dc45b59..65a7e3096f 100644 --- a/packages/engine/src/__tests__/executor-worktree.test.ts +++ b/packages/engine/src/__tests__/executor-worktree.test.ts @@ -1175,7 +1175,58 @@ describe("TaskExecutor worktree recovery", () => { 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 })); + expect(store.updateTask).toHaveBeenCalledWith( + "FN-8400", + expect.objectContaining({ + worktree: targetPath, + branch: "fusion/fn-8400", + branchWriteOrigin: "engine", + }), + ); + }); + + it("preserves operator provenance when reclaiming an operator-owned Fusion-namespaced branch", async () => { + const store = createMockStore(); + store.getSettings.mockResolvedValue({ worktreesDir: ".worktrees" } as any); + const executor = createWorktreeExecutor(store, "/tmp/test"); + const conflictPath = "/tmp/legacy-worktrees/recover-fn-8401"; + const targetPath = "/tmp/test/.worktrees/recover-fn-8401"; + const branch = "fusion/fn-8401"; + vi.spyOn(branchConflictModule, "inspectBranchConflict").mockResolvedValueOnce({ + kind: "reclaimable", + livePath: conflictPath, + tipSha: "70b47804bc6f27659638e17ac7cf279ed343ff6f", + taskAttributedCommitCount: 1, + strandedCommits: [{ sha: "70b47804bc6f27659638e17ac7cf279ed343ff6f", subject: "fix(FN-8401): preserve implementation" }], + } as any); + vi.spyOn(executor as any, "normalizeReclaimableWorktreePath").mockResolvedValue(targetPath); + + const result = await (executor as any).handleBranchConflict( + { + ...makeTask("FN-8401"), + branch, + worktree: conflictPath, + branchContext: { branchOverride: { by: "operator", at: "2026-08-22T22:00:00.000Z", branch } }, + }, + new BranchConflictError({ + branchName: branch, + conflictingWorktreePath: conflictPath, + existingTipSha: "70b47804bc6f27659638e17ac7cf279ed343ff6f", + strandedCommits: [], + startPoint: "main", + recommendedAction: "reclaim", + }), + ); + + expect(result).toBe("reclaimed"); + expect(store.updateTask).toHaveBeenCalledWith( + "FN-8401", + expect.objectContaining({ + worktree: targetPath, + branch, + branchWriteOrigin: "operator", + }), + ); }); it("uses the task-pinned target when normalizing a branch-conflict reclaim", async () => { diff --git a/packages/engine/src/executor/worktree-branch-conflict-handle.ts b/packages/engine/src/executor/worktree-branch-conflict-handle.ts index 0fd1a394af..27a2cca0bb 100644 --- a/packages/engine/src/executor/worktree-branch-conflict-handle.ts +++ b/packages/engine/src/executor/worktree-branch-conflict-handle.ts @@ -5,7 +5,7 @@ */ import { exec } from "node:child_process"; import { promisify } from "node:util"; -import { isFusionDeletableBranch, type Settings, type Task, type TaskStore } from "@fusion/core"; +import { classifyTaskBranchOrigin, isFusionDeletableBranch, type Settings, type Task, type TaskStore } from "@fusion/core"; import { assertCleanBranchAtBase, BranchConflictError, @@ -55,7 +55,11 @@ export async function reclaimExistingWorktree( ): Promise { const targetPath = preservedWorktreeTargetPathForTask(task.id, livePath, settings, deps.rootDir); const normalizedPath = await deps.normalizeReclaimableWorktreePath(livePath, targetPath, task.id, settings); - await deps.store.updateTask(task.id, { worktree: normalizedPath, branch }); + await deps.store.updateTask(task.id, { + worktree: normalizedPath, + branch, + branchWriteOrigin: classifyTaskBranchOrigin(task, branch) === "operator-supplied" ? "operator" : "engine", + }); const latestTask = await deps.store.getTask(task.id); const baseRef = await resolveDiffBaseRef(normalizedPath, latestTask.baseCommitSha); if (baseRef) {