perf(ui): stop polling the event-sourced company live-runs list (#9701)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Its web UI keeps live views fresh with a live-events websocket plus
React Query, and #9627 began replacing polling with event-sourcing
(pushing data into the cache)
> - After the churn fixes (#9624) and #9627 shipped, a long-lived tab's
memory footprint was still climbing, so I re-profiled with the Chrome
DevTools MCP
> - The dominant remaining churn is React Query re-arming a polled
query's `refetchInterval` timer on **every** observer notification — and
our live-event handlers `setQueryData(liveRuns(companyId))` on nearly
every event, so each pushed update re-arms every `liveRuns` observer's
timer (the sidebar is always mounted)
> - #9627 event-sourced the live-runs data but left the now-redundant
`refetchInterval` in place, so we did half the fix — the poll is pure
waste and the thing re-arming timers
> - This pull request removes `refetchInterval` from the event-sourced
company live-runs queries so the frequent cache writes have no timer to
re-arm
> - The benefit is that the steady-state timer churn on the
most-observed resource collapses, so the off-heap footprint stops
climbing

## 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.
Continues #9569 / #9624 / #9627.

**What happened?**

With the earlier fixes deployed, a browser tab left open on the app kept
growing its memory footprint. MCP profiling showed ~100+ `setInterval`
create/clear cycles per 5s on an aged tab (vs ~16 fresh), all from React
Query's refetch-interval timers being re-armed on every `setQueryData`
to the frequently-written `liveRuns(companyId)` query.

**Expected behavior**

A resource whose data is pushed (event-sourced) should not also poll;
cache writes should not repeatedly re-arm interval timers. Idle tabs
should hold a bounded footprint.

**Steps to reproduce**

Open a tab with agents streaming, leave it open, and instrument
`setInterval`/`clearInterval`: the churn rate climbs and traces to
`QueryObserver.updateTimers` (`refetchInterval`) for `liveRuns`,
re-armed by every live-event cache write.

**Paperclip version or commit**

Branch `perf/drop-live-runs-refetch-interval`, off `master` (after
#9627).

**Deployment mode**

Local dev (`pnpm dev`), web UI. Core UI live-updates plumbing; not
adapter-specific.

## What Changed

- Set `refetchInterval: false` on every site that polls the **plain**
`queryKeys.liveRuns(companyId)` query (event-sourced by #9627):
`Sidebar`, `SidebarAgents`, `Issues`, `Inbox`, `IssueDetail`
(companyLiveRuns), `ProjectDetail` (×2), `Routines`,
`ExecutionWorkspaceDetail`, `bridge-init`.
- Removed the now-unused `useVisibilityRefetchInterval` interval
vars/imports in `Issues`, `Inbox`, `IssueDetail`.
- **Left variant-key sites polling on purpose** — `Agents` page
(`[...liveRuns, "agents-page"]`) and `ActiveAgentsPanel` (`[...liveRuns,
scope, …]`) are NOT event-sourced by #9627 (different exact cache key),
so dropping their poll would make them stale. Those are a later phase.
- Freshness for the converted queries now comes from event-sourcing
(#9627) + its reconnect reconcile; the initial mount fetch and cross-tab
publish (`usePublishSharedQueryData`) still happen.

## Verification

- MCP profiling identified the churn: the single churning callback is
React Query's `refetchInterval` timer, re-armed by
`setQueryData(liveRuns)` on live events.
- `vitest`: all affected suites pass (`Sidebar`, `SidebarAgents`,
`Issues`, `Inbox`, `IssueDetail`, `ProjectDetail`, `Routines`,
`ExecutionWorkspaceDetail`, `LiveUpdatesProvider`) — 153 tests.
- Updated two `SidebarAgents` linger-window tests: they advanced fake
timers to the *exact* linger-expiry boundary and had relied on
poll-induced re-renders to flush. The linger self-schedules its own
`setTimeout`, so the tests now cross the boundary with a small margin +
an explicit flush (no product change).
- `tsc -b` clean.
- End-to-end footprint reduction should be re-measured against a rebuilt
bundle with the same instrumentation.

## Risks

Low, client-only.
- `liveRuns(companyId)` freshness now depends entirely on event-sourcing
+ reconnect reconcile (both from #9627). If an event path is missed, the
reconnect handler refetches once; durable replay is a planned later
phase.
- Variant-key run lists (Agents page, ActiveAgentsPanel) are unchanged
and still poll, so they don't regress.
- Issue-scoped run queries (`issues.liveRuns/activeRun/runs`) are
**not** touched here — they aren't event-sourced yet and are a separate
phase.

## 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), and the Chrome
DevTools MCP to re-profile the live instance and pinpoint the
`refetchInterval` timer churn.

## 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)
- [x] I have searched GitHub for duplicate or related PRs and linked
them above (continues #9627; 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)
This commit is contained in:
Dotta 2026-07-16 11:42:04 -05:00 committed by GitHub
parent 5a5c918705
commit 8b04147ca4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 28 additions and 19 deletions

View File

@ -68,7 +68,10 @@ export function Sidebar() {
resourceKey: "live-runs",
queryKey: liveRunsQueryKey,
enabled: !!selectedCompanyId,
refetchInterval: 10_000,
// Event-sourced via LiveUpdatesProvider (#9627) + reconnect reconcile — no
// interval poll needed. Polling here also re-armed React Query's timer on
// every live-event cache write, a major source of steady-state churn.
refetchInterval: false,
leaderOnly: true,
});
const { data: liveRuns, dataUpdatedAt: liveRunsUpdatedAt } = useQuery({

View File

@ -702,7 +702,8 @@ describe("SidebarAgents", () => {
expect(agentLinkLabels(container)).toEqual(["Alpha"]);
await act(async () => {
await vi.advanceTimersByTimeAsync(1);
await vi.advanceTimersByTimeAsync(5);
await vi.advanceTimersByTimeAsync(0);
});
expect(agentLinkLabels(container)).toEqual(["Alpha", "Bravo", "Charlie"]);
});
@ -758,12 +759,14 @@ describe("SidebarAgents", () => {
expect(agentLinkLabels(container)).toEqual(["Alpha", "Bravo"]);
await act(async () => {
await vi.advanceTimersByTimeAsync(60_001);
await vi.advanceTimersByTimeAsync(60_005);
await vi.advanceTimersByTimeAsync(0);
});
expect(agentLinkLabels(container)).toEqual(["Bravo"]);
await act(async () => {
await vi.advanceTimersByTimeAsync(60_000);
await vi.advanceTimersByTimeAsync(60_005);
await vi.advanceTimersByTimeAsync(0);
});
expect(agentLinkLabels(container)).toEqual(["Alpha", "Bravo", "Charlie"]);
});

View File

@ -339,7 +339,8 @@ export function SidebarAgents({ streamlined = false }: { streamlined?: boolean }
resourceKey: "live-runs",
queryKey: liveRunsQueryKey,
enabled: !!selectedCompanyId,
refetchInterval: 10_000,
// Event-sourced via LiveUpdatesProvider (#9627); no interval poll needed.
refetchInterval: false,
leaderOnly: true,
});
const { data: liveRuns, dataUpdatedAt: liveRunsUpdatedAt } = useQuery({

View File

@ -445,7 +445,8 @@ function ExecutionWorkspaceIssuesList({
resourceKey: "live-runs",
queryKey: liveRunsQueryKey,
enabled: !!companyId,
refetchInterval: 5000,
// Event-sourced via LiveUpdatesProvider (#9627); no interval poll needed.
refetchInterval: false,
leaderOnly: true,
});
const { data: liveRuns, dataUpdatedAt: liveRunsUpdatedAt } = useQuery({

View File

@ -2,7 +2,6 @@ import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } fro
import { Link, useLocation, useNavigate } from "@/lib/router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { deriveOriginatingActor, INBOX_MINE_ISSUE_STATUS_FILTER } from "@paperclipai/shared";
import { useVisibilityRefetchInterval } from "@/lib/polling";
import { usePublishSharedQueryData, useSharedPollingQuery } from "@/hooks/useSharedPolling";
import { approvalsApi } from "../api/approvals";
import { accessApi } from "../api/access";
@ -917,14 +916,14 @@ export function Inbox() {
refetchOnWindowFocus: false,
staleTime: INBOX_HOT_PATH_STALE_MS,
});
const liveRunsRefetchInterval = useVisibilityRefetchInterval({ visibleMs: 5000 });
const liveRunsQueryKey = queryKeys.liveRuns(selectedCompanyId!);
const sharedLiveRuns = useSharedPollingQuery({
companyId: selectedCompanyId,
resourceKey: "live-runs",
queryKey: liveRunsQueryKey,
enabled: !!selectedCompanyId,
refetchInterval: liveRunsRefetchInterval,
// Event-sourced via LiveUpdatesProvider (#9627); no interval poll needed.
refetchInterval: false,
leaderOnly: true,
});
const { data: liveRuns, dataUpdatedAt: liveRunsUpdatedAt } = useQuery({

View File

@ -2,7 +2,6 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState, type ChangeEve
import { pickTextColorForPillBg } from "@/lib/color-contrast";
import { Link, useLocation, useNavigate, useNavigationType, useParams } from "@/lib/router";
import { useInfiniteQuery, useQuery, useMutation, useQueryClient, type InfiniteData, type QueryClient } from "@tanstack/react-query";
import { useVisibilityRefetchInterval } from "@/lib/polling";
import { usePublishSharedQueryData, useSharedPollingQuery } from "@/hooks/useSharedPolling";
import { ApiError } from "../api/client";
import { issuesApi } from "../api/issues";
@ -1684,14 +1683,14 @@ export function IssueDetail() {
queryFn: () => issuesApi.list(resolvedCompanyId!, { parentId: issue!.parentId!, includeBlockedBy: true }),
enabled: !!resolvedCompanyId && !!issue?.parentId,
});
const companyLiveRunsRefetchInterval = useVisibilityRefetchInterval({ visibleMs: 5000 });
const companyLiveRunsQueryKey = resolvedCompanyId ? queryKeys.liveRuns(resolvedCompanyId) : ["live-runs", "pending"] as const;
const sharedCompanyLiveRuns = useSharedPollingQuery<LiveRunForIssue[]>({
companyId: resolvedCompanyId,
resourceKey: "live-runs",
queryKey: companyLiveRunsQueryKey,
enabled: !!resolvedCompanyId,
refetchInterval: companyLiveRunsRefetchInterval,
// Event-sourced via LiveUpdatesProvider (#9627); no interval poll needed.
refetchInterval: false,
leaderOnly: true,
});
const { data: companyLiveRuns, dataUpdatedAt: companyLiveRunsUpdatedAt } = useQuery({

View File

@ -8,7 +8,6 @@ import { heartbeatsApi } from "../api/heartbeats";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { collectLiveIssueIds } from "../lib/liveIssueIds";
import { useVisibilityRefetchInterval } from "@/lib/polling";
import { usePublishSharedQueryData, useSharedPollingQuery } from "@/hooks/useSharedPolling";
import { queryKeys } from "../lib/queryKeys";
import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
@ -98,14 +97,14 @@ export function Issues() {
enabled: !!selectedCompanyId,
});
const liveRunsRefetchInterval = useVisibilityRefetchInterval({ visibleMs: 5000 });
const liveRunsQueryKey = queryKeys.liveRuns(selectedCompanyId!);
const sharedLiveRuns = useSharedPollingQuery({
companyId: selectedCompanyId,
resourceKey: "live-runs",
queryKey: liveRunsQueryKey,
enabled: !!selectedCompanyId,
refetchInterval: liveRunsRefetchInterval,
// Event-sourced via LiveUpdatesProvider (#9627); no interval poll needed.
refetchInterval: false,
leaderOnly: true,
});
const { data: liveRuns, dataUpdatedAt: liveRunsUpdatedAt } = useQuery({

View File

@ -241,7 +241,8 @@ function ProjectIssuesList({ projectId, companyId }: { projectId: string; compan
resourceKey: "live-runs",
queryKey: liveRunsQueryKey,
enabled: !!companyId,
refetchInterval: 5000,
// Event-sourced via LiveUpdatesProvider (#9627); no interval poll needed.
refetchInterval: false,
leaderOnly: true,
});
const { data: liveRuns, dataUpdatedAt: liveRunsUpdatedAt } = useQuery({
@ -317,7 +318,8 @@ function ProjectPluginOperationsList({
resourceKey: "live-runs",
queryKey: liveRunsQueryKey,
enabled: !!companyId,
refetchInterval: 5000,
// Event-sourced via LiveUpdatesProvider (#9627); no interval poll needed.
refetchInterval: false,
leaderOnly: true,
});
const { data: liveRuns, dataUpdatedAt: liveRunsUpdatedAt } = useQuery({

View File

@ -342,7 +342,8 @@ export function Routines() {
resourceKey: "live-runs",
queryKey: liveRunsQueryKey,
enabled: !!selectedCompanyId && activeTab === "runs",
refetchInterval: 5000,
// Event-sourced via LiveUpdatesProvider (#9627); no interval poll needed.
refetchInterval: false,
leaderOnly: true,
});
const { data: liveRuns, dataUpdatedAt: liveRunsUpdatedAt } = useQuery({

View File

@ -277,7 +277,8 @@ function PluginSdkIssuesList({
resourceKey: "live-runs",
queryKey: liveRunsQueryKey,
enabled: !!companyId,
refetchInterval: 5000,
// Event-sourced via LiveUpdatesProvider (#9627); no interval poll needed.
refetchInterval: false,
leaderOnly: true,
});
const { data: liveRuns, dataUpdatedAt: liveRunsUpdatedAt } = useQuery({