FN-6962: ignore hidden overlap paths by default

Hidden dot paths no longer create file-overlap blockers unless operators opt back into legacy behavior.

- Add the ignoreHiddenOverlapPaths project setting, schema default, API sanitization, and Settings UI toggle.
- Filter hidden dot segments from scheduler, gridlock, and self-healing overlap checks by default while preserving explicit ignore paths.
- Cover default and opt-out behavior across core parity, dashboard settings routes, scheduler, gridlock, and self-healing tests.
- Document the setting and add a patch changeset for the published CLI package.

Files changed:
 .changeset/fn-6962-hidden-overlap-paths.md         |   7 ++
 docs/settings-reference.md                         |   1 +
 .../core/src/__tests__/settings-parity.test.ts     |   7 ++
 packages/core/src/settings-schema.ts               |   1 +
 packages/core/src/types.ts                         |   7 ++
 .../dashboard/app/components/SettingsModal.tsx     |   4 +-
 .../components/__tests__/SettingsModal.test.tsx    |  69 ++++++++++++++
 .../settings/sections/SchedulingSection.tsx        |  10 ++
 .../src/__tests__/routes-settings.test.ts          |  30 ++++++
 packages/dashboard/src/routes.ts                   |   9 ++
 .../src/routes/register-settings-memory-routes.ts  |   6 +-
 .../engine/src/__tests__/gridlock-detector.test.ts |  32 +++++++
 .../__tests__/scheduler-overlap-starvation.test.ts | 102 ++++++++++++++++++++-
 packages/engine/src/__tests__/self-healing.test.ts |  24 +++++
 packages/engine/src/gridlock-detector.ts           |   5 +-
 packages/engine/src/scheduler.ts                   |  40 +++++---
 packages/engine/src/self-healing.ts                |   2 +-
 packages/i18n/locales/en/app.json                  |   2 +
 packages/i18n/locales/es/app.json                  |  11 ++-
 packages/i18n/locales/fr/app.json                  |  11 ++-
 packages/i18n/locales/ko/app.json                  |  11 ++-
 packages/i18n/locales/zh-CN/app.json               |  11 ++-
 packages/i18n/locales/zh-TW/app.json               |  11 ++-
 packages/i18n/src/resources.d.ts                   |   2 +
 24 files changed, 379 insertions(+), 36 deletions(-)

Fusion-Task-Id: FN-6962
Fusion-Task-Lineage: c79120d3-fd9e-439d-b2cf-439f9e2e6952
This commit is contained in:
gsxdsm
2026-06-25 06:04:33 -07:00
parent 93fdc073e3
commit 9fabc9d750
24 changed files with 380 additions and 37 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Ignore hidden dot paths in overlap scheduling by default with a Settings toggle.
category: fix
dev: Adds project setting `ignoreHiddenOverlapPaths` and keeps `overlapIgnorePaths` as an additional explicit filter.

View File

@@ -328,6 +328,7 @@ Defaults from `DEFAULT_PROJECT_SETTINGS`; key scope from `PROJECT_SETTINGS_KEYS`
| `groupOverlappingFiles` | `boolean` | `true` | Serialize execution when file scopes overlap. |
| `pluginTrustPolicy` | `"off" | "warn" | "enforce"` | `"warn"` | Plugin provenance enforcement mode: `off` records verification metadata only, `warn` blocks only `invalid` signatures, `enforce` allows only `verified-trusted` or `trusted-local`. |
| `ignoreHiddenOverlapPaths` | `boolean` | `true` | Exclude hidden dot paths from overlap serialization by default. A hidden path is any normalized project-relative path with a segment beginning with `.`, such as `.fusion/tasks/FN-1/PROMPT.md`, `.changeset/fix.md`, `.github/workflows/ci.yml`, `.env`, or `packages/.cache/out.js`. Set to `false` to restore legacy strict counting of dot paths. Explicit `overlapIgnorePaths` entries still apply in addition to this default filter, and still apply when hidden-path filtering is disabled. |
| `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`. Tasks can override this per-task (including at create time in New Task modal via **Auto-merge** = Default/Enabled/Disabled); explicit overrides are tagged with `autoMergeProvenance: "user"`, while tasks left at **Default** keep following the live global setting and do not snapshot it when entering review. Legacy pre-FN-6245 in-review rows that were stamped `autoMerge: true` are marked `autoMergeProvenance: "legacy-stamp"` on startup and can be inspected/cleared with Settings → Merge → **Legacy auto-merge stamp cleanup**, `fn pr automerge-cleanup [--apply] [--json]`, or `reconcileLegacyAutoMergeStamps({ apply: true })` after operator review. For grouped branch flows, per-task `autoMerge` governs member→group-integration landing while group `autoMerge` governs group→default-branch promotion eligibility. |
| `maxAutoMergeRetries` | `number` | `3` | Project-scoped positive-integer cap for auto-merge conflict-resolution retries before Fusion parks or bounces a task for human/recovery handling. Unset, non-finite, zero, or negative values fall back to `3` to preserve historical behavior. |

View File

