capacity, part 3: delete the globalMaxConcurrent setting, API and UI (#2529)
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) <noreply@anthropic.com>
This commit is contained in:
7
.changeset/capacity-drop-global-cap.md
Normal file
7
.changeset/capacity-drop-global-cap.md
Normal file
@@ -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.
|
||||||
@@ -233,40 +233,7 @@ pgDescribe("CentralCore backend mode (PostgreSQL)", () => {
|
|||||||
expect(count).toBeGreaterThanOrEqual(1);
|
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 () => {
|
it("records project-node path mappings through PostgreSQL", async () => {
|
||||||
ctx = await setupCtx();
|
ctx = await setupCtx();
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ import type {
|
|||||||
DockerHostConfig,
|
DockerHostConfig,
|
||||||
DockerNodeConfig,
|
DockerNodeConfig,
|
||||||
DockerNodeStatus,
|
DockerNodeStatus,
|
||||||
GlobalConcurrencyState,
|
|
||||||
IsolationMode,
|
IsolationMode,
|
||||||
ManagedDockerNode,
|
ManagedDockerNode,
|
||||||
MeshSnapshotQuery,
|
MeshSnapshotQuery,
|
||||||
@@ -487,28 +486,23 @@ function mapMeshWriteRow(row: MeshWriteRow): MeshWriteQueueEntry {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* FNXC:CentralCore 2026-06-26-12:05:
|
* FNXC:CentralCore 2026-06-26-12:05:
|
||||||
* Backend-mode init: ensure the singleton globalConcurrency row (id=1) and
|
* Backend-mode init: ensure the singleton centralSettings row and the local node
|
||||||
* the local node exist. Mirrors the sync CentralCore.init() local-node
|
* exist. Mirrors the sync CentralCore.init() local-node
|
||||||
* bootstrap. The PostgreSQL schema baseline already created the tables; this
|
* bootstrap. The PostgreSQL schema baseline already created the tables; this
|
||||||
* only seeds the runtime singletons. Idempotent.
|
* 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<void> {
|
export async function ensureBackendBootstrap(layer: AsyncDataLayer): Promise<void> {
|
||||||
await layer.transactionImmediate(async (tx) => {
|
await layer.transactionImmediate(async (tx) => {
|
||||||
// Ensure the globalConcurrency singleton row exists (CHECK constraint forces id=1).
|
/*
|
||||||
const concurrency = (await tx
|
FNXC:CapacityModel 2026-07-28-23:30 (drop the cross-project cap — settings half):
|
||||||
.select()
|
The globalConcurrency singleton row is no longer SEEDED. Nothing reads it: the
|
||||||
.from(schema.central.globalConcurrency)
|
cap it held is deleted and its currently_active/queued_count counters were
|
||||||
.where(eq(schema.central.globalConcurrency.id, 1))
|
never incremented by production code. The table is dropped in the follow-up;
|
||||||
.limit(1)) as { id: number; globalMaxConcurrent: number | null }[];
|
not seeding it here first means the drop has no live writer to race.
|
||||||
if (concurrency.length === 0) {
|
*/
|
||||||
await tx.insert(schema.central.globalConcurrency).values({
|
|
||||||
id: 1,
|
|
||||||
globalMaxConcurrent: 4,
|
|
||||||
currentlyActive: 0,
|
|
||||||
queuedCount: 0,
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure the centralSettings singleton row exists.
|
// Ensure the centralSettings singleton row exists.
|
||||||
const settings = (await tx
|
const settings = (await tx
|
||||||
@@ -524,15 +518,22 @@ export async function ensureBackendBootstrap(layer: AsyncDataLayer): Promise<voi
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure a local node exists. Mirror sync: reuse maxConcurrent from the
|
/*
|
||||||
// globalConcurrency row (default 2 when unset, matching sync init()).
|
Ensure a local node exists.
|
||||||
|
|
||||||
|
FNXC:CapacityModel 2026-07-28-23:30: the node's maxConcurrent used to be seeded
|
||||||
|
from the global-concurrency row. That row is going, so the seed is the literal
|
||||||
|
default it already resolved to when the row was unset. This is NODE capacity
|
||||||
|
(multi-node work routing), a different concern from the deleted machine-wide
|
||||||
|
agent cap — it is seeded, not enforced, here.
|
||||||
|
*/
|
||||||
const existingLocal = await tx
|
const existingLocal = await tx
|
||||||
.select({ id: schema.central.nodes.id })
|
.select({ id: schema.central.nodes.id })
|
||||||
.from(schema.central.nodes)
|
.from(schema.central.nodes)
|
||||||
.where(eq(schema.central.nodes.type, "local"))
|
.where(eq(schema.central.nodes.type, "local"))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (existingLocal.length === 0) {
|
if (existingLocal.length === 0) {
|
||||||
const maxConcurrent = concurrency[0]?.globalMaxConcurrent ?? 2;
|
const maxConcurrent = DEFAULT_LOCAL_NODE_MAX_CONCURRENT;
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const localId = `node_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
|
const localId = `node_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
|
||||||
await tx.insert(schema.central.nodes).values({
|
await tx.insert(schema.central.nodes).values({
|
||||||
@@ -1347,150 +1348,26 @@ export async function setDefaultProjectId(
|
|||||||
.where(eq(schema.central.centralSettings.id, 1));
|
.where(eq(schema.central.centralSettings.id, 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Global Concurrency ──────────────────────────────────────────────────────
|
/*
|
||||||
|
FNXC:CapacityModel 2026-07-28-23:30 (drop the cross-project cap — settings half):
|
||||||
|
The whole Global Concurrency block is DELETED: getGlobalConcurrencyRow,
|
||||||
|
getProjectsActiveCounts, getGlobalConcurrencyState, updateGlobalConcurrencyRow,
|
||||||
|
acquireGlobalSlotAtomic and releaseGlobalSlotAtomic.
|
||||||
|
|
||||||
export async function getGlobalConcurrencyRow(
|
Capacity is two numbers PER PROJECT. The machine-wide cap lived here in a separate
|
||||||
handle: QueryHandle,
|
authority (the `global_concurrency` singleton row) that every runtime subscribed to
|
||||||
): Promise<{ globalMaxConcurrent: number; currentlyActive: number; queuedCount: number }> {
|
and periodically re-reconciled against the per-project gates.
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getProjectsActiveCounts(
|
The two slot functions were ALREADY dead before this change — measured in the
|
||||||
handle: QueryHandle,
|
enforcement half: nothing in production called acquire/releaseGlobalSlot, so
|
||||||
): Promise<Array<{ projectId: string; inFlightAgentCount: number }>> {
|
`currently_active` was never incremented by real work. The durable counter was
|
||||||
const rows = (await handle
|
fiction, which is why no operator ever saw the cap bind through it.
|
||||||
.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,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getGlobalConcurrencyState(
|
Live "N running (all projects)" telemetry is unaffected: it comes from
|
||||||
handle: QueryHandle,
|
CentralCore.getLiveRunningAgentCounts via the registered side-effect-safe source,
|
||||||
): Promise<GlobalConcurrencyState> {
|
never from this table. The table itself is dropped in the follow-up so this change
|
||||||
const row = await getGlobalConcurrencyRow(handle);
|
stays reversible without a schema migration.
|
||||||
const activeCounts = await getProjectsActiveCounts(handle);
|
*/
|
||||||
const projectsActive: Record<string, number> = {};
|
|
||||||
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<void> {
|
|
||||||
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<boolean> {
|
|
||||||
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<void> {
|
|
||||||
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));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Mesh Snapshots + Write Queue ────────────────────────────────────────────
|
// ── Mesh Snapshots + Write Queue ────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ import type {
|
|||||||
RegisteredProject,
|
RegisteredProject,
|
||||||
ProjectHealth,
|
ProjectHealth,
|
||||||
CentralActivityLogEntry,
|
CentralActivityLogEntry,
|
||||||
GlobalConcurrencyState,
|
|
||||||
IsolationMode,
|
IsolationMode,
|
||||||
ProjectStatus,
|
ProjectStatus,
|
||||||
ActivityEventType,
|
ActivityEventType,
|
||||||
@@ -160,7 +159,6 @@ export interface CentralCoreEvents {
|
|||||||
/** Emitted when a discovered node is lost */
|
/** Emitted when a discovered node is lost */
|
||||||
"discovery:node:lost": [name: string];
|
"discovery:node:lost": [name: string];
|
||||||
/** Emitted when global concurrency state changes */
|
/** Emitted when global concurrency state changes */
|
||||||
"concurrency:changed": [state: GlobalConcurrencyState];
|
|
||||||
/** Emitted when a node's version info is updated */
|
/** Emitted when a node's version info is updated */
|
||||||
"node:version:updated": [payload: { nodeId: string; versionInfo: NodeVersionInfo }];
|
"node:version:updated": [payload: { nodeId: string; versionInfo: NodeVersionInfo }];
|
||||||
/** Emitted when plugin sync comparison completes */
|
/** Emitted when plugin sync comparison completes */
|
||||||
@@ -2075,33 +2073,28 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
|||||||
|
|
||||||
// ── Global Concurrency API ─────────────────────────────────────────────
|
// ── Global Concurrency API ─────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/*
|
||||||
* Get the current global concurrency state.
|
FNXC:CapacityModel 2026-07-28-23:30 (drop the cross-project cap — settings half):
|
||||||
*
|
`getGlobalConcurrencyState`, `updateGlobalConcurrency`, `acquireGlobalSlot` and
|
||||||
* @returns Current concurrency state including per-project active counts
|
`releaseGlobalSlot` are DELETED, along with the `concurrency:changed` event they
|
||||||
*/
|
emitted. Capacity is two numbers PER PROJECT; a machine-wide cap was a third
|
||||||
async getGlobalConcurrencyState(): Promise<GlobalConcurrencyState> {
|
limiter living in a separate authority (the `global_concurrency` singleton row).
|
||||||
this.ensureInitialized();
|
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
/**
|
`getLiveRunningAgentCounts` SURVIVES and is now the only global readout. It is
|
||||||
* FNXC:GlobalConcurrencyControls 2026-06-26-17:22:
|
TELEMETRY, not a limiter: it derives live per-project agent counts from the
|
||||||
* Live running-agent counts from the registered side-effect-safe source.
|
registered side-effect-safe source so the dashboard can show "N running (all
|
||||||
* Falls back to persisted concurrency/health bookkeeping when no host source
|
projects)". Nothing gates on it.
|
||||||
* is registered so headless core callers keep their previous semantics.
|
*/
|
||||||
*/
|
|
||||||
async getLiveRunningAgentCounts(options?: { source?: RunningAgentCountSource }): Promise<RunningAgentCounts> {
|
async getLiveRunningAgentCounts(options?: { source?: RunningAgentCountSource }): Promise<RunningAgentCounts> {
|
||||||
this.ensureInitialized();
|
this.ensureInitialized();
|
||||||
|
|
||||||
const source = options?.source ?? getRunningAgentCountSource();
|
const source = options?.source ?? getRunningAgentCountSource();
|
||||||
if (!source) {
|
if (!source) {
|
||||||
const state = await this.getGlobalConcurrencyState();
|
return { currentlyActive: 0, projectsActive: {} };
|
||||||
return {
|
|
||||||
currentlyActive: state.currentlyActive,
|
|
||||||
projectsActive: state.projectsActive,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const projectIds = (await this.listProjects()).map((project) => project.id);
|
const projectIds = (await this.listProjects()).map((project) => project.id);
|
||||||
@@ -2109,85 +2102,6 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
|||||||
return deriveRunningAgentCounts(perProject);
|
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<Pick<GlobalConcurrencyState, "globalMaxConcurrent" | "currentlyActive" | "queuedCount">>
|
|
||||||
): Promise<GlobalConcurrencyState> {
|
|
||||||
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<boolean> {
|
|
||||||
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<void> {
|
|
||||||
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 ─────────────────────────────────────────────────────
|
// ── Utility Methods ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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).
|
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,
|
maxConcurrentVerifications: 1,
|
||||||
globalMaxConcurrent: 4,
|
|
||||||
maxWorktrees: 4,
|
maxWorktrees: 4,
|
||||||
/*
|
/*
|
||||||
FNXC:CapacityModel 2026-07-28-11:20:
|
FNXC:CapacityModel 2026-07-28-11:20:
|
||||||
|
|||||||
@@ -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.
|
* 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;
|
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;
|
maxWorktrees: number;
|
||||||
/**
|
/**
|
||||||
* FNXC:CapacityModel 2026-07-28-22:15 (PR #2502 review):
|
* FNXC:CapacityModel 2026-07-28-22:15 (PR #2502 review):
|
||||||
|
|||||||
@@ -113,11 +113,13 @@ export const EngineControlMenu = forwardRef<EngineControlMenuHandle, EngineContr
|
|||||||
const pendingProjectConcurrencySaveRef = useRef<ConcurrencyValues | null>(null);
|
const pendingProjectConcurrencySaveRef = useRef<ConcurrencyValues | null>(null);
|
||||||
const projectConcurrencyConfirmOpenRef = useRef(false);
|
const projectConcurrencyConfirmOpenRef = useRef(false);
|
||||||
const projectConcurrencyConfirmTokenRef = useRef(0);
|
const projectConcurrencyConfirmTokenRef = useRef(0);
|
||||||
const [pendingGlobalConcurrencyValue, setPendingGlobalConcurrencyValue] = useState<number | null>(null);
|
/*
|
||||||
const [globalConcurrencyDirty, setGlobalConcurrencyDirty] = useState(false);
|
FNXC:CapacityModel 2026-07-29-00:10 (drop the cross-project cap — settings half):
|
||||||
const [globalConcurrencyConfirmOpen, setGlobalConcurrencyConfirmOpen] = useState(false);
|
The footer's pending/dirty/confirm-token state for the global cap is DELETED with
|
||||||
const globalConcurrencyConfirmOpenRef = useRef(false);
|
the slider it guarded. Its whole purpose was to hold an edit un-persisted until the
|
||||||
const globalConcurrencyConfirmTokenRef = useRef(0);
|
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: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.
|
// 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 });
|
const gc = useGlobalConcurrency({ activeWhen: open });
|
||||||
@@ -143,33 +145,20 @@ export const EngineControlMenu = forwardRef<EngineControlMenuHandle, EngineContr
|
|||||||
setConcurrencySaveState("idle");
|
setConcurrencySaveState("idle");
|
||||||
}, [clearProjectConcurrencySaveTimeout]);
|
}, [clearProjectConcurrencySaveTimeout]);
|
||||||
|
|
||||||
const revertPendingGlobalConcurrencyEdit = useCallback(() => {
|
|
||||||
globalConcurrencyConfirmOpenRef.current = false;
|
|
||||||
globalConcurrencyConfirmTokenRef.current += 1;
|
|
||||||
setGlobalConcurrencyConfirmOpen(false);
|
|
||||||
setPendingGlobalConcurrencyValue(null);
|
|
||||||
setGlobalConcurrencyDirty(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const closeMenu = useCallback(() => {
|
const closeMenu = useCallback(() => {
|
||||||
if (concurrencyDirty || pendingProjectConcurrencySaveRef.current || projectConcurrencyConfirmOpenRef.current) {
|
if (concurrencyDirty || pendingProjectConcurrencySaveRef.current || projectConcurrencyConfirmOpenRef.current) {
|
||||||
revertPendingProjectConcurrencyEdit();
|
revertPendingProjectConcurrencyEdit();
|
||||||
}
|
}
|
||||||
if (globalConcurrencyDirty || pendingGlobalConcurrencyValue !== null || globalConcurrencyConfirmOpenRef.current) {
|
|
||||||
revertPendingGlobalConcurrencyEdit();
|
|
||||||
}
|
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}, [concurrencyDirty, globalConcurrencyDirty, pendingGlobalConcurrencyValue, revertPendingGlobalConcurrencyEdit, revertPendingProjectConcurrencyEdit]);
|
}, [concurrencyDirty, revertPendingProjectConcurrencyEdit]);
|
||||||
const openMenu = useCallback(() => setOpen(true), []);
|
const openMenu = useCallback(() => setOpen(true), []);
|
||||||
const toggleMenu = useCallback(() => {
|
const toggleMenu = useCallback(() => {
|
||||||
if (open && (concurrencyDirty || pendingProjectConcurrencySaveRef.current || projectConcurrencyConfirmOpenRef.current)) {
|
if (open && (concurrencyDirty || pendingProjectConcurrencySaveRef.current || projectConcurrencyConfirmOpenRef.current)) {
|
||||||
revertPendingProjectConcurrencyEdit();
|
revertPendingProjectConcurrencyEdit();
|
||||||
}
|
}
|
||||||
if (open && (globalConcurrencyDirty || pendingGlobalConcurrencyValue !== null || globalConcurrencyConfirmOpenRef.current)) {
|
|
||||||
revertPendingGlobalConcurrencyEdit();
|
|
||||||
}
|
|
||||||
setOpen((current) => !current);
|
setOpen((current) => !current);
|
||||||
}, [concurrencyDirty, globalConcurrencyDirty, open, pendingGlobalConcurrencyValue, revertPendingGlobalConcurrencyEdit, revertPendingProjectConcurrencyEdit]);
|
}, [concurrencyDirty, open, revertPendingProjectConcurrencyEdit]);
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
open: openMenu,
|
open: openMenu,
|
||||||
@@ -182,7 +171,7 @@ export const EngineControlMenu = forwardRef<EngineControlMenuHandle, EngineContr
|
|||||||
|
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
const target = event.target;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (menuRef.current && target instanceof Node && !menuRef.current.contains(target)) {
|
if (menuRef.current && target instanceof Node && !menuRef.current.contains(target)) {
|
||||||
@@ -309,60 +298,6 @@ export const EngineControlMenu = forwardRef<EngineControlMenuHandle, EngineContr
|
|||||||
};
|
};
|
||||||
}, [clearProjectConcurrencySaveTimeout, concurrencyDirty, concurrencyState.data, confirm, open, projectId, refresh, t]);
|
}, [clearProjectConcurrencySaveTimeout, concurrencyDirty, concurrencyState.data, confirm, open, projectId, refresh, t]);
|
||||||
|
|
||||||
/*
|
|
||||||
FNXC:GlobalConcurrencyControls 2026-06-29-00:00:
|
|
||||||
The footer keeps global-cap edits in local pending state until the operator confirms. Calling useGlobalConcurrency.setValue() immediately would enter the shared hook's debounce and close/unmount flush path, which can persist a footer drag from close, Escape, outside-click, backdrop, or cancel before consent.
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
|
||||||
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 updateConcurrencyValue = (key: keyof ConcurrencyValues, rawValue: string, min: number, max: number) => {
|
||||||
const nextValue = clamp(Number(rawValue), min, max);
|
const nextValue = clamp(Number(rawValue), min, max);
|
||||||
setConcurrencyState((current) => ({
|
setConcurrencyState((current) => ({
|
||||||
@@ -375,19 +310,6 @@ export const EngineControlMenu = forwardRef<EngineControlMenuHandle, EngineContr
|
|||||||
};
|
};
|
||||||
|
|
||||||
const concurrencyValues = concurrencyState.data ?? DEFAULT_CONCURRENCY_VALUES;
|
const concurrencyValues = concurrencyState.data ?? DEFAULT_CONCURRENCY_VALUES;
|
||||||
// FNXC:GlobalConcurrencyControls 2026-06-25-22:45: Mirror the per-project slider save-state labels for the shared global cap (Loading…/Load failed/Saving…/Saved/Save failed/Ready).
|
|
||||||
// FNXC:GlobalConcurrencyControls 2026-06-26-06:05: A failed initial load leaves saveState "idle", so without an explicit error branch the label fell through to "Ready" while the slider was disabled and an error alert was shown. Surface the load error instead.
|
|
||||||
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");
|
|
||||||
const saveLabel = concurrencyState.status === "loading"
|
const saveLabel = concurrencyState.status === "loading"
|
||||||
? t("commandCenter.controls.status.loading", "Loading…")
|
? t("commandCenter.controls.status.loading", "Loading…")
|
||||||
: concurrencySaveState === "saving"
|
: concurrencySaveState === "saving"
|
||||||
@@ -399,10 +321,7 @@ export const EngineControlMenu = forwardRef<EngineControlMenuHandle, EngineContr
|
|||||||
: t("commandCenter.controls.status.ready", "Ready");
|
: t("commandCenter.controls.status.ready", "Ready");
|
||||||
const globalCountsLoaded = gc.status === "loaded";
|
const globalCountsLoaded = gc.status === "loaded";
|
||||||
const projectActive = gc.projectActiveCount(projectId);
|
const projectActive = gc.projectActiveCount(projectId);
|
||||||
const globalSliderValue = pendingGlobalConcurrencyValue ?? gc.value;
|
|
||||||
const globalSliderMax = Math.max(gc.sliderMax, globalSliderValue);
|
|
||||||
const maxConcurrentSliderMax = getConcurrencySliderMax("maxConcurrent", concurrencyValues.maxConcurrent);
|
const maxConcurrentSliderMax = getConcurrencySliderMax("maxConcurrent", concurrencyValues.maxConcurrent);
|
||||||
const globalUseMarkerRatio = getUseMarkerRatio(gc.currentlyActive, globalSliderValue, gc.min, globalSliderMax);
|
|
||||||
const projectUseMarkerRatio = getUseMarkerRatio(
|
const projectUseMarkerRatio = getUseMarkerRatio(
|
||||||
projectActive,
|
projectActive,
|
||||||
concurrencyValues.maxConcurrent,
|
concurrencyValues.maxConcurrent,
|
||||||
@@ -465,55 +384,21 @@ export const EngineControlMenu = forwardRef<EngineControlMenuHandle, EngineContr
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/*
|
|
||||||
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.
|
|
||||||
*/}
|
|
||||||
<div className="engine-control-menu__section engine-control-menu__section--sliders engine-control-menu__section--global">
|
|
||||||
<div className="engine-control-menu__section-header">
|
|
||||||
<span>{t("settings.scheduling.globalMaxConcurrent", "Global Max Concurrent")}</span>
|
|
||||||
<span className="engine-control-menu__scope-caption">{t("commandCenter.controls.scope.allProjects", "All projects")}</span>
|
|
||||||
<span className={`engine-control-menu__save-state engine-control-menu__save-state--${gc.saveState}`} aria-live="polite">
|
|
||||||
{globalSaveLabel}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<label className="engine-control-menu__slider" htmlFor="engine-control-global-max-concurrent">
|
|
||||||
<span className="engine-control-menu__slider-label">
|
|
||||||
{t("settings.scheduling.maximumConcurrentAgentsAcrossAllProjects", "Maximum concurrent agents across all projects")}
|
|
||||||
<strong>{globalSliderValue}</strong>
|
|
||||||
</span>
|
|
||||||
{globalCountsLoaded ? (
|
|
||||||
<span className="engine-control-menu__slider-meta" data-testid="engine-control-global-running">
|
|
||||||
{t("commandCenter.controls.concurrency.runningGlobal", "{{count}} running (all projects)", { count: gc.currentlyActive })}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
<span className="engine-control-menu__range-wrap">
|
|
||||||
<input
|
|
||||||
id="engine-control-global-max-concurrent"
|
|
||||||
className="engine-control-menu__range input"
|
|
||||||
type="range"
|
|
||||||
min={gc.min}
|
|
||||||
max={globalSliderMax}
|
|
||||||
value={globalSliderValue}
|
|
||||||
disabled={!gc.interactive || globalConcurrencyConfirmOpen}
|
|
||||||
onChange={(event) => updateGlobalConcurrencyValue(event.target.value)}
|
|
||||||
/>
|
|
||||||
{globalCountsLoaded ? (
|
|
||||||
<span
|
|
||||||
className="status-dot status-dot--online engine-control-menu__use-marker"
|
|
||||||
style={getUseMarkerStyle(globalUseMarkerRatio)}
|
|
||||||
data-testid="engine-control-global-use-marker"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
{gc.status === "error" ? <p className="engine-control-menu__error" role="alert">{t("commandCenter.controls.concurrency.error", "Unable to load concurrency settings")}</p> : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="engine-control-menu__section engine-control-menu__section--sliders">
|
<div className="engine-control-menu__section engine-control-menu__section--sliders">
|
||||||
<div className="engine-control-menu__section-header">
|
<div className="engine-control-menu__section-header">
|
||||||
<span>{t("commandCenter.controls.concurrency.title", "Concurrency")}</span>
|
<span>{t("commandCenter.controls.concurrency.title", "Concurrency")}</span>
|
||||||
|
{/*
|
||||||
|
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 ? (
|
||||||
|
<span className="engine-control-menu__scope-caption" data-testid="engine-control-global-running">
|
||||||
|
{t("commandCenter.controls.concurrency.runningGlobal", "{{count}} running (all projects)", { count: gc.currentlyActive })}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
<span className={`engine-control-menu__save-state engine-control-menu__save-state--${concurrencySaveState}`} aria-live="polite">
|
<span className={`engine-control-menu__save-state engine-control-menu__save-state--${concurrencySaveState}`} aria-live="polite">
|
||||||
{saveLabel}
|
{saveLabel}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset } from "@fusion/core";
|
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset } from "@fusion/core";
|
||||||
import { DEFAULT_GLOBAL_SETTINGS } 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 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 { resolveScopedMcpSettings, splitSettingsSave, type McpSettingsScope } from "./settings/save-split";
|
||||||
import {
|
import {
|
||||||
@@ -46,7 +46,6 @@ import { PromptsSection } from "./settings/sections/PromptsSection";
|
|||||||
import { GeneralSection } from "./settings/sections/GeneralSection";
|
import { GeneralSection } from "./settings/sections/GeneralSection";
|
||||||
import { ProjectModelsSection, WorkflowLaneFlushRejection } from "./settings/sections/ProjectModelsSection";
|
import { ProjectModelsSection, WorkflowLaneFlushRejection } from "./settings/sections/ProjectModelsSection";
|
||||||
import { SchedulingSection } from "./settings/sections/SchedulingSection";
|
import { SchedulingSection } from "./settings/sections/SchedulingSection";
|
||||||
import { SchedulingGlobalSection } from "./settings/sections/SchedulingGlobalSection";
|
|
||||||
import { CliBinarySection } from "./settings/sections/CliBinarySection";
|
import { CliBinarySection } from "./settings/sections/CliBinarySection";
|
||||||
import { ScheduledEvalsSection } from "./settings/sections/ScheduledEvalsSection";
|
import { ScheduledEvalsSection } from "./settings/sections/ScheduledEvalsSection";
|
||||||
import { NodeRoutingSection } from "./settings/sections/NodeRoutingSection";
|
import { NodeRoutingSection } from "./settings/sections/NodeRoutingSection";
|
||||||
@@ -1505,11 +1504,12 @@ export function SettingsModal({
|
|||||||
const skipNextMemoryReloadRef = useRef(false);
|
const skipNextMemoryReloadRef = useRef(false);
|
||||||
|
|
||||||
// Global concurrency state
|
// Global concurrency state
|
||||||
const [globalMaxConcurrent, setGlobalMaxConcurrent] = useState<number | undefined>(4);
|
/*
|
||||||
const initialGlobalMaxConcurrentRef = useRef<number | undefined>(4);
|
FNXC:CapacityModel 2026-07-29-00:10 (drop the cross-project cap — settings half):
|
||||||
const hasFetchedGlobalConcurrencyRef = useRef(false);
|
The machine-wide cap's state, its dedicated fetch, its save branch and its dirty
|
||||||
const globalConcurrencyDirtyRef = useRef(false);
|
tracking are DELETED along with the Scheduling · Global section that hosted it.
|
||||||
const [globalConcurrencyLoaded, setGlobalConcurrencyLoaded] = useState(false);
|
Capacity is two numbers PER PROJECT, both of which live in the settings form.
|
||||||
|
*/
|
||||||
|
|
||||||
// Import/Export state
|
// Import/Export state
|
||||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||||
@@ -1702,38 +1702,6 @@ export function SettingsModal({
|
|||||||
void refreshSettingsForm(true);
|
void refreshSettingsForm(true);
|
||||||
}, [addToast, projectId]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -3371,7 +3339,6 @@ export function SettingsModal({
|
|||||||
const scopedSettingsSnapshot = scopedSettings;
|
const scopedSettingsSnapshot = scopedSettings;
|
||||||
const initialValuesSnapshot = initialValues;
|
const initialValuesSnapshot = initialValues;
|
||||||
const initialScopedValuesSnapshot = initialScopedValues;
|
const initialScopedValuesSnapshot = initialScopedValues;
|
||||||
const globalMaxConcurrentSnapshot = globalMaxConcurrent;
|
|
||||||
const activeSectionSnapshot = activeSection;
|
const activeSectionSnapshot = activeSection;
|
||||||
const globalGitlabSettingsSnapshot = globalGitlabSettings;
|
const globalGitlabSettingsSnapshot = globalGitlabSettings;
|
||||||
const workflowLaneRevisionSnapshot = workflowLaneRevisionRef.current;
|
const workflowLaneRevisionSnapshot = workflowLaneRevisionRef.current;
|
||||||
@@ -3523,9 +3490,6 @@ export function SettingsModal({
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
Object.keys(globalPatch).length > 0 ? updateGlobalSettings(globalPatch) : Promise.resolve(),
|
Object.keys(globalPatch).length > 0 ? updateGlobalSettings(globalPatch) : Promise.resolve(),
|
||||||
Object.keys(projectPatch).length > 0 ? updateSettings(projectPatch, projectId) : Promise.resolve(),
|
Object.keys(projectPatch).length > 0 ? updateSettings(projectPatch, projectId) : Promise.resolve(),
|
||||||
globalMaxConcurrentSnapshot !== initialGlobalMaxConcurrentRef.current
|
|
||||||
? updateGlobalConcurrency({ globalMaxConcurrent: globalMaxConcurrentSnapshot ?? 4 })
|
|
||||||
: Promise.resolve(),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await workflowLaneSaverRef.current?.();
|
await workflowLaneSaverRef.current?.();
|
||||||
@@ -3554,9 +3518,6 @@ export function SettingsModal({
|
|||||||
project: mergePatch(current.project, projectPatch as Record<string, unknown>) as Partial<Settings>,
|
project: mergePatch(current.project, projectPatch as Record<string, unknown>) as Partial<Settings>,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
if (globalMaxConcurrentSnapshot !== initialGlobalMaxConcurrentRef.current) {
|
|
||||||
initialGlobalMaxConcurrentRef.current = globalMaxConcurrentSnapshot;
|
|
||||||
}
|
|
||||||
/*
|
/*
|
||||||
FNXC:SettingsAutoSave 2026-08-02-21:45:
|
FNXC:SettingsAutoSave 2026-08-02-21:45:
|
||||||
A successful snapshot becomes the next autosave comparison point. If the
|
A successful snapshot becomes the next autosave comparison point. If the
|
||||||
@@ -3567,7 +3528,6 @@ export function SettingsModal({
|
|||||||
form: formSnapshot,
|
form: formSnapshot,
|
||||||
scopedSettings: scopedSettingsSnapshot,
|
scopedSettings: scopedSettingsSnapshot,
|
||||||
globalGitlabSettings: globalGitlabSettingsSnapshot,
|
globalGitlabSettings: globalGitlabSettingsSnapshot,
|
||||||
globalMaxConcurrent: globalMaxConcurrentSnapshot,
|
|
||||||
});
|
});
|
||||||
lastPersistSucceededRef.current = true;
|
lastPersistSucceededRef.current = true;
|
||||||
return true;
|
return true;
|
||||||
@@ -3585,7 +3545,7 @@ export function SettingsModal({
|
|||||||
void persistSettingsRef.current?.();
|
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;
|
persistSettingsRef.current = persistSettings;
|
||||||
const settingsDirty = useMemo(() => {
|
const settingsDirty = useMemo(() => {
|
||||||
@@ -3603,11 +3563,10 @@ export function SettingsModal({
|
|||||||
} : undefined,
|
} : undefined,
|
||||||
});
|
});
|
||||||
return Object.keys(globalPatch).length > 0 || Object.keys(projectPatch).length > 0
|
return Object.keys(globalPatch).length > 0 || Object.keys(projectPatch).length > 0
|
||||||
|| globalMaxConcurrent !== initialGlobalMaxConcurrentRef.current
|
|
||||||
|| workflowLanesDirty;
|
|| 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
|
const hasAutoSaveChange = autoSaveActivationSnapshotRef.current !== null
|
||||||
&& autoSaveActivationSnapshotRef.current !== autoSaveSnapshot;
|
&& autoSaveActivationSnapshotRef.current !== autoSaveSnapshot;
|
||||||
latestAutoSaveStateRef.current = { dirty: settingsDirty, changed: hasAutoSaveChange };
|
latestAutoSaveStateRef.current = { dirty: settingsDirty, changed: hasAutoSaveChange };
|
||||||
@@ -3640,7 +3599,7 @@ export function SettingsModal({
|
|||||||
return () => {
|
return () => {
|
||||||
if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current);
|
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 () => {
|
const requestClose = useCallback(async () => {
|
||||||
if (autoSaveTimerRef.current) {
|
if (autoSaveTimerRef.current) {
|
||||||
@@ -4150,23 +4109,21 @@ export function SettingsModal({
|
|||||||
setSessionBannersHidden={setSessionBannersHidden}
|
setSessionBannersHidden={setSessionBannersHidden}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "scheduling-global":
|
|
||||||
return (
|
|
||||||
<SchedulingGlobalSection
|
|
||||||
globalMaxConcurrent={globalMaxConcurrent}
|
|
||||||
concurrencyLoading={activeSection === "scheduling-global" && !globalConcurrencyLoaded && !globalConcurrencyDirtyRef.current}
|
|
||||||
onGlobalMaxConcurrentChange={(value) => {
|
|
||||||
globalConcurrencyDirtyRef.current = true;
|
|
||||||
setGlobalMaxConcurrent(value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case "scheduling":
|
case "scheduling":
|
||||||
return (
|
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.
|
||||||
|
*/
|
||||||
<SchedulingSection
|
<SchedulingSection
|
||||||
form={form}
|
form={form}
|
||||||
setForm={setForm}
|
setForm={setForm}
|
||||||
concurrencyLoading={activeSection === "scheduling" && !globalConcurrencyLoaded && !globalConcurrencyDirtyRef.current}
|
concurrencyLoading={loading}
|
||||||
onOverlapIgnorePathChange={handleOverlapIgnorePathChange}
|
onOverlapIgnorePathChange={handleOverlapIgnorePathChange}
|
||||||
onOpenOverlapPathPicker={openOverlapPathPicker}
|
onOpenOverlapPathPicker={openOverlapPathPicker}
|
||||||
onRemoveOverlapIgnorePath={handleRemoveOverlapIgnorePath}
|
onRemoveOverlapIgnorePath={handleRemoveOverlapIgnorePath}
|
||||||
|
|||||||
@@ -341,292 +341,17 @@ describe("EngineControlMenu", () => {
|
|||||||
expect(screen.getByLabelText(/max worktrees/i)).toHaveAttribute("max", "50");
|
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<string, number>;
|
|
||||||
}) => 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.
|
// 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 () => {
|
it("suppresses footer running counts and markers while utilization is loading", async () => {
|
||||||
let resolveGlobalConcurrency!: (value: {
|
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");
|
All of them pinned the machinery that held an edit un-persisted until the operator
|
||||||
expect(screen.queryByTestId("engine-control-global-running")).not.toBeInTheDocument();
|
confirmed. That machinery existed because the slider WROTE a machine-wide cap; the
|
||||||
expect(screen.queryByTestId("engine-control-project-running")).not.toBeInTheDocument();
|
cap is gone (capacity is two numbers PER PROJECT) and so is the PUT route, so there
|
||||||
expect(screen.queryByTestId("engine-control-global-use-marker")).not.toBeInTheDocument();
|
is no write left to guard. The PROJECT-side equivalents of every one of these cases
|
||||||
expect(screen.queryByTestId("engine-control-project-use-marker")).not.toBeInTheDocument();
|
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 () => {
|
it("persists a slider value of 50 after confirmation", async () => {
|
||||||
await openMenu();
|
await openMenu();
|
||||||
|
|||||||
@@ -922,35 +922,59 @@ describe("SettingsModal", () => {
|
|||||||
describe("deferred settings fetches", () => {
|
describe("deferred settings fetches", () => {
|
||||||
/*
|
/*
|
||||||
FNXC:SettingsConcurrency 2026-07-15-18:52:
|
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:
|
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 () => {
|
it("does not render concurrency inputs until their actual values load", async () => {
|
||||||
mockFetchGlobalConcurrency.mockReturnValue(new Promise(() => {}));
|
/*
|
||||||
renderModal();
|
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 waitForSettingsModalReady();
|
||||||
|
expect(await screen.findByLabelText("Max Concurrent Tasks")).not.toBeDisabled();
|
||||||
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();
|
|
||||||
// FNXC:SettingsConcurrency 2026-07-24-03:10: FN-8453 (eef5eb751) removed
|
// FNXC:SettingsConcurrency 2026-07-24-03:10: FN-8453 (eef5eb751) removed
|
||||||
// the duplicate "Max Triage Concurrent" control when concurrency
|
// the duplicate "Max Triage Concurrent" control when concurrency
|
||||||
// accounting was unified; it must stay gone.
|
// accounting was unified; it must stay gone.
|
||||||
@@ -2114,13 +2138,14 @@ describe("SettingsModal", () => {
|
|||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("persists global concurrency and scoped MCP edits without Save", async () => {
|
/*
|
||||||
renderModal({ initialSection: "scheduling-global" });
|
FNXC:CapacityModel 2026-07-29-03:10 (drop the cross-project cap — settings half):
|
||||||
await waitForSettingsModalReady();
|
The global-concurrency half of this auto-save case is deleted with the control it
|
||||||
fireEvent.change(await screen.findByLabelText("Global Max Concurrent"), { target: { value: "7" } });
|
edited; `updateGlobalConcurrency` no longer exists. The scoped-MCP half is the
|
||||||
await waitFor(() => expect(mockUpdateGlobalConcurrency).toHaveBeenCalledWith({ globalMaxConcurrent: 7 }));
|
part that still exercises save-without-Save, so it is kept and the case renamed
|
||||||
|
to what it now covers.
|
||||||
cleanup();
|
*/
|
||||||
|
it("persists scoped MCP edits without Save", async () => {
|
||||||
renderModal({ initialSection: "mcp" });
|
renderModal({ initialSection: "mcp" });
|
||||||
await waitForSettingsModalReady();
|
await waitForSettingsModalReady();
|
||||||
fireEvent.click(await screen.findByLabelText("Enable MCP servers for this scope"));
|
fireEvent.click(await screen.findByLabelText("Enable MCP servers for this scope"));
|
||||||
|
|||||||
@@ -791,7 +791,7 @@ describe("SettingsModal", () => {
|
|||||||
await waitForSettingsModalReady();
|
await waitForSettingsModalReady();
|
||||||
|
|
||||||
expect(screen.queryByText(/^Version\s+/)).not.toBeInTheDocument();
|
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(await screen.findByLabelText("Max Concurrent Tasks")).toBeInTheDocument();
|
||||||
expect(addToast).not.toHaveBeenCalled();
|
expect(addToast).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -216,7 +216,7 @@ describe("SettingsModal", () => {
|
|||||||
renderModal();
|
renderModal();
|
||||||
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
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();
|
expect(screen.getByLabelText(/ignore hidden dot paths in overlap checks/i)).toBeChecked();
|
||||||
});
|
});
|
||||||
@@ -231,7 +231,7 @@ describe("SettingsModal", () => {
|
|||||||
renderModal();
|
renderModal();
|
||||||
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
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();
|
expect(screen.getByLabelText(/ignore hidden dot paths in overlap checks/i)).not.toBeChecked();
|
||||||
});
|
});
|
||||||
@@ -240,7 +240,7 @@ describe("SettingsModal", () => {
|
|||||||
renderModal();
|
renderModal();
|
||||||
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
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.click(screen.getByLabelText(/ignore hidden dot paths in overlap checks/i));
|
||||||
await settingsModalUser.type(screen.getByPlaceholderText("docs/"), "generated/*");
|
await settingsModalUser.type(screen.getByPlaceholderText("docs/"), "generated/*");
|
||||||
@@ -265,7 +265,7 @@ describe("SettingsModal", () => {
|
|||||||
renderModal();
|
renderModal();
|
||||||
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
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.click(screen.getByLabelText(/ignore hidden dot paths in overlap checks/i));
|
||||||
|
|
||||||
@@ -285,7 +285,7 @@ describe("SettingsModal", () => {
|
|||||||
renderModal();
|
renderModal();
|
||||||
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
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("docs/")).toBeInTheDocument();
|
||||||
expect(screen.getByDisplayValue("generated/*")).toBeInTheDocument();
|
expect(screen.getByDisplayValue("generated/*")).toBeInTheDocument();
|
||||||
@@ -295,7 +295,7 @@ describe("SettingsModal", () => {
|
|||||||
renderModal();
|
renderModal();
|
||||||
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
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(screen.getByRole("button", { name: /browse path for ignored overlap entry 1/i }));
|
||||||
|
|
||||||
@@ -309,7 +309,7 @@ describe("SettingsModal", () => {
|
|||||||
renderModal();
|
renderModal();
|
||||||
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
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(screen.getByRole("button", { name: /browse path for ignored overlap entry 1/i }));
|
||||||
await settingsModalUser.click(await screen.findByRole("button", { name: "Select README.md" }));
|
await settingsModalUser.click(await screen.findByRole("button", { name: "Select README.md" }));
|
||||||
@@ -337,7 +337,7 @@ describe("SettingsModal", () => {
|
|||||||
renderModal();
|
renderModal();
|
||||||
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
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;
|
const select = screen.getByLabelText("Heartbeat Scope Discipline") as HTMLSelectElement;
|
||||||
expect(select.value).toBe("lite");
|
expect(select.value).toBe("lite");
|
||||||
@@ -366,7 +366,7 @@ describe("SettingsModal", () => {
|
|||||||
renderModal();
|
renderModal();
|
||||||
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
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);
|
expect((screen.getByLabelText("Let engineer agents auto-claim backlog tasks") as HTMLInputElement).checked).toBe(expectedChecked);
|
||||||
});
|
});
|
||||||
@@ -380,7 +380,7 @@ describe("SettingsModal", () => {
|
|||||||
renderModal();
|
renderModal();
|
||||||
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
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;
|
const toggle = screen.getByLabelText("Let engineer agents auto-claim backlog tasks") as HTMLInputElement;
|
||||||
expect(toggle.checked).toBe(false);
|
expect(toggle.checked).toBe(false);
|
||||||
@@ -404,7 +404,7 @@ describe("SettingsModal", () => {
|
|||||||
renderModal();
|
renderModal();
|
||||||
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
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;
|
const toggle = screen.getByLabelText("Let engineer agents auto-claim backlog tasks") as HTMLInputElement;
|
||||||
expect(toggle.checked).toBe(true);
|
expect(toggle.checked).toBe(true);
|
||||||
@@ -426,7 +426,7 @@ describe("SettingsModal", () => {
|
|||||||
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
||||||
|
|
||||||
// Open Scheduling section
|
// 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;
|
const input = screen.getByLabelText("Max Concurrent Tasks") as HTMLInputElement;
|
||||||
expect(input).toBeDefined();
|
expect(input).toBeDefined();
|
||||||
@@ -438,30 +438,20 @@ describe("SettingsModal", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/*
|
/*
|
||||||
FNXC:SettingsScope 2026-07-15-18:52:
|
FNXC:CapacityModel 2026-07-29-00:40 (drop the cross-project cap — settings half):
|
||||||
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".
|
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 () => {
|
it("allows clearing pollIntervalMs without leaving a stuck zero", async () => {
|
||||||
renderModal();
|
renderModal();
|
||||||
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
||||||
|
|
||||||
// Open Scheduling section
|
// 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;
|
const input = screen.getByLabelText("Poll Interval (ms)") as HTMLInputElement;
|
||||||
expect(input).toBeDefined();
|
expect(input).toBeDefined();
|
||||||
@@ -475,7 +465,7 @@ describe("SettingsModal", () => {
|
|||||||
renderModal();
|
renderModal();
|
||||||
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
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;
|
const input = screen.getByLabelText("Stale High Fan-out Escalation (hours)") as HTMLInputElement;
|
||||||
expect(input).toBeDefined();
|
expect(input).toBeDefined();
|
||||||
|
|||||||
@@ -481,7 +481,7 @@ export function installSettingsModalEnv(options?: { advancedSettings?: boolean }
|
|||||||
refresh: vi.fn(),
|
refresh: vi.fn(),
|
||||||
});
|
});
|
||||||
mockImportSettings.mockResolvedValue({ success: true, globalCount: 0, projectCount: 0 });
|
mockImportSettings.mockResolvedValue({ success: true, globalCount: 0, projectCount: 0 });
|
||||||
mockFetchGlobalConcurrency.mockResolvedValue({ globalMaxConcurrent: 4, currentlyActive: 0, queuedCount: 0, projectsActive: {} });
|
mockFetchGlobalConcurrency.mockResolvedValue({ currentlyActive: 0, projectsActive: {} });
|
||||||
mockUpdateGlobalConcurrency.mockResolvedValue({ globalMaxConcurrent: 4, currentlyActive: 0, queuedCount: 0, projectsActive: {} });
|
mockUpdateGlobalConcurrency.mockResolvedValue({ globalMaxConcurrent: 4, currentlyActive: 0, queuedCount: 0, projectsActive: {} });
|
||||||
mockFetchMemoryBackendStatus.mockResolvedValue({
|
mockFetchMemoryBackendStatus.mockResolvedValue({
|
||||||
currentBackend: "file",
|
currentBackend: "file",
|
||||||
|
|||||||
@@ -269,7 +269,7 @@ describe("SettingsModal Node Routing section", () => {
|
|||||||
it("removes routing controls from scheduling section", async () => {
|
it("removes routing controls from scheduling section", async () => {
|
||||||
renderModal();
|
renderModal();
|
||||||
await ready();
|
await ready();
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Scheduling · Project" }));
|
fireEvent.click(screen.getByRole("button", { name: "Scheduling" }));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByRole("heading", { name: "Scheduling" })).toBeInTheDocument();
|
expect(screen.getByRole("heading", { name: "Scheduling" })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1115,7 +1115,8 @@ describe("SettingsModal mobile adaptations", () => {
|
|||||||
const scopedPairs = [
|
const scopedPairs = [
|
||||||
["global-models", "project-models"],
|
["global-models", "project-models"],
|
||||||
["research-global", "research-project"],
|
["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"],
|
["source-control-global", "source-control"],
|
||||||
["backups-global", "backups"],
|
["backups-global", "backups"],
|
||||||
];
|
];
|
||||||
@@ -1123,7 +1124,6 @@ describe("SettingsModal mobile adaptations", () => {
|
|||||||
const visibleScopedPairs = scopedPairs.filter(([globalId, projectId]) => optionIds.includes(globalId) && optionIds.includes(projectId));
|
const visibleScopedPairs = scopedPairs.filter(([globalId, projectId]) => optionIds.includes(globalId) && optionIds.includes(projectId));
|
||||||
expect(visibleScopedPairs).toEqual(expect.arrayContaining([
|
expect(visibleScopedPairs).toEqual(expect.arrayContaining([
|
||||||
["global-models", "project-models"],
|
["global-models", "project-models"],
|
||||||
["scheduling-global", "scheduling"],
|
|
||||||
["source-control-global", "source-control"],
|
["source-control-global", "source-control"],
|
||||||
]));
|
]));
|
||||||
for (const [globalId, projectId] of visibleScopedPairs) {
|
for (const [globalId, projectId] of visibleScopedPairs) {
|
||||||
|
|||||||
@@ -108,9 +108,6 @@ export function CommandCenterControls({ projectId, colorTheme, themeMode, shadcn
|
|||||||
const persistedConcurrencyRef = useRef<ConcurrencyValues>(DEFAULT_CONCURRENCY_VALUES);
|
const persistedConcurrencyRef = useRef<ConcurrencyValues>(DEFAULT_CONCURRENCY_VALUES);
|
||||||
const pendingConcurrencyKeyRef = useRef<keyof ConcurrencyValues | null>(null);
|
const pendingConcurrencyKeyRef = useRef<keyof ConcurrencyValues | null>(null);
|
||||||
const concurrencyConfirmOpenRef = useRef(false);
|
const concurrencyConfirmOpenRef = useRef(false);
|
||||||
const [pendingGlobalConcurrencyValue, setPendingGlobalConcurrencyValue] = useState<number | null>(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.
|
// 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();
|
const gc = useGlobalConcurrency();
|
||||||
|
|
||||||
@@ -234,81 +231,17 @@ export function CommandCenterControls({ projectId, colorTheme, themeMode, shadcn
|
|||||||
setConcurrencySaveState("idle");
|
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 effectiveGlobalPaused = globalPaused;
|
||||||
const concurrencyValues = concurrencyState.data ?? DEFAULT_CONCURRENCY_VALUES;
|
const concurrencyValues = concurrencyState.data ?? DEFAULT_CONCURRENCY_VALUES;
|
||||||
const globalCountsLoaded = gc.status === "loaded";
|
const globalCountsLoaded = gc.status === "loaded";
|
||||||
const projectActive = gc.projectActiveCount(projectId);
|
const projectActive = gc.projectActiveCount(projectId);
|
||||||
const globalSliderValue = pendingGlobalConcurrencyValue ?? gc.value;
|
|
||||||
const globalSliderMax = Math.max(gc.sliderMax, globalSliderValue);
|
|
||||||
const maxConcurrentSliderMax = getConcurrencySliderMax("maxConcurrent", concurrencyValues.maxConcurrent);
|
const maxConcurrentSliderMax = getConcurrencySliderMax("maxConcurrent", concurrencyValues.maxConcurrent);
|
||||||
const globalUseMarkerRatio = getUseMarkerRatio(gc.currentlyActive, globalSliderValue, gc.min, globalSliderMax);
|
|
||||||
const projectUseMarkerRatio = getUseMarkerRatio(
|
const projectUseMarkerRatio = getUseMarkerRatio(
|
||||||
projectActive,
|
projectActive,
|
||||||
concurrencyValues.maxConcurrent,
|
concurrencyValues.maxConcurrent,
|
||||||
CONCURRENCY_SLIDER_LIMITS.maxConcurrent.min,
|
CONCURRENCY_SLIDER_LIMITS.maxConcurrent.min,
|
||||||
maxConcurrentSliderMax,
|
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:
|
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.
|
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
|
|||||||
</div>
|
</div>
|
||||||
<div className="cc-controls-sliders">
|
<div className="cc-controls-sliders">
|
||||||
{/*
|
{/*
|
||||||
FNXC:GlobalConcurrencyControls 2026-06-25-14:10:
|
FNXC:CapacityModel 2026-07-28-23:45 (drop the cross-project cap — settings half):
|
||||||
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.
|
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.
|
|
||||||
*/}
|
|
||||||
<label className="cc-controls-slider cc-controls-slider--global" htmlFor="cc-global-max-concurrent">
|
|
||||||
<span className="cc-controls-slider-label">
|
|
||||||
{t("settings.scheduling.globalMaxConcurrent", "Global Max Concurrent")}
|
|
||||||
<strong>{globalSliderValue}</strong>
|
|
||||||
</span>
|
|
||||||
<small className="cc-controls-slider-caption">{t("settings.scheduling.maximumConcurrentAgentsAcrossAllProjects", "Maximum concurrent agents across all projects")}</small>
|
|
||||||
{globalCountsLoaded ? (
|
|
||||||
<small className="cc-controls-slider-caption" data-testid="cc-global-running">
|
|
||||||
{t("commandCenter.controls.concurrency.runningGlobal", "{{count}} running (all projects)", { count: gc.currentlyActive })}
|
|
||||||
</small>
|
|
||||||
) : null}
|
|
||||||
<span className="cc-controls-range-wrap">
|
|
||||||
<input
|
|
||||||
id="cc-global-max-concurrent"
|
|
||||||
className="cc-controls-touch-slider"
|
|
||||||
type="range"
|
|
||||||
min={gc.min}
|
|
||||||
max={globalSliderMax}
|
|
||||||
value={globalSliderValue}
|
|
||||||
disabled={!gc.interactive}
|
|
||||||
onChange={(event) => updateGlobalConcurrencyValue(event.target.value)}
|
|
||||||
/>
|
|
||||||
{globalCountsLoaded ? (
|
|
||||||
<span
|
|
||||||
className="status-dot status-dot--online cc-controls-use-marker"
|
|
||||||
style={getUseMarkerStyle(globalUseMarkerRatio)}
|
|
||||||
data-testid="cc-global-use-marker"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
{/* FNXC:GlobalConcurrencyControls 2026-06-25-22:45: Surface the shared cap's save-state (and a fetch-error message that the card previously lacked) so operators see Saving…/Saved/Save failed and know when the slider is non-interactive due to a load failure. */}
|
|
||||||
<span className={`cc-controls-save-state cc-controls-save-state--${gc.saveState}`} aria-live="polite">
|
|
||||||
{globalSaveLabel}
|
|
||||||
</span>
|
|
||||||
{gc.status === "error" ? <small className="cc-controls-error" role="alert">{t("commandCenter.controls.concurrency.error", "Unable to load concurrency settings")}</small> : null}
|
|
||||||
</label>
|
|
||||||
<label className="cc-controls-slider" htmlFor="cc-max-concurrent">
|
<label className="cc-controls-slider" htmlFor="cc-max-concurrent">
|
||||||
<span className="cc-controls-slider-label">
|
<span className="cc-controls-slider-label">
|
||||||
{t("commandCenter.controls.concurrency.maxConcurrent", "Max concurrent tasks")}
|
{t("commandCenter.controls.concurrency.maxConcurrent", "Max concurrent tasks")}
|
||||||
<strong>{concurrencyValues.maxConcurrent}</strong>
|
<strong>{concurrencyValues.maxConcurrent}</strong>
|
||||||
</span>
|
</span>
|
||||||
{globalCountsLoaded ? (
|
{globalCountsLoaded ? (
|
||||||
<small className="cc-controls-slider-caption" data-testid="cc-project-running">
|
<>
|
||||||
{t("commandCenter.controls.concurrency.runningProject", "{{count}} running (this project)", { count: projectActive })}
|
<small className="cc-controls-slider-caption" data-testid="cc-project-running">
|
||||||
</small>
|
{t("commandCenter.controls.concurrency.runningProject", "{{count}} running (this project)", { count: projectActive })}
|
||||||
|
</small>
|
||||||
|
<small className="cc-controls-slider-caption" data-testid="cc-global-running">
|
||||||
|
{t("commandCenter.controls.concurrency.runningGlobal", "{{count}} running (all projects)", { count: gc.currentlyActive })}
|
||||||
|
</small>
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
<span className="cc-controls-range-wrap">
|
<span className="cc-controls-range-wrap">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -47,12 +47,10 @@ function renderControls(projectId = "proj_123") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function mockGlobalConcurrency(overrides: Partial<{
|
function mockGlobalConcurrency(overrides: Partial<{
|
||||||
globalMaxConcurrent: number;
|
|
||||||
currentlyActive: number;
|
currentlyActive: number;
|
||||||
projectsActive: Record<string, number>;
|
projectsActive: Record<string, number>;
|
||||||
}> = {}) {
|
}> = {}) {
|
||||||
legacyMocks.fetchGlobalConcurrency.mockResolvedValue({
|
legacyMocks.fetchGlobalConcurrency.mockResolvedValue({
|
||||||
globalMaxConcurrent: 10,
|
|
||||||
currentlyActive: 10,
|
currentlyActive: 10,
|
||||||
queuedCount: 0,
|
queuedCount: 0,
|
||||||
projectsActive: { proj_123: 10 },
|
projectsActive: { proj_123: 10 },
|
||||||
@@ -118,64 +116,9 @@ describe("CommandCenterControls concurrency markers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// FNXC:GlobalConcurrencyControls 2026-07-15-12:00: FN-8007 requires dashboard markers to use the exact native-thumb coordinate system when the expanded range max exceeds the persisted cap.
|
// FNXC:GlobalConcurrencyControls 2026-07-15-12:00: FN-8007 requires dashboard markers to use the exact native-thumb coordinate system when the expanded range max exceeds the persisted cap.
|
||||||
it("aligns dashboard global and project markers with their native thumbs", async () => {
|
|
||||||
renderControls();
|
|
||||||
|
|
||||||
await screen.findByTestId("cc-global-use-marker");
|
|
||||||
expectUseMarkerPct("cc-global-use-marker", `${((10 - 1) / (32 - 1)) * 100}%`);
|
|
||||||
expectUseMarkerPct("cc-project-use-marker", `${((10 - 1) / (50 - 1)) * 100}%`);
|
|
||||||
expectCommandCenterUseOffset("cc-global-use-marker", (10 - 1) / (32 - 1));
|
|
||||||
expectCommandCenterUseOffset("cc-project-use-marker", (10 - 1) / (50 - 1));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("pins dashboard over-cap markers at the cap thumb instead of the track end", async () => {
|
|
||||||
mockGlobalConcurrency({ currentlyActive: 40, projectsActive: { proj_123: 40 } });
|
|
||||||
renderControls();
|
|
||||||
|
|
||||||
await screen.findByTestId("cc-global-use-marker");
|
|
||||||
expectUseMarkerPct("cc-global-use-marker", `${((10 - 1) / (32 - 1)) * 100}%`);
|
|
||||||
expectUseMarkerPct("cc-project-use-marker", `${((12 - 1) / (50 - 1)) * 100}%`);
|
|
||||||
expect(screen.getByTestId("cc-global-use-marker").style.getPropertyValue("--use-pct")).not.toBe("100%");
|
|
||||||
expect(screen.getByTestId("cc-project-use-marker").style.getPropertyValue("--use-pct")).not.toBe("100%");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("maps one running agent to the visible dashboard slider start", async () => {
|
|
||||||
mockGlobalConcurrency({ currentlyActive: 1, projectsActive: { proj_123: 1 } });
|
|
||||||
renderControls();
|
|
||||||
|
|
||||||
await screen.findByTestId("cc-global-use-marker");
|
|
||||||
expectUseMarkerPct("cc-global-use-marker", "0%");
|
|
||||||
expectUseMarkerPct("cc-project-use-marker", "0%");
|
|
||||||
expectCommandCenterUseOffset("cc-global-use-marker", 0);
|
|
||||||
expectCommandCenterUseOffset("cc-project-use-marker", 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("suppresses dashboard marker shells while global concurrency is loading or unavailable", async () => {
|
|
||||||
let resolveGlobalConcurrency!: (value: {
|
|
||||||
globalMaxConcurrent: number;
|
|
||||||
currentlyActive: number;
|
|
||||||
queuedCount: number;
|
|
||||||
projectsActive: Record<string, number>;
|
|
||||||
}) => void;
|
|
||||||
legacyMocks.fetchGlobalConcurrency.mockReturnValue(new Promise((resolve) => {
|
|
||||||
resolveGlobalConcurrency = resolve;
|
|
||||||
}));
|
|
||||||
renderControls();
|
|
||||||
|
|
||||||
expect(screen.queryByTestId("cc-global-use-marker")).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByTestId("cc-project-use-marker")).not.toBeInTheDocument();
|
|
||||||
|
|
||||||
resolveGlobalConcurrency({ globalMaxConcurrent: 10, currentlyActive: 0, queuedCount: 0, projectsActive: {} });
|
|
||||||
await screen.findByTestId("cc-global-use-marker");
|
|
||||||
cleanup();
|
|
||||||
|
|
||||||
legacyMocks.fetchGlobalConcurrency.mockRejectedValue(new Error("global concurrency unavailable"));
|
|
||||||
renderControls();
|
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByRole("alert")).toBeInTheDocument());
|
|
||||||
expect(screen.queryByTestId("cc-global-use-marker")).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByTestId("cc-project-use-marker")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("matches the desktop and mobile native thumb-size CSS contract", () => {
|
it("matches the desktop and mobile native thumb-size CSS contract", () => {
|
||||||
expect(commandCenterControlsCss).toContain(
|
expect(commandCenterControlsCss).toContain(
|
||||||
@@ -193,4 +136,15 @@ describe("CommandCenterControls concurrency markers", () => {
|
|||||||
expect(commandCenterControlsCss).toContain("@media (max-width: 768px)");
|
expect(commandCenterControlsCss).toContain("@media (max-width: 768px)");
|
||||||
expect(commandCenterControlsCss).toContain("--cc-controls-range-thumb-size: var(--space-xl);");
|
expect(commandCenterControlsCss).toContain("--cc-controls-range-thumb-size: var(--space-xl);");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:CapacityModel 2026-07-29-00:25 (drop the cross-project cap — settings half):
|
||||||
|
The Command Center global-cap marker tests are DELETED with the slider they
|
||||||
|
measured. They asserted thumb-alignment and over-cap pinning for a control that
|
||||||
|
wrote a machine-wide limit; the limit is gone (capacity is two numbers PER PROJECT)
|
||||||
|
and the PUT route with it. The equivalent PROJECT-slider marker cases are retained.
|
||||||
|
|
||||||
|
The live "N running (all projects)" readout survives and moved onto the project
|
||||||
|
row — it is telemetry, so it has no cap to align a marker against.
|
||||||
|
*/
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ const EXPECTED_KEY_OWNING_SECTIONS: Record<string, "global" | "project"> = {
|
|||||||
|
|
||||||
const EXPECTED_EXCLUDED_SECTIONS = [
|
const EXPECTED_EXCLUDED_SECTIONS = [
|
||||||
// Owns one control, and it is not a settings-blob key (global-concurrency endpoint).
|
// Owns one control, and it is not a settings-blob key (global-concurrency endpoint).
|
||||||
"scheduling-global",
|
|
||||||
"secrets",
|
"secrets",
|
||||||
"global-mcp",
|
"global-mcp",
|
||||||
"mcp",
|
"mcp",
|
||||||
|
|||||||
@@ -68,7 +68,6 @@ const SECTION_FILE_TO_ID: Record<string, string> = {
|
|||||||
"ResearchGlobalSection.tsx": "research-global",
|
"ResearchGlobalSection.tsx": "research-global",
|
||||||
"ResearchProjectSection.tsx": "research-project",
|
"ResearchProjectSection.tsx": "research-project",
|
||||||
"ScheduledEvalsSection.tsx": "scheduled-evals",
|
"ScheduledEvalsSection.tsx": "scheduled-evals",
|
||||||
"SchedulingGlobalSection.tsx": "scheduling-global",
|
|
||||||
"SchedulingSection.tsx": "scheduling",
|
"SchedulingSection.tsx": "scheduling",
|
||||||
"SourceControlGlobalSection.tsx": "source-control-global",
|
"SourceControlGlobalSection.tsx": "source-control-global",
|
||||||
"SourceControlSection.tsx": "source-control",
|
"SourceControlSection.tsx": "source-control",
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import { remoteSearchEntries } from "../sections/RemoteSection.search";
|
|||||||
import { researchGlobalSearchEntries } from "../sections/ResearchGlobalSection.search";
|
import { researchGlobalSearchEntries } from "../sections/ResearchGlobalSection.search";
|
||||||
import { researchProjectSearchEntries } from "../sections/ResearchProjectSection.search";
|
import { researchProjectSearchEntries } from "../sections/ResearchProjectSection.search";
|
||||||
import { scheduledEvalsSearchEntries } from "../sections/ScheduledEvalsSection.search";
|
import { scheduledEvalsSearchEntries } from "../sections/ScheduledEvalsSection.search";
|
||||||
import { schedulingGlobalSearchEntries } from "../sections/SchedulingGlobalSection.search";
|
|
||||||
import { schedulingSearchEntries } from "../sections/SchedulingSection.search";
|
import { schedulingSearchEntries } from "../sections/SchedulingSection.search";
|
||||||
import { sourceControlGlobalSearchEntries } from "../sections/SourceControlGlobalSection.search";
|
import { sourceControlGlobalSearchEntries } from "../sections/SourceControlGlobalSection.search";
|
||||||
import { sourceControlSearchEntries } from "../sections/SourceControlSection.search";
|
import { sourceControlSearchEntries } from "../sections/SourceControlSection.search";
|
||||||
@@ -55,7 +54,6 @@ export const SETTINGS_SEARCH_ENTRIES: readonly SettingsSearchEntry[] = [
|
|||||||
...researchGlobalSearchEntries,
|
...researchGlobalSearchEntries,
|
||||||
...researchProjectSearchEntries,
|
...researchProjectSearchEntries,
|
||||||
...scheduledEvalsSearchEntries,
|
...scheduledEvalsSearchEntries,
|
||||||
...schedulingGlobalSearchEntries,
|
|
||||||
...schedulingSearchEntries,
|
...schedulingSearchEntries,
|
||||||
...sourceControlGlobalSearchEntries,
|
...sourceControlGlobalSearchEntries,
|
||||||
...sourceControlSearchEntries,
|
...sourceControlSearchEntries,
|
||||||
|
|||||||
@@ -219,10 +219,8 @@ export const PROJECT_SECTION_KEYS: Readonly<Record<string, readonly string[]>> =
|
|||||||
export const EXCLUDED_RESET_SECTIONS: Record<string, string> = {
|
export const EXCLUDED_RESET_SECTIONS: Record<string, string> = {
|
||||||
/*
|
/*
|
||||||
FNXC:SettingsReset 2026-07-15-18:52:
|
FNXC:SettingsReset 2026-07-15-18:52:
|
||||||
scheduling-global owns exactly one control (`globalMaxConcurrent`), and it is not a settings-blob key: it is read and written through the dedicated global-concurrency endpoint, so per-menu reset has nothing here to reset.
|
|
||||||
Listed explicitly rather than left to the unknown-id fallback: an unregistered id is reset-ineligible with NO reason, which renders the dialog without telling the operator why the button is unavailable.
|
Listed explicitly rather than left to the unknown-id fallback: an unregistered id is reset-ineligible with NO reason, which renders the dialog without telling the operator why the button is unavailable.
|
||||||
*/
|
*/
|
||||||
"scheduling-global": "The global concurrency cap is managed by the global-concurrency endpoint, not the settings form.",
|
|
||||||
secrets: "Secrets are managed by the Secrets store, not the settings form.",
|
secrets: "Secrets are managed by the Secrets store, not the settings form.",
|
||||||
"global-mcp": "MCP servers are managed by their own add/edit/remove flow.",
|
"global-mcp": "MCP servers are managed by their own add/edit/remove flow.",
|
||||||
mcp: "MCP servers are managed by their own add/edit/remove flow.",
|
mcp: "MCP servers are managed by their own add/edit/remove flow.",
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
/**
|
|
||||||
* Search entries for the Scheduling · Global section.
|
|
||||||
*
|
|
||||||
* FNXC:SettingsSearch 2026-07-15-18:52:
|
|
||||||
* `globalMaxConcurrent` moved here with its control when Scheduling was split into a Global/Project pair. The entry's `sectionId` must track the section that actually RENDERS the row — a stale id would surface the result, jump to a section that no longer holds the anchor, and do nothing.
|
|
||||||
*/
|
|
||||||
import type { SettingsSearchEntry } from "../search/types";
|
|
||||||
|
|
||||||
export const schedulingGlobalSearchEntries: SettingsSearchEntry[] = [
|
|
||||||
{
|
|
||||||
sectionId: "scheduling-global",
|
|
||||||
key: "globalMaxConcurrent",
|
|
||||||
labelKey: "settings.scheduling.globalMaxConcurrent",
|
|
||||||
labelFallback: "Global Max Concurrent",
|
|
||||||
helpKey: "settings.scheduling.maximumConcurrentAgentsAcrossAllProjects",
|
|
||||||
helpFallback: "Maximum concurrent agents across all projects. Default: 4.",
|
|
||||||
keywords: ["parallelism", "capacity", "machine wide", "cap"],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { SettingsNumberRow } from "../SettingsNumberRow";
|
|
||||||
|
|
||||||
export interface SchedulingGlobalSectionProps {
|
|
||||||
globalMaxConcurrent: number | undefined;
|
|
||||||
concurrencyLoading?: boolean;
|
|
||||||
onGlobalMaxConcurrentChange: (value: number | undefined) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
FNXC:SettingsScope 2026-07-15-18:52:
|
|
||||||
The machine-wide concurrency cap gets its own section instead of sitting on top of the project scheduling settings behind an in-section "Global — applies to all projects" subheading.
|
|
||||||
One section held two authority levels, so the answer to "does this affect other projects?" depended on which subheading an operator had scrolled past — and a search result landing mid-section shows no subheading at all. Sections are now single-scope, and the Global/Project pair sits adjacent under Automation, matching how Models/MCP/Research/General already read.
|
|
||||||
This split is also what lets the sibling project section drop its ScopeGroupHeader chrome entirely.
|
|
||||||
|
|
||||||
FNXC:SettingsScope 2026-07-15-18:52:
|
|
||||||
The row deliberately carries NO scope badge. `globalMaxConcurrent` is the one place the schema and the UI genuinely disagree: it is declared in `DEFAULT_PROJECT_SETTINGS` (settings-schema.ts:359) yet is read and written through the dedicated global-concurrency endpoint (hence the prop rather than `form`) and applies to every project on the machine.
|
|
||||||
Stamping "project" would contradict the section it lives in; stamping "global" would contradict the schema. The section is the honest source of scope until the schema is fixed — a badge here would assert a fact the data model does not support.
|
|
||||||
*/
|
|
||||||
export function SchedulingGlobalSection({ globalMaxConcurrent, concurrencyLoading = false, onGlobalMaxConcurrentChange, }: SchedulingGlobalSectionProps) {
|
|
||||||
const { t } = useTranslation("app");
|
|
||||||
return (<>
|
|
||||||
<h4 className="settings-section-heading">{t("settings.scheduling.scopeGlobalTitle", "Global — applies to all projects")}</h4>
|
|
||||||
<p className="settings-section-description">{t("settings.scheduling.scopeGlobalCaption", "Shared by every project on this machine.")}</p>
|
|
||||||
{/*
|
|
||||||
FNXC:SettingsConcurrency 2026-06-22-20:18:
|
|
||||||
Concurrency inputs represent live project/global limits. Keep them disabled while their actual values are still loading so users cannot edit a blank fallback and accidentally overwrite the resolved limits.
|
|
||||||
*/}
|
|
||||||
<SettingsNumberRow
|
|
||||||
descriptor={{
|
|
||||||
key: "globalMaxConcurrent",
|
|
||||||
label: t("settings.scheduling.globalMaxConcurrent", "Global Max Concurrent"),
|
|
||||||
help: t("settings.scheduling.maximumConcurrentAgentsAcrossAllProjects", "Maximum concurrent agents across all projects. Default: 4."),
|
|
||||||
min: 0,
|
|
||||||
max: 10000,
|
|
||||||
disabled: concurrencyLoading,
|
|
||||||
}}
|
|
||||||
value={globalMaxConcurrent ?? null}
|
|
||||||
onChange={(v) => onGlobalMaxConcurrentChange(v ?? undefined)}
|
|
||||||
/>
|
|
||||||
</>);
|
|
||||||
}
|
|
||||||
export default SchedulingGlobalSection;
|
|
||||||
@@ -211,7 +211,6 @@ const SETTING_DESCRIPTION_KEYS: Record<string, string> = {
|
|||||||
pushRemote: "merge.gitRemoteToPushToEGOrigin",
|
pushRemote: "merge.gitRemoteToPushToEGOrigin",
|
||||||
// NodeRouting / node sync covered above
|
// NodeRouting / node sync covered above
|
||||||
// SchedulingSection
|
// SchedulingSection
|
||||||
globalMaxConcurrent: "scheduling.maximumConcurrentAgentsAcrossAllProjects",
|
|
||||||
maxConcurrent: "scheduling.maxConcurrentTasksHint",
|
maxConcurrent: "scheduling.maxConcurrentTasksHint",
|
||||||
maxConcurrentVerifications: "scheduling.maxConcurrentVerificationsHint",
|
maxConcurrentVerifications: "scheduling.maxConcurrentVerificationsHint",
|
||||||
pollIntervalMs: "scheduling.pollIntervalMsHint",
|
pollIntervalMs: "scheduling.pollIntervalMsHint",
|
||||||
|
|||||||
@@ -1,18 +1,27 @@
|
|||||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:CapacityModel 2026-07-29-00:50 (drop the cross-project cap — settings half):
|
||||||
|
The hook is READ-ONLY now, so `updateGlobalConcurrency` and every case that drove a
|
||||||
|
write are gone: the debounced PUT, the save-state machine and the
|
||||||
|
keeps-counts-after-a-successful-PUT case. The cap they persisted no longer exists
|
||||||
|
(capacity is two numbers PER PROJECT) and the PUT route is deleted.
|
||||||
|
|
||||||
|
What is still covered is what the hook still does: populate live running counts,
|
||||||
|
return zero for an absent project, and refuse to surface stale truthy counts while
|
||||||
|
loading or in error — the last being a real past defect, where a failed load left
|
||||||
|
consumers reading the previous fetch's numbers as if current.
|
||||||
|
*/
|
||||||
const legacyMocks = vi.hoisted(() => ({
|
const legacyMocks = vi.hoisted(() => ({
|
||||||
fetchGlobalConcurrency: vi.fn(),
|
fetchGlobalConcurrency: vi.fn(),
|
||||||
updateGlobalConcurrency: vi.fn(),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../api/legacy", () => legacyMocks);
|
vi.mock("../../api/legacy", () => legacyMocks);
|
||||||
|
|
||||||
type UseGlobalConcurrencyModule = typeof import("../useGlobalConcurrency");
|
type UseGlobalConcurrencyModule = typeof import("../useGlobalConcurrency");
|
||||||
type GlobalConcurrencyApiState = {
|
type GlobalConcurrencyApiState = {
|
||||||
globalMaxConcurrent: number;
|
|
||||||
currentlyActive: number;
|
currentlyActive: number;
|
||||||
queuedCount: number;
|
|
||||||
projectsActive: Record<string, number>;
|
projectsActive: Record<string, number>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -24,9 +33,7 @@ async function loadHook(): Promise<UseGlobalConcurrencyModule["useGlobalConcurre
|
|||||||
|
|
||||||
function concurrencyState(overrides: Partial<GlobalConcurrencyApiState> = {}): GlobalConcurrencyApiState {
|
function concurrencyState(overrides: Partial<GlobalConcurrencyApiState> = {}): GlobalConcurrencyApiState {
|
||||||
return {
|
return {
|
||||||
globalMaxConcurrent: 6,
|
|
||||||
currentlyActive: 3,
|
currentlyActive: 3,
|
||||||
queuedCount: 0,
|
|
||||||
projectsActive: { proj_123: 2 },
|
projectsActive: { proj_123: 2 },
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
@@ -36,7 +43,6 @@ describe("useGlobalConcurrency", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
legacyMocks.fetchGlobalConcurrency.mockResolvedValue(concurrencyState());
|
legacyMocks.fetchGlobalConcurrency.mockResolvedValue(concurrencyState());
|
||||||
legacyMocks.updateGlobalConcurrency.mockResolvedValue(concurrencyState({ globalMaxConcurrent: 8 }));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -50,32 +56,12 @@ describe("useGlobalConcurrency", () => {
|
|||||||
const { result } = renderHook(() => useGlobalConcurrency());
|
const { result } = renderHook(() => useGlobalConcurrency());
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.status).toBe("loaded"));
|
await waitFor(() => expect(result.current.status).toBe("loaded"));
|
||||||
expect(result.current.value).toBe(6);
|
|
||||||
expect(result.current.currentlyActive).toBe(3);
|
expect(result.current.currentlyActive).toBe(3);
|
||||||
expect(result.current.projectActiveCount("proj_123")).toBe(2);
|
expect(result.current.projectActiveCount("proj_123")).toBe(2);
|
||||||
expect(result.current.projectActiveCount("missing-project")).toBe(0);
|
expect(result.current.projectActiveCount("missing-project")).toBe(0);
|
||||||
expect(result.current.projectActiveCount()).toBe(0);
|
expect(result.current.projectActiveCount()).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps last-known running counts after a successful PUT", async () => {
|
|
||||||
const useGlobalConcurrency = await loadHook();
|
|
||||||
const { result } = renderHook(() => useGlobalConcurrency());
|
|
||||||
await waitFor(() => expect(result.current.status).toBe("loaded"));
|
|
||||||
|
|
||||||
vi.useFakeTimers();
|
|
||||||
act(() => result.current.setValue("8"));
|
|
||||||
await act(async () => {
|
|
||||||
await vi.advanceTimersByTimeAsync(500);
|
|
||||||
await Promise.resolve();
|
|
||||||
});
|
|
||||||
vi.useRealTimers();
|
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.saveState).toBe("saved"));
|
|
||||||
expect(legacyMocks.updateGlobalConcurrency).toHaveBeenCalledWith({ globalMaxConcurrent: 8 });
|
|
||||||
expect(result.current.value).toBe(8);
|
|
||||||
expect(result.current.currentlyActive).toBe(3);
|
|
||||||
expect(result.current.projectActiveCount("proj_123")).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not surface stale truthy counts while loading or in error", async () => {
|
it("does not surface stale truthy counts while loading or in error", async () => {
|
||||||
const useGlobalConcurrency = await loadHook();
|
const useGlobalConcurrency = await loadHook();
|
||||||
|
|||||||
@@ -1,36 +1,37 @@
|
|||||||
import { useCallback, useEffect, useReducer, useRef, useState } from "react";
|
import { useCallback, useEffect, useReducer } from "react";
|
||||||
import { fetchGlobalConcurrency, updateGlobalConcurrency } from "../api/legacy";
|
import { fetchGlobalConcurrency } from "../api/legacy";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
FNXC:GlobalConcurrencyControls 2026-06-25-22:45:
|
FNXC:CapacityModel 2026-07-28-23:45 (drop the cross-project cap — settings half):
|
||||||
The global Max Concurrent cap is ONE shared, cross-project value persisted via /api/global-concurrency. Two independently-mounted sliders (the footer EngineControlMenu and the Command Center Concurrency card) must read and write a single source of truth, so this hook is backed by a MODULE-LEVEL shared store (a singleton cache plus a Set of subscriber callbacks). Without a shared store the two sliders kept private copies and produced last-writer-wins / stale-clobber bugs: dragging one slider, then opening the other, showed (and could re-persist) a stale value over the real cap.
|
READ-ONLY NOW. This hook owned an editable machine-wide concurrency cap. That
|
||||||
|
limiter is deleted (capacity is two numbers PER PROJECT), so everything that existed
|
||||||
|
only to PERSIST a value went with it: the 500ms debounce, the save-state machine,
|
||||||
|
the commit-on-close/unmount flush, the slider clamp/bounds, and the `interactive`
|
||||||
|
gate that guarded a writable control.
|
||||||
|
|
||||||
Invariants this hook enforces, all of which prior duplicated logic broke:
|
What remains is live utilization TELEMETRY — "N running (all projects)" plus the
|
||||||
- Revalidate after EVERY successful PUT: commit() calls setCache(), which notifies ALL subscribers so both sliders re-sync to the just-persisted value.
|
per-project breakdown — which the footer and Command Center still display. Nothing
|
||||||
- Treat fetch-error as NON-interactive: a failed load left the old slider enabled showing the floor value (1); a drag then persisted 1 over the real cap. interactive is true ONLY when status === "loaded".
|
gates on it.
|
||||||
- Surface save-state (saving / saved / error) like the per-project sliders, including a retry-friendly error that KEEPS the user's value (never silently reverts).
|
|
||||||
- Flush pending debounced edits on close/unmount: dragging then closing the menu (or unmounting the card) used to drop the in-flight write. A cleanup flush commits it immediately.
|
The module-level shared store is KEPT even though no write can race any more. Two
|
||||||
- Language changes (i18n `t`) must NOT refetch/reset, so `t` is intentionally never in a dependency array here.
|
independently-mounted consumers (footer EngineControlMenu, Command Center card)
|
||||||
|
read this, and the original reason for the singleton was that separate copies
|
||||||
|
drift — that argument holds for a polled read exactly as it did for a cap. It also
|
||||||
|
means one fetch serves both.
|
||||||
|
|
||||||
|
Retained deliberately, because they were about READS, not writes:
|
||||||
|
- Language changes (i18n `t`) must never refetch, so `t` is not a dependency here.
|
||||||
|
- `activeWhen` gates fetching so the footer menu only loads while open.
|
||||||
|
- A failed read reports zero rather than presenting stale counts as current.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const SLIDER_MIN = 1;
|
|
||||||
const SLIDER_BASE_MAX = 32;
|
|
||||||
const DEBOUNCE_MS = 500;
|
|
||||||
|
|
||||||
type GlobalConcurrencyStatus = "idle" | "loading" | "loaded" | "error";
|
type GlobalConcurrencyStatus = "idle" | "loading" | "loaded" | "error";
|
||||||
|
|
||||||
// FNXC:GlobalConcurrencyControls 2026-06-25-22:45: Module-level singleton cache + subscriber set is the single source of truth shared by every mounted hook instance.
|
|
||||||
/*
|
|
||||||
FNXC:GlobalConcurrencyControls 2026-06-26-06:26:
|
|
||||||
The shared global-concurrency store carries read-only utilization counts (`currentlyActive` and `projectsActive`) alongside the editable cap so footer consumers can show live running-agent counts without adding backend writes or breaking the Command Center's existing cap-only UI.
|
|
||||||
*/
|
|
||||||
const cache: {
|
const cache: {
|
||||||
value: number | null;
|
|
||||||
status: GlobalConcurrencyStatus;
|
status: GlobalConcurrencyStatus;
|
||||||
currentlyActive: number | null;
|
currentlyActive: number | null;
|
||||||
projectsActive: Record<string, number>;
|
projectsActive: Record<string, number>;
|
||||||
} = {
|
} = {
|
||||||
value: null,
|
|
||||||
status: "idle",
|
status: "idle",
|
||||||
currentlyActive: null,
|
currentlyActive: null,
|
||||||
projectsActive: {},
|
projectsActive: {},
|
||||||
@@ -43,35 +44,31 @@ function notify() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setCache(next: {
|
function setCache(next: {
|
||||||
value: number | null;
|
|
||||||
status: GlobalConcurrencyStatus;
|
status: GlobalConcurrencyStatus;
|
||||||
currentlyActive?: number | null;
|
currentlyActive?: number | null;
|
||||||
projectsActive?: Record<string, number>;
|
projectsActive?: Record<string, number>;
|
||||||
}) {
|
}) {
|
||||||
cache.value = next.value;
|
|
||||||
cache.status = next.status;
|
cache.status = next.status;
|
||||||
if (next.currentlyActive !== undefined) cache.currentlyActive = next.currentlyActive;
|
if (next.currentlyActive !== undefined) cache.currentlyActive = next.currentlyActive;
|
||||||
if (next.projectsActive !== undefined) cache.projectsActive = next.projectsActive;
|
if (next.projectsActive !== undefined) cache.projectsActive = next.projectsActive;
|
||||||
notify();
|
notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
// FNXC:GlobalConcurrencyControls 2026-06-25-22:45: Fetch once and dedupe concurrent callers via an in-flight promise. On error keep the previous value and mark status "error" so the slider goes non-interactive instead of falsely showing the floor.
|
/** Fetch live counts, deduping concurrent callers via an in-flight promise. */
|
||||||
function ensureFetched(force = false): Promise<void> {
|
function ensureFetched(force = false): Promise<void> {
|
||||||
if (inFlight) return inFlight;
|
if (inFlight) return inFlight;
|
||||||
if (!force && cache.status === "loaded") return Promise.resolve();
|
if (!force && cache.status === "loaded") return Promise.resolve();
|
||||||
setCache({ value: cache.value, status: "loading" });
|
setCache({ status: "loading" });
|
||||||
inFlight = (async () => {
|
inFlight = (async () => {
|
||||||
try {
|
try {
|
||||||
const result = await fetchGlobalConcurrency();
|
const result = await fetchGlobalConcurrency();
|
||||||
setCache({
|
setCache({
|
||||||
value: result.globalMaxConcurrent,
|
|
||||||
status: "loaded",
|
status: "loaded",
|
||||||
currentlyActive: result.currentlyActive,
|
currentlyActive: result.currentlyActive,
|
||||||
projectsActive: result.projectsActive,
|
projectsActive: result.projectsActive,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Keep the previous value; non-interactive while in error state.
|
setCache({ status: "error", currentlyActive: null, projectsActive: {} });
|
||||||
setCache({ value: cache.value, status: "error" });
|
|
||||||
} finally {
|
} finally {
|
||||||
inFlight = null;
|
inFlight = null;
|
||||||
}
|
}
|
||||||
@@ -80,41 +77,18 @@ function ensureFetched(force = false): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UseGlobalConcurrencyResult {
|
export interface UseGlobalConcurrencyResult {
|
||||||
value: number;
|
|
||||||
min: number;
|
|
||||||
sliderMax: number;
|
|
||||||
interactive: boolean;
|
|
||||||
status: GlobalConcurrencyStatus;
|
status: GlobalConcurrencyStatus;
|
||||||
saveState: "idle" | "saving" | "saved" | "error";
|
/** Live running-agent count across all projects; 0 unless loaded. */
|
||||||
currentlyActive: number;
|
currentlyActive: number;
|
||||||
projectActiveCount: (projectId?: string) => number;
|
projectActiveCount: (projectId?: string) => number;
|
||||||
setValue: (raw: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clamp(value: number, min: number, max: number) {
|
|
||||||
return Math.min(max, Math.max(min, value));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* FNXC:GlobalConcurrencyControls 2026-06-25-22:45:
|
|
||||||
* Shared hook for the cross-project global concurrency cap. `activeWhen` (default true)
|
|
||||||
* gates fetching so the footer menu only loads when open; the Command Center card calls
|
|
||||||
* it with no args. Backed by the module-level shared store above.
|
|
||||||
*/
|
|
||||||
export function useGlobalConcurrency(opts?: { activeWhen?: boolean }): UseGlobalConcurrencyResult {
|
export function useGlobalConcurrency(opts?: { activeWhen?: boolean }): UseGlobalConcurrencyResult {
|
||||||
const activeWhen = opts?.activeWhen ?? true;
|
const activeWhen = opts?.activeWhen ?? true;
|
||||||
|
|
||||||
// Force a re-render whenever the shared store notifies this instance.
|
// Force a re-render whenever the shared store notifies this instance.
|
||||||
const [, bump] = useReducer((n: number) => n + 1, 0);
|
const [, bump] = useReducer((n: number) => n + 1, 0);
|
||||||
const [saveState, setSaveState] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
|
||||||
|
|
||||||
// Local optimistic state for snappy dragging.
|
|
||||||
const dirtyRef = useRef(false);
|
|
||||||
const pendingValueRef = useRef<number | null>(null);
|
|
||||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const [localValue, setLocalValue] = useState<number | null>(null);
|
|
||||||
|
|
||||||
// Subscribe to the shared store on mount.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
subscribers.add(bump);
|
subscribers.add(bump);
|
||||||
return () => {
|
return () => {
|
||||||
@@ -122,84 +96,13 @@ export function useGlobalConcurrency(opts?: { activeWhen?: boolean }): UseGlobal
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const currentValue = (dirtyRef.current ? localValue : cache.value) ?? SLIDER_MIN;
|
// Force-revalidate each time the surface activates (menu opens / card mounts):
|
||||||
// FNXC:GlobalConcurrencyControls 2026-06-25-22:45: sliderMax expands past the base cap so already-persisted values >32 still render truthfully.
|
// the counts are live, so a fetch-once cache would show a stale figure.
|
||||||
const sliderMax = Math.max(SLIDER_BASE_MAX, currentValue);
|
|
||||||
// FNXC:GlobalConcurrencyControls 2026-06-26-06:05: Track the current value in a ref so setValue (a stable useCallback) clamps against the real ceiling without going stale; used to give the clamp an actual upper bound.
|
|
||||||
const currentValueRef = useRef(currentValue);
|
|
||||||
currentValueRef.current = currentValue;
|
|
||||||
|
|
||||||
const commit = useCallback((v: number) => {
|
|
||||||
// FNXC:GlobalConcurrencyControls 2026-06-26-06:05: Synchronously null the pending ref BEFORE the async PUT so the close-flush and unmount-cleanup guards (`dirtyRef && pendingValueRef != null`) are already false — otherwise a close-then-unmount within the in-flight window fires commit() twice and sends a duplicate PUT. dirtyRef stays true until the PUT resolves so the slider keeps showing the user's value (no snap-back) during save.
|
|
||||||
pendingValueRef.current = null;
|
|
||||||
setSaveState("saving");
|
|
||||||
void updateGlobalConcurrency({ globalMaxConcurrent: v })
|
|
||||||
.then(() => {
|
|
||||||
// Notifies ALL subscribers → both sliders re-sync to the persisted value while keeping last-known read-only utilization counts until the next forced revalidate.
|
|
||||||
setCache({ value: v, status: "loaded" });
|
|
||||||
dirtyRef.current = false;
|
|
||||||
pendingValueRef.current = null;
|
|
||||||
setLocalValue(null);
|
|
||||||
setSaveState("saved");
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
// KEEP dirty + the user's value so the next drag retries; never silently revert.
|
|
||||||
setSaveState("error");
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const setValue = useCallback((raw: string) => {
|
|
||||||
// FNXC:GlobalConcurrencyControls 2026-06-26-06:05: Clamp against a real ceiling (base cap expanded only by the already-persisted value), not Number(raw) — the latter made the upper bound equal the input, so there was no effective ceiling and a programmatic caller could set an arbitrarily large cap.
|
|
||||||
const next = clamp(Number(raw), SLIDER_MIN, Math.max(SLIDER_BASE_MAX, currentValueRef.current));
|
|
||||||
dirtyRef.current = true;
|
|
||||||
pendingValueRef.current = next;
|
|
||||||
setLocalValue(next);
|
|
||||||
setSaveState("saving");
|
|
||||||
if (timerRef.current) clearTimeout(timerRef.current);
|
|
||||||
timerRef.current = setTimeout(() => {
|
|
||||||
timerRef.current = null;
|
|
||||||
commit(next);
|
|
||||||
}, DEBOUNCE_MS);
|
|
||||||
}, [commit]);
|
|
||||||
|
|
||||||
// FNXC:GlobalConcurrencyControls 2026-06-26-06:05: Force-revalidate each time the surface activates (menu opens / card mounts). The cap can be written out-of-band — notably the Settings modal persists globalMaxConcurrent directly via updateGlobalConcurrency() without going through this store — so a plain "fetch once then never again" cache would show a stale value after such a save. Forcing on activate keeps every consumer truthful; concurrent forces still dedupe via the in-flight promise. `t` deliberately excluded so language changes never refetch/reset.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeWhen) return;
|
if (!activeWhen) return;
|
||||||
void ensureFetched(true);
|
void ensureFetched(true);
|
||||||
}, [activeWhen]);
|
}, [activeWhen]);
|
||||||
|
|
||||||
// FNXC:GlobalConcurrencyControls 2026-06-25-22:45: When the shared cache changes and we are not mid-edit, drop the local pending so the slider reflects the new shared value.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!dirtyRef.current) {
|
|
||||||
setLocalValue(null);
|
|
||||||
pendingValueRef.current = null;
|
|
||||||
}
|
|
||||||
}, [cache.value]);
|
|
||||||
|
|
||||||
// FLUSH ON CLOSE/UNMOUNT: commit any pending debounced write immediately so a drag-then-close/unmount never drops it.
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeWhen) return;
|
|
||||||
if (timerRef.current) {
|
|
||||||
clearTimeout(timerRef.current);
|
|
||||||
timerRef.current = null;
|
|
||||||
}
|
|
||||||
if (dirtyRef.current && pendingValueRef.current != null) {
|
|
||||||
commit(pendingValueRef.current);
|
|
||||||
}
|
|
||||||
}, [activeWhen, commit]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (timerRef.current) {
|
|
||||||
clearTimeout(timerRef.current);
|
|
||||||
timerRef.current = null;
|
|
||||||
}
|
|
||||||
if (dirtyRef.current && pendingValueRef.current != null) {
|
|
||||||
commit(pendingValueRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [commit]);
|
|
||||||
|
|
||||||
const countsAreLoaded = cache.status === "loaded";
|
const countsAreLoaded = cache.status === "loaded";
|
||||||
const projectActiveCount = useCallback((projectId?: string) => {
|
const projectActiveCount = useCallback((projectId?: string) => {
|
||||||
if (!countsAreLoaded || !projectId) return 0;
|
if (!countsAreLoaded || !projectId) return 0;
|
||||||
@@ -207,15 +110,8 @@ export function useGlobalConcurrency(opts?: { activeWhen?: boolean }): UseGlobal
|
|||||||
}, [countsAreLoaded]);
|
}, [countsAreLoaded]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
value: currentValue,
|
|
||||||
min: SLIDER_MIN,
|
|
||||||
sliderMax,
|
|
||||||
// interactive ONLY when loaded; loading/error → disabled slider.
|
|
||||||
interactive: cache.status === "loaded",
|
|
||||||
status: cache.status,
|
status: cache.status,
|
||||||
saveState,
|
|
||||||
currentlyActive: countsAreLoaded ? (cache.currentlyActive ?? 0) : 0,
|
currentlyActive: countsAreLoaded ? (cache.currentlyActive ?? 0) : 0,
|
||||||
projectActiveCount,
|
projectActiveCount,
|
||||||
setValue,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,16 +33,24 @@ describe("register setup/activity route contracts", () => {
|
|||||||
await request(app, "GET", "/api/activity?limit=7&type=task:created"); expect(getActivityLog).toHaveBeenLastCalledWith({ limit: 7, since: undefined, type: "task:created" });
|
await request(app, "GET", "/api/activity?limit=7&type=task:created"); expect(getActivityLog).toHaveBeenLastCalledWith({ limit: 7, since: undefined, type: "task:created" });
|
||||||
expect((await request(app, "DELETE", "/api/activity")).body).toEqual({ success: true }); expect(clearActivityLog).toHaveBeenCalledOnce();
|
expect((await request(app, "DELETE", "/api/activity")).body).toEqual({ success: true }); expect(clearActivityLog).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
it("uses the supplied CentralCore for feed and live concurrency state", async () => {
|
/*
|
||||||
const getRecentActivity = vi.fn().mockResolvedValue([{ id: "feed" }]); const getGlobalConcurrencyState = vi.fn().mockResolvedValue({ globalMaxConcurrent: 4, currentlyActive: 0, projectsActive: 0, queuedCount: 2 }); const getLiveRunningAgentCounts = vi.fn().mockResolvedValue({ currentlyActive: 3, projectsActive: 2 });
|
FNXC:CapacityModel 2026-07-29-03:40 (drop the cross-project cap — settings half):
|
||||||
const app = server({}, { getRecentActivity, getGlobalConcurrencyState, getLiveRunningAgentCounts });
|
GET /api/global-concurrency now returns TELEMETRY ONLY. `globalMaxConcurrent` and
|
||||||
|
`queuedCount` are gone from the payload: the cap is deleted (capacity is two
|
||||||
|
numbers PER PROJECT) and `queuedCount` came from slot bookkeeping production code
|
||||||
|
never incremented, so publishing it was publishing a zero dressed as state. The
|
||||||
|
route no longer calls `getGlobalConcurrencyState` at all — it reads live counts
|
||||||
|
through CentralCore's side-effect-safe source, which is asserted here.
|
||||||
|
|
||||||
|
The PUT bounds case is DELETED with the route. It validated 0/10001/1.5/"2" -> 400
|
||||||
|
for a limit that no longer exists; there is nothing left to bound.
|
||||||
|
*/
|
||||||
|
it("uses the supplied CentralCore for feed and live running-agent counts", async () => {
|
||||||
|
const getRecentActivity = vi.fn().mockResolvedValue([{ id: "feed" }]); const getLiveRunningAgentCounts = vi.fn().mockResolvedValue({ currentlyActive: 3, projectsActive: 2 });
|
||||||
|
const app = server({}, { getRecentActivity, getLiveRunningAgentCounts });
|
||||||
expect((await request(app, "GET", "/api/activity-feed?limit=8&projectId=p&types=task:created,task:moved")).body).toEqual([{ id: "feed" }]); expect(getRecentActivity).toHaveBeenCalledWith({ limit: 8, projectId: "p", types: ["task:created", "task:moved"] });
|
expect((await request(app, "GET", "/api/activity-feed?limit=8&projectId=p&types=task:created,task:moved")).body).toEqual([{ id: "feed" }]); expect(getRecentActivity).toHaveBeenCalledWith({ limit: 8, projectId: "p", types: ["task:created", "task:moved"] });
|
||||||
expect((await request(app, "GET", "/api/global-concurrency")).body).toEqual({ globalMaxConcurrent: 4, currentlyActive: 3, projectsActive: 2, queuedCount: 2 });
|
expect((await request(app, "GET", "/api/global-concurrency")).body).toEqual({ currentlyActive: 3, projectsActive: 2 });
|
||||||
});
|
expect(getLiveRunningAgentCounts).toHaveBeenCalled();
|
||||||
it("enforces concurrency bounds and updates valid state", async () => {
|
|
||||||
const updateGlobalConcurrency = vi.fn().mockResolvedValue({ globalMaxConcurrent: 9 }); const app = server({}, { updateGlobalConcurrency });
|
|
||||||
for (const globalMaxConcurrent of [0, 10001, 1.5, "2"]) expect((await request(app, "PUT", "/api/global-concurrency", JSON.stringify({ globalMaxConcurrent }), { "Content-Type": "application/json" })).status).toBe(400);
|
|
||||||
expect((await request(app, "PUT", "/api/global-concurrency", JSON.stringify({ globalMaxConcurrent: 9 }), { "Content-Type": "application/json" })).body).toEqual({ globalMaxConcurrent: 9 }); expect(updateGlobalConcurrency).toHaveBeenCalledWith({ globalMaxConcurrent: 9 });
|
|
||||||
});
|
});
|
||||||
it("rejects non-array setup projects and completes valid setup", async () => {
|
it("rejects non-array setup projects and completes valid setup", async () => {
|
||||||
completeSetup.mockResolvedValue({ success: true, projectsRegistered: 1, errors: [] });
|
completeSetup.mockResolvedValue({ success: true, projectsRegistered: 1, errors: [] });
|
||||||
|
|||||||
@@ -106,62 +106,31 @@ router.get("/activity-feed", async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/*
|
||||||
* GET /api/global-concurrency
|
FNXC:CapacityModel 2026-07-28-23:45 (drop the cross-project cap — settings half):
|
||||||
* Get global concurrency state across all projects.
|
PUT /api/global-concurrency is DELETED: it set a machine-wide limit that no longer
|
||||||
* Returns: GlobalConcurrencyState
|
exists. Capacity is two numbers PER PROJECT.
|
||||||
*/
|
|
||||||
|
GET SURVIVES but returns TELEMETRY ONLY — live "N running" counts per project, via
|
||||||
|
CentralCore's registered side-effect-safe source. It no longer reports
|
||||||
|
globalMaxConcurrent or queuedCount: those came from the deleted cap and from slot
|
||||||
|
bookkeeping that production code never incremented, so publishing them was
|
||||||
|
publishing zeros dressed as state. Nothing gates on this route.
|
||||||
|
*/
|
||||||
router.get("/global-concurrency", async (_req, res) => {
|
router.get("/global-concurrency", async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const central = options?.centralCore ?? new (await import("@fusion/core")).CentralCore();
|
const central = options?.centralCore ?? new (await import("@fusion/core")).CentralCore();
|
||||||
const shouldClose = !options?.centralCore;
|
const shouldClose = !options?.centralCore;
|
||||||
if (shouldClose || (typeof central.isInitialized === "function" && !central.isInitialized())) await central.init();
|
if (shouldClose || (typeof central.isInitialized === "function" && !central.isInitialized())) await central.init();
|
||||||
|
|
||||||
const state = await central.getGlobalConcurrencyState();
|
|
||||||
const liveCounts = await central.getLiveRunningAgentCounts();
|
const liveCounts = await central.getLiveRunningAgentCounts();
|
||||||
|
|
||||||
/*
|
if (shouldClose) await central.close();
|
||||||
FNXC:GlobalConcurrencyControls 2026-06-26-17:22:
|
|
||||||
The published global-concurrency route reads currentlyActive/projectsActive through CentralCore's live seam while preserving globalMaxConcurrent/queuedCount from slot bookkeeping. The dashboard-registered source only inspects already-open project stores, so this read stays side-effect-safe and never opens watchers or starts project runtimes.
|
res.json({
|
||||||
*/
|
|
||||||
const liveState = {
|
|
||||||
...state,
|
|
||||||
currentlyActive: liveCounts.currentlyActive,
|
currentlyActive: liveCounts.currentlyActive,
|
||||||
projectsActive: liveCounts.projectsActive,
|
projectsActive: liveCounts.projectsActive,
|
||||||
};
|
});
|
||||||
|
|
||||||
if (shouldClose) await central.close();
|
|
||||||
|
|
||||||
res.json(liveState);
|
|
||||||
} catch (err: unknown) {
|
|
||||||
if (err instanceof ApiError) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
rethrowAsApiError(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PUT /api/global-concurrency
|
|
||||||
* Update the system-wide concurrency limit across all projects.
|
|
||||||
* Body: { globalMaxConcurrent: number }
|
|
||||||
* Returns: GlobalConcurrencyState
|
|
||||||
*/
|
|
||||||
router.put("/global-concurrency", async (req, res) => {
|
|
||||||
const { globalMaxConcurrent } = req.body ?? {};
|
|
||||||
if (!Number.isInteger(globalMaxConcurrent) || globalMaxConcurrent < 1 || globalMaxConcurrent > 10000) {
|
|
||||||
throw badRequest("globalMaxConcurrent must be an integer between 1 and 10000");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const central = options?.centralCore ?? new (await import("@fusion/core")).CentralCore();
|
|
||||||
const shouldClose = !options?.centralCore;
|
|
||||||
if (shouldClose || (typeof central.isInitialized === "function" && !central.isInitialized())) await central.init();
|
|
||||||
|
|
||||||
const state = await central.updateGlobalConcurrency({ globalMaxConcurrent });
|
|
||||||
if (shouldClose) await central.close();
|
|
||||||
|
|
||||||
res.json(state);
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
throw err;
|
throw err;
|
||||||
|
|||||||
@@ -198,10 +198,13 @@ const SETTINGS_SECTION_DEFINITIONS: readonly SettingsSectionDefinition[] = [
|
|||||||
{ id: "__automation_header", label: "Automation", labelKey: "settings.nav.automationHeader", scope: undefined, isGroupHeader: true },
|
{ id: "__automation_header", label: "Automation", labelKey: "settings.nav.automationHeader", scope: undefined, isGroupHeader: true },
|
||||||
/*
|
/*
|
||||||
FNXC:SettingsNavigation 2026-07-15-18:52:
|
FNXC:SettingsNavigation 2026-07-15-18:52:
|
||||||
Scheduling is split into a Global/Project pair rather than one section holding both authority levels behind in-section subheadings. The machine-wide concurrency cap and a project's scheduling posture are different questions, and a search result landing mid-section showed no subheading to disambiguate them.
|
FNXC:CapacityModel 2026-07-29-00:10 (drop the cross-project cap — settings half):
|
||||||
|
The Global/Project scheduling PAIR is now a single "Scheduling" section. The
|
||||||
|
global half existed to host exactly one control, the machine-wide concurrency
|
||||||
|
cap, which is deleted — capacity is two numbers PER PROJECT. An empty section in
|
||||||
|
the nav is a promise of settings that are not there.
|
||||||
*/
|
*/
|
||||||
{ id: "scheduling-global", label: "Scheduling · Global", labelKey: "settings.nav.schedulingGlobal", scope: "global", searchableText: ["global max concurrent", "concurrency cap", "all projects", "machine wide", "parallel agents", "scheduler"] },
|
{ id: "scheduling", label: "Scheduling", labelKey: "settings.nav.scheduling", scope: "project", searchableText: ["max concurrent", "capacity", "stuck tasks", "poll interval", "parallel steps", "scheduler"] },
|
||||||
{ id: "scheduling", label: "Scheduling · Project", labelKey: "settings.nav.scheduling", scope: "project", searchableText: ["max concurrent", "capacity", "stuck tasks", "poll interval", "parallel steps", "scheduler"] },
|
|
||||||
{ id: "scheduled-evals", label: "Scheduled Evals", labelKey: "settings.nav.scheduledEvals", scope: "project", searchableText: ["scheduled evals", "evaluation schedule", "eval runs", "quality jobs"] },
|
{ id: "scheduled-evals", label: "Scheduled Evals", labelKey: "settings.nav.scheduledEvals", scope: "project", searchableText: ["scheduled evals", "evaluation schedule", "eval runs", "quality jobs"] },
|
||||||
|
|
||||||
{ id: "__integrations_header", label: "Integrations", labelKey: "settings.nav.integrationsHeader", scope: undefined, isGroupHeader: true },
|
{ id: "__integrations_header", label: "Integrations", labelKey: "settings.nav.integrationsHeader", scope: undefined, isGroupHeader: true },
|
||||||
|
|||||||
@@ -47,12 +47,7 @@ let engine: ProjectEngine | null = null;
|
|||||||
// The child doesn't need full CentralCore functionality
|
// The child doesn't need full CentralCore functionality
|
||||||
const createStubCentralCore = (): CentralCore => {
|
const createStubCentralCore = (): CentralCore => {
|
||||||
return {
|
return {
|
||||||
getGlobalConcurrencyState: async () => ({
|
getLiveRunningAgentCounts: async () => ({ currentlyActive: 0, projectsActive: {} }),
|
||||||
globalMaxConcurrent: 4,
|
|
||||||
currentlyActive: 0,
|
|
||||||
queuedCount: 0,
|
|
||||||
projectsActive: {},
|
|
||||||
}),
|
|
||||||
recordTaskCompletion: async () => {},
|
recordTaskCompletion: async () => {},
|
||||||
} as unknown as CentralCore;
|
} as unknown as CentralCore;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2420,20 +2420,12 @@ export class InProcessRuntime
|
|||||||
this.lastActivityAt = new Date().toISOString();
|
this.lastActivityAt = new Date().toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/*
|
||||||
* Get global concurrency limit from CentralCore.
|
FNXC:CapacityModel 2026-07-28-23:30 (drop the cross-project cap — settings half):
|
||||||
*/
|
`getGlobalConcurrencyLimit` is DELETED. Its only caller was the global semaphore
|
||||||
private async getGlobalConcurrencyLimit(): Promise<number> {
|
construction removed in the enforcement half, so it has been reading a limit
|
||||||
try {
|
nothing consults. Capacity is two numbers per project.
|
||||||
const state = await this.centralCore.getGlobalConcurrencyState();
|
*/
|
||||||
return state.globalMaxConcurrent;
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
|
||||||
runtimeLog.warn(`Failed to fetch global concurrency from CentralCore, falling back to default (4): ${msg}`);
|
|
||||||
// Fallback to default if CentralCore is unavailable
|
|
||||||
return 4;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Record task completion in CentralCore.
|
* Record task completion in CentralCore.
|
||||||
|
|||||||
Reference in New Issue
Block a user