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:
7
.changeset/fn-7102-health-triage-inflight.md
Normal file
7
.changeset/fn-7102-health-triage-inflight.md
Normal 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.
|
||||||
@@ -643,6 +643,8 @@ fn project remove my-app --force
|
|||||||
|
|
||||||
Subcommands: `list|ls`, `add`, `remove|rm`, `show`, `info`, `set-default|default`, `detect`.
|
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
|
`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
|
does not contain a Git repository yet, Fusion runs a minimal `git init` during
|
||||||
registration and fails the registration if Git is unavailable.
|
registration and fails the registration if Git is unavailable.
|
||||||
|
|||||||
@@ -108,12 +108,12 @@ 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.
|
`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
|
## 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. -->
|
<!-- 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
|
## Plugin Scope in Multi-Project Mode
|
||||||
|
|
||||||
|
|||||||
@@ -359,7 +359,7 @@ 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 () => {
|
it("runProjectShow derives In-Flight Agents from live executors and triage planners when central health is stale", async () => {
|
||||||
mockGetProject.mockResolvedValue({
|
mockGetProject.mockResolvedValue({
|
||||||
id: "proj-1",
|
id: "proj-1",
|
||||||
name: "demo",
|
name: "demo",
|
||||||
@@ -384,17 +384,20 @@ describe("project commands", () => {
|
|||||||
{ id: "FN-001", column: "todo" },
|
{ id: "FN-001", column: "todo" },
|
||||||
{ id: "FN-002", column: "in-progress" },
|
{ id: "FN-002", column: "in-progress" },
|
||||||
{ id: "FN-003", 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");
|
const { runProjectShow } = await import("../project.js");
|
||||||
await runProjectShow("proj-1");
|
await runProjectShow("proj-1");
|
||||||
|
|
||||||
const output = consoleSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
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);
|
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([
|
mockListProjects.mockResolvedValue([
|
||||||
{ id: "proj-1", name: "app-one", path: "/tmp/app-one", status: "active", isolationMode: "in-process" },
|
{ id: "proj-1", name: "app-one", path: "/tmp/app-one", status: "active", isolationMode: "in-process" },
|
||||||
]);
|
]);
|
||||||
@@ -410,17 +413,51 @@ describe("project commands", () => {
|
|||||||
mockTaskStoreListTasks.mockResolvedValue([
|
mockTaskStoreListTasks.mockResolvedValue([
|
||||||
{ id: "FN-001", column: "in-progress" },
|
{ id: "FN-001", column: "in-progress" },
|
||||||
{ id: "FN-002", 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");
|
const { runProjectList } = await import("../project.js");
|
||||||
await runProjectList({ json: true });
|
await runProjectList({ json: true });
|
||||||
|
|
||||||
const parsed = JSON.parse(consoleSpy.mock.calls.map((call) => String(call[0])).join(""));
|
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(parsed[0].health.activeTaskCount).toBe(1);
|
||||||
expect(mockGetProjectHealth.mock.results[0]).toBeDefined();
|
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 () => {
|
it("runProjectList table prints the live In-Flight column", async () => {
|
||||||
mockListProjects.mockResolvedValue([
|
mockListProjects.mockResolvedValue([
|
||||||
{ id: "proj-1", name: "app-one", path: "/tmp/app-one", status: "active", isolationMode: "in-process" },
|
{ id: "proj-1", name: "app-one", path: "/tmp/app-one", status: "active", isolationMode: "in-process" },
|
||||||
@@ -437,6 +474,7 @@ describe("project commands", () => {
|
|||||||
mockTaskStoreListTasks.mockResolvedValue([
|
mockTaskStoreListTasks.mockResolvedValue([
|
||||||
{ id: "FN-001", column: "todo" },
|
{ id: "FN-001", column: "todo" },
|
||||||
{ id: "FN-002", column: "in-progress" },
|
{ id: "FN-002", column: "in-progress" },
|
||||||
|
{ id: "FN-003", column: "triage", status: "planning", paused: false },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const { runProjectList } = await import("../project.js");
|
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 output = consoleSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
||||||
const projectLine = output.split("\n").find((line) => line.includes("app-one"));
|
const projectLine = output.split("\n").find((line) => line.includes("app-one"));
|
||||||
expect(output).toContain("In-Flight");
|
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({
|
mockGetProject.mockResolvedValue({
|
||||||
id: "proj-1",
|
id: "proj-1",
|
||||||
name: "demo",
|
name: "demo",
|
||||||
@@ -470,6 +508,8 @@ describe("project commands", () => {
|
|||||||
mockTaskStoreListTasks.mockResolvedValue([
|
mockTaskStoreListTasks.mockResolvedValue([
|
||||||
{ id: "FN-001", column: "todo" },
|
{ id: "FN-001", column: "todo" },
|
||||||
{ id: "FN-002", column: "done" },
|
{ 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");
|
const { runProjectShow } = await import("../project.js");
|
||||||
@@ -491,14 +531,17 @@ describe("project commands", () => {
|
|||||||
});
|
});
|
||||||
mockGetSettings.mockResolvedValue({});
|
mockGetSettings.mockResolvedValue({});
|
||||||
mockGetProjectHealth.mockResolvedValue(undefined);
|
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");
|
const { runProjectShow } = await import("../project.js");
|
||||||
await runProjectShow("proj-1");
|
await runProjectShow("proj-1");
|
||||||
|
|
||||||
const output = consoleSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
const output = consoleSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
||||||
expect(output).toContain("Health:");
|
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 () => {
|
it("runProjectShow falls back to zero In-Flight Agents when the task store is unreadable", async () => {
|
||||||
|
|||||||
@@ -82,6 +82,11 @@ export interface ProjectInfoData {
|
|||||||
defaultProject: boolean;
|
defaultProject: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TaskCountSummary {
|
||||||
|
byColumn: Record<string, number>;
|
||||||
|
activeTriagePlannerCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format a path for display, showing relative path when possible.
|
* 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.
|
* 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 {
|
try {
|
||||||
const store = new TaskStore(projectPath);
|
const store = new TaskStore(projectPath);
|
||||||
await store.init();
|
await store.init();
|
||||||
@@ -126,13 +131,17 @@ async function getTaskCounts(projectPath: string): Promise<Record<string, number
|
|||||||
for (const col of COLUMNS) {
|
for (const col of COLUMNS) {
|
||||||
counts[col] = 0;
|
counts[col] = 0;
|
||||||
}
|
}
|
||||||
|
let activeTriagePlannerCount = 0;
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
counts[task.column] = (counts[task.column] || 0) + 1;
|
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 {
|
} catch {
|
||||||
// Return empty counts if we can't read the project
|
// 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);
|
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.
|
* 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(
|
function buildDisplayHealth(
|
||||||
taskCounts: Record<string, number>,
|
taskCounts: TaskCountSummary,
|
||||||
health?: ProjectHealth
|
health?: ProjectHealth
|
||||||
): NonNullable<ProjectInfoData["health"]> {
|
): NonNullable<ProjectInfoData["health"]> {
|
||||||
return {
|
return {
|
||||||
@@ -207,7 +216,7 @@ export async function runProjectList(options: ProjectListOptions = {}): Promise<
|
|||||||
updatedAt: project.updatedAt,
|
updatedAt: project.updatedAt,
|
||||||
lastActivityAt: health?.lastActivityAt ?? project.lastActivityAt,
|
lastActivityAt: health?.lastActivityAt ?? project.lastActivityAt,
|
||||||
health: displayHealth,
|
health: displayHealth,
|
||||||
taskCounts,
|
taskCounts: taskCounts.byColumn,
|
||||||
defaultProject: defaultProject?.id === project.id,
|
defaultProject: defaultProject?.id === project.id,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
@@ -499,10 +508,10 @@ export async function runProjectShow(name?: string): Promise<void> {
|
|||||||
console.log(` Status: (not registered)`);
|
console.log(` Status: (not registered)`);
|
||||||
console.log();
|
console.log();
|
||||||
const counts = await getTaskCounts(detected.path);
|
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) {
|
if (total > 0) {
|
||||||
console.log(` Tasks: ${total} total`);
|
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) {
|
if (count > 0) {
|
||||||
console.log(` ${COLUMN_LABELS[col as Column]}: ${count}`);
|
console.log(` ${COLUMN_LABELS[col as Column]}: ${count}`);
|
||||||
}
|
}
|
||||||
@@ -552,10 +561,10 @@ export async function runProjectShow(name?: string): Promise<void> {
|
|||||||
|
|
||||||
console.log();
|
console.log();
|
||||||
console.log(` Tasks:`);
|
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}`);
|
console.log(` Total: ${total}`);
|
||||||
for (const col of COLUMNS) {
|
for (const col of COLUMNS) {
|
||||||
const count = taskCounts[col] || 0;
|
const count = taskCounts.byColumn[col] || 0;
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
console.log(` ${COLUMN_LABELS[col]}: ${count}`);
|
console.log(` ${COLUMN_LABELS[col]}: ${count}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -256,7 +256,12 @@ export async function resolveProject(options: ResolveOptions = {}): Promise<Reso
|
|||||||
// 4. Has .fusion/ but not registered
|
// 4. Has .fusion/ but not registered
|
||||||
if (interactive) {
|
if (interactive) {
|
||||||
console.log(`\n Found fn project at ${fusionDir} but it's not registered.`);
|
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) {
|
if (identity) {
|
||||||
const recover = await promptConfirm(
|
const recover = await promptConfirm(
|
||||||
@@ -274,7 +279,7 @@ export async function resolveProject(options: ResolveOptions = {}): Promise<Reso
|
|||||||
const recoveredProject = ensured.project;
|
const recoveredProject = ensured.project;
|
||||||
await central.updateProject(recoveredProject.id, { status: "active" });
|
await central.updateProject(recoveredProject.id, { status: "active" });
|
||||||
try {
|
try {
|
||||||
writeProjectIdentity(fusionDir, {
|
writeProjectIdentity(projectFusionDir, {
|
||||||
id: recoveredProject.id,
|
id: recoveredProject.id,
|
||||||
createdAt: recoveredProject.createdAt,
|
createdAt: recoveredProject.createdAt,
|
||||||
});
|
});
|
||||||
@@ -315,7 +320,7 @@ export async function resolveProject(options: ResolveOptions = {}): Promise<Reso
|
|||||||
// Activate the project (registration sets it to 'initializing')
|
// Activate the project (registration sets it to 'initializing')
|
||||||
await central.updateProject(newProject.id, { status: "active" });
|
await central.updateProject(newProject.id, { status: "active" });
|
||||||
try {
|
try {
|
||||||
writeProjectIdentity(fusionDir, {
|
writeProjectIdentity(projectFusionDir, {
|
||||||
id: newProject.id,
|
id: newProject.id,
|
||||||
createdAt: newProject.createdAt,
|
createdAt: newProject.createdAt,
|
||||||
});
|
});
|
||||||
@@ -332,7 +337,7 @@ export async function resolveProject(options: ResolveOptions = {}): Promise<Reso
|
|||||||
{ directory: fusionDir },
|
{ directory: fusionDir },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
writeProjectIdentity(fusionDir, {
|
writeProjectIdentity(projectFusionDir, {
|
||||||
id: newProject.id,
|
id: newProject.id,
|
||||||
createdAt: newProject.createdAt,
|
createdAt: newProject.createdAt,
|
||||||
});
|
});
|
||||||
@@ -827,9 +832,9 @@ 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:
|
* FNXC:CLIProjectHealth 2026-06-26-23:46:
|
||||||
* This resolver returns raw central health for metadata compatibility, so `health.inFlightAgentCount` remains persisted bookkeeping.
|
* 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<{
|
export async function getProjectInfo(name?: string): Promise<{
|
||||||
project: ResolvedProject;
|
project: ResolvedProject;
|
||||||
|
|||||||
@@ -5729,9 +5729,9 @@ export interface ProjectHealth {
|
|||||||
/** Number of tasks currently active */
|
/** Number of tasks currently active */
|
||||||
activeTaskCount: number;
|
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.
|
* 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;
|
inFlightAgentCount: number;
|
||||||
/** ISO-8601 timestamp of last activity */
|
/** ISO-8601 timestamp of last activity */
|
||||||
|
|||||||
@@ -1384,7 +1384,7 @@ describe("GET /api/projects/:id/health route handler", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Helper to create a mock store with specific tasks
|
// 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> };
|
const mockStore = new MockStoreForRoutes() as MockStoreForRoutes & { listTasks: ReturnType<typeof vi.fn> };
|
||||||
mockStore.listTasks = vi.fn().mockResolvedValue(tasks);
|
mockStore.listTasks = vi.fn().mockResolvedValue(tasks);
|
||||||
return mockStore;
|
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 () => {
|
it("returns project-specific task counts when using project-scoped store", async () => {
|
||||||
// Create a store with specific tasks
|
// Create a store with specific tasks
|
||||||
const projectATasks = [
|
const projectATasks = [
|
||||||
{ id: "FN-1", column: "triage" },
|
{ id: "FN-1", column: "triage", status: "planning", paused: false },
|
||||||
{ id: "FN-2", column: "todo" },
|
{ id: "FN-2", column: "todo" },
|
||||||
{ id: "FN-3", column: "in-progress" },
|
{ id: "FN-3", column: "in-progress" },
|
||||||
{ id: "FN-4", column: "in-review" },
|
{ 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
|
// Should have computed counts from project-scoped store, not stale central data
|
||||||
expect(health.projectId).toBe("proj_a");
|
expect(health.projectId).toBe("proj_a");
|
||||||
expect(health.activeTaskCount).toBe(4); // triage + todo + in-progress + in-review
|
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.totalTasksCompleted).toBe(2); // done + archived
|
||||||
expect(health.status).toBe("active");
|
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 () => {
|
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 = [
|
const projectATasks = [
|
||||||
{ id: "FN-1", column: "triage" },
|
{ id: "FN-1", column: "triage", status: "planning", paused: false },
|
||||||
{ id: "FN-2", column: "in-progress" },
|
{ id: "FN-2", column: "in-progress" },
|
||||||
{ id: "FN-3", column: "done" },
|
{ 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 = [
|
const projectBTasks = [
|
||||||
{ id: "FN-10", column: "todo" },
|
{ id: "FN-10", column: "todo" },
|
||||||
{ id: "FN-11", column: "todo" },
|
{ id: "FN-11", column: "todo" },
|
||||||
{ id: "FN-12", column: "in-progress" },
|
{ id: "FN-12", column: "in-progress" },
|
||||||
{ id: "FN-13", column: "in-review" },
|
{ id: "FN-13", column: "in-review" },
|
||||||
{ id: "FN-14", column: "archived" },
|
{ 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);
|
const storeA = createMockStoreWithTasks(projectATasks);
|
||||||
@@ -1497,9 +1541,9 @@ describe("GET /api/projects/:id/health route handler", () => {
|
|||||||
expect(resA.status).toBe(200);
|
expect(resA.status).toBe(200);
|
||||||
const healthA = resA.body as Record<string, unknown>;
|
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.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
|
expect(healthA.totalTasksCompleted).toBe(1); // done
|
||||||
|
|
||||||
// Request health for project B
|
// Request health for project B
|
||||||
@@ -1528,9 +1572,9 @@ describe("GET /api/projects/:id/health route handler", () => {
|
|||||||
expect(resB.status).toBe(200);
|
expect(resB.status).toBe(200);
|
||||||
const healthB = resB.body as Record<string, unknown>;
|
const healthB = resB.body as Record<string, unknown>;
|
||||||
|
|
||||||
// Project B: 2 todo + 1 in-progress + 1 in-review = 4 active, 1 in-flight
|
// Project B: 2 todo + 1 in-progress + 1 in-review + 3 triage = 7 active, 2 in-flight
|
||||||
expect(healthB.activeTaskCount).toBe(4); // 2 todo + 1 in-progress + 1 in-review
|
expect(healthB.activeTaskCount).toBe(7); // 2 todo + 1 in-progress + 1 in-review + 3 triage
|
||||||
expect(healthB.inFlightAgentCount).toBe(1); // in-progress
|
expect(healthB.inFlightAgentCount).toBe(2); // in-progress + active triage planner only
|
||||||
expect(healthB.totalTasksCompleted).toBe(1); // archived
|
expect(healthB.totalTasksCompleted).toBe(1); // archived
|
||||||
|
|
||||||
// Verify no bleed-through: project A counts should not equal project B counts
|
// 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-2", column: "in-progress" },
|
||||||
{ id: "FN-3", column: "in-progress" },
|
{ id: "FN-3", column: "in-progress" },
|
||||||
{ id: "FN-4", column: "done" },
|
{ id: "FN-4", column: "done" },
|
||||||
|
{ id: "FN-5", column: "triage", status: "planning", paused: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
const mockStore = createMockStoreWithTasks(tasks);
|
const mockStore = createMockStoreWithTasks(tasks);
|
||||||
@@ -1572,8 +1617,8 @@ describe("GET /api/projects/:id/health route handler", () => {
|
|||||||
|
|
||||||
// Should have computed counts from project store
|
// Should have computed counts from project store
|
||||||
expect(health.projectId).toBe("proj_new");
|
expect(health.projectId).toBe("proj_new");
|
||||||
expect(health.activeTaskCount).toBe(3); // todo + 2 in-progress
|
expect(health.activeTaskCount).toBe(4); // todo + 2 in-progress + triage
|
||||||
expect(health.inFlightAgentCount).toBe(2); // 2 in-progress
|
expect(health.inFlightAgentCount).toBe(3); // 2 in-progress + active triage planner
|
||||||
expect(health.totalTasksCompleted).toBe(1); // done
|
expect(health.totalTasksCompleted).toBe(1); // done
|
||||||
expect(health.status).toBe("active");
|
expect(health.status).toBe("active");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -822,7 +822,14 @@ export const registerProjectRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
const tasks = await projectStore.listTasks({ slim: true });
|
const tasks = await projectStore.listTasks({ slim: true });
|
||||||
const activeCols = new Set(["triage", "todo", "in-progress", "in-review"]);
|
const activeCols = new Set(["triage", "todo", "in-progress", "in-review"]);
|
||||||
const activeTaskCount = tasks.filter((t) => activeCols.has(t.column)).length;
|
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;
|
const totalTasksCompleted = tasks.filter((t) => t.column === "done" || t.column === "archived").length;
|
||||||
|
|
||||||
// Get central health metadata (if available) to preserve non-count fields
|
// Get central health metadata (if available) to preserve non-count fields
|
||||||
|
|||||||
Reference in New Issue
Block a user