diff --git a/.changeset/fix-pooled-worktree-ownership.md b/.changeset/fix-pooled-worktree-ownership.md new file mode 100644 index 0000000000..e986f26977 --- /dev/null +++ b/.changeset/fix-pooled-worktree-ownership.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Prevent stale worktree ownership metadata from blocking commits after a pooled checkout is reassigned. +category: fix +dev: Refreshes the task identity guard after pooled worktrees switch branches. diff --git a/packages/engine/src/__tests__/executor-worktree.test.ts b/packages/engine/src/__tests__/executor-worktree.test.ts index cdedbac4b3..3d29a36681 100644 --- a/packages/engine/src/__tests__/executor-worktree.test.ts +++ b/packages/engine/src/__tests__/executor-worktree.test.ts @@ -2447,6 +2447,19 @@ describe("TaskExecutor worktree pool integration", () => { expect.objectContaining({ agentId: "executor" }), ); + /* + FNXC:WorktreeIdentity 2026-07-19-16:05: + Reassigning a pooled checkout must refresh its task marker after the pool + changes branches; otherwise the shared pre-commit hook still names the + previous owner and blocks the new task's first commit. + */ + expect(mockedInstallTaskWorktreeIdentityGuard).toHaveBeenCalledWith( + expect.objectContaining({ + worktreePath: "/tmp/test/.worktrees/idle-wt", + taskId: "FN-020", + }), + ); + // Pool should be empty after acquire expect(pool.size).toBe(0); }); diff --git a/packages/engine/src/__tests__/reliability-interactions/precommit-identity-guard.real-git.test.ts b/packages/engine/src/__tests__/reliability-interactions/precommit-identity-guard.real-git.test.ts index e6bb7614fa..196ffdc5d4 100644 --- a/packages/engine/src/__tests__/reliability-interactions/precommit-identity-guard.real-git.test.ts +++ b/packages/engine/src/__tests__/reliability-interactions/precommit-identity-guard.real-git.test.ts @@ -11,6 +11,50 @@ function git(dir: string, cmd: string): string { } describe("pre-commit identity guard (real git)", () => { + it("refreshes a recycled worktree owner so the reassigned task can commit", async () => { + const rootDir = mkdtempSync(join(tmpdir(), "fn-8400-precommit-")); + const worktreeDir = join(rootDir, "wt-pooled"); + + try { + git(rootDir, "git init -b main"); + git(rootDir, 'git config user.email "test@example.com"'); + git(rootDir, 'git config user.name "Test"'); + writeFileSync(join(rootDir, "README.md"), "init\n"); + git(rootDir, "git add README.md && git commit -m 'init'"); + + git(rootDir, "git worktree add -b fusion/fn-old wt-pooled HEAD"); + await installTaskWorktreeIdentityGuard({ worktreePath: worktreeDir, taskId: "FN-OLD" }); + + // Pool preparation reuses the same linked worktree for a new task branch. + git(worktreeDir, "git checkout -B fusion/fn-new main"); + writeFileSync(join(worktreeDir, "new-owner.txt"), "new owner\n"); + git(worktreeDir, "git add new-owner.txt"); + + const staleOwnerCommit = spawnSync("git", ["commit", "-m", "fix(FN-NEW): blocked by stale owner"], { + cwd: worktreeDir, + encoding: "utf-8", + }); + expect(staleOwnerCommit.status).toBe(1); + expect(`${staleOwnerCommit.stderr}${staleOwnerCommit.stdout}`).toContain( + "fusion: refusing commit — worktree owns FN-OLD but HEAD is fusion/fn-new", + ); + + await installTaskWorktreeIdentityGuard({ worktreePath: worktreeDir, taskId: "FN-NEW" }); + + const refreshedOwnerCommit = spawnSync("git", ["commit", "-m", "fix(FN-NEW): accepted after owner refresh"], { + cwd: worktreeDir, + encoding: "utf-8", + }); + expect(refreshedOwnerCommit.status).toBe(0); + + const taskIdPathRaw = git(worktreeDir, "git rev-parse --git-path fusion-task-id"); + const taskIdPath = resolve(worktreeDir, taskIdPathRaw); + expect(readFileSync(taskIdPath, "utf-8").trim()).toBe("FN-NEW"); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } + }, 30_000); + it("uses per-worktree metadata so a shared stale hook still allows sibling owner commits", async () => { const rootDir = mkdtempSync(join(tmpdir(), "fn-5266-precommit-")); const staleDir = join(rootDir, "wt-stale"); diff --git a/packages/engine/src/worktree-acquisition.ts b/packages/engine/src/worktree-acquisition.ts index 6a34d7ed0d..52741d3dcd 100644 --- a/packages/engine/src/worktree-acquisition.ts +++ b/packages/engine/src/worktree-acquisition.ts @@ -736,6 +736,22 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro worktreePath = await resolveTaskWorktreePathForBackend(rootDir, fallbackName, settings, backend, branchName); branch = branchName; } else { + /* + FNXC:WorktreeIdentity 2026-07-19-16:05: + Pool preparation changes the checked-out branch but linked-worktree + identity metadata survives the prior occupant. Refresh the marker and + shared hooks before exposing the checkout to the new task. + */ + await installTaskWorktreeIdentityGuard({ + worktreePath, + taskId: task.id, + commitMsgHookEnabled: settings.commitMsgHookEnabled, + taskPrefix: settings.taskPrefix, + taskAttributionTrailerName: settings.taskAttributionTrailerNames?.[0], + commitAuthorEnabled: settings.commitAuthorEnabled, + commitAuthorName: settings.commitAuthorName, + commitAuthorEmail: settings.commitAuthorEmail, + }); acquiredFromPool = true; logger?.log(`Acquired worktree from pool: ${worktreePath}`); await store.updateTask(task.id, { worktree: worktreePath, branch });