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",