From 42d9c656ee6eec1c94ab91f86241cb8cbd2cf81c Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 26 Jun 2026 17:43:56 -0700 Subject: [PATCH] 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) --- .../fn-7080-concurrency-running-count-fix.md | 7 ++ .../src/__tests__/project-routes.test.ts | 113 ++++++++++++++++++ packages/dashboard/src/routes.ts | 29 ++++- 3 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 .changeset/fn-7080-concurrency-running-count-fix.md diff --git a/.changeset/fn-7080-concurrency-running-count-fix.md b/.changeset/fn-7080-concurrency-running-count-fix.md new file mode 100644 index 0000000000..5b27a5f057 --- /dev/null +++ b/.changeset/fn-7080-concurrency-running-count-fix.md @@ -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. diff --git a/packages/dashboard/src/__tests__/project-routes.test.ts b/packages/dashboard/src/__tests__/project-routes.test.ts index 3d93922ec5..0ff3fc016f 100644 --- a/packages/dashboard/src/__tests__/project-routes.test.ts +++ b/packages/dashboard/src/__tests__/project-routes.test.ts @@ -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 } { + const mockStore = new MockStoreForRoutes() as MockStoreForRoutes & { listTasks: ReturnType }; + 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 }).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(); diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts index 3f800c4152..ffa8bfab05 100644 --- a/packages/dashboard/src/routes.ts +++ b/packages/dashboard/src/routes.ts @@ -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 = {}; + 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;