FN-7102: count active triage planners in project health

Align project health in-flight readouts with live agent slot holders.\n\n- Count non-paused triage planning tasks alongside in-progress executors for dashboard health and CLI project displays.\n- Keep persisted projectHealth.inFlightAgentCount as bookkeeping while deriving user-facing counts from live task state.\n- Cover CLI and dashboard health routes for paused, inactive, and per-project triage planner cases.\n- Document the live count behavior and add a patch changeset.\n\nFiles changed:\n .changeset/fn-7102-health-triage-inflight.md       |  7 +++\n docs/cli-reference.md                              |  2 +\n docs/multi-project.md                              |  4 +-\n .../cli/src/commands/__tests__/project.test.ts     | 59 +++++++++++++++---\n packages/cli/src/commands/project.ts               | 35 +++++++----\n packages/cli/src/project-resolver.ts               | 17 ++++--\n packages/core/src/types.ts                         |  4 +-\n .../dashboard/src/__tests__/project-routes.test.ts | 71 ++++++++++++++++++----\n .../src/routes/register-project-routes.ts          |  9 ++-\n 9 files changed, 163 insertions(+), 45 deletions(-)

Fusion-Task-Id: FN-7102

Fusion-Task-Lineage: ebcba74c-238d-4ca7-aa77-df0d329c0ea9

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-27 00:12:54 -07:00
parent 5899dca4f8
commit 9e2fb5d62c
9 changed files with 163 additions and 45 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Project health In-Flight Agents now counts agents actively triaging tasks.
category: fix
dev: The dashboard /projects/:id/health route and the CLI fn project list/info in-flight count now add triage-column tasks with status "planning" (not paused) to the live in-progress count, matching FN-7097's countRunningAgentsInStore predicate; persisted projectHealth.inFlightAgentCount and slot semantics are unchanged.

View File

@@ -643,6 +643,8 @@ fn project remove my-app --force
Subcommands: `list|ls`, `add`, `remove|rm`, `show`, `info`, `set-default|default`, `detect`.
`fn project list` and `fn project show/info` report `In-Flight Agents` from live task state: in-progress executors plus triage planners whose task is in `triage` with `status === "planning"` and is not paused. The readout intentionally ignores stale persisted `projectHealth.inFlightAgentCount` bookkeeping.
`fn project add` registers an existing directory with Fusion. If the directory
does not contain a Git repository yet, Fusion runs a minimal `git init` during
registration and fails the registration if Git is unavailable.

View File

