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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-27 21:39:40 -07:00
parent d7a02c4d5b
commit a95cfa7f92
16 changed files with 220 additions and 58 deletions

View File

@@ -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.

View File

@@ -849,7 +849,7 @@ Features:
<!-- FNXC:CommandCenter 2026-06-25-19:47: FN-7019 restored the user-facing picker contract: preset and custom date-range selections must change every historical analytics tab, while Mission Control stays live and intentionally ignores historical range filters. -->
<!-- FNXC:CommandCenter 2026-06-19-23:54: FN-6755 moved team-specific operations out of Overview: org hierarchy and heartbeat pause/resume live in Team, while Overview keeps global AI engine, concurrency, and theme controls. -->
<!-- FNXC:GlobalConcurrencyControls 2026-06-26-00:00: The Command Center Concurrency card mirrors the footer concurrency popover by showing read-only running-agent counts and current-use markers for the shared global cap and current-project max-concurrent slider. -->
<!-- FNXC:GlobalConcurrencyControls 2026-06-26-18:35: Those running-agent counts include in-progress executors and active triage planners (`triage` + `planning`, not paused) because both consume global concurrency slots. -->
<!-- FNXC:GlobalConcurrencyControls 2026-06-27-00:00: Those running-agent counts include every top-level slot holder: in-progress executors, active triage planners (`triage` + `planning`, not paused), and active in-review reviewer/merger/fix agents. Current-use markers map absolute utilization on a 0..cap scale, not slider-value coordinates, so one active agent renders above 0%. -->
<!-- FNXC:CommandCenter 2026-06-26-00:00: The four Overview Concurrency sliders change live scheduler capacity, so each settled edit opens a confirmation popup before persisting; cancel, backdrop, or Escape leaves the previous persisted value in place. -->
<!-- FNXC:CommandCenter 2026-06-27-10:03: Tokens detail charts must show every model bucket returned by analytics for accurate spend attribution; Overview remains a compact top-model summary because its copy explicitly frames those cards as top consumers/share. -->
- **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.

View File

@@ -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 () => {

View File

@@ -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<string, number>;
activeTriagePlannerCount: number;
runningAgentCount: number;
}
/**
@@ -131,17 +132,13 @@ async function getTaskCounts(projectPath: string): Promise<TaskCountSummary> {
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(

View File

@@ -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<Task, "column"> & Partial<Pick<Task, "status" | "paused">>): Pick<Task, "column" | "status" | "paused"> {
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);
});
});

View File

@@ -416,6 +416,8 @@ export {
setRunningAgentCountSource,
getRunningAgentCountSource,
deriveRunningAgentCounts,
isRunningAgentTask,
countRunningAgentTasks,
type RunningAgentCountSource,
type RunningAgentCounts,
} from "./live-agent-count.js";

View File

@@ -1,5 +1,11 @@
import type { Task } from "./types.js";
export type RunningAgentCountSource = (projectIds: readonly string[]) => Promise<Record<string, number>> | Record<string, number>;
type RunningAgentTaskShape = Pick<Task, "column" | "status" | "paused">;
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<string, number>;
}
/**
* 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<string, number>): RunningAgentCounts {
const projectsActive: Record<string, number> = {};
let currentlyActive = 0;

View File

@@ -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 */

View File

@@ -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"

View File

@@ -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,

View File

@@ -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<string, unknown>;
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<string, unknown>;
// 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");
});

View File

@@ -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);

View File

@@ -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<number> {
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);
}
/**

View File

@@ -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

View File

@@ -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();

View File

@@ -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 {