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:
Fusion
2026-04-25 15:48:36 -07:00
committed by gsxdsm
parent d028b41164
commit 9d8852e26a
11 changed files with 570 additions and 7 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Add project-level overlap ignore paths so teams can exempt safe shared files/directories from overlap-based task serialization while keeping overlap protection enabled for the rest of the repo.

View File

@@ -85,6 +85,7 @@ Defaults from `DEFAULT_PROJECT_SETTINGS`; key scope from `PROJECT_SETTINGS_KEYS`
| `pollIntervalMs` | `number` | `15000` | Scheduler poll interval (ms). | | `pollIntervalMs` | `number` | `15000` | Scheduler poll interval (ms). |
| `heartbeatMultiplier` | `number` | `1` | Global multiplier applied to all agent heartbeat intervals. Configured from the Agents screen (not Settings). | | `heartbeatMultiplier` | `number` | `1` | Global multiplier applied to all agent heartbeat intervals. Configured from the Agents screen (not Settings). |
| `groupOverlappingFiles` | `boolean` | `true` | Serialize execution when file scopes overlap. | | `groupOverlappingFiles` | `boolean` | `true` | Serialize execution when file scopes overlap. |
| `overlapIgnorePaths` | `string[]` | `[]` | Optional project-relative file or directory paths to exclude from overlap blocking (for example `docs` or `generated/openapi.json`). Entries are trimmed, deduplicated, and must not be absolute or contain `..` traversal. |
| `autoMerge` | `boolean` | `true` | Auto-finalize tasks from `in-review`. | | `autoMerge` | `boolean` | `true` | Auto-finalize tasks from `in-review`. |
| `mergeStrategy` | `"direct" \| "pull-request"` | `"direct"` | Completion mode (local direct merge vs PR-first). | | `mergeStrategy` | `"direct" \| "pull-request"` | `"direct"` | Completion mode (local direct merge vs PR-first). |
| `pushAfterMerge` | `boolean` | `false` | Auto-push to remote after successful direct merge. Includes pulling latest and AI conflict resolution. | | `pushAfterMerge` | `boolean` | `false` | Auto-push to remote after successful direct merge. Includes pulling latest and AI conflict resolution. |

View File

@@ -96,6 +96,7 @@ API endpoints reviewed:
| `maxWorktrees` | Project | `GET/PUT /api/settings` | Worktree cap | | `maxWorktrees` | Project | `GET/PUT /api/settings` | Worktree cap |
| `pollIntervalMs` | Project | `GET/PUT /api/settings` | Scheduler poll interval | | `pollIntervalMs` | Project | `GET/PUT /api/settings` | Scheduler poll interval |
| `groupOverlappingFiles` | Project | `GET/PUT /api/settings` | Serialize overlapping file work | | `groupOverlappingFiles` | Project | `GET/PUT /api/settings` | Serialize overlapping file work |
| `overlapIgnorePaths` | Project | `GET/PUT /api/settings` | Project-relative file/directory paths ignored by overlap blocking |
| `autoMerge` | Project | `GET/PUT /api/settings` | Enable auto merge | | `autoMerge` | Project | `GET/PUT /api/settings` | Enable auto merge |
| `mergeStrategy` | Project | `GET/PUT /api/settings` | Direct vs PR merge strategy | | `mergeStrategy` | Project | `GET/PUT /api/settings` | Direct vs PR merge strategy |
| `worktreeInitCommand` | Project | `GET/PUT /api/settings` | Command run on worktree init | | `worktreeInitCommand` | Project | `GET/PUT /api/settings` | Command run on worktree init |

View File

@@ -69,6 +69,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
pollIntervalMs: 15000, pollIntervalMs: 15000,
heartbeatMultiplier: 1, heartbeatMultiplier: 1,
groupOverlappingFiles: true, groupOverlappingFiles: true,
overlapIgnorePaths: [],
autoMerge: true, autoMerge: true,
mergeStrategy: "direct", mergeStrategy: "direct",
pushAfterMerge: false, pushAfterMerge: false,

View File

