feat(FN-5325): priority-aware overlap deferral and merge integration worktr

The merge delivers several meaningful features and fixes: a **priority-aware overlap deferral fix** (FN-5325) in the scheduler that aligns the queued-overlap helper with the priority-sortable type, preventing inversion; a **merge integration worktree feature** (FN-5279) with settings UI, reusable wo

Fusion-Task-Id: FN-5325
This commit is contained in:
Fusion (runfusion.ai)
2026-05-20 13:57:21 -07:00
committed by gsxdsm
parent 8f2d5e7e61
commit 62f11e69d3
4 changed files with 328 additions and 15 deletions

View File

@@ -0,0 +1,141 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { Scheduler } from "../../scheduler.js";
import type { Task, TaskStore } from "@fusion/core";
function makeTask(overrides: Partial<Task> = {}): Task {
return {
id: "FN-001",
title: "task",
description: "",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
} as Task;
}
function createStore(tasks: Task[], scopes: Record<string, string[]>) {
const updateTask = vi.fn(async (id: string, patch: Partial<Task>) => {
const task = tasks.find((candidate) => candidate.id === id);
if (task) Object.assign(task, patch);
return task as Task;
});
const moveTask = vi.fn(async (id: string, column: Task["column"]) => {
const task = tasks.find((candidate) => candidate.id === id);
if (task) task.column = column;
return task as Task;
});
const store = {
listTasks: vi.fn(async () => tasks),
getSettings: vi.fn(async () => ({ maxConcurrent: 10, maxWorktrees: 10, groupOverlappingFiles: true })),
parseFileScopeFromPrompt: vi.fn(async (id: string) => scopes[id] ?? []),
updateTask,
moveTask,
getTask: vi.fn(async (id: string) => tasks.find((task) => task.id === id) ?? null),
logEntry: vi.fn(async () => undefined),
getRootDir: vi.fn(() => "/tmp/project"),
getTasksDir: vi.fn(() => "/tmp/project/.fusion/tasks"),
on: vi.fn(),
off: vi.fn(),
recordRunAuditEvent: vi.fn(async () => undefined),
} as unknown as TaskStore;
return { store, updateTask, moveTask };
}
describe("reliability interactions: FN-5325 scheduler overlap priority inversion", () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.spyOn(Scheduler.prototype as any, "validateTaskFilesystem").mockResolvedValue({ valid: true });
});
it("defers lower-priority overlap while urgent queued task dispatches first", async () => {
const tasks = [
makeTask({ id: "FN-1", priority: "urgent", status: "queued", createdAt: "2026-01-01T00:00:00.000Z" }),
makeTask({ id: "FN-2", priority: "normal", createdAt: "2026-01-01T00:01:00.000Z" }),
];
const { store, moveTask, updateTask } = createStore(tasks, { "FN-1": ["src/a.ts"], "FN-2": ["src/a.ts"] });
const scheduler = new Scheduler(store);
(scheduler as any).running = true;
await scheduler.schedule();
expect(moveTask).toHaveBeenCalledWith("FN-1", "in-progress", expect.anything());
expect(updateTask).toHaveBeenCalledWith("FN-2", expect.objectContaining({ status: "queued", overlapBlockedBy: "FN-1" }));
});
it("uses createdAt tiebreaker for equal-priority overlap", async () => {
const tasks = [
makeTask({ id: "FN-1", priority: "normal", status: "queued", createdAt: "2026-01-01T00:00:00.000Z" }),
makeTask({ id: "FN-2", priority: "normal", createdAt: "2026-01-01T00:05:00.000Z" }),
];
const { store } = createStore(tasks, { "FN-1": ["src/a.ts"], "FN-2": ["src/a.ts"] });
const scheduler = new Scheduler(store);
(scheduler as any).running = true;
await scheduler.schedule();
expect(store.logEntry).toHaveBeenCalledWith("FN-2", "queued — deferred for higher-priority queued task FN-1 (overlap)");
});
it("preserves FN-4969 fanout ordering and only defers when overlap exists", async () => {
const sharedStamp = "2026-01-01T00:00:00.000Z";
const tasks = [
makeTask({ id: "FN-10", priority: "normal", createdAt: sharedStamp }),
makeTask({ id: "FN-11", priority: "normal", createdAt: sharedStamp }),
makeTask({ id: "FN-21", dependencies: ["FN-10"] }),
makeTask({ id: "FN-22", dependencies: ["FN-10"] }),
];
const { store, moveTask, updateTask } = createStore(tasks, {
"FN-10": ["src/a.ts"],
"FN-11": ["src/b.ts"],
"FN-21": ["src/c.ts"],
"FN-22": ["src/d.ts"],
});
const scheduler = new Scheduler(store);
(scheduler as any).running = true;
await scheduler.schedule();
expect(moveTask.mock.calls[0][0]).toBe("FN-10");
expect(moveTask).toHaveBeenCalledWith("FN-11", "in-progress", expect.anything());
expect(updateTask).not.toHaveBeenCalledWith("FN-11", expect.objectContaining({ overlapBlockedBy: expect.any(String) }));
tasks.find((task) => task.id === "FN-11")!.column = "todo";
tasks.find((task) => task.id === "FN-10")!.column = "in-progress";
(store.parseFileScopeFromPrompt as any).mockImplementation(async (id: string) => ({ "FN-10": ["src/a.ts"], "FN-11": ["src/a.ts"] }[id] ?? ["src/x.ts"]));
await scheduler.schedule();
expect(updateTask).toHaveBeenCalledWith("FN-11", expect.objectContaining({ status: "queued", overlapBlockedBy: "FN-10" }));
});
it("emits one inversion audit event per pass for running lower-priority blocker", async () => {
const tasks = [
makeTask({ id: "FN-1", column: "in-progress", priority: "normal", createdAt: "2026-01-01T00:01:00.000Z" }),
makeTask({ id: "FN-2", priority: "urgent", status: "queued", createdAt: "2026-01-01T00:00:00.000Z" }),
];
const { store } = createStore(tasks, { "FN-1": ["src/a.ts"], "FN-2": ["src/a.ts"] });
const scheduler = new Scheduler(store);
(scheduler as any).running = true;
await scheduler.schedule();
const calls = (store.recordRunAuditEvent as any).mock.calls.filter(
(call: any[]) => call[0]?.mutationType === "scheduler:overlap-priority-inversion",
);
expect(calls).toHaveLength(1);
expect(calls[0][0]).toMatchObject({
target: "FN-2",
metadata: expect.objectContaining({
candidateId: "FN-2",
blockerId: "FN-1",
candidatePriority: "urgent",
blockerPriority: "normal",
blockerColumn: "in-progress",
}),
});
});
});

