feat: expose global execution concurrency limit in settings

- Add PUT /api/global-concurrency endpoint to update globalMaxConcurrent
  via CentralCore (validated 1-50 range)
- Make InProcessRuntime semaphore react to live concurrency changes via
  CentralCore "concurrency:changed" event — no restart needed
- Add Global Max Concurrent input to Settings modal Scheduling section
  with fetch-on-mount and save-alongside-project-settings behavior
- Add updateGlobalConcurrency API client function

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-13 14:23:40 -07:00
parent 8b2eea90ff
commit f2afce2468
4 changed files with 77 additions and 5 deletions

View File

@@ -3191,6 +3191,13 @@ export function fetchGlobalConcurrency(): Promise<GlobalConcurrencyState> {
return api<GlobalConcurrencyState>("/global-concurrency");
}
export function updateGlobalConcurrency(updates: { globalMaxConcurrent: number }): Promise<GlobalConcurrencyState> {
return api<GlobalConcurrencyState>("/global-concurrency", {
method: "PUT",
body: JSON.stringify(updates),
});
}
/** Fetch tasks for a specific project */
export function fetchProjectTasks(projectId: string, limit?: number, offset?: number): Promise<Task[]> {
const params = new URLSearchParams();

View File

@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useRef } from "react";
import { Globe, Folder } from "lucide-react";
import { THINKING_LEVELS, PROMPT_KEY_CATALOG, isGlobalSettingsKey, isProjectSettingsKey } from "@fusion/core";
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset, NtfyNotificationEvent } from "@fusion/core";
import { fetchSettings, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNtfyNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemory, saveMemory } from "../api";
import { fetchSettings, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNtfyNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemory, saveMemory, fetchGlobalConcurrency, updateGlobalConcurrency } from "../api";
import type { AuthProvider, ModelInfo, BackupListResponse, SettingsExportData } from "../api";
import type { ToastType } from "../hooks/useToast";
import { ThemeSelector } from "./ThemeSelector";
@@ -139,6 +139,9 @@ export function SettingsModal({
const [memoryLoading, setMemoryLoading] = useState(false);
const [memoryDirty, setMemoryDirty] = useState(false);
// Global concurrency state
const [globalMaxConcurrent, setGlobalMaxConcurrent] = useState<number>(4);
// Import/Export state
const [importDialogOpen, setImportDialogOpen] = useState(false);
const [, setImportFile] = useState<File | null>(null);
@@ -160,6 +163,14 @@ export function SettingsModal({
});
}, [addToast, projectId]);
useEffect(() => {
fetchGlobalConcurrency()
.then((state) => setGlobalMaxConcurrent(state.globalMaxConcurrent))
.catch(() => {
// Silently fail — global concurrency may not be available
});
}, []);
// Load auth status when the authentication section is active
const loadAuthStatus = useCallback(async () => {
try {
@@ -531,6 +542,7 @@ export function SettingsModal({
await Promise.all([
Object.keys(globalPatch).length > 0 ? updateGlobalSettings(globalPatch) : Promise.resolve(),
Object.keys(projectPatch).length > 0 ? updateSettings(projectPatch, projectId) : Promise.resolve(),
updateGlobalConcurrency({ globalMaxConcurrent }),
]);
addToast("Settings saved", "success");
@@ -538,7 +550,7 @@ export function SettingsModal({
} catch (err: any) {
addToast(err.message, "error");
}
}, [form, prefixError, presetDraft, onClose, addToast, projectId]);
}, [form, globalMaxConcurrent, prefixError, presetDraft, onClose, addToast, projectId]);
const handleSaveMemory = useCallback(async () => {
try {
@@ -1285,6 +1297,18 @@ export function SettingsModal({
<>
{renderScopeBanner()}
<h4 className="settings-section-heading">Scheduling</h4>
<div className="form-group">
<label htmlFor="globalMaxConcurrent">Global Max Concurrent</label>
<input
id="globalMaxConcurrent"
type="number"
min={1}
max={50}
value={globalMaxConcurrent}
onChange={(e) => setGlobalMaxConcurrent(Number(e.target.value))}
/>
<small className="form-text text-muted">Maximum concurrent agents across all projects</small>
</div>
<div className="form-group">
<label htmlFor="maxConcurrent">Max Concurrent Tasks</label>
<input

View File

@@ -13485,6 +13485,29 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
/**
* PUT /api/global-concurrency
* Update the global execution concurrency limit.
* Body: { globalMaxConcurrent: number }
*/
router.put("/global-concurrency", async (req, res) => {
try {
const { globalMaxConcurrent } = req.body;
if (typeof globalMaxConcurrent !== "number" || globalMaxConcurrent < 1 || globalMaxConcurrent > 50) {
throw new ApiError(400, "globalMaxConcurrent must be a number between 1 and 50");
}
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
const state = await central.updateGlobalConcurrency({ globalMaxConcurrent });
await central.close();
res.json(state);
} catch (err: any) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);
}
});
/**
* GET /api/first-run-status
* Check if user has projects or needs setup wizard.