@@ -108,12 +108,12 @@ 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.
`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 in-progress executor tasks plus active triage planners (`column === "triage"`, `status === "planning"`, and not paused) while preserving the stored health row for non-count metadata.
## Global Concurrency Management
<!-- FNXC:GlobalConcurrencyControls 2026-06-26-18:35: Live global-concurrency readouts must count both in-progress executors and active triage planners because both hold concurrency slots; paused or non-planning triage rows stay excluded. -->
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 plus triage tasks with `status === "planning"` that are not paused 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.
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 plus triage tasks with `status === "planning"` that are not paused 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

View File

@@ -359,7 +359,7 @@ 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 () => {
it("runProjectShow derives In-Flight Agents from live executors and triage planners when central health is stale", async () => {
mockGetProject.mockResolvedValue({
id: "proj-1",
name: "demo",
@@ -384,17 +384,20 @@ describe("project commands", () => {
{ id: "FN-001", column: "todo" },
{ id: "FN-002", column: "in-progress" },
{ id: "FN-003", column: "in-progress" },
{ id: "FN-004", column: "triage", status: "planning", paused: false },
{ id: "FN-005", column: "triage", status: "planning", paused: true },
{ id: "FN-006", column: "triage", status: "awaiting-approval", paused: false },
]);
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(output).toContain("In-Flight Agents: 3");
expect(staleHealth.inFlightAgentCount).toBe(0);
});
it("runProjectList JSON derives health.inFlightAgentCount from live in-progress tasks", async () => {
it("runProjectList JSON derives health.inFlightAgentCount from live executors and triage planners", async () => {
mockListProjects.mockResolvedValue([
{ id: "proj-1", name: "app-one", path: "/tmp/app-one", status: "active", isolationMode: "in-process" },
]);
@@ -410,17 +413,51 @@ describe("project commands", () => {
mockTaskStoreListTasks.mockResolvedValue([
{ id: "FN-001", column: "in-progress" },
{ id: "FN-002", column: "in-progress" },
{ id: "FN-003", column: "triage", status: "planning", paused: false },
{ id: "FN-004", column: "triage", status: "triaged", paused: false },
]);
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.inFlightAgentCount).toBe(3);
expect(parsed[0].health.activeTaskCount).toBe(1);
expect(mockGetProjectHealth.mock.results[0]).toBeDefined();
});
it("runProjectList JSON keeps per-project triage planner counts isolated", async () => {
mockListProjects.mockResolvedValue([
{ id: "proj-1", name: "app-one", path: "/tmp/app-one", status: "active", isolationMode: "in-process" },
{ id: "proj-2", name: "app-two", path: "/tmp/app-two", status: "active", isolationMode: "in-process" },
]);
mockGetSettings.mockResolvedValue({});
mockGetProjectHealth.mockResolvedValue({
projectId: "stale",
status: "active",
activeTaskCount: 0,
inFlightAgentCount: 99,
totalTasksCompleted: 0,
totalTasksFailed: 0,
});
mockTaskStoreListTasks
.mockResolvedValueOnce([
{ id: "FN-001", column: "triage", status: "planning", paused: false },
{ id: "FN-002", column: "triage", status: "planning", paused: true },
])
.mockResolvedValueOnce([
{ id: "FN-010", column: "in-progress" },
{ id: "FN-011", column: "triage", status: "planning", paused: false },
{ id: "FN-012", column: "triage", status: "awaiting-approval", paused: false },
]);
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.map((project: { health: { inFlightAgentCount: number } }) => project.health.inFlightAgentCount)).toEqual([1, 2]);
});
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" },
@@ -437,6 +474,7 @@ describe("project commands", () => {
mockTaskStoreListTasks.mockResolvedValue([
{ id: "FN-001", column: "todo" },
{ id: "FN-002", column: "in-progress" },
{ id: "FN-003", column: "triage", status: "planning", paused: false },
]);
const { runProjectList } = await import("../project.js");
@@ -445,10 +483,10 @@ describe("project commands", () => {
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");
expect(projectLine).toContain(" 2");
});
it("runProjectShow reports zero live In-Flight Agents when no tasks are in-progress", async () => {
it("runProjectShow reports zero live In-Flight Agents when no tasks hold agent slots", async () => {
mockGetProject.mockResolvedValue({
id: "proj-1",
name: "demo",
@@ -470,6 +508,8 @@ describe("project commands", () => {
mockTaskStoreListTasks.mockResolvedValue([
{ id: "FN-001", column: "todo" },
{ id: "FN-002", column: "done" },
{ id: "FN-003", column: "triage", status: "planning", paused: true },
{ id: "FN-004", column: "triage", status: "waiting", paused: false },
]);
const { runProjectShow } = await import("../project.js");
@@ -491,14 +531,17 @@ describe("project commands", () => {
});
mockGetSettings.mockResolvedValue({});
mockGetProjectHealth.mockResolvedValue(undefined);
mockTaskStoreListTasks.mockResolvedValue([{ id: "FN-001", column: "in-progress" }]);
mockTaskStoreListTasks.mockResolvedValue([
{ id: "FN-001", column: "in-progress" },
{ id: "FN-002", column: "triage", status: "planning", paused: false },
]);
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");
expect(output).toContain("In-Flight Agents: 2");
});
it("runProjectShow falls back to zero In-Flight Agents when the task store is unreadable", async () => {

View File

@@ -82,6 +82,11 @@ export interface ProjectInfoData {
defaultProject: boolean;
}
interface TaskCountSummary {
byColumn: Record<string, number>;
activeTriagePlannerCount: number;
}
/**
* Format a path for display, showing relative path when possible.
*/
@@ -116,7 +121,7 @@ function formatLastActivity(timestamp?: string | null): string {
/**
* Get task counts by column for a project.
*/
async function getTaskCounts(projectPath: string): Promise<Record<string, number>> {
async function getTaskCounts(projectPath: string): Promise<TaskCountSummary> {
try {
const store = new TaskStore(projectPath);
await store.init();
@@ -126,13 +131,17 @@ async function getTaskCounts(projectPath: string): Promise<Record<string, number
for (const col of COLUMNS) {
counts[col] = 0;
}
let activeTriagePlannerCount = 0;
for (const task of tasks) {
counts[task.column] = (counts[task.column] || 0) + 1;
if (task.column === "triage" && task.status === "planning" && !task.paused) {
activeTriagePlannerCount += 1;
}
}
return counts;
return { byColumn: counts, activeTriagePlannerCount };
} catch {
// Return empty counts if we can't read the project
return {};
return { byColumn: {}, activeTriagePlannerCount: 0 };
}
}
@@ -143,17 +152,17 @@ async function getProjectHealth(central: CentralCore, projectId: string): Promis
return central.getProjectHealth(projectId);
}
function getLiveInFlightAgentCount(taskCounts: Record<string, number>): number {
function getLiveInFlightAgentCount(taskCounts: TaskCountSummary): number {
/*
* FNXC:CLIProjectHealth 2026-06-26-18:25:
* FNXC:CLIProjectHealth 2026-06-26-23:46:
* 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.
* User-visible In-Flight Agents must mirror the dashboard read route and FN-7097's global running-agent count by deriving live agents from in-progress tasks plus active triage planners (`triage` + `planning` + not paused) without mutating persisted health rows.
*/
return taskCounts["in-progress"] ?? 0;
return (taskCounts.byColumn["in-progress"] ?? 0) + taskCounts.activeTriagePlannerCount;
}
function buildDisplayHealth(
taskCounts: Record<string, number>,
taskCounts: TaskCountSummary,
health?: ProjectHealth
): NonNullable<ProjectInfoData["health"]> {
return {
@@ -207,7 +216,7 @@ export async function runProjectList(options: ProjectListOptions = {}): Promise<
updatedAt: project.updatedAt,
lastActivityAt: health?.lastActivityAt ?? project.lastActivityAt,
health: displayHealth,
taskCounts,
taskCounts: taskCounts.byColumn,
defaultProject: defaultProject?.id === project.id,
};
})
@@ -499,10 +508,10 @@ export async function runProjectShow(name?: string): Promise<void> {
console.log(` Status: (not registered)`);
console.log();
const counts = await getTaskCounts(detected.path);
const total = Object.values(counts).reduce((a, b) => a + b, 0);
const total = Object.values(counts.byColumn).reduce((a, b) => a + b, 0);
if (total > 0) {
console.log(` Tasks: ${total} total`);
for (const [col, count] of Object.entries(counts)) {
for (const [col, count] of Object.entries(counts.byColumn)) {
if (count > 0) {
console.log(` ${COLUMN_LABELS[col as Column]}: ${count}`);
}
@@ -552,10 +561,10 @@ export async function runProjectShow(name?: string): Promise<void> {
console.log();
console.log(` Tasks:`);
const total = Object.values(taskCounts).reduce((a, b) => a + b, 0);
const total = Object.values(taskCounts.byColumn).reduce((a, b) => a + b, 0);
console.log(` Total: ${total}`);
for (const col of COLUMNS) {
const count = taskCounts[col] || 0;
const count = taskCounts.byColumn[col] || 0;
if (count > 0) {
console.log(` ${COLUMN_LABELS[col]}: ${count}`);
}

View File

@@ -256,7 +256,12 @@ export async function resolveProject(options: ResolveOptions = {}): Promise<Reso
// 4. Has .fusion/ but not registered
if (interactive) {
console.log(`\n Found fn project at ${fusionDir} but it's not registered.`);
const identity = readProjectIdentity(fusionDir);
/*
* FNXC:CLIProjectIdentity 2026-06-27-00:00:
* Project identity files live inside the project `.fusion` directory; cwd-based orphan restore must not read or stamp the project root.
*/
const projectFusionDir = join(fusionDir, ".fusion");
const identity = readProjectIdentity(projectFusionDir);
if (identity) {
const recover = await promptConfirm(
@@ -274,7 +279,7 @@ export async function resolveProject(options: ResolveOptions = {}): Promise<Reso
const recoveredProject = ensured.project;
await central.updateProject(recoveredProject.id, { status: "active" });
try {
writeProjectIdentity(fusionDir, {
writeProjectIdentity(projectFusionDir, {
id: recoveredProject.id,
createdAt: recoveredProject.createdAt,
});
@@ -315,7 +320,7 @@ export async function resolveProject(options: ResolveOptions = {}): Promise<Reso
// Activate the project (registration sets it to 'initializing')
await central.updateProject(newProject.id, { status: "active" });
try {
writeProjectIdentity(fusionDir, {
writeProjectIdentity(projectFusionDir, {
id: newProject.id,
createdAt: newProject.createdAt,
});
@@ -332,7 +337,7 @@ export async function resolveProject(options: ResolveOptions = {}): Promise<Reso
{ directory: fusionDir },
);
}
writeProjectIdentity(fusionDir, {
writeProjectIdentity(projectFusionDir, {
id: newProject.id,
createdAt: newProject.createdAt,
});
@@ -827,9 +832,9 @@ export async function unregisterProject(
/**
* Get detailed project info including runtime metrics and task counts.
*
* FNXC:CLIProjectHealth 2026-06-26-18:31:
* FNXC:CLIProjectHealth 2026-06-26-23:46:
* 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.
* Callers that display live running-agent counts must not render the raw health field; derive from slim task state with the FN-7097 predicate (`in-progress` plus `triage`/`planning`/not-paused) instead.
*/
export async function getProjectInfo(name?: string): Promise<{
project: ResolvedProject;

View File

@@ -5729,9 +5729,9 @@ export interface ProjectHealth {
/** Number of tasks currently active */
activeTaskCount: number;
/**
* FNXC:Concurrency 2026-06-26-18:34:
* FNXC:Concurrency 2026-06-26-23:46:
* 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.
* Consumers that need current running agents must derive from in-progress executors plus active triage planners (`column === "triage" && status === "planning" && !paused`) so FN-7102 project health matches FN-7097 global concurrency, and leave this stored value untouched.
*/
inFlightAgentCount: number;
/** ISO-8601 timestamp of last activity */

View File

@@ -1384,7 +1384,7 @@ describe("GET /api/projects/:id/health route handler", () => {
});
// Helper to create a mock store with specific tasks
function createMockStoreWithTasks(tasks: Array<{ id: string; column: string }>): MockStoreForRoutes & { listTasks: ReturnType<typeof vi.fn> } {
function createMockStoreWithTasks(tasks: Array<{ id: string; column: string; status?: string; paused?: boolean }>): MockStoreForRoutes & { listTasks: ReturnType<typeof vi.fn> } {
const mockStore = new MockStoreForRoutes() as MockStoreForRoutes & { listTasks: ReturnType<typeof vi.fn> };
mockStore.listTasks = vi.fn().mockResolvedValue(tasks);
return mockStore;
@@ -1393,7 +1393,7 @@ describe("GET /api/projects/:id/health route handler", () => {
it("returns project-specific task counts when using project-scoped store", async () => {
// Create a store with specific tasks
const projectATasks = [
{ id: "FN-1", column: "triage" },
{ id: "FN-1", column: "triage", status: "planning", paused: false },
{ id: "FN-2", column: "todo" },
{ id: "FN-3", column: "in-progress" },
{ id: "FN-4", column: "in-review" },
@@ -1437,25 +1437,69 @@ describe("GET /api/projects/:id/health route handler", () => {
// Should have computed counts from project-scoped store, not stale central data
expect(health.projectId).toBe("proj_a");
expect(health.activeTaskCount).toBe(4); // triage + todo + in-progress + in-review
expect(health.inFlightAgentCount).toBe(1); // only in-progress
expect(health.inFlightAgentCount).toBe(2); // in-progress + active triage planner
expect(health.totalTasksCompleted).toBe(2); // done + archived
expect(health.status).toBe("active");
});
it("counts only active triage planners as in-flight when no executors are running", async () => {
const tasks = [
{ id: "FN-1", column: "triage", status: "planning", paused: false },
{ id: "FN-2", column: "triage", status: "planning", paused: true },
{ id: "FN-3", column: "triage", status: "awaiting-approval", paused: false },
{ id: "FN-4", column: "todo" },
];
const mockStore = createMockStoreWithTasks(tasks);
mockGetOrCreateProjectStore.mockResolvedValue(mockStore);
mockGetProject.mockResolvedValue({
id: "proj_triage",
name: "Triage Project",
path: "/projects/triage",
status: "active",
isolationMode: "in-process",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
mockGetProjectHealth.mockResolvedValue({
projectId: "proj_triage",
status: "active",
activeTaskCount: 999,
inFlightAgentCount: 0,
totalTasksCompleted: 999,
totalTasksFailed: 0,
updatedAt: "2020-01-01T00:00:00.000Z",
});
const defaultStore = new MockStoreForRoutes();
const app = await createApp(defaultStore);
const res = await request(app, "GET", "/api/projects/proj_triage/health");
expect(res.status).toBe(200);
const health = res.body as Record<string, unknown>;
expect(health.activeTaskCount).toBe(4);
expect(health.inFlightAgentCount).toBe(1);
expect(health.totalTasksCompleted).toBe(0);
});
it("does not bleed counts between different projects", async () => {
// Project A has 3 tasks
// Project A has 3 tasks, including one active triage planner.
const projectATasks = [
{ id: "FN-1", column: "triage" },
{ id: "FN-1", column: "triage", status: "planning", paused: false },
{ id: "FN-2", column: "in-progress" },
{ id: "FN-3", column: "done" },
];
// Project B has 5 tasks
// Project B has 7 tasks, including one active and two inactive triage tasks.
const projectBTasks = [
{ id: "FN-10", column: "todo" },
{ id: "FN-11", column: "todo" },
{ id: "FN-12", column: "in-progress" },
{ id: "FN-13", column: "in-review" },
{ id: "FN-14", column: "archived" },
{ id: "FN-15", column: "triage", status: "planning", paused: false },
{ id: "FN-16", column: "triage", status: "planning", paused: true },
{ id: "FN-17", column: "triage", status: "awaiting-approval", paused: false },
];
const storeA = createMockStoreWithTasks(projectATasks);
@@ -1497,9 +1541,9 @@ describe("GET /api/projects/:id/health route handler", () => {
expect(resA.status).toBe(200);
const healthA = resA.body as Record<string, unknown>;
// Project A: 1 triage + 1 in-progress + 1 done = 3 tasks total, 2 active, 1 in-flight
// Project A: 1 triage + 1 in-progress + 1 done = 3 tasks total, 2 active, 2 in-flight
expect(healthA.activeTaskCount).toBe(2); // triage + in-progress
expect(healthA.inFlightAgentCount).toBe(1); // in-progress
expect(healthA.inFlightAgentCount).toBe(2); // in-progress + active triage planner
expect(healthA.totalTasksCompleted).toBe(1); // done
// Request health for project B
@@ -1528,9 +1572,9 @@ describe("GET /api/projects/:id/health route handler", () => {
expect(resB.status).toBe(200);
const healthB = resB.body as Record<string, unknown>;
// Project B: 2 todo + 1 in-progress + 1 in-review = 4 active, 1 in-flight
expect(healthB.activeTaskCount).toBe(4); // 2 todo + 1 in-progress + 1 in-review
expect(healthB.inFlightAgentCount).toBe(1); // in-progress
// Project B: 2 todo + 1 in-progress + 1 in-review + 3 triage = 7 active, 2 in-flight
expect(healthB.activeTaskCount).toBe(7); // 2 todo + 1 in-progress + 1 in-review + 3 triage
expect(healthB.inFlightAgentCount).toBe(2); // in-progress + active triage planner only
expect(healthB.totalTasksCompleted).toBe(1); // archived
// Verify no bleed-through: project A counts should not equal project B counts
@@ -1546,6 +1590,7 @@ describe("GET /api/projects/:id/health route handler", () => {
{ id: "FN-2", column: "in-progress" },
{ id: "FN-3", column: "in-progress" },
{ id: "FN-4", column: "done" },
{ id: "FN-5", column: "triage", status: "planning", paused: false },
];
const mockStore = createMockStoreWithTasks(tasks);
@@ -1572,8 +1617,8 @@ describe("GET /api/projects/:id/health route handler", () => {
// Should have computed counts from project store
expect(health.projectId).toBe("proj_new");
expect(health.activeTaskCount).toBe(3); // todo + 2 in-progress
expect(health.inFlightAgentCount).toBe(2); // 2 in-progress
expect(health.activeTaskCount).toBe(4); // todo + 2 in-progress + triage
expect(health.inFlightAgentCount).toBe(3); // 2 in-progress + active triage planner
expect(health.totalTasksCompleted).toBe(1); // done
expect(health.status).toBe("active");
});

View File

@@ -822,7 +822,14 @@ export const registerProjectRoutes: ApiRouteRegistrar = (ctx) => {
const tasks = await projectStore.listTasks({ slim: true });
const activeCols = new Set(["triage", "todo", "in-progress", "in-review"]);
const activeTaskCount = tasks.filter((t) => activeCols.has(t.column)).length;
const inFlightAgentCount = tasks.filter((t) => t.column === "in-progress").length;
/*
* FNXC:GlobalConcurrencyControls 2026-06-26-23:46:
* Project health In-Flight Agents is a live read-layer count, not persisted slot bookkeeping.
* Include active triage planners using the same `triage` + `planning` + not-paused predicate that gates `maxTriageConcurrent`, so project-level health matches FN-7097's global running-agent count without mutating stored health.
*/
const inFlightAgentCount = tasks.filter(
(t) => t.column === "in-progress" || (t.column === "triage" && t.status === "planning" && !t.paused),
).length;
const totalTasksCompleted = tasks.filter((t) => t.column === "done" || t.column === "archived").length;
// Get central health metadata (if available) to preserve non-count fields