View File

@@ -1,6 +1,12 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { PrMonitor } from "../pr-monitor.js";
import { Scheduler, pathsOverlap, filterPathsByIgnoreList, formatConcurrencyLimitMemoKey } from "../scheduler.js";
import {
Scheduler,
pathsOverlap,
filterPathsByIgnoreList,
formatConcurrencyLimitMemoKey,
findHigherPriorityQueuedOverlap,
} from "../scheduler.js";
import { AgentSemaphore } from "../concurrency.js";
import type { TaskStore, Task, TaskDetail } from "@fusion/core";
import { existsSync } from "node:fs";
@@ -166,6 +172,64 @@ describe("filterPathsByIgnoreList", () => {
});
});
describe("findHigherPriorityQueuedOverlap", () => {
const overlap = (a: string[], b: string[]) => pathsOverlap(a, b);
it("returns higher-priority queued overlap", () => {
const result = findHigherPriorityQueuedOverlap(
{ id: "FN-2", priority: "normal", createdAt: "2026-01-02T00:00:00Z", scope: ["src/a.ts"] },
[{ id: "FN-1", priority: "urgent", createdAt: "2026-01-03T00:00:00Z", scope: ["src/a.ts"] }],
overlap,
);
expect(result?.id).toBe("FN-1");
});
it("uses age tiebreaker at equal priority", () => {
const result = findHigherPriorityQueuedOverlap(
{ id: "FN-2", priority: "normal", createdAt: "2026-01-02T00:00:00Z", scope: ["src/a.ts"] },
[{ id: "FN-1", priority: "normal", createdAt: "2026-01-01T00:00:00Z", scope: ["src/a.ts"] }],
overlap,
);
expect(result?.id).toBe("FN-1");
});
it("uses task id tiebreaker when priority and age match", () => {
const result = findHigherPriorityQueuedOverlap(
{ id: "FN-10", priority: "normal", createdAt: "2026-01-01T00:00:00Z", scope: ["src/a.ts"] },
[{ id: "FN-2", priority: "normal", createdAt: "2026-01-01T00:00:00Z", scope: ["src/a.ts"] }],
overlap,
);
expect(result?.id).toBe("FN-2");
});
it("returns null when scopes do not overlap", () => {
const result = findHigherPriorityQueuedOverlap(
{ id: "FN-2", priority: "normal", createdAt: "2026-01-02T00:00:00Z", scope: ["src/a.ts"] },
[{ id: "FN-1", priority: "urgent", createdAt: "2026-01-01T00:00:00Z", scope: ["src/b.ts"] }],
overlap,
);
expect(result).toBeNull();
});
it("returns null when candidate or queued scopes are empty", () => {
expect(
findHigherPriorityQueuedOverlap(
{ id: "FN-2", priority: "normal", createdAt: "2026-01-02T00:00:00Z", scope: [] },
[{ id: "FN-1", priority: "urgent", createdAt: "2026-01-01T00:00:00Z", scope: ["src/a.ts"] }],
overlap,
),
).toBeNull();
expect(
findHigherPriorityQueuedOverlap(
{ id: "FN-2", priority: "normal", createdAt: "2026-01-02T00:00:00Z", scope: ["src/a.ts"] },
[{ id: "FN-1", priority: "urgent", createdAt: "2026-01-01T00:00:00Z", scope: [] }],
overlap,
),
).toBeNull();
});
});
describe("Scheduler", () => {
beforeEach(() => {
staleReporterReportMock.mockReset().mockResolvedValue({ surfaced: 0 });
@@ -1406,7 +1470,7 @@ describe("Scheduler", () => {
// Dependency-blocked urgent task should be queued, not started.
expect(updateTask).toHaveBeenCalledWith("FN-100", { status: "queued", blockedBy: "FN-900" });
// Overlap-blocked urgent task should be queued with blocker id.
expect(updateTask).toHaveBeenCalledWith("FN-103", { status: "queued", blockedBy: "FN-001", overlapBlockedBy: "FN-001" });
expect(updateTask).toHaveBeenCalledWith("FN-103", { status: "queued", blockedBy: null, overlapBlockedBy: "FN-001" });
// Paused and recovery-gated urgent tasks never enter scheduling.
expect(moveTask).not.toHaveBeenCalledWith("FN-101", "in-progress");
expect(moveTask).not.toHaveBeenCalledWith("FN-102", "in-progress");
@@ -1748,7 +1812,7 @@ describe("Scheduler", () => {
(scheduler as any).running = true;
await scheduler.schedule();
expect(updateTask).toHaveBeenCalledWith("FN-002", { status: "queued", blockedBy: "FN-001", overlapBlockedBy: "FN-001" });
expect(updateTask).toHaveBeenCalledWith("FN-002", { status: "queued", blockedBy: null, overlapBlockedBy: "FN-001" });
expect(moveTask).not.toHaveBeenCalledWith("FN-002", "in-progress");
});
@@ -1786,7 +1850,7 @@ describe("Scheduler", () => {
(scheduler as any).running = true;
await scheduler.schedule();
expect(updateTask).toHaveBeenCalledWith("FN-002", { status: "queued", blockedBy: "FN-001", overlapBlockedBy: "FN-001" });
expect(updateTask).toHaveBeenCalledWith("FN-002", { status: "queued", blockedBy: null, overlapBlockedBy: "FN-001" });
expect(moveTask).not.toHaveBeenCalledWith("FN-002", "in-progress");
});
});
@@ -1923,7 +1987,7 @@ describe("Scheduler", () => {
(scheduler as any).running = true;
await scheduler.schedule();
expect(updateTask).toHaveBeenCalledWith("FN-T", { status: "queued", blockedBy: "FN-B", overlapBlockedBy: "FN-B" });
expect(updateTask).toHaveBeenCalledWith("FN-T", { status: "queued", blockedBy: null, overlapBlockedBy: "FN-B" });
});
it("does not stamp blockedBy for todos without overlap, including empty scopes", async () => {

View File

@@ -4,6 +4,7 @@ import {
sortTasksByPriorityFanoutThenAgeAndId,
buildUnblockWeightMap,
computeBlockerFanoutMap,
compareTasksByPriorityThenAgeAndId,
HIGH_FANOUT_BLOCKER_TODO_THRESHOLD,
type TaskStore,
type Task,
@@ -104,6 +105,35 @@ export function filterPathsByIgnoreList(paths: string[], ignorePaths?: string[])
return paths.filter((path) => !normalizedIgnorePaths.some((ignore) => isIgnoredOverlapPath(path, ignore)));
}
export interface QueuedOverlapCandidate {
id: string;
priority?: Task["priority"] | null;
createdAt: string;
scope: string[];
}
export function findHigherPriorityQueuedOverlap(
candidate: QueuedOverlapCandidate,
queuedScopes: QueuedOverlapCandidate[],
overlap: (a: string[], b: string[]) => boolean,
): QueuedOverlapCandidate | null {
let higher: QueuedOverlapCandidate | null = null;
for (const queued of queuedScopes) {
if (queued.id === candidate.id) continue;
if (!queued.scope.length || !candidate.scope.length) continue;
if (!overlap(candidate.scope, queued.scope)) continue;
if (compareTasksByPriorityThenAgeAndId(queued, candidate) < 0) {
if (!higher || compareTasksByPriorityThenAgeAndId(queued, higher) < 0) {
higher = queued;
}
}
}
return higher;
}
type ConcurrencyGateName = "maxConcurrent" | "maxWorktrees" | "semaphore";
interface ConcurrencyGateSnapshot {
@@ -991,6 +1021,8 @@ export class Scheduler {
* subsequent todo tasks in the same pass also see them.
*/
const activeScopes = new Map<string, string[]>();
const inversionEmitted = new Set<string>();
const queuedHigherPriorityScopes: QueuedOverlapCandidate[] = [];
if (settings.groupOverlappingFiles) {
const overlapIgnorePaths = settings.overlapIgnorePaths ?? [];
// In-progress tasks
@@ -999,6 +1031,19 @@ export class Scheduler {
const filteredScope = filterPathsByIgnoreList(scope, overlapIgnorePaths);
if (filteredScope.length > 0) activeScopes.set(t.id, filteredScope);
}
for (const t of todo) {
if (t.status !== "queued" || t.paused || t.userPaused) continue;
const scope = await this.store.parseFileScopeFromPrompt(t.id);
const filteredScope = filterPathsByIgnoreList(scope, overlapIgnorePaths);
if (filteredScope.length === 0) continue;
queuedHigherPriorityScopes.push({
id: t.id,
priority: t.priority,
createdAt: t.createdAt,
scope: 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 overlap blockers; including them
@@ -1115,25 +1160,86 @@ export class Scheduler {
? overlapBlockerId
: activeScopeEntries.find(([, ipScope]) => this.pathsOverlap(taskScope, ipScope))?.[0] ?? null;
if (overlappingTaskId) {
const unresolvedDeps = task.dependencies.filter((depId) => {
const dep = tasks.find((t) => t.id === depId);
return dep && dep.column !== "done" && dep.column !== "in-review" && dep.column !== "archived";
});
const targetBlockedBy = task.dependencies.length > 0
? (unresolvedDeps[0] ?? null)
: overlappingTaskId;
const higherPriorityQueuedOverlap = findHigherPriorityQueuedOverlap(
{
id: task.id,
priority: task.priority,
createdAt: task.createdAt,
scope: taskScope,
},
queuedHigherPriorityScopes,
this.pathsOverlap.bind(this),
);
if (higherPriorityQueuedOverlap) {
const dependencyBlocker = unmetDeps[0] ?? null;
if (
task.status !== "queued"
|| task.blockedBy !== targetBlockedBy
|| task.blockedBy !== dependencyBlocker
|| task.overlapBlockedBy !== higherPriorityQueuedOverlap.id
) {
await this.store.updateTask(task.id, {
status: "queued",
blockedBy: dependencyBlocker,
overlapBlockedBy: higherPriorityQueuedOverlap.id,
});
}
await this.rollbackRunningAgentsForQueuedTodoTask(task.id);
await this.logDispatchQueuedReason(
task.id,
`queued — deferred for higher-priority queued task ${higherPriorityQueuedOverlap.id} (overlap)`,
);
continue;
}
if (overlappingTaskId) {
const dependencyBlocker = unmetDeps[0] ?? null;
if (
task.status !== "queued"
|| task.blockedBy !== dependencyBlocker
|| task.overlapBlockedBy !== overlappingTaskId
) {
await this.store.updateTask(task.id, {
status: "queued",
blockedBy: targetBlockedBy,
blockedBy: dependencyBlocker,
overlapBlockedBy: overlappingTaskId,
});
}
const overlapBlockerTask = tasks.find((candidate) => candidate.id === overlappingTaskId);
const inversionKey = `${task.id}|${overlappingTaskId}`;
if (
overlapBlockerTask
&& !inversionEmitted.has(inversionKey)
&& compareTasksByPriorityThenAgeAndId(task, overlapBlockerTask) < 0
) {
inversionEmitted.add(inversionKey);
try {
await this.store.recordRunAuditEvent?.({
taskId: task.id,
agentId: "scheduler",
runId: generateSyntheticRunId("scheduler", task.id),
domain: "database",
mutationType: "scheduler:overlap-priority-inversion",
target: task.id,
metadata: {
candidateId: task.id,
candidatePriority: task.priority ?? null,
candidateCreatedAt: task.createdAt ?? null,
blockerId: overlapBlockerTask.id,
blockerPriority: overlapBlockerTask.priority ?? null,
blockerCreatedAt: overlapBlockerTask.createdAt ?? null,
blockerColumn: overlapBlockerTask.column,
source: "scheduler.overlap-priority-inversion",
},
});
} catch (error) {
schedulerLog.warn(
`Task ${task.id} failed to emit overlap priority inversion audit: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
await this.rollbackRunningAgentsForQueuedTodoTask(task.id);
await this.logDispatchQueuedReason(task.id, `queued — file scope overlap with ${overlappingTaskId}`);
continue;