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:
@@ -3191,6 +3191,13 @@ export function fetchGlobalConcurrency(): Promise<GlobalConcurrencyState> {
|
|||||||
return api<GlobalConcurrencyState>("/global-concurrency");
|
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 */
|
/** Fetch tasks for a specific project */
|
||||||
export function fetchProjectTasks(projectId: string, limit?: number, offset?: number): Promise<Task[]> {
|
export function fetchProjectTasks(projectId: string, limit?: number, offset?: number): Promise<Task[]> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useRef } from "react";
|
|||||||
import { Globe, Folder } from "lucide-react";
|
import { Globe, Folder } from "lucide-react";
|
||||||
import { THINKING_LEVELS, PROMPT_KEY_CATALOG, isGlobalSettingsKey, isProjectSettingsKey } from "@fusion/core";
|
import { THINKING_LEVELS, PROMPT_KEY_CATALOG, isGlobalSettingsKey, isProjectSettingsKey } from "@fusion/core";
|
||||||
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset, NtfyNotificationEvent } 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 { AuthProvider, ModelInfo, BackupListResponse, SettingsExportData } from "../api";
|
||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
import { ThemeSelector } from "./ThemeSelector";
|
import { ThemeSelector } from "./ThemeSelector";
|
||||||
@@ -139,6 +139,9 @@ export function SettingsModal({
|
|||||||
const [memoryLoading, setMemoryLoading] = useState(false);
|
const [memoryLoading, setMemoryLoading] = useState(false);
|
||||||
const [memoryDirty, setMemoryDirty] = useState(false);
|
const [memoryDirty, setMemoryDirty] = useState(false);
|
||||||
|
|
||||||
|
// Global concurrency state
|
||||||
|
const [globalMaxConcurrent, setGlobalMaxConcurrent] = useState<number>(4);
|
||||||
|
|
||||||
// Import/Export state
|
// Import/Export state
|
||||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||||
const [, setImportFile] = useState<File | null>(null);
|
const [, setImportFile] = useState<File | null>(null);
|
||||||
@@ -160,6 +163,14 @@ export function SettingsModal({
|
|||||||
});
|
});
|
||||||
}, [addToast, projectId]);
|
}, [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
|
// Load auth status when the authentication section is active
|
||||||
const loadAuthStatus = useCallback(async () => {
|
const loadAuthStatus = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -531,6 +542,7 @@ 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(),
|
||||||
|
updateGlobalConcurrency({ globalMaxConcurrent }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
addToast("Settings saved", "success");
|
addToast("Settings saved", "success");
|
||||||
@@ -538,7 +550,7 @@ export function SettingsModal({
|
|||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
addToast(err.message, "error");
|
addToast(err.message, "error");
|
||||||
}
|
}
|
||||||
}, [form, prefixError, presetDraft, onClose, addToast, projectId]);
|
}, [form, globalMaxConcurrent, prefixError, presetDraft, onClose, addToast, projectId]);
|
||||||
|
|
||||||
const handleSaveMemory = useCallback(async () => {
|
const handleSaveMemory = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -1285,6 +1297,18 @@ export function SettingsModal({
|
|||||||
<>
|
<>
|
||||||
{renderScopeBanner()}
|
{renderScopeBanner()}
|
||||||
<h4 className="settings-section-heading">Scheduling</h4>
|
<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">
|
<div className="form-group">
|
||||||
<label htmlFor="maxConcurrent">Max Concurrent Tasks</label>
|
<label htmlFor="maxConcurrent">Max Concurrent Tasks</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -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
|
* GET /api/first-run-status
|
||||||
* Check if user has projects or needs setup wizard.
|
* Check if user has projects or needs setup wizard.
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ export class InProcessRuntime
|
|||||||
private missionExecutionLoop?: MissionExecutionLoop;
|
private missionExecutionLoop?: MissionExecutionLoop;
|
||||||
private missionAutopilot?: MissionAutopilot;
|
private missionAutopilot?: MissionAutopilot;
|
||||||
private triageProcessor?: TriageProcessor;
|
private triageProcessor?: TriageProcessor;
|
||||||
|
private concurrencyChangedListener?: (state: { globalMaxConcurrent: number }) => void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param config - Runtime configuration
|
* @param config - Runtime configuration
|
||||||
@@ -175,8 +176,19 @@ export class InProcessRuntime
|
|||||||
if (this.config.globalSemaphore) {
|
if (this.config.globalSemaphore) {
|
||||||
this.globalSemaphore = this.config.globalSemaphore;
|
this.globalSemaphore = this.config.globalSemaphore;
|
||||||
} else {
|
} else {
|
||||||
const globalLimit = await this.getGlobalConcurrencyLimit();
|
// Dynamic getter that re-reads from CentralCore on each semaphore acquire.
|
||||||
this.globalSemaphore = new AgentSemaphore(() => globalLimit);
|
// This ensures changes via PUT /api/global-concurrency take effect immediately.
|
||||||
|
let cachedLimit = await this.getGlobalConcurrencyLimit();
|
||||||
|
this.globalSemaphore = new AgentSemaphore(() => cachedLimit);
|
||||||
|
|
||||||
|
// Listen for concurrency changes from CentralCore (if it supports events)
|
||||||
|
if (typeof this.centralCore.on === "function") {
|
||||||
|
this.concurrencyChangedListener = (state: { globalMaxConcurrent: number }) => {
|
||||||
|
cachedLimit = state.globalMaxConcurrent;
|
||||||
|
runtimeLog.log(`Global concurrency limit updated to ${cachedLimit}`);
|
||||||
|
};
|
||||||
|
this.centralCore.on("concurrency:changed", this.concurrencyChangedListener);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Initialize Scheduler
|
// 5. Initialize Scheduler
|
||||||
@@ -549,7 +561,13 @@ export class InProcessRuntime
|
|||||||
runtimeLog.log(`Stopping InProcessRuntime for project ${this.config.projectId}`);
|
runtimeLog.log(`Stopping InProcessRuntime for project ${this.config.projectId}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Stop self-healing manager
|
// 1. Remove concurrency change listener (if we registered one)
|
||||||
|
if (this.concurrencyChangedListener && typeof this.centralCore.off === "function") {
|
||||||
|
this.centralCore.off("concurrency:changed", this.concurrencyChangedListener);
|
||||||
|
this.concurrencyChangedListener = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Stop self-healing manager
|
||||||
if (this.selfHealingManager) {
|
if (this.selfHealingManager) {
|
||||||
this.selfHealingManager.stop();
|
this.selfHealingManager.stop();
|
||||||
runtimeLog.log("SelfHealingManager stopped");
|
runtimeLog.log("SelfHealingManager stopped");
|
||||||
|
|||||||
Reference in New Issue
Block a user