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:
@@ -94,6 +94,48 @@ export function generateReservedWorktreeName(
|
||||
return `${baseName}-${suffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan a worktree directory path for a task that is about to enter
|
||||
* `in-progress`. Returns the absolute path under `<rootDir>/.worktrees/`.
|
||||
*
|
||||
* If the task already carries a `worktree` value, it is reused — the
|
||||
* caller is responsible for ensuring it does not collide with another
|
||||
* active task. Otherwise a name is generated according to `naming`,
|
||||
* avoiding any names already in `reservedNames`.
|
||||
*
|
||||
* Shared by the scheduler dispatch path and the manual-move HTTP route
|
||||
* so both allocate via the same collision rules.
|
||||
*/
|
||||
export function planTaskWorktreePath(
|
||||
task: { id: string; title?: string | null; description: string; worktree?: string | null },
|
||||
rootDir: string,
|
||||
naming: string | undefined,
|
||||
reservedNames: Set<string>,
|
||||
): string {
|
||||
if (task.worktree) {
|
||||
const existingName = task.worktree.split("/").filter(Boolean).pop();
|
||||
if (existingName) reservedNames.add(existingName);
|
||||
return task.worktree;
|
||||
}
|
||||
|
||||
let worktreeName: string;
|
||||
switch (naming || "random") {
|
||||
case "task-id":
|
||||
worktreeName = task.id.toLowerCase();
|
||||
break;
|
||||
case "task-title":
|
||||
worktreeName = slugify(task.title || task.description.slice(0, 60));
|
||||
break;
|
||||
case "random":
|
||||
default:
|
||||
worktreeName = generateReservedWorktreeName(rootDir, reservedNames);
|
||||
break;
|
||||
}
|
||||
|
||||
reservedNames.add(worktreeName);
|
||||
return join(rootDir, ".worktrees", worktreeName);
|
||||
}
|
||||
|
||||
function getExistingWorktreeNames(worktreesDir: string): Set<string> {
|
||||
if (!existsSync(worktreesDir)) {
|
||||
return new Set();
|
||||
|
||||
Reference in New Issue
Block a user