perf(panel): kanban virtualization + row memoization + scorecards batch endpoint (#594)

* perf(panel): kanban virtualization + row memoization + scorecards batch endpoint

The audit's remaining phases: the kanban card lists render through
@tanstack/react-virtual windows (columns are the dnd drop targets, cards
only drag — no sortable conflict) with memoized columns/cards and a
stabilized handleAction; the task table's desktop row and mobile card are
extracted and memoized (the table itself already client-paginates to 100).
Backend: the per-member scorecard N+1 (~20 requests x 3 queries per poll)
collapses into GET /dashboard/metrics/members backed by
get_all_member_scorecards with grouped rollup/overlay SQL shared with the
single-agent path.

* test(metrics): shared-DB-safe scorecard tests — unique seeds, delta assertions

The two new batch-scorecard tests assumed a private DB: fixed ceo/system
slugs collided with other tests' seeds (ix_agents_slug) and a global
exactly-one CEO lookup + exact roster count broke in the one-process
suite. Unique slugs, subset/disjoint assertions, dead count constant
dropped.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-19 19:42:24 +02:00
committed by GitHub
co-authored by Renn F
parent db04674342
commit bab53e31ca
14 changed files with 1009 additions and 442 deletions
+1
View File
@@ -37,6 +37,7 @@
"@radix-ui/react-tooltip": "^1.2.12",
"@tailwindcss/typography": "^0.5.20",
"@tanstack/react-query": "^5.101.2",
"@tanstack/react-virtual": "3.14.6",
"axios": "^1.16.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+20
View File
@@ -74,6 +74,9 @@ importers:
'@tanstack/react-query':
specifier: ^5.101.2
version: 5.101.2(react@19.2.7)
'@tanstack/react-virtual':
specifier: 3.14.6
version: 3.14.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
axios:
specifier: ^1.16.0
version: 1.18.1
@@ -1397,6 +1400,15 @@ packages:
peerDependencies:
react: ^18 || ^19
'@tanstack/react-virtual@3.14.6':
resolution: {integrity: sha512-4+Uq8m0/gzO4kMCHUEpTtGX1RnONK0C+g88b2ltwPMWUBiaVarBuWKoPJaz7gj1cKCVRAdyu+U8GcKhwCc2beA==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@tanstack/virtual-core@3.17.4':
resolution: {integrity: sha512-nGm5KteqxasUdThLc2izl6dHUqLv0LQj7Nuyo5gYalTPf/U8a9ermvsl7reT+6ioBW1l8WfpP/mcU338nLXpqw==}
'@testing-library/dom@10.4.1':
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
engines: {node: '>=18'}
@@ -5009,6 +5021,14 @@ snapshots:
'@tanstack/query-core': 5.101.2
react: 19.2.7
'@tanstack/react-virtual@3.14.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@tanstack/virtual-core': 3.17.4
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
'@tanstack/virtual-core@3.17.4': {}
'@testing-library/dom@10.4.1':
dependencies:
'@babel/code-frame': 7.29.7
@@ -28,7 +28,7 @@ import {
useSensor,
useSensors,
} from "@dnd-kit/core";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { KanbanCard } from "./kanban-card";
import { RequiredNotesDialog } from "@/components/tasks/task-detail/task-action-dialogs";
import { skippedPreconditions } from "./bypass-preconditions";
@@ -104,6 +104,23 @@ export function KanbanBoard({
const updateTask = useUpdateTask();
const [activeTask, setActiveTask] = useState<Task | null>(null);
// Latest tasks snapshot for handleAction below, read via ref instead of a
// closure — React Query hands back a new array reference on every refetch
// even when the underlying rows are unchanged, which would otherwise bust
// handleAction's identity (and, through it, KanbanCard's memoization) every
// 30s regardless of whether anything actually changed.
const tasksRef = useRef<Task[]>([]);
tasksRef.current = tasks || [];
// useMutation's mutate/mutateAsync are bound once per observer and stay
// referentially stable across renders (query-core binds them in the
// constructor) — pulling them out lets handleAction's useCallback below
// stay stable even though `lifecycle` itself is a fresh object every render.
const claimMutateAsync = lifecycle.claim.mutateAsync;
const startMutateAsync = lifecycle.start.mutateAsync;
const submitQaMutateAsync = lifecycle.submitQa.mutateAsync;
const unblockMutateAsync = lifecycle.unblock.mutateAsync;
const { register, unregister } = usePageRefresh();
useEffect(() => {
@@ -127,13 +144,21 @@ export function KanbanBoard({
}),
);
// Group tasks by status
const tasksByStatus = columns.reduce(
(acc, col) => {
acc[col.status] = (tasks || []).filter((t) => t.status === col.status);
return acc;
},
{} as Record<TaskStatus, Task[]>,
// Group tasks by status. Memoized so each column's `tasks` prop keeps a
// stable reference across renders that don't actually change the data —
// required for KanbanColumn/KanbanCard's React.memo to skip re-rendering.
const tasksByStatus = useMemo(
() =>
columns.reduce(
(acc, col) => {
acc[col.status] = (tasks || []).filter(
(t) => t.status === col.status,
);
return acc;
},
{} as Record<TaskStatus, Task[]>,
),
[tasks, columns],
);
const handleDragStart = (event: DragStartEvent) => {
@@ -223,57 +248,66 @@ export function KanbanBoard({
}
};
const handleAction = async (action: string, taskId: string) => {
try {
switch (action) {
case "move-forward":
// Determine next action based on current status
const task = tasks?.find((t) => t.id === taskId);
if (!task) return;
const handleAction = useCallback(
async (action: string, taskId: string) => {
try {
switch (action) {
case "move-forward":
// Determine next action based on current status
const task = tasksRef.current.find((t) => t.id === taskId);
if (!task) return;
switch (task.status) {
case TaskStatus.BACKLOG:
// BACKLOG tasks need session before activation - PM only
toast.info(
"Backlog tasks must be activated by PM with a session",
);
break;
case TaskStatus.PENDING:
await lifecycle.claim.mutateAsync(taskId);
toast.success("Task claimed");
break;
case TaskStatus.CLAIMED:
await lifecycle.start.mutateAsync(taskId);
toast.success("Task started");
break;
case TaskStatus.IN_PROGRESS:
await lifecycle.submitQa.mutateAsync({ taskId });
toast.success("Submitted for QA");
break;
case TaskStatus.BLOCKED:
await lifecycle.unblock.mutateAsync(taskId);
toast.success("Task unblocked");
break;
case TaskStatus.AWAITING_QA:
setPendingNotesAction({ kind: "pass-qa", taskId });
return; // Dialog collects the required note
case TaskStatus.AWAITING_DOCUMENTATION:
setPendingNotesAction({ kind: "complete", taskId });
return; // Dialog collects the required justification
}
break;
case "pass-qa":
setPendingNotesAction({ kind: "pass-qa", taskId });
return; // Dialog collects the required note
case "fail-qa":
setPendingNotesAction({ kind: "fail-qa", taskId });
return; // Dialog collects the required note
switch (task.status) {
case TaskStatus.BACKLOG:
// BACKLOG tasks need session before activation - PM only
toast.info(
"Backlog tasks must be activated by PM with a session",
);
break;
case TaskStatus.PENDING:
await claimMutateAsync(taskId);
toast.success("Task claimed");
break;
case TaskStatus.CLAIMED:
await startMutateAsync(taskId);
toast.success("Task started");
break;
case TaskStatus.IN_PROGRESS:
await submitQaMutateAsync({ taskId });
toast.success("Submitted for QA");
break;
case TaskStatus.BLOCKED:
await unblockMutateAsync(taskId);
toast.success("Task unblocked");
break;
case TaskStatus.AWAITING_QA:
setPendingNotesAction({ kind: "pass-qa", taskId });
return; // Dialog collects the required note
case TaskStatus.AWAITING_DOCUMENTATION:
setPendingNotesAction({ kind: "complete", taskId });
return; // Dialog collects the required justification
}
break;
case "pass-qa":
setPendingNotesAction({ kind: "pass-qa", taskId });
return; // Dialog collects the required note
case "fail-qa":
setPendingNotesAction({ kind: "fail-qa", taskId });
return; // Dialog collects the required note
}
refetch();
} catch {
toast.error("Action failed");
}
refetch();
} catch {
toast.error("Action failed");
}
};
},
[
claimMutateAsync,
startMutateAsync,
submitQaMutateAsync,
unblockMutateAsync,
refetch,
],
);
const handleNotesConfirm = async (text: string) => {
if (!pendingNotesAction) return;
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { memo, useState } from "react";
import { Task, TaskStatus } from "@/types";
import { useUpdateTask } from "@/hooks/use-tasks";
import { Card, CardContent } from "@/components/ui/card";
@@ -44,7 +44,12 @@ interface KanbanCardProps {
isDragging?: boolean;
}
export function KanbanCard({
// Memoized: a kanban column can mount hundreds of these across all statuses.
// `task` stays referentially stable across React Query refetches for
// unchanged rows (structural sharing), and `onAction` is stabilized by the
// board — so the default shallow prop comparison actually skips re-renders
// instead of every card re-rendering on any board-level state change.
function KanbanCardImpl({
task,
onAction,
showQaActions,
@@ -302,3 +307,5 @@ export function KanbanCard({
</Card>
);
}
export const KanbanCard = memo(KanbanCardImpl);
@@ -1,5 +1,6 @@
"use client";
import { memo, useRef } from "react";
import { Task, TaskStatus } from "@/types";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
@@ -12,8 +13,14 @@ import { HelpTip } from "@/components/ui/help-tip";
import { taskStatusDescription } from "@/components/tasks/task-status-badge";
import { KanbanCard } from "./kanban-card";
import { useDroppable } from "@dnd-kit/core";
import { useVirtualizer } from "@tanstack/react-virtual";
import { cn } from "@/lib/utils";
// Rough collapsed-card height (title + badges + assign row) used as the
// virtualizer's initial estimate — corrected per-card via measureElement
// once mounted, so a wrong guess only costs one extra frame of scroll jitter.
const ESTIMATED_CARD_HEIGHT = 132;
interface KanbanColumnProps {
id: string;
title: string;
@@ -27,7 +34,11 @@ interface KanbanColumnProps {
className?: string;
}
export function KanbanColumn({
// Memoized: KanbanBoard renders one of these per lifecycle-status column, and
// a column's `tasks` slice is stabilized upstream (kanban-board.tsx's
// tasksByStatus useMemo) so an unrelated board re-render (dialogs, drag
// state) skips every column that didn't actually change.
function KanbanColumnImpl({
id: _id,
title,
status,
@@ -42,6 +53,20 @@ export function KanbanColumn({
const { setNodeRef, isOver } = useDroppable({
id: status,
});
const scrollRef = useRef<HTMLDivElement>(null);
// A column can carry hundreds of cards (every task in a status, across a
// whole team's history) — window the DOM to roughly what's on screen
// instead of mounting every dnd-kit draggable card at once. Safe with
// dnd-kit here because the drop target is the column itself
// (useDroppable above), not per-card — cards only register useDraggable,
// no sortable reordering depends on every card's DOM node existing.
const rowVirtualizer = useVirtualizer({
count: tasks.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => ESTIMATED_CARD_HEIGHT,
overscan: 6,
getItemKey: (index) => tasks[index]?.id ?? index,
});
return (
<div
@@ -88,8 +113,9 @@ export function KanbanColumn({
</div>
{/* Native overflow scroll: Radix ScrollArea's display:table viewport
let cards grow past the column width and clip a plain div keeps
content constrained to the column. */}
<div className="flex-1 min-h-0 overflow-y-auto">
content constrained to the column. Also the virtualizer's scroll
container. */}
<div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto pr-2">
{isLoading ? (
<div className="space-y-2">
<Skeleton className="h-24" />
@@ -101,22 +127,37 @@ export function KanbanColumn({
No tasks
</div>
) : (
<div className="space-y-2 pr-2">
{tasks.map((task) => (
<KanbanCard
key={task.id}
task={task}
onAction={onAction}
showQaActions={
showQaActions &&
(status === TaskStatus.AWAITING_QA ||
status === TaskStatus.VERIFYING)
}
/>
))}
<div
className="relative w-full"
style={{ height: rowVirtualizer.getTotalSize() }}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const task = tasks[virtualRow.index];
return (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
className="absolute top-0 left-0 w-full"
style={{ transform: `translateY(${virtualRow.start}px)` }}
>
<KanbanCard
task={task}
onAction={onAction}
showQaActions={
showQaActions &&
(status === TaskStatus.AWAITING_QA ||
status === TaskStatus.VERIFYING)
}
/>
</div>
);
})}
</div>
)}
</div>
</div>
);
}
export const KanbanColumn = memo(KanbanColumnImpl);
@@ -14,7 +14,7 @@ const { mockOrg, mockCeo, mockMember, mockAgents } = vi.hoisted(() => ({
vi.mock("@/hooks/use-observability", () => ({
useOrgScorecard: mockOrg,
useCeoScorecard: mockCeo,
useMemberScorecard: mockMember,
useAllMemberScorecards: mockMember,
}));
vi.mock("@/hooks/use-agents", () => ({
@@ -96,18 +96,21 @@ describe("ScorecardsTabContent", () => {
isLoading: false,
});
mockMember.mockReturnValue({
data: member({
id: "dev1",
name: "be-dev-1",
tasks_completed: 7,
first_pass_yield: 0.8,
active_runtime_hours: 4.2,
qa_pass_rate: 0.9,
escalations: 1,
blocked_others: 2,
utilization: 0.6,
}),
data: [
member({
id: "dev1",
name: "be-dev-1",
tasks_completed: 7,
first_pass_yield: 0.8,
active_runtime_hours: 4.2,
qa_pass_rate: 0.9,
escalations: 1,
blocked_others: 2,
utilization: 0.6,
}),
],
isLoading: false,
isError: false,
});
mockAgents.mockReturnValue({
data: [
@@ -164,8 +167,10 @@ describe("ScorecardsTabContent", () => {
expect(
screen.getByText(/failed to load organization metrics/i),
).toBeInTheDocument();
// The member row shows a failed marker rather than a perpetual skeleton
// (exact lowercase text, distinct from the org card's message).
expect(screen.getByText("failed to load")).toBeInTheDocument();
// The batched member-scorecard fetch surfaces one table-level banner
// rather than a per-row error (or an endless per-row skeleton).
expect(
screen.getByText(/failed to load member scorecards/i),
).toBeInTheDocument();
});
});
+27 -17
View File
@@ -1,5 +1,6 @@
"use client";
import { useMemo } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
@@ -13,12 +14,12 @@ import {
TableRow,
} from "@/components/ui/table";
import {
useAllMemberScorecards,
useCeoScorecard,
useMemberScorecard,
useOrgScorecard,
} from "@/hooks/use-observability";
import { useAgents } from "@/hooks/use-agents";
import { AgentRole, type Agent } from "@/types";
import { AgentRole, type Agent, type MemberScorecard } from "@/types";
function pctOrNa(rate: number | null): string {
return rate === null ? "n/a" : (rate * 100).toFixed(0) + "%";
@@ -32,20 +33,18 @@ function hoursOrDash(seconds: number): string {
return (seconds / 3600).toFixed(1) + "h";
}
/** One member's row — each row self-fetches its rollup scorecard. */
function MemberRow({ agent }: { agent: Agent }) {
const { data, isLoading, isError } = useMemberScorecard(agent.id);
if (isError) {
return (
<TableRow>
<TableCell>{agent.name || agent.slug}</TableCell>
<TableCell colSpan={8} className="text-muted-foreground text-xs">
failed to load
</TableCell>
</TableRow>
);
}
if (isLoading || !data) {
/** One member's row. Data comes from the batched useAllMemberScorecards
* fetch (one request for the whole table) rather than each row self-fetching
* ~20 agents used to mean ~20 parallel `/metrics/member/{id}` requests
* (each 3 DB queries) on every table poll. */
function MemberRow({
agent,
data,
}: {
agent: Agent;
data: MemberScorecard | undefined;
}) {
if (!data) {
return (
<TableRow>
<TableCell>{agent.name || agent.slug}</TableCell>
@@ -172,6 +171,12 @@ export function ScorecardsTabContent() {
const members = (agents ?? []).filter(
(a) => a.role !== AgentRole.CEO && a.role !== AgentRole.SYSTEM,
);
const { data: scorecards, isError: scorecardsError } =
useAllMemberScorecards();
const scorecardById = useMemo(
() => new Map((scorecards ?? []).map((s) => [s.id, s])),
[scorecards],
);
return (
<div className="space-y-6">
<Card>
@@ -197,6 +202,11 @@ export function ScorecardsTabContent() {
<CardTitle>Members</CardTitle>
</CardHeader>
<CardContent>
{scorecardsError && (
<p className="text-muted-foreground mb-2 text-sm">
Failed to load member scorecards.
</p>
)}
<Table>
<TableHeader>
<TableRow>
@@ -245,7 +255,7 @@ export function ScorecardsTabContent() {
</TableHeader>
<TableBody>
{members.map((a) => (
<MemberRow key={a.id} agent={a} />
<MemberRow key={a.id} agent={a} data={scorecardById.get(a.id)} />
))}
</TableBody>
</Table>
+357 -316
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useMemo, useEffect } from "react";
import { useState, useMemo, useCallback, useEffect, memo } from "react";
import { Task } from "@/types";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
@@ -262,6 +262,319 @@ function SortableHeader({
);
}
interface TaskRowProps {
node: TaskTreeNode;
isExpanded: boolean;
childCount: number;
projectNames: Record<string, string>;
projectGitUrls: Record<string, string>;
productNames: Record<string, string>;
onToggleExpand: (taskId: string) => void;
}
// Memoized: a page renders up to 100 of these (PAGE_SIZE_OPTIONS caps there)
// and re-renders on every sort/page/expand change or unrelated parent update
// — memoizing stops an unchanged row's markup from being torn down and
// rebuilt every time. `node` stays referentially stable across React Query
// refetches for unchanged tasks (structural sharing); projectNames/
// projectGitUrls/productNames/onToggleExpand are stabilized by the caller.
const TaskTableRow = memo(function TaskTableRow({
node,
isExpanded,
childCount,
projectNames,
projectGitUrls,
productNames,
onToggleExpand,
}: TaskRowProps) {
const task = node.task;
const hasChildren = node.children.length > 0;
const handleRowClick = (e: React.MouseEvent) => {
// Don't toggle if clicking on interactive elements
const target = e.target as HTMLElement;
if (
target.closest("a") ||
target.closest("button") ||
target.closest('[role="button"]') ||
target.closest("[data-no-expand]")
) {
return;
}
if (hasChildren) {
onToggleExpand(task.id);
}
};
return (
<TableRow
className={cn(
"hover:bg-muted/50",
node.depth > 0 && "bg-muted/20",
hasChildren && "cursor-pointer",
)}
onClick={handleRowClick}
>
<TableCell className="max-w-[22rem]">
<div
className="flex items-center gap-1 min-w-0"
style={{ paddingLeft: `${node.depth * 1.5}rem` }}
>
{hasChildren ? (
<HelpTip
label={
isExpanded
? "Hides this task's subtasks"
: "Shows this task's subtasks inline"
}
>
<Button
onClick={() => onToggleExpand(task.id)}
variant="ghost"
size="icon-sm"
className="p-0.5 h-5 w-5 shrink-0"
aria-label={isExpanded ? "Collapse" : "Expand"}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRightIcon className="h-4 w-4" />
)}
</Button>
</HelpTip>
) : (
<span className="w-5 shrink-0" />
)}
<Link
prefetch={false}
href={"/tasks/" + task.id}
className="block hover:underline min-w-0"
>
<div className="font-medium flex items-center gap-2 min-w-0">
<span className="truncate" title={task.title}>
{task.title}
</span>
{task.batch_id && !task.parent_task_id && (
<HelpTip label="A multi-task batch — this is the umbrella task for a set of related tasks">
<Badge
variant="outline"
className="text-xs shrink-0 border-primary/50 text-primary"
>
MegaTask
</Badge>
</HelpTip>
)}
{childCount > 0 && (
<HelpTip label="Direct subtasks under this task, regardless of their current status.">
<Badge variant="secondary" className="text-xs shrink-0">
{childCount} subtask
{childCount !== 1 ? "s" : ""}
</Badge>
</HelpTip>
)}
</div>
</Link>
</div>
</TableCell>
<TableCell className="whitespace-nowrap">
<TaskStatusBadge status={task.status} />
</TableCell>
<TableCell className="whitespace-nowrap">
<GitStatusBadge
task={task}
repoUrl={
task.project_id ? projectGitUrls[task.project_id] : undefined
}
/>
</TableCell>
<TableCell className="capitalize whitespace-nowrap">
{task.team.replace(/_/g, " ")}
</TableCell>
<TableCell className="whitespace-nowrap text-sm">
{task.project_id && projectNames[task.project_id] ? (
<span>{projectNames[task.project_id]}</span>
) : task.product_id && productNames[task.product_id] ? (
<span className="text-muted-foreground">
{productNames[task.product_id]}{" "}
<span className="text-xs">(product)</span>
</span>
) : (
<span className="text-muted-foreground"></span>
)}
</TableCell>
<TableCell className="whitespace-nowrap">
<Badge
className={
(priorityColors[task.priority] ?? priorityColors[2]) + " text-xs"
}
>
{priorityLabels[task.priority] ?? "P2 - Medium"}
</Badge>
</TableCell>
<TableCell className="whitespace-nowrap">
<HelpTip
label={
task.assigned_to
? "The agent currently pinned to this task."
: "No agent pinned — the orchestrator routes it by role and availability."
}
>
<Badge variant="outline">
{getAgentDisplayName(task.assigned_to)}
</Badge>
</HelpTip>
</TableCell>
<TableCell className="text-muted-foreground text-sm whitespace-nowrap">
<HelpTip label={new Date(task.created_at).toLocaleString()}>
<span>
{formatDistanceToNow(new Date(task.created_at), {
addSuffix: true,
})}
</span>
</HelpTip>
</TableCell>
<TableCell>
<TaskActions task={task} />
</TableCell>
</TableRow>
);
});
// Mobile card counterpart of TaskTableRow — same memoization rationale.
const TaskTableCard = memo(function TaskTableCard({
node,
isExpanded,
childCount,
projectNames,
projectGitUrls,
productNames,
onToggleExpand,
}: TaskRowProps) {
const task = node.task;
const hasChildren = node.children.length > 0;
return (
<ResponsiveTableCard style={{ marginLeft: `${node.depth * 1}rem` }}>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
{hasChildren && (
<HelpTip
label={
isExpanded
? "Hides this task's subtasks"
: "Shows this task's subtasks inline"
}
>
<Button
onClick={() => onToggleExpand(task.id)}
variant="ghost"
size="icon-sm"
className="h-5 w-5 shrink-0 p-0.5"
aria-label={isExpanded ? "Collapse" : "Expand"}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRightIcon className="h-4 w-4" />
)}
</Button>
</HelpTip>
)}
<Link
prefetch={false}
href={"/tasks/" + task.id}
className="min-w-0 truncate font-medium hover:underline"
title={task.title}
>
{task.title}
</Link>
</div>
{(task.batch_id && !task.parent_task_id) || childCount > 0 ? (
<div className="mt-1 flex flex-wrap gap-1">
{task.batch_id && !task.parent_task_id && (
<HelpTip label="A multi-task batch — this is the umbrella task for a set of related tasks">
<Badge
variant="outline"
className="border-primary/50 text-xs text-primary"
>
MegaTask
</Badge>
</HelpTip>
)}
{childCount > 0 && (
<HelpTip label="Direct subtasks under this task, regardless of their current status.">
<Badge variant="secondary" className="text-xs">
{childCount} subtask
{childCount !== 1 ? "s" : ""}
</Badge>
</HelpTip>
)}
</div>
) : null}
</div>
<TaskActions task={task} />
</div>
<div className="mt-3 divide-y">
<ResponsiveTableCardRow label="Status">
<TaskStatusBadge status={task.status} />
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Git">
<GitStatusBadge
task={task}
repoUrl={
task.project_id ? projectGitUrls[task.project_id] : undefined
}
/>
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Team">
<span className="capitalize">{task.team.replace(/_/g, " ")}</span>
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Project">
{task.project_id && projectNames[task.project_id]
? projectNames[task.project_id]
: task.product_id && productNames[task.product_id]
? `${productNames[task.product_id]} (product)`
: "—"}
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Priority">
<Badge
className={
(priorityColors[task.priority] ?? priorityColors[2]) +
" text-xs"
}
>
{priorityLabels[task.priority] ?? "P2 - Medium"}
</Badge>
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Assigned">
<HelpTip
label={
task.assigned_to
? "The agent currently pinned to this task."
: "No agent pinned — the orchestrator routes it by role and availability."
}
>
<Badge variant="outline">
{getAgentDisplayName(task.assigned_to)}
</Badge>
</HelpTip>
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Created">
<HelpTip label={new Date(task.created_at).toLocaleString()}>
<span>
{formatDistanceToNow(new Date(task.created_at), {
addSuffix: true,
})}
</span>
</HelpTip>
</ResponsiveTableCardRow>
</div>
</ResponsiveTableCard>
);
});
export function TaskTable({
tasks,
isLoading,
@@ -431,19 +744,25 @@ export function TaskTable({
}
};
const toggleExpand = (taskId: string) => {
const next = new Set(expandedIds);
if (next.has(taskId)) {
next.delete(taskId);
} else {
next.add(taskId);
}
if (onExpandedChange) {
onExpandedChange(next);
} else {
setInternalExpandedIds(next);
}
};
// Stable across renders that don't touch expand state, so TaskTableRow's
// memoization actually holds on an unrelated re-render (e.g. a sibling
// page-chrome state change) instead of every row rebuilding regardless.
const toggleExpand = useCallback(
(taskId: string) => {
const next = new Set(expandedIds);
if (next.has(taskId)) {
next.delete(taskId);
} else {
next.add(taskId);
}
if (onExpandedChange) {
onExpandedChange(next);
} else {
setInternalExpandedIds(next);
}
},
[expandedIds, onExpandedChange],
);
const expandAll = () => {
const allParentIds = new Set<string>();
@@ -563,171 +882,18 @@ export function TaskTable({
) : paginatedTasks.length === 0 ? (
<TaskTableEmpty />
) : (
paginatedTasks.map((node) => {
const task = node.task;
const hasChildren = node.children.length > 0;
const isExpanded = expandedIds.has(task.id);
const childCount = childrenMap.get(task.id)?.length || 0;
const handleRowClick = (e: React.MouseEvent) => {
// Don't toggle if clicking on interactive elements
const target = e.target as HTMLElement;
if (
target.closest("a") ||
target.closest("button") ||
target.closest('[role="button"]') ||
target.closest("[data-no-expand]")
) {
return;
}
if (hasChildren) {
toggleExpand(task.id);
}
};
return (
<TableRow
key={task.id}
className={cn(
"hover:bg-muted/50",
node.depth > 0 && "bg-muted/20",
hasChildren && "cursor-pointer",
)}
onClick={handleRowClick}
>
<TableCell className="max-w-[22rem]">
<div
className="flex items-center gap-1 min-w-0"
style={{ paddingLeft: `${node.depth * 1.5}rem` }}
>
{hasChildren ? (
<HelpTip
label={
isExpanded
? "Hides this task's subtasks"
: "Shows this task's subtasks inline"
}
>
<Button
onClick={() => toggleExpand(task.id)}
variant="ghost"
size="icon-sm"
className="p-0.5 h-5 w-5 shrink-0"
aria-label={isExpanded ? "Collapse" : "Expand"}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRightIcon className="h-4 w-4" />
)}
</Button>
</HelpTip>
) : (
<span className="w-5 shrink-0" />
)}
<Link
prefetch={false}
href={"/tasks/" + task.id}
className="block hover:underline min-w-0"
>
<div className="font-medium flex items-center gap-2 min-w-0">
<span className="truncate" title={task.title}>
{task.title}
</span>
{task.batch_id && !task.parent_task_id && (
<HelpTip label="A multi-task batch — this is the umbrella task for a set of related tasks">
<Badge
variant="outline"
className="text-xs shrink-0 border-primary/50 text-primary"
>
MegaTask
</Badge>
</HelpTip>
)}
{childCount > 0 && (
<HelpTip label="Direct subtasks under this task, regardless of their current status.">
<Badge
variant="secondary"
className="text-xs shrink-0"
>
{childCount} subtask
{childCount !== 1 ? "s" : ""}
</Badge>
</HelpTip>
)}
</div>
</Link>
</div>
</TableCell>
<TableCell className="whitespace-nowrap">
<TaskStatusBadge status={task.status} />
</TableCell>
<TableCell className="whitespace-nowrap">
<GitStatusBadge
task={task}
repoUrl={
task.project_id
? projectGitUrls[task.project_id]
: undefined
}
/>
</TableCell>
<TableCell className="capitalize whitespace-nowrap">
{task.team.replace(/_/g, " ")}
</TableCell>
<TableCell className="whitespace-nowrap text-sm">
{task.project_id && projectNames[task.project_id] ? (
<span>{projectNames[task.project_id]}</span>
) : task.product_id &&
productNames[task.product_id] ? (
<span className="text-muted-foreground">
{productNames[task.product_id]}{" "}
<span className="text-xs">(product)</span>
</span>
) : (
<span className="text-muted-foreground"></span>
)}
</TableCell>
<TableCell className="whitespace-nowrap">
<Badge
className={
(priorityColors[task.priority] ??
priorityColors[2]) + " text-xs"
}
>
{priorityLabels[task.priority] ?? "P2 - Medium"}
</Badge>
</TableCell>
<TableCell className="whitespace-nowrap">
<HelpTip
label={
task.assigned_to
? "The agent currently pinned to this task."
: "No agent pinned — the orchestrator routes it by role and availability."
}
>
<Badge variant="outline">
{getAgentDisplayName(task.assigned_to)}
</Badge>
</HelpTip>
</TableCell>
<TableCell className="text-muted-foreground text-sm whitespace-nowrap">
<HelpTip
label={new Date(task.created_at).toLocaleString()}
>
<span>
{formatDistanceToNow(new Date(task.created_at), {
addSuffix: true,
})}
</span>
</HelpTip>
</TableCell>
<TableCell>
<TaskActions task={task} />
</TableCell>
</TableRow>
);
})
paginatedTasks.map((node) => (
<TaskTableRow
key={node.task.id}
node={node}
isExpanded={expandedIds.has(node.task.id)}
childCount={childrenMap.get(node.task.id)?.length || 0}
projectNames={projectNames}
projectGitUrls={projectGitUrls}
productNames={productNames}
onToggleExpand={toggleExpand}
/>
))
)}
</TableBody>
</Table>
@@ -741,143 +907,18 @@ export function TaskTable({
</ResponsiveTableCardEmpty>
) : (
<ResponsiveTableCardList className="p-3">
{paginatedTasks.map((node) => {
const task = node.task;
const hasChildren = node.children.length > 0;
const isExpanded = expandedIds.has(task.id);
const childCount = childrenMap.get(task.id)?.length || 0;
return (
<ResponsiveTableCard
key={task.id}
style={{ marginLeft: `${node.depth * 1}rem` }}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
{hasChildren && (
<HelpTip
label={
isExpanded
? "Hides this task's subtasks"
: "Shows this task's subtasks inline"
}
>
<Button
onClick={() => toggleExpand(task.id)}
variant="ghost"
size="icon-sm"
className="h-5 w-5 shrink-0 p-0.5"
aria-label={isExpanded ? "Collapse" : "Expand"}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRightIcon className="h-4 w-4" />
)}
</Button>
</HelpTip>
)}
<Link
prefetch={false}
href={"/tasks/" + task.id}
className="min-w-0 truncate font-medium hover:underline"
title={task.title}
>
{task.title}
</Link>
</div>
{(task.batch_id && !task.parent_task_id) ||
childCount > 0 ? (
<div className="mt-1 flex flex-wrap gap-1">
{task.batch_id && !task.parent_task_id && (
<HelpTip label="A multi-task batch — this is the umbrella task for a set of related tasks">
<Badge
variant="outline"
className="border-primary/50 text-xs text-primary"
>
MegaTask
</Badge>
</HelpTip>
)}
{childCount > 0 && (
<HelpTip label="Direct subtasks under this task, regardless of their current status.">
<Badge variant="secondary" className="text-xs">
{childCount} subtask
{childCount !== 1 ? "s" : ""}
</Badge>
</HelpTip>
)}
</div>
) : null}
</div>
<TaskActions task={task} />
</div>
<div className="mt-3 divide-y">
<ResponsiveTableCardRow label="Status">
<TaskStatusBadge status={task.status} />
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Git">
<GitStatusBadge
task={task}
repoUrl={
task.project_id
? projectGitUrls[task.project_id]
: undefined
}
/>
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Team">
<span className="capitalize">
{task.team.replace(/_/g, " ")}
</span>
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Project">
{task.project_id && projectNames[task.project_id]
? projectNames[task.project_id]
: task.product_id && productNames[task.product_id]
? `${productNames[task.product_id]} (product)`
: "—"}
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Priority">
<Badge
className={
(priorityColors[task.priority] ??
priorityColors[2]) + " text-xs"
}
>
{priorityLabels[task.priority] ?? "P2 - Medium"}
</Badge>
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Assigned">
<HelpTip
label={
task.assigned_to
? "The agent currently pinned to this task."
: "No agent pinned — the orchestrator routes it by role and availability."
}
>
<Badge variant="outline">
{getAgentDisplayName(task.assigned_to)}
</Badge>
</HelpTip>
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Created">
<HelpTip
label={new Date(task.created_at).toLocaleString()}
>
<span>
{formatDistanceToNow(new Date(task.created_at), {
addSuffix: true,
})}
</span>
</HelpTip>
</ResponsiveTableCardRow>
</div>
</ResponsiveTableCard>
);
})}
{paginatedTasks.map((node) => (
<TaskTableCard
key={node.task.id}
node={node}
isExpanded={expandedIds.has(node.task.id)}
childCount={childrenMap.get(node.task.id)?.length || 0}
projectNames={projectNames}
projectGitUrls={projectGitUrls}
productNames={productNames}
onToggleExpand={toggleExpand}
/>
))}
</ResponsiveTableCardList>
)
}
+18
View File
@@ -31,6 +31,14 @@ export const observabilityKeys = {
[...observabilityKeys.all, "scorecard", "ceo", days] as const,
memberScorecard: (agentId: string, days: number) =>
[...observabilityKeys.all, "scorecard", "member", agentId, days] as const,
allMemberScorecards: (days: number, team?: string) =>
[
...observabilityKeys.all,
"scorecard",
"members",
team ?? "all",
days,
] as const,
orgScorecard: (days: number, team?: string) =>
[
...observabilityKeys.all,
@@ -115,6 +123,16 @@ export function useMemberScorecard(agentId: string, days = 30) {
});
}
/** Every agent's rollup scorecard in one batch replaces the N+1 pattern of
* calling useMemberScorecard once per row in a member list/table. */
export function useAllMemberScorecards(days = 30, team?: string) {
return useQuery<MemberScorecard[]>({
queryKey: observabilityKeys.allMemberScorecards(days, team),
queryFn: () => observabilityApi.getAllMemberScorecards(days, team),
refetchInterval: SCORECARD_REFETCH_INTERVAL,
});
}
/** Org-wide (or per-cell) rollup aggregate. */
export function useOrgScorecard(days = 30, team?: string) {
return useQuery<OrgScorecard>({
+14
View File
@@ -167,6 +167,20 @@ export const observabilityApi = {
return data;
},
/** Every agent's rollup scorecard in one batch (N+1 fix for the panel's
* Members table) GET /dashboard/metrics/members?team&days */
getAllMemberScorecards: async (
days = 30,
team?: string,
): Promise<MemberScorecard[]> => {
if (isMockMode()) return [];
const { data } = await api.get<MemberScorecard[]>(
"/dashboard/metrics/members",
{ params: { days, ...(team ? { team } : {}) } },
);
return data;
},
/** Org / team rollup — GET /dashboard/metrics/org?team&days */
getOrgScorecard: async (days = 30, team?: string): Promise<OrgScorecard> => {
if (isMockMode()) return emptyOrg(team ?? null);
+14
View File
@@ -631,6 +631,20 @@ async def get_member_scorecard(
return card.to_dict()
@router.get("/metrics/members")
async def get_all_member_scorecards(
db: DbSession,
team: Team | None = None,
days: int = Query(default=30, ge=1, le=90),
) -> list[dict[str, Any]]:
"""Every (non-CEO, non-system) agent's rollup scorecard in one batch —
backs the panel's Members table without an N+1 (one request here instead
of one `/metrics/member/{id}` request per agent on the roster)."""
metrics_service = get_metrics_service(db)
cards = await metrics_service.get_all_member_scorecards(team=team, days=days)
return [card.to_dict() for card in cards]
@router.get("/metrics/org")
async def get_org_scorecard(
db: DbSession,
+179 -19
View File
@@ -21,7 +21,7 @@ from roboco.db.tables import (
TaskTable,
)
from roboco.foundation.policy.stage_effort import compute_stage_effort
from roboco.models.base import TaskStatus, Team
from roboco.models.base import AgentRole, TaskStatus, Team
from roboco.models.metrics import (
CEO_APPROVAL_DECISIONS,
CEO_UNBLOCK_DECISIONS,
@@ -1117,25 +1117,25 @@ class MetricsService(BaseService):
"""Guarded ratio → None when the denominator is 0."""
return round(numerator / denominator, 4) if denominator else None
async def get_member_scorecard(
self, agent_id: UUID, days: int = 30
) -> MemberScorecard | None:
"""Per-member rollup scorecard + live in-flight overlay, or None if no
such agent."""
agent = (
await self.session.execute(
select(AgentTable.slug, AgentTable.name).where(
AgentTable.id == agent_id
)
)
).one_or_none()
if agent is None:
return None
slug, name = agent
since_date = (datetime.now(UTC) - timedelta(days=days)).date()
sums, _ = await self._rollup_sums(since_date, agent_slug=slug, team=None)
_EMPTY_OVERLAY: ClassVar[dict[str, float]] = {
"active_runtime_seconds": 0.0,
"turns": 0.0,
"tool_calls": 0.0,
"tokens": 0.0,
"cost_usd": 0.0,
}
overlay = await self._live_inflight_overlay(agent_id)
def _assemble_member_scorecard(
self,
*,
agent_id: UUID,
name: str,
sums: dict[str, float],
overlay: dict[str, float],
) -> MemberScorecard:
"""Shared rollup+overlay -> MemberScorecard builder for both the
single-agent (`get_member_scorecard`) and batch
(`get_all_member_scorecards`) paths one derivation, no duplication."""
includes_live = any(v for v in overlay.values())
active_runtime = (
sums["active_runtime_seconds"] + overlay["active_runtime_seconds"]
@@ -1175,6 +1175,166 @@ class MetricsService(BaseService):
includes_live_inflight=includes_live,
)
async def get_member_scorecard(
self, agent_id: UUID, days: int = 30
) -> MemberScorecard | None:
"""Per-member rollup scorecard + live in-flight overlay, or None if no
such agent."""
agent = (
await self.session.execute(
select(AgentTable.slug, AgentTable.name).where(
AgentTable.id == agent_id
)
)
).one_or_none()
if agent is None:
return None
slug, name = agent
since_date = (datetime.now(UTC) - timedelta(days=days)).date()
sums, _ = await self._rollup_sums(since_date, agent_slug=slug, team=None)
overlay = await self._live_inflight_overlay(agent_id)
return self._assemble_member_scorecard(
agent_id=agent_id, name=name, sums=sums, overlay=overlay
)
async def _rollup_sums_by_agent(
self, since_date: Any, *, team: Team | None
) -> dict[str, dict[str, float]]:
"""Batch counterpart of `_rollup_sums`: one query grouped by
agent_slug instead of one query per agent filtered to that slug."""
cols = [
func.coalesce(
func.sum(getattr(MemberPerformanceDailyTable, name)), 0
).label(name)
for name in self._ROLLUP_SUM_COLUMNS
]
conds: list[Any] = [
MemberPerformanceDailyTable.member_kind == "agent",
MemberPerformanceDailyTable.date >= since_date,
]
if team is not None:
conds.append(MemberPerformanceDailyTable.team == team.value)
rows = (
await self.session.execute(
select(MemberPerformanceDailyTable.agent_slug, *cols)
.where(and_(*conds))
.group_by(MemberPerformanceDailyTable.agent_slug)
)
).all()
return {
row.agent_slug: {
name: float(getattr(row, name) or 0)
for name in self._ROLLUP_SUM_COLUMNS
}
for row in rows
}
async def _live_inflight_overlay_by_agent(
self, agent_ids: Sequence[UUID]
) -> dict[UUID, dict[str, float]]:
"""Batch counterpart of `_live_inflight_overlay`: one non-terminal-task
lookup + one grouped session-sum query across the whole roster,
instead of a per-agent task lookup + per-agent sum the scorecard
N+1 (~20 agents meant ~40 extra queries on every panel Members-tab
poll).
"""
if not agent_ids:
return {}
task_rows = (
await self.session.execute(
select(TaskTable.id, TaskTable.assigned_to).where(
TaskTable.assigned_to.in_(agent_ids),
TaskTable.status.notin_(
[TaskStatus.COMPLETED, TaskStatus.CANCELLED]
),
)
)
).all()
if not task_rows:
return {}
task_to_agent = {str(task_id): agent_id for task_id, agent_id in task_rows}
s = AgentSpawnSessionTable
session_rows = (
await self.session.execute(
select(
s.task_id,
func.coalesce(
func.sum(func.extract("epoch", func.now() - s.started_at)), 0
),
func.coalesce(func.sum(s.turns), 0),
func.coalesce(func.sum(s.tool_calls), 0),
func.coalesce(
func.sum(
s.tokens_input
+ s.tokens_output
+ s.tokens_cache_read
+ s.tokens_cache_write
),
0,
),
func.coalesce(func.sum(s.estimated_cost_usd), 0),
)
.where(
s.task_id.in_(list(task_to_agent.keys())),
s.ended_at.is_(None),
)
.group_by(s.task_id)
)
).all()
# Each SQL column is already func.coalesce(..., 0)'d, so no Python-side
# `or 0` fallback is needed here (keeps this loop's branch count low).
overlays: dict[UUID, dict[str, float]] = {}
for task_id, runtime, turns, tool_calls, tokens, cost in session_rows:
agent_id = task_to_agent.get(task_id)
if agent_id is None:
continue
bucket = overlays.setdefault(agent_id, dict(self._EMPTY_OVERLAY))
bucket["active_runtime_seconds"] += float(runtime)
bucket["turns"] += float(turns)
bucket["tool_calls"] += float(tool_calls)
bucket["tokens"] += float(tokens)
bucket["cost_usd"] += float(cost)
return overlays
async def get_all_member_scorecards(
self, team: Team | None = None, days: int = 30
) -> list[MemberScorecard]:
"""Every (non-CEO, non-system) agent's rollup scorecard in one batch —
the N+1 fix for the panel's Members table, which used to fire one
`/metrics/member/{id}` request (3 queries) per agent on the roster on
every poll instead of 3 queries total."""
since_date = (datetime.now(UTC) - timedelta(days=days)).date()
conds: list[Any] = [AgentTable.role.notin_([AgentRole.CEO, AgentRole.SYSTEM])]
if team is not None:
conds.append(AgentTable.team == team)
agents = (
await self.session.execute(
select(AgentTable.id, AgentTable.slug, AgentTable.name).where(
and_(*conds)
)
)
).all()
if not agents:
return []
sums_by_slug = await self._rollup_sums_by_agent(since_date, team=team)
overlay_by_agent = await self._live_inflight_overlay_by_agent(
[agent_id for agent_id, _, _ in agents]
)
empty_sums = dict.fromkeys(self._ROLLUP_SUM_COLUMNS, 0.0)
return [
self._assemble_member_scorecard(
agent_id=agent_id,
name=name,
sums=sums_by_slug.get(slug, empty_sums),
overlay=overlay_by_agent.get(agent_id, self._EMPTY_OVERLAY),
)
for agent_id, slug, name in agents
]
async def get_org_scorecard(
self, team: Team | None = None, days: int = 30
) -> OrgScorecard:
@@ -452,6 +452,52 @@ async def test_org_scorecard_endpoint(dashboard_client: AsyncClient) -> None:
assert set(body) >= {"member_count", "tasks_completed", "first_pass_yield"}
@pytest.mark.asyncio
async def test_all_member_scorecards_endpoint(
dashboard_client: AsyncClient, db_session: AsyncSession
) -> None:
"""The batch scorecard route (N+1 fix) returns one MemberScorecard per
non-CEO/non-system agent the CEO seeded by the fixture is excluded."""
dev = AgentTable(
id=uuid4(),
name="be-dev-1",
slug=f"be-dev-{uuid4().hex[:8]}",
role=AgentRole.DEVELOPER,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="dev",
capabilities=[],
permissions={},
metrics={},
)
db_session.add(dev)
await db_session.flush()
resp = await dashboard_client.get("/api/dashboard/metrics/members", headers=_HDR)
assert resp.status_code == HTTPStatus.OK
body = resp.json()
assert isinstance(body, list)
ids = {card["id"] for card in body}
assert str(dev.id) in ids
# The shared per-run DB can hold several CEO rows (other tests seed
# their own); every one of them must be excluded from the batch.
ceo_ids = (
(
await db_session.execute(
select(AgentTable.id).where(AgentTable.role == AgentRole.CEO)
)
)
.scalars()
.all()
)
assert ceo_ids
assert ids.isdisjoint({str(cid) for cid in ceo_ids})
for card in body:
assert card["scope"] == "member"
assert card["member_kind"] == "agent"
@pytest.mark.asyncio
async def test_task_metrics_404_for_missing_task(
dashboard_client: AsyncClient,
@@ -293,6 +293,162 @@ async def test_member_scorecard_404_and_guards(
assert card.utilization is None
_DEV2_OVERLAY_TURNS = 7
@pytest.mark.asyncio
async def test_all_member_scorecards_matches_single_agent_and_excludes_ceo_system(
svc: MetricsService, db_session: AsyncSession
) -> None:
"""get_all_member_scorecards (the N+1 batch fix) must return the exact
same derived numbers get_member_scorecard would for the same agent, plus:
CEO/SYSTEM excluded, and a rollup-less agent still appears zeroed instead
of silently dropped."""
# Unique slugs throughout: the suite shares one DB per run, so a fixed
# "ceo"/"system" slug collides with other tests' seeds (ix_agents_slug).
dev1 = _agent(AgentRole.DEVELOPER, f"be-dev-{uuid4().hex[:6]}")
dev2 = _agent(AgentRole.QA, f"be-qa-{uuid4().hex[:6]}")
dev3 = _agent(AgentRole.DEVELOPER, f"be-dev-{uuid4().hex[:6]}") # no rollup rows
ceo = _agent(AgentRole.CEO, f"ceo-{uuid4().hex[:6]}")
system = _agent(AgentRole.SYSTEM, f"system-{uuid4().hex[:6]}")
db_session.add_all([dev1, dev2, dev3, ceo, system])
await db_session.flush()
db_session.add_all(
[
_daily(
dev1.slug,
tasks_completed=2,
tasks_first_pass=1,
active_runtime_seconds=1800,
turns=6,
qa_reviews_total=3,
qa_reviews_passed=2,
idle_seconds=600,
),
_daily(dev2.slug, tasks_completed=1, tasks_first_pass=1),
]
)
await db_session.flush()
cards = await svc.get_all_member_scorecards(team=Team.BACKEND, days=30)
by_id = {c.id: c for c in cards}
# Delta assertions, not a global count: the one-process suite shares
# this DB, so other tests' agents may be on the roster too.
for seeded in (dev1, dev2, dev3):
assert str(seeded.id) in by_id
assert str(ceo.id) not in by_id
assert str(system.id) not in by_id
single = await svc.get_member_scorecard(cast("UUID", dev1.id), days=30)
assert single is not None
batch_dev1 = by_id[str(dev1.id)]
assert batch_dev1.tasks_completed == single.tasks_completed
assert batch_dev1.turns == single.turns
assert batch_dev1.first_pass_yield == single.first_pass_yield
assert batch_dev1.qa_pass_rate == single.qa_pass_rate
assert batch_dev1.utilization == single.utilization
# A roster member with no rollup rows still appears (zeroed), not dropped.
zeroed = by_id[str(dev3.id)]
assert zeroed.tasks_completed == 0
assert zeroed.first_pass_yield is None
assert zeroed.utilization is None
@pytest.mark.asyncio
async def test_all_member_scorecards_attributes_live_overlay_per_agent(
svc: MetricsService, db_session: AsyncSession
) -> None:
"""The batched overlay query must attribute each agent's OPEN spawn
session to that agent alone not merge every in-flight agent's effort
into one bucket."""
dev1 = _agent(AgentRole.DEVELOPER, f"be-dev-{uuid4().hex[:6]}")
dev2 = _agent(AgentRole.DEVELOPER, f"be-dev-{uuid4().hex[:6]}")
db_session.add_all([dev1, dev2])
await db_session.flush()
project = ProjectTable(
id=uuid4(),
name="P",
slug=f"p-{uuid4().hex[:6]}",
git_url="https://example.com/r.git",
assigned_cell=Team.BACKEND,
created_by=dev1.id,
)
db_session.add(project)
await db_session.flush()
db_session.add_all(
[
_daily(dev1.slug, tasks_completed=1, active_runtime_seconds=100),
_daily(dev2.slug, tasks_completed=1, active_runtime_seconds=200),
]
)
def _inflight(owner_id: UUID) -> TaskTable:
return TaskTable(
id=uuid4(),
title="t",
description="d",
acceptance_criteria=["ac"],
task_type=TaskType.CODE,
nature=TaskNature.TECHNICAL,
status=TaskStatus.IN_PROGRESS,
team=Team.BACKEND,
project_id=project.id,
created_by=owner_id,
assigned_to=owner_id,
estimated_complexity=Complexity.MEDIUM,
started_at=datetime.now(UTC) - timedelta(hours=1),
)
t1 = _inflight(cast("UUID", dev1.id))
t2 = _inflight(cast("UUID", dev2.id))
db_session.add_all([t1, t2])
await db_session.flush()
now = datetime.now(UTC)
db_session.add_all(
[
AgentSpawnSessionTable(
id=uuid4(),
agent_slug=dev1.slug,
team="backend",
role="developer",
model="claude",
task_id=str(t1.id),
started_at=now - timedelta(seconds=200),
ended_at=None,
turns=_OVERLAY_TURNS,
tool_calls=4,
tokens_input=10,
tokens_output=5,
estimated_cost_usd=0.2,
),
AgentSpawnSessionTable(
id=uuid4(),
agent_slug=dev2.slug,
team="backend",
role="developer",
model="claude",
task_id=str(t2.id),
started_at=now - timedelta(seconds=500),
ended_at=None,
turns=_DEV2_OVERLAY_TURNS,
tool_calls=9,
tokens_input=20,
tokens_output=8,
estimated_cost_usd=0.5,
),
]
)
await db_session.flush()
cards = {c.id: c for c in await svc.get_all_member_scorecards(days=30)}
c1, c2 = cards[str(dev1.id)], cards[str(dev2.id)]
assert c1.includes_live_inflight is True
assert c2.includes_live_inflight is True
assert c1.turns == _OVERLAY_TURNS
assert c2.turns == _DEV2_OVERLAY_TURNS
@pytest.mark.asyncio
async def test_org_scorecard_aggregates_members(
svc: MetricsService, db_session: AsyncSession