feat(FN-4200): prevent failed tasks from blocking overlap dispatch in sched

The scheduler now excludes failed review tasks from blocking overlap dispatch, preventing stuck tasks from stalling downstream work. A CSS fix prevents the failed badge from incorrectly overriding the in-review status in the list view, with tests covering both the scheduler logic and the badge displ

Fusion-Task-Id: FN-4200
This commit is contained in:
Fusion
2026-05-12 15:21:43 -07:00
committed by gsxdsm
parent ba86e7a0de
commit 61b670acce
6 changed files with 105 additions and 7 deletions

View File

@@ -1080,7 +1080,81 @@ describe("Scheduler", () => {
expect(updateTask).not.toHaveBeenCalledWith("FN-002", { status: "queued", blockedBy: "FN-001" });
});
it("blocks todo when overlapping in-review task is not paused", async () => {
it("excludes permanently-failed in-review tasks from active scopes", async () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFile).mockResolvedValue("# Task\nDo something");
const tasks = [
createMockTask({ id: "FN-001", column: "in-review", status: "failed", worktree: "/test/project/.worktrees/fn-001" }),
createMockTask({ id: "FN-002", column: "todo" }),
];
const parseScopeMock = vi.fn(async (taskId: string): Promise<string[]> => {
if (taskId === "FN-001") return ["src/foo.ts"];
if (taskId === "FN-002") return ["src/foo.ts"];
return [];
});
const updateTask = vi.fn().mockResolvedValue(undefined);
const moveTask = vi.fn().mockResolvedValue(undefined);
const store = createMockStore({
listTasks: vi.fn().mockResolvedValue(tasks),
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
groupOverlappingFiles: true,
}),
parseFileScopeFromPrompt: parseScopeMock,
updateTask,
moveTask,
});
const scheduler = new Scheduler(store);
(scheduler as any).running = true;
await scheduler.schedule();
expect(moveTask).toHaveBeenCalledWith("FN-002", "in-progress", expect.objectContaining({ allocateWorktree: expect.any(Function) }));
expect(updateTask).not.toHaveBeenCalledWith("FN-002", { status: "queued", blockedBy: "FN-001" });
});
it("clears stale blockedBy when prior overlap blocker is now permanently failed", async () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFile).mockResolvedValue("# Task\nDo something");
const tasks = [
createMockTask({ id: "FN-FAIL", column: "in-review", status: "failed", worktree: "/test/project/.worktrees/fn-fail" }),
createMockTask({ id: "FN-002", column: "todo", status: "queued", blockedBy: "FN-FAIL" }),
];
const parseScopeMock = vi.fn(async (taskId: string): Promise<string[]> => {
if (taskId === "FN-FAIL") return ["src/foo.ts"];
if (taskId === "FN-002") return ["src/foo.ts"];
return [];
});
const updateTask = vi.fn().mockResolvedValue(undefined);
const moveTask = vi.fn().mockResolvedValue(undefined);
const store = createMockStore({
listTasks: vi.fn().mockResolvedValue(tasks),
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
groupOverlappingFiles: true,
}),
parseFileScopeFromPrompt: parseScopeMock,
updateTask,
moveTask,
});
const scheduler = new Scheduler(store);
(scheduler as any).running = true;
await scheduler.schedule();
expect(moveTask).toHaveBeenCalledWith("FN-002", "in-progress", expect.objectContaining({ allocateWorktree: expect.any(Function) }));
expect(updateTask).not.toHaveBeenCalledWith("FN-002", { status: "queued", blockedBy: "FN-FAIL" });
});
it("still blocks todo when overlapping in-review task is not paused and not failed", async () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFile).mockResolvedValue("# Task\nDo something");

View File

@@ -733,12 +733,17 @@ export class Scheduler {
const filteredScope = filterPathsByIgnoreList(scope, overlapIgnorePaths);
if (filteredScope.length > 0) activeScopes.set(t.id, filteredScope);
}
// Only live in-review tasks with a worktree belong in activeScopes.
// Paused in-review tasks (e.g., failed-merge tasks awaiting human triage) cannot
// make progress, so they must not contribute to activeScopes. Including them
// make progress, so they must not contribute to overlap blockers; including them
// caused a deadlock pattern where a paused task indefinitely re-stamped
// `blockedBy` on overlapping todo tasks every scheduler tick. (FN-3867 / FN-3857)
// Permanently-failed in-review tasks from SelfHealingManager.checkStuckBudget()
// also keep their worktree, but after the stuck-kill budget is exhausted they
// will never merge, so superseding re-implementation tasks (for example FN-4177
// replaced by FN-4198) must not stay queued behind them. (FN-4200)
const inReviewWithWorktree = tasks.filter(
(t) => t.column === "in-review" && t.worktree && !t.paused,
(t) => t.column === "in-review" && Boolean(t.worktree) && !t.paused && t.status !== "failed",
);
for (const t of inReviewWithWorktree) {
const scope = await this.store.parseFileScopeFromPrompt(t.id);