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 9f2b96e235
commit 4866f341bd
11 changed files with 308 additions and 73 deletions

View File

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