FN-7461: count footer queues across task states
Correct the dashboard footer counter contract so active task pressure matches board state coverage. - Count triage and planning lanes as queued while excluding done, archived, and inactive custom lanes. - Treat only actionable blockedBy values as blocked tasks and keep running/stuck scoped to in-progress work. - Cover every visible footer counter, background/overlap segments, and the intentional absence of a Done footer count in regression tests. - Add a patch changeset for the published Fusion package. Files changed: .changeset/fn-7461-footer-counters.md | 7 ++ packages/dashboard/app/api/legacy.ts | 6 +- .../__tests__/ExecutorStatusBar.test.tsx | 86 +++++++++++++++++++++- .../app/hooks/__tests__/useExecutorStats.test.ts | 59 ++++++++------- packages/dashboard/app/hooks/useExecutorStats.ts | 24 +++++- 5 files changed, 150 insertions(+), 32 deletions(-) Fusion-Task-Id: FN-7461 Fusion-Task-Lineage: a1eceb98-76b2-4820-95c4-888ad61b4162 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7461-footer-counters.md
Normal file
7
.changeset/fn-7461-footer-counters.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Count planning tasks correctly in the dashboard footer queue metric.
|
||||
category: fix
|
||||
dev: Footer counter tests now cover queued, running, stuck, blocked, review, overlap, background AI, and Done absence.
|
||||
@@ -6933,7 +6933,9 @@ export type ExecutorState = "idle" | "running" | "paused" | "stopped";
|
||||
*
|
||||
* Counts (runningTaskCount, blockedTaskCount, queuedTaskCount, inReviewCount, stuckTaskCount)
|
||||
* are derived client-side from the same tasks array shared with the board, ensuring
|
||||
* the footer counts always match the column counts displayed on screen.
|
||||
* the footer counts always match the active work states displayed on screen. Queued covers
|
||||
* todo plus planning/triage work; Done is intentionally not exposed unless a footer Done
|
||||
* segment is added.
|
||||
* The API returns settings-based values (globalPause, enginePaused, maxConcurrent) and
|
||||
* lastActivityAt from the activity log.
|
||||
*
|
||||
@@ -6950,7 +6952,7 @@ export interface ExecutorStats {
|
||||
blockedTaskCount: number;
|
||||
/** Number of "in-progress" tasks with no activity for > 10 minutes */
|
||||
stuckTaskCount: number;
|
||||
/** Number of tasks in "todo" column */
|
||||
/** Number of tasks in "todo" plus planning/triage work states */
|
||||
queuedTaskCount: number;
|
||||
/** Number of tasks in "in-review" column */
|
||||
inReviewCount: number;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
@@ -42,7 +42,7 @@ vi.mock("../EngineControlMenu", async () => {
|
||||
});
|
||||
|
||||
import { useExecutorStats } from "../../hooks/useExecutorStats";
|
||||
import type { ExecutorStats } from "../../api";
|
||||
import type { AiSessionSummary, ExecutorStats } from "../../api";
|
||||
|
||||
const mockUseExecutorStats = useExecutorStats as ReturnType<typeof vi.fn>;
|
||||
const executorStatusBarCss = fs.readFileSync(path.join(__dirname, "../ExecutorStatusBar.css"), "utf-8");
|
||||
@@ -81,6 +81,32 @@ function makeTask(id: string, column: string, overrides: Record<string, unknown>
|
||||
};
|
||||
}
|
||||
|
||||
function getSegmentByLabel(label: string): HTMLElement {
|
||||
const labelElement = screen
|
||||
.getAllByText(label)
|
||||
.find((element) => element.classList.contains("executor-status-bar__label"));
|
||||
expect(labelElement).toBeTruthy();
|
||||
const segment = labelElement?.closest(".executor-status-bar__segment");
|
||||
expect(segment).toBeTruthy();
|
||||
return segment as HTMLElement;
|
||||
}
|
||||
|
||||
function expectSegmentCount(label: string, count: string): void {
|
||||
expect(within(getSegmentByLabel(label)).getByText(count)).toBeInTheDocument();
|
||||
}
|
||||
|
||||
function makeBackgroundSession(id: string, status: AiSessionSummary["status"]): AiSessionSummary {
|
||||
return {
|
||||
id,
|
||||
type: "planning",
|
||||
status,
|
||||
title: `Background ${id}`,
|
||||
projectId: "project-1",
|
||||
lockedByTab: null,
|
||||
updatedAt: "2026-07-03T12:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("ExecutorStatusBar", () => {
|
||||
const defaultStats: ExecutorStats = {
|
||||
runningTaskCount: 2,
|
||||
@@ -118,9 +144,65 @@ describe("ExecutorStatusBar", () => {
|
||||
expect(statusBar).toHaveTextContent("Blocked");
|
||||
expect(statusBar).toHaveTextContent("Queued");
|
||||
expect(statusBar).toHaveTextContent("In Review");
|
||||
expect(statusBar).not.toHaveTextContent("Done");
|
||||
expect(statusBar).not.toHaveTextContent("Escalated");
|
||||
});
|
||||
|
||||
it.each(["desktop", "tablet"] as const)("associates every visible footer count with its label on %s", (viewportMode) => {
|
||||
viewportModeMock.value = viewportMode;
|
||||
vi.mocked(mockUseExecutorStats).mockReturnValue({
|
||||
stats: {
|
||||
...defaultStats,
|
||||
queuedTaskCount: 9,
|
||||
runningTaskCount: 2,
|
||||
maxConcurrent: 4,
|
||||
stuckTaskCount: 1,
|
||||
blockedTaskCount: 2,
|
||||
inReviewCount: 1,
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
const tasks = [
|
||||
makeTask("FN-010", "in-progress", { columnMovedAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z" }),
|
||||
makeTask("FN-101", "todo", { blockedBy: "FN-010" }),
|
||||
makeTask("FN-102", "todo", { blockedBy: "FN-010" }),
|
||||
makeTask("FN-103", "todo", { blockedBy: "FN-010" }),
|
||||
makeTask("FN-104", "todo", { blockedBy: "FN-010" }),
|
||||
makeTask("FN-105", "todo", { blockedBy: "FN-010" }),
|
||||
];
|
||||
|
||||
render(
|
||||
<ExecutorStatusBar
|
||||
tasks={tasks as any[]}
|
||||
staleHighFanoutBlockerAgeThresholdMs={60 * 60 * 1000}
|
||||
backgroundSessions={[
|
||||
makeBackgroundSession("ai-1", "generating"),
|
||||
makeBackgroundSession("ai-2", "awaiting_input"),
|
||||
]}
|
||||
backgroundGenerating={1}
|
||||
backgroundNeedsInput={1}
|
||||
onOpenBackgroundSession={vi.fn()}
|
||||
onDismissBackgroundSession={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const statusBar = screen.getByRole("status");
|
||||
expectSegmentCount("Queued", "9");
|
||||
expectSegmentCount("Running", "2");
|
||||
expect(within(getSegmentByLabel("Running")).getByText("4")).toHaveClass("executor-status-bar__max");
|
||||
expectSegmentCount("Stuck", "1");
|
||||
expectSegmentCount("Blocked", "2");
|
||||
expectSegmentCount("In Review", "1");
|
||||
expect(statusBar).toHaveTextContent("Overlap queue");
|
||||
expect(statusBar).toHaveTextContent("FN-010 · 5 todo");
|
||||
expect(statusBar).toHaveTextContent("AI 2");
|
||||
expect(statusBar).not.toHaveTextContent("Done");
|
||||
expect(statusBar.firstElementChild).not.toHaveClass("executor-status-bar__divider");
|
||||
expect(statusBar.lastElementChild).toHaveClass("executor-status-bar__segment--engine-controls");
|
||||
});
|
||||
|
||||
it("shows overlap bottleneck summary with stable tie-break ordering", () => {
|
||||
const tasks = [
|
||||
makeTask("FN-010", "in-progress", { columnMovedAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z" }),
|
||||
|
||||
@@ -752,45 +752,52 @@ describe("useExecutorStats", () => {
|
||||
});
|
||||
|
||||
describe("board-sync regression", () => {
|
||||
it("derives counts from the same tasks array the board uses", async () => {
|
||||
// Simulate a full board: 2 triage, 3 todo (1 blocked), 2 in-progress, 1 in-review, 4 done
|
||||
it("derives the complete footer count matrix from the same tasks array the board uses", async () => {
|
||||
const now = new Date("2026-07-03T12:00:00.000Z").getTime();
|
||||
const staleUpdatedAt = new Date(now - 11 * 60 * 1000).toISOString();
|
||||
const freshUpdatedAt = new Date(now - 2 * 60 * 1000).toISOString();
|
||||
const tasks: Task[] = [
|
||||
createMockTask("FN-001", "triage"),
|
||||
createMockTask("FN-002", "triage"),
|
||||
createMockTask("FN-003", "todo"),
|
||||
{ ...createMockTask("FN-004", "todo"), blockedBy: "FN-010" },
|
||||
createMockTask("FN-005", "todo"),
|
||||
createMockTask("FN-006", "in-progress"),
|
||||
createMockTask("FN-007", "in-progress"),
|
||||
createMockTask("FN-008", "in-review"),
|
||||
createMockTask("FN-009", "done"),
|
||||
createMockTask("FN-010", "done"),
|
||||
createMockTask("FN-011", "done"),
|
||||
createMockTask("FN-012", "done"),
|
||||
createMockTask("FN-002", "todo"),
|
||||
{ ...createMockTask("FN-003", "in-progress"), updatedAt: staleUpdatedAt },
|
||||
{ ...createMockTask("FN-004", "in-progress"), updatedAt: freshUpdatedAt },
|
||||
createMockTask("FN-005", "in-review"),
|
||||
{ ...createMockTask("FN-006", "done"), status: "running" } as Task,
|
||||
createMockTask("FN-007", "archived"),
|
||||
{ ...createMockTask("FN-008", "todo"), blockedBy: "FN-006" },
|
||||
{ ...createMockTask("FN-009", "todo"), dependencies: ["FN-006"] },
|
||||
{ ...createMockTask("FN-010", "todo"), blockedBy: ["FN-006", "FN-006"] } as unknown as Task,
|
||||
{ ...createMockTask("FN-011", "todo"), blockedBy: "" },
|
||||
{ ...createMockTask("FN-012", "todo"), blockedBy: [] } as unknown as Task,
|
||||
{ ...createMockTask("FN-013", "todo"), blockedBy: null } as unknown as Task,
|
||||
{ ...createMockTask("FN-014", "custom-column" as Task["column"]) },
|
||||
{ ...createMockTask("FN-015", "triage"), status: "planning" } as Task,
|
||||
{ ...createMockTask("FN-016", "custom-planning" as Task["column"]), status: "planning" } as Task,
|
||||
];
|
||||
|
||||
const { result } = renderHook(() => useExecutorStats(tasks));
|
||||
const { result } = renderHook(() => useExecutorStats(tasks, undefined, 10 * 60 * 1000, now));
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
});
|
||||
|
||||
// These must match the column counts shown on the board
|
||||
expect(result.current.stats.runningTaskCount).toBe(2); // in-progress
|
||||
expect(result.current.stats.queuedTaskCount).toBe(3); // todo
|
||||
expect(result.current.stats.inReviewCount).toBe(1); // in-review
|
||||
expect(result.current.stats.blockedTaskCount).toBe(1); // blockedBy set
|
||||
expect(result.current.stats.stuckTaskCount).toBe(0); // all recent
|
||||
|
||||
// Verify the executor state reflects running tasks
|
||||
expect(result.current.stats.queuedTaskCount).toBe(10); // triage/planning + todo, no done/archived/non-planning custom
|
||||
expect(result.current.stats.runningTaskCount).toBe(2); // in-progress only
|
||||
expect(result.current.stats.stuckTaskCount).toBe(1); // stuck is an in-progress subset
|
||||
expect(result.current.stats.blockedTaskCount).toBe(2); // actionable string/array blockedBy only
|
||||
expect(result.current.stats.inReviewCount).toBe(1); // in-review only
|
||||
expect("doneTaskCount" in result.current.stats).toBe(false);
|
||||
expect(result.current.stats.executorState).toBe("running");
|
||||
});
|
||||
|
||||
it("does not count triage, done, or archived tasks in any footer metric", async () => {
|
||||
it("counts planning/triage as queued but excludes done, archived, and unknown columns", async () => {
|
||||
const tasks: Task[] = [
|
||||
createMockTask("FN-001", "triage"),
|
||||
createMockTask("FN-002", "done"),
|
||||
createMockTask("FN-003", "archived"),
|
||||
{ ...createMockTask("FN-002", "triage"), status: "planning" } as Task,
|
||||
createMockTask("FN-003", "done"),
|
||||
createMockTask("FN-004", "archived"),
|
||||
{ ...createMockTask("FN-005", "custom-column" as Task["column"]) },
|
||||
{ ...createMockTask("FN-006", "custom-planning" as Task["column"]), status: "planning" } as Task,
|
||||
];
|
||||
|
||||
const { result } = renderHook(() => useExecutorStats(tasks));
|
||||
@@ -800,7 +807,7 @@ describe("useExecutorStats", () => {
|
||||
});
|
||||
|
||||
expect(result.current.stats.runningTaskCount).toBe(0);
|
||||
expect(result.current.stats.queuedTaskCount).toBe(0);
|
||||
expect(result.current.stats.queuedTaskCount).toBe(3);
|
||||
expect(result.current.stats.inReviewCount).toBe(0);
|
||||
expect(result.current.stats.blockedTaskCount).toBe(0);
|
||||
expect(result.current.stats.stuckTaskCount).toBe(0);
|
||||
|
||||
@@ -57,6 +57,9 @@ function deriveExecutorState(
|
||||
|
||||
/**
|
||||
* Derive statistics from the task list.
|
||||
*
|
||||
* FNXC:ExecutorStatusBar 2026-07-03-00:18:
|
||||
* Footer task counters must mirror the board's operator-facing active work states: Queued includes todo plus planning/triage work, Running and Stuck stay scoped to in-progress execution, Done remains absent from the footer contract unless a labeled Done segment is introduced, and archived/completed/non-planning custom lanes never inflate active pressure counts.
|
||||
*/
|
||||
function deriveStatsFromTasks(tasks: Task[], taskStuckTimeoutMs?: number, lastFetchTimeMs?: number): Pick<
|
||||
ExecutorStats,
|
||||
@@ -77,15 +80,20 @@ function deriveStatsFromTasks(tasks: Task[], taskStuckTimeoutMs?: number, lastFe
|
||||
}
|
||||
break;
|
||||
case "todo":
|
||||
case "triage":
|
||||
queuedTaskCount++;
|
||||
break;
|
||||
case "in-review":
|
||||
inReviewCount++;
|
||||
break;
|
||||
default:
|
||||
if (task.status === "planning" && !isTerminalOrActiveTaskColumn(task.column)) {
|
||||
queuedTaskCount++;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Count tasks with blockedBy set
|
||||
if (task.blockedBy && task.blockedBy.length > 0) {
|
||||
if (hasActionableBlockedBy(task.blockedBy)) {
|
||||
blockedTaskCount++;
|
||||
}
|
||||
}
|
||||
@@ -99,6 +107,18 @@ function deriveStatsFromTasks(tasks: Task[], taskStuckTimeoutMs?: number, lastFe
|
||||
};
|
||||
}
|
||||
|
||||
function isTerminalOrActiveTaskColumn(column: Task["column"]): boolean {
|
||||
return column === "in-progress" || column === "in-review" || column === "done" || column === "archived";
|
||||
}
|
||||
|
||||
function hasActionableBlockedBy(blockedBy: Task["blockedBy"] | string[] | null): boolean {
|
||||
if (Array.isArray(blockedBy)) {
|
||||
return blockedBy.some((id) => typeof id === "string" && id.trim().length > 0);
|
||||
}
|
||||
|
||||
return typeof blockedBy === "string" && blockedBy.trim().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for aggregating executor statistics for the status bar.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user