diff --git a/.changeset/fn-9185-max-concurrent-effective-limit.md b/.changeset/fn-9185-max-concurrent-effective-limit.md new file mode 100644 index 0000000000..c4b16a6ee3 --- /dev/null +++ b/.changeset/fn-9185-max-concurrent-effective-limit.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Keep task concurrency settings and enforced capacity aligned. +category: fix +dev: Resolve configured and effective concurrency through the shared project-settings resolver. diff --git a/docs/architecture.md b/docs/architecture.md index 2c7a521a5e..50f2db9f4c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -653,7 +653,7 @@ See [Memory Plugin Contract](./memory-plugin-contract.md) for the full plan. ### Scheduling and execution - `Scheduler` (`scheduler.ts`) — dependency-aware task scheduling that dispatches eligible todo tasks by priority first, then dependency-unblock fanout within the same priority class (FN-4969), then FIFO (`createdAt` ascending) with task-id fallback. `urgent` always stays ahead of lower priorities, and overlap/file-scope blockers are excluded from fanout weighting. - - **Worktree-capacity admission (FN-8822):** scheduler execute, triage specify, project-engine merge, and direct workflow-planning continuation handoffs share one serialized project coordinator. Its ceiling is `min(maxConcurrent, maxWorktrees)` when worktree limiting is enabled and counts only canonical live task claims plus transient reservations—not retained directories, stale metadata, paused/terminal tasks, or orphans. A genuinely full cap persists a deduplicated queued reason with the `maxWorktrees` gate, used/limit, and holder IDs through ordinary task status/log APIs. Retained worktrees are deliberately non-destructive: cleanup and pooling preserve active, dirty, or uniquely committed work. + - **Worktree-capacity admission (FN-8822/FN-9185):** scheduler execute, triage specify, project-engine merge, and direct workflow-planning continuation handoffs share one serialized project coordinator. The core effective-concurrency resolver reads the live project-scoped settings blob (`TaskStore.getSettings()` or `getSettingsFast()`), yielding configured `maxConcurrent`, an optional worktree limit, the effective ceiling, and its binding knob. The ceiling is `min(maxConcurrent, maxWorktrees)` when worktree limiting is enabled and `maxConcurrent` otherwise; it counts only canonical live task claims plus transient reservations—not retained directories, stale metadata, paused/terminal tasks, or orphans. Registry snapshots are fallback-only, followed by shipped defaults. A genuinely full cap persists a deduplicated queued reason with the effective ceiling, binding knob, used/limit, and holder IDs through ordinary task status/log APIs. Retained worktrees are deliberately non-destructive: cleanup and pooling preserve active, dirty, or uniquely committed work. - `blockedBy` invariant (FN-3924/FN-4091): the field is only durable when it references a current unresolved explicit dependency (or, for dependency-free tasks, an active overlap blocker). Completion gating now validates `blockedBy` through live task resolution: missing blockers and blockers already in `done`/`archived` are treated as stale, while only still-active blockers continue to prevent `fn_task_done`. If no current blocker remains, scheduler/event reconciliation clears `blockedBy` to `null` and re-evaluates from live task state. - Dependency-cycle invariant (FN-5256): task dependency graphs are acyclic at write time (`DependencyCycleError` in `TaskStore` for `createTask`, `createTaskWithReservedId`, `updateTask`, and `applyReplicatedTaskCreate`) with `task:dependency-cycle-rejected` audit evidence. Self-healing batch 2 adds `reconcileDependencyCycles`, which emits `task:dependency-cycle-detected`, auto-repairs only bounded umbrella-back-edge loops via `task:auto-reconciled-dependency-cycle`, and leaves ambiguous cycles untouched with `task:dependency-cycle-unrepaired` for operator inspection. - Dependency-blocking lease invariant (FN-6292): an `in-progress` task with unmet scheduling dependencies must not contribute an active file-scope lease in scheduler lease maps. This prevents a holder from queueing its own dependency behind its lease and creating a circular wait. @@ -1181,7 +1181,7 @@ The run-audit system records every mutation performed by the engine across four Events are tied to specific run IDs for end-to-end traceability. -For scheduler concurrency diagnostics, the queued reason names the active limiter(s) and usage (for example `gate=maxConcurrent ...`). The reason includes the `bindingGates` (`maxConcurrent`/`maxWorktrees`/`semaphore`), per-gate `{ used, limit, slack }`, `holders`, and computed `available`. `maxConcurrent` is a per-project cap on enriched live top-level planning, execution, and review/merge agents; the host semaphore is the separate process-global pool. Each newly free project slot admits review/merge first, ready execution second, and planning last; oldest valid `createdAt` and task ID break ties only within that lifecycle lane. `maxWorktrees` is also enforced inside `TaskStore.moveTaskInternal` when committing an allocated move into `in-progress`, making it a hard active execution worktree cap even when workflow WIP/`maxConcurrent` would allow more tasks. These queued-reason logs are transition-only: a newly emitted line indicates the limiter signature changed or the condition cleared and later reappeared, not that a poll loop simply observed the same blocked state again. +For scheduler concurrency diagnostics, the queued reason names the active limiter(s) and usage (for example `gate=maxConcurrent ...`). It also states the resolver-derived `effectiveLimit` and `bindingKnob`, so an operator can see when `maxWorktrees` shadows a larger configured `maxConcurrent`. The reason includes the `bindingGates` (`maxConcurrent`/`maxWorktrees`/`semaphore`), per-gate `{ used, limit, slack }`, `holders`, and computed `available`. The authoritative read is the live project settings blob; only an unavailable project store may fall back to a registry snapshot, which then falls back to shipped defaults through the same resolver. `maxConcurrent` is a per-project cap on enriched live top-level planning, execution, and review/merge agents; the host semaphore is the separate process-global pool. Each newly free project slot admits review/merge first, ready execution second, and planning last; oldest valid `createdAt` and task ID break ties only within that lifecycle lane. `maxWorktrees` is also enforced inside `TaskStore.moveTaskInternal` when committing an allocated move into `in-progress`, making it a hard active execution worktree cap even when workflow WIP/`maxConcurrent` would allow more tasks. These queued-reason logs are transition-only: a newly emitted line indicates the limiter signature changed or the condition cleared and later reappeared, not that a poll loop simply observed the same blocked state again. **Run audit endpoints:** - `GET /api/agents/:id/runs/:runId/audit` — Returns audit trail for a specific agent run @@ -2462,3 +2462,7 @@ Workspace landing derives its repository obligations from confirmed repository s Every land and recovery door evaluates graph-owned pre-merge blockers before changing merge state, acquiring leases, or writing Git. Recovery uses the persisted transient merge counter and reports scheduling separately from observed finalization. Main-checkout committed-work detection requires task ownership after the repository baseline; historical task-ID commits, recorded landings, and foreign shared-checkout commits do not become task violations. A scope revision atomically clears both its approval fingerprints and Code Review remediation target; a current-scope approval likewise clears that target, so a successor cannot inherit a stale remediation coordinator. - **Bounded no-progress recovery (FN-9186):** `recoverNoProgressNoTaskDoneFailures()` spends the durable `taskDoneRetryCount` budget (maximum three) and writes the `recoveryRetryCount`/`nextRecoveryAt` display mirror using exponential backoff before requeuing a clean zero-progress failure. `recoveryRetryCount` cannot be the budget because terminal-failure recovery clears an expired mirror after a re-failure. On exhaustion the task stays failed with `NO_PROGRESS_REQUEUE_BUDGET_EXHAUSTED:`; the specific wedge descriptor prevents generic terminal-failure recovery from reopening it, and restart recovery also preserves the park. Manual Retry clears the error and counters to grant a fresh budget. + +### Effective task concurrency + +Task admission resolves capacity from the live project-scoped settings blob (`TaskStore.getSettings()` or `getSettingsFast()`): configured `maxConcurrent`, optional `maxWorktrees`, effective ceiling, and binding knob come from the shared core resolver. When worktree limiting is enabled, the effective ceiling is the lower of the two settings; when disabled, worktree capacity is structurally absent. Registry snapshots are fallback-only and resolve through the same defaults. Queued reasons and reporting APIs include the effective ceiling and binding knob. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index f1b0425e3f..3be9c8d56f 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -389,6 +389,7 @@ Remote actions support: > ⚠️ Remote URL/QR payloads include tokenized query data. Treat them like credentials and avoid sharing them in screenshots/chat/logs. Prefer short-lived links for ad-hoc phone login. Settings pane navigation and editing: +- The TUI shows the live project **Max Concurrent Tasks** and **Max Worktrees** values. Max Concurrent Tasks defaults to 2; when worktree limiting is on, admission uses the lower value and dashboard status surfaces identify the binding setting. - `Tab` switches focus between the settings list and the detail/edit pane. - In the settings list, `↑`/`↓` or `k`/`j` moves the selected setting. - In the detail/edit pane, `←`/`→` or `h`/`l` cycles enum values such as **Remote Provider**; `Space` toggles booleans; `+`/`-` adjusts numbers. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index f8e4348cd2..73c2673b8a 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -1419,6 +1419,10 @@ Features: Command Center is the combined analytics and live-operations surface for a project: it pairs historical usage, cost, throughput analytics, live system telemetry, and a live Mission Control panel. +### Task concurrency capacity + +**Settings → Scheduling**, Command Center controls, and the Engine Control menu all edit the same project-scoped **Max Concurrent Tasks** setting (range 1–50; default 2). When worktree limiting is on, Fusion shows the effective ceiling as the lower of Max Concurrent Tasks and Max Worktrees and names the setting that binds. Team status and the board's **Up Next** grouping use that effective ceiling, so they do not promise more parallel work than admission can start. + Navigation: - Desktop/tablet: primary header view toggle, immediately after **Agents** - Mobile: bottom nav tab, immediately after **Mailbox** diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 2b2239de9f..931a343230 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -481,7 +481,7 @@ Security-sensitive file-browser escape hatches are project-only. `allowAbsoluteF | `globalPause` | `boolean` | `false` | Hard stop: terminate active engine sessions and pause scheduling immediately. | | `globalPauseReason` | `string` | `undefined` | Optional reason for `globalPause` (`"rate-limit"` for automatic pauses, `"manual"` for user-triggered pauses). Cleared on unpause. | | `enginePaused` | `boolean` | `false` | Soft pause: stop dispatching new work while letting active sessions finish. While paused (including shared pause windows with `globalPause`), stuck-task polling/timers are suspended so paused wall-clock time does not count against `taskStuckTimeoutMs`. Clearing pause state resumes runtime scheduling and gives tracked active sessions a fresh stuck-task grace window before normal detection resumes; when `autoMerge` is enabled, eligible `in-review` tasks are re-swept into the auto-merge queue (paused/blocked/failed review tasks remain skipped). | -| `maxConcurrent` | `number` | `2` | Max concurrency for top-level working agents per project across planning, execution, and review/merge. Nested helper agents remain parent-internal and may temporarily exceed this displayed count. Editable from Settings, Command Center, and Engine Control. | +| `maxConcurrent` | `number` | `2` | Configured top-level working-agent concurrency per project across planning, execution, and review/merge. Range **1–50** in Settings, Command Center, and Engine Control. The enforced effective ceiling is the lower of this setting and `maxWorktrees` while worktree limiting is enabled; reporting surfaces expose `effectiveMaxConcurrent` and `concurrencyBindingKnob` so a worktree-bound cap is visible. | | `maxRecommendationsPerTask` | `number` | `3` | Project-scoped maximum accepted completion recommendations per task. Integers **0–20** only; `0` disables recommendation capture and `1–20` bounds task-ready out-of-scope suggestions. | | `requireTaskRecommendations` | `boolean` | `false` | Project-only opt-in. When `true` and `maxRecommendationsPerTask` is positive, every accepted successful completion must explicitly submit a recommendation array. The executor aims toward the cap using grounded, distinct, task-ready findings, but fewer recommendations or `[]` is correct when relevance does not support more; filler, duplicates, restatements, speculation, and scope drift are never valid. Cap `0` remains authoritative and disables capture, so this toggle cannot request a payload. Reset clears the project override back to `false`. | @@ -489,7 +489,7 @@ Security-sensitive file-browser escape hatches are project-only. `allowAbsoluteF | `maxConcurrentVerifications` | `number` | `1` | Max concurrent verification subprocesses (`fn_run_verification`, merge test/build commands) process-wide. Caps stacked monorepo typecheck/build so concurrent tasks do not peg host CPU. Range **1–8** (clamped at runtime and in Settings). Editable from Settings → Scheduling. Each project engine registers its cap; the effective process limit is the **minimum** of registered project caps. | | `maxTriageConcurrent` | `number` | `2` | Legacy persisted value; ignored. Planning shares `maxConcurrent` and no Max Triage control is displayed. | | `globalMaxConcurrent` | `number` | `4` | System-wide max concurrent agents across all projects. | -| `maxWorktrees` | `number` | `4` | Max simultaneous **live task claims** when worktree limiting is enabled, shared by scheduler execution, triage planning, project-engine merge, and workflow-continuation admission. Retained worktree directories for queued, paused, terminal, or missing tasks do not consume this cap. A real full cap leaves the candidate queued with a task-log/status reason naming `maxWorktrees`, used/limit, and live holders. Editable from Settings and the Command Center Overview controls dashboard. | +| `maxWorktrees` | `number` | `4` | Max simultaneous **live task claims** when worktree limiting is enabled, shared by scheduler execution, triage planning, project-engine merge, and workflow-continuation admission. Retained worktree directories for queued, paused, terminal, or missing tasks do not consume this cap. When it is at or below `maxConcurrent`, it is the effective concurrency ceiling and named as the binding knob in queued reasons, `/config`, and `/executor/stats`. Editable from Settings and the Command Center Overview controls dashboard. | | `pollIntervalMs` | `number` | `15000` | Scheduler poll interval (ms). | | `heartbeatMultiplier` | `number` | `1` | Global multiplier applied to agent heartbeat timing: both heartbeat intervals and unresponsive timeout bases. Configured from the Agents screen (not Settings). | | `heartbeatScopeDiscipline` | `"strict" \| "lite" \| "off"` | `"strict"` | Heartbeat prompt procedure mode. `strict` keeps coordination-heavy scope discipline, `lite` restores pre-2026-05-11 wording, and `off` uses a minimal procedure. Per-agent `runtimeConfig.heartbeatScopeDiscipline` can override this default. | diff --git a/packages/cli/src/commands/__tests__/dashboard-concurrency-settings.test.ts b/packages/cli/src/commands/__tests__/dashboard-concurrency-settings.test.ts new file mode 100644 index 0000000000..6c09ace623 --- /dev/null +++ b/packages/cli/src/commands/__tests__/dashboard-concurrency-settings.test.ts @@ -0,0 +1,166 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const harness = vi.hoisted(() => { + let settings: Record = {}; + let latestTui: FakeDashboardTui | undefined; + + class FakeDashboardTui { + settingsPayloads: Array> = []; + callbacks: Record = {}; + boardScopedProjectPath: string | null = null; + + constructor() { + latestTui = this; + } + + start = vi.fn(async () => undefined); + stop = vi.fn(async () => undefined); + setLoadingStatus = vi.fn(); + setSystemInfo = vi.fn(); + setReady = vi.fn(); + setTaskStats = vi.fn(); + setInteractiveData = vi.fn(); + onBoardScopeChange = vi.fn(); + hydrateVitestKillSettings = vi.fn(); + log = vi.fn(); + setCallbacks = vi.fn((callbacks: Record) => { this.callbacks = callbacks; }); + setSettings = vi.fn((payload: Record) => { this.settingsPayloads.push(payload); }); + } + + class FakeLogSink { + setTUI = vi.fn(); + captureConsole = vi.fn(); + log = vi.fn(); + warn = vi.fn(); + error = vi.fn(); + getRecentEntries = vi.fn(() => []); + } + + const listeners = new Map void>>(); + const store: Record = { + on: (event: string, listener: (...args: any[]) => void) => { + listeners.set(event, [...(listeners.get(event) ?? []), listener]); + return store; + }, + listenerCount: (event: string) => listeners.get(event)?.length ?? 0, + }; + Object.assign(store, { + init: vi.fn(async () => undefined), + watch: vi.fn(async () => undefined), + close: vi.fn(async () => undefined), + getAsyncLayer: vi.fn(() => ({})), + getFusionDir: vi.fn(() => "/repo/.fusion"), + getRootDir: vi.fn(() => "/repo"), + getSettings: vi.fn(async () => settings), + getGlobalSettingsStore: vi.fn(() => ({ getSettings: vi.fn(async () => ({})), updateSettings: vi.fn(async () => undefined) })), + getPluginStore: vi.fn(() => ({ init: vi.fn(async () => undefined) })), + healthCheck: vi.fn(async () => ({ ok: true })), + isBackendMode: vi.fn(() => false), + listTasks: vi.fn(async () => []), + }); + + const appListeners = new Map void>>(); + const app: Record = { + on: (event: string, listener: (...args: any[]) => void) => { + appListeners.set(event, [...(appListeners.get(event) ?? []), listener]); + return app; + }, + }; + Object.assign(app, { + listen: vi.fn(() => { + queueMicrotask(() => appListeners.get("listening")?.forEach((listener) => listener())); + return app; + }), + address: vi.fn(() => ({ port: 0 })), + close: vi.fn(), + }); + + return { + FakeDashboardTui, + FakeLogSink, + app, + store, + getSettings: () => settings, + latestTui: () => latestTui, + setSettings: (next: Record) => { settings = next; }, + reset: () => { + settings = {}; + latestTui = undefined; + vi.clearAllMocks(); + }, + }; +}); + +vi.mock("@fusion/core", async (importOriginal) => { + const actual = await importOriginal(); + class NoopStore { + init = vi.fn(async () => undefined); + on = vi.fn(); + close = vi.fn(async () => undefined); + } + return { + ...actual, + createTaskStoreForBackend: vi.fn(async () => ({ taskStore: harness.store, shutdown: vi.fn(async () => undefined) })), + AutomationStore: NoopStore, + AgentStore: class extends NoopStore { listAgents = vi.fn(async () => []); }, + PluginLoader: class { loadAllPlugins = vi.fn(async () => ({ loaded: 0, errors: 0 })); getPluginSkills = vi.fn(() => []); }, + MissionStore: NoopStore, + setHostTaskStore: vi.fn(), + setDiagnosticDbHealthCheck: vi.fn(), + setDiagnosticStoreListenerCheck: vi.fn(), + }; +}); + +vi.mock("@fusion/dashboard", async (importOriginal) => ({ + ...(await importOriginal()), + createServer: vi.fn(() => harness.app), + refreshAllCustomProviderModels: vi.fn(async () => ({ refreshed: 0, failed: 0, skipped: 0 })), + stopAllDevServers: vi.fn(async () => undefined), +})); + +vi.mock("../dashboard-tui/index.js", () => ({ + DashboardTUI: harness.FakeDashboardTui, + DashboardLogSink: harness.FakeLogSink, + isTTYAvailable: vi.fn(() => true), +})); + +vi.mock("../dashboard-startup-chain.js", () => ({ + DASHBOARD_STARTUP_STATUS: { + initializingTaskStore: "Initializing task store…", + initializingAgentStore: "Initializing agent store…", + startingAgents: "Starting agents…", + loadingExtensions: "Loading extensions…", + startingEngine: "Starting engine…", + }, + runTuiStartupPrelude: vi.fn(async (tui: { start: () => Promise; setLoadingStatus: (status: string) => void }) => { + await tui.start(); + tui.setLoadingStatus("Initializing task store…"); + }), +})); + +const { runDashboard } = await import("../dashboard.js"); + +/** + * FNXC:CapacityModel 2026-08-21-17:43: + * The reported console mismatch was in dashboard startup, not the standalone mapping helper. + * Run the real TTY branch with a controlled live store and capture DashboardTUI.setSettings so + * future callback changes cannot reintroduce private defaults or bypass the shared resolver. + */ +describe("dashboard TUI concurrency settings", () => { + beforeEach(() => harness.reset()); + + it.each([ + ["unset", {}, { maxConcurrent: 2, maxWorktrees: 4 }], + ["configured", { maxConcurrent: 6, maxWorktrees: 9 }, { maxConcurrent: 6, maxWorktrees: 9 }], + ["worktree-bound", { maxConcurrent: 8, maxWorktrees: 4, worktreeLimitEnabled: true }, { maxConcurrent: 8, maxWorktrees: 4 }], + ])("hydrates %s live settings through runDashboard's TUI setter", async (_state, settings, expected) => { + harness.setSettings(settings); + + await runDashboard(0, { noEngine: true, noAuth: true }); + await new Promise((resolve) => setImmediate(resolve)); + + const tui = harness.latestTui(); + expect(harness.store.getSettings).toHaveBeenCalled(); + expect(tui?.setSettings).toHaveBeenCalledWith(expect.objectContaining(expected)); + }); +}); diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index 2cdfe0f374..7738f121c8 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -37,9 +37,21 @@ import { isPostgresUniqueError, ProjectPartitionRekeyError, resolveTaskLifecycleColumns, + DEFAULT_PROJECT_SETTINGS, + resolveEffectiveConcurrency, + resolveWorktreeCapacityLimit, type WorkflowIr, } from "@fusion/core"; +export function mapTuiConcurrencySettings(settings: Record | null | undefined): { maxConcurrent: number; maxWorktrees: number } { + const capacity = resolveEffectiveConcurrency(settings); + return { + maxConcurrent: capacity.maxConcurrent, + // FNXC:CapacityModel 2026-08-21-16:18: TUI settings display the configured worktree value even when its admission gate is off. + maxWorktrees: resolveWorktreeCapacityLimit({ ...(settings ?? {}), worktreeLimitEnabled: true })!, + }; +} + /* FNXC:WorkflowLifecycleColumns 2026-08-02-08:50 (fleet: CLI dashboard/serve stats): "ACTIVE" IS THE BOARD'S WIP AND REVIEW LANES, counted once for a whole task list. @@ -902,8 +914,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: const fullSettings = await store.getSettings(); // Return SettingsValues subset for TUI return { - maxConcurrent: fullSettings.maxConcurrent ?? 1, - maxWorktrees: fullSettings.maxWorktrees ?? 2, + ...mapTuiConcurrencySettings(fullSettings), autoMerge: fullSettings.autoMerge ?? false, mergeStrategy: fullSettings.mergeStrategy ?? "direct", pollIntervalMs: fullSettings.pollIntervalMs ?? 60_000, @@ -915,8 +926,8 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: }; } return { - maxConcurrent: 1, - maxWorktrees: 2, + maxConcurrent: DEFAULT_PROJECT_SETTINGS.maxConcurrent, + maxWorktrees: DEFAULT_PROJECT_SETTINGS.maxWorktrees, autoMerge: false, mergeStrategy: "direct", pollIntervalMs: 60_000, @@ -1259,8 +1270,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: try { const settings = await store.getSettings(); tui.setSettings({ - maxConcurrent: settings.maxConcurrent ?? 1, - maxWorktrees: settings.maxWorktrees ?? 2, + ...mapTuiConcurrencySettings(settings), autoMerge: settings.autoMerge ?? false, mergeStrategy: settings.mergeStrategy ?? "direct", pollIntervalMs: settings.pollIntervalMs ?? 60_000, @@ -3138,8 +3148,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: applyTunnelUrl(); tui.setReady(true); tui.setSettings({ - maxConcurrent: settings.maxConcurrent ?? 1, - maxWorktrees: settings.maxWorktrees ?? 2, + ...mapTuiConcurrencySettings(settings), autoMerge: settings.autoMerge ?? false, mergeStrategy: settings.mergeStrategy ?? "direct", pollIntervalMs: settings.pollIntervalMs ?? 60_000, @@ -3288,8 +3297,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: getSettings: async () => { const s = await store.getSettings(); return { - maxConcurrent: s.maxConcurrent ?? 1, - maxWorktrees: s.maxWorktrees ?? 2, + ...mapTuiConcurrencySettings(s), autoMerge: s.autoMerge ?? false, mergeStrategy: s.mergeStrategy ?? "direct", pollIntervalMs: s.pollIntervalMs ?? 60_000, diff --git a/packages/cli/src/commands/onboard.ts b/packages/cli/src/commands/onboard.ts index 7a85130b0f..34d79c87a6 100644 --- a/packages/cli/src/commands/onboard.ts +++ b/packages/cli/src/commands/onboard.ts @@ -1,7 +1,7 @@ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; import { dirname } from "node:path"; import { createInterface } from "node:readline"; -import { CentralCore, GlobalSettingsStore, getDefaultCentralDbPath } from "@fusion/core"; +import { resolveEffectiveConcurrency, CentralCore, GlobalSettingsStore, getDefaultCentralDbPath } from "@fusion/core"; import { createFusionAuthStorage, createFusionModelRegistry } from "@fusion/engine"; import { resolveProject } from "../project-context.js"; import { promptOutputStream } from "../output.js"; @@ -405,7 +405,7 @@ export async function runOnboard(options: OnboardOptions = {}): Promise { if (projectContext) { const rawMaxConcurrent = await prompts.prompt( "Set maxConcurrent for this project", - String((await projectContext.store.getSettings()).maxConcurrent ?? 2), + String(resolveEffectiveConcurrency(await projectContext.store.getSettings()).maxConcurrent), ); const maxConcurrent = validateMaxConcurrent(rawMaxConcurrent); await projectContext.store.updateSettings({ maxConcurrent }); diff --git a/packages/cli/src/project-resolver.ts b/packages/cli/src/project-resolver.ts index 12ef636b27..7be6529f6a 100644 --- a/packages/cli/src/project-resolver.ts +++ b/packages/cli/src/project-resolver.ts @@ -13,6 +13,7 @@ import { basename, dirname, join, normalize, resolve } from "node:path"; import { createInterface } from "node:readline/promises"; import { promptOutputStream, result as outputResult } from "./output.js"; import { + resolveEffectiveConcurrency, CentralCore, createTaskStoreForBackend, hasProjectIdentity, @@ -926,13 +927,33 @@ export async function startProjectRuntime(projectId: string): Promise { + it("uses shipped defaults for absent values", () => { + expect(resolveEffectiveConcurrency(undefined)).toEqual({ + maxConcurrent: DEFAULT_PROJECT_SETTINGS.maxConcurrent, + worktreeLimit: DEFAULT_PROJECT_SETTINGS.maxWorktrees, + effectiveLimit: DEFAULT_PROJECT_SETTINGS.maxConcurrent, + bindingKnob: "maxConcurrent", + }); + }); + + it.each([0, -3, Number.NaN, Infinity, "2", null])("rejects invalid maxConcurrent %j", (maxConcurrent) => { + expect(resolveMaxConcurrentSetting({ maxConcurrent } as never)).toBe(DEFAULT_PROJECT_SETTINGS.maxConcurrent); + }); + + it("honors configured values and names a binding worktree limit", () => { + expect(resolveEffectiveConcurrency({ maxConcurrent: 8, maxWorktrees: 4, worktreeLimitEnabled: true })).toEqual({ + maxConcurrent: 8, + worktreeLimit: 4, + effectiveLimit: 4, + bindingKnob: "maxWorktrees", + }); + }); + + it("makes the worktree dimension structurally absent when disabled", () => { + expect(resolveEffectiveConcurrency({ maxConcurrent: 8, maxWorktrees: 4, worktreeLimitEnabled: false })).toEqual({ + maxConcurrent: 8, + worktreeLimit: null, + effectiveLimit: 8, + bindingKnob: "maxConcurrent", + }); + }); + + it("keeps exported capacity defaults aligned with shipped settings", () => { + expect(DEFAULT_MAX_CONCURRENT).toBe(DEFAULT_PROJECT_SETTINGS.maxConcurrent); + expect(DEFAULT_MAX_WORKTREES).toBe(DEFAULT_PROJECT_SETTINGS.maxWorktrees); + }); +}); diff --git a/packages/core/src/__tests__/worktree-capacity-limit.test.ts b/packages/core/src/__tests__/worktree-capacity-limit.test.ts index 3ea58656ae..8771cc5de4 100644 --- a/packages/core/src/__tests__/worktree-capacity-limit.test.ts +++ b/packages/core/src/__tests__/worktree-capacity-limit.test.ts @@ -76,11 +76,9 @@ describe("worktrees-off is structural: no unaudited maxWorktrees bound", () => { */ const AUDITED_BOUNDS: Array<{ file: string; expr: string; reason: string }> = [ { - file: "packages/engine/src/scheduler.ts", - expr: "settings.maxWorktrees ?? this.options.maxWorktrees ?? 4", - reason: - "THE admission gate's limit read, and the ONLY one: it feeds resolveWorktreeCapacityLimit, whose " - + "gate snapshot is optional so OFF mode constructs no gate at all.", + file: "packages/core/src/workflows/workflow-capacity.ts", + expr: "return typeof limit === \"number\" && Number.isFinite(limit) && limit > 0", + reason: "The canonical resolver rejects zero and invalid persisted worktree values before any admission reader can observe them.", }, { file: "packages/engine/src/scheduler.ts", @@ -169,10 +167,9 @@ describe("worktrees-off is structural: no unaudited maxWorktrees bound", () => { logic, new home. */ file: "packages/engine/src/concurrency/concurrency.ts", - expr: "worktreeLimit !== null && worktreeLimit <= params.maxConcurrent", + expr: "return resolveEffectiveConcurrency(params).effectiveLimit;", reason: - "Binding-gate discriminator after resolveWorktreeCapacityLimit: null means worktrees are not a " - + "capacity dimension, so this arm cannot bind in OFF mode.", + "The shared engine admission entry delegates to the canonical core resolver, preserving structural absence in OFF mode.", }, { file: "packages/engine/src/triage.ts", @@ -317,6 +314,37 @@ describe("worktrees-off is structural: no unaudited maxWorktrees bound", () => { } }); + it("rejects private numeric fallbacks for shared concurrency settings", async () => { + const { execFileSync } = await import("node:child_process"); + const { resolve } = await import("node:path"); + const root = resolve(__dirname, "../../../.."); + const allowlisted = new Set([ + "packages/core/src/central/central-core.ts", // mesh node capacity, not project admission + "packages/dashboard/src/routes/register-docker-provisioning-routes.ts", // provisioned node capacity + "packages/engine/src/self-healing.ts", // disk-hygiene retention cap + ]); + const files = execFileSync("git", ["ls-files", "--", "packages", "plugins"], { cwd: root, encoding: "utf-8" }) + .split("\n") + .filter((file) => /\/src\//.test(file) && /\.tsx?$/.test(file) && !file.includes("__tests__")); + const { readFileSync } = await import("node:fs"); + const offenders = files.flatMap((file) => { + if (allowlisted.has(file)) return []; + return readFileSync(resolve(root, file), "utf-8").split("\n").flatMap((line, index) => + /\b(maxConcurrent|maxWorktrees)\s*\?\?\s*\d/.test(line) ? [`${file}:${index + 1}: ${line.trim()}`] : [], + ); + }); + expect(offenders, "capacity defaults must resolve through resolveEffectiveConcurrency").toEqual([]); + }); + + it("rejects reporting routes that emit a bare maxConcurrent literal", async () => { + const { readFileSync } = await import("node:fs"); + const { resolve } = await import("node:path"); + const root = resolve(__dirname, "../../../.."); + const projectRoutes = readFileSync(resolve(root, "packages/dashboard/src/routes/register-project-routes.ts"), "utf-8"); + expect(projectRoutes).toContain("getSettingsFast()"); + expect(projectRoutes).not.toMatch(/maxConcurrent\s*:\s*\d/); + }); + it("admission has exactly the known worktree-limit readers", async () => { /* FNXC:WorktreeCapacity 2026-08-03-02:01: @@ -326,15 +354,14 @@ describe("worktrees-off is structural: no unaudited maxWorktrees bound", () => { const { execFileSync } = await import("node:child_process"); const { resolve } = await import("node:path"); const root = resolve(__dirname, "../../../.."); - // Call sites only (exclude the definition, the barrel re-exports, and prose). + // Admissions now delegate to the core effective-concurrency resolver rather than + // constructing partial worktree setting objects at each lane. const hits = execFileSync( "git", - ["grep", "-n", "resolveWorktreeCapacityLimit({", "--", "packages"], + ["grep", "-n", "resolveActiveTaskCapacityLimit(settings)", "--", "packages/engine/src"], { cwd: root, encoding: "utf-8" }, ).split("\n").filter((l) => l && !l.includes("__tests__")); - expect(hits.length, `expected two admission readers, got:\n${hits.join("\n")}`).toBe(2); - expect(hits.some((h) => h.includes("packages/engine/src/scheduler.ts"))).toBe(true); expect(hits.some((h) => h.includes("packages/engine/src/triage.ts"))).toBe(true); }); }); diff --git a/packages/core/src/index.gate.ts b/packages/core/src/index.gate.ts index 712ac621f6..bbb85994d5 100644 --- a/packages/core/src/index.gate.ts +++ b/packages/core/src/index.gate.ts @@ -467,7 +467,7 @@ export { } from "./plugins/plugin-gate-verdict.js"; export type { PluginGateVerdict, ColumnPluginGate } from "./plugins/plugin-gate-verdict.js"; // ── U6: workflow capacity (WIP) resolution shared by store + sweep ─────────── -export { resolveColumnCapacity, DEFAULT_WORKFLOW_POOL_ID, resolveCapacityPoolId, resolveWorktreeCapacityLimit} from "./workflows/workflow-capacity.js"; +export { resolveColumnCapacity, DEFAULT_WORKFLOW_POOL_ID, resolveCapacityPoolId, resolveWorktreeCapacityLimit, resolveMaxConcurrentSetting, resolveEffectiveConcurrency, DEFAULT_MAX_CONCURRENT, DEFAULT_MAX_WORKTREES } from "./workflows/workflow-capacity.js"; export type { ColumnCapacity } from "./workflows/workflow-capacity.js"; // ── U5: workflow lifecycle reconciliation (switch / edit / delete) ─────────── export { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 337b787297..22da9dee23 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -524,7 +524,7 @@ export { } from "./plugins/plugin-gate-verdict.js"; export type { PluginGateVerdict, ColumnPluginGate } from "./plugins/plugin-gate-verdict.js"; // ── U6: workflow capacity (WIP) resolution shared by store + sweep ─────────── -export { resolveColumnCapacity, resolveWipBudgetColumns, DEFAULT_WORKFLOW_POOL_ID, resolveCapacityPoolId, resolveWorktreeCapacityLimit} from "./workflows/workflow-capacity.js"; +export { resolveColumnCapacity, resolveWipBudgetColumns, DEFAULT_WORKFLOW_POOL_ID, resolveCapacityPoolId, resolveWorktreeCapacityLimit, resolveMaxConcurrentSetting, resolveEffectiveConcurrency, DEFAULT_MAX_CONCURRENT, DEFAULT_MAX_WORKTREES } from "./workflows/workflow-capacity.js"; export { createWorkflowEventBus, getWorkflowEventBus, emitWorkflowLifecycleEvent, resetWorkflowEventBusForTesting } from "./workflow-events.js"; export { IMPLEMENTATION_EXITS } from "./types/workflow-events.js"; export type { ImplementationExit } from "./types/workflow-events.js"; diff --git a/packages/core/src/workflows/workflow-capacity.ts b/packages/core/src/workflows/workflow-capacity.ts index 91def84149..11992d2cdf 100644 --- a/packages/core/src/workflows/workflow-capacity.ts +++ b/packages/core/src/workflows/workflow-capacity.ts @@ -20,7 +20,7 @@ * in-txn check arbitrates (two holds, one slot → one wins). */ -import type { Settings } from "../types.js"; +import { DEFAULT_PROJECT_SETTINGS, type Settings } from "../types.js"; import type { WorkflowIr, WorkflowIrV2, WorkflowIrColumn } from "./workflow-ir-types.js"; import { DEFAULT_WORKFLOW_COLUMN_IDS } from "./workflow-ir.js"; import { getTraitRegistry } from "./trait-registry.js"; @@ -29,8 +29,14 @@ import { getTraitRegistry } from "./trait-registry.js"; * `settings.maxConcurrent` (the legacy "N agents in-progress" gate). */ const DEFAULT_WIP_COLUMN_ID = "in-progress"; -/** Fallback when `maxWorktrees` is unset. Matches `DEFAULT_SETTINGS.maxWorktrees`. */ -const DEFAULT_MAX_WORKTREES = 4; +/** Shipped worktree default; kept as an export for capacity consumers and tests. */ +export const DEFAULT_MAX_WORKTREES = DEFAULT_PROJECT_SETTINGS.maxWorktrees; + +/** Shipped agent-concurrency default. */ +export const DEFAULT_MAX_CONCURRENT = DEFAULT_PROJECT_SETTINGS.maxConcurrent; + +/** Settings-like input accepted by every live, fast, and fallback capacity reader. */ +export type ConcurrencySettingsInput = Partial> | Record | null | undefined; /* FNXC:CapacityModel 2026-07-28-11:20: @@ -81,12 +87,47 @@ silently remove the disk bound altogether, which is a leak, not a simplification `maxWorktrees` must be named with a reason, so a future raw admission bound fails instead of quietly re-limiting a project that turned worktrees off. */ -export function resolveWorktreeCapacityLimit( - settings: Pick | undefined, -): number | null { +export function resolveWorktreeCapacityLimit(settings: ConcurrencySettingsInput): number | null { if (settings?.worktreeLimitEnabled === false) return null; const limit = settings?.maxWorktrees; - return typeof limit === "number" && Number.isFinite(limit) ? limit : DEFAULT_MAX_WORKTREES; + return typeof limit === "number" && Number.isFinite(limit) && limit > 0 + ? limit + : DEFAULT_MAX_WORKTREES; +} + +/* +FNXC:CapacityModel 2026-08-21-15:25: +FN-9185 consolidates the operator-reported max-concurrent mismatch into one resolver. +The live project settings blob is authoritative; registry snapshots and boot options are +fallback-only inputs, so every surface falls back to shipped defaults rather than private literals. +*/ +export function resolveMaxConcurrentSetting(settings: ConcurrencySettingsInput): number { + const value = settings?.maxConcurrent; + return typeof value === "number" && Number.isFinite(value) && value > 0 + ? value + : DEFAULT_MAX_CONCURRENT; +} + +export interface EffectiveConcurrency { + maxConcurrent: number; + worktreeLimit: number | null; + effectiveLimit: number; + bindingKnob: "maxConcurrent" | "maxWorktrees"; +} + +/** Resolves the configured capacity and its visible, enforced effective ceiling. */ +export function resolveEffectiveConcurrency(settings: ConcurrencySettingsInput): EffectiveConcurrency { + const maxConcurrent = resolveMaxConcurrentSetting(settings); + const worktreeLimit = resolveWorktreeCapacityLimit(settings); + const bindingKnob = worktreeLimit !== null && worktreeLimit <= maxConcurrent + ? "maxWorktrees" + : "maxConcurrent"; + return { + maxConcurrent, + worktreeLimit, + effectiveLimit: worktreeLimit === null ? maxConcurrent : Math.min(maxConcurrent, worktreeLimit), + bindingKnob, + }; } /** U6 (KTD-10): sentinel effective-workflow id for default-workflow @@ -202,13 +243,11 @@ export function resolveColumnCapacity( if (configLimit !== undefined) { limit = configLimit; } else if (limitSetting === "maxConcurrent") { - const maxConcurrent = settings?.maxConcurrent; - limit = typeof maxConcurrent === "number" && Number.isFinite(maxConcurrent) ? maxConcurrent : 2; + limit = resolveMaxConcurrentSetting(settings); } else if (columnId === DEFAULT_WIP_COLUMN_ID && isDefaultWorkflowColumns(ir)) { // Read-through: legacy maxConcurrent maps onto the default workflow's // in-progress WIP limit (U6 scheduler integration). - const maxConcurrent = settings?.maxConcurrent; - limit = typeof maxConcurrent === "number" && Number.isFinite(maxConcurrent) ? maxConcurrent : 2; + limit = resolveMaxConcurrentSetting(settings); } else { limit = Infinity; } diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 6dcf26e53a..cf9fbcee42 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -845,6 +845,7 @@ function AppInner() { // Settings state const { maxConcurrent, + effectiveMaxConcurrent, autoMerge, mergeStrategy, planAutoApproveEnabled, @@ -1740,6 +1741,7 @@ function AppInner() { mainPanelDetailTask, filteredBoardTasks, maxConcurrent, + effectiveMaxConcurrent, showWorktreeGrouping, moveTask, pauseTask, diff --git a/packages/dashboard/app/__tests__/concurrency-ui-surfaces.test.tsx b/packages/dashboard/app/__tests__/concurrency-ui-surfaces.test.tsx new file mode 100644 index 0000000000..578c4f75a2 --- /dev/null +++ b/packages/dashboard/app/__tests__/concurrency-ui-surfaces.test.tsx @@ -0,0 +1,155 @@ +// @vitest-environment jsdom + +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Column } from "../components/Column"; +import { EngineControlMenu } from "../components/EngineControlMenu"; +import { CommandCenterControls } from "../components/command-center/CommandCenterControls"; +import { TeamArea } from "../components/command-center/areas/TeamArea"; +import type { Task } from "@fusion/core"; + +const worktreeGroupProps = vi.hoisted(() => [] as Array<{ label: string; queuedTasks: Task[] }>); +const api = vi.hoisted(() => ({ + fetchConfig: vi.fn(), + fetchSettings: vi.fn(), + updateSettings: vi.fn(), + fetchExecutorStats: vi.fn(), + fetchOrgTree: vi.fn(), + t: (_key: string, fallback: string) => fallback, +})); + +vi.mock("../api/legacy", () => api); +vi.mock("../api", () => ({ + fetchBoardWorkflows: vi.fn().mockResolvedValue({ + flagEnabled: true, + defaultWorkflowId: "builtin:coding", + workflows: [{ id: "builtin:coding", name: "Coding", columns: [{ id: "todo", name: "Todo", flags: { hold: true } }] }], + taskWorkflowIds: {}, + }), + fetchWorkflowSteps: vi.fn().mockResolvedValue([]), + promoteTask: vi.fn().mockResolvedValue({}), +})); +vi.mock("../components/WorktreeGroup", () => ({ + WorktreeGroup: (props: { label: string; queuedTasks: Task[] }) => { + worktreeGroupProps.push(props); + return
{props.queuedTasks.length}
; + }, +})); +vi.mock("../components/TaskCard", () => ({ TaskCard: () =>
})); +vi.mock("../hooks/useAppSettings", () => ({ + useAppSettings: () => ({ globalPaused: false, enginePaused: false, toggleGlobalPause: vi.fn(), toggleEnginePause: vi.fn(), refresh: vi.fn() }), +})); +vi.mock("../hooks/useConfirm", () => ({ useConfirm: () => ({ confirm: vi.fn().mockResolvedValue(true) }) })); +vi.mock("../hooks/useGlobalConcurrency", () => ({ + useGlobalConcurrency: () => ({ status: "idle", currentlyActive: 0, projectActiveCount: () => 0 }), +})); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: api.t }), + initReactI18next: { type: "3rdParty", init: () => undefined }, +})); +vi.mock("../components/command-center/areas/useAnalyticsArea", () => ({ + useAnalyticsArea: () => ({ data: { agents: [], totals: { tokens: 0, cost: 0, filesChanged: 0, tasksCompleted: 0 } }, isLoading: false, error: null }), +})); +vi.mock("../components/ThemeDropdown", () => ({ ThemeDropdown: () =>
})); + +const task = (id: string, column = "todo"): Task => ({ id, description: "", column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-01-01", updatedAt: "2026-01-01" }); + +describe("dashboard concurrency surface data", () => { + beforeEach(() => { + api.fetchSettings.mockResolvedValue({ heartbeatMultiplier: 1 }); + api.updateSettings.mockResolvedValue({}); + api.fetchExecutorStats.mockResolvedValue({ globalPause: false, enginePaused: false, maxConcurrent: 8, effectiveMaxConcurrent: 4, concurrencyBindingKnob: "maxWorktrees" }); + api.fetchOrgTree.mockResolvedValue([]); + }); + + afterEach(() => { + vi.clearAllMocks(); + worktreeGroupProps.length = 0; + }); + + it("renders configured values through both editable control surfaces", async () => { + api.fetchSettings.mockResolvedValue({ maxConcurrent: 6, maxWorktrees: 9, worktreeLimitEnabled: true }); + api.fetchConfig.mockResolvedValue({ maxConcurrent: 6, maxWorktrees: 9, effectiveMaxConcurrent: 6, concurrencyBindingKnob: "maxConcurrent" }); + const { getByTestId } = render(<> + {}} onThemeModeChange={() => {}} /> + + ); + + fireEvent.click(getByTestId("engine-control-menu-trigger")); + const commandCenter = within(getByTestId("cc-controls-concurrency")); + const engineControls = within(getByTestId("engine-control-menu")); + const commandCenterInput = commandCenter.getAllByRole("slider")[0] as HTMLInputElement; + const engineControlsInput = engineControls.getAllByRole("slider")[0] as HTMLInputElement; + await waitFor(() => { + expect(commandCenterInput.value).toBe("6"); + expect(engineControlsInput.value).toBe("6"); + }); + expect(commandCenterInput.max).toBe("50"); + expect(engineControlsInput.max).toBe("50"); + }); + + it("renders worktree-bound values through both editable control surfaces", async () => { + api.fetchSettings.mockResolvedValue({ maxConcurrent: 8, maxWorktrees: 4, worktreeLimitEnabled: true }); + const { getByTestId } = render(<> + {}} onThemeModeChange={() => {}} /> + + ); + + fireEvent.click(getByTestId("engine-control-menu-trigger")); + const commandCenterInputs = within(getByTestId("cc-controls-concurrency")).getAllByRole("slider") as HTMLInputElement[]; + const engineControlInputs = within(getByTestId("engine-control-menu")).getAllByRole("slider") as HTMLInputElement[]; + await waitFor(() => { + expect(commandCenterInputs[0].value).toBe("8"); + expect(engineControlInputs[0].value).toBe("8"); + }); + expect(commandCenterInputs[1].value).toBe("4"); + expect(engineControlInputs[1].value).toBe("4"); + }); + + it("renders shipped resolver defaults through both editable control surfaces", async () => { + api.fetchSettings.mockResolvedValue({}); + const { getByTestId } = render(<> + {}} onThemeModeChange={() => {}} /> + + ); + + fireEvent.click(getByTestId("engine-control-menu-trigger")); + const commandCenterInputs = within(getByTestId("cc-controls-concurrency")).getAllByRole("slider") as HTMLInputElement[]; + const engineControlInputs = within(getByTestId("engine-control-menu")).getAllByRole("slider") as HTMLInputElement[]; + await waitFor(() => { + expect(commandCenterInputs[0].value).toBe("2"); + expect(engineControlInputs[0].value).toBe("2"); + }); + expect(commandCenterInputs[1].value).toBe("4"); + expect(engineControlInputs[1].value).toBe("4"); + }); + + it("renders the live effective ceiling in Team status", async () => { + render(); + + expect(await screen.findByText("8 (4 effective: maxWorktrees)")).toBeTruthy(); + expect(api.fetchExecutorStats).toHaveBeenCalledWith("project-live"); + }); + + it("applies the effective ceiling through the production Column worktree grouping", async () => { + const tasks = [task("FN-1", "in-progress"), task("FN-2"), task("FN-3"), task("FN-4"), task("FN-5"), task("FN-6")]; + render( task("FN-1")} + onOpenDetail={() => {}} + addToast={() => {}} + />); + + await waitFor(() => { + const upNext = worktreeGroupProps.find(({ label }) => label === "Up Next"); + expect(upNext?.queuedTasks).toHaveLength(4); + }); + }); + +}); diff --git a/packages/dashboard/app/__tests__/scheduling-effective-concurrency.test.tsx b/packages/dashboard/app/__tests__/scheduling-effective-concurrency.test.tsx new file mode 100644 index 0000000000..8356862e24 --- /dev/null +++ b/packages/dashboard/app/__tests__/scheduling-effective-concurrency.test.tsx @@ -0,0 +1,48 @@ +// @vitest-environment jsdom + +import { useState } from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { SchedulingSection } from "../components/settings/sections/SchedulingSection"; +import type { SettingsFormState } from "../components/settings/sections/context"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (_key: string, fallback: string) => fallback }), +})); + +function SchedulingHarness({ initial }: { initial: Partial }) { + const [form, setForm] = useState(initial as SettingsFormState); + return ( + {}} + onOpenOverlapPathPicker={() => {}} + onRemoveOverlapIgnorePath={() => {}} + onAddOverlapIgnorePath={() => {}} + /> + ); +} + +describe("SchedulingSection effective concurrency affordance", () => { + it("accepts the shared 1–50 range through the rendered settings control", async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByLabelText("Max Concurrent Tasks"); + await user.type(input, "12"); + + expect(input).toHaveValue(12); + expect(input).toHaveAttribute("max", "50"); + expect(screen.getByText("Default: 2. The effective ceiling is the lower of Max Concurrent Tasks and Max Worktrees while worktree limiting is on.")).toBeInTheDocument(); + }); + + it("renders the worktree binding explanation only while the worktree gate binds", () => { + const { rerender } = render(); + expect(screen.getByText("Effective concurrency ceiling: 4, bound by Max Worktrees.")).toBeInTheDocument(); + + rerender(); + expect(screen.queryByText(/Effective concurrency ceiling/)).not.toBeInTheDocument(); + }); +}); diff --git a/packages/dashboard/app/api/projects/projects.ts b/packages/dashboard/app/api/projects/projects.ts index 8502ac5eb6..2b72aa30d0 100644 --- a/packages/dashboard/app/api/projects/projects.ts +++ b/packages/dashboard/app/api/projects/projects.ts @@ -108,8 +108,12 @@ export interface ExecutorStats { inReviewCount: number; /** Derived executor state: "idle", "running", "paused", or "stopped" */ executorState: ExecutorState; - /** Maximum concurrent tasks allowed from settings */ + /** Configured maximum concurrent tasks. */ maxConcurrent: number; + /** Engine-enforced ceiling after the worktree limit is applied. */ + effectiveMaxConcurrent: number; + /** Setting that currently binds the effective ceiling. */ + concurrencyBindingKnob: "maxConcurrent" | "maxWorktrees"; /** ISO timestamp of most recent task event from activity log */ lastActivityAt?: string; } @@ -708,6 +712,8 @@ export function fetchExecutorStats(projectId?: string): Promise<{ globalPause: boolean; enginePaused: boolean; maxConcurrent: number; + effectiveMaxConcurrent: number; + concurrencyBindingKnob: "maxConcurrent" | "maxWorktrees"; lastActivityAt?: string; }> { const path = withProjectId("/executor/stats", projectId); @@ -715,6 +721,8 @@ export function fetchExecutorStats(projectId?: string): Promise<{ globalPause: boolean; enginePaused: boolean; maxConcurrent: number; + effectiveMaxConcurrent: number; + concurrencyBindingKnob: "maxConcurrent" | "maxWorktrees"; lastActivityAt?: string; }>(path)); } diff --git a/packages/dashboard/app/api/settings/settings.ts b/packages/dashboard/app/api/settings/settings.ts index 0b63ed2c71..3911e7b8da 100644 --- a/packages/dashboard/app/api/settings/settings.ts +++ b/packages/dashboard/app/api/settings/settings.ts @@ -9,9 +9,19 @@ import { withProjectId } from "../client/health.js"; import type { UpdateCheckResponse } from "../client/health.js"; import { dedupe } from "../client/dedupe.js"; -export function fetchConfig(projectId?: string): Promise<{ maxConcurrent: number; rootDir: string }> { +export function fetchConfig(projectId?: string): Promise<{ + maxConcurrent: number; + effectiveMaxConcurrent: number; + concurrencyBindingKnob: "maxConcurrent" | "maxWorktrees"; + rootDir: string; +}> { const path = withProjectId("/config", projectId); - return dedupe(path, () => api<{ maxConcurrent: number; rootDir: string }>(path)); + return dedupe(path, () => api<{ + maxConcurrent: number; + effectiveMaxConcurrent: number; + concurrencyBindingKnob: "maxConcurrent" | "maxWorktrees"; + rootDir: string; + }>(path)); } export function fetchSettings(projectId?: string, options?: FetchOptions): Promise { diff --git a/packages/dashboard/app/components/Board.tsx b/packages/dashboard/app/components/Board.tsx index 6be370d268..25f5453ad0 100644 --- a/packages/dashboard/app/components/Board.tsx +++ b/packages/dashboard/app/components/Board.tsx @@ -32,6 +32,8 @@ interface BoardProps { tasks: Task[]; projectId?: string; maxConcurrent: number; + /** Shared engine-enforced capacity for the board's Up Next preview. */ + effectiveMaxConcurrent?: number; showWorktreeGrouping: boolean; onMoveTask: (id: string, column: ColumnId) => Promise; onPauseTask?: (id: string) => Promise; @@ -177,7 +179,7 @@ function columnDefOffersArchiveAllDone(columnDef: { flags: { complete?: boolean; return columnDef.flags.complete === true && columnDef.flags.archived !== true; } -export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, onMoveTask, onPauseTask, onUnpauseTask, onResetTask, onDuplicateTask, onMergeTask, onOpenDetail, onOpenRefine, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, mergeStrategy = "direct", onToggleAutoMerge, planAutoApproveEnabled, onTogglePlanAutoApprove, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onRevertTask, onReviseTask, onDeleteTask, onArchiveAllDone, onLoadArchivedTasks, onLoadMoreArchivedTasks, archivedSortMode, onArchivedSortModeChange, archivedHasMore, archivedLoadingMore, searchQuery = "", availableModels, onPlanningMode, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, onOpenMission, staleHighFanoutBlockerAgeThresholdMs, lastFetchTimeMs, prAuthAvailable, onOpenWorkflowEditor, onCreateWorkflow, workflowControlsInHeader = false }: BoardProps) { +export function Board({ tasks, projectId, maxConcurrent, effectiveMaxConcurrent = maxConcurrent, showWorktreeGrouping, onMoveTask, onPauseTask, onUnpauseTask, onResetTask, onDuplicateTask, onMergeTask, onOpenDetail, onOpenRefine, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, mergeStrategy = "direct", onToggleAutoMerge, planAutoApproveEnabled, onTogglePlanAutoApprove, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onRevertTask, onReviseTask, onDeleteTask, onArchiveAllDone, onLoadArchivedTasks, onLoadMoreArchivedTasks, archivedSortMode, onArchivedSortModeChange, archivedHasMore, archivedLoadingMore, searchQuery = "", availableModels, onPlanningMode, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, onOpenMission, staleHighFanoutBlockerAgeThresholdMs, lastFetchTimeMs, prAuthAvailable, onOpenWorkflowEditor, onCreateWorkflow, workflowControlsInHeader = false }: BoardProps) { const { t } = useTranslation("app"); const [archivedCollapsed, setArchivedCollapsed] = useState(true); /* @@ -970,6 +972,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o tasks={aggregateTasksByColumn[columnDef.id] ?? []} projectId={projectId} maxConcurrent={maxConcurrent} + effectiveMaxConcurrent={effectiveMaxConcurrent} showWorktreeGrouping={showWorktreeGrouping} onMoveTask={onMoveTask} onPauseTask={onPauseTask} @@ -1066,6 +1069,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o allTasks={selectedWorkflowTasks} projectId={projectId} maxConcurrent={maxConcurrent} + effectiveMaxConcurrent={effectiveMaxConcurrent} showWorktreeGrouping={showWorktreeGrouping} onMoveTask={onMoveTask} onPromote={handlePromote} @@ -1130,6 +1134,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o allTasks={selectedWorkflowTasks} projectId={projectId} maxConcurrent={maxConcurrent} + effectiveMaxConcurrent={effectiveMaxConcurrent} showWorktreeGrouping={showWorktreeGrouping} onMoveTask={onMoveTask} onPromote={handlePromote} diff --git a/packages/dashboard/app/components/Column.tsx b/packages/dashboard/app/components/Column.tsx index e21b03b7a9..ace007952f 100644 --- a/packages/dashboard/app/components/Column.tsx +++ b/packages/dashboard/app/components/Column.tsx @@ -118,6 +118,8 @@ interface ColumnProps { tasks: Task[]; projectId?: string; maxConcurrent: number; + /** Effective engine ceiling, including a binding worktree limit when enabled. */ + effectiveMaxConcurrent?: number; showWorktreeGrouping: boolean; onMoveTask: (id: string, column: ColumnId, optionsOrPosition?: { preserveProgress?: boolean } | number) => Promise; onPauseTask?: (id: string) => Promise; @@ -227,7 +229,7 @@ interface ColumnProps { onPromote?: (taskId: string, options?: { force?: boolean }) => Promise; } -function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktreeGrouping, onMoveTask, onPauseTask, onUnpauseTask, onResetTask, onDuplicateTask, onMergeTask, onOpenDetail, onOpenRefine, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, mergeStrategy = "direct", onToggleAutoMerge, planAutoApproveEnabled, onTogglePlanAutoApprove, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onRevertTask, onDeleteTask, onArchiveAllDone, sortMode, onSortModeChange, doneSortMode, onDoneSortModeChange, collapsed, onToggleCollapse, archivedHasMore, archivedLoadingMore, onLoadMoreArchived, allTasks, availableModels, onPlanningMode, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, onOpenMission, lastFetchTimeMs, taskCardFieldDefs, taskWorkflowBadges, blockerFanoutMap, prAuthAvailable, holdTaskIds, workflowMode, workflowId, workflowOptions, defaultWorkflowId, columnDisplayName, columnDescription, columnFlags, workflowContextMenuColumns, taskContextMenuColumnsByTaskId, onPromote }: ColumnProps) { +function ColumnComponent({ column, tasks, projectId, maxConcurrent, effectiveMaxConcurrent = maxConcurrent, showWorktreeGrouping, onMoveTask, onPauseTask, onUnpauseTask, onResetTask, onDuplicateTask, onMergeTask, onOpenDetail, onOpenRefine, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, mergeStrategy = "direct", onToggleAutoMerge, planAutoApproveEnabled, onTogglePlanAutoApprove, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onRevertTask, onDeleteTask, onArchiveAllDone, sortMode, onSortModeChange, doneSortMode, onDoneSortModeChange, collapsed, onToggleCollapse, archivedHasMore, archivedLoadingMore, onLoadMoreArchived, allTasks, availableModels, onPlanningMode, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, onOpenMission, lastFetchTimeMs, taskCardFieldDefs, taskWorkflowBadges, blockerFanoutMap, prAuthAvailable, holdTaskIds, workflowMode, workflowId, workflowOptions, defaultWorkflowId, columnDisplayName, columnDescription, columnFlags, workflowContextMenuColumns, taskContextMenuColumnsByTaskId, onPromote }: ColumnProps) { const { t } = useTranslation("app"); // Anchor the board.rejection.* catalog keys for the i18next extractor (it // scopes `t` to the useTranslation binding, so the shared translateRejection @@ -497,14 +499,19 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree return index; }, [allTasks, tasks, taskContextMenuColumnsByTaskId]); + /* + FNXC:CapacityModel 2026-08-21-15:45: + FN-9185 requires the board's Up Next preview to use the same effective ceiling as engine admission. + The configured max can be shadowed by an enabled worktree cap, so showing it here would promise slots the scheduler cannot grant. + */ const worktreeGroups = useMemo(() => { if (!showWorktreeGroups) return []; - return groupByWorktree(tasks, allTasks ?? tasks, maxConcurrent, holdTaskIds, dependencyColumnFlags); + return groupByWorktree(tasks, allTasks ?? tasks, effectiveMaxConcurrent, holdTaskIds, dependencyColumnFlags); // `holdTaskIds` IS a dependency: the board resolves it after the workflows fetch, so // omitting it would pin the first-paint value and the upcoming-work list would keep // using the legacy-id fallback for the rest of the session. This repo has no // react-hooks/exhaustive-deps rule, so nothing catches that but reading it. - }, [showWorktreeGroups, tasks, allTasks, maxConcurrent, holdTaskIds, dependencyColumnFlags]); + }, [showWorktreeGroups, tasks, allTasks, effectiveMaxConcurrent, holdTaskIds, dependencyColumnFlags]); const visibleTasks = useMemo(() => { if (!shouldPaginate) return tasks; diff --git a/packages/dashboard/app/components/EngineControlMenu.tsx b/packages/dashboard/app/components/EngineControlMenu.tsx index de18718235..10bae67c6d 100644 --- a/packages/dashboard/app/components/EngineControlMenu.tsx +++ b/packages/dashboard/app/components/EngineControlMenu.tsx @@ -2,8 +2,9 @@ import "./EngineControlMenu.css"; import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState, type CSSProperties } from "react"; import { useTranslation } from "react-i18next"; import { DEFAULT_PROJECT_SETTINGS } from "@fusion/core"; +import { resolveEffectiveConcurrency } from "../../../core/src/workflows/workflow-capacity.js"; import { Pause, Play, SlidersHorizontal, Square, X } from "lucide-react"; -import { fetchConfig, fetchSettings, updateSettings } from "../api/legacy"; +import { fetchSettings, updateSettings } from "../api/legacy"; import { useAppSettings } from "../hooks/useAppSettings"; import { useConfirm } from "../hooks/useConfirm"; // FNXC:GlobalConcurrencyControls 2026-06-25-22:45: Footer menu adopts the shared global-concurrency hook so it and the Command Center card read/write ONE source of truth (no more duplicated fetch/debounce/clobber logic). @@ -199,11 +200,19 @@ export const EngineControlMenu = forwardRef { try { - const [config, settings] = await Promise.all([fetchConfig(projectId), fetchSettings(projectId)]); + const settings = await fetchSettings(projectId); if (!cancelled) { + const capacity = resolveEffectiveConcurrency(settings); + /* + FNXC:CapacityModel 2026-08-21-16:37: + Worktree limiting controls admission, not storage. Preserve the configured Max Worktrees + value when its gate is disabled so saving a Max Concurrent edit never overwrites an + operator's dormant worktree setting with the shipped default. + */ + const configuredWorktreeCapacity = resolveEffectiveConcurrency({ ...settings, worktreeLimitEnabled: true }); const persistedValues = { - maxConcurrent: settings.maxConcurrent ?? config.maxConcurrent ?? DEFAULT_CONCURRENCY_VALUES.maxConcurrent, - maxWorktrees: settings.maxWorktrees ?? DEFAULT_CONCURRENCY_VALUES.maxWorktrees, + maxConcurrent: capacity.maxConcurrent, + maxWorktrees: configuredWorktreeCapacity.worktreeLimit ?? DEFAULT_CONCURRENCY_VALUES.maxWorktrees, }; persistedProjectConcurrencyRef.current = persistedValues; pendingProjectConcurrencySaveRef.current = null; diff --git a/packages/dashboard/app/components/command-center/CommandCenterControls.tsx b/packages/dashboard/app/components/command-center/CommandCenterControls.tsx index fcf6f4da0a..2fbc13e0d4 100644 --- a/packages/dashboard/app/components/command-center/CommandCenterControls.tsx +++ b/packages/dashboard/app/components/command-center/CommandCenterControls.tsx @@ -2,7 +2,8 @@ import { useEffect, useRef, useState, type CSSProperties } from "react"; import { useTranslation } from "react-i18next"; import { Power } from "lucide-react"; import { DEFAULT_PROJECT_SETTINGS, type ColorTheme, type ThemeMode } from "@fusion/core"; -import { fetchConfig, fetchSettings, updateSettings } from "../../api/legacy"; +import { resolveEffectiveConcurrency } from "../../../../core/src/workflows/workflow-capacity.js"; +import { fetchSettings, updateSettings } from "../../api/legacy"; import { useAppSettings } from "../../hooks/useAppSettings"; import { useConfirm } from "../../hooks/useConfirm"; // FNXC:GlobalConcurrencyControls 2026-06-25-22:45: Concurrency card adopts the shared global-concurrency hook so it and the footer EngineControlMenu read/write ONE source of truth (no more duplicated fetch/debounce/clobber logic). @@ -121,11 +122,19 @@ export function CommandCenterControls({ projectId, colorTheme, themeMode, shadcn setConcurrencyState({ status: "loading", data: null, error: null }); void (async () => { try { - const [config, settings] = await Promise.all([fetchConfig(projectId), fetchSettings(projectId)]); + const settings = await fetchSettings(projectId); if (!cancelled) { + const capacity = resolveEffectiveConcurrency(settings); + /* + FNXC:CapacityModel 2026-08-21-16:37: + Worktree limiting controls admission, not storage. Preserve the configured Max Worktrees + value when its gate is disabled so saving a Max Concurrent edit never overwrites an + operator's dormant worktree setting with the shipped default. + */ + const configuredWorktreeCapacity = resolveEffectiveConcurrency({ ...settings, worktreeLimitEnabled: true }); const persistedValues = { - maxConcurrent: settings.maxConcurrent ?? config.maxConcurrent ?? DEFAULT_CONCURRENCY_VALUES.maxConcurrent, - maxWorktrees: settings.maxWorktrees ?? DEFAULT_CONCURRENCY_VALUES.maxWorktrees, + maxConcurrent: capacity.maxConcurrent, + maxWorktrees: configuredWorktreeCapacity.worktreeLimit ?? DEFAULT_CONCURRENCY_VALUES.maxWorktrees, worktreeLimitEnabled: settings.worktreeLimitEnabled !== false, }; persistedConcurrencyRef.current = persistedValues; diff --git a/packages/dashboard/app/components/command-center/areas/TeamArea.tsx b/packages/dashboard/app/components/command-center/areas/TeamArea.tsx index 43cfaf4438..69519e3411 100644 --- a/packages/dashboard/app/components/command-center/areas/TeamArea.tsx +++ b/packages/dashboard/app/components/command-center/areas/TeamArea.tsx @@ -42,6 +42,8 @@ type ExecutorStats = { globalPause: boolean; enginePaused: boolean; maxConcurrent: number; + effectiveMaxConcurrent: number; + concurrencyBindingKnob: "maxConcurrent" | "maxWorktrees"; lastActivityAt?: string; }; @@ -521,7 +523,13 @@ export function TeamArea({
{t("commandCenter.controls.status.maxConcurrent", "Max concurrent")}
-
{executorStatsState.data?.maxConcurrent ?? "—"}
+
+ {executorStatsState.data + ? executorStatsState.data.effectiveMaxConcurrent === executorStatsState.data.maxConcurrent + ? executorStatsState.data.maxConcurrent + : `${executorStatsState.data.maxConcurrent} (${executorStatsState.data.effectiveMaxConcurrent} effective: ${executorStatsState.data.concurrencyBindingKnob})` + : "—"} +
{executorStatsState.status === "error" ?

{executorStatsState.error}

: null} diff --git a/packages/dashboard/app/components/dashboard/MainContent.tsx b/packages/dashboard/app/components/dashboard/MainContent.tsx index 562129c637..fa0408bd7f 100644 --- a/packages/dashboard/app/components/dashboard/MainContent.tsx +++ b/packages/dashboard/app/components/dashboard/MainContent.tsx @@ -140,6 +140,7 @@ export function MainContent({ mainPanelDetailTask, filteredBoardTasks, maxConcurrent, + effectiveMaxConcurrent, showWorktreeGrouping, moveTask, pauseTask, @@ -885,6 +886,7 @@ export function MainContent({ tasks={filteredBoardTasks} projectId={currentProject?.id} maxConcurrent={maxConcurrent} + effectiveMaxConcurrent={effectiveMaxConcurrent} showWorktreeGrouping={showWorktreeGrouping} onMoveTask={moveTask} onPauseTask={pauseTask} @@ -1006,6 +1008,7 @@ export function MainContent({ tasks={filteredBoardTasks} projectId={currentProject?.id} maxConcurrent={maxConcurrent} + effectiveMaxConcurrent={effectiveMaxConcurrent} showWorktreeGrouping={showWorktreeGrouping} onMoveTask={moveTask} onPauseTask={pauseTask} diff --git a/packages/dashboard/app/components/dashboard/types.ts b/packages/dashboard/app/components/dashboard/types.ts index 7376c4f6e8..b6747f1b30 100644 --- a/packages/dashboard/app/components/dashboard/types.ts +++ b/packages/dashboard/app/components/dashboard/types.ts @@ -190,6 +190,8 @@ export interface MainContentProps { mainPanelDetailTask: Task | TaskDetail | null; filteredBoardTasks: Task[]; maxConcurrent: number; + /** Shared effective ceiling used by board previews and engine admission. */ + effectiveMaxConcurrent: number; showWorktreeGrouping: boolean; moveTask: ( id: string, diff --git a/packages/dashboard/app/components/settings/sections/SchedulingSection.tsx b/packages/dashboard/app/components/settings/sections/SchedulingSection.tsx index 19bd2ddbd5..50d5678f3b 100644 --- a/packages/dashboard/app/components/settings/sections/SchedulingSection.tsx +++ b/packages/dashboard/app/components/settings/sections/SchedulingSection.tsx @@ -1,4 +1,6 @@ import { useTranslation } from "react-i18next"; +import { DEFAULT_PROJECT_SETTINGS } from "@fusion/core"; +import { resolveEffectiveConcurrency } from "../../../../../core/src/workflows/workflow-capacity.js"; import { MovedSettingsStub } from "./MovedSettingsStub"; import { SettingsToggleRow } from "../SettingsToggleRow"; import { SettingsSelectRow } from "../SettingsSelectRow"; @@ -32,6 +34,7 @@ The `overlapIgnorePaths` allowlist deliberately keeps its bespoke markup: it is */ export function SchedulingSection({ form, setForm, concurrencyLoading = false, onOverlapIgnorePathChange, onOpenOverlapPathPicker, onRemoveOverlapIgnorePath, onAddOverlapIgnorePath, onOpenWorkflowSettings, }: SchedulingSectionProps) { const { t } = useTranslation("app"); + const concurrency = resolveEffectiveConcurrency(form); return (<>

{t("settings.scheduling.scheduling", "Scheduling")}

{/* FNXC:ExecutorToolFailureRetry 2026-08-06-14:56: project controls tune bounded same-model retry before terminal executor parking; one terminal tool error qualifies by default while values still floor to core's resolver contract. */} @@ -45,15 +48,20 @@ export function SchedulingSection({ form, setForm, concurrencyLoading = false, o descriptor={{ key: "maxConcurrent", label: t("settings.scheduling.maxConcurrentTasks", "Max Concurrent Tasks"), - help: t("settings.scheduling.maxConcurrentTasksHint", "Default: 2."), + help: t("settings.scheduling.maxConcurrentTasksHint", `Default: ${DEFAULT_PROJECT_SETTINGS.maxConcurrent}. The effective ceiling is the lower of Max Concurrent Tasks and Max Worktrees while worktree limiting is on.`), scope: "project", min: 1, - max: 10, + max: 50, disabled: concurrencyLoading, }} value={form.maxConcurrent ?? null} onChange={(v) => setForm((f) => ({ ...f, maxConcurrent: v ?? undefined } as SettingsFormState))} /> + {concurrency.bindingKnob === "maxWorktrees" && ( + + {`Effective concurrency ceiling: ${concurrency.effectiveLimit}, bound by Max Worktrees.`} + + )} ("."); const [autoMerge, setAutoMerge] = useState(true); const [mergeStrategy, setMergeStrategy] = useState("direct"); @@ -149,6 +152,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult { if (configResult.status === "fulfilled") { setMaxConcurrent(configResult.value.maxConcurrent); + setEffectiveMaxConcurrent(configResult.value.effectiveMaxConcurrent); setRootDir(configResult.value.rootDir); } @@ -393,6 +397,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult { return { maxConcurrent, + effectiveMaxConcurrent, rootDir, autoMerge, mergeStrategy, diff --git a/packages/dashboard/app/hooks/useExecutorStats.ts b/packages/dashboard/app/hooks/useExecutorStats.ts index e260a01c2a..9842a21be8 100644 --- a/packages/dashboard/app/hooks/useExecutorStats.ts +++ b/packages/dashboard/app/hooks/useExecutorStats.ts @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { isReviewColumnRole } from "../utils/columnRoles"; -import type { Task, TraitFlags } from "@fusion/core"; +import { DEFAULT_PROJECT_SETTINGS, type Task, type TraitFlags } from "@fusion/core"; import { enrichRunningAgentTaskShapeFromFlags, isRunningAgentTask, isWaitingAgentTask } from "../../../core/src/agents/live-agent-count"; import { fetchExecutorStats } from "../api"; import type { ExecutorStats, ExecutorState } from "../api"; @@ -138,13 +138,15 @@ function hasActionableBlockedBy(blockedBy: Task["blockedBy"] | string[] | null): * - Derives executorState from globalPause and enginePaused flags, with globalPause mapping to "stopped" and enginePaused to "paused" at any running count * - Returns ExecutorStats object with reactive updates */ -const DEFAULT_API_DATA: Pick & { +const DEFAULT_API_DATA: Pick & { globalPause: boolean; enginePaused: boolean; } = { globalPause: false, enginePaused: false, - maxConcurrent: 2, + maxConcurrent: DEFAULT_PROJECT_SETTINGS.maxConcurrent, + effectiveMaxConcurrent: DEFAULT_PROJECT_SETTINGS.maxConcurrent, + concurrencyBindingKnob: "maxConcurrent", }; export function useExecutorStats(tasks: Task[], projectId?: string, columnFlagsByTaskId?: ReadonlyMap): UseExecutorStatsResult { @@ -260,6 +262,8 @@ export function useExecutorStats(tasks: Task[], projectId?: string, columnFlagsB ...taskStats, executorState, maxConcurrent: apiData.maxConcurrent, + effectiveMaxConcurrent: apiData.effectiveMaxConcurrent, + concurrencyBindingKnob: apiData.concurrencyBindingKnob, lastActivityAt: apiData.lastActivityAt, }; diff --git a/packages/dashboard/app/utils/__tests__/worktreeGrouping.test.ts b/packages/dashboard/app/utils/__tests__/worktreeGrouping.test.ts index 850a681c49..5143faafd8 100644 --- a/packages/dashboard/app/utils/__tests__/worktreeGrouping.test.ts +++ b/packages/dashboard/app/utils/__tests__/worktreeGrouping.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; import { groupByWorktree, getWorktreeLabel } from "../worktreeGrouping"; -import type { Task } from "@fusion/core"; +import { resolveEffectiveConcurrency, type Task } from "@fusion/core"; function makeTask(overrides: Partial & { id: string }): Task { return { @@ -87,6 +87,16 @@ describe("groupByWorktree", () => { expect(groups.find((g) => g.label === "Up Next")).toBeUndefined(); }); + it("caps Up Next at the effective worktree-bound ceiling, not configured max concurrent", () => { + const active = makeTask({ id: "FN-001", worktree: ".worktrees/swift-falcon" }); + const queued = ["FN-010", "FN-011", "FN-012", "FN-013", "FN-014"].map((id) => makeTask({ id, column: "todo" })); + const capacity = resolveEffectiveConcurrency({ maxConcurrent: 8, maxWorktrees: 4, worktreeLimitEnabled: true }); + + const upNext = groupByWorktree([active], [active, ...queued], capacity.effectiveLimit).find((group) => group.label === "Up Next"); + expect(capacity).toMatchObject({ maxConcurrent: 8, effectiveLimit: 4, bindingKnob: "maxWorktrees" }); + expect(upNext?.queuedTasks).toHaveLength(4); + }); + it("respects maxConcurrent limit on queued tasks shown", () => { const active = makeTask({ id: "FN-001", worktree: ".worktrees/swift-falcon" }); const q1 = makeTask({ id: "FN-010", column: "todo" }); diff --git a/packages/dashboard/app/utils/worktreeGrouping.ts b/packages/dashboard/app/utils/worktreeGrouping.ts index fdb314a57d..d4125c3c5e 100644 --- a/packages/dashboard/app/utils/worktreeGrouping.ts +++ b/packages/dashboard/app/utils/worktreeGrouping.ts @@ -57,12 +57,12 @@ function resolveDependencyOrder(tasks: Task[]): string[] { * Queued tasks (eligible "todo" tasks whose dependencies are all satisfied) * are always placed in the "Up Next" group — they are never distributed * to worktree-specific groups since they have no worktree assignment yet. - * The number of queued tasks shown is capped at `maxConcurrent`. + * The number of queued tasks shown is capped at the engine's effective concurrency ceiling. */ export function groupByWorktree( inProgressTasks: Task[], allTasks: Task[], - maxConcurrent: number, + effectiveConcurrencyLimit: number, /* FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (U12 — R8 drift conversion): The ids of TASKS whose own column is a hold lane in their own workflow, when the caller @@ -186,8 +186,8 @@ export function groupByWorktree( }); } - // All eligible queued tasks go into the "Up Next" group (capped at maxConcurrent) - const queued = orderedEligible.slice(0, maxConcurrent); + // All eligible queued tasks go into the "Up Next" group (capped at the effective ceiling). + const queued = orderedEligible.slice(0, effectiveConcurrencyLimit); if (queued.length > 0) { groups.push({ id: "up-next", diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts index 21585b4fe6..16f677e1ee 100644 --- a/packages/dashboard/src/routes.ts +++ b/packages/dashboard/src/routes.ts @@ -25,6 +25,7 @@ import { writeAgentMemoryFile, resolveWorkflowIrForTask, columnsWithFlag, + resolveEffectiveConcurrency, } from "@fusion/core"; import type { ServerOptions } from "./server.js"; import { SESSION_CLEANUP_DEFAULT_MAX_AGE_MS, type AiSessionType } from "./ai-session-store.js"; @@ -1232,10 +1233,13 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout // If we can't get activity log, that's OK - just leave lastActivityAt undefined } + const capacity = resolveEffectiveConcurrency(settings); res.json({ globalPause: settings.globalPause ?? false, enginePaused: settings.enginePaused ?? false, - maxConcurrent: settings.maxConcurrent ?? 2, + maxConcurrent: capacity.maxConcurrent, + effectiveMaxConcurrent: capacity.effectiveLimit, + concurrencyBindingKnob: capacity.bindingKnob, lastActivityAt, }); } catch (err: unknown) { diff --git a/packages/dashboard/src/routes/__tests__/concurrency-authoritative-source-routes.test.ts b/packages/dashboard/src/routes/__tests__/concurrency-authoritative-source-routes.test.ts new file mode 100644 index 0000000000..64e43addac --- /dev/null +++ b/packages/dashboard/src/routes/__tests__/concurrency-authoritative-source-routes.test.ts @@ -0,0 +1,90 @@ +// @vitest-environment node + +import express from "express"; +import { describe, expect, it, vi } from "vitest"; +import { request } from "../../test-request.js"; +import { registerProjectRoutes } from "../register-project-routes.js"; +import { createApiRoutes } from "../../routes.js"; +import { resolveEffectiveConcurrency, type TaskStore } from "@fusion/core"; + +const getOrCreateProjectStore = vi.hoisted(() => vi.fn()); + +vi.mock("../../project-store-resolver.js", () => ({ + getOrCreateProjectStore: (...args: unknown[]) => getOrCreateProjectStore(...args), + evictProjectStore: vi.fn(), +})); + +function createLiveStore(settings: Record): TaskStore { + return { + getSettings: vi.fn().mockResolvedValue(settings), + getSettingsFast: vi.fn().mockResolvedValue(settings), + getActivityLog: vi.fn().mockResolvedValue([]), + getRootDir: vi.fn().mockReturnValue("/live"), + getFusionDir: vi.fn().mockReturnValue("/live/.fusion"), + listTasks: vi.fn().mockResolvedValue([]), + getProjectScopedPluginMcpServers: vi.fn().mockResolvedValue([]), + } as unknown as TaskStore; +} + +async function requestReportingSurfaces(settings: Record) { + const app = express(); + app.use(express.json()); + const liveStore = createLiveStore(settings); + getOrCreateProjectStore.mockResolvedValue(liveStore); + app.use("/api", createApiRoutes(liveStore)); + registerProjectRoutes({ + router: app, + options: { centralCore: { getProject: vi.fn().mockResolvedValue({ id: "project-live", path: "/live" }) } }, + runtimeLogger: { child: () => ({ warn: vi.fn() }), warn: vi.fn() }, + prioritizeProjectsForCurrentDirectory: vi.fn(), + rethrowAsApiError: (error: unknown): never => { throw error; }, + } as never); + + const [config, executorStats, projectConfig] = await Promise.all([ + request(app, "GET", "/api/config"), + request(app, "GET", "/api/executor/stats"), + request(app, "GET", "/projects/project-live/config"), + ]); + return { config, executorStats, projectConfig, liveStore }; +} + +/* +FNXC:CapacityModel 2026-08-21-17:24: +FN-9185 requires a production-route matrix rather than route-local payload checks. +Every reporter must read the same live project blob and expose the resolver's configured, +effective, and binding values for unset, configured, and worktree-bound states. +*/ +describe("concurrency reporting route authority", () => { + it.each([ + ["unset", {}, { maxConcurrent: 2, effectiveLimit: 2, bindingKnob: "maxConcurrent" }], + ["configured", { maxConcurrent: 6, maxWorktrees: 9, worktreeLimitEnabled: true }, { maxConcurrent: 6, effectiveLimit: 6, bindingKnob: "maxConcurrent" }], + ["worktree-bound", { maxConcurrent: 8, maxWorktrees: 4, worktreeLimitEnabled: true }, { maxConcurrent: 8, effectiveLimit: 4, bindingKnob: "maxWorktrees" }], + ] as const)("reports matching live capacity through all routes for %s settings", async (_state, settings, expected) => { + const { config, executorStats, projectConfig, liveStore } = await requestReportingSurfaces(settings); + + for (const response of [config, executorStats, projectConfig]) { + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + maxConcurrent: expected.maxConcurrent, + effectiveMaxConcurrent: expected.effectiveLimit, + concurrencyBindingKnob: expected.bindingKnob, + }); + } + expect(liveStore.getSettingsFast).toHaveBeenCalled(); + expect(liveStore.getSettings).toHaveBeenCalledOnce(); + }); + + it("prefers a target project's live blob when a stale registry snapshot omits maxConcurrent", async () => { + const { config, executorStats, projectConfig, liveStore } = await requestReportingSurfaces({ maxConcurrent: 6, maxWorktrees: 9, worktreeLimitEnabled: true }); + const capacity = resolveEffectiveConcurrency({ maxConcurrent: 6, maxWorktrees: 9, worktreeLimitEnabled: true }); + + for (const response of [config, executorStats, projectConfig]) { + expect(response.body).toMatchObject({ + maxConcurrent: capacity.maxConcurrent, + effectiveMaxConcurrent: capacity.effectiveLimit, + concurrencyBindingKnob: capacity.bindingKnob, + }); + } + expect(liveStore.getSettingsFast).toHaveBeenCalled(); + }); +}); diff --git a/packages/dashboard/src/routes/__tests__/register-config-mcp-pi-settings-routes.test.ts b/packages/dashboard/src/routes/__tests__/register-config-mcp-pi-settings-routes.test.ts index 8c9f6352c7..23c1c0e946 100644 --- a/packages/dashboard/src/routes/__tests__/register-config-mcp-pi-settings-routes.test.ts +++ b/packages/dashboard/src/routes/__tests__/register-config-mcp-pi-settings-routes.test.ts @@ -39,14 +39,37 @@ describe("registerConfigMcpPiSettingsRoutes", () => { const response = await request(createApp(), "GET", "/config"); expect(response.status).toBe(200); - expect(response.body).toEqual({ maxConcurrent: 6, maxWorktrees: 2, rootDir: "/workspace" }); + expect(response.body).toEqual({ + maxConcurrent: 6, + maxWorktrees: 2, + effectiveMaxConcurrent: 2, + worktreeLimitEnabled: true, + concurrencyBindingKnob: "maxWorktrees", + rootDir: "/workspace", + }); }); - it("uses option and fixed defaults for missing scheduler settings", async () => { + it("uses shipped resolver defaults for missing scheduler settings", async () => { const response = await request(createApp({}), "GET", "/config"); expect(response.status).toBe(200); - expect(response.body).toEqual({ maxConcurrent: 9, maxWorktrees: 4, rootDir: "/workspace" }); + expect(response.body).toEqual({ + maxConcurrent: 2, + maxWorktrees: 4, + effectiveMaxConcurrent: 2, + worktreeLimitEnabled: true, + concurrencyBindingKnob: "maxConcurrent", + rootDir: "/workspace", + }); + }); + + it("reports the resolver defaults when the authoritative settings read fails", async () => { + const app = express(); + const store = { getRootDir: () => "/workspace", getSettingsFast: async () => { throw new Error("unavailable"); } }; + registerConfigMcpPiSettingsRoutes({ router: app, getProjectContext: async () => ({ store }), rethrowAsApiError(error: unknown): never { throw error; } } as unknown as ApiRoutesContext); + + const response = await request(app, "GET", "/config"); + expect(response.body).toMatchObject({ maxConcurrent: 2, maxWorktrees: 4, effectiveMaxConcurrent: 2, concurrencyBindingKnob: "maxConcurrent" }); }); it("lists only provider-filtered valid project plugin MCP contributions", async () => { diff --git a/packages/dashboard/src/routes/register-config-mcp-pi-settings-routes.ts b/packages/dashboard/src/routes/register-config-mcp-pi-settings-routes.ts index ccea512cda..d3d4b085f7 100644 --- a/packages/dashboard/src/routes/register-config-mcp-pi-settings-routes.ts +++ b/packages/dashboard/src/routes/register-config-mcp-pi-settings-routes.ts @@ -1,5 +1,5 @@ import type { McpServerDefinition, PluginMcpServerContribution, TaskStore } from "@fusion/core"; -import { mapPluginMcpServerContribution, validateMcpServerDefinitionDetailed } from "@fusion/core"; +import { mapPluginMcpServerContribution, resolveEffectiveConcurrency, validateMcpServerDefinitionDetailed } from "@fusion/core"; import { resolveFusionMemoryMcpEntry } from "@fusion/core/mcp-builtin-servers"; import { discoverMcpServers, @@ -112,20 +112,27 @@ async function resolveMcpServerForValidation( } export const registerConfigMcpPiSettingsRoutes: ApiRouteRegistrar = (ctx) => { - const { router, getProjectContext, options, rethrowAsApiError } = ctx; + const { router, getProjectContext, rethrowAsApiError } = ctx; router.get("/config", async (req, res) => { try { const { store: scopedStore } = await getProjectContext(req); const settings = await scopedStore.getSettingsFast(); + const capacity = resolveEffectiveConcurrency(settings); res.json({ - maxConcurrent: settings.maxConcurrent ?? options?.maxConcurrent ?? 2, - maxWorktrees: settings.maxWorktrees ?? 4, + maxConcurrent: capacity.maxConcurrent, + maxWorktrees: capacity.worktreeLimit ?? settings.maxWorktrees, + effectiveMaxConcurrent: capacity.effectiveLimit, + worktreeLimitEnabled: settings.worktreeLimitEnabled !== false, + concurrencyBindingKnob: capacity.bindingKnob, rootDir: scopedStore.getRootDir(), }); } catch { const { store: scopedStore } = await getProjectContext(req); - res.json({ maxConcurrent: options?.maxConcurrent ?? 2, maxWorktrees: 4, rootDir: scopedStore.getRootDir() }); + // FNXC:CapacityModel 2026-08-21-15:25: a failed live read reports shipped resolver defaults, + // never a private route literal or a stale boot option. + const capacity = resolveEffectiveConcurrency(undefined); + res.json({ maxConcurrent: capacity.maxConcurrent, maxWorktrees: capacity.worktreeLimit, effectiveMaxConcurrent: capacity.effectiveLimit, worktreeLimitEnabled: true, concurrencyBindingKnob: capacity.bindingKnob, rootDir: scopedStore.getRootDir() }); } }); diff --git a/packages/dashboard/src/routes/register-project-routes.ts b/packages/dashboard/src/routes/register-project-routes.ts index 04d22ec9d3..ea83b10214 100644 --- a/packages/dashboard/src/routes/register-project-routes.ts +++ b/packages/dashboard/src/routes/register-project-routes.ts @@ -14,6 +14,7 @@ import { columnsWithFlag, resolveTaskLifecycleColumns, isTerminalColumnRole, + resolveEffectiveConcurrency, } from "@fusion/core"; import type { CentralCore as CentralCoreApi, WorkflowIr } from "@fusion/core"; import { ApiError, badRequest, notFound } from "../api-error.js"; @@ -1017,8 +1018,19 @@ export const registerProjectRoutes: ApiRouteRegistrar = (ctx) => { throw notFound("Project not found"); } + /* + FNXC:CapacityModel 2026-08-21-15:25: + FN-9185 replaces this route's historical literal `2` with the target project's + live settings blob. Registry metadata only establishes existence and rootDir. + */ + const settings = await (await getOrCreateProjectStore(req.params.id)).getSettingsFast(); + const capacity = resolveEffectiveConcurrency(settings); res.json({ - maxConcurrent: 2, + maxConcurrent: capacity.maxConcurrent, + maxWorktrees: capacity.worktreeLimit ?? settings.maxWorktrees, + effectiveMaxConcurrent: capacity.effectiveLimit, + worktreeLimitEnabled: settings.worktreeLimitEnabled !== false, + concurrencyBindingKnob: capacity.bindingKnob, rootDir: project.path, }); } catch (err: unknown) { diff --git a/packages/engine/src/__tests__/concurrency-effective-limit-surfaces.test.ts b/packages/engine/src/__tests__/concurrency-effective-limit-surfaces.test.ts new file mode 100644 index 0000000000..13ff560173 --- /dev/null +++ b/packages/engine/src/__tests__/concurrency-effective-limit-surfaces.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { resolveActiveTaskCapacityLimit, formatAdmissionCapacityQueuedReason } from "../concurrency/concurrency.js"; +import { formatConcurrencyLimitReason } from "../scheduler.js"; + +describe("effective concurrency operator surfaces", () => { + it("uses one ceiling for unset, configured, and worktree-bound admission", () => { + expect(resolveActiveTaskCapacityLimit({})).toBe(2); + expect(resolveActiveTaskCapacityLimit({ maxConcurrent: 6, maxWorktrees: 9 })).toBe(6); + expect(resolveActiveTaskCapacityLimit({ maxConcurrent: 8, maxWorktrees: 4, worktreeLimitEnabled: true })).toBe(4); + expect(resolveActiveTaskCapacityLimit({ maxConcurrent: 8, maxWorktrees: 4, worktreeLimitEnabled: false })).toBe(8); + }); + + it("names the effective ceiling and binding setting in the shared admission reason", () => { + expect(formatAdmissionCapacityQueuedReason({ + maxConcurrent: 8, + maxWorktrees: 4, + worktreeLimitEnabled: true, + claimed: 4, + holderTaskIds: ["FN-1"], + })).toContain("effectiveLimit=4; bindingKnob=maxWorktrees"); + }); + + it("names the effective ceiling and binding setting in scheduler diagnostics", () => { + const reason = formatConcurrencyLimitReason({ + available: 0, + bindingGates: ["maxWorktrees"], + maxConcurrentGate: { used: 4, limit: 8, slack: 4 }, + maxWorktreesGate: { used: 4, limit: 4, slack: 0 }, + semaphoreGate: undefined, + holders: { maxConcurrent: ["FN-1"], maxWorktrees: ["FN-1"], semaphore: undefined }, + }); + expect(reason).toContain("effectiveLimit=4 (bindingKnob=maxWorktrees)"); + }); +}); diff --git a/packages/engine/src/__tests__/project-engine-manager.test.ts b/packages/engine/src/__tests__/project-engine-manager.test.ts index 359d6c9331..2373d7a402 100644 --- a/packages/engine/src/__tests__/project-engine-manager.test.ts +++ b/packages/engine/src/__tests__/project-engine-manager.test.ts @@ -150,6 +150,25 @@ describe("ProjectEngineManager", () => { ); }); + it("uses the live scoped settings blob over a stale registry snapshot at runtime startup", async () => { + const liveStore = { + getRootDir: () => "/mapped/proj_aaa", + getSettingsFast: vi.fn().mockResolvedValue({ maxConcurrent: 6 }), + } as any; + const staleProject = { ...projectA, settings: {} } as RegisteredProject; + (centralCore.getProject as ReturnType).mockResolvedValue(staleProject); + const manager = new ProjectEngineManager(centralCore, { externalTaskStore: liveStore }); + + await manager.ensureEngine("proj_aaa"); + + expect(liveStore.getSettingsFast).toHaveBeenCalledOnce(); + expect(ProjectEngine).toHaveBeenLastCalledWith( + expect.objectContaining({ maxConcurrent: 6 }), + centralCore, + expect.any(Object), + ); + }); + it("returns existing engine on repeated calls", async () => { const manager = new ProjectEngineManager(centralCore); const engine1 = await manager.ensureEngine("proj_aaa"); diff --git a/packages/engine/src/__tests__/scheduler-workflow-cutover.test.ts b/packages/engine/src/__tests__/scheduler-workflow-cutover.test.ts index ddf6132cba..be9c2b5d7d 100644 --- a/packages/engine/src/__tests__/scheduler-workflow-cutover.test.ts +++ b/packages/engine/src/__tests__/scheduler-workflow-cutover.test.ts @@ -572,7 +572,7 @@ describe("Scheduler workflow cutover", () => { expect(store.updateTask).not.toHaveBeenCalledWith("FN-002", expect.objectContaining({ status: null })); expect(store.logEntry).toHaveBeenCalledWith( "FN-002", - expect.stringContaining("gate=maxWorktrees; maxConcurrent used=1/4"), + expect.stringContaining("gate=maxWorktrees; effectiveLimit=1 (bindingKnob=maxWorktrees); maxConcurrent used=1/4"), ); expect(store.logEntry).toHaveBeenCalledWith( "FN-002", @@ -596,7 +596,7 @@ describe("Scheduler workflow cutover", () => { expect(store.updateTask).toHaveBeenCalledWith("FN-200", { status: "queued" }); expect(store.logEntry).toHaveBeenCalledWith( "FN-200", - expect.stringContaining("gate=maxWorktrees; maxConcurrent used=5/10"), + expect.stringContaining("gate=maxWorktrees; effectiveLimit=4 (bindingKnob=maxWorktrees); maxConcurrent used=5/10"), ); expect(store.logEntry).toHaveBeenCalledWith( "FN-200", @@ -746,7 +746,7 @@ describe("Scheduler workflow cutover", () => { expect(store.moveTaskIf).not.toHaveBeenCalledWith("FN-402", "in-progress", expect.anything(), expect.anything()); expect(store.logEntry).toHaveBeenCalledWith( "FN-402", - expect.stringContaining("gate=maxWorktrees; maxConcurrent used=4/10"), + expect.stringContaining("gate=maxWorktrees; effectiveLimit=4 (bindingKnob=maxWorktrees); maxConcurrent used=4/10"), ); expect(onSchedule).toHaveBeenCalledTimes(1); }); diff --git a/packages/engine/src/concurrency/concurrency.ts b/packages/engine/src/concurrency/concurrency.ts index 0f70f58147..f186945e53 100644 --- a/packages/engine/src/concurrency/concurrency.ts +++ b/packages/engine/src/concurrency/concurrency.ts @@ -3,7 +3,7 @@ import { countRunningAgentTasks, enrichRunningAgentTaskShape, isRunningAgentTask, - resolveWorktreeCapacityLimit, + resolveEffectiveConcurrency, resolveWorkflowIrForTask, type Task, type WorkflowIrResolverStore, @@ -26,14 +26,11 @@ population. Collapse them to one project admission ceiling so planning, execute, and merge cannot each observe and claim the final worktree slot independently. */ export function resolveActiveTaskCapacityLimit(params: { - maxConcurrent: number; - maxWorktrees: number; - worktreeLimitEnabled?: boolean; + maxConcurrent?: unknown; + maxWorktrees?: unknown; + worktreeLimitEnabled?: unknown; }): number { - const maxWorktrees = resolveWorktreeCapacityLimit(params); - return maxWorktrees === null - ? params.maxConcurrent - : Math.min(params.maxConcurrent, maxWorktrees); + return resolveEffectiveConcurrency(params).effectiveLimit; } /** @@ -49,13 +46,11 @@ export function formatAdmissionCapacityQueuedReason(params: { claimed: number; holderTaskIds: Iterable; }): string { - const limit = resolveActiveTaskCapacityLimit(params); - const worktreeLimit = resolveWorktreeCapacityLimit(params); - const gate = worktreeLimit !== null && worktreeLimit <= params.maxConcurrent - ? "maxWorktrees" - : "maxConcurrent"; + const concurrency = resolveEffectiveConcurrency(params); + const limit = concurrency.effectiveLimit; + const gate = concurrency.bindingKnob; const holders = [...new Set(params.holderTaskIds)].sort(); - return `queued — ${gate} capacity exhausted: used=${params.claimed}/${limit}; holders=${holders.join(",") || "none"}`; + return `queued — ${gate} capacity exhausted: used=${params.claimed}/${limit}; effectiveLimit=${limit}; bindingKnob=${gate}; holders=${holders.join(",") || "none"}`; } /** Lifecycle lanes ordered by the project admission coordinator. */ diff --git a/packages/engine/src/concurrency/hybrid-executor.ts b/packages/engine/src/concurrency/hybrid-executor.ts index dcfb7aa100..a22b67f535 100644 --- a/packages/engine/src/concurrency/hybrid-executor.ts +++ b/packages/engine/src/concurrency/hybrid-executor.ts @@ -1,5 +1,6 @@ import { EventEmitter } from "node:events"; -import type { Task, CentralCore, RegisteredProject, IsolationMode } from "@fusion/core"; +import { existsSync } from "node:fs"; +import { createTaskStoreForBackend, resolveEffectiveConcurrency, type Task, type CentralCore, type RegisteredProject, type IsolationMode } from "@fusion/core"; import { ProjectManager } from "../project/project-manager.js"; import { NodeHealthMonitor } from "../project/node-health-monitor.js"; import type { @@ -184,12 +185,13 @@ export class HybridExecutor extends EventEmitter { project.id, ); + const capacity = await this.resolveStartupConcurrency(project, workingDirectory); await this.addProject({ projectId: project.id, workingDirectory, isolationMode: project.isolationMode, - maxConcurrent: project.settings?.maxConcurrent ?? 2, - maxWorktrees: project.settings?.maxWorktrees ?? 4, + maxConcurrent: capacity.maxConcurrent, + maxWorktrees: capacity.worktreeLimit ?? capacity.maxConcurrent, settings: project.settings, }); hybridExecutorLog.log(`Loaded project runtime for ${project.name}`); @@ -232,6 +234,25 @@ export class HybridExecutor extends EventEmitter { return runtime; } + /* + FNXC:CapacityModel 2026-08-21-15:45: + FN-9185 requires hybrid runtime startup to read the target project's live settings blob. + A registry snapshot is a fallback only when the project root cannot open its scoped TaskStore. + */ + private async resolveStartupConcurrency(project: RegisteredProject, workingDirectory: string) { + if (!existsSync(workingDirectory)) return resolveEffectiveConcurrency(project.settings); + try { + const boot = await createTaskStoreForBackend({ rootDir: workingDirectory, projectId: project.id }); + try { + return resolveEffectiveConcurrency(await boot.taskStore.getSettingsFast()); + } finally { + await boot.shutdown(); + } + } catch { + return resolveEffectiveConcurrency(project.settings); + } + } + /** * Remove a project runtime and stop it. * @@ -293,12 +314,16 @@ export class HybridExecutor extends EventEmitter { await this.projectManager.removeProject(projectId); // Get the full current config + const capacity = await this.resolveStartupConcurrency({ + ...project, + settings: config.settings ?? project.settings, + }, workingDirectory); const fullConfig: ProjectRuntimeConfig = { projectId, workingDirectory, isolationMode: config.isolationMode, - maxConcurrent: config.maxConcurrent ?? 2, - maxWorktrees: config.maxWorktrees ?? 4, + maxConcurrent: capacity.maxConcurrent, + maxWorktrees: capacity.worktreeLimit ?? capacity.maxConcurrent, settings: config.settings, }; diff --git a/packages/engine/src/executor/create-spawn-agent-tool.ts b/packages/engine/src/executor/create-spawn-agent-tool.ts index b306426d47..3d2776206e 100644 --- a/packages/engine/src/executor/create-spawn-agent-tool.ts +++ b/packages/engine/src/executor/create-spawn-agent-tool.ts @@ -22,7 +22,7 @@ import type { Settings, TaskStore, } from "@fusion/core"; -import { resolveExecutorFallbackModel, resolveProjectColumnsForRoles } from "@fusion/core"; +import { resolveEffectiveConcurrency, resolveExecutorFallbackModel, resolveProjectColumnsForRoles } from "@fusion/core"; import type { ToolDefinition, AgentSession } from "@earendil-works/pi-coding-agent"; import { createResolvedAgentSession, @@ -141,7 +141,7 @@ export function createSpawnAgentTool( store: deps.store, tasks: await deps.store.listTasks({ slim: true, includeArchived: false }), }); - const spawnCap = settings.maxConcurrent ?? 2; + const spawnCap = resolveEffectiveConcurrency(settings).effectiveLimit; const liveChildren = deps.getTotalSpawnedCount(); if (spawnClaimed + liveChildren >= spawnCap) { return { @@ -187,8 +187,8 @@ export function createSpawnAgentTool( to the agent gate alone, matching every other lane. */ { - const spawnMaxWorktrees = (settings as { maxWorktrees?: number | null }).maxWorktrees ?? 4; - if (typeof spawnMaxWorktrees === "number" && Number.isFinite(spawnMaxWorktrees)) { + const spawnMaxWorktrees = resolveEffectiveConcurrency(settings).worktreeLimit; + if (spawnMaxWorktrees !== null) { const spawnTasks = await deps.store.listTasks({ slim: true, includeArchived: false }); /* FNXC:WorkflowResolvedColumns 2026-08-01-03:05: diff --git a/packages/engine/src/project-engine-manager.ts b/packages/engine/src/project-engine-manager.ts index 58933112db..62c673c8ad 100644 --- a/packages/engine/src/project-engine-manager.ts +++ b/packages/engine/src/project-engine-manager.ts @@ -13,14 +13,9 @@ * - Graceful shutdown of all engines via `stopAll()` */ -import { realpathSync } from "node:fs"; +import { existsSync, realpathSync } from "node:fs"; import { resolve as pathResolve } from "node:path"; -import type { - CentralCore, - TaskStore, - RegisteredProject, - MigrationProgressEvent, -} from "@fusion/core"; +import { createTaskStoreForBackend, resolveEffectiveConcurrency, type CentralCore, type TaskStore, type RegisteredProject, type MigrationProgressEvent } from "@fusion/core"; import { ProjectEngine } from "./project-engine.js"; import type { ProjectEngineOptions } from "./project-engine.js"; import type { ProjectRuntimeConfig } from "./project/project-runtime.js"; @@ -528,22 +523,51 @@ export class ProjectEngineManager { } private async buildRuntimeConfig(project: RegisteredProject): Promise { - const settings = project.settings as - | Record - | undefined; + const workingDirectory = await this.centralCore.resolveLocalProjectWorkingDirectory(project.id); + const capacity = await this.resolveStartupConcurrency(project, workingDirectory); return { projectId: project.id, - workingDirectory: await this.centralCore.resolveLocalProjectWorkingDirectory(project.id), + workingDirectory, isolationMode: (project.isolationMode as "in-process" | "child-process") ?? "in-process", - maxConcurrent: (settings?.maxConcurrent as number) ?? 4, - maxWorktrees: (settings?.maxWorktrees as number) ?? 10, + maxConcurrent: capacity.maxConcurrent, + maxWorktrees: capacity.worktreeLimit ?? capacity.maxConcurrent, onMigrationProgress: this.options.onMigrationProgress, }; } + /* + FNXC:CapacityModel 2026-08-21-15:45: + FN-9185 makes live project settings authoritative even while a runtime is starting. + The registry snapshot is used only when a project-scoped TaskStore cannot be opened; this avoids + constructing a startup capacity from a stale central record that omits a persisted project override. + */ + private async resolveStartupConcurrency(project: RegisteredProject, workingDirectory: string) { + const externalStore = this.options.externalTaskStore; + const getExternalSettingsFast = externalStore?.getSettingsFast; + if (externalStore && typeof getExternalSettingsFast === "function" && sameProjectRoot(externalStore.getRootDir(), workingDirectory)) { + return resolveEffectiveConcurrency(await getExternalSettingsFast.call(externalStore)); + } + + // A missing root cannot host a project-scoped store (notably a stale registry row). + if (!existsSync(workingDirectory)) { + return resolveEffectiveConcurrency(project.settings as Record | undefined); + } + + try { + const boot = await createTaskStoreForBackend({ rootDir: workingDirectory, projectId: project.id }); + try { + return resolveEffectiveConcurrency(await boot.taskStore.getSettingsFast()); + } finally { + await boot.shutdown(); + } + } catch { + return resolveEffectiveConcurrency(project.settings as Record | undefined); + } + } + private buildEngineOptions( project: RegisteredProject, workingDirectory: string, diff --git a/packages/engine/src/project-engine.ts b/packages/engine/src/project-engine.ts index 18a0e8c841..24d86f2895 100644 --- a/packages/engine/src/project-engine.ts +++ b/packages/engine/src/project-engine.ts @@ -4326,8 +4326,8 @@ export class ProjectEngine { await projectAdmissionCoordinator.admitNext({ projectId: cwd, maxConcurrent: resolveActiveTaskCapacityLimit({ - maxConcurrent: admissionSettings.maxConcurrent ?? 2, - maxWorktrees: admissionSettings.maxWorktrees ?? 4, + maxConcurrent: admissionSettings.maxConcurrent, + maxWorktrees: admissionSettings.maxWorktrees, worktreeLimitEnabled: admissionSettings.worktreeLimitEnabled, }), claimed: async () => (await getMergeClaimSnapshot()).count, @@ -4346,8 +4346,8 @@ export class ProjectEngine { if (!selected) { const snapshot = await getMergeClaimSnapshot(); const limit = resolveActiveTaskCapacityLimit({ - maxConcurrent: admissionSettings.maxConcurrent ?? 2, - maxWorktrees: admissionSettings.maxWorktrees ?? 4, + maxConcurrent: admissionSettings.maxConcurrent, + maxWorktrees: admissionSettings.maxWorktrees, worktreeLimitEnabled: admissionSettings.worktreeLimitEnabled, }); if (snapshot.count >= limit) { @@ -4358,8 +4358,8 @@ export class ProjectEngine { snapshot proves exhaustion rather than a higher-priority candidate winning. */ const reason = formatAdmissionCapacityQueuedReason({ - maxConcurrent: admissionSettings.maxConcurrent ?? 2, - maxWorktrees: admissionSettings.maxWorktrees ?? 4, + maxConcurrent: admissionSettings.maxConcurrent, + maxWorktrees: admissionSettings.maxWorktrees, worktreeLimitEnabled: admissionSettings.worktreeLimitEnabled, claimed: snapshot.count, holderTaskIds: snapshot.ids, diff --git a/packages/engine/src/project/project-manager.ts b/packages/engine/src/project/project-manager.ts index bd52350704..72a766b8d6 100644 --- a/packages/engine/src/project/project-manager.ts +++ b/packages/engine/src/project/project-manager.ts @@ -1,5 +1,6 @@ import { EventEmitter } from "node:events"; -import type { Task, CentralCore } from "@fusion/core"; +import { existsSync } from "node:fs"; +import { createTaskStoreForBackend, resolveEffectiveConcurrency, type Task, type CentralCore, type RegisteredProject } from "@fusion/core"; import { InProcessRuntime } from "../runtimes/in-process-runtime.js"; import { ChildProcessRuntime } from "../runtimes/child-process-runtime.js"; import { RemoteNodeRuntime } from "../runtimes/remote-node-runtime.js"; @@ -470,12 +471,13 @@ export class ProjectManager extends EventEmitter { const workingDirectory = await this.centralCore.resolveLocalProjectWorkingDirectory(projectId); await this.removeProject(projectId); + const capacity = await this.resolveStartupConcurrency(project, workingDirectory); await this.addProject({ projectId, workingDirectory, isolationMode: project.isolationMode, - maxConcurrent: project.settings?.maxConcurrent ?? 2, - maxWorktrees: project.settings?.maxWorktrees ?? 4, + maxConcurrent: capacity.maxConcurrent, + maxWorktrees: capacity.worktreeLimit ?? capacity.maxConcurrent, settings: project.settings, }); @@ -487,6 +489,24 @@ export class ProjectManager extends EventEmitter { }); } + /* + FNXC:CapacityModel 2026-08-21-15:45: + Runtime restarts must preserve FN-9185's live-settings precedence instead of restoring a stale registry snapshot. + */ + private async resolveStartupConcurrency(project: RegisteredProject, workingDirectory: string) { + if (!existsSync(workingDirectory)) return resolveEffectiveConcurrency(project.settings); + try { + const boot = await createTaskStoreForBackend({ rootDir: workingDirectory, projectId: project.id }); + try { + return resolveEffectiveConcurrency(await boot.taskStore.getSettingsFast()); + } finally { + await boot.shutdown(); + } + } catch { + return resolveEffectiveConcurrency(project.settings); + } + } + /** * Stop all runtimes and clean up. */ diff --git a/packages/engine/src/runtimes/in-process-runtime.ts b/packages/engine/src/runtimes/in-process-runtime.ts index 32c71a4d52..faa433a839 100644 --- a/packages/engine/src/runtimes/in-process-runtime.ts +++ b/packages/engine/src/runtimes/in-process-runtime.ts @@ -616,8 +616,8 @@ export async function admitPlanningContinuation(input: { await projectAdmissionCoordinator.admitNext({ projectId: input.projectId, maxConcurrent: resolveActiveTaskCapacityLimit({ - maxConcurrent: settings.maxConcurrent ?? 2, - maxWorktrees: settings.maxWorktrees ?? 4, + maxConcurrent: settings.maxConcurrent, + maxWorktrees: settings.maxWorktrees, worktreeLimitEnabled: settings.worktreeLimitEnabled, }), claimed: async () => (await getAdmissionSnapshot()).count, @@ -664,8 +664,8 @@ export async function admitPlanningContinuation(input: { } const snapshot = await getAdmissionSnapshot(); const limit = resolveActiveTaskCapacityLimit({ - maxConcurrent: settings.maxConcurrent ?? 2, - maxWorktrees: settings.maxWorktrees ?? 4, + maxConcurrent: settings.maxConcurrent, + maxWorktrees: settings.maxWorktrees, worktreeLimitEnabled: settings.worktreeLimitEnabled, }); if (snapshot.count >= limit) { @@ -676,8 +676,8 @@ export async function admitPlanningContinuation(input: { execute, triage, and merge admission; unchanged retries remain deduplicated. */ const reason = formatAdmissionCapacityQueuedReason({ - maxConcurrent: settings.maxConcurrent ?? 2, - maxWorktrees: settings.maxWorktrees ?? 4, + maxConcurrent: settings.maxConcurrent, + maxWorktrees: settings.maxWorktrees, worktreeLimitEnabled: settings.worktreeLimitEnabled, claimed: snapshot.count, holderTaskIds: snapshot.ids, diff --git a/packages/engine/src/scheduler.ts b/packages/engine/src/scheduler.ts index e609c1ca57..1b9dcba7d6 100644 --- a/packages/engine/src/scheduler.ts +++ b/packages/engine/src/scheduler.ts @@ -44,7 +44,7 @@ import { BacklogPressureReporter } from "./scheduling/backlog-pressure-reporter. import { UnlinkedMissionsAdvisoryReporter } from "./missions/unlinked-missions-advisory-reporter.js"; import { createRunAuditor, generateSyntheticRunId } from "./util/run-audit.js"; import type { TaskMoveLanes } from "@fusion/core"; -import { resolveProjectColumnsForRoles, resolveWorkflowIrForTask, resolveWorkflowIrById, resolveColumnFlags, resolveWorktreeCapacityLimit, resolveLifecycleColumns, isWipColumnRole, isReviewColumnRole, isCompleteColumnRole, columnsWithFlag } from "@fusion/core"; +import { resolveProjectColumnsForRoles, resolveWorkflowIrForTask, resolveWorkflowIrById, resolveColumnFlags, resolveWorktreeCapacityLimit, resolveMaxConcurrentSetting, resolveLifecycleColumns, isWipColumnRole, isReviewColumnRole, isCompleteColumnRole, columnsWithFlag } from "@fusion/core"; import type { WorkflowIr, WorkflowIrV2, WorkflowSelectionCache } from "@fusion/core"; import type { ColumnRoleTraitFlags } from "@fusion/core"; @@ -790,13 +790,21 @@ function computeConcurrencyGateDiagnostic(params: { }; } -function formatConcurrencyLimitReason(diagnostic: ConcurrencyGateDiagnostic): string { +export function formatConcurrencyLimitReason(diagnostic: ConcurrencyGateDiagnostic): string { const holdersText = (gate: ConcurrencyGateName): string => { const holders = diagnostic.holders[gate]; return holders && holders.length > 0 ? holders.join(", ") : "none"; }; const gateLabel = diagnostic.bindingGates.join(", "); + const effectiveLimit = Math.min( + diagnostic.maxConcurrentGate.limit, + diagnostic.maxWorktreesGate?.limit ?? Infinity, + ); + const bindingKnob = diagnostic.maxWorktreesGate && diagnostic.maxWorktreesGate.limit <= diagnostic.maxConcurrentGate.limit + ? "maxWorktrees" + : "maxConcurrent"; const details = [ + `effectiveLimit=${effectiveLimit} (bindingKnob=${bindingKnob})`, `maxConcurrent used=${diagnostic.maxConcurrentGate.used}/${diagnostic.maxConcurrentGate.limit} (holders: ${holdersText("maxConcurrent")})`, ]; /* @@ -2302,16 +2310,15 @@ export class Scheduler { try { // FNXC:CapacityModel 2026-07-28-11:35: null = worktrees are not a capacity // dimension for this project (worktreeLimitEnabled false), NOT unlimited. - const maxWorktrees = resolveWorktreeCapacityLimit({ - maxWorktrees: settings.maxWorktrees ?? this.options.maxWorktrees ?? 4, - worktreeLimitEnabled: settings.worktreeLimitEnabled, - }); - const maxConcurrent = settings.maxConcurrent ?? this.options.maxConcurrent ?? 2; - const activeTaskLimit = resolveActiveTaskCapacityLimit({ - maxConcurrent, - maxWorktrees: settings.maxWorktrees ?? this.options.maxWorktrees ?? 4, - worktreeLimitEnabled: settings.worktreeLimitEnabled, - }); + /* FNXC:CapacityModel 2026-08-21-15:25: live project settings are authoritative; + * scheduler boot options remain fallback-only when a setting is absent. */ + const capacitySettings = { + ...this.options, + ...settings, + }; + const maxWorktrees = resolveWorktreeCapacityLimit(capacitySettings); + const maxConcurrent = resolveMaxConcurrentSetting(capacitySettings); + const activeTaskLimit = resolveActiveTaskCapacityLimit(capacitySettings); /* FNXC:WorkflowScheduling 2026-07-19-02:35 (U4/KTD-9): Count active WIP reservations by the `wip` trait, not the literal diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index 89fb8ceada..69cc398601 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -2145,7 +2145,7 @@ export class TriageProcessor { same maxConcurrent live-agent claim as execute/review so a project cannot exceed its operator-facing top-level capacity in a different lane. */ - const maxConcurrent = settings.maxConcurrent ?? 2; + const maxConcurrent = fusionCore.resolveMaxConcurrentSetting(settings); // processing entries that have not yet written status:"planning" still claim a future slot. let pendingSpecifyCount = 0; for (const id of this.processing) { @@ -2179,15 +2179,8 @@ export class TriageProcessor { reuse/cleanup without consuming admission capacity. Every newly admitted planner becomes live and spends one slot below, even when it reuses an existing directory. */ - const maxWorktrees = resolveWorktreeCapacityLimit({ - maxWorktrees: settings.maxWorktrees ?? 4, - worktreeLimitEnabled: settings.worktreeLimitEnabled, - }); - const activeTaskLimit = resolveActiveTaskCapacityLimit({ - maxConcurrent, - maxWorktrees: settings.maxWorktrees ?? 4, - worktreeLimitEnabled: settings.worktreeLimitEnabled, - }); + const maxWorktrees = resolveWorktreeCapacityLimit(settings); + const activeTaskLimit = resolveActiveTaskCapacityLimit(settings); const worktreeRoom = maxWorktrees === null ? Number.POSITIVE_INFINITY : Math.max(0, maxWorktrees - claimed); @@ -2208,7 +2201,7 @@ export class TriageProcessor { const blockedBy = worktreeRoom <= 0 && projectRoom > 0 ? "worktree cap" : "running-agent cap"; const capacityReason = formatAdmissionCapacityQueuedReason({ maxConcurrent, - maxWorktrees: settings.maxWorktrees ?? 4, + maxWorktrees: settings.maxWorktrees, worktreeLimitEnabled: settings.worktreeLimitEnabled, claimed, holderTaskIds: await persistedTopLevelAgentTaskIdsFromStore(this.store, allTasks),