fix: board lane counts and card glow never exceed live running agents

Operators summing the lane-header executing counts saw 10 active cards
under a 9-slot concurrency cap and read it as a capacity breach. The
header unioned the shared Running predicate with the card activity-chrome
predicate, which glowed needs-replan parks (FN-8494) and fresh
planner-log windows (FN-8300) that hold no concurrency slot.

isTaskAgentActive's positive arm now delegates to the shared
isRunningAgentTask predicate (footer/admission truth) and Column headers
count only that predicate, so glow and counts are a strict subset of the
live-agent population. Idle replans render the existing "Queued to
revise" waiting label instead of activity chrome.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-08-01 11:03:04 -07:00
parent 45daf4478c
commit 7dbcff139c
10 changed files with 196 additions and 175 deletions

View File

@@ -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").

View File

@@ -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:

View File

@@ -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(
<Column

View File

@@ -793,7 +793,9 @@ describe("ListView", () => {
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();

View File

@@ -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(
<TaskCard
task={makeTask({ column: "triage", status: "needs-replan" })}
@@ -2481,7 +2483,7 @@ describe("TaskCard", () => {
/>,
);
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(
<TaskCard
@@ -2982,8 +2986,8 @@ describe("TaskCard", () => {
/>,
);
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(
<TaskCard
task={makeTask({ id: "FN-8494-board", column: "triage", status: "needs-replan" })}
@@ -3003,8 +3009,9 @@ describe("TaskCard", () => {
/>,
);
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([

View File

@@ -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(
<TaskCard
task={planningTask({ status: "planning" } as Partial<Task>)}
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(
<TaskCard
task={planningTask({ recentAgentActivityAt: new Date().toISOString() } as Partial<Task>)}
@@ -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", () => {

View File

@@ -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),

View File

@@ -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> = {}): Task {
return {
id: "FN-8055",
@@ -32,53 +43,50 @@ function taskWithRunningWorkflowStep(overrides: Partial<Task> = {}): 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);
});
});

View File

@@ -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> = {}): 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);
});
});

View File

@@ -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, "column" | "status" | "paused" | "userPaused" | "steps" | "enabledWorkflowSteps" | "workflowStepResults" | "recentAgentActivityAt">,
task: Pick<Task, "column" | "status" | "paused" | "userPaused" | "steps" | "enabledWorkflowSteps" | "workflowStepResults" | "recentAgentActivityAt" | "sessionFile" | "checkedOutBy">,
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));
}