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:
7
.changeset/fix-pooled-worktree-ownership.md
Normal file
7
.changeset/fix-pooled-worktree-ownership.md
Normal 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.
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user