@@ -91,6 +91,13 @@ describe("settings key parity", () => {
expect(DEFAULT_PROJECT_SETTINGS.heartbeatMultiplier).toBe(1);
});
it("defaults hidden overlap path filtering on and keeps it project-scoped", () => {
expect(DEFAULT_PROJECT_SETTINGS.ignoreHiddenOverlapPaths).toBe(true);
expect(isProjectSettingsKey("ignoreHiddenOverlapPaths")).toBe(true);
expect(isGlobalSettingsKey("ignoreHiddenOverlapPaths")).toBe(false);
expect(PROJECT_SETTINGS_KEYS).toContain("ignoreHiddenOverlapPaths");
});
it("defaults autoClaimCandidatesInPrompt to 5 and keeps it project-scoped", () => {
expect(DEFAULT_PROJECT_SETTINGS.autoClaimCandidatesInPrompt).toBe(5);
expect(isProjectSettingsKey("autoClaimCandidatesInPrompt")).toBe(true);

View File

@@ -275,6 +275,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
heartbeatScopeDiscipline: "strict",
heartbeatPromptTemplate: "default",
groupOverlappingFiles: true,
ignoreHiddenOverlapPaths: true,
overlapIgnorePaths: [],
autoMerge: true,
// U18 (R15): the Review-response loop is default-on. Independent of `autoMerge` —

View File

@@ -3650,6 +3650,13 @@ export interface ProjectSettings {
*/
heartbeatPromptTemplate?: HeartbeatPromptTemplate;
groupOverlappingFiles: boolean;
/**
* When true (default), file-overlap serialization ignores project-relative paths
* containing any hidden dot segment (for example `.fusion/`, `.changeset/`,
* `.github/`, `.env`, or `packages/.cache/out.js`). Set false to restore the
* legacy behavior that counts hidden paths as overlap blockers.
*/
ignoreHiddenOverlapPaths?: 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.

View File

@@ -711,6 +711,7 @@ export function SettingsModal({
pollIntervalMs: 15000,
heartbeatMultiplier: 1,
groupOverlappingFiles: true,
ignoreHiddenOverlapPaths: true,
overlapIgnorePaths: [],
autoMerge: true,
mergeStrategy: "direct",
@@ -986,6 +987,7 @@ export function SettingsModal({
.then(([s, scoped]) => {
const normalizedSettings = {
...s,
ignoreHiddenOverlapPaths: s.ignoreHiddenOverlapPaths ?? true,
mergeIntegrationWorktree: normalizeMergeIntegrationWorktreeMode(s.mergeIntegrationWorktree),
mergeAdvanceAutoSync: normalizeMergeAdvanceAutoSyncMode(s.mergeAdvanceAutoSync),
maxAutoMergeRetries: resolveMaxAutoMergeRetriesForSettingsForm(s),
@@ -2025,7 +2027,7 @@ export function SettingsModal({
setImportFile(null);
// Refresh settings to show imported values
const refreshed = await fetchSettings(projectId);
setForm(refreshed);
setForm({ ...refreshed, ignoreHiddenOverlapPaths: refreshed.ignoreHiddenOverlapPaths ?? true });
} else {
addToast(result.error || t("settings.importExport.importFailed", "Import failed"), "error");
}

View File

@@ -201,6 +201,7 @@ const defaultSettings = {
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: true,
ignoreHiddenOverlapPaths: true,
overlapIgnorePaths: [],
autoMerge: true,
mergeStrategy: "direct",
@@ -2873,6 +2874,74 @@ describe("SettingsModal", () => {
});
describe("Scheduling overlap ignore paths", () => {
it("defaults hidden overlap path filtering checked when settings omit the key", async () => {
const { ignoreHiddenOverlapPaths: _omitted, ...settingsWithoutHiddenDefault } = defaultSettings;
mockFetchSettings.mockResolvedValue(settingsWithoutHiddenDefault);
mockFetchSettingsByScope.mockResolvedValue({ global: settingsWithoutHiddenDefault, project: {} });
renderModal();
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Scheduling & Capacity"));
expect(screen.getByLabelText(/ignore hidden dot paths in overlap checks/i)).toBeChecked();
});
it("renders saved false for hidden overlap path filtering", async () => {
mockFetchSettings.mockResolvedValue({
...defaultSettings,
ignoreHiddenOverlapPaths: false,
});
mockFetchSettingsByScope.mockResolvedValue({ global: defaultSettings, project: { ignoreHiddenOverlapPaths: false } });
renderModal();
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Scheduling & Capacity"));
expect(screen.getByLabelText(/ignore hidden dot paths in overlap checks/i)).not.toBeChecked();
});
it("sends hidden overlap filtering false without disrupting explicit ignore paths", async () => {
renderModal();
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Scheduling & Capacity"));
await userEvent.click(screen.getByLabelText(/ignore hidden dot paths in overlap checks/i));
await userEvent.type(screen.getByPlaceholderText("docs/"), "generated/*");
await userEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(mockUpdateSettings).toHaveBeenCalledTimes(1);
});
const payload = mockUpdateSettings.mock.calls[0][0];
expect(payload.ignoreHiddenOverlapPaths).toBe(false);
expect(payload.overlapIgnorePaths).toEqual(["generated/*"]);
});
it("sends hidden overlap filtering true after toggling saved false", async () => {
mockFetchSettings.mockResolvedValue({
...defaultSettings,
ignoreHiddenOverlapPaths: false,
});
mockFetchSettingsByScope.mockResolvedValue({ global: defaultSettings, project: { ignoreHiddenOverlapPaths: false } });
renderModal();
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Scheduling & Capacity"));
await userEvent.click(screen.getByLabelText(/ignore hidden dot paths in overlap checks/i));
await userEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(mockUpdateSettings).toHaveBeenCalledTimes(1);
});
expect(mockUpdateSettings.mock.calls[0][0].ignoreHiddenOverlapPaths).toBe(true);
});
it("renders existing overlap ignore paths from settings", async () => {
mockFetchSettings.mockResolvedValue({
...defaultSettings,

View File

@@ -162,6 +162,16 @@ export function SchedulingSection({ scopeBanner, form, setForm, globalMaxConcurr
<small>{t("settings.scheduling.whenEnabledTasksThatModifyTheSameFiles", "When enabled, tasks that modify the same files are queued serially to avoid merge conflicts")}</small>
</div>
{/**
* FNXC:SettingsScheduling 2026-06-23-13:22:
* Operators need a Scheduling toggle that defaults on for ignoring hidden dot paths in overlap checks while preserving the selected value independently of overlap serialization being enabled.
*/}
<div className="form-group">
<label htmlFor="ignoreHiddenOverlapPaths" className="checkbox-label">
<input id="ignoreHiddenOverlapPaths" type="checkbox" checked={form.ignoreHiddenOverlapPaths !== false} onChange={(e) => setForm((f) => ({ ...f, ignoreHiddenOverlapPaths: e.target.checked }))}/>{t("settings.scheduling.ignoreHiddenDotPathsInOverlapChecks", " Ignore hidden dot paths in overlap checks ")}</label>
<small>{t("settings.scheduling.ignoreHiddenDotPathsHelp", "When enabled, overlap checks ignore hidden path segments such as .fusion/, .changeset/, .github/, .env, and nested .cache/ directories. Uncheck to restore legacy counting for stricter serialization.")}</small>
</div>
<div className="form-group settings-overlap-ignore-group">
<label>{t("settings.scheduling.ignoredOverlapPaths", "Ignored overlap paths")}</label>
<small>{t("settings.scheduling.optionalFileOrDirectoryPathsToIgnoreWhen", " Optional file or directory paths to ignore when overlap serialization is enabled. Paths are project-relative (for example ")}<code>docs/</code>{t("settings.scheduling.or", " or ")}<code>generated/*</code>{t("settings.scheduling.closeParenPeriod", ").")}

View File

@@ -773,6 +773,36 @@ describe("PUT /settings", () => {
expect(store.updateSettings).toHaveBeenCalledWith({ maxConcurrent: 8, autoMerge: false });
});
it("accepts boolean hidden-overlap filtering values", async () => {
const updatedSettings = { ...DEFAULT_SETTINGS, ignoreHiddenOverlapPaths: false };
(store.updateSettings as ReturnType<typeof vi.fn>).mockResolvedValue(updatedSettings);
const res = await REQUEST(
buildApp(),
"PUT",
"/api/settings",
JSON.stringify({ ignoreHiddenOverlapPaths: false }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(store.updateSettings).toHaveBeenCalledWith({ ignoreHiddenOverlapPaths: false });
});
it("rejects non-boolean hidden-overlap filtering values", async () => {
const res = await REQUEST(
buildApp(),
"PUT",
"/api/settings",
JSON.stringify({ ignoreHiddenOverlapPaths: "false" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("ignoreHiddenOverlapPaths must be a boolean");
expect(store.updateSettings).not.toHaveBeenCalled();
});
it("accepts valid nested evalSettings payload", async () => {
(store.updateSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,

View File

@@ -532,6 +532,14 @@ function validateModelPresets(value: unknown): ModelPreset[] | undefined {
});
}
function sanitizeBooleanSetting(name: string, value: unknown): boolean | undefined {
if (value === undefined || value === null) return undefined;
if (typeof value !== "boolean") {
throw badRequest(`${name} must be a boolean`);
}
return value;
}
function sanitizeOverlapIgnorePaths(value: unknown): string[] | undefined {
if (value === undefined || value === null) return undefined;
if (!Array.isArray(value)) {
@@ -1049,6 +1057,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
registerSettingsMemoryRoutes(routeContext, {
githubToken,
validateModelPresets,
sanitizeBooleanSetting,
sanitizeOverlapIgnorePaths,
discoverDashboardPiExtensions,
});

View File

@@ -81,6 +81,7 @@ export function __resetCreateFnAgentForInsights(): void {
interface SettingsMemoryRouteDeps {
githubToken?: string;
validateModelPresets: (input: unknown) => ModelPreset[] | undefined;
sanitizeBooleanSetting: (name: string, input: unknown) => boolean | undefined;
sanitizeOverlapIgnorePaths: (input: unknown) => string[] | undefined;
discoverDashboardPiExtensions: (cwd: string) => Promise<PiExtensionSettings>;
}
@@ -196,7 +197,7 @@ export function validateCloudflaredManifest(input: unknown): { ok: true } | { ok
export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: SettingsMemoryRouteDeps): void {
const { router, options, store, runtimeLogger, getProjectContext, rethrowAsApiError } = ctx;
const { githubToken, validateModelPresets, sanitizeOverlapIgnorePaths, discoverDashboardPiExtensions } = deps;
const { githubToken, validateModelPresets, sanitizeBooleanSetting, sanitizeOverlapIgnorePaths, discoverDashboardPiExtensions } = deps;
const execFileAsync = promisify(execFile);
// Query the local tailscaled for this node's tailnet DNS name and any
@@ -603,6 +604,9 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
if (Object.prototype.hasOwnProperty.call(clientSettings, "modelPresets")) {
clientSettings.modelPresets = validateModelPresets(clientSettings.modelPresets);
}
if (Object.prototype.hasOwnProperty.call(clientSettings, "ignoreHiddenOverlapPaths")) {
clientSettings.ignoreHiddenOverlapPaths = sanitizeBooleanSetting("ignoreHiddenOverlapPaths", clientSettings.ignoreHiddenOverlapPaths);
}
if (Object.prototype.hasOwnProperty.call(clientSettings, "overlapIgnorePaths")) {
clientSettings.overlapIgnorePaths = sanitizeOverlapIgnorePaths(clientSettings.overlapIgnorePaths);
}

View File

@@ -181,6 +181,38 @@ describe("GridlockDetector", () => {
expect(event).toBeNull();
});
it("does not report gridlock for hidden-only overlaps by default", async () => {
tasks = [
createTask("FN-1", { column: "todo" }),
createTask("FN-2", { column: "in-progress" }),
];
scopes = {
"FN-1": [".fusion/tasks/FN-1/PROMPT.md", "packages/.cache/out.js"],
"FN-2": [".fusion/tasks/FN-1/PROMPT.md", "packages/.cache/out.js"],
};
const event = await detector.detectGridlock();
expect(event).toBeNull();
expect(onGridlock).not.toHaveBeenCalled();
});
it("reports gridlock for hidden-only overlaps when legacy counting is restored", async () => {
settings = createSettings({ ignoreHiddenOverlapPaths: false });
tasks = [
createTask("FN-1", { column: "todo" }),
createTask("FN-2", { column: "in-progress" }),
];
scopes = {
"FN-1": [".fusion/tasks/FN-1/PROMPT.md", "packages/.cache/out.js"],
"FN-2": [".fusion/tasks/FN-1/PROMPT.md", "packages/.cache/out.js"],
};
const event = await detector.detectGridlock();
expect(event?.blockedTaskIds).toEqual(["FN-1"]);
expect(event?.reasons).toEqual({ "FN-1": "overlap" });
expect(event?.blockingTaskIds).toEqual(["FN-2"]);
});
it("respects overlap ignore paths from settings", async () => {
settings = createSettings({ overlapIgnorePaths: ["docs/"] });
tasks = [

View File

@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { Scheduler } from "../scheduler.js";
import type { Agent, AgentStore, Task, TaskStore } from "@fusion/core";
import { filterPathsByIgnoreList, Scheduler } from "../scheduler.js";
import type { Agent, AgentStore, Settings, Task, TaskStore } from "@fusion/core";
function makeTask(overrides: Partial<Task> = {}): Task {
return {
@@ -35,7 +35,7 @@ function createAgentStore(agents: Agent[]): AgentStore {
} as unknown as AgentStore;
}
function createStore(tasks: Task[], scopes: Record<string, string[]>): TaskStore {
function createStore(tasks: Task[], scopes: Record<string, string[]>, settings: Partial<Settings> = {}): TaskStore {
const updateTask = vi.fn(async (id: string, patch: Partial<Task>) => {
const task = tasks.find((candidate) => candidate.id === id);
if (task) Object.assign(task, patch);
@@ -49,7 +49,7 @@ function createStore(tasks: Task[], scopes: Record<string, string[]>): TaskStore
return {
listTasks: vi.fn(async () => tasks),
getSettings: vi.fn(async () => ({ maxConcurrent: 10, maxWorktrees: 10, groupOverlappingFiles: true })),
getSettings: vi.fn(async () => ({ maxConcurrent: 10, maxWorktrees: 10, groupOverlappingFiles: true, ...settings })),
parseFileScopeFromPrompt: vi.fn(async (id: string) => scopes[id] ?? []),
updateTask,
moveTask,
@@ -63,6 +63,57 @@ function createStore(tasks: Task[], scopes: Record<string, string[]>): TaskStore
} as unknown as TaskStore;
}
describe("filterPathsByIgnoreList", () => {
it("ignores hidden top-level files and directories by default", () => {
expect(filterPathsByIgnoreList([
".env",
".fusion/tasks/FN-1/PROMPT.md",
".changeset/fn-6962.md",
".github/workflows/ci.yml",
"src/foo.ts",
])).toEqual(["src/foo.ts"]);
});
it("ignores nested hidden directories and Windows separators by default", () => {
expect(filterPathsByIgnoreList([
"packages/.cache/out.js",
"packages\\.vite\\manifest.json",
"docs/readme.md",
])).toEqual(["docs/readme.md"]);
});
it("preserves hidden paths when legacy counting is explicitly restored", () => {
expect(filterPathsByIgnoreList([
".env",
"packages/.cache/out.js",
"src/foo.ts",
], undefined, { ignoreHiddenOverlapPaths: false })).toEqual([".env", "packages/.cache/out.js", "src/foo.ts"]);
});
it("applies explicit ignores when hidden filtering is enabled", () => {
expect(filterPathsByIgnoreList([
".fusion/tasks/FN-1/PROMPT.md",
"docs/readme.md",
"generated/out.js",
"src/foo.ts",
], ["docs/", "generated/*"])).toEqual(["src/foo.ts"]);
});
it("applies explicit ignores when hidden filtering is disabled", () => {
expect(filterPathsByIgnoreList([
".fusion/tasks/FN-1/PROMPT.md",
"docs/readme.md",
"generated/out.js",
"src/foo.ts",
], ["docs/", "generated/*"], { ignoreHiddenOverlapPaths: false })).toEqual([".fusion/tasks/FN-1/PROMPT.md", "src/foo.ts"]);
});
it("keeps visible paths for empty and blank explicit ignore lists", () => {
expect(filterPathsByIgnoreList(["src/foo.ts", "docs/readme.md"], [])).toEqual(["src/foo.ts", "docs/readme.md"]);
expect(filterPathsByIgnoreList(["src/foo.ts", "docs/readme.md"], ["", " "])).toEqual(["src/foo.ts", "docs/readme.md"]);
});
});
describe("scheduler overlap starvation regression (FN-057)", () => {
beforeEach(() => {
vi.restoreAllMocks();
@@ -138,6 +189,49 @@ describe("scheduler overlap starvation regression (FN-057)", () => {
);
});
it("ignores hidden-only overlap leases by default when scheduling todo work", async () => {
const tasks = [
makeTask({ id: "FN-039", column: "in-progress", priority: "normal" }),
makeTask({ id: "FN-030", column: "todo", priority: "urgent" }),
];
const store = createStore(tasks, {
"FN-039": [".fusion/tasks/FN-039/PROMPT.md", "packages/.cache/out.js"],
"FN-030": [".fusion/tasks/FN-039/PROMPT.md", "packages/.cache/out.js"],
});
const scheduler = new Scheduler(store);
(scheduler as any).running = true;
await scheduler.schedule();
expect(store.moveTask).toHaveBeenCalledWith("FN-030", "in-progress", expect.objectContaining({ allocateWorktree: expect.any(Function) }));
expect(store.updateTask).not.toHaveBeenCalledWith(
"FN-030",
expect.objectContaining({ status: "queued", overlapBlockedBy: "FN-039" }),
);
});
it("counts hidden-only overlap leases when legacy counting is restored", async () => {
const tasks = [
makeTask({ id: "FN-039", column: "in-progress", priority: "normal" }),
makeTask({ id: "FN-030", column: "todo", priority: "urgent" }),
];
const store = createStore(tasks, {
"FN-039": [".fusion/tasks/FN-039/PROMPT.md", "packages/.cache/out.js"],
"FN-030": [".fusion/tasks/FN-039/PROMPT.md", "packages/.cache/out.js"],
}, { ignoreHiddenOverlapPaths: false });
const scheduler = new Scheduler(store);
(scheduler as any).running = true;
await scheduler.schedule();
expect(store.updateTask).toHaveBeenCalledWith("FN-030", {
status: "queued",
blockedBy: null,
overlapBlockedBy: "FN-039",
});
expect(store.moveTask).not.toHaveBeenCalledWith("FN-030", "in-progress", expect.anything());
});
it("keeps active file-scope leases bounded while non-overlapping ready work proceeds", async () => {
const tasks = [
makeTask({ id: "FN-039", column: "in-progress", priority: "normal" }),

View File

@@ -9430,6 +9430,30 @@ describe("FN-5335 triple-proof no-action unit coverage", () => {
manager.stop();
});
it("ignores hidden-only unmet dependency overlap when the setting is absent", async () => {
const { store, manager } = setup([
makeTask({ id: "FN-H", column: "in-progress", dependencies: ["FN-D"] }),
makeTask({ id: "FN-D", column: "todo" }),
], { "FN-H": [".fusion/tasks/FN-H/PROMPT.md"], "FN-D": [".fusion/tasks/FN-H/PROMPT.md"] });
vi.spyOn(manager as any, "evaluateBackwardMoveTripleProof").mockResolvedValue({ ok: true, metadata: {} });
await expect(manager.reconcileDependencyBlockingLeases()).resolves.toBe(0);
expect(store.moveTask).not.toHaveBeenCalled();
manager.stop();
});
it("rebounds hidden-only unmet dependency overlap when legacy counting is restored", async () => {
const { store, manager } = setup([
makeTask({ id: "FN-H", column: "in-progress", dependencies: ["FN-D"] }),
makeTask({ id: "FN-D", column: "todo" }),
], { "FN-H": [".fusion/tasks/FN-H/PROMPT.md"], "FN-D": [".fusion/tasks/FN-H/PROMPT.md"] }, { ignoreHiddenOverlapPaths: false });
vi.spyOn(manager as any, "evaluateBackwardMoveTripleProof").mockResolvedValue({ ok: true, stalenessMs: 10_000, reason: "test", metadata: {} });
await expect(manager.reconcileDependencyBlockingLeases()).resolves.toBe(1);
expect(store.moveTask).toHaveBeenCalledWith("FN-H", "todo", expect.objectContaining({ moveSource: "engine", recoveryRehome: true }));
manager.stop();
});
it.each([{ userPaused: true }, { paused: true }])("does not rebound operator-paused holders: %o", async (pauseState) => {
const { store, manager } = setup([
makeTask({ id: "FN-H", column: "in-progress", dependencies: ["FN-D"], ...pauseState }),

View File

@@ -79,10 +79,11 @@ export class GridlockDetector {
}
const overlapIgnorePaths = settings.overlapIgnorePaths ?? [];
const filterOptions = { ignoreHiddenOverlapPaths: settings.ignoreHiddenOverlapPaths };
const activeScopes = new Map<string, string[]>();
if (settings.groupOverlappingFiles) {
for (const task of active) {
const scope = filterPathsByIgnoreList(await this.store.parseFileScopeFromPrompt(task.id), overlapIgnorePaths);
const scope = filterPathsByIgnoreList(await this.store.parseFileScopeFromPrompt(task.id), overlapIgnorePaths, filterOptions);
if (scope.length > 0) {
activeScopes.set(task.id, scope);
}
@@ -106,7 +107,7 @@ export class GridlockDetector {
if (!settings.groupOverlappingFiles) continue;
const taskScope = filterPathsByIgnoreList(await this.store.parseFileScopeFromPrompt(task.id), overlapIgnorePaths);
const taskScope = filterPathsByIgnoreList(await this.store.parseFileScopeFromPrompt(task.id), overlapIgnorePaths, filterOptions);
if (taskScope.length === 0) continue;
for (const [activeId, activeScope] of activeScopes) {

View File

@@ -83,6 +83,12 @@ function normalizeOverlapPath(path: string): string {
return path.trim().replaceAll("\\", "/").replace(/^\.\//, "");
}
function hasHiddenOverlapPathSegment(path: string): boolean {
const normalizedPath = normalizeOverlapPath(path);
if (!normalizedPath) return false;
return normalizedPath.split("/").some((segment) => segment.startsWith("."));
}
function isIgnoredOverlapPath(path: string, ignorePath: string): boolean {
const normalizedPath = normalizeOverlapPath(path);
const normalizedIgnore = normalizeOverlapPath(ignorePath);
@@ -114,22 +120,32 @@ function computeAutoClaimFingerprint(task: Task): string {
].join("|");
}
export interface FilterOverlapPathsOptions {
ignoreHiddenOverlapPaths?: boolean;
}
/**
* 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.
* Remove scope entries that should not participate in file-overlap serialization.
*
* FNXC:OverlapScheduling 2026-06-23-13:09:
* Hidden dot paths are ignored by default for overlap blockers because task artifacts and hidden/generated metadata such as `.fusion/`, `.changeset/`, `.github/`, `.env`, and nested `.cache/` directories caused false serialization. Operators can set `ignoreHiddenOverlapPaths=false` to restore legacy counting, while explicit `overlapIgnorePaths` keep their exact/directory/glob-prefix semantics in either mode.
*/
export function filterPathsByIgnoreList(paths: string[], ignorePaths?: string[]): string[] {
if (!ignorePaths || ignorePaths.length === 0) {
export function filterPathsByIgnoreList(
paths: string[],
ignorePaths?: string[],
options: FilterOverlapPathsOptions = {},
): string[] {
const shouldIgnoreHidden = options.ignoreHiddenOverlapPaths ?? true;
const normalizedIgnorePaths = (ignorePaths ?? []).map(normalizeOverlapPath).filter(Boolean);
if (!shouldIgnoreHidden && normalizedIgnorePaths.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)));
return paths.filter((path) => {
if (shouldIgnoreHidden && hasHiddenOverlapPathSegment(path)) return false;
return !normalizedIgnorePaths.some((ignore) => isIgnoredOverlapPath(path, ignore));
});
}
export interface QueuedOverlapCandidate {
@@ -1471,7 +1487,7 @@ export class Scheduler {
const cached = filteredScopeByTaskId.get(taskId);
if (cached !== undefined) return cached;
const scope = await this.store.parseFileScopeFromPrompt(taskId);
const filteredScope = filterPathsByIgnoreList(scope, overlapIgnorePaths);
const filteredScope = filterPathsByIgnoreList(scope, overlapIgnorePaths, { ignoreHiddenOverlapPaths: settings.ignoreHiddenOverlapPaths });
filteredScopeByTaskId.set(taskId, filteredScope);
return filteredScope;
};
@@ -2147,7 +2163,7 @@ export class Scheduler {
const cached = filteredScopeByTaskId.get(taskId);
if (cached !== undefined) return cached;
const scope = await this.store.parseFileScopeFromPrompt(taskId);
const filteredScope = filterPathsByIgnoreList(scope, overlapIgnorePaths);
const filteredScope = filterPathsByIgnoreList(scope, overlapIgnorePaths, { ignoreHiddenOverlapPaths: settings.ignoreHiddenOverlapPaths });
filteredScopeByTaskId.set(taskId, filteredScope);
return filteredScope;
};

View File

@@ -5033,7 +5033,7 @@ export class SelfHealingManager {
const cached = filteredScopeByTaskId.get(taskId);
if (cached) return cached;
const scope = await this.store.parseFileScopeFromPrompt(taskId);
const filteredScope = filterPathsByIgnoreList(scope, overlapIgnorePaths);
const filteredScope = filterPathsByIgnoreList(scope, overlapIgnorePaths, { ignoreHiddenOverlapPaths: settings.ignoreHiddenOverlapPaths });
filteredScopeByTaskId.set(taskId, filteredScope);
return filteredScope;
};

View File

@@ -6396,6 +6396,8 @@
"fullAgentLog": "Full agent log",
"globalMaxConcurrent": "Global Max Concurrent",
"heartbeatScopeDiscipline": "Heartbeat Scope Discipline",
"ignoreHiddenDotPathsHelp": "When enabled, overlap checks ignore hidden path segments such as .fusion/, .changeset/, .github/, .env, and nested .cache/ directories. Uncheck to restore legacy counting for stricter serialization.",
"ignoreHiddenDotPathsInOverlapChecks": " Ignore hidden dot paths in overlap checks ",
"ignoredOverlapPaths": "Ignored overlap paths",
"letEngineerAgentsAutoClaimBacklogTasks": " Let engineer agents auto-claim backlog tasks ",
"lite": "Lite",

View File

@@ -1932,7 +1932,8 @@
"loadingMessage": "Cargando panel de Fusion",
"loadingProgress": "Progreso de carga del panel",
"updatingMessage": "Actualizando panel de Fusion",
"updatingVersion": "Actualizando a una nueva versión de frontend..."
"updatingVersion": "Actualizando a una nueva versión de frontend...",
"title": ""
},
"dbBanner": {
"body": "La verificación de integridad de SQLite de Fusion en segundo plano informó de corrupción. Revise los objetos con error a continuación antes de continuar con operaciones críticas.",
@@ -6427,7 +6428,9 @@
"whenEnabledTasksWithStalePlansPROMPTMd": "",
"whenTheStuckDetectorKillsAndReQueues": "",
"browseWorkspacePath": "",
"overlapPickerNote": ""
"overlapPickerNote": "",
"ignoreHiddenDotPathsHelp": "",
"ignoreHiddenDotPathsInOverlapChecks": ""
},
"scope": {
"globalBanner": "Estos ajustes se comparten entre todos tus proyectos de Fusion.",
@@ -8542,7 +8545,9 @@
"todo": "Todo",
"triggerAria": "Select workflow. Current workflow: {{name}}",
"editWorkflow": "Edit workflow",
"newWorkflow": "New workflow"
"newWorkflow": "New workflow",
"merging": "",
"mergingTitle": ""
},
"workspace": {
"projectRoot": "Raíz del proyecto",

View File

@@ -1932,7 +1932,8 @@
"loadingMessage": "Chargement du tableau de bord Fusion",
"loadingProgress": "Progression du chargement du tableau de bord",
"updatingMessage": "Mise à jour du tableau de bord Fusion",
"updatingVersion": "Mise à jour vers une nouvelle version de l'interface..."
"updatingVersion": "Mise à jour vers une nouvelle version de l'interface...",
"title": ""
},
"dbBanner": {
"body": "La vérification d'intégrité SQLite en arrière-plan de Fusion a signalé une corruption. Veuillez examiner les objets défaillants ci-dessous avant de continuer les opérations critiques.",
@@ -6427,7 +6428,9 @@
"whenEnabledTasksWithStalePlansPROMPTMd": "",
"whenTheStuckDetectorKillsAndReQueues": "",
"browseWorkspacePath": "",
"overlapPickerNote": ""
"overlapPickerNote": "",
"ignoreHiddenDotPathsHelp": "",
"ignoreHiddenDotPathsInOverlapChecks": ""
},
"scope": {
"globalBanner": "Ces paramètres sont partagés entre tous vos projets Fusion.",
@@ -8542,7 +8545,9 @@
"todo": "Todo",
"triggerAria": "Select workflow. Current workflow: {{name}}",
"editWorkflow": "Edit workflow",
"newWorkflow": "New workflow"
"newWorkflow": "New workflow",
"merging": "",
"mergingTitle": ""
},
"workspace": {
"projectRoot": "Racine du projet",

View File

@@ -1932,7 +1932,8 @@
"loadingMessage": "Fusion 대시보드 로드 중",
"loadingProgress": "대시보드 로딩 진행 상황",
"updatingMessage": "Fusion 대시보드 업데이트 중",
"updatingVersion": "새 프런트엔드 버전으로 업데이트 중..."
"updatingVersion": "새 프런트엔드 버전으로 업데이트 중...",
"title": ""
},
"dbBanner": {
"body": "Fusion의 백그라운드 SQLite 무결성 검사에서 손상이 발견되었습니다. 중요한 작업을 계속하기 전에 아래의 실패한 개체를 검토하세요.",
@@ -6427,7 +6428,9 @@
"whenEnabledTasksWithStalePlansPROMPTMd": "",
"whenTheStuckDetectorKillsAndReQueues": "",
"browseWorkspacePath": "",
"overlapPickerNote": ""
"overlapPickerNote": "",
"ignoreHiddenDotPathsHelp": "",
"ignoreHiddenDotPathsInOverlapChecks": ""
},
"scope": {
"globalBanner": "이 설정은 모든 Fusion 프로젝트에서 공유됩니다.",
@@ -8542,7 +8545,9 @@
"todo": "Todo",
"triggerAria": "Select workflow. Current workflow: {{name}}",
"editWorkflow": "Edit workflow",
"newWorkflow": "New workflow"
"newWorkflow": "New workflow",
"merging": "",
"mergingTitle": ""
},
"workspace": {
"projectRoot": "프로젝트 루트",

View File

@@ -1932,7 +1932,8 @@
"loadingMessage": "加载 Fusion 仪表板",
"loadingProgress": "仪表板加载进度",
"updatingMessage": "更新 Fusion 仪表板",
"updatingVersion": "更新到新的前端版本..."
"updatingVersion": "更新到新的前端版本...",
"title": ""
},
"dbBanner": {
"body": "Fusion 的后台 SQLite 完整性检查报告有损坏。继续关键操作之前,请查看下面的失败对象。",
@@ -6427,7 +6428,9 @@
"whenEnabledTasksWithStalePlansPROMPTMd": "",
"whenTheStuckDetectorKillsAndReQueues": "",
"browseWorkspacePath": "",
"overlapPickerNote": ""
"overlapPickerNote": "",
"ignoreHiddenDotPathsHelp": "",
"ignoreHiddenDotPathsInOverlapChecks": ""
},
"scope": {
"globalBanner": "这些设置在所有 Fusion 项目中共享。",
@@ -8542,7 +8545,9 @@
"todo": "Todo",
"triggerAria": "Select workflow. Current workflow: {{name}}",
"editWorkflow": "Edit workflow",
"newWorkflow": "New workflow"
"newWorkflow": "New workflow",
"merging": "",
"mergingTitle": ""
},
"workspace": {
"projectRoot": "项目根目录",

View File

@@ -1932,7 +1932,8 @@
"loadingMessage": "載入 Fusion 儀表板",
"loadingProgress": "儀表板加載進度",
"updatingMessage": "更新 Fusion 儀表板",
"updatingVersion": "更新到新的前端版本..."
"updatingVersion": "更新到新的前端版本...",
"title": ""
},
"dbBanner": {
"body": "Fusion 的背景 SQLite 完整性檢查報告有損毀。繼續重要操作之前,請查看下面的失敗物件。",
@@ -6427,7 +6428,9 @@
"whenEnabledTasksWithStalePlansPROMPTMd": "",
"whenTheStuckDetectorKillsAndReQueues": "",
"browseWorkspacePath": "",
"overlapPickerNote": ""
"overlapPickerNote": "",
"ignoreHiddenDotPathsHelp": "",
"ignoreHiddenDotPathsInOverlapChecks": ""
},
"scope": {
"globalBanner": "這些設定在所有 Fusion 專案中共用。",
@@ -8542,7 +8545,9 @@
"todo": "Todo",
"triggerAria": "Select workflow. Current workflow: {{name}}",
"editWorkflow": "Edit workflow",
"newWorkflow": "New workflow"
"newWorkflow": "New workflow",
"merging": "",
"mergingTitle": ""
},
"workspace": {
"projectRoot": "項目根目錄",

View File

@@ -6403,6 +6403,8 @@ export default interface Resources {
"fullAgentLog": "Full agent log",
"globalMaxConcurrent": "Global Max Concurrent",
"heartbeatScopeDiscipline": "Heartbeat Scope Discipline",
"ignoreHiddenDotPathsHelp": "When enabled, overlap checks ignore hidden path segments such as .fusion/, .changeset/, .github/, .env, and nested .cache/ directories. Uncheck to restore legacy counting for stricter serialization.",
"ignoreHiddenDotPathsInOverlapChecks": " Ignore hidden dot paths in overlap checks ",
"ignoredOverlapPaths": "Ignored overlap paths",
"letEngineerAgentsAutoClaimBacklogTasks": " Let engineer agents auto-claim backlog tasks ",
"lite": "Lite",