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

@@ -0,0 +1,37 @@
---
"@runfusion/fusion": patch
---
Fix worktree collisions when tasks are manually moved into in-progress.
Two related bugs caused two in-progress tasks to 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 owned by another active task.
2. `TaskStore.moveTask({ preserveResumeState: true })` kept the
worktree pointer on requeue. When the on-disk checkout was later
removed or reassigned, the next dispatch could collide with a
worktree the scheduler had since handed to another task.
Fixes:
- `moveTask` now releases the worktree pointer on every reopen-to-todo
hop. The `branch` field is preserved so the next run reattaches via
`git worktree add <path> <branch>` and resumes any committed
progress. A new `preserveWorktree: true` option opts internal
bounces (workflow-rerun) out of the release so listeners never see
an interim `worktree=null` state.
- `moveTask` accepts an `allocateWorktree` callback that runs under a
cross-task allocation lock in `TaskStore`, building `reservedNames`
from a fresh `listTasks` snapshot so two concurrent moves cannot
pick the same name.
- The manual-move route and the scheduler dispatch path both flow
through the new allocator, sharing the lock.
- `planTaskWorktreePath` is exported from `@fusion/engine` for
consumers that need to plan worktree paths the same way the
scheduler does.

View File

@@ -6350,15 +6350,17 @@ Task with acceptance criteria
expect(doneMoved.executionStartedAt).toBeUndefined(); expect(doneMoved.executionStartedAt).toBeUndefined();
}); });
it("preserveResumeState takes precedence when preserveProgress and preserveResumeState are both true", async () => { it("preserveResumeState keeps step progress and timing but always releases the worktree", async () => {
const task = await createTaskWithSteps(); const task = await createTaskWithSteps();
await store.moveTask(task.id, "todo"); await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress"); await store.moveTask(task.id, "in-progress");
await setMixedStepStatuses(task.id); await setMixedStepStatuses(task.id);
const startedAt = new Date().toISOString();
await store.updateTask(task.id, { await store.updateTask(task.id, {
currentStep: 2, currentStep: 2,
worktree: "/tmp/worktree", worktree: "/tmp/worktree",
executionStartedAt: new Date().toISOString(), branch: "fusion/fn-test",
executionStartedAt: startedAt,
executionCompletedAt: new Date().toISOString(), executionCompletedAt: new Date().toISOString(),
}); });
@@ -6370,9 +6372,68 @@ Task with acceptance criteria
expect(moved.steps[0].status).toBe("done"); expect(moved.steps[0].status).toBe("done");
expect(moved.steps[1].status).toBe("in-progress"); expect(moved.steps[1].status).toBe("in-progress");
expect(moved.currentStep).toBe(2); expect(moved.currentStep).toBe(2);
expect(moved.worktree).toBe("/tmp/worktree"); // Worktree is always released on requeue so the directory can be
expect(moved.executionStartedAt).toBeDefined(); // reused by another task; the branch stays so progress is kept.
expect(moved.worktree).toBeUndefined();
expect(moved.branch).toBe("fusion/fn-test");
expect(moved.executionStartedAt).toBe(startedAt);
expect(moved.executionCompletedAt).toBeUndefined(); expect(moved.executionCompletedAt).toBeUndefined();
// Round-trip: when the task is re-promoted to in-progress with a
// fresh allocator, the branch reference must survive the requeue
// so the executor can reattach to it via createFromExistingBranch
// and resume the in-flight changes. Guards against regressions in
// the in-review → todo full-reset path leaking into other paths.
const repromoted = await store.moveTask(task.id, "in-progress", {
allocateWorktree: () => "/tmp/worktree-fresh",
});
expect(repromoted.branch).toBe("fusion/fn-test");
expect(repromoted.worktree).toBe("/tmp/worktree-fresh");
});
it("preserveWorktree keeps the directory across an internal bounce", async () => {
const task = await createTaskWithSteps();
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.updateTask(task.id, { worktree: "/tmp/wt-bounce" });
const moved = await store.moveTask(task.id, "todo", {
preserveResumeState: true,
preserveWorktree: true,
});
// The bounce path keeps the same checkout assigned so listeners
// never observe an interim worktree=null state and self-healing
// can't reclaim the directory as idle.
expect(moved.worktree).toBe("/tmp/wt-bounce");
});
it("allocateWorktree assigns a path under the cross-task lock and avoids names already in use", async () => {
const a = await createTaskWithSteps();
const b = await createTaskWithSteps();
await store.moveTask(a.id, "todo");
await store.moveTask(a.id, "in-progress");
await store.updateTask(a.id, { worktree: "/tmp/.worktrees/eager-daisy" });
await store.moveTask(b.id, "todo");
const seenReserved: Set<string>[] = [];
const moved = await store.moveTask(b.id, "in-progress", {
allocateWorktree: (reservedNames) => {
seenReserved.push(new Set(reservedNames));
// Caller picks a name; if it collides with reservedNames the
// caller is responsible for choosing a different one. Here we
// assert the reservedNames snapshot reflects task A's
// assignment, then return a non-colliding path.
return "/tmp/.worktrees/swift-falcon";
},
});
expect(seenReserved).toHaveLength(1);
expect(seenReserved[0].has("eager-daisy")).toBe(true);
// The allocator's task itself must not appear in reservedNames —
// a task should never be told to avoid its own current name.
expect(seenReserved[0].has("swift-falcon")).toBe(false);
expect(moved.worktree).toBe("/tmp/.worktrees/swift-falcon");
}); });
it("resets steps when moving from in-review to todo", async () => { it("resets steps when moving from in-review to todo", async () => {

View File

@@ -482,6 +482,13 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
private debounceMs = 150; private debounceMs = 150;
/** Per-task promise chain for serializing writes */ /** Per-task promise chain for serializing writes */
private taskLocks: Map<string, Promise<void>> = new Map(); private taskLocks: Map<string, Promise<void>> = new Map();
/**
* Cross-task lock for worktree path allocation. Serializes the
* read-tasks → pick-name → write-task sequence so two concurrent
* `moveTask` calls (or a moveTask vs. a scheduler dispatch) cannot
* pick the same name from a stale snapshot.
*/
private worktreeAllocationLock: Promise<void> = Promise.resolve();
/** Promise chain for serializing config.json read-modify-write cycles */ /** Promise chain for serializing config.json read-modify-write cycles */
private configLock: Promise<void> = Promise.resolve(); private configLock: Promise<void> = Promise.resolve();
/** Cached workflow steps — invalidated on create/update/delete */ /** Cached workflow steps — invalidated on create/update/delete */
@@ -1503,6 +1510,21 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
* Serialize all mutations to a given task's task.json by chaining promises * Serialize all mutations to a given task's task.json by chaining promises
* per task ID. Concurrent callers for the same ID will queue behind each other. * per task ID. Concurrent callers for the same ID will queue behind each other.
*/ */
private withWorktreeAllocationLock<T>(fn: () => Promise<T>): Promise<T> {
let resolve: () => void;
const next = new Promise<void>((r) => { resolve = r; });
const prev = this.worktreeAllocationLock;
this.worktreeAllocationLock = next;
return prev.then(async () => {
try {
return await fn();
} finally {
resolve!();
}
});
}
private withTaskLock<T>(id: string, fn: () => Promise<T>): Promise<T> { private withTaskLock<T>(id: string, fn: () => Promise<T>): Promise<T> {
const prev = this.taskLocks.get(id) ?? Promise.resolve(); const prev = this.taskLocks.get(id) ?? Promise.resolve();
let resolve: () => void; let resolve: () => void;
@@ -2841,6 +2863,26 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
* (worktree and wall-clock timing fields). * (worktree and wall-clock timing fields).
*/ */
preserveProgress?: boolean; preserveProgress?: boolean;
/**
* Skip the default "release worktree on requeue" behavior. Used by
* internal bounce paths (e.g. workflow-rerun) that immediately
* promote the task back to in-progress on the same checkout, where
* publishing an interim `worktree=null` state to listeners would be
* misleading. Has no effect on transitions that don't otherwise
* clear the worktree.
*/
preserveWorktree?: boolean;
/**
* When transitioning to in-progress on a task that has no worktree
* assigned, invoke this allocator to pick a path. The store calls
* the allocator with a fresh `reservedNames` set (built from every
* other task's current `worktree`) inside a cross-task allocation
* lock, so two concurrent moves cannot pick the same name. The
* allocator should return an absolute path or `null` to skip
* allocation. Provided by callers (the manual-move route, the
* scheduler) so the store stays free of worktree-naming policy.
*/
allocateWorktree?: (reservedNames: Set<string>) => string | null;
}, },
): Promise<Task> { ): Promise<Task> {
return this.withTaskLock(id, async () => { return this.withTaskLock(id, async () => {
@@ -2918,8 +2960,24 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
const preserveStepProgress = const preserveStepProgress =
options?.preserveResumeState || (options?.preserveProgress === true && hasNonPendingStepProgress); options?.preserveResumeState || (options?.preserveProgress === true && hasNonPendingStepProgress);
if (!options?.preserveResumeState) { // Default: release the on-disk worktree directory on requeue. The
// checkout may have been removed, may now collide with another
// task's allocation, or may simply be abandoned by the bounce.
// `task.branch` is intentionally left intact so the next run can
// reattach to the same line of work — the executor's worktree
// creation path falls back to `git worktree add <path> <branch>`
// when the branch already exists, so any committed progress is
// preserved even though a fresh directory is allocated.
//
// Opt-out: internal bounces that immediately re-promote the task
// to in-progress on the same checkout (e.g. workflow-rerun) pass
// `preserveWorktree: true` so listeners never observe an interim
// `worktree=null` state.
if (!options?.preserveWorktree) {
task.worktree = undefined; task.worktree = undefined;
}
if (!options?.preserveResumeState) {
// Reset wall-clock runtime so the next run gets a fresh timer. // Reset wall-clock runtime so the next run gets a fresh timer.
task.executionStartedAt = undefined; task.executionStartedAt = undefined;
task.executionCompletedAt = undefined; task.executionCompletedAt = undefined;
@@ -2962,6 +3020,29 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
task.nextRecoveryAt = undefined; task.nextRecoveryAt = undefined;
} }
// Atomic worktree allocation on transition to in-progress.
// Wrapped in withWorktreeAllocationLock so the read-tasks → pick-name
// sequence cannot interleave with another concurrent moveTask. The
// caller supplies the naming policy via the `allocateWorktree`
// callback; the store builds `reservedNames` here so the snapshot
// is fresh under the global lock.
if (toColumn === "in-progress" && !task.worktree && options?.allocateWorktree) {
const allocator = options.allocateWorktree;
const allocated = await this.withWorktreeAllocationLock(async () => {
const others = await this.listTasks({ slim: true, includeArchived: false });
const reservedNames = new Set<string>();
for (const other of others) {
if (other.id === id || !other.worktree) continue;
const name = other.worktree.split("/").filter(Boolean).pop();
if (name) reservedNames.add(name);
}
return allocator(reservedNames);
});
if (allocated) {
task.worktree = allocated;
}
}
await this.atomicWriteTaskJson(dir, task); await this.atomicWriteTaskJson(dir, task);
if (toColumn === "done") { if (toColumn === "done") {
this.clearLinkedAgentTaskIds(id, task.updatedAt); this.clearLinkedAgentTaskIds(id, task.updatedAt);

View File

@@ -8,6 +8,7 @@ import {
resolveTitleSummarizerSettingsModel, resolveTitleSummarizerSettingsModel,
validateNodeOverrideChange, validateNodeOverrideChange,
} from "@fusion/core"; } from "@fusion/core";
import { planTaskWorktreePath } from "@fusion/engine";
import { ApiError, badRequest, conflict, notFound } from "../api-error.js"; import { ApiError, badRequest, conflict, notFound } from "../api-error.js";
import type { ApiRoutesContext } from "./types.js"; import type { ApiRoutesContext } from "./types.js";
@@ -228,8 +229,29 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
if (preserveProgress != null && typeof preserveProgress !== "boolean") { if (preserveProgress != null && typeof preserveProgress !== "boolean") {
throw badRequest("preserveProgress must be a 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, { const task = await scopedStore.moveTask(req.params.id, column as Column, {
preserveProgress, preserveProgress,
allocateWorktree,
}); });
res.json(task); res.json(task);
} catch (err: unknown) { } catch (err: unknown) {

View File

@@ -8649,7 +8649,7 @@ describe("Workflow Steps Execution", () => {
// todo must flag preserveResumeState so the workflow-rerun bounce keeps // todo must flag preserveResumeState so the workflow-rerun bounce keeps
// the worktree and accumulated step progress through the transient // the worktree and accumulated step progress through the transient
// todo state on its way back to in-progress. // todo state on its way back to in-progress.
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo", { preserveResumeState: true }); expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo", { preserveResumeState: true, preserveWorktree: true });
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-progress"); expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-progress");
// onComplete should NOT be called (task is being retried, not completed) // onComplete should NOT be called (task is being retried, not completed)
@@ -8784,7 +8784,7 @@ describe("Workflow Steps Execution", () => {
// todo must flag preserveResumeState so the workflow-rerun bounce keeps // todo must flag preserveResumeState so the workflow-rerun bounce keeps
// the worktree and accumulated step progress through the transient // the worktree and accumulated step progress through the transient
// todo state on its way back to in-progress. // todo state on its way back to in-progress.
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo", { preserveResumeState: true }); expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo", { preserveResumeState: true, preserveWorktree: true });
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-progress"); expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-progress");
// onComplete should NOT be called (task is being retried, not completed) // onComplete should NOT be called (task is being retried, not completed)
@@ -8925,7 +8925,7 @@ describe("Workflow Steps Execution", () => {
await new Promise<void>((resolve) => queueMicrotask(resolve)); await new Promise<void>((resolve) => queueMicrotask(resolve));
// (2) bounce uses preserveResumeState so step progress + worktree survive // (2) bounce uses preserveResumeState so step progress + worktree survive
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo", { preserveResumeState: true }); expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo", { preserveResumeState: true, preserveWorktree: true });
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-progress"); expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-progress");
expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "in-review"); expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "in-review");
expect(onError).not.toHaveBeenCalled(); expect(onError).not.toHaveBeenCalled();
@@ -11880,7 +11880,7 @@ describe("TaskExecutor watchdogs", () => {
executionStartedAt: originalExecutionStartedAt, executionStartedAt: originalExecutionStartedAt,
}); });
expect(store.moveTask.mock.calls).toEqual([ expect(store.moveTask.mock.calls).toEqual([
["FN-WD-4", "todo", { preserveResumeState: true }], ["FN-WD-4", "todo", { preserveResumeState: true, preserveWorktree: true }],
["FN-WD-4", "in-progress"], ["FN-WD-4", "in-progress"],
]); ]);
}); });
@@ -12716,7 +12716,7 @@ describe("StepSessionExecutor integration", () => {
// Task should move to todo then in-progress (not in-review). The // Task should move to todo then in-progress (not in-review). The
// workflow-rerun bounce flags preserveResumeState so the worktree and // workflow-rerun bounce flags preserveResumeState so the worktree and
// accumulated step progress survive the transient todo state. // accumulated step progress survive the transient todo state.
expect(store.moveTask).toHaveBeenCalledWith("FN-200", "todo", { preserveResumeState: true }); expect(store.moveTask).toHaveBeenCalledWith("FN-200", "todo", { preserveResumeState: true, preserveWorktree: true });
expect(store.moveTask).toHaveBeenCalledWith("FN-200", "in-progress"); expect(store.moveTask).toHaveBeenCalledWith("FN-200", "in-progress");
vi.useRealTimers(); vi.useRealTimers();

View File

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

View File

@@ -244,7 +244,7 @@ describe("Scheduler", () => {
await flushAsyncWork(); await flushAsyncWork();
// Verify schedule() was called (moveTask should be called since task can start) // Verify schedule() was called (moveTask should be called since task can start)
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-progress"); expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-progress", expect.objectContaining({ allocateWorktree: expect.any(Function) }));
}); });
it("resets mergeRetries when dispatching a task to in-progress", async () => { it("resets mergeRetries when dispatching a task to in-progress", async () => {
@@ -282,7 +282,7 @@ describe("Scheduler", () => {
"FN-001", "FN-001",
expect.objectContaining({ mergeRetries: 0 }), expect.objectContaining({ mergeRetries: 0 }),
); );
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-progress"); expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-progress", expect.objectContaining({ allocateWorktree: expect.any(Function) }));
}); });
it("registers task:moved event listener", () => { it("registers task:moved event listener", () => {
@@ -335,7 +335,7 @@ describe("Scheduler", () => {
await flushAsyncWork(); await flushAsyncWork();
// Verify schedule() was called - FN-002 should now be able to start // Verify schedule() was called - FN-002 should now be able to start
expect(store.moveTask).toHaveBeenCalledWith("FN-002", "in-progress"); expect(store.moveTask).toHaveBeenCalledWith("FN-002", "in-progress", expect.objectContaining({ allocateWorktree: expect.any(Function) }));
}); });
it("does not trigger scheduling for non-done task:moved events", async () => { it("does not trigger scheduling for non-done task:moved events", async () => {
@@ -406,7 +406,7 @@ describe("Scheduler", () => {
await flushAsyncWork(); await flushAsyncWork();
// Verify schedule() was called — task in todo should be scheduled // Verify schedule() was called — task in todo should be scheduled
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-progress"); expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-progress", expect.objectContaining({ allocateWorktree: expect.any(Function) }));
}); });
}); });
@@ -450,7 +450,7 @@ describe("Scheduler", () => {
await flushAsyncWork(); await flushAsyncWork();
// Should have triggered scheduling and moved the task to in-progress // Should have triggered scheduling and moved the task to in-progress
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-progress"); expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-progress", expect.objectContaining({ allocateWorktree: expect.any(Function) }));
}); });
it("does not trigger scheduling on unpause if scheduler is not running", async () => { it("does not trigger scheduling on unpause if scheduler is not running", async () => {
@@ -710,7 +710,7 @@ describe("Scheduler", () => {
expect(moveTask).not.toHaveBeenCalledWith("FN-102", "in-progress"); expect(moveTask).not.toHaveBeenCalledWith("FN-102", "in-progress");
// Lower-priority ready task still runs. // Lower-priority ready task still runs.
expect(moveTask).toHaveBeenCalledWith("FN-104", "in-progress"); expect(moveTask).toHaveBeenCalledWith("FN-104", "in-progress", expect.objectContaining({ allocateWorktree: expect.any(Function) }));
// Overlap-blocked urgent task must not run. // Overlap-blocked urgent task must not run.
expect(moveTask).not.toHaveBeenCalledWith("FN-103", "in-progress"); expect(moveTask).not.toHaveBeenCalledWith("FN-103", "in-progress");
}); });
@@ -751,7 +751,7 @@ describe("Scheduler", () => {
(scheduler as any).running = true; (scheduler as any).running = true;
await scheduler.schedule(); await scheduler.schedule();
expect(moveTask).toHaveBeenCalledWith("FN-002", "in-progress"); expect(moveTask).toHaveBeenCalledWith("FN-002", "in-progress", expect.objectContaining({ allocateWorktree: expect.any(Function) }));
expect(updateTask).not.toHaveBeenCalledWith("FN-002", { status: "queued", blockedBy: "FN-001" }); expect(updateTask).not.toHaveBeenCalledWith("FN-002", { status: "queued", blockedBy: "FN-001" });
}); });
@@ -789,7 +789,7 @@ describe("Scheduler", () => {
(scheduler as any).running = true; (scheduler as any).running = true;
await scheduler.schedule(); await scheduler.schedule();
expect(moveTask).toHaveBeenCalledWith("FN-002", "in-progress"); expect(moveTask).toHaveBeenCalledWith("FN-002", "in-progress", expect.objectContaining({ allocateWorktree: expect.any(Function) }));
expect(updateTask).not.toHaveBeenCalledWith("FN-002", { status: "queued", blockedBy: "FN-001" }); expect(updateTask).not.toHaveBeenCalledWith("FN-002", { status: "queued", blockedBy: "FN-001" });
}); });
@@ -855,12 +855,11 @@ describe("Scheduler", () => {
status: null, status: null,
blockedBy: null, blockedBy: null,
executionStartBranch: undefined, executionStartBranch: undefined,
worktree: "/test/project/.worktrees/fn-010",
effectiveNodeId: null, effectiveNodeId: null,
effectiveNodeSource: "local", effectiveNodeSource: "local",
mergeRetries: 0, mergeRetries: 0,
}); });
expect(moveTask).toHaveBeenCalledWith("FN-010", "in-progress"); expect(moveTask).toHaveBeenCalledWith("FN-010", "in-progress", expect.objectContaining({ allocateWorktree: expect.any(Function) }));
expect(updateTask.mock.invocationCallOrder[0]).toBeLessThan(moveTask.mock.invocationCallOrder[0]); expect(updateTask.mock.invocationCallOrder[0]).toBeLessThan(moveTask.mock.invocationCallOrder[0]);
}); });
@@ -894,7 +893,6 @@ describe("Scheduler", () => {
status: null, status: null,
blockedBy: null, blockedBy: null,
executionStartBranch: undefined, executionStartBranch: undefined,
worktree: "/test/project/.worktrees/amber-aspen",
effectiveNodeId: null, effectiveNodeId: null,
effectiveNodeSource: "local", effectiveNodeSource: "local",
mergeRetries: 0, mergeRetries: 0,
@@ -903,7 +901,6 @@ describe("Scheduler", () => {
status: null, status: null,
blockedBy: null, blockedBy: null,
executionStartBranch: undefined, executionStartBranch: undefined,
worktree: "/test/project/.worktrees/amber-aspen-2",
effectiveNodeId: null, effectiveNodeId: null,
effectiveNodeSource: "local", effectiveNodeSource: "local",
mergeRetries: 0, mergeRetries: 0,
@@ -1035,7 +1032,7 @@ describe("Scheduler", () => {
// Flush any remaining microtasks // Flush any remaining microtasks
await new Promise(resolve => setTimeout(resolve, 0)); await new Promise(resolve => setTimeout(resolve, 0));
expect(moveTask).toHaveBeenCalledWith("FN-010", "in-progress"); expect(moveTask).toHaveBeenCalledWith("FN-010", "in-progress", expect.objectContaining({ allocateWorktree: expect.any(Function) }));
expect(moveTask).not.toHaveBeenCalledWith("FN-010", "triage"); expect(moveTask).not.toHaveBeenCalledWith("FN-010", "triage");
}); });
@@ -1217,7 +1214,7 @@ describe("Scheduler", () => {
expect.any(String) expect.any(String)
); );
// Should move to in-progress (since deps are satisfied and concurrency allows) // Should move to in-progress (since deps are satisfied and concurrency allows)
expect(moveTask).toHaveBeenCalledWith("FN-004", "in-progress"); expect(moveTask).toHaveBeenCalledWith("FN-004", "in-progress", expect.objectContaining({ allocateWorktree: expect.any(Function) }));
}); });
it("does not validate filesystem for tasks with unmet dependencies", async () => { it("does not validate filesystem for tasks with unmet dependencies", async () => {
@@ -1956,7 +1953,7 @@ describe("Scheduler", () => {
(scheduler as any).running = true; (scheduler as any).running = true;
await scheduler.schedule(); await scheduler.schedule();
expect(store.moveTask).toHaveBeenCalledWith("FN-100", "in-progress"); expect(store.moveTask).toHaveBeenCalledWith("FN-100", "in-progress", expect.objectContaining({ allocateWorktree: expect.any(Function) }));
}); });
it("schedules tasks without sliceId regardless of mission state", async () => { it("schedules tasks without sliceId regardless of mission state", async () => {
@@ -1981,7 +1978,7 @@ describe("Scheduler", () => {
(scheduler as any).running = true; (scheduler as any).running = true;
await scheduler.schedule(); await scheduler.schedule();
expect(store.moveTask).toHaveBeenCalledWith("FN-100", "in-progress"); expect(store.moveTask).toHaveBeenCalledWith("FN-100", "in-progress", expect.objectContaining({ allocateWorktree: expect.any(Function) }));
}); });
}); });
@@ -2037,7 +2034,7 @@ describe("Scheduler", () => {
(scheduler as any).running = true; (scheduler as any).running = true;
await scheduler.schedule(); await scheduler.schedule();
expect(store.moveTask).toHaveBeenCalledWith("FN-011", "in-progress"); expect(store.moveTask).toHaveBeenCalledWith("FN-011", "in-progress", expect.objectContaining({ allocateWorktree: expect.any(Function) }));
}); });
it("picks up todo tasks without nextRecoveryAt normally", async () => { it("picks up todo tasks without nextRecoveryAt normally", async () => {
@@ -2063,7 +2060,7 @@ describe("Scheduler", () => {
(scheduler as any).running = true; (scheduler as any).running = true;
await scheduler.schedule(); await scheduler.schedule();
expect(store.moveTask).toHaveBeenCalledWith("FN-012", "in-progress"); expect(store.moveTask).toHaveBeenCalledWith("FN-012", "in-progress", expect.objectContaining({ allocateWorktree: expect.any(Function) }));
}); });
}); });

