From 3b7680c9dd1c1cfb4b71fc16a2399da30bb3d8e2 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 19 Jul 2026 17:05:05 -0700 Subject: [PATCH] fix(engine): refresh pooled worktree ownership (#2348) ## Summary Pooled worktrees no longer retain the previous task's identity after reassignment, preventing valid commits from being rejected when the checked-out branch and stale ownership marker disagree. Fusion now refreshes the identity guard immediately after the pool prepares the new branch and before the checkout is exposed to the task, preserving the cross-task commit safety check. Regression coverage exercises the pooled acquisition path and confirms the new task identity is installed. Related: FN-8400 ## Validation - 85 focused engine tests passed across executor worktree and acquisition coverage. - `@fusion/engine` typecheck passed. - Changed implementation lint and changeset validation passed. ## Summary by CodeRabbit * **Bug Fixes** * Prevented stale pooled worktree ownership metadata from blocking commits after a pooled checkout is reassigned. * Refreshed task identity metadata when pooled worktrees are reused across task branches, ensuring hooks/attribution settings are correctly applied. * **Tests** * Added/updated coverage to confirm the task identity guard is reinstalled when acquiring pooled worktrees (including a real-git scenario proving commits succeed after stale identity is cleared). --- .changeset/fix-pooled-worktree-ownership.md | 7 +++ .../src/__tests__/executor-worktree.test.ts | 13 ++++++ .../precommit-identity-guard.real-git.test.ts | 44 +++++++++++++++++++ packages/engine/src/worktree-acquisition.ts | 16 +++++++ 4 files changed, 80 insertions(+) create mode 100644 .changeset/fix-pooled-worktree-ownership.md 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 });