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:
committed by
gsxdsm
parent
8f2d5e7e61
commit
62f11e69d3
@@ -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",
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user