feat(FN-2496): add overlap ignore paths support to scheduler settings
- Add overlap-ignore path validation and typed settings support in core schema - Apply overlap ignore paths in scheduler overlap detection with dedicated engine tests - Add Settings modal UI and routes handling for overlap ignore paths including path-picker feedback fixes - Document overlap ignore paths in storage/settings docs and include a changeset for @runfusion/fusion
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { PrMonitor } from "../pr-monitor.js";
|
||||
import { Scheduler, pathsOverlap } from "../scheduler.js";
|
||||
import { Scheduler, pathsOverlap, filterPathsByIgnoreList } from "../scheduler.js";
|
||||
import { AgentSemaphore } from "../concurrency.js";
|
||||
import type { TaskStore, Task, TaskDetail } from "@fusion/core";
|
||||
import { existsSync } from "node:fs";
|
||||
@@ -126,6 +126,25 @@ describe("pathsOverlap", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterPathsByIgnoreList", () => {
|
||||
it("filters exact ignored file paths", () => {
|
||||
expect(filterPathsByIgnoreList(["docs/README.md", "src/index.ts"], ["docs/README.md"]))
|
||||
.toEqual(["src/index.ts"]);
|
||||
});
|
||||
|
||||
it("filters ignored directories with and without trailing slash", () => {
|
||||
expect(filterPathsByIgnoreList(["docs/guide.md", "docs/api/types.md", "src/index.ts"], ["docs"]))
|
||||
.toEqual(["src/index.ts"]);
|
||||
expect(filterPathsByIgnoreList(["docs/guide.md", "src/index.ts"], ["docs/"]))
|
||||
.toEqual(["src/index.ts"]);
|
||||
});
|
||||
|
||||
it("filters ignored glob-style directories", () => {
|
||||
expect(filterPathsByIgnoreList(["generated/*", "generated/client.ts", "src/index.ts"], ["generated/*"]))
|
||||
.toEqual(["src/index.ts"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Scheduler", () => {
|
||||
// Helper to create mock MissionStore (shared across mission-related test suites)
|
||||
function createMockMissionStore(overrides = {}) {
|
||||
@@ -659,6 +678,122 @@ describe("Scheduler", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("overlap ignore paths", () => {
|
||||
it("allows scheduling when overlap is only on ignored files", async () => {
|
||||
vi.mocked(existsSync).mockReturnValue(true);
|
||||
vi.mocked(readFile).mockResolvedValue("# Task\nDo something");
|
||||
|
||||
const tasks = [
|
||||
createMockTask({ id: "FN-001", column: "in-progress" }),
|
||||
createMockTask({ id: "FN-002", column: "todo" }),
|
||||
];
|
||||
|
||||
const parseScopeMock = vi.fn(async (taskId: string): Promise<string[]> => {
|
||||
if (taskId === "FN-001") return ["docs/README.md"];
|
||||
if (taskId === "FN-002") return ["docs/README.md"];
|
||||
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,
|
||||
overlapIgnorePaths: ["docs/README.md"],
|
||||
}),
|
||||
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(updateTask).not.toHaveBeenCalledWith("FN-002", { status: "queued", blockedBy: "FN-001" });
|
||||
});
|
||||
|
||||
it("allows scheduling when overlap is only within ignored directories", async () => {
|
||||
vi.mocked(existsSync).mockReturnValue(true);
|
||||
vi.mocked(readFile).mockResolvedValue("# Task\nDo something");
|
||||
|
||||
const tasks = [
|
||||
createMockTask({ id: "FN-001", column: "in-review", 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 ["docs/guide.md"];
|
||||
if (taskId === "FN-002") return ["docs/reference.md"];
|
||||
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,
|
||||
overlapIgnorePaths: ["docs/"],
|
||||
}),
|
||||
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(updateTask).not.toHaveBeenCalledWith("FN-002", { status: "queued", blockedBy: "FN-001" });
|
||||
});
|
||||
|
||||
it("still blocks overlap for non-ignored paths", async () => {
|
||||
vi.mocked(existsSync).mockReturnValue(true);
|
||||
vi.mocked(readFile).mockResolvedValue("# Task\nDo something");
|
||||
|
||||
const tasks = [
|
||||
createMockTask({ id: "FN-001", column: "in-progress" }),
|
||||
createMockTask({ id: "FN-002", column: "todo" }),
|
||||
];
|
||||
|
||||
const parseScopeMock = vi.fn(async (taskId: string): Promise<string[]> => {
|
||||
if (taskId === "FN-001") return ["src/scheduler.ts"];
|
||||
if (taskId === "FN-002") return ["src/scheduler.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,
|
||||
overlapIgnorePaths: ["docs/"],
|
||||
}),
|
||||
parseFileScopeFromPrompt: parseScopeMock,
|
||||
updateTask,
|
||||
moveTask,
|
||||
});
|
||||
|
||||
const scheduler = new Scheduler(store);
|
||||
(scheduler as any).running = true;
|
||||
await scheduler.schedule();
|
||||
|
||||
expect(updateTask).toHaveBeenCalledWith("FN-002", { status: "queued", blockedBy: "FN-001" });
|
||||
expect(moveTask).not.toHaveBeenCalledWith("FN-002", "in-progress");
|
||||
});
|
||||
});
|
||||
|
||||
describe("worktree reservation", () => {
|
||||
it("assigns a planned worktree path before moving a task to in-progress", async () => {
|
||||
vi.mocked(existsSync).mockReturnValue(true);
|
||||
|
||||
@@ -51,6 +51,45 @@ export function pathsOverlap(a: string[], b: string[]): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function normalizeOverlapPath(path: string): string {
|
||||
return path.trim().replaceAll("\\", "/").replace(/^\.\//, "");
|
||||
}
|
||||
|
||||
function isIgnoredOverlapPath(path: string, ignorePath: string): boolean {
|
||||
const normalizedPath = normalizeOverlapPath(path);
|
||||
const normalizedIgnore = normalizeOverlapPath(ignorePath);
|
||||
|
||||
if (normalizedIgnore.endsWith("/*")) {
|
||||
const directory = normalizedIgnore.slice(0, -2);
|
||||
return normalizedPath === directory || normalizedPath.startsWith(`${directory}/`);
|
||||
}
|
||||
|
||||
if (normalizedIgnore.endsWith("/")) {
|
||||
const directory = normalizedIgnore.slice(0, -1);
|
||||
return normalizedPath === directory || normalizedPath.startsWith(normalizedIgnore);
|
||||
}
|
||||
|
||||
return normalizedPath === normalizedIgnore || normalizedPath.startsWith(`${normalizedIgnore}/`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove scope entries that match configured overlap-ignore paths.
|
||||
* Used by scheduler overlap gating so shared safe paths (docs/generated/etc.)
|
||||
* can bypass serialization while keeping overlap protection enabled globally.
|
||||
*/
|
||||
export function filterPathsByIgnoreList(paths: string[], ignorePaths?: string[]): string[] {
|
||||
if (!ignorePaths || ignorePaths.length === 0) {
|
||||
return paths;
|
||||
}
|
||||
|
||||
const normalizedIgnorePaths = ignorePaths.map(normalizeOverlapPath).filter(Boolean);
|
||||
if (normalizedIgnorePaths.length === 0) {
|
||||
return paths;
|
||||
}
|
||||
|
||||
return paths.filter((path) => !normalizedIgnorePaths.some((ignore) => isIgnoredOverlapPath(path, ignore)));
|
||||
}
|
||||
|
||||
export interface SchedulerOptions {
|
||||
/** Max concurrent in-progress tasks. Default: 2 */
|
||||
maxConcurrent?: number;
|
||||
@@ -592,10 +631,12 @@ export class Scheduler {
|
||||
*/
|
||||
const activeScopes = new Map<string, string[]>();
|
||||
if (settings.groupOverlappingFiles) {
|
||||
const overlapIgnorePaths = settings.overlapIgnorePaths ?? [];
|
||||
// In-progress tasks
|
||||
for (const t of inProgress) {
|
||||
const scope = await this.store.parseFileScopeFromPrompt(t.id);
|
||||
if (scope.length > 0) activeScopes.set(t.id, scope);
|
||||
const filteredScope = filterPathsByIgnoreList(scope, overlapIgnorePaths);
|
||||
if (filteredScope.length > 0) activeScopes.set(t.id, filteredScope);
|
||||
}
|
||||
// In-review tasks with unmerged worktrees
|
||||
const inReviewWithWorktree = tasks.filter(
|
||||
@@ -603,7 +644,8 @@ export class Scheduler {
|
||||
);
|
||||
for (const t of inReviewWithWorktree) {
|
||||
const scope = await this.store.parseFileScopeFromPrompt(t.id);
|
||||
if (scope.length > 0) activeScopes.set(t.id, scope);
|
||||
const filteredScope = filterPathsByIgnoreList(scope, overlapIgnorePaths);
|
||||
if (filteredScope.length > 0) activeScopes.set(t.id, filteredScope);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -658,7 +700,11 @@ export class Scheduler {
|
||||
|
||||
// Check file scope overlap when enabled
|
||||
if (settings.groupOverlappingFiles) {
|
||||
const taskScope = await this.store.parseFileScopeFromPrompt(task.id);
|
||||
const overlapIgnorePaths = settings.overlapIgnorePaths ?? [];
|
||||
const taskScope = filterPathsByIgnoreList(
|
||||
await this.store.parseFileScopeFromPrompt(task.id),
|
||||
overlapIgnorePaths,
|
||||
);
|
||||
if (taskScope.length > 0) {
|
||||
let overlappingTaskId: string | null = null;
|
||||
for (const [ipId, ipScope] of activeScopes) {
|
||||
@@ -716,7 +762,10 @@ export class Scheduler {
|
||||
|
||||
// Track newly started task's file scope for overlap with remaining todo tasks
|
||||
if (settings.groupOverlappingFiles) {
|
||||
const scope = await this.store.parseFileScopeFromPrompt(task.id);
|
||||
const scope = filterPathsByIgnoreList(
|
||||
await this.store.parseFileScopeFromPrompt(task.id),
|
||||
settings.overlapIgnorePaths,
|
||||
);
|
||||
if (scope.length > 0) activeScopes.set(task.id, scope);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user