Internal SmartGift build of a Claude Code monitoring dashboard. Lanes: a durable unit of parallel agent work, one per working directory, tracked across session restarts. Managed lanes are git worktrees the dashboard provisions and can reset or remove behind a three-check destroy guard and a counted preflight; adopted lanes are directories you already own and are never destroyable. Pipelines: a lane moves through pipeline stages. A stage the agent declares with evidence renders green; a stage inferred from the tool-event stream renders dashed amber and never counts as done. Detection is forward-only within a 30-minute window, and never writes the declared stage. Workspace: one page at /run with a lane grid, the selected lane's pipeline, and a full Claude console behind a disclosure.
9.4 KiB
Design: Fix Agent-Monitor server memory leak
- Date: 2026-05-22
- Author: zhihua + Claude (brainstorming collaboration)
- Status: Design Approved, pending implementation plan
Background
After running npm start locally, the server process memory grows continuously over time and eventually exhausts host memory when combined with Claude / IDE / browser. The initial proposal was to deploy Agent-Monitor on a remote server and access it via the local browser, but investigation showed this only relocates the problem — the root cause is in the server itself, and a long-running remote instance will also OOM.
This design focuses on root-cause remediation, not remote deployment. Once memory is stable post-fix, we can revisit whether remote deployment is still desirable.
Current diagnosis (with code evidence)
Measured locally:
| Metric | Value |
|---|---|
data/dashboard.db |
192 MB |
events row count |
251,244 |
sessions count |
1130 (completed 1015 + abandoned 110 + active 5) |
| Largest single event size | 369 KB |
~/.claude/projects |
58 MB |
Three leak / performance sources were identified:
Leak #1: TranscriptCache entry has no per-entry size cap
In server/lib/transcript-cache.js, every cache entry holds three push-only arrays:
state.turnDurations.push(...)(l.327)state.errors.push(...)(l.332, 343)state.compaction.entries.push(...)(l.315)
_merge() incremental merging (l.456, 462, 468) likewise only pushes and never trims.
MAX_CACHE_ENTRIES = 200 bounds the number of entries, but each entry is unbounded in size. A long session emits one turnDuration per turn (~50 bytes), so a few thousand turns = MB-scale per entry; 200 entries × tens of MB = multiple GB.
Leak #2: _set() stores everything twice (per-entry memory doubled)
server/lib/transcript-cache.js:51-58:
this._set(key, {
errors: result?.errors ? [...result.errors] : null, // top-level shallow copy
turnDurations: result?.turnDurations ? [...result.turnDurations] : null,
compaction: this._cloneCompaction(result.compaction),
...
result, // contains references to the same fields
});
The top-level fields are shallow-copied ([...result.errors]) new array objects that do not share references with result.errors. Each array exists twice on the heap per cache entry.
Performance issue: the sweep does a full scan over events
server/index.js:329 runs every 60-300s:
SELECT DISTINCT e.session_id, json_extract(e.data,'$.transcript_path') AS tp
FROM events e JOIN sessions s ON s.id=e.session_id
WHERE s.status='active' AND json_extract(e.data,'$.transcript_path') IS NOT NULL
GROUP BY e.session_id ORDER BY MAX(e.id) DESC
Doing json_extract + DISTINCT + ORDER BY across 250k events rows produces large temporary SQLite memory spikes and is slow.
Goals and constraints
Goals:
- Server process RSS stays stable over long runs (< 300 MB)
- No Agent log loss (events table remains complete; no retention)
- Reversible changes confined to
server/; no changes to hook-handler / UI / WebSocket protocol
Non-goals (explicitly out of scope):
- Remote deployment
- Events table retention / archival
- DB engine swap / compression / sharding
- UI / frontend / CLI changes
Design
Change A: TranscriptCache per-entry sliding window
Add a configurable cap:
const MAX_ARRAY_LEN = parseInt(process.env.TRANSCRIPT_CACHE_MAX_ARRAY_LEN, 10) || 1000;
After each push in _streamRange parsing (l.315/327/332/343 etc.) and in _merge incremental merging (l.456/462/468), trim immediately:
if (arr.length > MAX_ARRAY_LEN) arr.splice(0, arr.length - MAX_ARRAY_LEN);
Applies to turnDurations, errors, compaction.entries, and usageExtras.{service_tiers, speeds, inference_geos} (these Set→Array conversions can also accumulate).
Why no data loss:
routes/hooks.js:583, 633 already inserts result.errors / result.turnDurations into the events table on every hook trigger, with dedup (SELECT 1 ... WHERE summary=? / WHERE created_at=?). After cache truncation, the next hook re-reads the transcript file → dedup skips existing rows → only new rows are inserted. The events table stays complete.
Capacity estimate:
- 1 turn ≈ 50 bytes
- 1000 turns = 50 KB / cache entry
- 200 entries full ≈ 10 MB
Change B: Eliminate _set() double storage
Simplify the cache entry shape:
this._cache.set(key, { mtimeMs, size, bytesRead, result });
Drop all top-level errors / turnDurations / compaction / usageExtras / tokensByModel / thinkingBlockCount / latestModel fields. _merge computes via local variables and writes back only into result.
Expected effect: ~50% memory reduction per entry.
Change C: Stop sweeping events for transcript_path
Schema migration (server/db.js):
-- Add column (idempotent)
ALTER TABLE sessions ADD COLUMN transcript_path TEXT;
-- One-time backfill (runs once at startup, gated by a .migrations marker file to prevent reruns)
UPDATE sessions SET transcript_path = (
SELECT json_extract(data,'$.transcript_path') FROM events
WHERE events.session_id=sessions.id
AND json_extract(data,'$.transcript_path') IS NOT NULL
LIMIT 1
) WHERE transcript_path IS NULL;
Follow the idempotent migration pattern at server/db.js:284 (the agents_new rebuild).
Write path (server/routes/hooks.js ensureSession):
When transcript_path is first seen, run UPDATE sessions SET transcript_path=? WHERE id=? AND transcript_path IS NULL.
Sweep query rewrite (server/index.js:329):
SELECT id, transcript_path FROM sessions
WHERE status='active' AND transcript_path IS NOT NULL
The query at server/index.js:309 that fetches transcript_path on abandonment is also rewritten to read from the sessions table.
Complexity: drops from O(total events rows) to O(active sessions ≈ single digits). Not a single events row is removed.
Verification strategy
Unit tests (new server/__tests__/transcript-cache-bounded.test.js)
- With
MAX_ARRAY_LEN=100, feed 500 turns →result.turnDurations.length === 100, tail retained - After cache truncation, re-extracting → events table dedup skips existing rows, insert count == 0
- Coarse memory assertion: 200 entries × 1000 turns,
process.memoryUsage().heapUseddelta < 30 MB
Integration tests
npm run test:servergreennpm run test:clientgreennpm run mcp:typecheckpasses
Measurement script (one-off)
New scripts/memory-soak-test.js:
- Generate fake transcript jsonl with 10000 turns
- Start the server, simulate 10 concurrent active sessions, fire a hook every 1s
- Run for 30 minutes, log
process.memoryUsage().rssper minute - Assert: RSS growth at minute 30 < 50 MB
Verification checklist (pre-merge)
- Unit + integration tests green
npm run mcp:typecheckpasses- Local
npm startfor 1h,ps -o rss=monitoring shows a flat curve - DB migration idempotent: two consecutive
npm startruns without errors - Old DB (no
transcript_pathcolumn) → migrate + backfill → sweep works - After cache truncation, the UI events list still shows all old turns/errors
Risks and rollback
Risk matrix
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
MAX_ARRAY_LEN=1000 too small for ultra-long sessions |
Low | Medium | Env var tunable to 5000-10000; events table is always complete, UI can still query |
| Extra dedup SELECTs after cache truncation | Medium | Low | Sweep runs every 60-300s; an extra 100-1000 primary-key lookups per run is acceptable |
| ALTER TABLE fails on old DB | Very low | High | Use the migration pattern at db.js:284 — try-catch + column-existence check |
| transcript_path backfill is slow due to events scan | Low | Low | One-time migration takes ~1s; use EXISTS subquery instead of join |
_set() shape change breaks other readers |
Low | Medium | Grep the repo to confirm all external consumers of extract() only read result.* |
Rollback
- All changes are confined to
server/; hook-handler / UI / WebSocket protocol untouched - Rollback at any phase =
git revertof the matching commit - DB schema:
ALTER TABLE ... ADD COLUMNis not reversible, but an unread/unwritten new column is harmless; once code is reverted, sessions just has an extra empty column
Optional follow-ups (out of scope here)
- Add a
(session_id, event_type, created_at)composite index on events (UI query performance) - Add a
lastProcessedTurnTimestampcursor to the cache soextractonly returns new turns (eliminates dedup SELECTs entirely) /api/internal/memorydiagnostic endpoint returningcache.stats()+process.memoryUsage()
Decision record
| Option | Choice | Rationale |
|---|---|---|
| Remote deployment vs fix leak | Fix leak | Remote deployment relocates the problem; the leak hits remote too |
| Permanent events retention vs retention policy | Permanent | Hard user constraint: guarantee Agent log integrity |
| Truncate cache vs not truncate | Truncate to MAX_ARRAY_LEN | Events table already persists raw data; the cache is a derived view |
| Delete events vs rewrite the sweep query | Rewrite the query | Satisfies the "no log loss" constraint |
| Introduce LRU byte-budget instead of entry count | No | Entry-count cap + per-entry cap is already enough; byte accounting adds complexity |