mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-12 08:19:54 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Its web UI keeps live views (issue threads, run transcripts, dashboard) fresh via React Query polling plus a live-events websocket, coordinated across tabs with a `BroadcastChannel` layer > - Long-lived tabs viewing live agent runs grew to multi-GB memory footprints while their JS heap stayed ~60–200 MB — so the memory is off-heap (Blink/native + committed allocator arenas), not a classic JS leak > - Live profiling of a reproduced 2.7 GB / 66 MB tab showed 15–30% idle CPU, ~3 fetches/sec across overlapping poll loops, and ~8 `setInterval` create/clear cycles per second whose rate grew ~7× as the tab aged — relentless allocation churn that inflates committed memory the OS never reclaims, amplified across tabs by the cross-tab fan-out > - This pull request cuts that churn at its four largest sources (invalidation storm, per-instance 1 s timers, redundant polling, unbounded streamed-run set) > - The benefit is that idle tabs do far less periodic work, so their off-heap footprint stops ballooning over a long session ## 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. **What happened?** Browser tabs viewing live agent runs grew to 8–16 GB memory footprint over a long session (multiple tabs open), while each tab's "live" JS heap stayed only ~150–250 MB. Every idle tab also burned 15–30% CPU. Tabs eventually approached the ~4 GB V8 heap ceiling / OS pressure and could crash. **Expected behavior** Tabs viewing live runs should hold a bounded footprint and do minimal work while idle, regardless of how long they stay open or how many tabs are open. **Steps to reproduce** Open several issue/run tabs that have agents actively streaming and leave them open for a while. Watch Chrome's Task Manager: Memory Footprint climbs into the GBs while "JavaScript Memory" stays small, and CPU stays high on idle tabs. Reproduced in ~90 minutes: a tab reached 2.7 GB footprint on a 66 MB JS heap, and the per-second timer-churn rate was ~7× higher on a 90-minute-old tab than a fresh one. **Paperclip version or commit** Branch `fix/live-updates-churn`, off `master`. **Deployment mode** Local dev (`pnpm dev`), web UI. Not adapter-specific — core UI live-updates plumbing (observed with `claude_local` / `codex_local` runs). ## What Changed - **`ui/src/lib/query-invalidation-batcher.ts` (new)** — `createInvalidationBatcher` throttles + de-dupes React Query invalidations into one flush per ~300 ms, and `createCoalescingQueryClient` wraps the client via a `Proxy` so only `invalidateQueries` is batched (optimistic `setQueryData` writes stay immediate). Wired into `LiveUpdatesProvider`, which previously invalidated synchronously on every websocket event. - **`ui/src/hooks/useSecondTick.ts` (new)** — one shared, ref-counted, page-wide 1 s ticker. `useLiveElapsed` in `IssueChatThread` now uses it instead of a per-instance `setInterval` that forced a full-thread re-render every second per live element. - **`ui/src/components/transcript/useLiveRunTranscripts.ts`** — when the realtime websocket is enabled, the recurring log poll backs off to a 30 s safety-net cadence instead of polling every 2 s on top of the live stream. Added a marker for the durable poll→push rearchitecture. - **`ui/src/lib/issueChatTranscriptRuns.ts`** — `resolveIssueChatTranscriptRuns` now caps the streamed run set (live/active runs always kept; most-recent linked runs fill up to 20) so a large run history can't open a live-transcript poll per historical run. - **`ui/src/main.tsx`** — explicit `gcTime` so cross-tab-published cache entries for unobserved resources are collected promptly. - Tests for the batcher, shared ticker, and run cap. ## Verification - `vitest`: new suites `query-invalidation-batcher.test.ts` (batcher collapses 20 invalidations → 1 flush; keeps distinct keys/variants; dispose cancels; proxy passes non-invalidate methods through), `useSecondTick.test.tsx` (single ref-counted timer, stops when idle), `issueChatTranscriptRuns.test.ts` (cap keeps newest + live). All pass. - Existing affected suites pass: `LiveUpdatesProvider` (23), `IssueChatThread` (), `useLiveRunTranscripts`, `AgentDetail.instructions` — 109 tests across affected files. - `tsc -b` clean. - Behavior confirmed by live profiling before the change (2.7 GB / 66 MB tab, ~8 interval churns/sec growing 7× with age). Runtime churn reduction should be re-measured against a rebuilt bundle with the same instrumentation. ## Risks Low-to-moderate; all changes reduce work rather than add features. - **Invalidation batching** delays live-driven refetches by up to ~300 ms. Optimistic `setQueryData` writes (e.g. the visible issue's new comment) remain immediate, so foreground updates still feel instant; only the safety-net refetch is throttled. Non-live invalidations (user actions, mutations) are unaffected — they use the real client. - **Poll back-off** relies on the websocket as the live source when realtime is enabled; a 30 s fallback poll still covers gaps/reconnects (both the transcript hook and `LiveUpdatesProvider` also auto-reconnect). - **Run cap (20)** means an issue with a very large run history streams live transcripts only for its live/active + 20 most-recent runs; older runs still open normally via their run pages. - Downstream test fallout (timing-sensitive tests around invalidation/polling) may need adjustment — flagged intentionally for follow-up. Durable follow-up (out of scope, marked in code): replace transcript/run polling with server push (SSE/websocket deltas) so idle tabs do no periodic work at all. ## 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 for codebase analysis, and the Chrome DevTools MCP to reproduce and profile the memory/CPU churn on a live instance. ## 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 bug/perf fix, not planned core feature work) - [x] I have searched GitHub for duplicate or related PRs and linked them above (none found) - [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>
142 lines
4.9 KiB
TypeScript
142 lines
4.9 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { QueryClient } from "@tanstack/react-query";
|
|
import {
|
|
createCoalescingQueryClient,
|
|
createInvalidationBatcher,
|
|
} from "./query-invalidation-batcher";
|
|
|
|
function fakeClient() {
|
|
const calls: unknown[] = [];
|
|
const client = {
|
|
invalidateQueries: vi.fn((filters?: unknown) => {
|
|
calls.push(filters);
|
|
return Promise.resolve();
|
|
}),
|
|
};
|
|
return { client, calls };
|
|
}
|
|
|
|
describe("createInvalidationBatcher", () => {
|
|
beforeEach(() => vi.useFakeTimers());
|
|
afterEach(() => vi.useRealTimers());
|
|
|
|
it("coalesces repeated invalidations of the same key into one call per window", () => {
|
|
const { client } = fakeClient();
|
|
const batcher = createInvalidationBatcher(client, 300);
|
|
|
|
for (let i = 0; i < 20; i++) batcher.schedule({ queryKey: ["dashboard", "c1"] });
|
|
expect(client.invalidateQueries).not.toHaveBeenCalled(); // nothing until flush
|
|
|
|
vi.advanceTimersByTime(300);
|
|
expect(client.invalidateQueries).toHaveBeenCalledTimes(1);
|
|
expect(client.invalidateQueries).toHaveBeenCalledWith({ queryKey: ["dashboard", "c1"] });
|
|
});
|
|
|
|
it("keeps distinct keys and distinct refetchType variants separate", () => {
|
|
const { client } = fakeClient();
|
|
const batcher = createInvalidationBatcher(client, 300);
|
|
|
|
batcher.schedule({ queryKey: ["a"] });
|
|
batcher.schedule({ queryKey: ["b"] });
|
|
batcher.schedule({ queryKey: ["a"] }); // dup of first
|
|
batcher.schedule({ queryKey: ["a"], refetchType: "inactive" }); // distinct variant
|
|
|
|
vi.advanceTimersByTime(300);
|
|
expect(client.invalidateQueries).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it("never coalesces predicate-based invalidations", () => {
|
|
const { client } = fakeClient();
|
|
const batcher = createInvalidationBatcher(client, 300);
|
|
|
|
// Two distinct predicate filters must both run — they can't be proven equal.
|
|
batcher.schedule({ predicate: () => true });
|
|
batcher.schedule({ predicate: () => false });
|
|
|
|
vi.advanceTimersByTime(300);
|
|
expect(client.invalidateQueries).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("schedule() resolves only after the flush has invalidated", async () => {
|
|
const { client } = fakeClient();
|
|
const batcher = createInvalidationBatcher(client, 300);
|
|
|
|
let resolved = false;
|
|
const p = batcher.schedule({ queryKey: ["a"] }).then(() => {
|
|
resolved = true;
|
|
});
|
|
|
|
await Promise.resolve();
|
|
expect(resolved).toBe(false); // not yet — window still open
|
|
expect(client.invalidateQueries).not.toHaveBeenCalled();
|
|
|
|
vi.advanceTimersByTime(300);
|
|
await p;
|
|
expect(resolved).toBe(true);
|
|
expect(client.invalidateQueries).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("starts a fresh window after flushing", () => {
|
|
const { client } = fakeClient();
|
|
const batcher = createInvalidationBatcher(client, 300);
|
|
|
|
batcher.schedule({ queryKey: ["a"] });
|
|
vi.advanceTimersByTime(300);
|
|
expect(client.invalidateQueries).toHaveBeenCalledTimes(1);
|
|
|
|
batcher.schedule({ queryKey: ["a"] });
|
|
vi.advanceTimersByTime(300);
|
|
expect(client.invalidateQueries).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("dispose cancels a pending flush", () => {
|
|
const { client } = fakeClient();
|
|
const batcher = createInvalidationBatcher(client, 300);
|
|
|
|
batcher.schedule({ queryKey: ["a"] });
|
|
batcher.dispose();
|
|
vi.advanceTimersByTime(1000);
|
|
expect(client.invalidateQueries).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("flush() invalidates immediately", () => {
|
|
const { client } = fakeClient();
|
|
const batcher = createInvalidationBatcher(client, 300);
|
|
|
|
batcher.schedule({ queryKey: ["a"] });
|
|
batcher.flush();
|
|
expect(client.invalidateQueries).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe("createCoalescingQueryClient", () => {
|
|
beforeEach(() => vi.useFakeTimers());
|
|
afterEach(() => vi.useRealTimers());
|
|
|
|
it("batches invalidateQueries but passes other methods straight through", () => {
|
|
const setQueryData = vi.fn();
|
|
const getQueryData = vi.fn(() => ({ some: "data" }));
|
|
const realInvalidate = vi.fn(() => Promise.resolve());
|
|
const real = { invalidateQueries: realInvalidate, setQueryData, getQueryData } as unknown as QueryClient;
|
|
|
|
const batcher = createInvalidationBatcher(
|
|
{ invalidateQueries: realInvalidate } as unknown as QueryClient,
|
|
300,
|
|
);
|
|
const proxied = createCoalescingQueryClient(real, batcher);
|
|
|
|
// setQueryData / getQueryData pass through immediately.
|
|
proxied.setQueryData(["k"], 1);
|
|
expect(setQueryData).toHaveBeenCalledWith(["k"], 1);
|
|
expect(proxied.getQueryData(["k"])).toEqual({ some: "data" });
|
|
|
|
// invalidateQueries is deferred through the batcher.
|
|
void proxied.invalidateQueries({ queryKey: ["k"] });
|
|
void proxied.invalidateQueries({ queryKey: ["k"] });
|
|
expect(realInvalidate).not.toHaveBeenCalled();
|
|
|
|
vi.advanceTimersByTime(300);
|
|
expect(realInvalidate).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|