diff --git a/.changeset/fn-7097-triage-running-count.md b/.changeset/fn-7097-triage-running-count.md new file mode 100644 index 0000000000..876ddb9f55 --- /dev/null +++ b/.changeset/fn-7097-triage-running-count.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: The running-agents count now includes agents actively triaging tasks, not just executors. +category: fix +dev: countRunningAgentsInStore now adds triage-column tasks with status "planning" (not paused) to the live running-agent count alongside in-progress tasks, matching the maxTriageConcurrent liveness predicate; feeds getLiveRunningAgentCounts and the global-concurrency readouts. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 916ca2feb5..87ebd2a27f 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -842,6 +842,7 @@ Features: + - **Overview controls dashboard** sits at the top of the Overview landing surface on desktop and mobile. It includes AI engine stop/start backed by `globalPause`, live scheduler status from executor stats, the shared Global Max Concurrent slider backed by `/api/global-concurrency`, range sliders for `maxConcurrent`, `maxTriageConcurrent`, and `maxWorktrees` that persist through `/api/settings`, and a compact theme dropdown with the same color-chip swatches and Shadcn variant list as Settings → Appearance. The four concurrency sliders ask for confirmation after a changed value settles; confirming persists the new cap, while cancel, backdrop, or Escape dismissal reverts to the last persisted value without saving. The global and current-project max-concurrent sliders show running-agent counts plus a current-use dot on the track once utilization data loads; triage and worktree sliders remain cap-only. These controls reuse existing APIs and App-level theme setters; they do not add a new backend route or second theme owner. - **Overview** summarizes token usage/cost, autonomy, active nodes, sessions, agent runs, tasks done, model breadth, and real open signals, and includes the SDLC throughput funnel for the selected range at the bottom of the Overview content in loading, error, empty, and populated states. Its token total and Live activity snapshot token metric refresh on a bounded live cadence and animate number changes while preserving reduced-motion preferences. The sessions card uses the selected-range `ActivityAnalytics.sessions` value already loaded for the overview. The Live activity snapshot also shows the current board-state count for tasks in progress, independent of the selected analytics date range. Overview includes a graph-rich software-factory snapshot with the existing tokens-by-model bar, tool-category bar, real recharts token-share pie, and the daily activity multi-series line chart placed before the daily activity sparkline/trend so the richer line graph sits higher in the chart grid. These reuse the already-loaded tokens, tools, activity, and signals analytics; the signals count comes from `/api/command-center/signals` and renders unavailable (`—`) while the incidents-backed response is loading or unavailable. The chart reveal/glow accents are decorative and disabled when reduced-motion preferences are active. The SDLC completion rate is shown as a radial gauge and is calculated as cohort conversion from in-range triage entrants, so the rate is capped at 100% even when older tasks finish during the range. @@ -998,7 +999,7 @@ Use this panel when upgrading a project with pre-FN-6245/FN-6277 in-review rows ### Executor footer engine controls -The global AI engine stop/start control and triage pause/resume control live in the executor footer status bar rather than the header. Select the small engine-controls button beside the executor state badge, or select the state text such as **Running**, to open the footer popover. The popover includes **Stop AI engine** / **Start AI engine**, **Pause triage** / **Resume scheduling**, and live scheduler sliders for max concurrent tasks, max triage concurrency, and max worktrees. The global and current-project concurrency sliders also show how many agents are running and a dot on the slider track for current use, clamped to the track when usage exceeds the configured cap. Slider changes save through the existing `/api/settings` path with the same debounced behavior used by Command Center controls; no separate backend route is required. +The global AI engine stop/start control and triage pause/resume control live in the executor footer status bar rather than the header. Select the small engine-controls button beside the executor state badge, or select the state text such as **Running**, to open the footer popover. The popover includes **Stop AI engine** / **Start AI engine**, **Pause triage** / **Resume scheduling**, and live scheduler sliders for max concurrent tasks, max triage concurrency, and max worktrees. The global and current-project concurrency sliders also show how many agents are running, including actively-triaging planners (`triage` + `planning`, not paused), and a dot on the slider track for current use, clamped to the track when usage exceeds the configured cap. Slider changes save through the existing `/api/settings` path with the same debounced behavior used by Command Center controls; no separate backend route is required. ### Engine status banner diff --git a/docs/multi-project.md b/docs/multi-project.md index e2a6aed879..561d9620c5 100644 --- a/docs/multi-project.md +++ b/docs/multi-project.md @@ -112,7 +112,8 @@ Central health tracking keeps mutable project metrics, including: ## Global Concurrency Management -A singleton central record enforces system-wide limits so one project cannot monopolize all execution slots. `globalConcurrency.currentlyActive` remains persisted slot bookkeeping maintained by acquire/free flows; live read-only running-agent displays derive `currentlyActive` and per-project active counts from `in-progress` tasks in already-open project stores, while the persisted `globalMaxConcurrent` cap and `queuedCount` continue to come from central concurrency state. The slot acquire/free limiter semantics and DB column names are unchanged. + +A singleton central record enforces system-wide limits so one project cannot monopolize all execution slots. `globalConcurrency.currentlyActive` remains persisted slot bookkeeping maintained by acquire/free flows; live read-only running-agent displays derive `currentlyActive` and per-project active counts from in-progress tasks plus triage tasks with `status === "planning"` that are not paused in already-open project stores, while the persisted `globalMaxConcurrent` cap and `queuedCount` continue to come from central concurrency state. The slot acquire/free limiter semantics and DB column names are unchanged. ## Plugin Scope in Multi-Project Mode diff --git a/packages/dashboard/src/__tests__/project-store-resolver.test.ts b/packages/dashboard/src/__tests__/project-store-resolver.test.ts index af86aca4c9..ca7e125abe 100644 --- a/packages/dashboard/src/__tests__/project-store-resolver.test.ts +++ b/packages/dashboard/src/__tests__/project-store-resolver.test.ts @@ -13,6 +13,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { getOrCreateProjectStore, countRunningAgentsInRegisteredProjectStores, + countRunningAgentsInStore, evictProjectStore, evictAllProjectStores, listRegisteredProjectStores, @@ -366,8 +367,18 @@ describe("countRunningAgentsInRegisteredProjectStores", () => { createdStores.length = 0; }); - function installTaskList(store: TaskStore, columns: string[]) { - const tasks = columns.map((column, index) => ({ id: `FN-${index + 1}`, column })); + type SeedTask = { + column: string; + status?: string; + paused?: boolean; + }; + + function installTaskList(store: TaskStore, seedTasks: Array) { + const tasks = seedTasks.map((seedTask, index) => ( + typeof seedTask === "string" + ? { id: `FN-${index + 1}`, column: seedTask } + : { id: `FN-${index + 1}`, ...seedTask } + )); const listTasks = vi.fn().mockImplementation(async (options?: { column?: string }) => ( options?.column ? tasks.filter((task) => task.column === options.column) : tasks )); @@ -385,7 +396,7 @@ describe("countRunningAgentsInRegisteredProjectStores", () => { const counts = await countRunningAgentsInRegisteredProjectStores(["proj_open", "proj_unopened"]); expect(counts).toEqual({ proj_open: 2 }); - expect(listTasks).toHaveBeenCalledWith({ column: "in-progress", slim: true }); + expect(listTasks).toHaveBeenCalledWith({ slim: true }); expect(createdStores).toHaveLength(1); expect(openEntry?.watchMock).not.toHaveBeenCalled(); }); @@ -402,7 +413,7 @@ describe("countRunningAgentsInRegisteredProjectStores", () => { const counts = await countRunningAgentsInRegisteredProjectStores(["proj_zero", "proj_unopened"]); expect(counts).toEqual({ proj_zero: 0 }); - expect(zeroListTasks).toHaveBeenCalledWith({ column: "in-progress", slim: true }); + expect(zeroListTasks).toHaveBeenCalledWith({ slim: true }); expect(ignoredListTasks).not.toHaveBeenCalled(); expect(createdStores).toHaveLength(2); expect(zeroEntry?.watchMock).not.toHaveBeenCalled(); @@ -419,8 +430,58 @@ describe("countRunningAgentsInRegisteredProjectStores", () => { const counts = await countRunningAgentsInRegisteredProjectStores(["proj_a", "proj_b"]); expect(counts).toEqual({ proj_a: 1, proj_b: 2 }); - expect(listTasksA).toHaveBeenCalledWith({ column: "in-progress", slim: true }); - expect(listTasksB).toHaveBeenCalledWith({ column: "in-progress", slim: true }); + expect(listTasksA).toHaveBeenCalledWith({ slim: true }); + expect(listTasksB).toHaveBeenCalledWith({ slim: true }); + expect(createdStores).toHaveLength(2); + }); + + it("counts an actively triaging planning task even when no executors are running", async () => { + const store = await getOrCreateProjectStore("proj_triage_only"); + const listTasks = installTaskList(store, [ + { column: "triage", status: "planning" }, + "todo", + ]); + + await expect(countRunningAgentsInStore(store)).resolves.toBe(1); + expect(listTasks).toHaveBeenCalledWith({ slim: true }); + }); + + it("sums in-progress executors and active triage agents while excluding inactive triage states", async () => { + const store = await getOrCreateProjectStore("proj_mixed"); + installTaskList(store, [ + "in-progress", + { column: "triage", status: "planning" }, + { column: "triage", status: "planning", paused: true }, + { column: "triage", status: "triaged" }, + { column: "triage" }, + "todo", + ]); + + await expect(countRunningAgentsInStore(store)).resolves.toBe(2); + }); + + it("returns per-project active triage and executor counts for multiple already-open stores", async () => { + const storeA = await getOrCreateProjectStore("proj_triage_a"); + const storeB = await getOrCreateProjectStore("proj_triage_b"); + const listTasksA = installTaskList(storeA, [ + "in-progress", + { column: "triage", status: "planning" }, + { column: "triage", status: "waiting" }, + ]); + const listTasksB = installTaskList(storeB, [ + { column: "triage", status: "planning" }, + { column: "triage", status: "planning" }, + { column: "triage", status: "planning", paused: true }, + "done", + ]); + vi.clearAllMocks(); + + const counts = await countRunningAgentsInRegisteredProjectStores(["proj_triage_a", "proj_triage_b"]); + + expect(counts).toEqual({ proj_triage_a: 2, proj_triage_b: 2 }); + expect(Object.values(counts).reduce((sum, count) => sum + count, 0)).toBe(4); + expect(listTasksA).toHaveBeenCalledWith({ slim: true }); + expect(listTasksB).toHaveBeenCalledWith({ slim: true }); expect(createdStores).toHaveLength(2); }); }); diff --git a/packages/dashboard/src/project-store-resolver.ts b/packages/dashboard/src/project-store-resolver.ts index cdc0f83ac0..1920c7636a 100644 --- a/packages/dashboard/src/project-store-resolver.ts +++ b/packages/dashboard/src/project-store-resolver.ts @@ -158,9 +158,16 @@ export function listRegisteredProjectStores(): Array<{ projectId: string; store: return Array.from(storeCache.entries(), ([projectId, store]) => ({ projectId, store })); } +/** + * FNXC:GlobalConcurrencyControls 2026-06-26-18:20: + * The live running-agent count must include actively-triaging agents because `triage` + `planning` tasks hold a global concurrency slot just like in-progress executors. Mirror the `maxTriageConcurrent` liveness predicate from `triage.ts` so the footer and Command Center do not under-count planning work. + */ export async function countRunningAgentsInStore(store: TaskStore): Promise { - const tasks = await store.listTasks({ column: "in-progress", slim: true }); - return tasks.length; + const tasks = await store.listTasks({ slim: true }); + return tasks.filter((task) => ( + task.column === "in-progress" || + (task.column === "triage" && task.status === "planning" && !task.paused) + )).length; } /**