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:
gsxdsm
2026-06-26 17:43:56 -07:00
parent 4c48ccfe03
commit 42d9c656ee
3 changed files with 148 additions and 1 deletions

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

View File

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

View File

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