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:
7
.changeset/fn-7083-cli-inflight-live-count.md
Normal file
7
.changeset/fn-7083-cli-inflight-live-count.md
Normal 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.
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -143,6 +143,27 @@ async function getProjectHealth(central: CentralCore, projectId: string): Promis
|
||||
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.
|
||||
*
|
||||
@@ -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<void> {
|
||||
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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user