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:
7
.changeset/board-activity-never-exceeds-live-agents.md
Normal file
7
.changeset/board-activity-never-exceeds-live-agents.md
Normal 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").
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user