Repository navigation
fix: long agent-team sessions no longer slow down (TUI dev-React leak, backend busy polls) - #957
Merged
Merged
Conversation
…, backend busy polls) A session running an agent team grew slower over a few hours. Measured on the live session: - The TUI was at 3.1 GB. An earlier TUI from the same kind of session hit 3.2 GB against V8's 4.5 GB limit. - The idle backend burned about 8 CPU-hours. TUI (root cause): scripts/build.mjs never defined NODE_ENV, and the launcher runs `node dist/entry.js` with NODE_ENV unset, so every user loaded React's development build. Its performance tracks call performance.measure() as components re-render, and Node keeps every entry in its global timeline. A heap-snapshot diff traced the growth to measureEntryBuffer: about 200 MB/h from the spinner alone, roughly three times that with agent progress. - Fold NODE_ENV to "production" at build time. Setting it at runtime would leak into the agent-server and user commands. - The bun fallback gets the same define. Backend: two 50 ms loops re-read files that rarely change. - The team mailbox sweep re-parsed every message each inbox had ever held. - Each idle teammate re-read the whole task board. Both now skip a file whose (inode, size, mtime) version is unchanged since they last read it to the end. The version is taken before the read, so a racing write is picked up next tick. A failed read is never marked consumed. An idle teammate still re-reads the board every 2 s, because coarse-mtime filesystems can repeat a version. At the real cadence on Python 3.10: - sweep: 2.46 -> 0.42 ms - idle wake: 1.05 -> 0.35 ms Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Test Results 5 files 1 021 suites 21m 51s ⏱️ Results for commit 843ddb8. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A clawcodex session running an agent team became very slow after a few hours. The session was still live, so the investigation measured it directly rather than guessing. It found two defects that both got worse the longer the session ran.
1. The TUI shipped React's development build and leaked memory on every render (root cause)
Symptom
large_object_spaceheld only 37 MB. That points to millions of small retained objects rather than a few big strings.Cause
ui-tui/scripts/build.mjsnever definedprocess.env.NODE_ENV, so esbuild kept React's runtime switch.node dist/entry.jswithNODE_ENVunset, so every user loaded the development builds ofreact,react-reconcilerandscheduler.performance.measure()with adetail.devtoolspayload whenever a component re-renders.clearMeasures()is called, and nothing calls it.Proof
gc()heap read over the inspector.(GC roots) → observerCallback → measureEntryBuffer → PerformanceMeasure → detail.devtools.properties, at 4,506 new measures per minute.Heap after forced GC:
What's left after the fix is Ink's
Output.charCache. It holds at most 16,384 lines and clears when it overflows, so memory follows a sawtooth: about 31 minutes per cycle for the spinner alone, faster under streaming. It peaks around 46 MB for spinner-sized lines, or more for full-width transcript lines. That cap existed before this PR.Fix
define: { 'process.env.NODE_ENV': '"production"' }.performance.measure(and no runtimeNODE_ENVcheck.bun run src/entry.tsxfallback, used when there's no built bundle, gets the same define:bun run --define, verified to load the production build.NODE_ENVis deliberately not set at runtime. The agent-server and the user's Bash commands inherit the TUI's environment, andnpm installskips devDependencies underNODE_ENV=production.NODE_ENVreaders in the bundle only gate development warnings and hooks:developmentandtest. Those checks were already off at runtime.!== 'production'dev hooks were on and are now off. Nothing changes, becausecleanStoresisn't bundled.2. An idle team kept the backend busy, and the cost grew with the session
Symptom
ps -Mput nearly all of it on three threads: 4.5 h, 1.7 h and 1.5 h.Cause: two 50 ms loops re-read files that rarely change.
TeamRuntime._poll→sweep_mailboxesre-parsed every message each inbox had ever held, then sliced[offset:].claim_next_task.Fix: skip a file that hasn't changed since it was last read to the end.
sweep_mailboxestakes an optionalconsumedmap of inbox versions (inode, size and mtime, from the newfile_version()).read_mailbox(strict=True)raises on an unreadable inbox instead of returning[], including when anexists()pre-check would have hidden the error, and the sweep retries.CPU at the real 50 ms cadence, on Python 3.10 (the live interpreter), against a copy of the live team's inboxes and board:
The remaining floor is the 50 ms wake itself (0.54% per teammate). The Claude Code reference polls every 500 ms.
Not a clawcodex defect
The lead's model steps went from about 15 s to about 50 s, but seconds per 1k output tokens stayed flat and the cache hit rate stayed at 95–97%. The steps got slower because each one produced more output (median 363 → 843 tokens) and read a context of up to 520K tokens.
/compacthelps with that.Test plan
buildProductionReact.test.tsruns the real build into a temp dir. It asserts the production reconciler is present, and that the development reconciler,performance.measure(andprocess.env.NODE_ENVare absent.test_mailbox_poller.py:open()failed once is retried on the next sweep;exists()check failed once is retried on the next sweep.test_team_runtime_e2e.py:test_tui_launcher.py: the bun fallback carries the define.test_headless_keeps_the_damped_wording…, is pre-existing: it fails the same way onmain.main, and this PR changes no source file they exercise.Follow-ups (not in this PR)
node dist/entry.jswithout the--max-old-space-size=8192from the shebang inentry.tsx, because the build strips the shebang.memoryMonitor.tsderives its thresholds from the real ~4.5 GB limit; only its comments still say 8 GB.npm run buildinui-tui.🤖 Generated with Claude Code