diff --git a/.changeset/fn-6962-hidden-overlap-paths.md b/.changeset/fn-6962-hidden-overlap-paths.md
new file mode 100644
index 0000000000..b24d2897d5
--- /dev/null
+++ b/.changeset/fn-6962-hidden-overlap-paths.md
@@ -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.
diff --git a/docs/settings-reference.md b/docs/settings-reference.md
index e33d26d115..b2e930774d 100644
--- a/docs/settings-reference.md
+++ b/docs/settings-reference.md
@@ -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. |
diff --git a/packages/core/src/__tests__/settings-parity.test.ts b/packages/core/src/__tests__/settings-parity.test.ts
index 24d962c631..318997a17a 100644
--- a/packages/core/src/__tests__/settings-parity.test.ts
+++ b/packages/core/src/__tests__/settings-parity.test.ts
@@ -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);
diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts
index adb2ecb701..91ae5e72ad 100644
--- a/packages/core/src/settings-schema.ts
+++ b/packages/core/src/settings-schema.ts
@@ -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` —
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index 957af522d5..a0d30dba19 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -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.
diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx
index dc3ed05ef2..95c5b703e5 100644
--- a/packages/dashboard/app/components/SettingsModal.tsx
+++ b/packages/dashboard/app/components/SettingsModal.tsx
@@ -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");
}
diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx
index 4c6d141789..c4cfdad37f 100644
--- a/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx
+++ b/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx
@@ -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,
diff --git a/packages/dashboard/app/components/settings/sections/SchedulingSection.tsx b/packages/dashboard/app/components/settings/sections/SchedulingSection.tsx
index df2e859ad9..9ce080a6a5 100644
--- a/packages/dashboard/app/components/settings/sections/SchedulingSection.tsx
+++ b/packages/dashboard/app/components/settings/sections/SchedulingSection.tsx
@@ -162,6 +162,16 @@ export function SchedulingSection({ scopeBanner, form, setForm, globalMaxConcurr
{t("settings.scheduling.whenEnabledTasksThatModifyTheSameFiles", "When enabled, tasks that modify the same files are queued serially to avoid merge conflicts")}
+ {/**
+ * 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.
+ */}
+
+
+ {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.")}
+
+
{t("settings.scheduling.optionalFileOrDirectoryPathsToIgnoreWhen", " Optional file or directory paths to ignore when overlap serialization is enabled. Paths are project-relative (for example ")}docs/{t("settings.scheduling.or", " or ")}generated/*{t("settings.scheduling.closeParenPeriod", ").")}
diff --git a/packages/dashboard/src/__tests__/routes-settings.test.ts b/packages/dashboard/src/__tests__/routes-settings.test.ts
index de6a5711ca..a93127163c 100644
--- a/packages/dashboard/src/__tests__/routes-settings.test.ts
+++ b/packages/dashboard/src/__tests__/routes-settings.test.ts
@@ -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).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).mockResolvedValue({
...DEFAULT_SETTINGS,
diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts
index 891c18bd1b..97bd5ecfea 100644
--- a/packages/dashboard/src/routes.ts
+++ b/packages/dashboard/src/routes.ts
@@ -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,
});
diff --git a/packages/dashboard/src/routes/register-settings-memory-routes.ts b/packages/dashboard/src/routes/register-settings-memory-routes.ts
index e8205ab684..45de62bf7f 100644
--- a/packages/dashboard/src/routes/register-settings-memory-routes.ts
+++ b/packages/dashboard/src/routes/register-settings-memory-routes.ts
@@ -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;
}
@@ -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);
}
diff --git a/packages/engine/src/__tests__/gridlock-detector.test.ts b/packages/engine/src/__tests__/gridlock-detector.test.ts
index aabcf24ef0..5b9dc116f3 100644
--- a/packages/engine/src/__tests__/gridlock-detector.test.ts
+++ b/packages/engine/src/__tests__/gridlock-detector.test.ts
@@ -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 = [
diff --git a/packages/engine/src/__tests__/scheduler-overlap-starvation.test.ts b/packages/engine/src/__tests__/scheduler-overlap-starvation.test.ts
index 46f5a71302..f14753a772 100644
--- a/packages/engine/src/__tests__/scheduler-overlap-starvation.test.ts
+++ b/packages/engine/src/__tests__/scheduler-overlap-starvation.test.ts
@@ -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 {
return {
@@ -35,7 +35,7 @@ function createAgentStore(agents: Agent[]): AgentStore {
} as unknown as AgentStore;
}
-function createStore(tasks: Task[], scopes: Record): TaskStore {
+function createStore(tasks: Task[], scopes: Record, settings: Partial = {}): TaskStore {
const updateTask = vi.fn(async (id: string, patch: Partial) => {
const task = tasks.find((candidate) => candidate.id === id);
if (task) Object.assign(task, patch);
@@ -49,7 +49,7 @@ function createStore(tasks: Task[], scopes: Record): 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): 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" }),
diff --git a/packages/engine/src/__tests__/self-healing.test.ts b/packages/engine/src/__tests__/self-healing.test.ts
index e494363222..5dd290a110 100644
--- a/packages/engine/src/__tests__/self-healing.test.ts
+++ b/packages/engine/src/__tests__/self-healing.test.ts
@@ -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 }),
diff --git a/packages/engine/src/gridlock-detector.ts b/packages/engine/src/gridlock-detector.ts
index c98d5cb90a..49a9df519c 100644
--- a/packages/engine/src/gridlock-detector.ts
+++ b/packages/engine/src/gridlock-detector.ts
@@ -79,10 +79,11 @@ export class GridlockDetector {
}
const overlapIgnorePaths = settings.overlapIgnorePaths ?? [];
+ const filterOptions = { ignoreHiddenOverlapPaths: settings.ignoreHiddenOverlapPaths };
const activeScopes = new Map();
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) {
diff --git a/packages/engine/src/scheduler.ts b/packages/engine/src/scheduler.ts
index 73aa405159..030b87ca51 100644
--- a/packages/engine/src/scheduler.ts
+++ b/packages/engine/src/scheduler.ts
@@ -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;
};
diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts
index 69a36f106d..70a714afd6 100644
--- a/packages/engine/src/self-healing.ts
+++ b/packages/engine/src/self-healing.ts
@@ -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;
};
diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json
index 00417c7aac..6244a91448 100644
--- a/packages/i18n/locales/en/app.json
+++ b/packages/i18n/locales/en/app.json
@@ -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",
diff --git a/packages/i18n/locales/es/app.json b/packages/i18n/locales/es/app.json
index 244358bd1f..bc4341f638 100644
--- a/packages/i18n/locales/es/app.json
+++ b/packages/i18n/locales/es/app.json
@@ -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",
diff --git a/packages/i18n/locales/fr/app.json b/packages/i18n/locales/fr/app.json
index 129198ee2d..809c285625 100644
--- a/packages/i18n/locales/fr/app.json
+++ b/packages/i18n/locales/fr/app.json
@@ -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",
diff --git a/packages/i18n/locales/ko/app.json b/packages/i18n/locales/ko/app.json
index 715c44bb18..e91255e823 100644
--- a/packages/i18n/locales/ko/app.json
+++ b/packages/i18n/locales/ko/app.json
@@ -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": "프로젝트 루트",
diff --git a/packages/i18n/locales/zh-CN/app.json b/packages/i18n/locales/zh-CN/app.json
index 5c10636e07..601c69fbed 100644
--- a/packages/i18n/locales/zh-CN/app.json
+++ b/packages/i18n/locales/zh-CN/app.json
@@ -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": "项目根目录",
diff --git a/packages/i18n/locales/zh-TW/app.json b/packages/i18n/locales/zh-TW/app.json
index 13681b889c..22c5f0b917 100644
--- a/packages/i18n/locales/zh-TW/app.json
+++ b/packages/i18n/locales/zh-TW/app.json
@@ -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": "項目根目錄",
diff --git a/packages/i18n/src/resources.d.ts b/packages/i18n/src/resources.d.ts
index e52f4ffc43..ad70e63e37 100644
--- a/packages/i18n/src/resources.d.ts
+++ b/packages/i18n/src/resources.d.ts
@@ -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",