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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-26 18:35:54 -07:00
parent 8bcda7325c
commit 5608bf555f
7 changed files with 235 additions and 25 deletions

View File

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

View File

@@ -1172,6 +1172,7 @@ SQLite schema is initialized in `packages/core/src/db.ts` and uses:
- **Central DB**: `~/.fusion/fusion-central.db` - **Central DB**: `~/.fusion/fusion-central.db`
- Schema in `packages/core/src/central-db.ts` - Schema in `packages/core/src/central-db.ts`
- `projects`, `projectHealth`, `centralActivityLog`, `globalConcurrency`, `nodes`, `peerNodes`, `projectNodePathMappings`, `settingsSyncState`, `__meta` - `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 ### Memory files
- OpenClaw-style memory workspace: - OpenClaw-style memory workspace:

View File

@@ -108,9 +108,11 @@ Central health tracking keeps mutable project metrics, including:
- project status (`initializing`, `active`, `paused`, `errored`) - 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 - 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 ## 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 ## Plugin Scope in Multi-Project Mode

View File

@@ -347,7 +347,7 @@ describe("project commands", () => {
totalTasksFailed: 1, totalTasksFailed: 1,
lastActivityAt: new Date().toISOString(), lastActivityAt: new Date().toISOString(),
}); });
mockTaskStoreListTasks.mockResolvedValue([]); mockTaskStoreListTasks.mockResolvedValue([{ id: "FN-003", column: "in-progress" }]);
const { runProjectShow } = await import("../project.js"); const { runProjectShow } = await import("../project.js");
await runProjectShow("proj-1"); await runProjectShow("proj-1");
@@ -359,6 +359,176 @@ describe("project commands", () => {
expect(output).toContain("Completed: 10"); 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 () => { it("validation exits on invalid project name for runProjectAdd", async () => {
const { runProjectAdd } = await import("../project.js"); const { runProjectAdd } = await import("../project.js");
await expect(runProjectAdd("bad name", "/tmp")).rejects.toThrow("process.exit:1"); await expect(runProjectAdd("bad name", "/tmp")).rejects.toThrow("process.exit:1");

View File

@@ -143,6 +143,27 @@ async function getProjectHealth(central: CentralCore, projectId: string): Promis
return central.getProjectHealth(projectId); return central.getProjectHealth(projectId);
} }
function getLiveInFlightAgentCount(taskCounts: Record<string, number>): 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<string, number>,
health?: ProjectHealth
): NonNullable<ProjectInfoData["health"]> {
return {
activeTaskCount: health?.activeTaskCount ?? 0,
inFlightAgentCount: getLiveInFlightAgentCount(taskCounts),
totalTasksCompleted: health?.totalTasksCompleted ?? 0,
totalTasksFailed: health?.totalTasksFailed ?? 0,
};
}
/** /**
* List all registered projects. * List all registered projects.
* *
@@ -174,6 +195,8 @@ export async function runProjectList(options: ProjectListOptions = {}): Promise<
getProjectHealth(central, project.id), getProjectHealth(central, project.id),
]); ]);
const displayHealth = buildDisplayHealth(taskCounts, health);
return { return {
id: project.id, id: project.id,
name: project.name, name: project.name,
@@ -183,14 +206,7 @@ export async function runProjectList(options: ProjectListOptions = {}): Promise<
createdAt: project.createdAt, createdAt: project.createdAt,
updatedAt: project.updatedAt, updatedAt: project.updatedAt,
lastActivityAt: health?.lastActivityAt ?? project.lastActivityAt, lastActivityAt: health?.lastActivityAt ?? project.lastActivityAt,
health: health health: displayHealth,
? {
activeTaskCount: health.activeTaskCount,
inFlightAgentCount: health.inFlightAgentCount,
totalTasksCompleted: health.totalTasksCompleted,
totalTasksFailed: health.totalTasksFailed,
}
: undefined,
taskCounts, taskCounts,
defaultProject: defaultProject?.id === project.id, defaultProject: defaultProject?.id === project.id,
}; };
@@ -208,8 +224,8 @@ export async function runProjectList(options: ProjectListOptions = {}): Promise<
console.log(); console.log();
// Header // Header
console.log(" Name Status Isolation Tasks Last Activity"); console.log(" Name Status Isolation Tasks In-Flight Last Activity");
console.log(" " + "─".repeat(72)); console.log(" " + "─".repeat(83));
for (const project of projectData) { for (const project of projectData) {
const totalTasks = Object.values(project.taskCounts).reduce((a, b) => a + b, 0); 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 status = `${statusDot} ${project.status}`.padEnd(12);
const isolation = project.isolationMode.padEnd(12); const isolation = project.isolationMode.padEnd(12);
const tasks = String(totalTasks).padStart(5); const tasks = String(totalTasks).padStart(5);
const inFlightAgents = String(project.health?.inFlightAgentCount ?? 0).padStart(9);
const lastActivity = formatLastActivity(project.lastActivityAt); const lastActivity = formatLastActivity(project.lastActivityAt);
console.log(` ${defaultMarker}${name} ${status} ${isolation} ${tasks} ${lastActivity}`); console.log(` ${defaultMarker}${name} ${status} ${isolation} ${tasks} ${inFlightAgents} ${lastActivity}`);
} }
console.log(); console.log();
@@ -520,16 +537,17 @@ export async function runProjectShow(name?: string): Promise<void> {
console.log(` Created: ${project.createdAt ?? "unknown"}`); console.log(` Created: ${project.createdAt ?? "unknown"}`);
console.log(` Updated: ${project.updatedAt ?? "unknown"}`); console.log(` Updated: ${project.updatedAt ?? "unknown"}`);
if (health) { const displayHealth = buildDisplayHealth(taskCounts, health);
console.log(); const lastHealthActivityAt = health?.lastActivityAt ?? project.lastActivityAt;
console.log(` Health:`);
console.log(` Active Tasks: ${health.activeTaskCount}`); console.log();
console.log(` In-Flight Agents: ${health.inFlightAgentCount}`); console.log(` Health:`);
console.log(` Completed: ${health.totalTasksCompleted}`); console.log(` Active Tasks: ${displayHealth.activeTaskCount}`);
console.log(` Failed: ${health.totalTasksFailed}`); console.log(` In-Flight Agents: ${displayHealth.inFlightAgentCount}`);
if (health.lastActivityAt) { console.log(` Completed: ${displayHealth.totalTasksCompleted}`);
console.log(` Last Activity: ${formatLastActivity(health.lastActivityAt)}`); console.log(` Failed: ${displayHealth.totalTasksFailed}`);
} if (lastHealthActivityAt) {
console.log(` Last Activity: ${formatLastActivity(lastHealthActivityAt)}`);
} }
console.log(); console.log();

View File

@@ -826,6 +826,10 @@ export async function unregisterProject(
/** /**
* Get detailed project info including runtime metrics and task counts. * 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<{ export async function getProjectInfo(name?: string): Promise<{
project: ResolvedProject; project: ResolvedProject;

View File

@@ -5728,7 +5728,11 @@ export interface ProjectHealth {
status: ProjectStatus; status: ProjectStatus;
/** Number of tasks currently active */ /** Number of tasks currently active */
activeTaskCount: number; 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; inFlightAgentCount: number;
/** ISO-8601 timestamp of last activity */ /** ISO-8601 timestamp of last activity */
lastActivityAt?: string; lastActivityAt?: string;
@@ -5772,7 +5776,11 @@ export interface CentralActivityLogEntry {
export interface GlobalConcurrencyState { export interface GlobalConcurrencyState {
/** System-wide concurrent agent limit (default: 4) */ /** System-wide concurrent agent limit (default: 4) */
globalMaxConcurrent: number; 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; currentlyActive: number;
/** Tasks waiting for concurrency slots */ /** Tasks waiting for concurrency slots */
queuedCount: number; queuedCount: number;