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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user