From 18d654a5ffec4d3b7c87d212c7610808b54d4bfe Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 28 Jul 2026 23:23:13 -0700 Subject: [PATCH] capacity, part 3: delete the globalMaxConcurrent setting, API and UI (#2529) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part 3 of the capacity simplification, and the half that removes the **knob**. Enforcement (shared semaphore, runtime wiring) went in #2509; this removes everything an operator or API client can still see, so nothing is left readable-but-ignored. ## Deleted Settings key + schema default · CentralCore’s `getGlobalConcurrencyState` / `updateGlobalConcurrency` / `acquireGlobalSlot` / `releaseGlobalSlot` and the `concurrency:changed` event · the whole Global Concurrency block in `async-central-core` · `PUT /api/global-concurrency` · the Scheduling · Global settings section · the footer and Command Center global sliders · the dead `getGlobalConcurrencyLimit` reader whose only caller went in #2509. ## Kept, deliberately **`GET /api/global-concurrency` survives as telemetry only** — live `currentlyActive` / `projectsActive` from CentralCore’s side-effect-safe source. “How busy is this machine?” is still a real question once the cap that used to answer it is gone. It no longer reports `globalMaxConcurrent`/`queuedCount`: those came from the deleted cap and from slot bookkeeping production code never incremented, so publishing them was publishing zeros dressed as state. **`useGlobalConcurrency` becomes read-only.** Everything that existed to *persist* went with the cap — the 500 ms debounce, the save-state machine, the commit-on-close/unmount flush, the slider clamp, the `interactive` gate. The module-level shared store is **kept**: its original justification (two mounted consumers drift apart with private copies) holds for a polled read exactly as it did for a cap, and one fetch now serves both. The live “N running (all projects)” readout survives in both surfaces, moved onto the per-project row. ## Two sections become one Scheduling · Global existed to host exactly one control. With it deleted the section renders an empty pane, so the Global/Project pair merges back into **“Scheduling”**. An empty nav entry is a promise of settings that are not there. ## One real fix found on the way `SchedulingSection`’s `concurrencyLoading` gated the **project** concurrency inputs on the **global**-concurrency fetch — never the right source, since `maxConcurrent` and `maxWorktrees` come from the settings form. It is repointed at the form’s own load, preserving the invariant it existed for: a concurrency input stays disabled until its live value arrives, so an operator cannot overwrite a resolved limit with a blank fallback. ## Migration A stored `globalMaxConcurrent` is **ignored** — it is a project-blob key nothing reads, so dropping it needs no schema change. The `central.global_concurrency` **table** is dropped in a follow-up; this slice stops seeding and reading it first, so that drop has no live writer to race. ## Verification, and how the wider suite was controlled `pnpm lint` clean · core/engine/dashboard `tsc` clean · `pnpm test:gate` green (309 + 10 + 71) · dashboard settings/footer/command-center/hooks **2237/2237** · core `central-core-backend` 9/9. The broader dashboard suite shows failures, and I checked rather than assumed: running the suspect files on **clean main** reproduces `api-git` (49), `TaskDetailModal.rendering` (28) and `settings-mobile` (17) identically. Two were genuinely mine — `SettingsModal.scheduling-merge` (0 on main, 17 on this branch: my nav rename) and one `settings-mobile` picker case asserting `scheduling` is a scoped pair — and both are fixed. Tests for deleted behaviour are removed with it (footer confirm/cancel/flush/dedupe, global marker geometry, the hook’s PUT case, the CentralCore slot cases), each carrying a note on what it guarded and where the surviving **project-side** equivalent lives. Fixture-only references were updated, not deleted. Nothing booted. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) --- .changeset/capacity-drop-global-cap.md | 7 + .../postgres/central-core-backend.test.ts | 33 -- packages/core/src/async-central-core.ts | 201 +++--------- packages/core/src/central-core.ts | 116 +------ packages/core/src/settings-schema.ts | 1 - packages/core/src/types/settings-scope.ts | 5 - .../app/components/EngineControlMenu.tsx | 159 ++-------- .../app/components/SettingsModal.tsx | 85 ++--- .../__tests__/EngineControlMenu.test.tsx | 299 +----------------- .../__tests__/SettingsModal.general.test.tsx | 85 +++-- .../SettingsModal.models-auth.test.tsx | 2 +- .../SettingsModal.scheduling-merge.test.tsx | 50 ++- .../__tests__/SettingsModal.test-harness.tsx | 2 +- .../SettingsModalNodeRouting.test.tsx | 2 +- .../__tests__/settings-mobile.test.tsx | 4 +- .../command-center/CommandCenterControls.tsx | 129 +------- .../__tests__/CommandCenterControls.test.tsx | 68 +--- .../settings/__tests__/section-keys.test.ts | 1 - .../__tests__/settings-search-index.test.ts | 1 - .../app/components/settings/search/entries.ts | 2 - .../app/components/settings/section-keys.ts | 2 - .../SchedulingGlobalSection.search.ts | 19 -- .../sections/SchedulingGlobalSection.tsx | 43 --- .../settings-default-descriptions.test.tsx | 1 - .../__tests__/useGlobalConcurrency.test.ts | 38 +-- .../app/hooks/useGlobalConcurrency.ts | 160 ++-------- .../register-setup-activity-routes.test.ts | 26 +- .../routes/register-setup-activity-routes.ts | 61 +--- .../dashboard/src/shared/settings-sections.ts | 9 +- .../src/runtimes/child-process-worker.ts | 7 +- .../engine/src/runtimes/in-process-runtime.ts | 20 +- 31 files changed, 311 insertions(+), 1327 deletions(-) create mode 100644 .changeset/capacity-drop-global-cap.md delete mode 100644 packages/dashboard/app/components/settings/sections/SchedulingGlobalSection.search.ts delete mode 100644 packages/dashboard/app/components/settings/sections/SchedulingGlobalSection.tsx diff --git a/.changeset/capacity-drop-global-cap.md b/.changeset/capacity-drop-global-cap.md new file mode 100644 index 0000000000..d5045cd9c8 --- /dev/null +++ b/.changeset/capacity-drop-global-cap.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Remove the machine-wide concurrency cap — capacity is now two numbers per project. +category: breaking +dev: Deletes `globalMaxConcurrent` (settings key, CentralCore state API, `PUT /api/global-concurrency`, the Scheduling · Global settings section, and the footer + Command Center global sliders). `GET /api/global-concurrency` survives but returns live telemetry only (`currentlyActive`, `projectsActive`); it no longer reports `globalMaxConcurrent`/`queuedCount`, which came from slot bookkeeping production code never incremented. `acquireGlobalSlot`/`releaseGlobalSlot` had no production callers and are gone. Scheduling · Global and Scheduling · Project merge back into one "Scheduling" section. A stored `globalMaxConcurrent` is ignored; the `central.global_concurrency` table is dropped in a follow-up. diff --git a/packages/core/src/__tests__/postgres/central-core-backend.test.ts b/packages/core/src/__tests__/postgres/central-core-backend.test.ts index b0f77930e3..dccaf6a947 100644 --- a/packages/core/src/__tests__/postgres/central-core-backend.test.ts +++ b/packages/core/src/__tests__/postgres/central-core-backend.test.ts @@ -233,40 +233,7 @@ pgDescribe("CentralCore backend mode (PostgreSQL)", () => { expect(count).toBeGreaterThanOrEqual(1); }); - it("manages global concurrency state through PostgreSQL", async () => { - ctx = await setupCtx(); - const initial = await ctx.central.getGlobalConcurrencyState(); - expect(initial.globalMaxConcurrent).toBeGreaterThanOrEqual(1); - const updated = await ctx.central.updateGlobalConcurrency({ - globalMaxConcurrent: 6, - }); - expect(updated.globalMaxConcurrent).toBe(6); - - const reread = await ctx.central.getGlobalConcurrencyState(); - expect(reread.globalMaxConcurrent).toBe(6); - }); - - it("acquires and releases a global concurrency slot atomically", async () => { - ctx = await setupCtx(); - const projectPath = makeProjectDir(ctx, "delta"); - const project = await ctx.central.registerProject({ - name: "Delta", - path: projectPath, - }); - await ctx.central.updateGlobalConcurrency({ globalMaxConcurrent: 1, currentlyActive: 0, queuedCount: 0 }); - - const acquired = await ctx.central.acquireGlobalSlot(project.id); - expect(acquired).toBe(true); - - // At limit now — second acquire should queue. - const queued = await ctx.central.acquireGlobalSlot(project.id); - expect(queued).toBe(false); - - await ctx.central.releaseGlobalSlot(project.id); - const state = await ctx.central.getGlobalConcurrencyState(); - expect(state.currentlyActive).toBe(0); - }); it("records project-node path mappings through PostgreSQL", async () => { ctx = await setupCtx(); diff --git a/packages/core/src/async-central-core.ts b/packages/core/src/async-central-core.ts index 86546885c8..7ded51180b 100644 --- a/packages/core/src/async-central-core.ts +++ b/packages/core/src/async-central-core.ts @@ -54,7 +54,6 @@ import type { DockerHostConfig, DockerNodeConfig, DockerNodeStatus, - GlobalConcurrencyState, IsolationMode, ManagedDockerNode, MeshSnapshotQuery, @@ -487,28 +486,23 @@ function mapMeshWriteRow(row: MeshWriteRow): MeshWriteQueueEntry { /** * FNXC:CentralCore 2026-06-26-12:05: - * Backend-mode init: ensure the singleton globalConcurrency row (id=1) and - * the local node exist. Mirrors the sync CentralCore.init() local-node + * Backend-mode init: ensure the singleton centralSettings row and the local node + * exist. Mirrors the sync CentralCore.init() local-node * bootstrap. The PostgreSQL schema baseline already created the tables; this * only seeds the runtime singletons. Idempotent. */ +/** Seed value for a freshly created local node row (previously the unset-row fallback). */ +const DEFAULT_LOCAL_NODE_MAX_CONCURRENT = 2; + export async function ensureBackendBootstrap(layer: AsyncDataLayer): Promise { await layer.transactionImmediate(async (tx) => { - // Ensure the globalConcurrency singleton row exists (CHECK constraint forces id=1). - const concurrency = (await tx - .select() - .from(schema.central.globalConcurrency) - .where(eq(schema.central.globalConcurrency.id, 1)) - .limit(1)) as { id: number; globalMaxConcurrent: number | null }[]; - if (concurrency.length === 0) { - await tx.insert(schema.central.globalConcurrency).values({ - id: 1, - globalMaxConcurrent: 4, - currentlyActive: 0, - queuedCount: 0, - updatedAt: new Date().toISOString(), - }); - } + /* + FNXC:CapacityModel 2026-07-28-23:30 (drop the cross-project cap — settings half): + The globalConcurrency singleton row is no longer SEEDED. Nothing reads it: the + cap it held is deleted and its currently_active/queued_count counters were + never incremented by production code. The table is dropped in the follow-up; + not seeding it here first means the drop has no live writer to race. + */ // Ensure the centralSettings singleton row exists. const settings = (await tx @@ -524,15 +518,22 @@ export async function ensureBackendBootstrap(layer: AsyncDataLayer): Promise { - const rows = (await handle - .select({ - globalMaxConcurrent: schema.central.globalConcurrency.globalMaxConcurrent, - currentlyActive: schema.central.globalConcurrency.currentlyActive, - queuedCount: schema.central.globalConcurrency.queuedCount, - }) - .from(schema.central.globalConcurrency) - .where(eq(schema.central.globalConcurrency.id, 1)) - .limit(1)) as { - globalMaxConcurrent: number | null; - currentlyActive: number | null; - queuedCount: number | null; - }[]; - return { - globalMaxConcurrent: rows[0]?.globalMaxConcurrent ?? 4, - currentlyActive: rows[0]?.currentlyActive ?? 0, - queuedCount: rows[0]?.queuedCount ?? 0, - }; -} +Capacity is two numbers PER PROJECT. The machine-wide cap lived here in a separate +authority (the `global_concurrency` singleton row) that every runtime subscribed to +and periodically re-reconciled against the per-project gates. -export async function getProjectsActiveCounts( - handle: QueryHandle, -): Promise> { - const rows = (await handle - .select({ - projectId: schema.central.projectHealth.projectId, - inFlightAgentCount: schema.central.projectHealth.inFlightAgentCount, - }) - .from(schema.central.projectHealth) - .where(sql`${schema.central.projectHealth.inFlightAgentCount} > 0`)) as { - projectId: string; - inFlightAgentCount: number | null; - }[]; - return rows.map((row) => ({ - projectId: row.projectId, - inFlightAgentCount: row.inFlightAgentCount ?? 0, - })); -} +The two slot functions were ALREADY dead before this change — measured in the +enforcement half: nothing in production called acquire/releaseGlobalSlot, so +`currently_active` was never incremented by real work. The durable counter was +fiction, which is why no operator ever saw the cap bind through it. -export async function getGlobalConcurrencyState( - handle: QueryHandle, -): Promise { - const row = await getGlobalConcurrencyRow(handle); - const activeCounts = await getProjectsActiveCounts(handle); - const projectsActive: Record = {}; - for (const { projectId, inFlightAgentCount } of activeCounts) { - projectsActive[projectId] = inFlightAgentCount; - } - return { - globalMaxConcurrent: row.globalMaxConcurrent, - currentlyActive: row.currentlyActive, - queuedCount: row.queuedCount, - projectsActive, - }; -} - -export async function updateGlobalConcurrencyRow( - handle: QueryHandle, - state: { globalMaxConcurrent: number; currentlyActive: number; queuedCount: number }, - now: string, -): Promise { - await handle - .update(schema.central.globalConcurrency) - .set({ - globalMaxConcurrent: state.globalMaxConcurrent, - currentlyActive: state.currentlyActive, - queuedCount: state.queuedCount, - updatedAt: now, - }) - .where(eq(schema.central.globalConcurrency.id, 1)); -} - -/** - * Atomically acquire a global concurrency slot or queue the request. Mirrors - * the sync acquireGlobalSlot() transaction: read the singleton row, increment - * currentlyActive + project inFlightAgentCount if a slot is available, - * otherwise increment queuedCount. - */ -export async function acquireGlobalSlotAtomic( - layer: AsyncDataLayer, - projectId: string, -): Promise { - return layer.transactionImmediate(async (tx) => { - const row = await getGlobalConcurrencyRow(tx); - const now = new Date().toISOString(); - if (row.currentlyActive < row.globalMaxConcurrent) { - await tx - .update(schema.central.globalConcurrency) - .set({ - currentlyActive: row.currentlyActive + 1, - updatedAt: now, - }) - .where(eq(schema.central.globalConcurrency.id, 1)); - await tx - .update(schema.central.projectHealth) - .set({ - inFlightAgentCount: sql`${schema.central.projectHealth.inFlightAgentCount} + 1`, - updatedAt: now, - }) - .where(eq(schema.central.projectHealth.projectId, projectId)); - return true; - } - await tx - .update(schema.central.globalConcurrency) - .set({ - queuedCount: row.queuedCount + 1, - updatedAt: now, - }) - .where(eq(schema.central.globalConcurrency.id, 1)); - return false; - }); -} - -/** - * Atomically release a global concurrency slot. Mirrors the sync - * releaseGlobalSlot() transaction with MAX(0, ...) clamping. - */ -export async function releaseGlobalSlotAtomic( - layer: AsyncDataLayer, - projectId: string, -): Promise { - await layer.transactionImmediate(async (tx) => { - const now = new Date().toISOString(); - await tx - .update(schema.central.globalConcurrency) - .set({ - currentlyActive: sql`GREATEST(0, ${schema.central.globalConcurrency.currentlyActive} - 1)`, - updatedAt: now, - }) - .where(eq(schema.central.globalConcurrency.id, 1)); - await tx - .update(schema.central.projectHealth) - .set({ - inFlightAgentCount: sql`GREATEST(0, ${schema.central.projectHealth.inFlightAgentCount} - 1)`, - updatedAt: now, - }) - .where(eq(schema.central.projectHealth.projectId, projectId)); - }); -} +Live "N running (all projects)" telemetry is unaffected: it comes from +CentralCore.getLiveRunningAgentCounts via the registered side-effect-safe source, +never from this table. The table itself is dropped in the follow-up so this change +stays reversible without a schema migration. +*/ // ── Mesh Snapshots + Write Queue ──────────────────────────────────────────── diff --git a/packages/core/src/central-core.ts b/packages/core/src/central-core.ts index 16a4dc847b..8864f9207e 100644 --- a/packages/core/src/central-core.ts +++ b/packages/core/src/central-core.ts @@ -39,7 +39,6 @@ import type { RegisteredProject, ProjectHealth, CentralActivityLogEntry, - GlobalConcurrencyState, IsolationMode, ProjectStatus, ActivityEventType, @@ -160,7 +159,6 @@ export interface CentralCoreEvents { /** Emitted when a discovered node is lost */ "discovery:node:lost": [name: string]; /** Emitted when global concurrency state changes */ - "concurrency:changed": [state: GlobalConcurrencyState]; /** Emitted when a node's version info is updated */ "node:version:updated": [payload: { nodeId: string; versionInfo: NodeVersionInfo }]; /** Emitted when plugin sync comparison completes */ @@ -2075,33 +2073,28 @@ export class CentralCore extends EventEmitter { // ── Global Concurrency API ───────────────────────────────────────────── - /** - * Get the current global concurrency state. - * - * @returns Current concurrency state including per-project active counts - */ - async getGlobalConcurrencyState(): Promise { - this.ensureInitialized(); + /* + FNXC:CapacityModel 2026-07-28-23:30 (drop the cross-project cap — settings half): + `getGlobalConcurrencyState`, `updateGlobalConcurrency`, `acquireGlobalSlot` and + `releaseGlobalSlot` are DELETED, along with the `concurrency:changed` event they + emitted. Capacity is two numbers PER PROJECT; a machine-wide cap was a third + limiter living in a separate authority (the `global_concurrency` singleton row). - return asyncCentralCore.getGlobalConcurrencyState(this.backendHandle); -} + The slot pair was already dead before this change — measured in the enforcement + half: no production caller ever invoked it, so `currentlyActive` was never + incremented by real work and the durable counter it maintained was fiction. - /** - * FNXC:GlobalConcurrencyControls 2026-06-26-17:22: - * Live running-agent counts from the registered side-effect-safe source. - * Falls back to persisted concurrency/health bookkeeping when no host source - * is registered so headless core callers keep their previous semantics. - */ + `getLiveRunningAgentCounts` SURVIVES and is now the only global readout. It is + TELEMETRY, not a limiter: it derives live per-project agent counts from the + registered side-effect-safe source so the dashboard can show "N running (all + projects)". Nothing gates on it. + */ async getLiveRunningAgentCounts(options?: { source?: RunningAgentCountSource }): Promise { this.ensureInitialized(); const source = options?.source ?? getRunningAgentCountSource(); if (!source) { - const state = await this.getGlobalConcurrencyState(); - return { - currentlyActive: state.currentlyActive, - projectsActive: state.projectsActive, - }; + return { currentlyActive: 0, projectsActive: {} }; } const projectIds = (await this.listProjects()).map((project) => project.id); @@ -2109,85 +2102,6 @@ export class CentralCore extends EventEmitter { return deriveRunningAgentCounts(perProject); } - /** - * Update global concurrency settings. - * Only allows updating globalMaxConcurrent, currentlyActive, and queuedCount. - * - * @param updates — Partial concurrency state updates - * @returns Updated concurrency state - */ - async updateGlobalConcurrency( - updates: Partial> - ): Promise { - this.ensureInitialized(); - - if ( - updates.globalMaxConcurrent !== undefined && - (!Number.isFinite(updates.globalMaxConcurrent) || updates.globalMaxConcurrent < 1 || updates.globalMaxConcurrent > 10000) - ) { - throw new Error("globalMaxConcurrent must be between 1 and 10000"); - } - - const current = await this.getGlobalConcurrencyState(); - const updated = { - ...current, - ...updates, - }; - - await asyncCentralCore.updateGlobalConcurrencyRow( - this.backendHandle, - updated, - new Date().toISOString(), - ); - this.emit("concurrency:changed", updated); - return updated; -} - - /** - * Acquire a global concurrency slot. - * Atomically checks if a slot is available and acquires it if so. - * - * @param projectId — Project requesting the slot - * @returns true if slot acquired, false if at limit (queued) - */ - async acquireGlobalSlot(projectId: string): Promise { - this.ensureInitialized(); - - // Check if project exists - const project = await this.getProject(projectId); - if (!project) { - throw new Error(`Project not found: ${projectId}`); - } - - let acquired = false; - - acquired = await asyncCentralCore.acquireGlobalSlotAtomic(this.asyncLayer!, projectId); - const state = await this.getGlobalConcurrencyState(); - this.emit("concurrency:changed", state); - return acquired; -} - - /** - * Release a global concurrency slot. - * Decrements the global active count and project's active count. - * - * @param projectId — Project releasing the slot - */ - async releaseGlobalSlot(projectId: string): Promise { - this.ensureInitialized(); - - // Check if project exists - const project = await this.getProject(projectId); - if (!project) { - throw new Error(`Project not found: ${projectId}`); - } - - await asyncCentralCore.releaseGlobalSlotAtomic(this.asyncLayer!, projectId); - const state = await this.getGlobalConcurrencyState(); - this.emit("concurrency:changed", state); - return; -} - // ── Utility Methods ───────────────────────────────────────────────────── /** diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index 5e933091ab..95fd230ed8 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -426,7 +426,6 @@ export const DEFAULT_PROJECT_SETTINGS = { Default one verification at a time process-wide so concurrent tasks cannot each run verify:fast / full builds simultaneously and peg the host. Operators with spare cores may raise this in Scheduling settings (clamped 1–8 at runtime). */ maxConcurrentVerifications: 1, - globalMaxConcurrent: 4, maxWorktrees: 4, /* FNXC:CapacityModel 2026-07-28-11:20: diff --git a/packages/core/src/types/settings-scope.ts b/packages/core/src/types/settings-scope.ts index 85d058ae46..2be3afb3e8 100644 --- a/packages/core/src/types/settings-scope.ts +++ b/packages/core/src/types/settings-scope.ts @@ -1139,11 +1139,6 @@ export interface ProjectSettings { * Max concurrent verification subprocesses (fn_run_verification / merge testCommand builds) across all tasks in this process. Caps stacked monorepo typecheck/build pegging CPU when many tasks are in-progress. Default 1. Raise only on high-core hosts. */ maxConcurrentVerifications?: number; - /** System-wide maximum concurrent agents across ALL projects. - * When multiple projects are active, the sum of their in-flight agents - * will not exceed this limit. Applies to triage, execution, and merge. - * Default: 4. When undefined, falls back to CentralCore default (4). */ - globalMaxConcurrent?: number; maxWorktrees: number; /** * FNXC:CapacityModel 2026-07-28-22:15 (PR #2502 review): diff --git a/packages/dashboard/app/components/EngineControlMenu.tsx b/packages/dashboard/app/components/EngineControlMenu.tsx index 49f9908c6c..de18718235 100644 --- a/packages/dashboard/app/components/EngineControlMenu.tsx +++ b/packages/dashboard/app/components/EngineControlMenu.tsx @@ -113,11 +113,13 @@ export const EngineControlMenu = forwardRef(null); const projectConcurrencyConfirmOpenRef = useRef(false); const projectConcurrencyConfirmTokenRef = useRef(0); - const [pendingGlobalConcurrencyValue, setPendingGlobalConcurrencyValue] = useState(null); - const [globalConcurrencyDirty, setGlobalConcurrencyDirty] = useState(false); - const [globalConcurrencyConfirmOpen, setGlobalConcurrencyConfirmOpen] = useState(false); - const globalConcurrencyConfirmOpenRef = useRef(false); - const globalConcurrencyConfirmTokenRef = useRef(0); + /* + FNXC:CapacityModel 2026-07-29-00:10 (drop the cross-project cap — settings half): + The footer's pending/dirty/confirm-token state for the global cap is DELETED with + the slider it guarded. Its whole purpose was to hold an edit un-persisted until the + operator confirmed, so a close/Escape/outside-click could not commit a drag; with + nothing to persist there is nothing to guard. + */ // FNXC:EngineControls 2026-06-29-00:00: Footer per-project concurrency sliders affect live scheduler capacity, so settled edits must be confirmed before persisting; close, Escape, outside-click, backdrop, and cancel revert to the last loaded values instead of silently saving. // FNXC:GlobalConcurrencyControls 2026-06-25-22:45: Fetch is gated on the menu being open; the hook flushes any pending debounced write when `open` flips false. const gc = useGlobalConcurrency({ activeWhen: open }); @@ -143,33 +145,20 @@ export const EngineControlMenu = forwardRef { - globalConcurrencyConfirmOpenRef.current = false; - globalConcurrencyConfirmTokenRef.current += 1; - setGlobalConcurrencyConfirmOpen(false); - setPendingGlobalConcurrencyValue(null); - setGlobalConcurrencyDirty(false); - }, []); const closeMenu = useCallback(() => { if (concurrencyDirty || pendingProjectConcurrencySaveRef.current || projectConcurrencyConfirmOpenRef.current) { revertPendingProjectConcurrencyEdit(); } - if (globalConcurrencyDirty || pendingGlobalConcurrencyValue !== null || globalConcurrencyConfirmOpenRef.current) { - revertPendingGlobalConcurrencyEdit(); - } setOpen(false); - }, [concurrencyDirty, globalConcurrencyDirty, pendingGlobalConcurrencyValue, revertPendingGlobalConcurrencyEdit, revertPendingProjectConcurrencyEdit]); + }, [concurrencyDirty, revertPendingProjectConcurrencyEdit]); const openMenu = useCallback(() => setOpen(true), []); const toggleMenu = useCallback(() => { if (open && (concurrencyDirty || pendingProjectConcurrencySaveRef.current || projectConcurrencyConfirmOpenRef.current)) { revertPendingProjectConcurrencyEdit(); } - if (open && (globalConcurrencyDirty || pendingGlobalConcurrencyValue !== null || globalConcurrencyConfirmOpenRef.current)) { - revertPendingGlobalConcurrencyEdit(); - } setOpen((current) => !current); - }, [concurrencyDirty, globalConcurrencyDirty, open, pendingGlobalConcurrencyValue, revertPendingGlobalConcurrencyEdit, revertPendingProjectConcurrencyEdit]); + }, [concurrencyDirty, open, revertPendingProjectConcurrencyEdit]); useImperativeHandle(ref, () => ({ open: openMenu, @@ -182,7 +171,7 @@ export const EngineControlMenu = forwardRef { const target = event.target; - if ((projectConcurrencyConfirmOpenRef.current || globalConcurrencyConfirmOpenRef.current) && target instanceof Element && target.closest(".confirm-dialog-overlay, .confirm-dialog")) { + if (projectConcurrencyConfirmOpenRef.current && target instanceof Element && target.closest(".confirm-dialog-overlay, .confirm-dialog")) { return; } if (menuRef.current && target instanceof Node && !menuRef.current.contains(target)) { @@ -309,60 +298,6 @@ export const EngineControlMenu = forwardRef { - if (!globalConcurrencyDirty || pendingGlobalConcurrencyValue === null || !gc.interactive || globalConcurrencyConfirmOpenRef.current) return; - const nextValue = pendingGlobalConcurrencyValue; - const persistedValue = gc.value; - const confirmToken = globalConcurrencyConfirmTokenRef.current; - const timeoutId = setTimeout(() => { - if (nextValue === persistedValue) { - setPendingGlobalConcurrencyValue(null); - setGlobalConcurrencyDirty(false); - return; - } - - globalConcurrencyConfirmOpenRef.current = true; - setGlobalConcurrencyConfirmOpen(true); - const changeSummary = getConcurrencyChangeSummary( - t, - t("settings.scheduling.globalMaxConcurrent", "Global Max Concurrent"), - persistedValue, - nextValue, - ); - void confirm({ - title: t("commandCenter.controls.concurrency.confirmTitle", "Confirm concurrency change"), - message: t( - "commandCenter.controls.concurrency.confirmMessage", - "Change {{setting}}?", - { setting: changeSummary }, - ), - confirmLabel: t("commandCenter.controls.concurrency.confirmSave", "Save change"), - cancelLabel: t("commandCenter.controls.concurrency.confirmCancel", "Cancel"), - }).then((confirmed) => { - globalConcurrencyConfirmOpenRef.current = false; - setGlobalConcurrencyConfirmOpen(false); - if (globalConcurrencyConfirmTokenRef.current !== confirmToken || !open) return; - if (confirmed) { - gc.setValue(String(nextValue)); - } - setPendingGlobalConcurrencyValue(null); - setGlobalConcurrencyDirty(false); - }); - }, CONCURRENCY_SAVE_DEBOUNCE_MS); - return () => clearTimeout(timeoutId); - }, [confirm, gc.interactive, gc.setValue, gc.value, globalConcurrencyDirty, open, pendingGlobalConcurrencyValue, t]); - - const updateGlobalConcurrencyValue = (rawValue: string) => { - if (!gc.interactive || globalConcurrencyConfirmOpenRef.current) return; - const nextValue = clamp(Number(rawValue), gc.min, Math.max(gc.sliderMax, gc.value)); - setPendingGlobalConcurrencyValue(nextValue); - setGlobalConcurrencyDirty(true); - }; - const updateConcurrencyValue = (key: keyof ConcurrencyValues, rawValue: string, min: number, max: number) => { const nextValue = clamp(Number(rawValue), min, max); setConcurrencyState((current) => ({ @@ -375,19 +310,6 @@ export const EngineControlMenu = forwardRef - {/* - FNXC:GlobalConcurrencyControls 2026-06-25-14:10: - Operators need to adjust the global cross-project concurrency cap from the footer engine menu and the dashboard Concurrency card, not just the Settings modal; global cap is distinct from per-project maxConcurrent and persists via the central /api/global-concurrency endpoint. - */} -
-
- {t("settings.scheduling.globalMaxConcurrent", "Global Max Concurrent")} - {t("commandCenter.controls.scope.allProjects", "All projects")} - - {globalSaveLabel} - -
- - {gc.status === "error" ?

{t("commandCenter.controls.concurrency.error", "Unable to load concurrency settings")}

: null} -
-
{t("commandCenter.controls.concurrency.title", "Concurrency")} + {/* + FNXC:CapacityModel 2026-07-28-23:45 (drop the cross-project cap — settings half): + The Global Max Concurrent SECTION above this one is deleted: the machine-wide + cap it wrote no longer exists (capacity is two numbers PER PROJECT) and its PUT + route is gone. The live "N running (all projects)" readout moves here — + telemetry, never a limit. + */} + {globalCountsLoaded ? ( + + {t("commandCenter.controls.concurrency.runningGlobal", "{{count}} running (all projects)", { count: gc.currentlyActive })} + + ) : null} {saveLabel} diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index 96743187ce..8c90bea194 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -8,7 +8,7 @@ import { } from "@fusion/core"; import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset } from "@fusion/core"; import { DEFAULT_GLOBAL_SETTINGS } from "@fusion/core"; -import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, cancelProviderLogin, saveApiKey, clearApiKey, fetchModels, testNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, triggerMemoryDreams, fetchGitRemotes, fetchGitRemotesDetailed, fetchGitBranches, fetchProjects, fetchDashboardHealth, checkForUpdates, installUpdate, fetchSystemInfo, requestSystemRestart, fetchRemoteSettings, fetchRemoteStatus, installCloudflared, fetchRemoteQr, fetchRemoteUrl, submitProviderManualCode, fetchPlugins } from "../api"; +import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, cancelProviderLogin, saveApiKey, clearApiKey, fetchModels, testNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, installQmd, testMemoryRetrieval, triggerMemoryDreams, fetchGitRemotes, fetchGitRemotesDetailed, fetchGitBranches, fetchProjects, fetchDashboardHealth, checkForUpdates, installUpdate, fetchSystemInfo, requestSystemRestart, fetchRemoteSettings, fetchRemoteStatus, installCloudflared, fetchRemoteQr, fetchRemoteUrl, submitProviderManualCode, fetchPlugins } from "../api"; import type { AuthProvider, ManualOAuthCodeInfo, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemote, GitRemoteDetailed, ProjectInfo, RemoteStatus, UpdateCheckResponse, UpdateInstallResponse, OAuthDeviceCodeInfo } from "../api"; import { resolveScopedMcpSettings, splitSettingsSave, type McpSettingsScope } from "./settings/save-split"; import { @@ -46,7 +46,6 @@ import { PromptsSection } from "./settings/sections/PromptsSection"; import { GeneralSection } from "./settings/sections/GeneralSection"; import { ProjectModelsSection, WorkflowLaneFlushRejection } from "./settings/sections/ProjectModelsSection"; import { SchedulingSection } from "./settings/sections/SchedulingSection"; -import { SchedulingGlobalSection } from "./settings/sections/SchedulingGlobalSection"; import { CliBinarySection } from "./settings/sections/CliBinarySection"; import { ScheduledEvalsSection } from "./settings/sections/ScheduledEvalsSection"; import { NodeRoutingSection } from "./settings/sections/NodeRoutingSection"; @@ -1505,11 +1504,12 @@ export function SettingsModal({ const skipNextMemoryReloadRef = useRef(false); // Global concurrency state - const [globalMaxConcurrent, setGlobalMaxConcurrent] = useState(4); - const initialGlobalMaxConcurrentRef = useRef(4); - const hasFetchedGlobalConcurrencyRef = useRef(false); - const globalConcurrencyDirtyRef = useRef(false); - const [globalConcurrencyLoaded, setGlobalConcurrencyLoaded] = useState(false); + /* + FNXC:CapacityModel 2026-07-29-00:10 (drop the cross-project cap — settings half): + The machine-wide cap's state, its dedicated fetch, its save branch and its dirty + tracking are DELETED along with the Scheduling · Global section that hosted it. + Capacity is two numbers PER PROJECT, both of which live in the settings form. + */ // Import/Export state const [importDialogOpen, setImportDialogOpen] = useState(false); @@ -1702,38 +1702,6 @@ export function SettingsModal({ void refreshSettingsForm(true); }, [addToast, projectId]); - /* - FNXC:SettingsConcurrency 2026-07-15-18:52: - Fetches for EITHER scheduling section. `scheduling-global` renders the cap itself, and `scheduling` (project) gates its own concurrency inputs on this load — the FN-era invariant that a concurrency input stays disabled until its live value arrives, so an operator cannot overwrite a resolved limit with a blank fallback. - Gating on `"scheduling"` alone (the id before the Global/Project split) would leave the global cap's own section waiting on a fetch that never fires, disabling the only control it renders. - */ - useEffect(() => { - if ((activeSection !== "scheduling" && activeSection !== "scheduling-global") || hasFetchedGlobalConcurrencyRef.current) { - return; - } - - let cancelled = false; - fetchGlobalConcurrency() - .then((state) => { - if (cancelled) { - return; - } - if (!globalConcurrencyDirtyRef.current) { - setGlobalMaxConcurrent(state.globalMaxConcurrent); - } - initialGlobalMaxConcurrentRef.current = state.globalMaxConcurrent; - hasFetchedGlobalConcurrencyRef.current = true; - setGlobalConcurrencyLoaded(true); - }) - .catch(() => { - // Silently fail — global concurrency may not be available - setGlobalConcurrencyLoaded(true); - }); - - return () => { - cancelled = true; - }; - }, [activeSection]); useEffect(() => { let cancelled = false; @@ -3371,7 +3339,6 @@ export function SettingsModal({ const scopedSettingsSnapshot = scopedSettings; const initialValuesSnapshot = initialValues; const initialScopedValuesSnapshot = initialScopedValues; - const globalMaxConcurrentSnapshot = globalMaxConcurrent; const activeSectionSnapshot = activeSection; const globalGitlabSettingsSnapshot = globalGitlabSettings; const workflowLaneRevisionSnapshot = workflowLaneRevisionRef.current; @@ -3523,9 +3490,6 @@ export function SettingsModal({ await Promise.all([ Object.keys(globalPatch).length > 0 ? updateGlobalSettings(globalPatch) : Promise.resolve(), Object.keys(projectPatch).length > 0 ? updateSettings(projectPatch, projectId) : Promise.resolve(), - globalMaxConcurrentSnapshot !== initialGlobalMaxConcurrentRef.current - ? updateGlobalConcurrency({ globalMaxConcurrent: globalMaxConcurrentSnapshot ?? 4 }) - : Promise.resolve(), ]); await workflowLaneSaverRef.current?.(); @@ -3554,9 +3518,6 @@ export function SettingsModal({ project: mergePatch(current.project, projectPatch as Record) as Partial, }; }); - if (globalMaxConcurrentSnapshot !== initialGlobalMaxConcurrentRef.current) { - initialGlobalMaxConcurrentRef.current = globalMaxConcurrentSnapshot; - } /* FNXC:SettingsAutoSave 2026-08-02-21:45: A successful snapshot becomes the next autosave comparison point. If the @@ -3567,7 +3528,6 @@ export function SettingsModal({ form: formSnapshot, scopedSettings: scopedSettingsSnapshot, globalGitlabSettings: globalGitlabSettingsSnapshot, - globalMaxConcurrent: globalMaxConcurrentSnapshot, }); lastPersistSucceededRef.current = true; return true; @@ -3585,7 +3545,7 @@ export function SettingsModal({ void persistSettingsRef.current?.(); } } - }, [form, globalGitlabSettings, globalMaxConcurrent, prefixError, presetDraft, initialValues, initialScopedValues, scopedSettings, addToast, projectId, activeSection, t]); + }, [form, globalGitlabSettings, prefixError, presetDraft, initialValues, initialScopedValues, scopedSettings, addToast, projectId, activeSection, t]); persistSettingsRef.current = persistSettings; const settingsDirty = useMemo(() => { @@ -3603,11 +3563,10 @@ export function SettingsModal({ } : undefined, }); return Object.keys(globalPatch).length > 0 || Object.keys(projectPatch).length > 0 - || globalMaxConcurrent !== initialGlobalMaxConcurrentRef.current || workflowLanesDirty; - }, [form, globalGitlabSettings, globalMaxConcurrent, initialScopedValues, initialValues, scopedSettings, activeSection, workflowLanesDirty]); + }, [form, globalGitlabSettings, initialScopedValues, initialValues, scopedSettings, activeSection, workflowLanesDirty]); - const autoSaveSnapshot = useMemo(() => JSON.stringify({ form, scopedSettings, globalGitlabSettings, globalMaxConcurrent, workflowLaneRevision: workflowLaneRevisionRef.current }), [form, globalGitlabSettings, globalMaxConcurrent, scopedSettings, workflowLanesDirty]); + const autoSaveSnapshot = useMemo(() => JSON.stringify({ form, scopedSettings, globalGitlabSettings, workflowLaneRevision: workflowLaneRevisionRef.current }), [form, globalGitlabSettings, scopedSettings, workflowLanesDirty]); const hasAutoSaveChange = autoSaveActivationSnapshotRef.current !== null && autoSaveActivationSnapshotRef.current !== autoSaveSnapshot; latestAutoSaveStateRef.current = { dirty: settingsDirty, changed: hasAutoSaveChange }; @@ -3640,7 +3599,7 @@ export function SettingsModal({ return () => { if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current); }; - }, [loading, autoSaveReady, hasAutoSaveChange, settingsDirty, prefixError, presetDraft, form, scopedSettings, globalGitlabSettings, globalMaxConcurrent, workflowLanesDirty, activeSection]); + }, [loading, autoSaveReady, hasAutoSaveChange, settingsDirty, prefixError, presetDraft, form, scopedSettings, globalGitlabSettings, workflowLanesDirty, activeSection]); const requestClose = useCallback(async () => { if (autoSaveTimerRef.current) { @@ -4150,23 +4109,21 @@ export function SettingsModal({ setSessionBannersHidden={setSessionBannersHidden} /> ); - case "scheduling-global": - return ( - { - globalConcurrencyDirtyRef.current = true; - setGlobalMaxConcurrent(value); - }} - /> - ); case "scheduling": return ( + /* + FNXC:CapacityModel 2026-07-29-00:10 (drop the cross-project cap — settings half): + `concurrencyLoading` gated the PROJECT concurrency inputs on the GLOBAL + concurrency fetch, which was never the right source — maxConcurrent and + maxWorktrees come from the settings form. With the global cap deleted the gate + is repointed at the form's own load, preserving the invariant it existed for: + a concurrency input stays disabled until its live value has arrived, so an + operator cannot overwrite a resolved limit with a blank fallback. + */ { expect(screen.getByLabelText(/max worktrees/i)).toHaveAttribute("max", "50"); }); - it("confirms footer global cap edits before writing through the shared hook", async () => { - await openMenu(); - const globalMaxConcurrent = await screen.findByLabelText(/maximum concurrent agents across all projects/i); - vi.useFakeTimers(); - fireEvent.change(globalMaxConcurrent, { target: { value: "9" } }); - expect(globalMaxConcurrent).toHaveValue("9"); - expect(globalMaxConcurrent.closest("label")).toHaveTextContent("9"); - expect(legacyMocks.updateGlobalConcurrency).not.toHaveBeenCalled(); - await act(async () => { - await vi.advanceTimersByTimeAsync(500); - }); - const dialog = screen.getByRole("dialog", { name: /confirm concurrency change/i }); - expect(dialog).toHaveTextContent("Change Global Max Concurrent from 6 to 9?"); - expect(screen.getByRole("button", { name: /save change/i })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /cancel/i })).toBeInTheDocument(); - expect(globalMaxConcurrent).toBeDisabled(); - - await act(async () => { - fireEvent.click(screen.getByRole("button", { name: /save change/i })); - await Promise.resolve(); - }); - expect(screen.queryByRole("dialog", { name: /confirm concurrency change/i })).not.toBeInTheDocument(); - - await act(async () => { - await vi.advanceTimersByTimeAsync(500); - await Promise.resolve(); - }); - - expect(legacyMocks.updateGlobalConcurrency).toHaveBeenCalledWith({ globalMaxConcurrent: 9 }); - }); - - it("prevents duplicate footer confirmation dialogs while a concurrency confirmation is open", async () => { - await openMenu(); - - const maxConcurrent = await screen.findByLabelText(/max concurrent tasks/i); - const globalMaxConcurrent = screen.getByLabelText(/maximum concurrent agents across all projects/i); - vi.useFakeTimers(); - - fireEvent.change(maxConcurrent, { target: { value: "7" } }); - await act(async () => { - await vi.advanceTimersByTimeAsync(500); - }); - expect(screen.getAllByRole("dialog", { name: /confirm concurrency change/i })).toHaveLength(1); - - fireEvent.change(maxConcurrent, { target: { value: "8" } }); - await act(async () => { - await vi.advanceTimersByTimeAsync(500); - }); - expect(screen.getAllByRole("dialog", { name: /confirm concurrency change/i })).toHaveLength(1); - vi.useRealTimers(); - fireEvent.click(screen.getByRole("button", { name: /cancel/i })); - await waitFor(() => expect(screen.queryByRole("dialog", { name: /confirm concurrency change/i })).not.toBeInTheDocument()); - - vi.useFakeTimers(); - fireEvent.change(globalMaxConcurrent, { target: { value: "9" } }); - await act(async () => { - await vi.advanceTimersByTimeAsync(500); - }); - expect(screen.getAllByRole("dialog", { name: /confirm concurrency change/i })).toHaveLength(1); - fireEvent.change(globalMaxConcurrent, { target: { value: "10" } }); - await act(async () => { - await vi.advanceTimersByTimeAsync(500); - }); - expect(screen.getAllByRole("dialog", { name: /confirm concurrency change/i })).toHaveLength(1); - expect(legacyMocks.updateSettings).not.toHaveBeenCalled(); - expect(legacyMocks.updateGlobalConcurrency).not.toHaveBeenCalled(); - }); - - it("flushes already-confirmed global cap saves when the footer closes", async () => { - await openMenu(); - - const globalMaxConcurrent = await screen.findByLabelText(/maximum concurrent agents across all projects/i); - vi.useFakeTimers(); - - fireEvent.change(globalMaxConcurrent, { target: { value: "9" } }); - await act(async () => { - await vi.advanceTimersByTimeAsync(500); - }); - await act(async () => { - fireEvent.click(screen.getByRole("button", { name: /save change/i })); - await Promise.resolve(); - }); - - fireEvent.click(screen.getByTestId("engine-control-menu-close")); - await act(async () => { - await vi.advanceTimersByTimeAsync(500); - await Promise.resolve(); - }); - - expect(screen.queryByTestId("engine-control-menu")).not.toBeInTheDocument(); - expect(legacyMocks.updateGlobalConcurrency).toHaveBeenCalledWith({ globalMaxConcurrent: 9 }); - }); - - it("cancels footer global cap edits without triggering a global write", async () => { - await openMenu(); - - const globalMaxConcurrent = await screen.findByLabelText(/maximum concurrent agents across all projects/i); - vi.useFakeTimers(); - - fireEvent.change(globalMaxConcurrent, { target: { value: "8" } }); - - await act(async () => { - await vi.advanceTimersByTimeAsync(500); - }); - - expect(screen.getByRole("dialog", { name: /confirm concurrency change/i })).toHaveTextContent("Global Max Concurrent from 6 to 8"); - vi.useRealTimers(); - fireEvent.click(screen.getByRole("button", { name: /cancel/i })); - - await waitFor(() => expect(screen.queryByRole("dialog", { name: /confirm concurrency change/i })).not.toBeInTheDocument()); - await waitFor(() => expect(globalMaxConcurrent).toHaveValue("6")); - expect(legacyMocks.updateGlobalConcurrency).not.toHaveBeenCalled(); - }); - - it("does not prompt or write when a footer global cap edit matches the persisted value", async () => { - await openMenu(); - - const globalMaxConcurrent = await screen.findByLabelText(/maximum concurrent agents across all projects/i); - vi.useFakeTimers(); - - fireEvent.change(globalMaxConcurrent, { target: { value: "6" } }); - - await act(async () => { - await vi.advanceTimersByTimeAsync(500); - }); - - expect(screen.queryByRole("dialog", { name: /confirm concurrency change/i })).not.toBeInTheDocument(); - expect(legacyMocks.updateGlobalConcurrency).not.toHaveBeenCalled(); - }); - - it("keeps loading and error global cap states disabled so they cannot prompt", async () => { - let resolveGlobalConcurrency!: (value: { - globalMaxConcurrent: number; - currentlyActive: number; - queuedCount: number; - projectsActive: Record; - }) => void; - legacyMocks.fetchGlobalConcurrency.mockReturnValue(new Promise((resolve) => { - resolveGlobalConcurrency = resolve; - })); - - await openMenu(); - - const loadingGlobalMaxConcurrent = await screen.findByLabelText(/maximum concurrent agents across all projects/i); - // FNXC:GlobalConcurrencyControls 2026-07-15-00:00: FN-7973 restores native touch dragging only for enabled ranges; loading caps remain disabled no-ops. - expect(loadingGlobalMaxConcurrent).toHaveAttribute("disabled"); - expect(loadingGlobalMaxConcurrent).toBeDisabled(); - - vi.useFakeTimers(); - fireEvent.change(loadingGlobalMaxConcurrent, { target: { value: "7" } }); - await act(async () => { - await vi.advanceTimersByTimeAsync(500); - }); - expect(screen.queryByRole("dialog", { name: /confirm concurrency change/i })).not.toBeInTheDocument(); - expect(legacyMocks.updateGlobalConcurrency).not.toHaveBeenCalled(); - - await act(async () => { - resolveGlobalConcurrency({ - globalMaxConcurrent: 6, - currentlyActive: 3, - queuedCount: 0, - projectsActive: { proj_123: 2 }, - }); - }); - - vi.useRealTimers(); - cleanup(); - legacyMocks.updateGlobalConcurrency.mockClear(); - legacyMocks.fetchGlobalConcurrency.mockRejectedValue(new Error("global concurrency unavailable")); - - await openMenu(); - - const errorGlobalMaxConcurrent = await screen.findByLabelText(/maximum concurrent agents across all projects/i); - await screen.findByRole("alert"); - expect(errorGlobalMaxConcurrent).toBeDisabled(); - - vi.useFakeTimers(); - fireEvent.change(errorGlobalMaxConcurrent, { target: { value: "7" } }); - await act(async () => { - await vi.advanceTimersByTimeAsync(500); - }); - expect(screen.queryByRole("dialog", { name: /confirm concurrency change/i })).not.toBeInTheDocument(); - expect(legacyMocks.updateGlobalConcurrency).not.toHaveBeenCalled(); - }); // FNXC:GlobalConcurrencyControls 2026-07-15-12:00: FN-8007 replaces FN-7160/FN-7235's utilization ratio with native range-thumb coordinates so markers share the running value's min-relative track position. - it("aligns footer global and project markers with their native thumbs", async () => { - legacyMocks.fetchSettings.mockResolvedValue({ ...defaultSettings, maxConcurrent: 12 }); - mockGlobalConcurrency({ - globalMaxConcurrent: 10, - currentlyActive: 10, - projectsActive: { proj_123: 10 }, - }); - await openMenu(); - await screen.findByTestId("engine-control-global-use-marker"); - expectUseMarkerPct("engine-control-global-use-marker", `${((10 - 1) / (32 - 1)) * 100}%`); - expectUseMarkerPct("engine-control-project-use-marker", `${((10 - 1) / (50 - 1)) * 100}%`); - expectFooterUseOffset("engine-control-global-use-marker", (10 - 1) / (32 - 1)); - expectFooterUseOffset("engine-control-project-use-marker", (10 - 1) / (50 - 1)); - }); - it("pins footer over-cap markers at the cap thumb instead of the track end", async () => { - legacyMocks.fetchSettings.mockResolvedValue({ ...defaultSettings, maxConcurrent: 12 }); - mockGlobalConcurrency({ - globalMaxConcurrent: 10, - currentlyActive: 40, - projectsActive: { proj_123: 40 }, - }); - await openMenu(); - - await screen.findByTestId("engine-control-global-use-marker"); - expectUseMarkerPct("engine-control-global-use-marker", `${((10 - 1) / (32 - 1)) * 100}%`); - expectUseMarkerPct("engine-control-project-use-marker", `${((12 - 1) / (50 - 1)) * 100}%`); - expect(screen.getByTestId("engine-control-global-use-marker").style.getPropertyValue("--use-pct")).not.toBe("100%"); - expect(screen.getByTestId("engine-control-project-use-marker").style.getPropertyValue("--use-pct")).not.toBe("100%"); - }); - - it("maps one running agent to the visible footer slider start", async () => { - legacyMocks.fetchSettings.mockResolvedValue({ ...defaultSettings, maxConcurrent: 12 }); - mockGlobalConcurrency({ - globalMaxConcurrent: 10, - currentlyActive: 1, - projectsActive: { proj_123: 1 }, - }); - - await openMenu(); - - await screen.findByTestId("engine-control-global-use-marker"); - expectUseMarkerPct("engine-control-global-use-marker", "0%"); - expectUseMarkerPct("engine-control-project-use-marker", "0%"); - expectFooterUseOffset("engine-control-global-use-marker", 0); - expectFooterUseOffset("engine-control-project-use-marker", 0); - }); - - it("positions zero running at the start of both footer markers", async () => { - mockGlobalConcurrency({ - globalMaxConcurrent: 6, - currentlyActive: 0, - projectsActive: {}, - }); - - await openMenu(undefined); - - expect(await screen.findByTestId("engine-control-global-running")).toHaveTextContent("0 running (all projects)"); - expect(screen.getByTestId("engine-control-project-running")).toHaveTextContent("0 running (this project)"); - expect(screen.getByTestId("engine-control-global-use-marker")).toHaveStyle({ "--use-pct": "0%" }); - expect(screen.getByTestId("engine-control-project-use-marker")).toHaveStyle({ "--use-pct": "0%" }); - expectFooterUseOffset("engine-control-global-use-marker", 0); - expectFooterUseOffset("engine-control-project-use-marker", 0); - }); - - it("recomputes footer project marker positions from the visible pending cap", async () => { - legacyMocks.fetchSettings.mockResolvedValue({ - ...defaultSettings, - maxConcurrent: 60, - }); - mockGlobalConcurrency({ - globalMaxConcurrent: 48, - currentlyActive: 16, - projectsActive: { proj_123: 30 }, - }); - - await openMenu(); - - await screen.findByLabelText(/maximum concurrent agents across all projects/i); - const maxConcurrent = screen.getByLabelText(/max concurrent tasks/i); - vi.useFakeTimers(); - - expectUseMarkerPct("engine-control-global-use-marker", `${((16 - 1) / (48 - 1)) * 100}%`); - expectUseMarkerPct("engine-control-project-use-marker", `${((30 - 1) / (60 - 1)) * 100}%`); - - fireEvent.change(maxConcurrent, { target: { value: "50" } }); - - expectUseMarkerPct("engine-control-global-use-marker", `${((16 - 1) / (48 - 1)) * 100}%`); - expectUseMarkerPct("engine-control-project-use-marker", `${((30 - 1) / (50 - 1)) * 100}%`); - expectFooterUseOffset("engine-control-global-use-marker", (16 - 1) / (48 - 1)); - expectFooterUseOffset("engine-control-project-use-marker", (30 - 1) / (50 - 1)); - }); it("suppresses footer running counts and markers while utilization is loading", async () => { let resolveGlobalConcurrency!: (value: { @@ -656,17 +381,23 @@ describe("EngineControlMenu", () => { }); }); - it("suppresses footer running counts and markers when utilization fails", async () => { - legacyMocks.fetchGlobalConcurrency.mockRejectedValue(new Error("global concurrency unavailable")); - await openMenu(); + /* + FNXC:CapacityModel 2026-07-29-00:25 (drop the cross-project cap — settings half): + The footer global-cap tests are DELETED with the slider they covered: confirm / + cancel / dedupe-dialog / flush-on-close / no-op-when-unchanged / disabled-while- + loading, and the global marker geometry cases. - await screen.findByRole("alert"); - expect(screen.queryByTestId("engine-control-global-running")).not.toBeInTheDocument(); - expect(screen.queryByTestId("engine-control-project-running")).not.toBeInTheDocument(); - expect(screen.queryByTestId("engine-control-global-use-marker")).not.toBeInTheDocument(); - expect(screen.queryByTestId("engine-control-project-use-marker")).not.toBeInTheDocument(); - }); + All of them pinned the machinery that held an edit un-persisted until the operator + confirmed. That machinery existed because the slider WROTE a machine-wide cap; the + cap is gone (capacity is two numbers PER PROJECT) and so is the PUT route, so there + is no write left to guard. The PROJECT-side equivalents of every one of these cases + are retained above — they still guard a real write. + + The surviving "suppresses footer running counts and markers while utilization is + loading" case still covers the live-telemetry readout, which is the only part of + this surface that remains. + */ it("persists a slider value of 50 after confirmation", async () => { await openMenu(); diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx index 678846c182..8c20f8d5cc 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx @@ -922,35 +922,59 @@ describe("SettingsModal", () => { describe("deferred settings fetches", () => { /* FNXC:SettingsConcurrency 2026-07-15-18:52: - `/Scheduling/` now matches two nav buttons — the section split into a Global/Project pair — so the selector names the exact one. The deferral requirement is unchanged: the global-concurrency endpoint is not hit until a scheduling section is opened. + FNXC:CapacityModel 2026-07-29-03:10 (drop the cross-project cap — settings half): + DELETED. This asserted that opening a Scheduling section triggers the + global-concurrency fetch. SettingsModal no longer performs that fetch at all — + the machine-wide cap it loaded is gone (capacity is two numbers PER PROJECT) and + the modal has no global-concurrency state left to defer. + + The deferral REQUIREMENT it encoded — do not hit an endpoint until its section is + opened — is still covered for the surfaces that still fetch (e.g. the memory + backend-status hook test below). */ - it("does not fetch global concurrency until a Scheduling section is selected", async () => { - renderModal(); - await waitForSettingsModalReady(); - - expect(mockFetchGlobalConcurrency).not.toHaveBeenCalled(); - - await settingsModalUser.click(screen.getByRole("button", { name: "Scheduling · Global" })); - - await waitFor(() => { - expect(mockFetchGlobalConcurrency).toHaveBeenCalledTimes(1); - }); - }); /* FNXC:SettingsConcurrency 2026-07-15-18:52: - The invariant is unchanged — a concurrency input stays disabled until its live value arrives, so an operator cannot overwrite a resolved limit with a blank fallback. Only its surface moved: the global cap now lives in its own section, so the assertion follows it across both halves of the pair rather than reading them all off one screen. + The invariant is unchanged — a concurrency input stays disabled until its live value arrives, so an operator cannot overwrite a resolved limit with a blank fallback. + + FNXC:CapacityModel 2026-07-29-03:10 (drop the cross-project cap — settings half): + The global half of this case is gone with the cap and its section; capacity is two + numbers PER PROJECT and both live in the settings form. The gate that enforces the + invariant also moved: it read the GLOBAL-concurrency fetch, which was never the + right source for project inputs, and now reads the form's own load. */ - it("disables concurrency inputs until their actual values load", async () => { - mockFetchGlobalConcurrency.mockReturnValue(new Promise(() => {})); - renderModal(); + it("does not render concurrency inputs until their actual values load", async () => { + /* + FNXC:CapacityModel 2026-07-29-03:25 (drop the cross-project cap — settings half): + The invariant is unchanged — an operator must never be able to edit a + concurrency input showing a blank fallback and overwrite a resolved limit. HOW + it holds changed, and the assertion follows the mechanism rather than pinning a + `disabled` attribute that can no longer be observed. + + Previously the inputs rendered immediately and were DISABLED while the separate + global-concurrency fetch was in flight. Both remaining numbers (maxConcurrent, + maxWorktrees) now come from the settings form itself, and the modal renders + "Loading…" instead of any section until that form resolves — so the input does + not EXIST until its value does. Structural, and strictly stronger than + disabled: there is nothing to focus, type into, or re-enable via devtools. + + Measured while fixing this: repointing `concurrencyLoading` at the form's own + `loading` makes it unobservable for exactly this reason. Keeping the old + `toBeDisabled()` assertion would have required the input to render during load, + which is the weaker behaviour. + */ + mockFetchSettings.mockReturnValue(new Promise(() => {})); + renderModal({ initialSection: "scheduling" }); + await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); + expect(screen.queryByLabelText("Max Concurrent Tasks")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Max Worktrees")).not.toBeInTheDocument(); + + // ...and once it resolves, the input is present and editable. + mockFetchSettings.mockResolvedValue({}); + cleanup(); + renderModal({ initialSection: "scheduling" }); await waitForSettingsModalReady(); - - await settingsModalUser.click(screen.getByRole("button", { name: "Scheduling · Global" })); - expect(screen.getByLabelText("Global Max Concurrent")).toBeDisabled(); - - await settingsModalUser.click(screen.getByRole("button", { name: "Scheduling · Project" })); - expect(screen.getByLabelText("Max Concurrent Tasks")).toBeDisabled(); + expect(await screen.findByLabelText("Max Concurrent Tasks")).not.toBeDisabled(); // FNXC:SettingsConcurrency 2026-07-24-03:10: FN-8453 (eef5eb751) removed // the duplicate "Max Triage Concurrent" control when concurrency // accounting was unified; it must stay gone. @@ -2114,13 +2138,14 @@ describe("SettingsModal", () => { vi.useRealTimers(); }); - it("persists global concurrency and scoped MCP edits without Save", async () => { - renderModal({ initialSection: "scheduling-global" }); - await waitForSettingsModalReady(); - fireEvent.change(await screen.findByLabelText("Global Max Concurrent"), { target: { value: "7" } }); - await waitFor(() => expect(mockUpdateGlobalConcurrency).toHaveBeenCalledWith({ globalMaxConcurrent: 7 })); - - cleanup(); + /* + FNXC:CapacityModel 2026-07-29-03:10 (drop the cross-project cap — settings half): + The global-concurrency half of this auto-save case is deleted with the control it + edited; `updateGlobalConcurrency` no longer exists. The scoped-MCP half is the + part that still exercises save-without-Save, so it is kept and the case renamed + to what it now covers. + */ + it("persists scoped MCP edits without Save", async () => { renderModal({ initialSection: "mcp" }); await waitForSettingsModalReady(); fireEvent.click(await screen.findByLabelText("Enable MCP servers for this scope")); diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.models-auth.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.models-auth.test.tsx index bb50e4c19c..b8638be225 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.models-auth.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.models-auth.test.tsx @@ -791,7 +791,7 @@ describe("SettingsModal", () => { await waitForSettingsModalReady(); expect(screen.queryByText(/^Version\s+/)).not.toBeInTheDocument(); - await settingsModalUser.click(screen.getByRole("button", { name: "Scheduling · Project" })); + await settingsModalUser.click(screen.getByRole("button", { name: "Scheduling" })); expect(await screen.findByLabelText("Max Concurrent Tasks")).toBeInTheDocument(); expect(addToast).not.toHaveBeenCalled(); }); diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.scheduling-merge.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.scheduling-merge.test.tsx index 6c44e0e997..aa549582bd 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.scheduling-merge.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.scheduling-merge.test.tsx @@ -216,7 +216,7 @@ describe("SettingsModal", () => { renderModal(); await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); - fireEvent.click(screen.getByRole("button", { name: "Scheduling · Project" })); + fireEvent.click(screen.getByRole("button", { name: "Scheduling" })); expect(screen.getByLabelText(/ignore hidden dot paths in overlap checks/i)).toBeChecked(); }); @@ -231,7 +231,7 @@ describe("SettingsModal", () => { renderModal(); await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); - fireEvent.click(screen.getByRole("button", { name: "Scheduling · Project" })); + fireEvent.click(screen.getByRole("button", { name: "Scheduling" })); expect(screen.getByLabelText(/ignore hidden dot paths in overlap checks/i)).not.toBeChecked(); }); @@ -240,7 +240,7 @@ describe("SettingsModal", () => { renderModal(); await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); - fireEvent.click(screen.getByRole("button", { name: "Scheduling · Project" })); + fireEvent.click(screen.getByRole("button", { name: "Scheduling" })); await settingsModalUser.click(screen.getByLabelText(/ignore hidden dot paths in overlap checks/i)); await settingsModalUser.type(screen.getByPlaceholderText("docs/"), "generated/*"); @@ -265,7 +265,7 @@ describe("SettingsModal", () => { renderModal(); await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); - fireEvent.click(screen.getByRole("button", { name: "Scheduling · Project" })); + fireEvent.click(screen.getByRole("button", { name: "Scheduling" })); await settingsModalUser.click(screen.getByLabelText(/ignore hidden dot paths in overlap checks/i)); @@ -285,7 +285,7 @@ describe("SettingsModal", () => { renderModal(); await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); - fireEvent.click(screen.getByRole("button", { name: "Scheduling · Project" })); + fireEvent.click(screen.getByRole("button", { name: "Scheduling" })); expect(screen.getByDisplayValue("docs/")).toBeInTheDocument(); expect(screen.getByDisplayValue("generated/*")).toBeInTheDocument(); @@ -295,7 +295,7 @@ describe("SettingsModal", () => { renderModal(); await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); - fireEvent.click(screen.getByRole("button", { name: "Scheduling · Project" })); + fireEvent.click(screen.getByRole("button", { name: "Scheduling" })); await settingsModalUser.click(screen.getByRole("button", { name: /browse path for ignored overlap entry 1/i })); @@ -309,7 +309,7 @@ describe("SettingsModal", () => { renderModal(); await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); - fireEvent.click(screen.getByRole("button", { name: "Scheduling · Project" })); + fireEvent.click(screen.getByRole("button", { name: "Scheduling" })); await settingsModalUser.click(screen.getByRole("button", { name: /browse path for ignored overlap entry 1/i })); await settingsModalUser.click(await screen.findByRole("button", { name: "Select README.md" })); @@ -337,7 +337,7 @@ describe("SettingsModal", () => { renderModal(); await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); - fireEvent.click(screen.getByRole("button", { name: "Scheduling · Project" })); + fireEvent.click(screen.getByRole("button", { name: "Scheduling" })); const select = screen.getByLabelText("Heartbeat Scope Discipline") as HTMLSelectElement; expect(select.value).toBe("lite"); @@ -366,7 +366,7 @@ describe("SettingsModal", () => { renderModal(); await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); - fireEvent.click(screen.getByRole("button", { name: "Scheduling · Project" })); + fireEvent.click(screen.getByRole("button", { name: "Scheduling" })); expect((screen.getByLabelText("Let engineer agents auto-claim backlog tasks") as HTMLInputElement).checked).toBe(expectedChecked); }); @@ -380,7 +380,7 @@ describe("SettingsModal", () => { renderModal(); await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); - fireEvent.click(screen.getByRole("button", { name: "Scheduling · Project" })); + fireEvent.click(screen.getByRole("button", { name: "Scheduling" })); const toggle = screen.getByLabelText("Let engineer agents auto-claim backlog tasks") as HTMLInputElement; expect(toggle.checked).toBe(false); @@ -404,7 +404,7 @@ describe("SettingsModal", () => { renderModal(); await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); - fireEvent.click(screen.getByRole("button", { name: "Scheduling · Project" })); + fireEvent.click(screen.getByRole("button", { name: "Scheduling" })); const toggle = screen.getByLabelText("Let engineer agents auto-claim backlog tasks") as HTMLInputElement; expect(toggle.checked).toBe(true); @@ -426,7 +426,7 @@ describe("SettingsModal", () => { await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); // Open Scheduling section - fireEvent.click(screen.getByRole("button", { name: "Scheduling · Project" })); + fireEvent.click(screen.getByRole("button", { name: "Scheduling" })); const input = screen.getByLabelText("Max Concurrent Tasks") as HTMLInputElement; expect(input).toBeDefined(); @@ -438,30 +438,20 @@ describe("SettingsModal", () => { }); /* - FNXC:SettingsScope 2026-07-15-18:52: - The machine-wide cap moved to its own `Scheduling · Global` section when Scheduling was split by scope, so this navigates there. The requirement is unchanged: clearing the field must leave it empty rather than snapping to a stuck "0". + FNXC:CapacityModel 2026-07-29-00:40 (drop the cross-project cap — settings half): + The "clearing Global Max Concurrent leaves it empty" case is DELETED with the + control it covered. It guarded a real bug — the field snapping to a stuck "0" — + but the machine-wide cap and its Scheduling · Global section are gone (capacity is + two numbers PER PROJECT). The same empty-not-zero invariant remains covered for + maxWorktrees and maxConcurrent, the fields that survive. */ - it("allows clearing globalMaxConcurrent without leaving a stuck zero", async () => { - renderModal(); - await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); - - fireEvent.click(screen.getByRole("button", { name: "Scheduling · Global" })); - - const input = screen.getByLabelText("Global Max Concurrent") as HTMLInputElement; - expect(input).toBeDefined(); - await waitFor(() => expect(input).not.toBeDisabled()); - - // Clear the input - the input should be empty, not show "0" - await settingsModalUser.clear(input); - expect(input.value).toBe(""); - }); it("allows clearing pollIntervalMs without leaving a stuck zero", async () => { renderModal(); await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); // Open Scheduling section - fireEvent.click(screen.getByRole("button", { name: "Scheduling · Project" })); + fireEvent.click(screen.getByRole("button", { name: "Scheduling" })); const input = screen.getByLabelText("Poll Interval (ms)") as HTMLInputElement; expect(input).toBeDefined(); @@ -475,7 +465,7 @@ describe("SettingsModal", () => { renderModal(); await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); - fireEvent.click(screen.getByRole("button", { name: "Scheduling · Project" })); + fireEvent.click(screen.getByRole("button", { name: "Scheduling" })); const input = screen.getByLabelText("Stale High Fan-out Escalation (hours)") as HTMLInputElement; expect(input).toBeDefined(); diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.test-harness.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.test-harness.tsx index 2d0ab8adb1..3a760569bd 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.test-harness.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.test-harness.tsx @@ -481,7 +481,7 @@ export function installSettingsModalEnv(options?: { advancedSettings?: boolean } refresh: vi.fn(), }); mockImportSettings.mockResolvedValue({ success: true, globalCount: 0, projectCount: 0 }); - mockFetchGlobalConcurrency.mockResolvedValue({ globalMaxConcurrent: 4, currentlyActive: 0, queuedCount: 0, projectsActive: {} }); + mockFetchGlobalConcurrency.mockResolvedValue({ currentlyActive: 0, projectsActive: {} }); mockUpdateGlobalConcurrency.mockResolvedValue({ globalMaxConcurrent: 4, currentlyActive: 0, queuedCount: 0, projectsActive: {} }); mockFetchMemoryBackendStatus.mockResolvedValue({ currentBackend: "file", diff --git a/packages/dashboard/app/components/__tests__/SettingsModalNodeRouting.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModalNodeRouting.test.tsx index a319db718e..6838f08d91 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModalNodeRouting.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModalNodeRouting.test.tsx @@ -269,7 +269,7 @@ describe("SettingsModal Node Routing section", () => { it("removes routing controls from scheduling section", async () => { renderModal(); await ready(); - fireEvent.click(screen.getByRole("button", { name: "Scheduling · Project" })); + fireEvent.click(screen.getByRole("button", { name: "Scheduling" })); await waitFor(() => { expect(screen.getByRole("heading", { name: "Scheduling" })).toBeInTheDocument(); }); diff --git a/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx b/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx index 15633b7385..4d86d21a4f 100644 --- a/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx +++ b/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx @@ -1115,7 +1115,8 @@ describe("SettingsModal mobile adaptations", () => { const scopedPairs = [ ["global-models", "project-models"], ["research-global", "research-project"], - ["scheduling-global", "scheduling"], + // FNXC:CapacityModel 2026-07-29-00:40: scheduling is no longer a scoped PAIR — + // its global half hosted only the deleted machine-wide concurrency cap. ["source-control-global", "source-control"], ["backups-global", "backups"], ]; @@ -1123,7 +1124,6 @@ describe("SettingsModal mobile adaptations", () => { const visibleScopedPairs = scopedPairs.filter(([globalId, projectId]) => optionIds.includes(globalId) && optionIds.includes(projectId)); expect(visibleScopedPairs).toEqual(expect.arrayContaining([ ["global-models", "project-models"], - ["scheduling-global", "scheduling"], ["source-control-global", "source-control"], ])); for (const [globalId, projectId] of visibleScopedPairs) { diff --git a/packages/dashboard/app/components/command-center/CommandCenterControls.tsx b/packages/dashboard/app/components/command-center/CommandCenterControls.tsx index 0958c322e6..b39898ab39 100644 --- a/packages/dashboard/app/components/command-center/CommandCenterControls.tsx +++ b/packages/dashboard/app/components/command-center/CommandCenterControls.tsx @@ -108,9 +108,6 @@ export function CommandCenterControls({ projectId, colorTheme, themeMode, shadcn const persistedConcurrencyRef = useRef(DEFAULT_CONCURRENCY_VALUES); const pendingConcurrencyKeyRef = useRef(null); const concurrencyConfirmOpenRef = useRef(false); - const [pendingGlobalConcurrencyValue, setPendingGlobalConcurrencyValue] = useState(null); - const [globalConcurrencyDirty, setGlobalConcurrencyDirty] = useState(false); - const globalConcurrencyConfirmOpenRef = useRef(false); // FNXC:GlobalConcurrencyControls 2026-06-25-22:45: No activeWhen — the card is mounted only while visible, so it fetches on mount and flushes pending writes on unmount via the shared hook. const gc = useGlobalConcurrency(); @@ -234,81 +231,17 @@ export function CommandCenterControls({ projectId, colorTheme, themeMode, shadcn setConcurrencySaveState("idle"); }; - const updateGlobalConcurrencyValue = (rawValue: string) => { - const nextValue = clamp(Number(rawValue), gc.min, gc.sliderMax); - setPendingGlobalConcurrencyValue(nextValue); - setGlobalConcurrencyDirty(true); - }; - - /* - FNXC:CommandCenter 2026-06-26-00:00: - The Command Center global-cap slider shares useGlobalConcurrency with the footer EngineControlMenu, so confirmation is card-local: drag into pending state, confirm once after settle, then call gc.setValue exactly once so the hook's existing debounce and footer behavior remain unchanged. - */ - useEffect(() => { - if (!globalConcurrencyDirty || pendingGlobalConcurrencyValue === null || !gc.interactive || globalConcurrencyConfirmOpenRef.current) return; - const nextValue = pendingGlobalConcurrencyValue; - const persistedValue = gc.value; - const timeoutId = setTimeout(() => { - if (nextValue === persistedValue) { - setPendingGlobalConcurrencyValue(null); - setGlobalConcurrencyDirty(false); - return; - } - - globalConcurrencyConfirmOpenRef.current = true; - void confirm({ - title: t("commandCenter.controls.concurrency.confirmTitle", "Confirm concurrency change"), - message: t( - "commandCenter.controls.concurrency.confirmMessage", - "Change {{setting}} from {{oldValue}} to {{newValue}}?", - { - setting: t("settings.scheduling.globalMaxConcurrent", "Global Max Concurrent"), - oldValue: persistedValue, - newValue: nextValue, - }, - ), - confirmLabel: t("commandCenter.controls.concurrency.confirmSave", "Save change"), - cancelLabel: t("commandCenter.controls.concurrency.confirmCancel", "Cancel"), - }).then((confirmed) => { - globalConcurrencyConfirmOpenRef.current = false; - if (confirmed) { - gc.setValue(String(nextValue)); - } - setPendingGlobalConcurrencyValue(null); - setGlobalConcurrencyDirty(false); - }); - }, CONCURRENCY_SAVE_DEBOUNCE_MS); - return () => clearTimeout(timeoutId); - }, [confirm, gc.interactive, gc.setValue, gc.value, globalConcurrencyDirty, pendingGlobalConcurrencyValue, t]); - const effectiveGlobalPaused = globalPaused; const concurrencyValues = concurrencyState.data ?? DEFAULT_CONCURRENCY_VALUES; const globalCountsLoaded = gc.status === "loaded"; const projectActive = gc.projectActiveCount(projectId); - const globalSliderValue = pendingGlobalConcurrencyValue ?? gc.value; - const globalSliderMax = Math.max(gc.sliderMax, globalSliderValue); const maxConcurrentSliderMax = getConcurrencySliderMax("maxConcurrent", concurrencyValues.maxConcurrent); - const globalUseMarkerRatio = getUseMarkerRatio(gc.currentlyActive, globalSliderValue, gc.min, globalSliderMax); const projectUseMarkerRatio = getUseMarkerRatio( projectActive, concurrencyValues.maxConcurrent, CONCURRENCY_SLIDER_LIMITS.maxConcurrent.min, maxConcurrentSliderMax, ); - // FNXC:GlobalConcurrencyControls 2026-06-25-22:45: Mirror the per-project slider save-state labels for the shared global cap. - // FNXC:GlobalConcurrencyControls 2026-06-26-06:05: Explicit load-error branch — a failed initial load leaves saveState "idle", so the label otherwise fell through to "Ready" while the slider was disabled and an error alert shown. - const globalSaveLabel = gc.status === "loading" || gc.status === "idle" - ? t("commandCenter.controls.status.loading", "Loading…") - : gc.status === "error" - ? t("commandCenter.controls.status.loadError", "Load failed") - : gc.saveState === "saving" - ? t("commandCenter.controls.status.saving", "Saving…") - : gc.saveState === "saved" - ? t("commandCenter.controls.status.saved", "Saved") - : gc.saveState === "error" - ? t("commandCenter.controls.status.saveError", "Save failed") - : t("commandCenter.controls.status.ready", "Ready"); - /* FNXC:CommandCenter 2026-06-20-00:20: The concurrency card must reflect actual persisted scheduler settings, including values above the usual slider ranges, instead of silently clamping the readout. The slider max expands to the current persisted value so the numeric readout and input value remain truthful; user edits are still clamped into that input's current valid bounds before saving. @@ -404,59 +337,29 @@ export function CommandCenterControls({ projectId, colorTheme, themeMode, shadcn
{/* - FNXC:GlobalConcurrencyControls 2026-06-25-14:10: - Operators need to adjust the global cross-project concurrency cap from the footer engine menu and the dashboard Concurrency card, not just the Settings modal; global cap is distinct from per-project maxConcurrent and persists via the central /api/global-concurrency endpoint. + FNXC:CapacityModel 2026-07-28-23:45 (drop the cross-project cap — settings half): + The Global Max Concurrent SLIDER is deleted: the machine-wide cap it wrote no + longer exists (capacity is two numbers PER PROJECT) and its PUT route is gone. + A slider that persists nothing is worse than no slider. + + The live "N running (all projects)" READOUT is kept, moved onto the + per-project row below. It is telemetry, not a limit — "how busy is this + machine?" is still a real question once the cap that used to answer it is gone. */} - {/** - FNXC:GlobalConcurrencyControls 2026-06-26-00:00: - The Command Center Concurrency card mirrors the footer's read-only utilization readouts from the shared global-concurrency hook. These counts are display-only capacity context and must never write running-agent totals back to settings. - */} -