@@ -1180,6 +1180,12 @@ export interface ProjectSettings {
* Must be > 0. Default: 1 (no change). */ * Must be > 0. Default: 1 (no change). */
heartbeatMultiplier?: number; heartbeatMultiplier?: number;
groupOverlappingFiles: boolean; groupOverlappingFiles: boolean;
/** File/directory paths to ignore when evaluating overlap serialization.
* Entries are project-relative paths (for example: `docs/README.md`, `docs/`, `generated/*`).
* Absolute paths and `..` traversal are not allowed.
* When set, matching paths are excluded from overlap checks for both
* active in-progress tasks and in-review tasks with unmerged worktrees. */
overlapIgnorePaths?: string[];
autoMerge: boolean; autoMerge: boolean;
/** How completed in-review tasks should be finalized when autoMerge is enabled. /** How completed in-review tasks should be finalized when autoMerge is enabled.
* - "direct": preserve the existing local squash-merge flow into the current branch * - "direct": preserve the existing local squash-merge flow into the current branch

View File

@@ -254,10 +254,74 @@
color: var(--text-muted); color: var(--text-muted);
} }
.settings-overlap-ignore-group code {
font-family: var(--font-mono);
font-size: 0.9em;
}
.settings-overlap-ignore-list {
display: flex;
flex-direction: column;
gap: var(--space-sm);
margin: var(--space-sm) 0;
}
.settings-overlap-ignore-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--space-sm);
align-items: center;
}
.settings-overlap-ignore-path-controls {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--space-sm);
align-items: center;
}
.settings-overlap-path-picker-modal {
max-width: min(960px, calc(100vw - var(--space-2xl)));
}
.settings-overlap-path-picker-body {
display: flex;
flex-direction: column;
gap: var(--space-sm);
max-height: min(70vh, 720px);
}
.settings-overlap-path-picker-note {
margin: 0;
font-size: 12px;
color: var(--text-muted);
}
.settings-section-divider {
border-top: 1px solid var(--border);
margin: var(--space-lg) var(--space-xl);
}
@media (max-width: 768px) { @media (max-width: 768px) {
.settings-note { .settings-note {
padding: 0 var(--space-lg); padding: 0 var(--space-lg);
} }
.settings-overlap-ignore-row {
grid-template-columns: minmax(0, 1fr);
}
.settings-overlap-ignore-path-controls {
grid-template-columns: minmax(0, 1fr);
}
.settings-overlap-ignore-row > .btn {
justify-self: start;
}
.settings-overlap-path-picker-modal {
max-width: calc(100vw - var(--space-md));
}
} }
/* === Notifications Settings === */ /* === Notifications Settings === */

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useRef, lazy, Suspense } from "react"; import { useState, useEffect, useCallback, useRef, lazy, Suspense, type MouseEvent } from "react";
import { Globe, Folder } from "lucide-react"; import { Globe, Folder } from "lucide-react";
import { THINKING_LEVELS, isGlobalSettingsKey, isProjectSettingsKey, getErrorMessage } from "@fusion/core"; import { THINKING_LEVELS, isGlobalSettingsKey, isProjectSettingsKey, getErrorMessage } from "@fusion/core";
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset, NtfyNotificationEvent, AgentPromptsConfig, ThinkingLevel } from "@fusion/core"; import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset, NtfyNotificationEvent, AgentPromptsConfig, ThinkingLevel } from "@fusion/core";
@@ -10,6 +10,8 @@ import { ThemeSelector } from "./ThemeSelector";
import "./SettingsModal.css"; import "./SettingsModal.css";
import { CustomModelDropdown } from "./CustomModelDropdown"; import { CustomModelDropdown } from "./CustomModelDropdown";
import { FileEditor } from "./FileEditor"; import { FileEditor } from "./FileEditor";
import { FileBrowser } from "./FileBrowser";
import { useWorkspaceFileBrowser } from "../hooks/useWorkspaceFileBrowser";
const PluginManager = lazy(() => import("./PluginManager").then((m) => ({ default: m.PluginManager }))); const PluginManager = lazy(() => import("./PluginManager").then((m) => ({ default: m.PluginManager })));
const PiExtensionsManager = lazy(() => import("./PiExtensionsManager").then((m) => ({ default: m.PiExtensionsManager }))); const PiExtensionsManager = lazy(() => import("./PiExtensionsManager").then((m) => ({ default: m.PiExtensionsManager })));
import { ClaudeCliProviderCard } from "./ClaudeCliProviderCard"; import { ClaudeCliProviderCard } from "./ClaudeCliProviderCard";
@@ -146,6 +148,7 @@ export function SettingsModal({
pollIntervalMs: 15000, pollIntervalMs: 15000,
heartbeatMultiplier: 1, heartbeatMultiplier: 1,
groupOverlappingFiles: true, groupOverlappingFiles: true,
overlapIgnorePaths: [],
autoMerge: true, autoMerge: true,
mergeStrategy: "direct", mergeStrategy: "direct",
recycleWorktrees: false, recycleWorktrees: false,
@@ -183,6 +186,16 @@ export function SettingsModal({
); );
const [appVersion, setAppVersion] = useState<string | null>(null); const [appVersion, setAppVersion] = useState<string | null>(null);
const [prefixError, setPrefixError] = useState<string | null>(null); const [prefixError, setPrefixError] = useState<string | null>(null);
const [overlapPathPickerIndex, setOverlapPathPickerIndex] = useState<number | null>(null);
const {
entries: overlapPathPickerEntries,
currentPath: overlapPathPickerCurrentPath,
setPath: setOverlapPathPickerPath,
loading: overlapPathPickerLoading,
error: overlapPathPickerError,
refresh: refreshOverlapPathPicker,
} = useWorkspaceFileBrowser("project", overlapPathPickerIndex !== null, projectId);
/** Get the scope of the currently active section */ /** Get the scope of the currently active section */
const activeSectionScope = SETTINGS_SECTIONS.find((s) => s.id === activeSection)?.scope; const activeSectionScope = SETTINGS_SECTIONS.find((s) => s.id === activeSection)?.scope;
@@ -863,6 +876,85 @@ export function SettingsModal({
})); }));
} }
const openOverlapPathPicker = useCallback((index: number) => {
setOverlapPathPickerIndex(index);
setOverlapPathPickerPath(".");
}, [setOverlapPathPickerPath]);
const closeOverlapPathPicker = useCallback(() => {
setOverlapPathPickerIndex(null);
}, []);
const selectOverlapIgnorePath = useCallback((path: string) => {
if (overlapPathPickerIndex === null) return;
setForm((f) => {
const currentPaths = f.overlapIgnorePaths && f.overlapIgnorePaths.length > 0
? [...f.overlapIgnorePaths]
: [""];
currentPaths[overlapPathPickerIndex] = path;
return { ...f, overlapIgnorePaths: currentPaths };
});
closeOverlapPathPicker();
}, [overlapPathPickerIndex, closeOverlapPathPicker]);
const handleSelectCurrentDirectoryForOverlapIgnore = useCallback(() => {
if (overlapPathPickerCurrentPath === ".") {
return;
}
const directoryPath = overlapPathPickerCurrentPath.endsWith("/")
? overlapPathPickerCurrentPath
: `${overlapPathPickerCurrentPath}/`;
selectOverlapIgnorePath(directoryPath);
}, [overlapPathPickerCurrentPath, selectOverlapIgnorePath]);
const handleOverlapPathPickerOverlayClick = useCallback((event: MouseEvent<HTMLDivElement>) => {
if (event.target === event.currentTarget) {
closeOverlapPathPicker();
}
}, [closeOverlapPathPicker]);
const handleOverlapIgnorePathChange = useCallback((index: number, value: string) => {
setForm((f) => {
const currentPaths = f.overlapIgnorePaths && f.overlapIgnorePaths.length > 0
? [...f.overlapIgnorePaths]
: [""];
currentPaths[index] = value;
return { ...f, overlapIgnorePaths: currentPaths };
});
}, []);
const handleRemoveOverlapIgnorePath = useCallback((index: number) => {
setForm((f) => {
const currentPaths = f.overlapIgnorePaths && f.overlapIgnorePaths.length > 0
? [...f.overlapIgnorePaths]
: [""];
const nextPaths = currentPaths.filter((_, i) => i !== index);
return { ...f, overlapIgnorePaths: nextPaths.length > 0 ? nextPaths : [] };
});
if (overlapPathPickerIndex === index) {
closeOverlapPathPicker();
return;
}
if (overlapPathPickerIndex !== null && overlapPathPickerIndex > index) {
setOverlapPathPickerIndex(overlapPathPickerIndex - 1);
}
}, [overlapPathPickerIndex, closeOverlapPathPicker]);
const handleAddOverlapIgnorePath = useCallback(() => {
setForm((f) => {
const currentPaths = f.overlapIgnorePaths && f.overlapIgnorePaths.length > 0
? f.overlapIgnorePaths
: [""];
return { ...f, overlapIgnorePaths: [...currentPaths, ""] };
});
}, []);
const handleSave = useCallback(async () => { const handleSave = useCallback(async () => {
if (prefixError || presetDraft) return; if (prefixError || presetDraft) return;
try { try {
@@ -870,6 +962,7 @@ export function SettingsModal({
...form, ...form,
worktreeInitCommand: form.worktreeInitCommand?.trim() || undefined, worktreeInitCommand: form.worktreeInitCommand?.trim() || undefined,
taskPrefix: form.taskPrefix?.trim() || undefined, taskPrefix: form.taskPrefix?.trim() || undefined,
overlapIgnorePaths: (form.overlapIgnorePaths ?? []).map((path) => path.trim()).filter((path) => path.length > 0),
}; };
// Always save both global and project settings with strict scope separation. // Always save both global and project settings with strict scope separation.
@@ -2018,7 +2111,52 @@ export function SettingsModal({
<small>When enabled, tasks that modify the same files are queued serially to avoid merge conflicts</small> <small>When enabled, tasks that modify the same files are queued serially to avoid merge conflicts</small>
</div> </div>
<div style={{ borderTop: "1px solid var(--border)", margin: "var(--space-lg) 0" }} /> <div className="form-group settings-overlap-ignore-group">
<label>Ignored overlap paths</label>
<small>
Optional file or directory paths to ignore when overlap serialization is enabled.
Paths are project-relative (for example <code>docs/</code> or <code>generated/*</code>).
</small>
<div className="settings-overlap-ignore-list">
{(form.overlapIgnorePaths && form.overlapIgnorePaths.length > 0 ? form.overlapIgnorePaths : [""]).map((path, index) => (
<div key={`overlap-ignore-${index}`} className="settings-overlap-ignore-row">
<div className="settings-overlap-ignore-path-controls">
<input
type="text"
value={path}
placeholder="docs/"
onChange={(e) => handleOverlapIgnorePathChange(index, e.target.value)}
/>
<button
type="button"
className="btn btn-sm"
onClick={() => openOverlapPathPicker(index)}
aria-label={`Browse path for ignored overlap entry ${index + 1}`}
>
Browse
</button>
</div>
<button
type="button"
className="btn btn-sm"
onClick={() => handleRemoveOverlapIgnorePath(index)}
disabled={(form.overlapIgnorePaths ?? []).length === 0 && index === 0}
>
Remove
</button>
</div>
))}
</div>
<button
type="button"
className="btn btn-sm"
onClick={handleAddOverlapIgnorePath}
>
Add ignored path
</button>
</div>
<div className="settings-section-divider" />
<h5 className="settings-section-heading">Step Execution</h5> <h5 className="settings-section-heading">Step Execution</h5>
<div className="form-group"> <div className="form-group">
@@ -3528,6 +3666,60 @@ export function SettingsModal({
</div> </div>
</div> </div>
{overlapPathPickerIndex !== null && (
<div
className="modal-overlay open"
onClick={handleOverlapPathPickerOverlayClick}
role="dialog"
aria-modal="true"
aria-label="Browse workspace path"
>
<div className="modal modal-lg settings-overlap-path-picker-modal" onClick={(event) => event.stopPropagation()}>
<div className="modal-header">
<h3>Select ignored overlap path</h3>
<button className="modal-close" onClick={closeOverlapPathPicker} aria-label="Close">
&times;
</button>
</div>
<div className="modal-body settings-overlap-path-picker-body">
<p className="settings-overlap-path-picker-note">
Choose a file to ignore directly, or navigate into a folder and select the current directory.
</p>
<FileBrowser
entries={overlapPathPickerEntries}
currentPath={overlapPathPickerCurrentPath}
onSelectFile={selectOverlapIgnorePath}
onNavigate={setOverlapPathPickerPath}
loading={overlapPathPickerLoading}
error={overlapPathPickerError}
onRetry={refreshOverlapPathPicker}
workspace="project"
projectId={projectId}
/>
</div>
<div className="modal-actions">
<div className="modal-actions-left">
<small>
Current directory: <code>{overlapPathPickerCurrentPath === "." ? "(project root)" : overlapPathPickerCurrentPath}</code>
</small>
</div>
<div className="modal-actions-right">
<button className="btn btn-sm" onClick={closeOverlapPathPicker}>
Cancel
</button>
<button
className="btn btn-primary btn-sm"
onClick={handleSelectCurrentDirectoryForOverlapIgnore}
disabled={overlapPathPickerCurrentPath === "."}
>
Select current directory
</button>
</div>
</div>
</div>
</div>
)}
{/* Import Confirmation Dialog */} {/* Import Confirmation Dialog */}
{importDialogOpen && importPreview && ( {importDialogOpen && importPreview && (
<div className="modal-overlay open" onClick={(e) => e.target === e.currentTarget && setImportDialogOpen(false)} role="dialog" aria-modal="true"> <div className="modal-overlay open" onClick={(e) => e.target === e.currentTarget && setImportDialogOpen(false)} role="dialog" aria-modal="true">

View File

@@ -29,6 +29,7 @@ const mockTestMemoryRetrieval = vi.fn();
const mockInstallQmd = vi.fn(); const mockInstallQmd = vi.fn();
const mockFetchGitRemotesDetailed = vi.fn(); const mockFetchGitRemotesDetailed = vi.fn();
const mockFetchDashboardHealth = vi.fn(); const mockFetchDashboardHealth = vi.fn();
const mockUseWorkspaceFileBrowser = vi.fn();
vi.mock("../../api", () => ({ vi.mock("../../api", () => ({
fetchSettings: (...args: unknown[]) => mockFetchSettings(...args), fetchSettings: (...args: unknown[]) => mockFetchSettings(...args),
@@ -75,6 +76,18 @@ vi.mock("../PluginSlot", () => ({
PluginSlot: () => <div data-testid="plugin-slot">Plugin slot content</div>, PluginSlot: () => <div data-testid="plugin-slot">Plugin slot content</div>,
})); }));
vi.mock("../../hooks/useWorkspaceFileBrowser", () => ({
useWorkspaceFileBrowser: (...args: unknown[]) => mockUseWorkspaceFileBrowser(...args),
}));
vi.mock("../FileBrowser", () => ({
FileBrowser: ({ onSelectFile }: { onSelectFile: (path: string) => void }) => (
<div data-testid="mock-overlap-file-browser">
<button type="button" onClick={() => onSelectFile("README.md")}>Select README.md</button>
</div>
),
}));
const noop = () => {}; const noop = () => {};
const defaultSettings = { const defaultSettings = {
@@ -82,6 +95,7 @@ const defaultSettings = {
maxWorktrees: 4, maxWorktrees: 4,
pollIntervalMs: 15000, pollIntervalMs: 15000,
groupOverlappingFiles: true, groupOverlappingFiles: true,
overlapIgnorePaths: [],
autoMerge: true, autoMerge: true,
mergeStrategy: "direct", mergeStrategy: "direct",
pushAfterMerge: false, pushAfterMerge: false,
@@ -164,6 +178,14 @@ describe("SettingsModal", () => {
}); });
mockFetchGitRemotesDetailed.mockResolvedValue([]); mockFetchGitRemotesDetailed.mockResolvedValue([]);
mockFetchDashboardHealth.mockResolvedValue({ status: "ok", version: "1.2.3", uptime: 123 }); mockFetchDashboardHealth.mockResolvedValue({ status: "ok", version: "1.2.3", uptime: 123 });
mockUseWorkspaceFileBrowser.mockReturnValue({
entries: [],
currentPath: ".",
setPath: vi.fn(),
loading: false,
error: null,
refresh: vi.fn(),
});
mockImportSettings.mockResolvedValue({ success: true, globalCount: 0, projectCount: 0 }); mockImportSettings.mockResolvedValue({ success: true, globalCount: 0, projectCount: 0 });
mockFetchGlobalConcurrency.mockResolvedValue({ globalMaxConcurrent: 4, currentlyActive: 0, queuedCount: 0, projectsActive: {} }); mockFetchGlobalConcurrency.mockResolvedValue({ globalMaxConcurrent: 4, currentlyActive: 0, queuedCount: 0, projectsActive: {} });
mockUpdateGlobalConcurrency.mockResolvedValue({ globalMaxConcurrent: 4, currentlyActive: 0, queuedCount: 0, projectsActive: {} }); mockUpdateGlobalConcurrency.mockResolvedValue({ globalMaxConcurrent: 4, currentlyActive: 0, queuedCount: 0, projectsActive: {} });
@@ -412,6 +434,60 @@ describe("SettingsModal", () => {
}); });
}); });
describe("Scheduling overlap ignore paths", () => {
it("renders existing overlap ignore paths from settings", async () => {
mockFetchSettings.mockResolvedValue({
...defaultSettings,
overlapIgnorePaths: ["docs/", "generated/*"],
});
renderModal();
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Scheduling"));
expect(screen.getByDisplayValue("docs/")).toBeInTheDocument();
expect(screen.getByDisplayValue("generated/*")).toBeInTheDocument();
});
it("supports selecting ignore paths through the browse picker", async () => {
renderModal();
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Scheduling"));
await userEvent.click(screen.getByRole("button", { name: /browse path for ignored overlap entry 1/i }));
expect(await screen.findByRole("dialog", { name: /browse workspace path/i })).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Select README.md" }));
expect(screen.getByDisplayValue("README.md")).toBeInTheDocument();
});
it("includes overlapIgnorePaths in save payload", async () => {
renderModal();
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Scheduling"));
await userEvent.click(screen.getByRole("button", { name: /browse path for ignored overlap entry 1/i }));
await userEvent.click(await screen.findByRole("button", { name: "Select README.md" }));
await userEvent.click(screen.getByRole("button", { name: /add ignored path/i }));
const inputs = screen.getAllByPlaceholderText("docs/");
await userEvent.type(inputs[1], "generated/*");
await userEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(mockUpdateSettings).toHaveBeenCalledTimes(1);
});
const payload = mockUpdateSettings.mock.calls[0][0];
expect(payload.overlapIgnorePaths).toEqual(["README.md", "generated/*"]);
});
});
describe("Number input clearing", () => { describe("Number input clearing", () => {
it("allows clearing maxConcurrent without leaving a stuck zero", async () => { it("allows clearing maxConcurrent without leaving a stuck zero", async () => {
renderModal(); renderModal();

View File

@@ -505,6 +505,36 @@ function validateModelPresets(value: unknown): ModelPreset[] | undefined {
}); });
} }
function sanitizeOverlapIgnorePaths(value: unknown): string[] | undefined {
if (value === undefined || value === null) return undefined;
if (!Array.isArray(value)) {
throw badRequest("overlapIgnorePaths must be an array of project-relative paths");
}
const normalized = value.map((entry, index) => {
if (typeof entry !== "string") {
throw badRequest(`overlapIgnorePaths[${index}] must be a string`);
}
const trimmed = entry.trim().replaceAll("\\", "/");
if (!trimmed) {
throw badRequest(`overlapIgnorePaths[${index}] cannot be empty`);
}
if (isAbsolute(trimmed) || /^[a-zA-Z]:\//.test(trimmed)) {
throw badRequest(`overlapIgnorePaths[${index}] must be a project-relative path`);
}
if (/^\.{1,2}(\/|$)/.test(trimmed) || /(^|\/)\.\.(\/|$)/.test(trimmed)) {
throw badRequest(`overlapIgnorePaths[${index}] cannot include '..' traversal`);
}
return trimmed;
});
return [...new Set(normalized)];
}
// ── Run-Audit Timeline Types & Helpers ───────────────────────────────────── // ── Run-Audit Timeline Types & Helpers ─────────────────────────────────────
/** Valid domain filters for run-audit queries. */ /** Valid domain filters for run-audit queries. */
@@ -2671,6 +2701,9 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
if (Object.prototype.hasOwnProperty.call(clientSettings, "modelPresets")) { if (Object.prototype.hasOwnProperty.call(clientSettings, "modelPresets")) {
clientSettings.modelPresets = validateModelPresets(clientSettings.modelPresets); clientSettings.modelPresets = validateModelPresets(clientSettings.modelPresets);
} }
if (Object.prototype.hasOwnProperty.call(clientSettings, "overlapIgnorePaths")) {
clientSettings.overlapIgnorePaths = sanitizeOverlapIgnorePaths(clientSettings.overlapIgnorePaths);
}
// Validate backup settings if provided // Validate backup settings if provided
if (clientSettings.autoBackupSchedule !== undefined && !validateBackupSchedule(clientSettings.autoBackupSchedule)) { if (clientSettings.autoBackupSchedule !== undefined && !validateBackupSchedule(clientSettings.autoBackupSchedule)) {

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import type { PrMonitor } from "../pr-monitor.js"; 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 { AgentSemaphore } from "../concurrency.js";
import type { TaskStore, Task, TaskDetail } from "@fusion/core"; import type { TaskStore, Task, TaskDetail } from "@fusion/core";
import { existsSync } from "node:fs"; 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", () => { describe("Scheduler", () => {
// Helper to create mock MissionStore (shared across mission-related test suites) // Helper to create mock MissionStore (shared across mission-related test suites)
function createMockMissionStore(overrides = {}) { 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", () => { describe("worktree reservation", () => {
it("assigns a planned worktree path before moving a task to in-progress", async () => { it("assigns a planned worktree path before moving a task to in-progress", async () => {
vi.mocked(existsSync).mockReturnValue(true); vi.mocked(existsSync).mockReturnValue(true);

View File

@@ -51,6 +51,45 @@ export function pathsOverlap(a: string[], b: string[]): boolean {
return false; 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 { export interface SchedulerOptions {
/** Max concurrent in-progress tasks. Default: 2 */ /** Max concurrent in-progress tasks. Default: 2 */
maxConcurrent?: number; maxConcurrent?: number;
@@ -592,10 +631,12 @@ export class Scheduler {
*/ */
const activeScopes = new Map<string, string[]>(); const activeScopes = new Map<string, string[]>();
if (settings.groupOverlappingFiles) { if (settings.groupOverlappingFiles) {
const overlapIgnorePaths = settings.overlapIgnorePaths ?? [];
// In-progress tasks // In-progress tasks
for (const t of inProgress) { for (const t of inProgress) {
const scope = await this.store.parseFileScopeFromPrompt(t.id); 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 // In-review tasks with unmerged worktrees
const inReviewWithWorktree = tasks.filter( const inReviewWithWorktree = tasks.filter(
@@ -603,7 +644,8 @@ export class Scheduler {
); );
for (const t of inReviewWithWorktree) { for (const t of inReviewWithWorktree) {
const scope = await this.store.parseFileScopeFromPrompt(t.id); 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 // Check file scope overlap when enabled
if (settings.groupOverlappingFiles) { 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) { if (taskScope.length > 0) {
let overlappingTaskId: string | null = null; let overlappingTaskId: string | null = null;
for (const [ipId, ipScope] of activeScopes) { 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 // Track newly started task's file scope for overlap with remaining todo tasks
if (settings.groupOverlappingFiles) { 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); if (scope.length > 0) activeScopes.set(task.id, scope);
} }
} }