diff --git a/.changeset/board-activity-never-exceeds-live-agents.md b/.changeset/board-activity-never-exceeds-live-agents.md new file mode 100644 index 0000000000..f1ec6dfd2d --- /dev/null +++ b/.changeset/board-activity-never-exceeds-live-agents.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Board lane counts and card glow now never exceed the actual number of running agents. +category: fix +dev: `isTaskAgentActive`'s positive arm now delegates to the shared `isRunningAgentTask` predicate and Column headers count only that predicate; the needs-replan REVISING chrome and fresh planner-log glow window are removed (idle replans render "Queued to revise"). diff --git a/packages/dashboard/app/components/Column.tsx b/packages/dashboard/app/components/Column.tsx index 88c1737627..1d841d9395 100644 --- a/packages/dashboard/app/components/Column.tsx +++ b/packages/dashboard/app/components/Column.tsx @@ -11,7 +11,6 @@ import { WorktreeGroup } from "./WorktreeGroup"; import { QuickEntryBox } from "./QuickEntryBox"; import { PluginSlot } from "./PluginSlot"; import { groupByWorktree } from "../utils/worktreeGrouping"; -import { isTaskAgentActive } from "../utils/taskActivity"; import { isArchivedColumnRole, isCompleteColumnRole, @@ -20,7 +19,6 @@ import { isReviewColumnRole, isWipColumnRole, } from "../utils/columnRoles"; -import { isTaskStuck } from "../utils/taskStuck"; import type { ToastType } from "../hooks/useToast"; import type { TaskContextMenuColumnMetadata } from "./TaskContextMenu"; import { ChevronDown, ChevronUp, MoreVertical } from "lucide-react"; @@ -365,24 +363,23 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree Column header is executing/total (e.g. 3/4). Executing uses the same Running predicate as the footer (unpaused WIP, live planners, active review). Total is the card count in this lane. - FNXC:BoardColumnCount 2026-07-22-06:10: - The header must agree with the cards below it: a Todo card parked in the durable - `needs-replan` stage keeps its REVISING badge and activity chrome (FN-8494), so a header - that only counts live agents read 0/2 under a glowing card. Union the shared Running - predicate with the card's own activity-chrome predicate (isTaskAgentActive, same - globalPaused/stuck gates the card applies) so the count equals the number of visibly - active cards. Footer Running and admission intentionally keep the live-agent-only truth — - a parked replan must not consume top-level concurrency capacity. + FNXC:BoardColumnCount 2026-08-01-17:53: + Operator requirement: summing the lane headers must never exceed the engine's live-agent + population (the concurrency cap's admission truth). The former union with the card + activity-chrome predicate (FN-8494 REVISING chrome et al.) let the sum read cap+1 (e.g. 10 + glowing cards under a 9-slot limit), which operators read as a capacity breach. The union is + removed and the chrome predicate itself (isTaskAgentActive) now delegates its positive arm to + the same shared Running predicate, so the header still agrees with the glowing cards below it: + glow is a strict subset of Running, never a superset. */ const activeTaskCount = useMemo( () => tasks.filter((task) => - isRunningAgentTask(enrichRunningAgentTaskShapeFromFlags(task, columnFlags)) // FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (PR #2566 review — greptile): these // tasks are IN this column, so the column's own flags are their column traits. Without // them the header undercounts executing work on a merged planning lane. - || isTaskAgentActive(task, { globalPaused, isStuck: isTaskStuck(task, taskStuckTimeoutMs, lastFetchTimeMs, columnFlags), columnFlags }), + isRunningAgentTask(enrichRunningAgentTaskShapeFromFlags(task, columnFlags)), ).length, - [tasks, columnFlags, globalPaused, taskStuckTimeoutMs, lastFetchTimeMs], + [tasks, columnFlags], ); /* FNXC:BoardColumnWindowing 2026-07-26-11:48: diff --git a/packages/dashboard/app/components/__tests__/Column.test.tsx b/packages/dashboard/app/components/__tests__/Column.test.tsx index ca9c41f172..d6c84d9105 100644 --- a/packages/dashboard/app/components/__tests__/Column.test.tsx +++ b/packages/dashboard/app/components/__tests__/Column.test.tsx @@ -185,12 +185,13 @@ describe("Column count-flash", () => { expect(screen.getByLabelText("1 executing of 4")).toHaveTextContent("1/4"); }); - it("counts cards with active chrome — a REVISING (needs-replan) todo card and a live code-review gate", () => { - // Header must equal the number of visibly active cards (FN-8494 keeps REVISING chrome on - // parked replans; a live gate session runs with null status and a pending step lease). + it("does NOT count a parked REVISING (needs-replan) todo card — it holds no concurrency slot", () => { + // FNXC:BoardColumnCount 2026-08-01-17:53: summing lane headers must never exceed the + // engine's live-agent population, so the header counts only the shared Running predicate. + // A parked replan glows nothing and counts nothing; a live planning card counts. const tasks = [ { ...makeTask("FN-001"), column: "todo" as ColumnType, status: "needs-replan" as any }, - { ...makeTask("FN-002"), column: "todo" as ColumnType }, + { ...makeTask("FN-002"), column: "todo" as ColumnType, status: "planning" as any }, ]; render( { viewportSpy.mockRestore(); }); - it("renders the active Planning badge for a fresh status-null triage card in grouped mobile cards", () => { + it("does not glow a fresh status-null triage card in grouped mobile cards", () => { + // FNXC:TaskActivity 2026-08-01-17:53: fresh planner logs alone are not a concurrency + // slot; the pulsing Planning badge requires the authoritative planning status. const viewportSpy = mockMobileViewport(); try { renderListView({ @@ -805,14 +807,14 @@ describe("ListView", () => { }); const card = screen.getByText("FN-8300-mobile").closest(".list-card") as HTMLElement; - expect(card).toHaveClass("agent-active"); - expect(within(card).getByLabelText("Planning")).toHaveClass("list-status-badge", "pulsing"); + expect(card).not.toHaveClass("agent-active"); + expect(within(card).queryByLabelText("Planning")).not.toBeInTheDocument(); } finally { viewportSpy.mockRestore(); } }); - it("renders the active Planning badge for a fresh status-null triage card in desktop table rows", () => { + it("does not glow a fresh status-null triage card in desktop table rows", () => { const viewportSpy = mockDesktopViewport(); try { renderListView({ @@ -824,8 +826,8 @@ describe("ListView", () => { }); const row = screen.getByText("FN-8300-desktop").closest("tr") as HTMLElement; - expect(row).toHaveClass("agent-active"); - expect(within(row).getByLabelText("Planning")).toHaveClass("list-status-badge", "pulsing"); + expect(row).not.toHaveClass("agent-active"); + expect(within(row).queryByLabelText("Planning")).not.toBeInTheDocument(); } finally { viewportSpy.mockRestore(); } @@ -2620,14 +2622,16 @@ describe("ListView", () => { } }); - it("FN-8493 renders Revising, not Replan, for bare needs-replan list rows on desktop and mobile", () => { + it("FN-8493 renders the idle Queued to revise label, not Replan, for bare needs-replan list rows on desktop and mobile", () => { + // FNXC:TaskActivity 2026-08-01-17:53: a parked replan is idle (no concurrency slot), so + // list rows show the descriptive waiting label rather than the live "Revising" copy. const task = createMockTask({ id: "FN-8493-needs-replan", column: "triage", status: "needs-replan" }); const desktopViewport = mockDesktopViewport(); try { const { unmount } = renderListView({ tasks: [task] }); const row = screen.getByText(task.id).closest("tr") as HTMLElement; - expect(within(row).getByText("Revising")).toHaveClass("list-status-badge"); + expect(within(row).getByText("Queued to revise")).toHaveClass("list-status-badge"); expect(within(row).queryByText("Replan")).not.toBeInTheDocument(); unmount(); } finally { @@ -2638,7 +2642,7 @@ describe("ListView", () => { try { renderListView({ tasks: [task] }); const card = screen.getByText(task.id).closest(".list-card") as HTMLElement; - expect(within(card).getByText("Revising")).toHaveClass("list-status-badge"); + expect(within(card).getByText("Queued to revise")).toHaveClass("list-status-badge"); expect(within(card).queryByText("Replan")).not.toBeInTheDocument(); } finally { mobileViewport.mockRestore(); @@ -2657,8 +2661,6 @@ describe("ListView", () => { it.each([ { status: "executing", column: "in-progress" as const, label: "executing" }, { status: "merging-fix", column: "in-review" as const, label: "Merging fixes…" }, - { status: "needs-replan", column: "triage" as const, label: "Revising" }, - { status: "needs-replan", column: "todo" as const, label: "Revising" }, ])("renders agent-active tasks with static highlight styling for $status", ({ status, column, label }) => { const tasks = [ createMockTask({ @@ -2675,6 +2677,26 @@ describe("ListView", () => { expect(screen.getByText(label)).toBeInTheDocument(); }); + it.each([ + { status: "needs-replan", column: "triage" as const }, + { status: "needs-replan", column: "todo" as const }, + ])("does NOT highlight parked needs-replan rows ($column) — they hold no concurrency slot", ({ status, column }) => { + // FNXC:TaskActivity 2026-08-01-17:53: replan parks are waiting states; glow and lane + // counts must never exceed the live-agent population. + const tasks = [ + createMockTask({ + id: "FN-001", + status, + column, + }), + ]; + + renderListView({ tasks, globalPaused: false }); + + const row = screen.getByText("FN-001").closest("tr"); + expect(row?.className).not.toContain("agent-active"); + }); + it("does not render agent-active when globalPaused is true", () => { const tasks = [ createMockTask({ @@ -5845,8 +5867,6 @@ describe("ListView - Bulk Selection", () => { it.each([ { status: "executing", column: "in-progress" as const }, { status: "merging-fix", column: "in-review" as const }, - { status: "needs-replan", column: "triage" as const }, - { status: "needs-replan", column: "todo" as const }, ])("applies agent-active class to mobile cards for active states (%s)", ({ status, column }) => { mockMobileViewport(); @@ -5865,6 +5885,28 @@ describe("ListView - Bulk Selection", () => { expect(card?.className).toContain("agent-active"); }); + it.each([ + { status: "needs-replan", column: "triage" as const }, + { status: "needs-replan", column: "todo" as const }, + ])("does NOT apply agent-active to mobile cards for parked replans (%s)", ({ status, column }) => { + // FNXC:TaskActivity 2026-08-01-17:53: parked replans hold no concurrency slot. + mockMobileViewport(); + + const { container } = renderListView({ + tasks: [ + createMockTask({ + id: "FN-001", + status, + column, + }), + ], + globalPaused: false, + }); + + const card = container.querySelector('.list-card[data-id="FN-001"]'); + expect(card?.className).not.toContain("agent-active"); + }); + it("does not apply agent-active class to mobile cards when globalPaused is true", () => { mockMobileViewport(); diff --git a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx index d3abdba236..e7b83ff7f7 100644 --- a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx @@ -2472,7 +2472,9 @@ describe("TaskCard", () => { expect(screen.getByText("executing")).toBeDefined(); }); - it("FN-8493 renders Revising, not Replan, for a bare needs-replan Board card", () => { + it("FN-8493 renders the idle Queued to revise label, not Replan, for a bare needs-replan Board card", () => { + // FNXC:TaskActivity 2026-08-01-17:53: needs-replan holds no concurrency slot, so the card is + // idle — it renders the descriptive waiting label instead of the live "Revising" copy. render( { />, ); - expect(screen.getByText("Revising")).toHaveClass("card-status-badge"); + expect(screen.getByText("Queued to revise")).toHaveClass("card-status-badge"); expect(screen.queryByText("Replan")).not.toBeInTheDocument(); }); @@ -2972,7 +2974,9 @@ describe("TaskCard", () => { expect(headerBadges.contains(badge)).toBe(true); }); - it("renders an active Planning badge when a status-null triage card has fresh planner activity", () => { + it("does not glow a status-null triage card on fresh planner logs alone", () => { + // FNXC:TaskActivity 2026-08-01-17:53: a log line is not a concurrency slot; the pulsing + // Planning badge requires the authoritative planning status the engine counts. const recentAgentActivityAt = new Date().toISOString(); const { container } = render( { />, ); - expect(container.querySelector(".card")).toHaveClass("agent-active"); - expect(screen.getByLabelText("Planning")).toHaveClass("card-status-badge", "pulsing"); + expect(container.querySelector(".card")).not.toHaveClass("agent-active"); + expect(container.querySelector(".card-status-badge")).toBeNull(); }); it("does not render a status badge when a status-null triage card has no fresh planner activity", () => { @@ -2994,7 +2998,9 @@ describe("TaskCard", () => { expect(container.querySelector(".card-status-badge")).toBeNull(); }); - it("keeps board replan cards glowing and their status badge pulsing", () => { + it("does not glow board replan cards — a parked replan holds no concurrency slot", () => { + // FNXC:TaskActivity 2026-08-01-17:53: FN-8494's replan chrome is removed so lane counts + // and glow can never exceed the live-agent population; the badge stays, statically. const { container } = render( { />, ); - expect(container.querySelector(".card")).toHaveClass("agent-active"); - expect(screen.getByText("Revising")).toHaveClass("card-status-badge", "pulsing"); + expect(container.querySelector(".card")).not.toHaveClass("agent-active"); + expect(screen.getByText("Queued to revise")).toHaveClass("card-status-badge"); + expect(screen.getByText("Queued to revise")).not.toHaveClass("pulsing"); }); it.each([ diff --git a/packages/dashboard/app/components/__tests__/TaskCard.u11-merged-column.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.u11-merged-column.test.tsx index dfe93aa642..552dd229e0 100644 --- a/packages/dashboard/app/components/__tests__/TaskCard.u11-merged-column.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskCard.u11-merged-column.test.tsx @@ -62,17 +62,25 @@ describe("TaskCard on the U11 merged planning column", () => { test — matching a word that also appears in chrome. */ - it("reads as agent-active from fresh planner activity on the merged column", () => { + it("reads as agent-active from a live planning status on the merged column", () => { /* - FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (PR #2566 review — greptile): - `isTaskAgentActive`'s planner-lane clause needs this card's column traits. Without - them it falls back to the legacy ids, and a status-null card on the merged lane reads - IDLE — pulsing Planning state gone, optional-gate activity suppressed, column header - undercounting executing work. Threading ListView alone left the board cards broken. - - REVERT CHECK: drop `columnFlags` from TaskCard's `isTaskAgentActive` call and this - fails — the pulsing class disappears because the card is in `todo`, not `triage`. + FNXC:TaskActivity 2026-08-01-17:53: + Activity chrome now follows the shared live-agent predicate: a `planning` status card + glows on the merged lane, while fresh planner logs alone no longer do (a log line is + not a concurrency slot, and lane counts must never exceed the live-agent population). */ + const { container } = render( + )} + taskColumnFlags={MERGED_PLANNING_FLAGS} + onOpenDetail={() => {}} + addToast={() => {}} + />, + ); + expect(container.querySelector(".pulsing")).not.toBeNull(); + }); + + it("does NOT read as agent-active from fresh planner logs alone", () => { const { container } = render( )} @@ -81,7 +89,7 @@ describe("TaskCard on the U11 merged planning column", () => { addToast={() => {}} />, ); - expect(container.querySelector(".pulsing")).not.toBeNull(); + expect(container.querySelector(".pulsing")).toBeNull(); }); it("does NOT offer Start on the merged column, which auto-triages", () => { diff --git a/packages/dashboard/app/components/__tests__/column-role-id-invariance.test.tsx b/packages/dashboard/app/components/__tests__/column-role-id-invariance.test.tsx index e137b1c1bf..bd1bf6cebf 100644 --- a/packages/dashboard/app/components/__tests__/column-role-id-invariance.test.tsx +++ b/packages/dashboard/app/components/__tests__/column-role-id-invariance.test.tsx @@ -122,16 +122,18 @@ describe("column-role decisions are invariant under column RENAMING (U12 evidenc fallback here reports planning work as idle board-wide — the failure that motivated the `taskActivity` conversion, and one that throws nothing. */ - const recent = new Date(Date.now() - 5_000).toISOString(); + // FNXC:TaskActivity 2026-08-01-17:53: glow now requires the authoritative `planning` + // status (the slot-holding signal); the client-only fresh-log window is removed so lane + // counts can never exceed the live-agent population. const verdicts = LINEAGES.map((lineage) => isTaskAgentActive( - mkTask({ id: "FN-2", column: lineage.columnId, recentAgentActivityAt: recent } as never), + mkTask({ id: "FN-2", column: lineage.columnId, status: "planning" } as never), { columnFlags: PRE_IMPLEMENTATION_TRAITS as never }, ), ); expect(new Set(verdicts).size).toBe(1); - // Non-vacuous: fresh planner activity on a pre-implementation card IS active. + // Non-vacuous: a live planning card on a pre-implementation lane IS active. expect(verdicts[0]).toBe(true); }); @@ -140,7 +142,8 @@ describe("column-role decisions are invariant under column RENAMING (U12 evidenc Without this, the case above would pass for a predicate hardwired to `true`. Both verdicts must be unanimous AND opposite each other for the invariance to mean anything. */ - const stale = new Date(Date.now() - 60 * 60 * 1000).toISOString(); + // A status-null waiting card (even with recent planner logs) holds no slot: inactive. + const stale = new Date(Date.now() - 5_000).toISOString(); const verdicts = LINEAGES.map((lineage) => isTaskAgentActive( mkTask({ id: "FN-3", column: lineage.columnId, recentAgentActivityAt: stale } as never), diff --git a/packages/dashboard/app/utils/__tests__/taskActivity.test.ts b/packages/dashboard/app/utils/__tests__/taskActivity.test.ts index a02b34588b..97c33212bb 100644 --- a/packages/dashboard/app/utils/__tests__/taskActivity.test.ts +++ b/packages/dashboard/app/utils/__tests__/taskActivity.test.ts @@ -1,7 +1,18 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import type { Task } from "@fusion/core"; import { ACTIVE_STATUSES, isTaskAgentActive } from "../taskActivity"; +/* +FNXC:TaskActivity 2026-08-01-17:53: +Operator requirement: card activity chrome (and the lane counts derived from it) must never show +more work than the engine's actual live-agent population. The positive arm of isTaskAgentActive is +now exactly the shared isRunningAgentTask predicate used by footer Running and project admission, +so the former render-only extras — needs-replan REVISING chrome, the fresh planner-log window, +ACTIVE_STATUSES in arbitrary columns — no longer glow: they described cards that hold no +concurrency slot, and summing lane headers exceeded the concurrency cap (10 glowing cards under a +9-slot limit read as a capacity breach). +*/ + function makeTask(overrides: Partial = {}): Task { return { id: "FN-8055", @@ -32,53 +43,50 @@ function taskWithRunningWorkflowStep(overrides: Partial = {}): Task { } describe("isTaskAgentActive", () => { - afterEach(() => vi.useRealTimers()); - - it("uses the canonical set for every active phase", () => { + it("keeps the canonical phase vocabulary for lock policy without gating glow on it", () => { + // ACTIVE_STATUSES remains the model/routing lock vocabulary; glow no longer unions it. expect([...ACTIVE_STATUSES]).toEqual([ "planning", "researching", "executing", "finalizing", "merging", "merging-pr", "merging-fix", "reviewing", "landing", ]); - for (const status of ACTIVE_STATUSES) { - expect(isTaskAgentActive(makeTask({ status }))).toBe(true); - } }); - it("recognizes an in-progress task and status-null running workflow step", () => { + it("glows only where the shared Running predicate holds a slot", () => { + // planning is live in any non-terminal column. + expect(isTaskAgentActive(makeTask({ status: "planning" }))).toBe(true); + // Merge-pipeline statuses are live only in the review/merge lane. + expect(isTaskAgentActive(makeTask({ column: "in-review", status: "merging" }))).toBe(true); + expect(isTaskAgentActive(makeTask({ column: "triage", status: "merging" }))).toBe(false); + // A stale execution status outside the WIP lane holds no slot and must not glow. + expect(isTaskAgentActive(makeTask({ column: "triage", status: "executing" }))).toBe(false); + // WIP membership is live regardless of status. + expect(isTaskAgentActive(makeTask({ column: "in-progress", status: "executing" }))).toBe(true); + }); + + it("recognizes an in-progress task and status-null pending gate lease", () => { expect(isTaskAgentActive(makeTask({ column: "in-progress" }))).toBe(true); expect(isTaskAgentActive(taskWithRunningWorkflowStep())).toBe(true); }); - it("keeps durable replan cards active without changing lock statuses", () => { + it("does not glow durable replan parks — they hold no concurrency slot", () => { expect(ACTIVE_STATUSES.has("needs-replan")).toBe(false); - expect(isTaskAgentActive(makeTask({ status: "needs-replan", column: "triage" }))).toBe(true); - expect(isTaskAgentActive(makeTask({ status: "needs-replan", column: "todo" }))).toBe(true); + expect(isTaskAgentActive(makeTask({ status: "needs-replan", column: "triage" }))).toBe(false); + expect(isTaskAgentActive(makeTask({ status: "needs-replan", column: "todo" }))).toBe(false); }); - it("does not treat a status-null task without a running item as active", () => { + it("does not treat a status-null intake task as active", () => { expect(isTaskAgentActive(makeTask())).toBe(false); }); - it("uses a fresh client-only planner log signal for a status-null triage card", () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-07-28T12:00:30.000Z")); - + it("does not glow on a fresh planner log without an authoritative live status", () => { + // The FN-8300 client-only fresh-log window is removed: a log line is not a slot. expect(isTaskAgentActive(makeTask({ - recentAgentActivityAt: "2026-07-28T12:00:00.000Z", - }))).toBe(true); - expect(isTaskAgentActive(makeTask({ - recentAgentActivityAt: "2026-07-28T11:59:00.000Z", + recentAgentActivityAt: new Date().toISOString(), }))).toBe(false); - }); - - it("extends fresh planner activity to plan-in-place todo replans", () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-07-22T09:25:30.000Z")); - expect(isTaskAgentActive(makeTask({ column: "todo", status: "needs-replan", - recentAgentActivityAt: "2026-07-22T09:25:00.000Z", - }))).toBe(true); + recentAgentActivityAt: new Date().toISOString(), + }))).toBe(false); }); it.each([ @@ -108,7 +116,9 @@ describe("isTaskAgentActive", () => { ["paused replan", makeTask({ status: "needs-replan", paused: true, recentAgentActivityAt: new Date().toISOString() }), {}], ["failed replan", makeTask({ status: "failed", recentAgentActivityAt: new Date().toISOString() }), {}], ["done-column replan", makeTask({ column: "done", status: "needs-replan", recentAgentActivityAt: new Date().toISOString() }), {}], - ] as const)("rejects %s before running workflow activity", (_name, task, options) => { + ["paused planning", makeTask({ status: "planning", paused: true }), {}], + ["globally paused WIP", makeTask({ column: "in-progress" }), { globalPaused: true }], + ] as const)("rejects %s before running-agent evaluation", (_name, task, options) => { expect(isTaskAgentActive(task, options)).toBe(false); }); }); diff --git a/packages/dashboard/app/utils/__tests__/taskActivity.u11-planner-lane.test.ts b/packages/dashboard/app/utils/__tests__/taskActivity.u11-planner-lane.test.ts index 8ac86ac850..16c4ede749 100644 --- a/packages/dashboard/app/utils/__tests__/taskActivity.u11-planner-lane.test.ts +++ b/packages/dashboard/app/utils/__tests__/taskActivity.u11-planner-lane.test.ts @@ -3,16 +3,13 @@ FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (U12 — R8 drift conversion): `isTaskAgentActive` under the U11 COLUMN SHAPE — merged pre-implementation column keeps the id `todo`, `triage` is deleted. -This predicate is the one that matters most in the dashboard drift, and it is not a -badge: it drives the pulsing status badge, the agent-active row border, AND the column -header's executing count. Its fresh-planner-activity clause was keyed on -`column === "triage"`, so once U11 lands a planning card with live planner logs reads as -IDLE everywhere at once — the board quietly stops reporting that planning is happening, -with nothing failing. - -REVERT CHECK: drop the `columnFlags` branch and "merged planning column" fails — the card -is in `todo` without `needs-replan`, which the legacy clause does not recognise as a -planner lane. +FNXC:TaskActivity 2026-08-01-17:53: +Rewritten for the "never show more than actual" requirement: the fresh planner-log window +and `needs-replan` chrome are removed, so planner-lane glow now requires the authoritative +`planning` status (the same signal admission counts). A card whose planner logs stream in +before the status row lands stays dark for that brief window — under-reporting is the +accepted direction; over-reporting read as a concurrency-cap breach (10 glowing cards under +a 9-slot limit). */ import { describe, expect, it } from "vitest"; import type { Task } from "@fusion/core"; @@ -30,28 +27,21 @@ function plannerCard(overrides: Partial = {}): Task { } describe("isTaskAgentActive planner lane (U11 merged column)", () => { - it("recognises fresh planner activity on the merged planning column", () => { - // intake + hold, id `todo` — the post-U11 shape. Without the trait branch this is - // false, because the legacy clause only accepts `triage` (or `todo` while replanning). - expect(isTaskAgentActive(plannerCard(), { columnFlags: { intake: true, hold: true } })).toBe(true); + it("glows a live planning card on the merged planning column", () => { + expect(isTaskAgentActive(plannerCard({ status: "planning" as Task["status"] }), { columnFlags: { intake: true, hold: true } })).toBe(true); }); - it("still recognises the legacy triage lane when no flags are supplied", () => { - // The fallback path: callers without resolved metadata keep today's behaviour. - expect(isTaskAgentActive(plannerCard({ column: "triage" as Task["column"] }), {})).toBe(true); + it("does not glow fresh planner logs without the authoritative planning status", () => { + // The log stream is not a slot; only the engine's planning status counts. + expect(isTaskAgentActive(plannerCard(), { columnFlags: { intake: true, hold: true } })).toBe(false); + expect(isTaskAgentActive(plannerCard({ column: "triage" as Task["column"] }), {})).toBe(false); }); - it("does NOT treat a hold lane as a planner lane unless it is replanning", () => { - // The `hold && isReplanning` half, preserved from the legacy `todo && needs-replan` - // clause — a card merely waiting for capacity is not agent-active. - expect(isTaskAgentActive(plannerCard(), { columnFlags: { hold: true } })).toBe(false); - expect( - isTaskAgentActive(plannerCard({ status: "needs-replan" as Task["status"] }), { columnFlags: { hold: true } }), - ).toBe(true); + it("does not glow a hold-lane replan park — it holds no concurrency slot", () => { + expect(isTaskAgentActive(plannerCard({ status: "needs-replan" as Task["status"] }), { columnFlags: { hold: true } })).toBe(false); }); - it("does not report activity for a stale planner timestamp", () => { - const stale = plannerCard({ recentAgentActivityAt: new Date(Date.now() - 60 * 60 * 1000).toISOString() }); - expect(isTaskAgentActive(stale, { columnFlags: { intake: true, hold: true } })).toBe(false); + it("still glows a planning card without resolved column metadata", () => { + expect(isTaskAgentActive(plannerCard({ status: "planning" as Task["status"] }), {})).toBe(true); }); }); diff --git a/packages/dashboard/app/utils/taskActivity.ts b/packages/dashboard/app/utils/taskActivity.ts index 6b3c7079a4..9ec8f71e7f 100644 --- a/packages/dashboard/app/utils/taskActivity.ts +++ b/packages/dashboard/app/utils/taskActivity.ts @@ -1,6 +1,6 @@ import type { Task } from "@fusion/core"; -import { getUnifiedTaskProgress } from "./taskProgress"; -import { isArchivedColumnRole, isCompleteColumnRole, isIntakeColumnRole, isPreImplementationColumnRole, isWipColumnRole } from "./columnRoles"; +import { enrichRunningAgentTaskShapeFromFlags, isRunningAgentTask } from "../../../core/src/live-agent-count"; +import { isArchivedColumnRole, isCompleteColumnRole } from "./columnRoles"; /** The shared status vocabulary for active task phases and lock/model policy. */ export const ACTIVE_STATUSES = new Set([ @@ -15,50 +15,50 @@ export const ACTIVE_STATUSES = new Set([ "landing", ]); -export const RECENT_PLANNER_ACTIVITY_WINDOW_MS = 60_000; - export interface TaskAgentActivityOptions { globalPaused?: boolean; queued?: boolean; isStuck?: boolean; /* FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (U12 — R8 drift conversion): - The task's own column traits, when the caller has them. Fresh-planner-activity was - keyed on `column === "triage"`, so under U11 — merged planning column keeps the id - `todo`, `triage` deleted — a planning card with live planner logs stops reading as - agent-active. That is not one badge: this predicate drives the pulsing status badge, - the agent-active row border, and the column header's executing count, so the whole - board would quietly report planning work as idle. + The task's own column traits, when the caller has them. This predicate drives the + pulsing status badge, the agent-active row border, and the column header's executing + count, so a card in a renamed lane must resolve its roles from traits rather than ids. Optional, and the legacy ids remain the fallback: callers without resolved metadata (pre-load, or a card stranded in a vanished lane) must keep their current behaviour rather than lose activity detection entirely. */ /* - FNXC:WorkflowResolvedColumns 2026-07-30-11:30 (batch-dashboard-app): - Widened from `{intake, hold}` to carry the terminal and wip roles too, because this predicate asks - three separate lifecycle questions and only the planner one was resolved. Callers already pass this - from their per-task flags; the extra fields cost them nothing. + FNXC:TaskActivity 2026-08-01-17:53: + Widened with the review/merge roles because the positive arm now delegates to the shared + live-agent predicate, which resolves merge statuses through mergeOrchestration/mergeBlocker. */ - columnFlags?: { intake?: boolean; hold?: boolean; complete?: boolean; archived?: boolean; countsTowardWip?: boolean }; + columnFlags?: { intake?: boolean; hold?: boolean; complete?: boolean; archived?: boolean; countsTowardWip?: boolean; mergeOrchestration?: boolean; mergeBlocker?: boolean }; } /* FNXC:TaskActivity 2026-07-16-00:00: -FN-8055 makes the agent-active border and pulsing badges represent the same ground truth: an agent is working now. Reject render-context global pause, queue, and derived freshness-stuck gates before checking activity, then combine the engine's column-aware active window with canonical phase statuses and the running unified workflow item that drives progress badges. +FN-8055 makes the agent-active border and pulsing badges represent the same ground truth: an agent is working now. Reject render-context global pause, queue, and derived freshness-stuck gates before checking activity. -FNXC:TaskActivity 2026-07-28-12:00: -FN-8300 also honors a bounded, client-only fresh planner-log timestamp for triage cards. The log stream can arrive before the authoritative planning-status row; this render-only fallback closes that window without changing routing/model locks. - -FNXC:TaskActivity 2026-07-22-09:25: -FN-8494 requires cards parked in the engine's durable `needs-replan` planning stage to keep their activity chrome on both triage and plan-in-place todo lanes. This is rendering-only: do not add `needs-replan` to ACTIVE_STATUSES, because model and routing pickers use that set as a long-lived lock policy while this predicate only describes live operator chrome. Extend the bounded fresh-log window to the todo replan lane so an incoming planner log remains represented consistently there. +FNXC:TaskActivity 2026-08-01-17:53: +Operator requirement: activity chrome and lane counts must NEVER show more work than the engine's +actual live-agent population. Summing lane headers used to exceed the concurrency cap (e.g. 10 glowing +cards under a 9-slot limit) because this predicate unioned extra render-only signals — the FN-8494 +`needs-replan` REVISING chrome, the FN-8300 fresh planner-log window, ACTIVE_STATUSES in any column, +and running unified-progress items. Those extras glowed on cards that hold no concurrency slot, which +operators read as a capacity breach. +The positive arm is now exactly the shared `isRunningAgentTask` predicate used by footer Running and +project admission, so card glow (and the header counts derived from it) is a strict subset of the +slot-holding population. The suppression gates above it only ever subtract (queued/stuck/paused/failed +cards can still hold a slot briefly but must not glow), preserving the "never more" direction. Stuck-killed and both terminal columns are never active, even when stale execution status or workflow-step data remains on the task. Model-resolution and routing locks intentionally import only ACTIVE_STATUSES and retain their status-or-in-progress policy; using this rendering predicate there would change lock behavior during status-null workflow steps. */ export function isTaskAgentActive( - task: Pick, + task: Pick, options: TaskAgentActivityOptions = {}, ): boolean { const status = task.status; @@ -82,49 +82,5 @@ export function isTaskAgentActive( return false; } - const isReplanning = status === "needs-replan"; - const recentPlannerActivityAtMs = Date.parse(task.recentAgentActivityAt ?? ""); - const nowMs = Date.now(); - /* - FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (U12 — R8 drift conversion): - Planner activity belongs to the PRE-IMPLEMENTATION lane. With traits the rule is - "intake lane, or a hold lane that is replanning"; without them it falls back to the - ids, which is the same shape the two lanes have today. - */ - /* - FNXC:WorkflowResolvedColumns 2026-07-30-20:15 (Phase B — one shared predicate): - The degraded arm now composes `utils/columnRoles`' predicates instead of naming ids, so the legacy - id list lives in exactly one place. Equivalent by construction rather than by inspection: - - intake lane isIntakeColumnRole(undefined, col) -> `triage` - hold lane preImplementation AND NOT intake -> `todo` - - which reproduces `col === "triage" || (col === "todo" && isReplanning)` exactly, because the - shared pre-implementation set is {todo, triage} and the shared intake id is `triage`. - - Expressed as "not the intake lane" rather than a second id list, so if either shared set changes - this composition follows it instead of silently disagreeing with the file next door. - */ - const isLegacyIntakeLane = isIntakeColumnRole(undefined, task.column); - const isLegacyHoldLane = isPreImplementationColumnRole(undefined, task.column) && !isLegacyIntakeLane; - const inPlannerLane = options.columnFlags - ? options.columnFlags.intake === true || (options.columnFlags.hold === true && isReplanning) - : isLegacyIntakeLane || (isLegacyHoldLane && isReplanning); - const hasFreshPlannerActivity = inPlannerLane - && Number.isFinite(recentPlannerActivityAtMs) - && nowMs - recentPlannerActivityAtMs >= 0 - && nowMs - recentPlannerActivityAtMs <= RECENT_PLANNER_ACTIVITY_WINDOW_MS; - - /* - FNXC:WorkflowResolvedColumns 2026-07-30-11:30 (batch-dashboard-app): - "Is an agent working on this card?" — the WIP question, and the last of the three in this function - that was still keyed on a legacy id. On a renamed board a card in the wip lane read as INACTIVE - unless its status happened to be one of ACTIVE_STATUSES, so the activity dot and everything keyed - off it went dark while an agent was running. - */ - return isWipColumnRole(options.columnFlags, task.column) || - ACTIVE_STATUSES.has(status ?? "") || - isReplanning || - hasFreshPlannerActivity || - getUnifiedTaskProgress(task).items.some((item) => item.status === "running"); + return isRunningAgentTask(enrichRunningAgentTaskShapeFromFlags(task, options.columnFlags)); }