fix(engine): prevent worktree collisions on manual task moves

Two related bugs let two in-progress tasks share a single
.worktrees/<name> directory:

1. The dashboard POST /tasks/:id/move route promoted tasks to
   in-progress without allocating a fresh worktree path, so a queued
   task carrying a stale worktree field from a prior preserveResumeState
   requeue could land in-progress on a directory already held by another
   active task.

2. moveTask({preserveResumeState:true}) kept the worktree pointer on
   requeue. When the on-disk checkout was later removed or reassigned,
   the next dispatch collided with a worktree the scheduler had handed
   to another task.

moveTask now releases the worktree pointer on every reopen-to-todo hop
(branch is kept so committed progress survives via git worktree add
<path> <branch>). A new preserveWorktree option opts internal bounces
out of the release. moveTask also accepts an allocateWorktree callback
that runs under a new cross-task allocation lock in TaskStore, so two
concurrent moves cannot pick the same name from a stale snapshot. Both
the manual-move route and the scheduler dispatch path flow through the
allocator and share the lock.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-06 07:23:38 -07:00
parent a143bcc4f5
commit 1100b39cff
11 changed files with 308 additions and 73 deletions

View File

@@ -8,6 +8,7 @@ import {
resolveTitleSummarizerSettingsModel,
validateNodeOverrideChange,
} from "@fusion/core";
import { planTaskWorktreePath } from "@fusion/engine";
import { ApiError, badRequest, conflict, notFound } from "../api-error.js";
import type { ApiRoutesContext } from "./types.js";
@@ -228,8 +229,29 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
if (preserveProgress != null && typeof preserveProgress !== "boolean") {
throw badRequest("preserveProgress must be a boolean");
}
// When manually promoting to in-progress, supply an allocator so
// moveTask assigns a worktree path under its cross-task allocation
// lock. This mirrors scheduler dispatch semantics — without it, a
// user-initiated move would land the task in-progress with a stale
// (or null) worktree and could collide with another active task.
// The executor's createWorktree path will reuse `task.branch` if it
// already exists, so any prior committed progress survives even
// though the on-disk worktree directory is freshly allocated.
let allocateWorktree: ((reservedNames: Set<string>) => string | null) | undefined;
if ((column as Column) === "in-progress") {
const existing = await scopedStore.getTask(req.params.id);
if (existing) {
const settings = await scopedStore.getSettings();
const rootDir = scopedStore.getRootDir();
allocateWorktree = (reservedNames) =>
planTaskWorktreePath(existing, rootDir, settings.worktreeNaming, reservedNames);
}
}
const task = await scopedStore.moveTask(req.params.id, column as Column, {
preserveProgress,
allocateWorktree,
});
res.json(task);
} catch (err: unknown) {