perf(ui): event-source the company live-runs list (Phase 1) (#9627)
## 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 <noreply@anthropic.com>
This commit is contained in:
parent
3e348b96b9
commit
02a4f52277
|
|
@ -1085,3 +1085,54 @@ describe("LiveUpdatesProvider run lifecycle toasts", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyRunLifecycleToCompanyLiveRuns", () => {
|
||||
function makeClient(initial: Array<{ id: string; status: string }>) {
|
||||
const cache = new Map<string, unknown>([
|
||||
[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
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>,
|
||||
): 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<typeof useQueryClient>,
|
||||
companyId: string,
|
||||
payload: Record<string, unknown>,
|
||||
) {
|
||||
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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
Loading…
Reference in New Issue