From a95cfa7f92f22d042f8279109cd44fdbd1addb07 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 27 Jun 2026 21:39:40 -0700 Subject: [PATCH] FN-7160: unify live agent counts and fix use markers Unifies running-agent slot accounting across engine, CLI, and dashboard surfaces. - Add shared core helpers for identifying and counting active top-level agent tasks, including in-review reviewer, merger, fix, and PR merge states. - Reuse the shared count in engine semaphore repair, project health displays, and dashboard project-store routes. - Correct Command Center utilization markers to use a zero-based active/cap ratio. - Cover the shared predicate and affected CLI, dashboard, and engine behaviors with regression tests. Files changed: .changeset/fn-7160-running-agent-count.md | 7 ++++ docs/dashboard-guide.md | 2 +- .../cli/src/commands/__tests__/project.test.ts | 40 ++++++++++++++---- packages/cli/src/commands/project.ts | 15 +++---- .../core/src/__tests__/live-agent-count.test.ts | 47 ++++++++++++++++++++++ packages/core/src/index.ts | 2 + packages/core/src/live-agent-count.ts | 30 ++++++++++++++ packages/core/src/types.ts | 2 +- .../command-center/CommandCenterControls.tsx | 14 ++++--- .../__tests__/CommandCenterControls.test.tsx | 22 +++++++++- .../dashboard/src/__tests__/project-routes.test.ts | 31 +++++++++----- .../src/__tests__/project-store-resolver.test.ts | 20 ++++++--- packages/dashboard/src/project-store-resolver.ts | 9 ++--- .../src/routes/register-project-routes.ts | 7 ++-- packages/engine/src/__tests__/concurrency.test.ts | 18 +++++++++ packages/engine/src/concurrency.ts | 12 +++--- 16 files changed, 220 insertions(+), 58 deletions(-) Fusion-Task-Id: FN-7160 Fusion-Task-Lineage: f48332f8-ac5d-46eb-b975-d2f6c880ddd5 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-7160-running-agent-count.md | 7 +++ docs/dashboard-guide.md | 2 +- .../src/commands/__tests__/project.test.ts | 40 ++++++++++++---- packages/cli/src/commands/project.ts | 15 +++--- .../src/__tests__/live-agent-count.test.ts | 47 +++++++++++++++++++ packages/core/src/index.ts | 2 + packages/core/src/live-agent-count.ts | 30 ++++++++++++ packages/core/src/types.ts | 2 +- .../command-center/CommandCenterControls.tsx | 14 ++++-- .../__tests__/CommandCenterControls.test.tsx | 22 ++++++++- .../src/__tests__/project-routes.test.ts | 31 +++++++----- .../__tests__/project-store-resolver.test.ts | 20 ++++++-- .../dashboard/src/project-store-resolver.ts | 9 ++-- .../src/routes/register-project-routes.ts | 7 ++- .../engine/src/__tests__/concurrency.test.ts | 18 +++++++ packages/engine/src/concurrency.ts | 12 ++--- 16 files changed, 220 insertions(+), 58 deletions(-) create mode 100644 .changeset/fn-7160-running-agent-count.md create mode 100644 packages/core/src/__tests__/live-agent-count.test.ts diff --git a/.changeset/fn-7160-running-agent-count.md b/.changeset/fn-7160-running-agent-count.md new file mode 100644 index 0000000000..41adf73483 --- /dev/null +++ b/.changeset/fn-7160-running-agent-count.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Running-agent counts include active in-review agents, and the concurrency use-marker is no longer off by one. +category: fix +dev: Adds shared isRunningAgentTask/countRunningAgentTasks in @fusion/core; engine concurrency.persistedTopLevelAgentSlots and the dashboard/CLI count surfaces delegate to it. CommandCenterControls use-marker ratio is now 0-based. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 82b0f1fe8c..c6dff8ce52 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -849,7 +849,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. diff --git a/packages/cli/src/commands/__tests__/project.test.ts b/packages/cli/src/commands/__tests__/project.test.ts index 5627c5c969..326ac80d97 100644 --- a/packages/cli/src/commands/__tests__/project.test.ts +++ b/packages/cli/src/commands/__tests__/project.test.ts @@ -64,6 +64,11 @@ vi.mock("@fusion/core", () => ({ init: mockTaskStoreInit, listTasks: mockTaskStoreListTasks, })), + countRunningAgentTasks: (tasks: Array<{ column: string; status?: string; paused?: boolean }>) => tasks.filter((task) => ( + task.column === "in-progress" || + (task.column === "triage" && task.status === "planning" && !task.paused) || + (task.column === "in-review" && ["merging", "merging-pr", "merging-fix", "reviewing", "fixing"].includes(String(task.status ?? "")) && !task.paused) + )).length, ensureMemoryFileWithBackend: mockEnsureMemoryFileWithBackend, readProjectIdentity: vi.fn().mockReturnValue(undefined), writeProjectIdentity: vi.fn(), @@ -359,7 +364,7 @@ describe("project commands", () => { expect(output).toContain("Completed: 10"); }); - it("runProjectShow derives In-Flight Agents from live executors and triage planners when central health is stale", async () => { + it("runProjectShow derives In-Flight Agents from live executors, triage planners, and in-review agents when central health is stale", async () => { mockGetProject.mockResolvedValue({ id: "proj-1", name: "demo", @@ -387,17 +392,23 @@ describe("project commands", () => { { id: "FN-004", column: "triage", status: "planning", paused: false }, { id: "FN-005", column: "triage", status: "planning", paused: true }, { id: "FN-006", column: "triage", status: "awaiting-approval", paused: false }, + { id: "FN-007", column: "in-review", status: "reviewing", paused: false }, + { id: "FN-008", column: "in-review", status: "merging", paused: false }, + { id: "FN-009", column: "in-review", status: "merging-pr", paused: false }, + { id: "FN-010", column: "in-review", status: "merging-fix", paused: false }, + { id: "FN-011", column: "in-review", status: "fixing", paused: false }, + { id: "FN-012", column: "in-review", status: "reviewing", paused: true }, ]); const { runProjectShow } = await import("../project.js"); await runProjectShow("proj-1"); const output = consoleSpy.mock.calls.map((call) => String(call[0])).join("\n"); - expect(output).toContain("In-Flight Agents: 3"); + expect(output).toContain("In-Flight Agents: 8"); expect(staleHealth.inFlightAgentCount).toBe(0); }); - it("runProjectList JSON derives health.inFlightAgentCount from live executors and triage planners", async () => { + it("runProjectList JSON derives health.inFlightAgentCount from live executors, triage planners, and in-review agents", async () => { mockListProjects.mockResolvedValue([ { id: "proj-1", name: "app-one", path: "/tmp/app-one", status: "active", isolationMode: "in-process" }, ]); @@ -415,18 +426,24 @@ describe("project commands", () => { { id: "FN-002", column: "in-progress" }, { id: "FN-003", column: "triage", status: "planning", paused: false }, { id: "FN-004", column: "triage", status: "triaged", paused: false }, + { id: "FN-005", column: "in-review", status: "reviewing", paused: false }, + { id: "FN-006", column: "in-review", status: "merging", paused: false }, + { id: "FN-007", column: "in-review", status: "merging-pr", paused: false }, + { id: "FN-008", column: "in-review", status: "merging-fix", paused: false }, + { id: "FN-009", column: "in-review", status: "fixing", paused: false }, + { id: "FN-010", column: "in-review", status: "fixing", paused: true }, ]); const { runProjectList } = await import("../project.js"); await runProjectList({ json: true }); const parsed = JSON.parse(consoleSpy.mock.calls.map((call) => String(call[0])).join("")); - expect(parsed[0].health.inFlightAgentCount).toBe(3); + expect(parsed[0].health.inFlightAgentCount).toBe(8); expect(parsed[0].health.activeTaskCount).toBe(1); expect(mockGetProjectHealth.mock.results[0]).toBeDefined(); }); - it("runProjectList JSON keeps per-project triage planner counts isolated", async () => { + it("runProjectList JSON keeps per-project running-agent counts isolated", async () => { mockListProjects.mockResolvedValue([ { id: "proj-1", name: "app-one", path: "/tmp/app-one", status: "active", isolationMode: "in-process" }, { id: "proj-2", name: "app-two", path: "/tmp/app-two", status: "active", isolationMode: "in-process" }, @@ -444,18 +461,21 @@ describe("project commands", () => { .mockResolvedValueOnce([ { id: "FN-001", column: "triage", status: "planning", paused: false }, { id: "FN-002", column: "triage", status: "planning", paused: true }, + { id: "FN-003", column: "in-review", status: "reviewing", paused: false }, ]) .mockResolvedValueOnce([ { id: "FN-010", column: "in-progress" }, { id: "FN-011", column: "triage", status: "planning", paused: false }, { id: "FN-012", column: "triage", status: "awaiting-approval", paused: false }, + { id: "FN-013", column: "in-review", status: "fixing", paused: false }, + { id: "FN-014", column: "in-review", status: "merging-fix", paused: false }, ]); const { runProjectList } = await import("../project.js"); await runProjectList({ json: true }); const parsed = JSON.parse(consoleSpy.mock.calls.map((call) => String(call[0])).join("")); - expect(parsed.map((project: { health: { inFlightAgentCount: number } }) => project.health.inFlightAgentCount)).toEqual([1, 2]); + expect(parsed.map((project: { health: { inFlightAgentCount: number } }) => project.health.inFlightAgentCount)).toEqual([2, 4]); }); it("runProjectList table prints the live In-Flight column", async () => { @@ -475,6 +495,7 @@ describe("project commands", () => { { id: "FN-001", column: "todo" }, { id: "FN-002", column: "in-progress" }, { id: "FN-003", column: "triage", status: "planning", paused: false }, + { id: "FN-004", column: "in-review", status: "merging-pr", paused: false }, ]); const { runProjectList } = await import("../project.js"); @@ -483,7 +504,7 @@ describe("project commands", () => { const output = consoleSpy.mock.calls.map((call) => String(call[0])).join("\n"); const projectLine = output.split("\n").find((line) => line.includes("app-one")); expect(output).toContain("In-Flight"); - expect(projectLine).toContain(" 2"); + expect(projectLine).toContain(" 3"); }); it("runProjectShow reports zero live In-Flight Agents when no tasks hold agent slots", async () => { @@ -510,6 +531,8 @@ describe("project commands", () => { { id: "FN-002", column: "done" }, { id: "FN-003", column: "triage", status: "planning", paused: true }, { id: "FN-004", column: "triage", status: "waiting", paused: false }, + { id: "FN-005", column: "in-review", status: "reviewing", paused: true }, + { id: "FN-006", column: "in-review", status: "pending", paused: false }, ]); const { runProjectShow } = await import("../project.js"); @@ -534,6 +557,7 @@ describe("project commands", () => { mockTaskStoreListTasks.mockResolvedValue([ { id: "FN-001", column: "in-progress" }, { id: "FN-002", column: "triage", status: "planning", paused: false }, + { id: "FN-003", column: "in-review", status: "reviewing", paused: false }, ]); const { runProjectShow } = await import("../project.js"); @@ -541,7 +565,7 @@ describe("project commands", () => { const output = consoleSpy.mock.calls.map((call) => String(call[0])).join("\n"); expect(output).toContain("Health:"); - expect(output).toContain("In-Flight Agents: 2"); + expect(output).toContain("In-Flight Agents: 3"); }); it("runProjectShow falls back to zero In-Flight Agents when the task store is unreadable", async () => { diff --git a/packages/cli/src/commands/project.ts b/packages/cli/src/commands/project.ts index 827161db6f..acbad7c631 100644 --- a/packages/cli/src/commands/project.ts +++ b/packages/cli/src/commands/project.ts @@ -21,6 +21,7 @@ import { COLUMNS, COLUMN_LABELS, type Column, + countRunningAgentTasks, readProjectIdentity, writeProjectIdentity, } from "@fusion/core"; @@ -84,7 +85,7 @@ export interface ProjectInfoData { interface TaskCountSummary { byColumn: Record; - activeTriagePlannerCount: number; + runningAgentCount: number; } /** @@ -131,17 +132,13 @@ async function getTaskCounts(projectPath: string): Promise { for (const col of COLUMNS) { counts[col] = 0; } - let activeTriagePlannerCount = 0; for (const task of tasks) { counts[task.column] = (counts[task.column] || 0) + 1; - if (task.column === "triage" && task.status === "planning" && !task.paused) { - activeTriagePlannerCount += 1; - } } - return { byColumn: counts, activeTriagePlannerCount }; + return { byColumn: counts, runningAgentCount: countRunningAgentTasks(tasks) }; } catch { // Return empty counts if we can't read the project - return { byColumn: {}, activeTriagePlannerCount: 0 }; + return { byColumn: {}, runningAgentCount: 0 }; } } @@ -156,9 +153,9 @@ function getLiveInFlightAgentCount(taskCounts: TaskCountSummary): number { /* * FNXC:CLIProjectHealth 2026-06-26-23:46: * FN-7081 confirmed central projectHealth.inFlightAgentCount is slot/health bookkeeping that can be stale or zero in the default in-process runtime. - * User-visible In-Flight Agents must mirror the dashboard read route and FN-7097's global running-agent count by deriving live agents from in-progress tasks plus active triage planners (`triage` + `planning` + not paused) without mutating persisted health rows. + * User-visible In-Flight Agents must mirror the dashboard read route and global running-agent count by deriving live agents from the shared top-level slot predicate, including active in-review reviewer/merger/fix agents, without mutating persisted health rows. */ - return (taskCounts.byColumn["in-progress"] ?? 0) + taskCounts.activeTriagePlannerCount; + return taskCounts.runningAgentCount; } function buildDisplayHealth( diff --git a/packages/core/src/__tests__/live-agent-count.test.ts b/packages/core/src/__tests__/live-agent-count.test.ts new file mode 100644 index 0000000000..55d310c433 --- /dev/null +++ b/packages/core/src/__tests__/live-agent-count.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { countRunningAgentTasks, isRunningAgentTask } from "../live-agent-count.js"; +import type { Task } from "../types.js"; + +function task(overrides: Pick & Partial>): Pick { + return { + column: overrides.column, + status: overrides.status, + paused: overrides.paused, + }; +} + +describe("live agent count predicates", () => { + it("identifies tasks that hold top-level running-agent slots", () => { + expect(isRunningAgentTask(task({ column: "in-progress" }))).toBe(true); + expect(isRunningAgentTask(task({ column: "triage", status: "planning", paused: false }))).toBe(true); + expect(isRunningAgentTask(task({ column: "triage", status: "planning", paused: true }))).toBe(false); + + for (const status of ["merging", "merging-pr", "merging-fix", "reviewing", "fixing"]) { + expect(isRunningAgentTask(task({ column: "in-review", status, paused: false }))).toBe(true); + } + + expect(isRunningAgentTask(task({ column: "in-review", paused: false }))).toBe(false); + expect(isRunningAgentTask(task({ column: "in-review", status: "pending", paused: false }))).toBe(false); + expect(isRunningAgentTask(task({ column: "in-review", status: "reviewing", paused: true }))).toBe(false); + expect(isRunningAgentTask(task({ column: "done" }))).toBe(false); + expect(isRunningAgentTask(task({ column: "todo" }))).toBe(false); + expect(isRunningAgentTask(task({ column: "archived" }))).toBe(false); + }); + + it("counts only tasks that satisfy the shared running-agent predicate", () => { + expect(countRunningAgentTasks([ + task({ column: "in-progress" }), + task({ column: "triage", status: "planning", paused: false }), + task({ column: "triage", status: "planning", paused: true }), + task({ column: "in-review", status: "merging", paused: false }), + task({ column: "in-review", status: "merging-pr", paused: false }), + task({ column: "in-review", status: "merging-fix", paused: false }), + task({ column: "in-review", status: "reviewing", paused: false }), + task({ column: "in-review", status: "fixing", paused: false }), + task({ column: "in-review", status: "fixing", paused: true }), + task({ column: "todo" }), + task({ column: "done" }), + task({ column: "archived" }), + ])).toBe(7); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index da3b42c7b0..60e3db5466 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -416,6 +416,8 @@ export { setRunningAgentCountSource, getRunningAgentCountSource, deriveRunningAgentCounts, + isRunningAgentTask, + countRunningAgentTasks, type RunningAgentCountSource, type RunningAgentCounts, } from "./live-agent-count.js"; diff --git a/packages/core/src/live-agent-count.ts b/packages/core/src/live-agent-count.ts index 4ea062f7d5..94236d2309 100644 --- a/packages/core/src/live-agent-count.ts +++ b/packages/core/src/live-agent-count.ts @@ -1,5 +1,11 @@ +import type { Task } from "./types.js"; + export type RunningAgentCountSource = (projectIds: readonly string[]) => Promise> | Record; +type RunningAgentTaskShape = Pick; + +const ACTIVE_IN_REVIEW_AGENT_STATUSES = new Set(["merging", "merging-pr", "merging-fix", "reviewing", "fixing"]); + let runningAgentCountSource: RunningAgentCountSource | undefined; /** @@ -22,6 +28,30 @@ export interface RunningAgentCounts { projectsActive: Record; } +/** + * FNXC:GlobalConcurrencyControls 2026-06-27-00:00: + * FN-7160 defines live running-agent counts as top-level concurrency slot holders: in-progress executors, active unpaused triage planners, and active unpaused in-review reviewer/merger/fix agents, including PR/fix merge substates. Keep this pure predicate as the shared source of truth for engine slot accounting and all dashboard/CLI read-layer count surfaces so in-review agents cannot drift out of utilization displays again. + */ +export function isRunningAgentTask(task: RunningAgentTaskShape): boolean { + if (task.column === "in-progress") { + return true; + } + + if (task.column === "triage") { + return task.status === "planning" && !task.paused; + } + + if (task.column === "in-review") { + return ACTIVE_IN_REVIEW_AGENT_STATUSES.has(String(task.status ?? "")) && !task.paused; + } + + return false; +} + +export function countRunningAgentTasks(tasks: readonly RunningAgentTaskShape[]): number { + return tasks.filter(isRunningAgentTask).length; +} + export function deriveRunningAgentCounts(perProject: Record): RunningAgentCounts { const projectsActive: Record = {}; let currentlyActive = 0; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index c8e7ac2d19..de329da5c3 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -5751,7 +5751,7 @@ export interface ProjectHealth { /** * FNXC:Concurrency 2026-06-26-23:46: * Persisted project-health bookkeeping refreshed only by health polling / slot accounting paths; it is not a live read-layer running-agent count. - * Consumers that need current running agents must derive from in-progress executors plus active triage planners (`column === "triage" && status === "planning" && !paused`) so FN-7102 project health matches FN-7097 global concurrency, and leave this stored value untouched. + * Consumers that need current running agents must derive from the shared top-level slot predicate: in-progress executors, active triage planners (`column === "triage" && status === "planning" && !paused`), and active in-review reviewer/merger/fix agents including PR/fix merge substates, leaving this stored value untouched. */ inFlightAgentCount: number; /** ISO-8601 timestamp of last activity */ diff --git a/packages/dashboard/app/components/command-center/CommandCenterControls.tsx b/packages/dashboard/app/components/command-center/CommandCenterControls.tsx index 005825fcd0..e4b072b149 100644 --- a/packages/dashboard/app/components/command-center/CommandCenterControls.tsx +++ b/packages/dashboard/app/components/command-center/CommandCenterControls.tsx @@ -69,9 +69,13 @@ function getConcurrencySliderMax(key: keyof ConcurrencyValues, value: number) { return Math.max(CONCURRENCY_SLIDER_LIMITS[key].max, value); } -function getUseMarkerRatio(current: number, min: number, max: number) { - if (max <= min) return 0; - return clamp((current - min) / (max - min), 0, 1); +/* +FNXC:GlobalConcurrencyControls 2026-06-27-00:00: +FN-7160 requires the current-use marker to show absolute utilization on a 0..cap scale. Do not reuse slider value coordinates here: the slider floor is 1, and subtracting it makes one active agent render as 0% utilization. +*/ +function getUseMarkerRatio(current: number, max: number) { + if (max <= 0) return 0; + return clamp(current / max, 0, 1); } function getUseMarkerStyle(ratio: number): CSSProperties { @@ -289,8 +293,8 @@ export function CommandCenterControls({ projectId, colorTheme, themeMode, shadcn const globalSliderValue = pendingGlobalConcurrencyValue ?? gc.value; const globalSliderMax = Math.max(gc.sliderMax, globalSliderValue); const maxConcurrentSliderMax = getConcurrencySliderMax("maxConcurrent", concurrencyValues.maxConcurrent); - const globalUseMarkerRatio = getUseMarkerRatio(gc.currentlyActive, gc.min, globalSliderMax); - const projectUseMarkerRatio = getUseMarkerRatio(projectActive, CONCURRENCY_SLIDER_LIMITS.maxConcurrent.min, maxConcurrentSliderMax); + const globalUseMarkerRatio = getUseMarkerRatio(gc.currentlyActive, globalSliderMax); + const projectUseMarkerRatio = getUseMarkerRatio(projectActive, maxConcurrentSliderMax); // FNXC:GlobalConcurrencyControls 2026-06-25-22:45: Mirror the per-project slider save-state labels for the shared global cap. // FNXC:GlobalConcurrencyControls 2026-06-26-06:05: Explicit load-error branch — a failed initial load leaves saveState "idle", so the label otherwise fell through to "Ready" while the slider was disabled and an error alert shown. const globalSaveLabel = gc.status === "loading" || gc.status === "idle" diff --git a/packages/dashboard/app/components/command-center/__tests__/CommandCenterControls.test.tsx b/packages/dashboard/app/components/command-center/__tests__/CommandCenterControls.test.tsx index e2723f7fed..7dceb27f24 100644 --- a/packages/dashboard/app/components/command-center/__tests__/CommandCenterControls.test.tsx +++ b/packages/dashboard/app/components/command-center/__tests__/CommandCenterControls.test.tsx @@ -137,11 +137,29 @@ describe("CommandCenterControls", () => { expect(within(section).getByTestId("cc-global-running")).toHaveTextContent("3 running (all projects)"); expect(within(section).getByTestId("cc-project-running")).toHaveTextContent("2 running (this project)"); - expect(within(section).getByTestId("cc-global-use-marker").style.getPropertyValue("--use-pct")).toBe(`${((3 - 1) / (32 - 1)) * 100}%`); - expect(within(section).getByTestId("cc-project-use-marker").style.getPropertyValue("--use-pct")).toBe(`${((2 - 1) / (50 - 1)) * 100}%`); + expect(within(section).getByTestId("cc-global-use-marker").style.getPropertyValue("--use-pct")).toBe(`${(3 / 32) * 100}%`); + expect(within(section).getByTestId("cc-project-use-marker").style.getPropertyValue("--use-pct")).toBe(`${(2 / 50) * 100}%`); expect(within(section).queryAllByTestId(/cc-.*-use-marker/)).toHaveLength(2); }); + it("positions one active agent above zero on both use markers", async () => { + mocks.fetchGlobalConcurrency.mockResolvedValueOnce({ + globalMaxConcurrent: 8, + currentlyActive: 1, + queuedCount: 0, + projectsActive: { "project-a": 1 }, + }); + renderControls("project-a"); + + await flushPromises(); + const section = screen.getByTestId("cc-controls-concurrency"); + + expect(within(section).getByTestId("cc-global-use-marker").style.getPropertyValue("--use-pct")).toBe(`${(1 / 32) * 100}%`); + expect(within(section).getByTestId("cc-project-use-marker").style.getPropertyValue("--use-pct")).toBe(`${(1 / 50) * 100}%`); + expect(within(section).getByTestId("cc-global-use-marker").style.getPropertyValue("--use-pct")).not.toBe("0%"); + expect(within(section).getByTestId("cc-project-use-marker").style.getPropertyValue("--use-pct")).not.toBe("0%"); + }); + it("shows truthful zero or missing project running counts only after utilization loads", async () => { mocks.fetchGlobalConcurrency.mockResolvedValueOnce({ globalMaxConcurrent: 8, diff --git a/packages/dashboard/src/__tests__/project-routes.test.ts b/packages/dashboard/src/__tests__/project-routes.test.ts index 16ba7390dc..af9067d0bb 100644 --- a/packages/dashboard/src/__tests__/project-routes.test.ts +++ b/packages/dashboard/src/__tests__/project-routes.test.ts @@ -1437,17 +1437,22 @@ describe("GET /api/projects/:id/health route handler", () => { // Should have computed counts from project-scoped store, not stale central data expect(health.projectId).toBe("proj_a"); expect(health.activeTaskCount).toBe(4); // triage + todo + in-progress + in-review - expect(health.inFlightAgentCount).toBe(2); // in-progress + active triage planner + expect(health.inFlightAgentCount).toBe(2); // in-progress + active triage planner (plain in-review without active status is inactive) expect(health.totalTasksCompleted).toBe(2); // done + archived expect(health.status).toBe("active"); }); - it("counts only active triage planners as in-flight when no executors are running", async () => { + it("counts active triage planners and active in-review agents as in-flight when no executors are running", async () => { const tasks = [ { id: "FN-1", column: "triage", status: "planning", paused: false }, { id: "FN-2", column: "triage", status: "planning", paused: true }, { id: "FN-3", column: "triage", status: "awaiting-approval", paused: false }, { id: "FN-4", column: "todo" }, + { id: "FN-5", column: "in-review", status: "reviewing", paused: false }, + { id: "FN-6", column: "in-review", status: "merging", paused: false }, + { id: "FN-7", column: "in-review", status: "fixing", paused: false }, + { id: "FN-8", column: "in-review", status: "reviewing", paused: true }, + { id: "FN-9", column: "in-review", status: "pending", paused: false }, ]; const mockStore = createMockStoreWithTasks(tasks); @@ -1478,8 +1483,8 @@ describe("GET /api/projects/:id/health route handler", () => { expect(res.status).toBe(200); const health = res.body as Record; - expect(health.activeTaskCount).toBe(4); - expect(health.inFlightAgentCount).toBe(1); + expect(health.activeTaskCount).toBe(9); + expect(health.inFlightAgentCount).toBe(4); expect(health.totalTasksCompleted).toBe(0); }); @@ -1490,16 +1495,19 @@ describe("GET /api/projects/:id/health route handler", () => { { id: "FN-2", column: "in-progress" }, { id: "FN-3", column: "done" }, ]; - // Project B has 7 tasks, including one active and two inactive triage tasks. + // Project B has 9 active tasks, including one executor, one active triage planner, and three active in-review agents. const projectBTasks = [ { id: "FN-10", column: "todo" }, { id: "FN-11", column: "todo" }, { id: "FN-12", column: "in-progress" }, - { id: "FN-13", column: "in-review" }, + { id: "FN-13", column: "in-review", status: "reviewing", paused: false }, { id: "FN-14", column: "archived" }, { id: "FN-15", column: "triage", status: "planning", paused: false }, { id: "FN-16", column: "triage", status: "planning", paused: true }, { id: "FN-17", column: "triage", status: "awaiting-approval", paused: false }, + { id: "FN-18", column: "in-review", status: "merging", paused: false }, + { id: "FN-19", column: "in-review", status: "fixing", paused: false }, + { id: "FN-20", column: "in-review", status: "fixing", paused: true }, ]; const storeA = createMockStoreWithTasks(projectATasks); @@ -1572,9 +1580,9 @@ describe("GET /api/projects/:id/health route handler", () => { expect(resB.status).toBe(200); const healthB = resB.body as Record; - // Project B: 2 todo + 1 in-progress + 1 in-review + 3 triage = 7 active, 2 in-flight - expect(healthB.activeTaskCount).toBe(7); // 2 todo + 1 in-progress + 1 in-review + 3 triage - expect(healthB.inFlightAgentCount).toBe(2); // in-progress + active triage planner only + // Project B: 2 todo + 1 in-progress + 4 in-review + 3 triage = 10 active, 5 in-flight + expect(healthB.activeTaskCount).toBe(10); // 2 todo + 1 in-progress + 4 in-review + 3 triage + expect(healthB.inFlightAgentCount).toBe(5); // in-progress + active triage planner + active in-review agents expect(healthB.totalTasksCompleted).toBe(1); // archived // Verify no bleed-through: project A counts should not equal project B counts @@ -1591,6 +1599,7 @@ describe("GET /api/projects/:id/health route handler", () => { { id: "FN-3", column: "in-progress" }, { id: "FN-4", column: "done" }, { id: "FN-5", column: "triage", status: "planning", paused: false }, + { id: "FN-6", column: "in-review", status: "reviewing", paused: false }, ]; const mockStore = createMockStoreWithTasks(tasks); @@ -1617,8 +1626,8 @@ describe("GET /api/projects/:id/health route handler", () => { // Should have computed counts from project store expect(health.projectId).toBe("proj_new"); - expect(health.activeTaskCount).toBe(4); // todo + 2 in-progress + triage - expect(health.inFlightAgentCount).toBe(3); // 2 in-progress + active triage planner + expect(health.activeTaskCount).toBe(5); // todo + 2 in-progress + triage + in-review + expect(health.inFlightAgentCount).toBe(4); // 2 in-progress + active triage planner + active in-review agent expect(health.totalTasksCompleted).toBe(1); // done expect(health.status).toBe("active"); }); diff --git a/packages/dashboard/src/__tests__/project-store-resolver.test.ts b/packages/dashboard/src/__tests__/project-store-resolver.test.ts index ca7e125abe..bf76fc1aea 100644 --- a/packages/dashboard/src/__tests__/project-store-resolver.test.ts +++ b/packages/dashboard/src/__tests__/project-store-resolver.test.ts @@ -446,7 +446,7 @@ describe("countRunningAgentsInRegisteredProjectStores", () => { expect(listTasks).toHaveBeenCalledWith({ slim: true }); }); - it("sums in-progress executors and active triage agents while excluding inactive triage states", async () => { + it("sums in-progress executors, active triage agents, and active in-review agents while excluding inactive states", async () => { const store = await getOrCreateProjectStore("proj_mixed"); installTaskList(store, [ "in-progress", @@ -454,13 +454,20 @@ describe("countRunningAgentsInRegisteredProjectStores", () => { { column: "triage", status: "planning", paused: true }, { column: "triage", status: "triaged" }, { column: "triage" }, + { column: "in-review", status: "reviewing", paused: false }, + { column: "in-review", status: "merging", paused: false }, + { column: "in-review", status: "merging-pr", paused: false }, + { column: "in-review", status: "merging-fix", paused: false }, + { column: "in-review", status: "fixing", paused: false }, + { column: "in-review", status: "reviewing", paused: true }, + { column: "in-review", status: "pending", paused: false }, "todo", ]); - await expect(countRunningAgentsInStore(store)).resolves.toBe(2); + await expect(countRunningAgentsInStore(store)).resolves.toBe(7); }); - it("returns per-project active triage and executor counts for multiple already-open stores", async () => { + it("returns per-project active triage, in-review, 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, [ @@ -472,14 +479,17 @@ describe("countRunningAgentsInRegisteredProjectStores", () => { { column: "triage", status: "planning" }, { column: "triage", status: "planning" }, { column: "triage", status: "planning", paused: true }, + { column: "in-review", status: "fixing", paused: false }, + { column: "in-review", status: "merging-fix", paused: false }, + { column: "in-review", status: "merging", 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(counts).toEqual({ proj_triage_a: 2, proj_triage_b: 4 }); + expect(Object.values(counts).reduce((sum, count) => sum + count, 0)).toBe(6); 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 1920c7636a..c981dd6ae9 100644 --- a/packages/dashboard/src/project-store-resolver.ts +++ b/packages/dashboard/src/project-store-resolver.ts @@ -14,7 +14,7 @@ * const store = await getOrCreateProjectStore(projectId); */ -import type { TaskStore } from "@fusion/core"; +import { countRunningAgentTasks, type TaskStore } from "@fusion/core"; /** * Internal cache: projectId → TaskStore instance. @@ -160,14 +160,11 @@ export function listRegisteredProjectStores(): Array<{ projectId: string; 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. + * The live running-agent count must include every top-level slot holder: in-progress executors, active triage planners, and active in-review reviewer/merger/fix agents. Delegate to the shared core predicate so the footer and Command Center cannot under-count in-review work. */ export async function countRunningAgentsInStore(store: TaskStore): Promise { const tasks = await store.listTasks({ slim: true }); - return tasks.filter((task) => ( - task.column === "in-progress" || - (task.column === "triage" && task.status === "planning" && !task.paused) - )).length; + return countRunningAgentTasks(tasks); } /** diff --git a/packages/dashboard/src/routes/register-project-routes.ts b/packages/dashboard/src/routes/register-project-routes.ts index 665f0e1c85..08bd3f5dbe 100644 --- a/packages/dashboard/src/routes/register-project-routes.ts +++ b/packages/dashboard/src/routes/register-project-routes.ts @@ -1,6 +1,7 @@ import * as fsPromises from "node:fs/promises"; import { dirname, isAbsolute, join } from "node:path"; import { + countRunningAgentTasks, ensureMemoryFileWithBackend, isValidSqliteDatabaseFile, ProjectIdentityConflictError, @@ -825,11 +826,9 @@ export const registerProjectRoutes: ApiRouteRegistrar = (ctx) => { /* * FNXC:GlobalConcurrencyControls 2026-06-26-23:46: * Project health In-Flight Agents is a live read-layer count, not persisted slot bookkeeping. - * Include active triage planners using the same `triage` + `planning` + not-paused predicate that gates `maxTriageConcurrent`, so project-level health matches FN-7097's global running-agent count without mutating stored health. + * Include all shared top-level slot holders, including active in-review reviewer/merger/fix agents, so project-level health matches global concurrency without mutating stored health. */ - const inFlightAgentCount = tasks.filter( - (t) => t.column === "in-progress" || (t.column === "triage" && t.status === "planning" && !t.paused), - ).length; + const inFlightAgentCount = countRunningAgentTasks(tasks); const totalTasksCompleted = tasks.filter((t) => t.column === "done" || t.column === "archived").length; // Get central health metadata (if available) to preserve non-count fields diff --git a/packages/engine/src/__tests__/concurrency.test.ts b/packages/engine/src/__tests__/concurrency.test.ts index c1eec50c59..590c3905ed 100644 --- a/packages/engine/src/__tests__/concurrency.test.ts +++ b/packages/engine/src/__tests__/concurrency.test.ts @@ -6,6 +6,7 @@ import { PRIORITY_MERGE, PRIORITY_EXECUTE, PRIORITY_SPECIFY, + persistedTopLevelAgentSlots, recoverIdleSemaphoreLeakCandidate, } from "../concurrency.js"; @@ -349,6 +350,23 @@ describe("AgentSemaphore", () => { sem.release(); }); + it("counts persisted top-level slots with the shared in-review running-agent predicate", () => { + const tasks = [ + { column: "in-progress" }, + { column: "triage", status: "planning", paused: false }, + { column: "triage", status: "planning", paused: true }, + { column: "in-review", status: "reviewing", paused: false }, + { column: "in-review", status: "merging", paused: false }, + { column: "in-review", status: "merging-pr", paused: false }, + { column: "in-review", status: "merging-fix", paused: false }, + { column: "in-review", status: "fixing", paused: false }, + { column: "in-review", status: "reviewing", paused: true }, + { column: "todo" }, + ] as Task[]; + + expect(persistedTopLevelAgentSlots(tasks)).toBe(7); + }); + it("recovers idle semaphore leaks only after a stable persisted-idle window", async () => { const sem = new AgentSemaphore(2); await sem.acquire(); diff --git a/packages/engine/src/concurrency.ts b/packages/engine/src/concurrency.ts index df413ae0ff..7434feffe5 100644 --- a/packages/engine/src/concurrency.ts +++ b/packages/engine/src/concurrency.ts @@ -1,4 +1,4 @@ -import type { Task } from "@fusion/core"; +import { countRunningAgentTasks, type Task } from "@fusion/core"; import { createLogger } from "./logger.js"; const concurrencyLog = createLogger("concurrency"); @@ -18,12 +18,12 @@ interface PriorityWaiter { export const IDLE_SEMAPHORE_LEAK_REPAIR_MS = 5_000; +/** + * FNXC:GlobalConcurrencyControls 2026-06-27-00:00: + * Persisted semaphore repair must use the same top-level slot predicate as dashboard and CLI live counts, including active in-review agents, so read-layer utilization and engine recovery cannot drift. + */ export function persistedTopLevelAgentSlots(tasks: Task[]): number { - return tasks.filter((task) => ( - task.column === "in-progress" - || (task.column === "triage" && task.status === "planning" && !task.paused) - || (task.column === "in-review" && ["merging", "reviewing", "fixing"].includes(String(task.status ?? ""))) - )).length; + return countRunningAgentTasks(tasks); } export interface IdleSemaphoreLeakRecoveryResult {