FN-7080: report live concurrency counts
Derive global concurrency running totals from live task columns so active work no longer appears idle. - Count in-progress tasks across all projects for `/api/global-concurrency`. - Replace stale slot bookkeeping in the response while preserving configured caps and queue counts. - Cover empty, multi-project, and over-cap running-count scenarios in route tests. - Add a patch changeset for the published Fusion package. Files changed: .changeset/fn-7080-concurrency-running-count-fix.md | 7 ++ packages/dashboard/src/__tests__/project-routes.test.ts | 113 +++++++++++++++++++++ packages/dashboard/src/routes.ts | 29 +++++- 3 files changed, 148 insertions(+), 1 deletion(-) Fusion-Task-Id: FN-7080 Fusion-Task-Lineage: a22d366f-ea93-47a5-b758-5e2d3828cff3 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7080-concurrency-running-count-fix.md
Normal file
7
.changeset/fn-7080-concurrency-running-count-fix.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Concurrency panels now show the real number of running agents instead of 0 when tasks are in progress.
|
||||
category: fix
|
||||
dev: global-concurrency running counts (currentlyActive/projectsActive) are now derived live from in-progress task columns, mirroring the /projects/:id/health computation, instead of slot/health bookkeeping that the default in-process runtime never updates.
|
||||
@@ -1208,6 +1208,119 @@ describe("project path mapping route handlers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/global-concurrency route handler", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGetOrCreateProjectStore.mockReset();
|
||||
mockGetGlobalConcurrencyState.mockResolvedValue({
|
||||
globalMaxConcurrent: 2,
|
||||
currentlyActive: 0,
|
||||
queuedCount: 7,
|
||||
projectsActive: { stale_project: 999 },
|
||||
});
|
||||
});
|
||||
|
||||
function project(id: string) {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
path: `/projects/${id}`,
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
function storeWithColumns(columns: string[]): MockStoreForRoutes & { listTasks: ReturnType<typeof vi.fn> } {
|
||||
const mockStore = new MockStoreForRoutes() as MockStoreForRoutes & { listTasks: ReturnType<typeof vi.fn> };
|
||||
mockStore.listTasks = vi.fn().mockResolvedValue(columns.map((column, index) => ({ id: `FN-${index + 1}`, column })));
|
||||
return mockStore;
|
||||
}
|
||||
|
||||
it("omits projects and reports zero when no tasks are in progress", async () => {
|
||||
const storeA = storeWithColumns(["todo", "in-review", "done", "archived"]);
|
||||
mockListProjects.mockResolvedValue([project("proj_a")]);
|
||||
mockGetOrCreateProjectStore.mockResolvedValue(storeA);
|
||||
|
||||
const app = await createApp(new MockStoreForRoutes());
|
||||
const res = await request(app, "GET", "/api/global-concurrency");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
globalMaxConcurrent: 2,
|
||||
currentlyActive: 0,
|
||||
queuedCount: 7,
|
||||
projectsActive: {},
|
||||
});
|
||||
expect(storeA.listTasks).toHaveBeenCalledWith({ slim: true });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "one in-progress task in one project",
|
||||
max: 4,
|
||||
stores: { proj_a: ["todo", "in-progress", "done"] },
|
||||
expectedProjects: { proj_a: 1 },
|
||||
expectedTotal: 1,
|
||||
},
|
||||
{
|
||||
name: "multiple in-progress tasks in one project",
|
||||
max: 8,
|
||||
stores: { proj_a: ["in-progress", "todo", "in-progress", "in-review"] },
|
||||
expectedProjects: { proj_a: 2 },
|
||||
expectedTotal: 2,
|
||||
},
|
||||
{
|
||||
name: "two projects each with in-progress tasks",
|
||||
max: 10,
|
||||
stores: {
|
||||
proj_a: ["in-progress", "done", "todo"],
|
||||
proj_b: ["todo", "in-progress", "in-progress", "archived"],
|
||||
proj_c: ["todo", "done"],
|
||||
},
|
||||
expectedProjects: { proj_a: 1, proj_b: 2 },
|
||||
expectedTotal: 3,
|
||||
},
|
||||
{
|
||||
name: "over-subscription reports truthful count above cap",
|
||||
max: 2,
|
||||
stores: { proj_a: ["in-progress", "in-progress", "in-progress", "todo"] },
|
||||
expectedProjects: { proj_a: 3 },
|
||||
expectedTotal: 3,
|
||||
},
|
||||
])("derives live running counts for $name", async ({ max, stores, expectedProjects, expectedTotal }) => {
|
||||
mockGetGlobalConcurrencyState.mockResolvedValue({
|
||||
globalMaxConcurrent: max,
|
||||
currentlyActive: 0,
|
||||
queuedCount: 7,
|
||||
projectsActive: { stale_project: 999 },
|
||||
});
|
||||
mockListProjects.mockResolvedValue(Object.keys(stores).map(project));
|
||||
const storesByProject = new Map(
|
||||
Object.entries(stores).map(([projectId, columns]) => [projectId, storeWithColumns(columns)]),
|
||||
);
|
||||
mockGetOrCreateProjectStore.mockImplementation(async (projectId: string) => storesByProject.get(projectId) ?? storeWithColumns([]));
|
||||
|
||||
const app = await createApp(new MockStoreForRoutes());
|
||||
const res = await request(app, "GET", "/api/global-concurrency");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
globalMaxConcurrent: max,
|
||||
currentlyActive: expectedTotal,
|
||||
queuedCount: 7,
|
||||
projectsActive: expectedProjects,
|
||||
});
|
||||
expect((res.body as { currentlyActive: number }).currentlyActive).toBeGreaterThanOrEqual(expectedTotal);
|
||||
expect((res.body as { projectsActive: Record<string, number> }).projectsActive).not.toHaveProperty("stale_project");
|
||||
for (const [projectId, mockStore] of storesByProject) {
|
||||
expect(mockGetOrCreateProjectStore).toHaveBeenCalledWith(projectId);
|
||||
expect(mockStore.listTasks).toHaveBeenCalledWith({ slim: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/global-concurrency route handler", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
} from "./api-error.js";
|
||||
import { createPluginRouter, resolvePluginManifest } from "./plugin-routes.js";
|
||||
import { fetchFromRemoteNode } from "./routes/register-settings-sync-helpers.js";
|
||||
import { getOrCreateProjectStore } from "./project-store-resolver.js";
|
||||
import { hermesRuntimeMetadata } from "@fusion-plugin-examples/hermes-runtime";
|
||||
import { openclawRuntimeMetadata } from "@fusion-plugin-examples/openclaw-runtime";
|
||||
|
||||
@@ -4637,9 +4638,35 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
if (shouldClose || (typeof central.isInitialized === "function" && !central.isInitialized())) await central.init();
|
||||
|
||||
const state = await central.getGlobalConcurrencyState();
|
||||
const projects = await central.listProjects();
|
||||
const projectCounts = await Promise.all(projects.map(async (project) => {
|
||||
const projectStore = await getOrCreateProjectStore(project.id);
|
||||
const tasks = await projectStore.listTasks({ slim: true });
|
||||
return [project.id, tasks.filter((task) => task.column === "in-progress").length] as const;
|
||||
}));
|
||||
|
||||
const projectsActive: Record<string, number> = {};
|
||||
let currentlyActive = 0;
|
||||
for (const [projectId, activeCount] of projectCounts) {
|
||||
currentlyActive += activeCount;
|
||||
if (activeCount > 0) {
|
||||
projectsActive[projectId] = activeCount;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:GlobalConcurrencyControls 2026-06-26-12:00:
|
||||
The footer EngineControlMenu and Command Center Concurrency card need running-agent counts from live task state. Slot bookkeeping (`globalConcurrency.currentlyActive`) and polled project health are not synced in the default in-process runtime, so derive read-only currentlyActive/projectsActive from authoritative `in-progress` task columns without mutating the slot limiter or editable cap.
|
||||
*/
|
||||
const liveState = {
|
||||
...state,
|
||||
currentlyActive,
|
||||
projectsActive,
|
||||
};
|
||||
|
||||
if (shouldClose) await central.close();
|
||||
|
||||
res.json(state);
|
||||
res.json(liveState);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
|
||||
Reference in New Issue
Block a user