View File

@@ -1427,11 +1427,25 @@ export class TaskExecutor {
// moveTask's default reopen-to-todo path resets every step to // moveTask's default reopen-to-todo path resets every step to
// pending and rewrites PROMPT.md checkboxes, which would discard // pending and rewrites PROMPT.md checkboxes, which would discard
// the partial progress this bounce is supposed to retry on top of. // the partial progress this bounce is supposed to retry on top of.
// `preserveWorktree` keeps the same checkout assigned across the
// hop so listeners never observe an interim `worktree=null` state
// — this bounce immediately re-promotes the task on the same
// directory, so releasing it would publish a misleading snapshot
// and could let self-healing reclaim the worktree as idle.
if (preserveResumeState) { if (preserveResumeState) {
await this.store.moveTask(taskId, "todo", { preserveResumeState: true }); await this.store.moveTask(taskId, "todo", {
preserveResumeState: true,
preserveWorktree: true,
});
} else { } else {
await this.store.moveTask(taskId, "todo"); await this.store.moveTask(taskId, "todo", { preserveWorktree: true });
} }
// Restore worktree + executionStartedAt unconditionally to match
// the original bounce contract: even with preserveWorktree the
// worktree pointer could have been cleared by an in-flight
// updateTask, and executionStartedAt is reset by moveTask when
// preserveResumeState is false. Keep the writes so callers and
// tests can observe the restoration deterministically.
await this.store.updateTask(taskId, { await this.store.updateTask(taskId, {
worktree: worktreePath, worktree: worktreePath,
executionStartedAt: originalExecutionStartedAt ?? null, executionStartedAt: originalExecutionStartedAt ?? null,

View File

@@ -66,6 +66,7 @@ export {
} from "./agent-instructions.js"; } from "./agent-instructions.js";
export { HEARTBEAT_PROCEDURE, HEARTBEAT_SYSTEM_PROMPT, HEARTBEAT_NO_TASK_SYSTEM_PROMPT } from "./agent-heartbeat.js"; export { HEARTBEAT_PROCEDURE, HEARTBEAT_SYSTEM_PROMPT, HEARTBEAT_NO_TASK_SYSTEM_PROMPT } from "./agent-heartbeat.js";
export { WorktreePool, scanIdleWorktrees, cleanupOrphanedWorktrees, reapOrphanWorktrees } from "./worktree-pool.js"; export { WorktreePool, scanIdleWorktrees, cleanupOrphanedWorktrees, reapOrphanWorktrees } from "./worktree-pool.js";
export { generateReservedWorktreeName, generateWorktreeName, planTaskWorktreePath, slugify } from "./worktree-names.js";
export { createLogger, type Logger } from "./logger.js"; export { createLogger, type Logger } from "./logger.js";
export { isUsageLimitError, UsageLimitPauser } from "./usage-limit-detector.js"; export { isUsageLimitError, UsageLimitPauser } from "./usage-limit-detector.js";
export { withRateLimitRetry } from "./rate-limit-retry.js"; export { withRateLimitRetry } from "./rate-limit-retry.js";

View File

@@ -10,9 +10,9 @@ import {
} from "@fusion/core"; } from "@fusion/core";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import { basename, join } from "node:path"; import { join } from "node:path";
import type { AgentSemaphore } from "./concurrency.js"; import type { AgentSemaphore } from "./concurrency.js";
import { generateReservedWorktreeName, slugify } from "./worktree-names.js"; import { planTaskWorktreePath } from "./worktree-names.js";
import { schedulerLog } from "./logger.js"; import { schedulerLog } from "./logger.js";
import { type PrMonitor, type PrComment } from "./pr-monitor.js"; import { type PrMonitor, type PrComment } from "./pr-monitor.js";
import { reconcileMissionFeatureState } from "./mission-feature-sync.js"; import { reconcileMissionFeatureState } from "./mission-feature-sync.js";
@@ -495,28 +495,7 @@ export class Scheduler {
naming: string | undefined, naming: string | undefined,
reservedNames: Set<string>, reservedNames: Set<string>,
): string { ): string {
if (task.worktree) { return planTaskWorktreePath(task, this.store.getRootDir(), naming, reservedNames);
const existingName = basename(task.worktree);
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(this.store.getRootDir(), reservedNames);
break;
}
reservedNames.add(worktreeName);
return join(this.store.getRootDir(), ".worktrees", worktreeName);
} }
/** /**
@@ -687,11 +666,6 @@ export class Scheduler {
// Resolve dependency order among todo tasks // Resolve dependency order among todo tasks
const ordered = resolveDependencyOrder(todo); const ordered = resolveDependencyOrder(todo);
let started = 0; let started = 0;
const reservedWorktreeNames = new Set(
tasks
.map((task) => (task.worktree ? basename(task.worktree) : undefined))
.filter((name): name is string => Boolean(name)),
);
for (const taskId of ordered) { for (const taskId of ordered) {
const task = tasks.find((t) => t.id === taskId)!; const task = tasks.find((t) => t.id === taskId)!;
@@ -760,13 +734,11 @@ export class Scheduler {
continue; continue;
} }
// Dependencies met — resolve base branch from in-review deps // Dependencies met — resolve base branch from in-review deps.
// Worktree allocation is deferred to moveTask below, where it
// runs under TaskStore's cross-task allocation lock so it can't
// race against a concurrent manual-move.
const baseBranch = this.resolveBaseBranch(task, tasks); const baseBranch = this.resolveBaseBranch(task, tasks);
const plannedWorktree = this.planWorktreePath(
task,
settings.worktreeNaming,
reservedWorktreeNames,
);
// Compare-and-swap: re-read the task to verify it's still in "todo" before dispatching. // Compare-and-swap: re-read the task to verify it's still in "todo" before dispatching.
// This prevents dispatching a task twice if another schedule() call or user action // This prevents dispatching a task twice if another schedule() call or user action
@@ -836,12 +808,14 @@ export class Scheduler {
status: null, status: null,
blockedBy: null, blockedBy: null,
executionStartBranch: baseBranch ?? undefined, executionStartBranch: baseBranch ?? undefined,
worktree: plannedWorktree,
effectiveNodeId: effectiveNode.nodeId ?? null, effectiveNodeId: effectiveNode.nodeId ?? null,
effectiveNodeSource: effectiveNode.source, effectiveNodeSource: effectiveNode.source,
mergeRetries: 0, mergeRetries: 0,
}); });
await this.store.moveTask(task.id, "in-progress"); await this.store.moveTask(task.id, "in-progress", {
allocateWorktree: (reservedNames) =>
this.planWorktreePath(task, settings.worktreeNaming, reservedNames),
});
this.wasNodeBlocked.delete(task.id); this.wasNodeBlocked.delete(task.id);
await this.store.logEntry(task.id, `Node routing resolved: ${effectiveNode.nodeId ?? "local"} (source: ${effectiveNode.source})`); await this.store.logEntry(task.id, `Node routing resolved: ${effectiveNode.nodeId ?? "local"} (source: ${effectiveNode.source})`);
this.options.onSchedule?.(task); this.options.onSchedule?.(task);

View File

@@ -94,6 +94,48 @@ export function generateReservedWorktreeName(
return `${baseName}-${suffix}`; 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> { function getExistingWorktreeNames(worktreesDir: string): Set<string> {
if (!existsSync(worktreesDir)) { if (!existsSync(worktreesDir)) {
return new Set(); return new Set();