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:
7
.changeset/fn-7160-running-agent-count.md
Normal file
7
.changeset/fn-7160-running-agent-count.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
47
packages/core/src/__tests__/live-agent-count.test.ts
Normal file
47
packages/core/src/__tests__/live-agent-count.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -416,6 +416,8 @@ export {
|
||||
setRunningAgentCountSource,
|
||||
getRunningAgentCountSource,
|
||||
deriveRunningAgentCounts,
|
||||
isRunningAgentTask,
|
||||
countRunningAgentTasks,
|
||||
type RunningAgentCountSource,
|
||||
type RunningAgentCounts,
|
||||
} from "./live-agent-count.js";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user