Files
multica/packages/core/agents/queries.ts
Multica Eve d4dac0e77c perf(agents): index-backed latest-terminal lookup in task snapshot (MUL-5436) (#6085)
ListWorkspaceAgentTaskSnapshot took each agent's latest completed/failed
task with a workspace-wide DISTINCT ON, so every presence load read and
sorted the workspace's whole terminal history. Neither existing index
matches that shape: (agent_id, status) has no completed_at, and migration
231's (completed_at) partial index is completed_at-first for the Usage
rollups.

Replace the outcome half with a per-agent JOIN LATERAL Top-1 and add a
partial index on (agent_id, completed_at DESC NULLS LAST, created_at DESC,
id DESC) WHERE status IN ('completed','failed'). On a 40-agent workspace
with 200k terminal rows this goes from 6631 shared buffers / 48.3 ms to
162 buffers / 0.1 ms, with an identical row set.

The (created_at, id) tie-break also makes the pick deterministic when
completed_at ties or is NULL — completed_at DESC alone left the winner up
to the plan.

Report #6075 asked to delete the outcome half as dead code, but PR #2608
made the Squad hover card (AgentLivePeekCard) read those rows for its
"last activity" line, so removing them would be a product regression for
shipped desktop builds. The response contract is unchanged here; splitting
the outcome into a lazy endpoint stays follow-up work.

Also tighten pickLatestTerminal to completed/failed only, matching the
snapshot's filter — it accepted cancelled, which the endpoint never
returns and which would have masked an agent's last real outcome.

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-29 14:28:44 +08:00

166 lines
5.8 KiB
TypeScript

import { queryOptions } from "@tanstack/react-query";
import { api } from "../api";
import type {
WorkspaceWorkingAgentMineRelation,
WorkspaceWorkingAgentType,
} from "../types";
export const agentTaskSnapshotKeys = {
all: (wsId: string) => ["workspaces", wsId, "agent-task-snapshot"] as const,
list: (wsId: string) => [...agentTaskSnapshotKeys.all(wsId), "list"] as const,
};
export const workspaceWorkingAgentsKeys = {
all: (wsId: string) => ["workspaces", wsId, "working-agents"] as const,
list: (
wsId: string,
type?: WorkspaceWorkingAgentType,
mineRelation?: WorkspaceWorkingAgentMineRelation,
parentIssueId?: string,
) =>
[
...workspaceWorkingAgentsKeys.all(wsId),
"list",
type ?? "all",
mineRelation
? `mine:${mineRelation}`
: parentIssueId
? `parent:${parentIssueId}`
: "workspace",
] as const,
};
export const agentActivityKeys = {
all: (wsId: string) => ["workspaces", wsId, "agent-activity"] as const,
last30d: (wsId: string) => [...agentActivityKeys.all(wsId), "30d"] as const,
};
export const agentRunCountsKeys = {
all: (wsId: string) => ["workspaces", wsId, "agent-run-counts"] as const,
last30d: (wsId: string) => [...agentRunCountsKeys.all(wsId), "30d"] as const,
};
// Workspace-scoped agent task snapshot — every active task plus each agent's
// most recent terminal task. This is the single shared source of truth that
// powers per-agent presence derivation across the app. One fetch per
// workspace; all agent dots / hover cards / list rows derive presence from
// this cache with zero additional network traffic.
//
// Presence itself is derived from the active tasks only (see derive-presence.ts
// and #1823). The one terminal row per agent is used solely for the Squad hover
// card's "last activity" line; MUL-5436 tracks moving it to a dedicated lazy
// endpoint so this hot query stops carrying history at all.
//
// The 30s staleTime is a safety net only; the primary freshness signal is
// WS task events, which invalidate this query immediately. Without WS,
// presence still updates within 30s on focus / mount.
export function agentTaskSnapshotOptions(wsId: string) {
return queryOptions({
queryKey: agentTaskSnapshotKeys.list(wsId),
queryFn: () => api.getAgentTaskSnapshot(),
staleTime: 30 * 1000,
gcTime: 5 * 60 * 1000,
refetchOnWindowFocus: true,
});
}
// Working-agent summaries, optionally narrowed to a My Issues relation or to
// one issue's direct children. Task lifecycle WebSocket events invalidate
// every narrowing immediately; the short stale time is the reconnect /
// missed-event safety net.
export function workspaceWorkingAgentsOptions(
wsId: string,
type?: WorkspaceWorkingAgentType,
mineRelation?: WorkspaceWorkingAgentMineRelation,
parentIssueId?: string,
) {
return queryOptions({
queryKey: workspaceWorkingAgentsKeys.list(
wsId,
type,
mineRelation,
parentIssueId,
),
queryFn: () =>
api.getWorkspaceWorkingAgents(type, mineRelation, parentIssueId),
staleTime: 30 * 1000,
gcTime: 5 * 60 * 1000,
refetchOnWindowFocus: true,
});
}
// Workspace-wide daily task activity for the last 30 days, anchored on
// completed_at. One fetch backs both the Agents-list sparkline (which
// only uses the trailing 7 buckets via `summarizeActivityWindow`) and
// the agent detail "Last 30 days" panel. WS task lifecycle events
// invalidate this query in useRealtimeSync; the staleTime is a
// tab-focus safety net.
export function agentActivity30dOptions(wsId: string) {
return queryOptions({
queryKey: agentActivityKeys.last30d(wsId),
queryFn: () => api.getWorkspaceAgentActivity30d(),
staleTime: 60 * 1000,
gcTime: 5 * 60 * 1000,
refetchOnWindowFocus: true,
});
}
// Workspace-wide 30-day run counts for the Agents-list RUNS column. Same
// single-fetch / WS-invalidate pattern as activity24hOptions.
export function agentRunCounts30dOptions(wsId: string) {
return queryOptions({
queryKey: agentRunCountsKeys.last30d(wsId),
queryFn: () => api.getWorkspaceAgentRunCounts(),
staleTime: 60 * 1000,
gcTime: 5 * 60 * 1000,
refetchOnWindowFocus: true,
});
}
export const agentTasksKeys = {
all: (wsId: string) => ["workspaces", wsId, "agent-tasks"] as const,
detail: (wsId: string, agentId: string) =>
[...agentTasksKeys.all(wsId), agentId] as const,
};
// All tasks for a single agent (the agent detail page consumer). Powers both
// the inspector's 7-day throughput stats and the Tasks tab list — shared so
// they don't fetch twice. WS task events invalidate this via the existing
// task-prefix invalidation in useRealtimeSync.
export function agentTasksOptions(wsId: string, agentId: string) {
return queryOptions({
queryKey: agentTasksKeys.detail(wsId, agentId),
queryFn: () => api.listAgentTasks(agentId),
staleTime: 30 * 1000,
gcTime: 5 * 60 * 1000,
refetchOnWindowFocus: true,
});
}
// Agent templates are workspace-independent: a static catalog served from
// the server's embedded JSON. Cache effectively forever — the only way the
// list / detail change is a server deploy, and a hard reload picks that up.
export const agentTemplateKeys = {
all: () => ["agent-templates"] as const,
list: () => [...agentTemplateKeys.all(), "list"] as const,
detail: (slug: string) => [...agentTemplateKeys.all(), "detail", slug] as const,
};
export function agentTemplateListOptions() {
return queryOptions({
queryKey: agentTemplateKeys.list(),
queryFn: () => api.listAgentTemplates(),
staleTime: Infinity,
gcTime: 30 * 60 * 1000,
});
}
export function agentTemplateDetailOptions(slug: string) {
return queryOptions({
queryKey: agentTemplateKeys.detail(slug),
queryFn: () => api.getAgentTemplate(slug),
staleTime: Infinity,
gcTime: 30 * 60 * 1000,
});
}