From 9e2fb5d62c81697605413e0681234292c8d32b23 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 27 Jun 2026 00:12:54 -0700 Subject: [PATCH] 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) --- .changeset/fn-7102-health-triage-inflight.md | 7 ++ docs/cli-reference.md | 2 + docs/multi-project.md | 4 +- .../src/commands/__tests__/project.test.ts | 59 ++++++++++++--- packages/cli/src/commands/project.ts | 35 +++++---- packages/cli/src/project-resolver.ts | 17 +++-- packages/core/src/types.ts | 4 +- .../src/__tests__/project-routes.test.ts | 71 +++++++++++++++---- .../src/routes/register-project-routes.ts | 9 ++- 9 files changed, 163 insertions(+), 45 deletions(-) create mode 100644 .changeset/fn-7102-health-triage-inflight.md diff --git a/.changeset/fn-7102-health-triage-inflight.md b/.changeset/fn-7102-health-triage-inflight.md new file mode 100644 index 0000000000..4a9db7c756 --- /dev/null +++ b/.changeset/fn-7102-health-triage-inflight.md @@ -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. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index e4aa158b25..0bbd97167c 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -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. diff --git a/docs/multi-project.md b/docs/multi-project.md index 561d9620c5..c3e003f955 100644 --- a/docs/multi-project.md +++ b/docs/multi-project.md @@ -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 -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 diff --git a/packages/cli/src/commands/__tests__/project.test.ts b/packages/cli/src/commands/__tests__/project.test.ts index f4651a0840..5627c5c969 100644 --- a/packages/cli/src/commands/__tests__/project.test.ts +++ b/packages/cli/src/commands/__tests__/project.test.ts @@ -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 () => { diff --git a/packages/cli/src/commands/project.ts b/packages/cli/src/commands/project.ts index 8cf9d7728f..827161db6f 100644 --- a/packages/cli/src/commands/project.ts +++ b/packages/cli/src/commands/project.ts @@ -82,6 +82,11 @@ export interface ProjectInfoData { defaultProject: boolean; } +interface TaskCountSummary { + byColumn: Record; + 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> { +async function getTaskCounts(projectPath: string): Promise { try { const store = new TaskStore(projectPath); await store.init(); @@ -126,13 +131,17 @@ async function getTaskCounts(projectPath: string): Promise): 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, + taskCounts: TaskCountSummary, health?: ProjectHealth ): NonNullable { 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 { 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 { 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}`); } diff --git a/packages/cli/src/project-resolver.ts b/packages/cli/src/project-resolver.ts index bec67105f6..4a16ffcfdf 100644 --- a/packages/cli/src/project-resolver.ts +++ b/packages/cli/src/project-resolver.ts @@ -256,7 +256,12 @@ export async function resolveProject(options: ResolveOptions = {}): Promise { }); // Helper to create a mock store with specific tasks - function createMockStoreWithTasks(tasks: Array<{ id: string; column: string }>): MockStoreForRoutes & { listTasks: ReturnType } { + function createMockStoreWithTasks(tasks: Array<{ id: string; column: string; status?: string; paused?: boolean }>): MockStoreForRoutes & { listTasks: ReturnType } { const mockStore = new MockStoreForRoutes() as MockStoreForRoutes & { listTasks: ReturnType }; 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; + 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; - // 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; - // 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"); }); diff --git a/packages/dashboard/src/routes/register-project-routes.ts b/packages/dashboard/src/routes/register-project-routes.ts index ec287999ff..665f0e1c85 100644 --- a/packages/dashboard/src/routes/register-project-routes.ts +++ b/packages/dashboard/src/routes/register-project-routes.ts @@ -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