From 5608bf555f9ec466b9598a3295b98e65304a68f9 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 26 Jun 2026 18:35:54 -0700 Subject: [PATCH] FN-7083: derive project in-flight counts from live tasks Derive CLI project in-flight agent displays from live in-progress task counts instead of persisted health bookkeeping. - Add shared CLI display health handling for project list and show output. - Cover stale, missing, unreadable, table, and JSON project health cases in CLI tests. - Clarify central health and concurrency fields as persisted bookkeeping in docs and types. - Add a patch changeset for the published CLI behavior fix. Files changed: .changeset/fn-7083-cli-inflight-live-count.md | 7 + docs/architecture.md | 1 + docs/multi-project.md | 4 +- .../cli/src/commands/__tests__/project.test.ts | 172 ++++++++++++++++++++- packages/cli/src/commands/project.ts | 60 ++++--- packages/cli/src/project-resolver.ts | 4 + packages/core/src/types.ts | 12 +- 7 files changed, 235 insertions(+), 25 deletions(-) Fusion-Task-Id: FN-7083 Fusion-Task-Lineage: 2b57cb35-383f-443b-8bf3-3fc3c7a1ad43 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-7083-cli-inflight-live-count.md | 7 + docs/architecture.md | 1 + docs/multi-project.md | 4 +- .../src/commands/__tests__/project.test.ts | 172 +++++++++++++++++- packages/cli/src/commands/project.ts | 60 +++--- packages/cli/src/project-resolver.ts | 4 + packages/core/src/types.ts | 12 +- 7 files changed, 235 insertions(+), 25 deletions(-) create mode 100644 .changeset/fn-7083-cli-inflight-live-count.md diff --git a/.changeset/fn-7083-cli-inflight-live-count.md b/.changeset/fn-7083-cli-inflight-live-count.md new file mode 100644 index 0000000000..8d17fa23f6 --- /dev/null +++ b/.changeset/fn-7083-cli-inflight-live-count.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: fn project list/info now show live running-agent counts from in-progress tasks. +category: fix +dev: CLI In-Flight Agents derives from `column === "in-progress"` task counts, mirroring FN-7080's dashboard route; persisted `projectHealth.inFlightAgentCount` and slot semantics are unchanged. diff --git a/docs/architecture.md b/docs/architecture.md index d94cf11240..d123fe6108 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1172,6 +1172,7 @@ SQLite schema is initialized in `packages/core/src/db.ts` and uses: - **Central DB**: `~/.fusion/fusion-central.db` - Schema in `packages/core/src/central-db.ts` - `projects`, `projectHealth`, `centralActivityLog`, `globalConcurrency`, `nodes`, `peerNodes`, `projectNodePathMappings`, `settingsSyncState`, `__meta` +- `projectHealth.inFlightAgentCount` and `globalConcurrency.currentlyActive` are persisted slot/health bookkeeping fields. They are not live read-layer running-agent counts; dashboard and CLI read surfaces derive current running agents from tasks in `column === "in-progress"` while leaving slot acquire/free semantics and DB column names unchanged. ### Memory files - OpenClaw-style memory workspace: diff --git a/docs/multi-project.md b/docs/multi-project.md index 29be655d24..e2a6aed879 100644 --- a/docs/multi-project.md +++ b/docs/multi-project.md @@ -108,9 +108,11 @@ Central health tracking keeps mutable project metrics, including: - project status (`initializing`, `active`, `paused`, `errored`) - dashboard project status badges degrade gracefully if registry or health data briefly carries an unknown or missing status value +`projectHealth.inFlightAgentCount` is persisted slot/health bookkeeping, not an authoritative live running-agent count. Read-layer surfaces that need the current number of running agents (for example the dashboard project health route and `fn project list/info`) derive it from project tasks whose `column === "in-progress"` while preserving the stored health row for non-count metadata. + ## Global Concurrency Management -A singleton central record enforces system-wide limits so one project cannot monopolize all execution slots. Slot acquire/release bookkeeping remains separate from read-only running-agent displays: live read surfaces derive `currentlyActive` and per-project active counts from `in-progress` tasks in already-open project stores, while the persisted `globalMaxConcurrent` cap and `queuedCount` continue to come from central concurrency state. +A singleton central record enforces system-wide limits so one project cannot monopolize all execution slots. `globalConcurrency.currentlyActive` remains persisted slot bookkeeping maintained by acquire/free flows; live read-only running-agent displays derive `currentlyActive` and per-project active counts from `in-progress` tasks in already-open project stores, while the persisted `globalMaxConcurrent` cap and `queuedCount` continue to come from central concurrency state. The slot acquire/free limiter semantics and DB column names are unchanged. ## Plugin Scope in Multi-Project Mode diff --git a/packages/cli/src/commands/__tests__/project.test.ts b/packages/cli/src/commands/__tests__/project.test.ts index 7d813a7d10..f4651a0840 100644 --- a/packages/cli/src/commands/__tests__/project.test.ts +++ b/packages/cli/src/commands/__tests__/project.test.ts @@ -347,7 +347,7 @@ describe("project commands", () => { totalTasksFailed: 1, lastActivityAt: new Date().toISOString(), }); - mockTaskStoreListTasks.mockResolvedValue([]); + mockTaskStoreListTasks.mockResolvedValue([{ id: "FN-003", column: "in-progress" }]); const { runProjectShow } = await import("../project.js"); await runProjectShow("proj-1"); @@ -359,6 +359,176 @@ describe("project commands", () => { expect(output).toContain("Completed: 10"); }); + it("runProjectShow derives In-Flight Agents from live in-progress tasks when central health is stale", async () => { + mockGetProject.mockResolvedValue({ + id: "proj-1", + name: "demo", + path: "/tmp/demo", + status: "active", + isolationMode: "in-process", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-02T00:00:00.000Z", + }); + mockGetSettings.mockResolvedValue({}); + const staleHealth = { + projectId: "proj-1", + status: "active", + activeTaskCount: 2, + inFlightAgentCount: 0, + totalTasksCompleted: 10, + totalTasksFailed: 1, + lastActivityAt: new Date().toISOString(), + }; + mockGetProjectHealth.mockResolvedValue(staleHealth); + mockTaskStoreListTasks.mockResolvedValue([ + { id: "FN-001", column: "todo" }, + { id: "FN-002", column: "in-progress" }, + { id: "FN-003", column: "in-progress" }, + ]); + + 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: 2"); + expect(staleHealth.inFlightAgentCount).toBe(0); + }); + + it("runProjectList JSON derives health.inFlightAgentCount from live in-progress tasks", async () => { + mockListProjects.mockResolvedValue([ + { id: "proj-1", name: "app-one", path: "/tmp/app-one", status: "active", isolationMode: "in-process" }, + ]); + mockGetSettings.mockResolvedValue({}); + mockGetProjectHealth.mockResolvedValue({ + projectId: "proj-1", + status: "active", + activeTaskCount: 1, + inFlightAgentCount: 0, + totalTasksCompleted: 4, + totalTasksFailed: 0, + }); + mockTaskStoreListTasks.mockResolvedValue([ + { id: "FN-001", column: "in-progress" }, + { id: "FN-002", column: "in-progress" }, + ]); + + 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(2); + expect(parsed[0].health.activeTaskCount).toBe(1); + expect(mockGetProjectHealth.mock.results[0]).toBeDefined(); + }); + + it("runProjectList table prints the live In-Flight column", async () => { + mockListProjects.mockResolvedValue([ + { id: "proj-1", name: "app-one", path: "/tmp/app-one", status: "active", isolationMode: "in-process" }, + ]); + mockGetSettings.mockResolvedValue({}); + mockGetProjectHealth.mockResolvedValue({ + projectId: "proj-1", + status: "active", + activeTaskCount: 1, + inFlightAgentCount: 0, + totalTasksCompleted: 0, + totalTasksFailed: 0, + }); + mockTaskStoreListTasks.mockResolvedValue([ + { id: "FN-001", column: "todo" }, + { id: "FN-002", column: "in-progress" }, + ]); + + const { runProjectList } = await import("../project.js"); + await runProjectList(); + + 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(" 1"); + }); + + it("runProjectShow reports zero live In-Flight Agents when no tasks are in-progress", async () => { + mockGetProject.mockResolvedValue({ + id: "proj-1", + name: "demo", + path: "/tmp/demo", + status: "active", + isolationMode: "in-process", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-02T00:00:00.000Z", + }); + mockGetSettings.mockResolvedValue({}); + mockGetProjectHealth.mockResolvedValue({ + projectId: "proj-1", + status: "active", + activeTaskCount: 2, + inFlightAgentCount: 9, + totalTasksCompleted: 0, + totalTasksFailed: 0, + }); + mockTaskStoreListTasks.mockResolvedValue([ + { id: "FN-001", column: "todo" }, + { id: "FN-002", column: "done" }, + ]); + + 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: 0"); + }); + + it("runProjectShow renders live In-Flight Agents even without a central health row", async () => { + mockGetProject.mockResolvedValue({ + id: "proj-1", + name: "demo", + path: "/tmp/demo", + status: "active", + isolationMode: "in-process", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-02T00:00:00.000Z", + }); + mockGetSettings.mockResolvedValue({}); + mockGetProjectHealth.mockResolvedValue(undefined); + mockTaskStoreListTasks.mockResolvedValue([{ id: "FN-001", column: "in-progress" }]); + + 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("Health:"); + expect(output).toContain("In-Flight Agents: 1"); + }); + + it("runProjectShow falls back to zero In-Flight Agents when the task store is unreadable", async () => { + mockGetProject.mockResolvedValue({ + id: "proj-1", + name: "demo", + path: "/tmp/demo", + status: "active", + isolationMode: "in-process", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-02T00:00:00.000Z", + }); + mockGetSettings.mockResolvedValue({}); + mockGetProjectHealth.mockResolvedValue({ + projectId: "proj-1", + status: "active", + activeTaskCount: 2, + inFlightAgentCount: 3, + totalTasksCompleted: 0, + totalTasksFailed: 0, + }); + mockTaskStoreListTasks.mockRejectedValue(new Error("cannot read tasks")); + + const { runProjectShow } = await import("../project.js"); + await expect(runProjectShow("proj-1")).resolves.toBeUndefined(); + + const output = consoleSpy.mock.calls.map((call) => String(call[0])).join("\n"); + expect(output).toContain("In-Flight Agents: 0"); + }); + it("validation exits on invalid project name for runProjectAdd", async () => { const { runProjectAdd } = await import("../project.js"); await expect(runProjectAdd("bad name", "/tmp")).rejects.toThrow("process.exit:1"); diff --git a/packages/cli/src/commands/project.ts b/packages/cli/src/commands/project.ts index 911529137c..8cf9d7728f 100644 --- a/packages/cli/src/commands/project.ts +++ b/packages/cli/src/commands/project.ts @@ -143,6 +143,27 @@ async function getProjectHealth(central: CentralCore, projectId: string): Promis return central.getProjectHealth(projectId); } +function getLiveInFlightAgentCount(taskCounts: Record): number { + /* + * FNXC:CLIProjectHealth 2026-06-26-18:25: + * 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 FN-7080's dashboard read route by deriving the live count from tasks currently in the in-progress column without mutating persisted health rows. + */ + return taskCounts["in-progress"] ?? 0; +} + +function buildDisplayHealth( + taskCounts: Record, + health?: ProjectHealth +): NonNullable { + return { + activeTaskCount: health?.activeTaskCount ?? 0, + inFlightAgentCount: getLiveInFlightAgentCount(taskCounts), + totalTasksCompleted: health?.totalTasksCompleted ?? 0, + totalTasksFailed: health?.totalTasksFailed ?? 0, + }; +} + /** * List all registered projects. * @@ -174,6 +195,8 @@ export async function runProjectList(options: ProjectListOptions = {}): Promise< getProjectHealth(central, project.id), ]); + const displayHealth = buildDisplayHealth(taskCounts, health); + return { id: project.id, name: project.name, @@ -183,14 +206,7 @@ export async function runProjectList(options: ProjectListOptions = {}): Promise< createdAt: project.createdAt, updatedAt: project.updatedAt, lastActivityAt: health?.lastActivityAt ?? project.lastActivityAt, - health: health - ? { - activeTaskCount: health.activeTaskCount, - inFlightAgentCount: health.inFlightAgentCount, - totalTasksCompleted: health.totalTasksCompleted, - totalTasksFailed: health.totalTasksFailed, - } - : undefined, + health: displayHealth, taskCounts, defaultProject: defaultProject?.id === project.id, }; @@ -208,8 +224,8 @@ export async function runProjectList(options: ProjectListOptions = {}): Promise< console.log(); // Header - console.log(" Name Status Isolation Tasks Last Activity"); - console.log(" " + "─".repeat(72)); + console.log(" Name Status Isolation Tasks In-Flight Last Activity"); + console.log(" " + "─".repeat(83)); for (const project of projectData) { const totalTasks = Object.values(project.taskCounts).reduce((a, b) => a + b, 0); @@ -220,9 +236,10 @@ export async function runProjectList(options: ProjectListOptions = {}): Promise< const status = `${statusDot} ${project.status}`.padEnd(12); const isolation = project.isolationMode.padEnd(12); const tasks = String(totalTasks).padStart(5); + const inFlightAgents = String(project.health?.inFlightAgentCount ?? 0).padStart(9); const lastActivity = formatLastActivity(project.lastActivityAt); - console.log(` ${defaultMarker}${name} ${status} ${isolation} ${tasks} ${lastActivity}`); + console.log(` ${defaultMarker}${name} ${status} ${isolation} ${tasks} ${inFlightAgents} ${lastActivity}`); } console.log(); @@ -520,16 +537,17 @@ export async function runProjectShow(name?: string): Promise { console.log(` Created: ${project.createdAt ?? "unknown"}`); console.log(` Updated: ${project.updatedAt ?? "unknown"}`); - if (health) { - console.log(); - console.log(` Health:`); - console.log(` Active Tasks: ${health.activeTaskCount}`); - console.log(` In-Flight Agents: ${health.inFlightAgentCount}`); - console.log(` Completed: ${health.totalTasksCompleted}`); - console.log(` Failed: ${health.totalTasksFailed}`); - if (health.lastActivityAt) { - console.log(` Last Activity: ${formatLastActivity(health.lastActivityAt)}`); - } + const displayHealth = buildDisplayHealth(taskCounts, health); + const lastHealthActivityAt = health?.lastActivityAt ?? project.lastActivityAt; + + console.log(); + console.log(` Health:`); + console.log(` Active Tasks: ${displayHealth.activeTaskCount}`); + console.log(` In-Flight Agents: ${displayHealth.inFlightAgentCount}`); + console.log(` Completed: ${displayHealth.totalTasksCompleted}`); + console.log(` Failed: ${displayHealth.totalTasksFailed}`); + if (lastHealthActivityAt) { + console.log(` Last Activity: ${formatLastActivity(lastHealthActivityAt)}`); } console.log(); diff --git a/packages/cli/src/project-resolver.ts b/packages/cli/src/project-resolver.ts index cd171d135a..bec67105f6 100644 --- a/packages/cli/src/project-resolver.ts +++ b/packages/cli/src/project-resolver.ts @@ -826,6 +826,10 @@ export async function unregisterProject( /** * Get detailed project info including runtime metrics and task counts. + * + * FNXC:CLIProjectHealth 2026-06-26-18:31: + * This resolver returns raw central health for metadata compatibility, so `health.inFlightAgentCount` remains persisted bookkeeping. + * Callers that display live running-agent counts must derive them from `taskCounts["in-progress"]` instead of rendering the raw health field. */ export async function getProjectInfo(name?: string): Promise<{ project: ResolvedProject; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 887a7e363d..61c1b7852f 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -5728,7 +5728,11 @@ export interface ProjectHealth { status: ProjectStatus; /** Number of tasks currently active */ activeTaskCount: number; - /** Number of agents currently running */ + /** + * FNXC:Concurrency 2026-06-26-18:34: + * 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 tasks where `column === "in-progress"` (FN-7080/FN-7081) and leave this stored value untouched. + */ inFlightAgentCount: number; /** ISO-8601 timestamp of last activity */ lastActivityAt?: string; @@ -5772,7 +5776,11 @@ export interface CentralActivityLogEntry { export interface GlobalConcurrencyState { /** System-wide concurrent agent limit (default: 4) */ globalMaxConcurrent: number; - /** Active agents across all projects */ + /** + * FNXC:Concurrency 2026-06-26-18:34: + * Persisted global slot bookkeeping maintained by acquire/release flows; it is not a live aggregate of project task stores. + * Read surfaces that need current running-agent totals should aggregate live `column === "in-progress"` task counts while preserving slot limiter semantics and DB column names. + */ currentlyActive: number; /** Tasks waiting for concurrency slots */ queuedCount: number;