From 02a4f52277129baea97438355cd343e28bc96084 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:40:35 -0500 Subject: [PATCH] perf(ui): event-source the company live-runs list (Phase 1) (#9627) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Its web UI keeps live views fresh with React Query, coordinated by a live-events websocket (`/api/companies/:id/events/ws`) and cross-tab polling > - We already cut the worst live-update churn (#9569, #9624), but the deeper issue is that even the "push" path is *push-the-signal, pull-the-data*: a websocket event triggers `invalidateQueries` → an HTTP refetch > - The company live-runs list (`queryKeys.liveRuns`) is the most-observed resource — the sidebar renders it on nearly every page — so its refetch is the most ambient source of churn, fired on every `heartbeat.run.queued` / `heartbeat.run.status` event > - Those events already carry enough (`runId`, `status`) to update the cached list directly, so this pull request event-sources that list instead of refetching it > - The benefit is that the always-observed live-runs list stops refetching on run lifecycle events — the first concrete step of the push-over-poll redesign ## Linked Issues or Issue Description No public GitHub issue exists; describing inline per CONTRIBUTING.md → "Link Issues or Describe Them In-PR", following the bug report template. This continues the memory/CPU-churn work from #9569 and #9624. **What happened?** Live agent-run tabs accrue high idle CPU and off-heap memory because live-update events cause HTTP refetches. Profiling showed the company live-runs list — observed on almost every page via the sidebar — being refetched on every run status change, one of the most frequent ambient refetches. **Expected behavior** A websocket event that already carries the changed data should update the cached list directly, without an HTTP round-trip, so the always-observed live-runs list does no refetch on routine run lifecycle events. **Steps to reproduce** Open the app with agents running and watch the network panel: `GET /api/companies/:id/live-runs` fires on each `heartbeat.run.status` / `heartbeat.run.queued` event even though the event payload already describes the change. **Paperclip version or commit** Branch `perf/live-runs-event-sourced`, off `master` (after #9624). **Deployment mode** Local dev (`pnpm dev`), web UI. Core UI live-updates plumbing; not adapter-specific. ## What Changed - **`ui/src/lib/live-runs-cache.ts` (new)** — pure `removeRunFromList` / `patchRunStatusInList` helpers for the cached `LiveRunForIssue[]`. - **`ui/src/context/LiveUpdatesProvider.tsx`** — on `heartbeat.run.queued` / `heartbeat.run.status`, patch `liveRuns(companyId)` in place instead of invalidating it: - terminal status → remove the run from the list, - status change on a run already in the list → update it in place, - a genuinely new run (can't be reconstructed from the event) → fall back to a single `invalidateQueries` refetch. - Removed the blanket `liveRuns` invalidation from `invalidateHeartbeatQueries`. - On websocket **reconnect**, refetch `liveRuns` once to reconcile events missed while disconnected (durable replay is a later phase). - Other resources these events invalidate (`dashboard`, `costs`, `sidebarBadges`, `agents.list`, agent detail) are unchanged — they're lower-frequency / less-often-observed and are follow-up phases. This keeps the change scoped and **client-only** (no server changes). ## Verification - `vitest`: new `live-runs-cache.test.ts` (remove/patch/no-op/undefined) and new lifecycle-handler cases in `LiveUpdatesProvider.test.ts` (terminal→remove, present→patch, new→needs-refetch) via `__liveUpdatesTestUtils`. All existing `LiveUpdatesProvider` tests still pass (33 total across the two files). - `tsc -b` clean. - Runtime: the event-sourced path is covered by unit tests; end-to-end refetch reduction should be re-measured against a rebuilt bundle with the network panel / MCP instrumentation. ## Risks Low, and client-only. - **Staleness across a dropped connection:** an event missed while the socket is down isn't replayed yet, so the reconnect handler refetches `liveRuns` once to reconcile. Durable event replay (Last-Event-ID) is a planned later phase; until then reconnect-reconcile covers the gap. - **New-run fallback:** a genuinely new run still triggers one refetch (it can't be reconstructed from the event alone), so no new runs are missed. - Aggregate resources (dashboard/costs/badges) are untouched and still invalidate (already coalesced), so their behavior is unchanged. Follow-up phases (from the design discussion): event-source `activity`/comments and the remaining class-B resources; give pure-poll resources events and drop their intervals; add durable event sequence + reconnect replay; and a shared bus (Postgres `LISTEN/NOTIFY`) only when the API tier scales to >1 replica. ## Model Used - **Provider:** Anthropic, via the Claude Code CLI. - **Model:** Claude Opus 4.8 (`claude-opus-4-8`). - **Reasoning mode:** Extended thinking enabled. - **Capabilities used:** tool use (shell, file editing), sub-agent fan-out to inventory the client polling and server push infrastructure, and the Chrome DevTools MCP to reproduce/profile the churn that motivated this redesign. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work (a perf/plumbing change, not planned core feature work) - [x] I have searched GitHub for duplicate or related PRs and linked them above (continues #9569 / #9624; no duplicates) - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have considered and documented any risks above - [ ] I have updated relevant documentation to reflect my changes (N/A — no user-facing docs; rationale documented inline) - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 --- ui/src/context/LiveUpdatesProvider.test.ts | 51 +++++++++++++++++ ui/src/context/LiveUpdatesProvider.tsx | 51 ++++++++++++++++- ui/src/lib/live-runs-cache.test.ts | 64 ++++++++++++++++++++++ ui/src/lib/live-runs-cache.ts | 49 +++++++++++++++++ 4 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 ui/src/lib/live-runs-cache.test.ts create mode 100644 ui/src/lib/live-runs-cache.ts diff --git a/ui/src/context/LiveUpdatesProvider.test.ts b/ui/src/context/LiveUpdatesProvider.test.ts index 9d13bfe50a..c1336ab280 100644 --- a/ui/src/context/LiveUpdatesProvider.test.ts +++ b/ui/src/context/LiveUpdatesProvider.test.ts @@ -1085,3 +1085,54 @@ describe("LiveUpdatesProvider run lifecycle toasts", () => { }); }); }); + +describe("applyRunLifecycleToCompanyLiveRuns", () => { + function makeClient(initial: Array<{ id: string; status: string }>) { + const cache = new Map([ + [JSON.stringify(queryKeys.liveRuns("company-1")), initial], + ]); + const client = { + getQueryData: (key: unknown) => cache.get(JSON.stringify(key)), + setQueryData: (key: unknown, updater: unknown) => { + const cacheKey = JSON.stringify(key); + const current = cache.get(cacheKey); + cache.set(cacheKey, typeof updater === "function" ? updater(current) : updater); + }, + }; + const read = () => cache.get(JSON.stringify(queryKeys.liveRuns("company-1"))); + return { client, read }; + } + + it("removes a run on a terminal status (patched, no refetch needed)", () => { + const { client, read } = makeClient([{ id: "run-1", status: "running" }, { id: "run-2", status: "running" }]); + const patched = __liveUpdatesTestUtils.applyRunLifecycleToCompanyLiveRuns( + client as never, + "company-1", + { runId: "run-1", status: "succeeded" }, + ); + expect(patched).toBe(true); + expect(read()).toEqual([{ id: "run-2", status: "running" }]); + }); + + it("patches status in place for a run already in the list", () => { + const { client, read } = makeClient([{ id: "run-1", status: "queued" }]); + const patched = __liveUpdatesTestUtils.applyRunLifecycleToCompanyLiveRuns( + client as never, + "company-1", + { runId: "run-1", status: "running" }, + ); + expect(patched).toBe(true); + expect(read()).toEqual([{ id: "run-1", status: "running" }]); + }); + + it("reports not-patched for a genuinely new run so the caller refetches", () => { + const { client, read } = makeClient([{ id: "run-1", status: "running" }]); + const patched = __liveUpdatesTestUtils.applyRunLifecycleToCompanyLiveRuns( + client as never, + "company-1", + { runId: "run-new", status: "running" }, + ); + expect(patched).toBe(false); + expect(read()).toEqual([{ id: "run-1", status: "running" }]); // unchanged + }); +}); diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index f864e45a6c..59124be446 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef, type ReactNode } from "react"; import { useQuery, useQueryClient, type InfiniteData, type QueryClient } from "@tanstack/react-query"; import { createCoalescingQueryClient, createInvalidationBatcher } from "../lib/query-invalidation-batcher"; +import { patchRunStatusInList, removeRunFromList } from "../lib/live-runs-cache"; import type { Agent, Issue, IssueComment, LiveEvent } from "@paperclipai/shared"; import type { RunForIssue } from "../api/activity"; import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats"; @@ -797,12 +798,51 @@ function buildRunStatusToast( }; } +/** + * Event-source the company live-runs list from run-lifecycle events instead of + * invalidating + refetching it. Returns true when the cache was fully patched; + * false means a genuinely new run appeared that can't be reconstructed from the + * event, so the caller should refetch once to pick it up. + */ +function applyRunLifecycleToCompanyLiveRuns( + queryClient: QueryClient, + companyId: string, + payload: Record, +): boolean { + const runId = readString(payload.runId); + const status = readString(payload.status); + if (!runId || !status) return false; + + if (TERMINAL_RUN_STATUSES.has(status)) { + queryClient.setQueryData( + queryKeys.liveRuns(companyId), + (current: LiveRunForIssue[] | undefined) => removeRunFromList(current, runId), + ); + // Always "handled": a terminal run must never be in the live list, so if it + // wasn't present there is deliberately nothing to refetch (removeRunFromList + // was a no-op and we must not re-add it). + return true; + } + + let present = false; + queryClient.setQueryData( + queryKeys.liveRuns(companyId), + (current: LiveRunForIssue[] | undefined) => { + const result = patchRunStatusInList(current, runId, status); + present = result.present; + return result.next; + }, + ); + return present; +} + function invalidateHeartbeatQueries( queryClient: ReturnType, companyId: string, payload: Record, ) { - queryClient.invalidateQueries({ queryKey: queryKeys.liveRuns(companyId) }); + // Note: liveRuns(companyId) is intentionally NOT invalidated here — it is + // event-sourced via applyRunLifecycleToCompanyLiveRuns in the caller. queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(companyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(companyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(companyId) }); @@ -1062,7 +1102,12 @@ function handleLiveEvent( event.type === "heartbeat.run.queued" || event.type === "heartbeat.run.status" ) { + const liveRunsPatched = applyRunLifecycleToCompanyLiveRuns(queryClient, expectedCompanyId, payload); invalidateHeartbeatQueries(queryClient, expectedCompanyId, payload); + if (!liveRunsPatched) { + // A new run we couldn't reconstruct from the event — refetch once to add it. + queryClient.invalidateQueries({ queryKey: queryKeys.liveRuns(expectedCompanyId) }); + } invalidateVisibleIssueRunQueries(queryClient, pathname, payload); if (event.type === "heartbeat.run.status") { const toast = buildRunStatusToast(payload, nameOf); @@ -1160,6 +1205,7 @@ function closeSocketQuietly(target: LiveUpdatesSocketLike | null, reason: string } export const __liveUpdatesTestUtils = { + applyRunLifecycleToCompanyLiveRuns, buildAgentStatusToast, buildRunStatusToast, closeSocketQuietly, @@ -1259,6 +1305,9 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) { } if (reconnectAttempt > 0) { gateRef.current.suppressUntil = Date.now() + RECONNECT_SUPPRESS_MS; + // Reconcile after a gap: events missed while disconnected can't be + // replayed yet, so refetch the event-sourced live-runs list once. + queryClient.invalidateQueries({ queryKey: queryKeys.liveRuns(liveCompanyId) }); } reconnectAttempt = 0; }; diff --git a/ui/src/lib/live-runs-cache.test.ts b/ui/src/lib/live-runs-cache.test.ts new file mode 100644 index 0000000000..fc388e1ee2 --- /dev/null +++ b/ui/src/lib/live-runs-cache.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import type { LiveRunForIssue } from "../api/heartbeats"; +import { patchRunStatusInList, removeRunFromList } from "./live-runs-cache"; + +function run(id: string, status: string): LiveRunForIssue { + return { + id, + status, + invocationSource: "automation", + triggerDetail: null, + startedAt: null, + finishedAt: null, + createdAt: "2026-07-15T00:00:00.000Z", + agentId: "agent-1", + agentName: "Agent One", + adapterType: "codex_local", + }; +} + +describe("removeRunFromList", () => { + it("removes the matching run", () => { + const list = [run("a", "running"), run("b", "running")]; + expect(removeRunFromList(list, "a")).toEqual([run("b", "running")]); + }); + + it("returns the same reference when the run isn't present", () => { + const list = [run("a", "running")]; + expect(removeRunFromList(list, "zzz")).toBe(list); + }); + + it("handles undefined", () => { + expect(removeRunFromList(undefined, "a")).toBeUndefined(); + }); +}); + +describe("patchRunStatusInList", () => { + it("updates status in place and reports present", () => { + const list = [run("a", "queued"), run("b", "running")]; + const { next, present } = patchRunStatusInList(list, "a", "running"); + expect(present).toBe(true); + expect(next?.find((r) => r.id === "a")?.status).toBe("running"); + expect(next?.find((r) => r.id === "b")).toBe(list[1]); // untouched entry kept by ref + }); + + it("returns the same reference and present=false when the run isn't in the list", () => { + const list = [run("a", "running")]; + const { next, present } = patchRunStatusInList(list, "new", "running"); + expect(present).toBe(false); + expect(next).toBe(list); + }); + + it("returns the same reference (no re-render) when status is unchanged", () => { + const list = [run("a", "running")]; + const { next, present } = patchRunStatusInList(list, "a", "running"); + expect(present).toBe(true); + expect(next).toBe(list); // unchanged → original reference preserved + }); + + it("handles undefined", () => { + const { next, present } = patchRunStatusInList(undefined, "a", "running"); + expect(present).toBe(false); + expect(next).toBeUndefined(); + }); +}); diff --git a/ui/src/lib/live-runs-cache.ts b/ui/src/lib/live-runs-cache.ts new file mode 100644 index 0000000000..f0533d20c6 --- /dev/null +++ b/ui/src/lib/live-runs-cache.ts @@ -0,0 +1,49 @@ +import type { LiveRunForIssue } from "../api/heartbeats"; + +/** + * Pure cache operations for the company `liveRuns` list so run-lifecycle + * websocket events can patch it in place instead of invalidating it and + * triggering a full HTTP refetch. The company live-runs list is observed on + * almost every page (the sidebar), so its refetch is the most ambient source of + * live-update churn — event-sourcing it removes that entirely for the common + * cases (a run finishing, or a status change on a run already in the list). + * + * A genuinely new run can't be reconstructed from a status event alone (the + * list item needs fields the event doesn't carry), so the caller falls back to + * a single refetch for that case; and a reconnect reconciles any missed events. + */ + +/** Remove a run from the list. Returns the same reference if it wasn't present. */ +export function removeRunFromList( + runs: LiveRunForIssue[] | undefined, + runId: string, +): LiveRunForIssue[] | undefined { + if (!runs) return runs; + const next = runs.filter((run) => run.id !== runId); + return next.length === runs.length ? runs : next; +} + +/** + * Update a run's `status` in place. `present` reports whether the run was in the + * list; when it wasn't, `next` is the original reference and the caller should + * refetch to pick up the new run. + */ +export function patchRunStatusInList( + runs: LiveRunForIssue[] | undefined, + runId: string, + status: string, +): { next: LiveRunForIssue[] | undefined; present: boolean } { + if (!runs) return { next: runs, present: false }; + let present = false; + let changed = false; + const next = runs.map((run) => { + if (run.id !== runId) return run; + present = true; + if (run.status === status) return run; + changed = true; + return { ...run, status }; + }); + // Preserve the original reference when nothing actually changed (run absent, + // or its status already matched) so redundant events don't trigger re-renders. + return { next: changed ? next : runs, present }; +}