Skip to content

perf: reduce memory footprint across real workflows (idle agent reclaim, poll churn, worker pools)#3149

Merged
fercgomes merged 8 commits into
mainfrom
memory-footprint-autoresearch
Jul 5, 2026
Merged

perf: reduce memory footprint across real workflows (idle agent reclaim, poll churn, worker pools)#3149
fercgomes merged 8 commits into
mainfrom
memory-footprint-autoresearch

Conversation

@fercgomes

Copy link
Copy Markdown
Contributor

Reduces PostHog Code's memory footprint in real user workflows, driven by an autonomous measure → change → verify loop against the live dev app. Users report the app hogging memory and CPU; this PR attacks the verified causes rather than guesses.

TL;DR results

Finding Fix Verified effect
The visible task pins a ~340MB claude-cli + ~75MB MCP servers forever (activity heartbeat + auto-reconnect never let the existing 15-min idle kill fire) Presence-gate the heartbeat and the reconcile (useUserPresence) Agent reclaimed after user is away (test knobs: at t+177s; total RSS 1500 → 1168MB, agent children 0). Focus + click auto-reconnects with a fresh agent in ~15s
Idle poll churn: ~5.7MB/min of API traffic while untouched (96% task-list fetches: full descriptions + latest_run blobs every 30s ×3 pollers), driving renderer RSS up ~19MB/min with flat JS heap — also a big slice of the reported CPU drain Full task lists 30s→60s (lookup consumers only), slack-origin list 30s→5min (origin is immutable), slim summaries stay at 30s Task-switch scenario delta +158MB → −2MB; idle churn 5.7 → 3.1MB/min (show-all-users profile), ~6× lower for default profiles
@pierre/diffs spawns 8 shiki highlighter workers per pool by default (each a full V8 isolate with grammars loaded); two pools mount poolSize: 2 at both providers −67MB total RSS benchmarked; 9 workers → 3
Parked (hidden) terminals keep a live WebGL GPU context indefinitely (Chromium drops the oldest past 16) Dispose on detach, reload on attach One GPU context freed per hidden terminal
Unbounded in-memory accumulation: agent-chat feed (an AcpMessage per stream chunk), canvas edit history (full source copy per version) Caps: 2000 messages / 50 versions Bounds worst-case growth on long sessions

Research method

Everything was measured on the real dev app (live tRPC, workspace-server, real agent backend) — no synthetic fixtures.

Instrumentation (included in this PR):

  • scripts/bench-memory.mjs — samples RSS across the app's entire process tree via the CDP listener pid, classifies each process (main / renderer / GPU / utility / workspace-server / agent children), and reads renderer JS heap over CDP. Emits median steady-state totals.
  • scripts/bench-memory-run.mjs — owns the full benchmark lifecycle for comparability: fresh app launch per run (POSTHOG_CODE_CDP_PORT, added in bootstrap.ts, since Chrome typically owns :9222), drives a scenario over CDP with playwright-core, samples idle + post-workflow, tears down. Scenarios: thread (N cheap real agent turns — "reply with exactly "), switch (visit 6 sidebar tasks with dwell — the realest daily workflow), longout (one turn with a ~40KB bash tool output). Thread scenarios navigate to a dedicated bench task by id and refuse to send turns into any other task.
  • Test knobs for fast verification of the idle-reclaim loop: VITE_PRESENCE_IDLE_MS (renderer) and POSTHOG_CODE_AGENT_IDLE_TIMEOUT_MS (workspace-server) — the full suspend → reclaim → return → reconnect cycle is verifiable in ~4 minutes with the window defocused instead of ~25.

