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.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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).
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
gsxdsm
2026-07-19 17:05:05 -07:00
committed by GitHub
parent e4a032d9d9
commit 3b7680c9dd
4 changed files with 80 additions and 0 deletions

View File

@@ -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.

View File

@@ -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);
});

View File

@@ -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");

View File

@@ -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 });