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:
@@ -725,8 +725,14 @@ describe("In-progress task resume after restart", () => {
|
||||
// Run any pending microtasks (the async code in setTimeout)
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
// Task should move to todo then in-progress (not in-review)
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-963", "todo");
|
||||
// Task should move to todo then in-progress (not in-review). The
|
||||
// workflow-rerun bounce passes `preserveWorktree: true` so the
|
||||
// checkout doesn't briefly disappear during the hop.
|
||||
expect(store.moveTask).toHaveBeenCalledWith(
|
||||
"FN-963",
|
||||
"todo",
|
||||
expect.objectContaining({ preserveWorktree: true }),
|
||||
);
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-963", "in-progress");
|
||||
|
||||
vi.useRealTimers();
|
||||
@@ -963,7 +969,7 @@ describe("Scheduler after restart", () => {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
scheduler.stop();
|
||||
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-070", "in-progress");
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-070", "in-progress", expect.objectContaining({ allocateWorktree: expect.any(Function) }));
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-070", expect.objectContaining({ status: null, blockedBy: null }));
|
||||
expect(onSchedule).toHaveBeenCalledWith(todoTask);
|
||||
});
|
||||
@@ -1033,7 +1039,7 @@ describe("Scheduler after restart", () => {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
scheduler.stop();
|
||||
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-081", "in-progress");
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-081", "in-progress", expect.objectContaining({ allocateWorktree: expect.any(Function) }));
|
||||
|
||||
// 3. Executor resumes in-progress tasks
|
||||
vi.clearAllMocks();
|
||||
@@ -1549,7 +1555,7 @@ describe("Engine pause/unpause cycle", () => {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
// Scheduler should have moved todo task to in-progress
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-EP3", "in-progress");
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-EP3", "in-progress", expect.objectContaining({ allocateWorktree: expect.any(Function) }));
|
||||
|
||||
// Now simulate engine pause then unpause
|
||||
store.moveTask.mockClear();
|
||||
@@ -1573,7 +1579,7 @@ describe("Engine pause/unpause cycle", () => {
|
||||
scheduler.stop();
|
||||
|
||||
// The new task should have been scheduled after unpause
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-EP4", "in-progress");
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-EP4", "in-progress", expect.objectContaining({ allocateWorktree: expect.any(Function) }));
|
||||
});
|
||||
|
||||
it("concurrency slots freed after agent completes during enginePaused (soft pause)", async () => {
|
||||
|
||||
Reference in New Issue
Block a user