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:
Dotta 2026-07-15 15:40:35 -05:00 committed by GitHub
parent 3e348b96b9
commit 02a4f52277
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 214 additions and 1 deletions

View File

@ -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
});
});

View File

@ -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;
};

View File

@ -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();
});
});

View File

@ -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 };
}