Protocol: pinned metric (post-workflow steady-state total RSS, median over 30s), one change per cycle, keep/discard by measurement plus guards (pnpm typecheck, scoped vitest suites, biome lint packages/core, host-boundary check). Measured noise floor: ±35–50MB run-to-run (each bench run also grows the bench thread's transcript, drifting the baseline upward — treated sub-noise deltas as neutral).

Key diagnostics that found the issues:

  • Per-process RSS attribution showed a claude-cli child alive at boot with --resume and ~340MB — before any user action.
  • Reading the heartbeat/reconcile code explained why the workspace-server's existing idle kill (which demonstrably works — log history) could never fire for a mounted task.
  • A 35-min unattended idle profile failed to show reclaim — root-caused to the experiment itself: HMR from concurrent edits plus the dev window sitting on the user's active desktop, where stray mouse-overs counted as presence. That produced the focus-gate hardening (input only counts while the window has focus) and the fast-verify knobs.
  • The task-switch scenario retained +158MB with flat JS heap and flat DOM; a decay watch showed renderer RSS climbing ~19MB/min at idle, and a playwright network tap attributed 96% of a steady ~5.7MB/min of idle traffic to two full-payload task-list polls (630KB created_by + 2.2MB slack-origin/all-users, each every 30s, limit=500 — ~630KB of which is 205KB task descriptions + 320KB latest_run blobs per response).

Structural floors (measured, not worth chasing client-side): Electron main ~310MB RSS with only ~60MB JS heap; GPU ~130MB; network/utility ~78MB; renderer ~590MB dev / ~340–380MB prod with ~145MB JS heap.

Verification

  • Idle-reclaim loop verified end-to-end with short knobs and a defocused window: boot spawn → presence lapse → heartbeat paused → workspace-server "Killing idle session" → agent + MCP children gone (node-child 0MB) → focus + click → auto-reconnect ~15s.
  • Task-switch scenario re-run post-fix: visit-6-tasks delta +158MB → −2MB.
  • Regression gate: @posthog/ui full suite green (incl. 7 new presence tests), @posthog/core 1970/1971 (the 1 failure is a locale-dependent billing test that fails identically on main), workspace-server agent suite 63/63 (archive integration failures identical to main), check-host-boundaries clean.

Follow-ups (found, not implemented — product calls)

  1. Server-side slim tasks endpoint — the show-all-users sidebar still needs the 2.25MB full poll only because /tasks/summaries/ lacks origin_product/creator fields; adding them cuts the heaviest remaining poll ~20×.
  2. Cold boot resume — the app spawns claude-cli --resume at every launch before any user action (~340MB for up to 25 min; booting on a cloud task vs a local task swings idle RSS by ~450MB). Needs a "cold session" state that paints the transcript from logs and connects on first prompt.
  3. Quiet "suspended" UI state — an idle-killed session currently shows the error-flavored "disconnected due to inactivity" while the user is away; a dedicated state would look intentional.
  4. sessionStore.events grows unbounded for the live viewed session during long streaming runs (residency eviction only covers backgrounded sessions).

🤖 Generated with Claude Code

https://claude.ai/code/session_014cVSjw1BsiioCeA5Fhhavz

fercgomes and others added 7 commits July 5, 2026 15:18
Adds scripts/bench-memory.mjs (process-tree RSS sampler with per-category
breakdown + renderer heap over CDP) and scripts/bench-memory-run.mjs (full
lifecycle: launch, drive a thread workflow with cheap agent turns via
playwright-core over CDP, sample idle + post-workflow, tear down).
bootstrap.ts now honors POSTHOG_CODE_CDP_PORT (matching electron-cdp.mjs)
so the bench can run beside a Chrome that owns :9222.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Each pooled @pierre/diffs worker is a full V8 isolate with shiki grammars
loaded (~12MB RSS each, eagerly spawned per provider). Benchmarked -67MB
total app RSS with no visible highlighting latency change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A mounted task view heartbeated agent.recordActivity every 5 minutes
forever, and reconcileLocalConnection auto-respawned any idle-killed
session immediately — so the visible task always pinned a live claude-cli
process (~340MB RSS) plus its stdio MCP servers, even overnight.

Gate both on user presence (any window input in the last 10 minutes):
when the user walks away the heartbeat stops, the existing workspace-server
15-minute idle kill reclaims the agent process, and the existing
idle-kill reconcile path restores the session seconds after the user
returns. Agents mid-prompt or with in-flight MCP calls are never killed
(killIdleSession already refuses).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- agentChatStore: retain at most 2000 AcpMessages per live chat (a
  streaming chat appends one per chunk, unbounded on long sessions)
- freeformChatStore: retain at most 50 edit-history versions (each holds
  a full copy of the canvas source + context)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…free parked terminal GPU contexts

- useUserPresence: input only counts while the window has focus (a stray
  mouse-over of an unfocused window is not use); VITE_PRESENCE_IDLE_MS
  override and pause/resume logging so the suspend/reclaim/reconnect loop
  is verifiable in minutes.
- AgentService: POSTHOG_CODE_AGENT_IDLE_TIMEOUT_MS override (dev/test).
- TerminalManager: dispose the WebGL renderer when a terminal is parked
  offscreen and reload it on attach — a parked terminal otherwise holds a
  live GPU context indefinitely (Chromium drops the oldest past 16).

Verified end-to-end with 60s presence / 180s server idle: agent process
reclaimed at t+177s (total RSS 1500MB -> 1168MB, node children 0), and
focus + click auto-reconnected the session with a fresh agent in ~15s.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Idle-network profiling (scripts/bench-memory-run.mjs --scenario switch)
showed task polling was 96% of idle traffic (~5.7MB/min): full task
payloads (descriptions + latest_run blobs) refetched every 30s by three
pollers. Slack-origin decoration (icons + thread links) polled 2.2MB
responses for data that is immutable per task.

- full task lists: 30s -> 60s (lookup consumers; sidebar freshness comes
  from the slim summaries poll, which stays at 30s)
- slack-origin list: 30s -> 5min (origin never changes; new tasks appear
  within the window or on window focus)
- bench harness: add --scenario switch|longout|thread drivers

Halves idle churn in show-all-users profiles (~3.1MB/min) and cuts it
~6x for default profiles (~0.9MB/min). Renderer RSS growth after
visiting 6 tasks dropped from ~19MB/min to ~9MB/min.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
--scenario switch|longout|thread; thread/longout navigate to the dedicated
bench task by id and refuse to send turns into any other task.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@trunk-io

trunk-io Bot commented Jul 5, 2026

Copy link
Copy Markdown

Merging to main in this repository is managed by Trunk.

  • To merge this pull request, check the box to the left or comment /trunk merge below.

After your PR is submitted to the merge queue, this comment will be automatically updated with its status. If the PR fails, failure details will also be posted here

@github-actions

github-actions Bot commented Jul 5, 2026

Copy link
Copy Markdown

React Doctor found no issues in the changed files. 🎉

Reviewed by React Doctor for commit 124f2a8.

@fercgomes fercgomes marked this pull request as ready for review July 5, 2026 18:22
@greptile-apps

greptile-apps Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Comments Outside Diff (2)

  1. packages/ui/src/hooks/useUserPresence.test.ts, line 401-410 (link)

    P2 pointermove missing from the unmount assertion

    PRESENCE_EVENTS contains five events including "pointermove", but the "removes listeners on unmount" assertion only checks for four of them. arrayContaining doesn't catch if "pointermove" is accidentally dropped from PRESENCE_EVENTS in future — the test would still pass. Adding "pointermove" to the expected array makes the assertion exhaustive for all five registered events.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

  2. scripts/bench-memory-run.mjs, line 653-668 (link)

    P2 Hardcoded task ID is developer-environment-specific

    The default thread-task-id and thread-title ("Casual greeting") are tied to one developer's local data. Any other contributor running this script without --thread-task-id will hit the safety error "refusing to send benchmark turns" with no explanation of how to supply a valid task. The usage block at the top lists available flags but doesn't call out that this argument is effectively required outside the original author's environment. A note there would save the next person a debugging loop.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Reviews (1): Last reviewed commit: "feat(bench): scenario drivers + safe ben..." | Re-trigger Greptile

Comment thread packages/ui/src/hooks/useUserPresence.ts
@fercgomes fercgomes merged commit 807a22a into main Jul 5, 2026
23 checks passed
@fercgomes fercgomes deleted the memory-footprint-autoresearch branch July 5, 2026 18:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants