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:
5
.changeset/overlap-ignore-paths-scheduling.md
Normal file
5
.changeset/overlap-ignore-paths-scheduling.md
Normal 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.
|
||||||
@@ -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. |
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 === */
|
||||||
|
|||||||
@@ -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">
|
||||||
|
×
|
||||||
|
</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">
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user