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", () => {
|
describe("PUT /api/global-concurrency route handler", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ import {
|
|||||||
} from "./api-error.js";
|
} from "./api-error.js";
|
||||||
import { createPluginRouter, resolvePluginManifest } from "./plugin-routes.js";
|
import { createPluginRouter, resolvePluginManifest } from "./plugin-routes.js";
|
||||||
import { fetchFromRemoteNode } from "./routes/register-settings-sync-helpers.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 { hermesRuntimeMetadata } from "@fusion-plugin-examples/hermes-runtime";
|
||||||
import { openclawRuntimeMetadata } from "@fusion-plugin-examples/openclaw-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();
|
if (shouldClose || (typeof central.isInitialized === "function" && !central.isInitialized())) await central.init();
|
||||||
|
|
||||||
const state = await central.getGlobalConcurrencyState();
|
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();
|
if (shouldClose) await central.close();
|
||||||
|
|
||||||
res.json(state);
|
res.json(liveState);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
throw err;
|
throw err;
|
||||||
|
|||||||
Reference in New Issue
Block a user