fix(ui): cap live agent-run transcript buffers to bound tab memory (#9569)
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents stream their run output live into the web UI, viewed per-run
in the `AgentDetail` transcript viewer
> - Browser tabs holding these views were sitting at 8–16 GB of memory
footprint while their JS heap stayed at ~256 MB — a 60×+ gap, meaning
the cost is in retained DOM / render objects, not JS objects
> - The `LogViewer` in `AgentDetail.tsx` kept every streamed
stdout/stderr line and structured event in unbounded React state and
rendered each into a rich DOM block (the default "nice" mode has no
virtualization), so a run streaming for hours grew an unbounded live DOM
tree
> - The sibling `useLiveRunTranscripts` hook (used by `IssueChatThread`)
already bounds its buffers and virtualizes; `LogViewer` bypassed it and
managed its own uncapped state — that inconsistency is the bug
> - This pull request caps the live buffers and bounds the live DOM
render, aligning `LogViewer` with the already-bounded path
> - The benefit is that long-lived streaming tabs no longer grow without
bound, cutting multi-GB tabs back to a bounded footprint
## 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?**
Chrome's Task Manager showed multiple long-lived Paperclip tabs
(agent-run / task views) each consuming 8–16 GB of memory footprint,
while each tab's JS heap stayed at only ~150–256 MB. Memory grew
monotonically the longer a run streamed.
**Expected behavior**
A tab viewing a live agent run should hold a bounded amount of memory
regardless of how long the run streams.
**Steps to reproduce**
Open an agent run with a long-running / high-volume stream in
`AgentDetail`, leave the tab open while output streams for an extended
period, and watch the tab's memory footprint climb without bound in
Chrome's Task Manager.
**Paperclip version or commit**
`3991a19a` (branch `fix/agent-run-transcript-memory`, off `master`).
**Deployment mode**
Local dev (`pnpm dev`), web UI. Not adapter-specific — core UI bug in
the shared transcript viewer.
## What Changed
- Add `ui/src/lib/live-log-buffer.ts`: a pure `appendCapped(prev,
additions, max)` helper plus caps `MAX_LIVE_LOG_LINES=5000`,
`MAX_LIVE_EVENTS=2000`, and `LIVE_TRANSCRIPT_RENDER_LIMIT=1500`, with
rationale documented in the module.
- Add `ui/src/lib/live-log-buffer.test.ts`: 6 unit tests (append,
trim-to-cap, oversized batch, exact-cap, no-mutation, referential
bail-out).
- `ui/src/pages/AgentDetail.tsx` (`LogViewer`): route all four
live-append sites (WebSocket log / progress / event, plus the poll
fallback) through `appendCapped`, and pass
`limit={LIVE_TRANSCRIPT_RENDER_LIMIT}` to `RunTranscriptView` for live
runs so the "nice" view mounts only the most recent blocks.
- The terminated-run "Load more log" pagination is deliberately left
**uncapped** (guarded by `isLive`), so no historical output is lost —
older output remains on the server and reachable there.
## Verification
- `vitest run src/lib/live-log-buffer.test.ts` → 6/6 pass.
- Existing suites `RunTranscriptView.test.tsx`,
`AgentDetail.instructions.test.tsx`, `useLiveRunTranscripts.test.tsx` →
22/22 pass.
- `tsc -b` (UI) → clean.
- Manual/behavioral: live runs tail the last ~1500 blocks; terminated
runs still render full history via "Load more log". Follow-up planned to
profile before/after with the Chrome DevTools MCP.
## Risks
Low risk. Changes only bound **in-memory state for live runs**; the
terminated-run paginated path is untouched (still uncapped, guarded by
`isLive`). No API, schema, or persistence changes. Worst case for a live
run is that only the most recent 5000 lines / 1500 rendered blocks are
visible in the tab — which is the intended "tail" behavior, and full
history remains on the server.
## 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 execution, file editing),
sub-agent fan-out for the codebase memory sweep, and the Chrome DevTools
MCP for the diagnosis phase.
## 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 (the ROADMAP "Memory" item is about company/agent
knowledge, unrelated to this browser-tab memory fix)
- [x] I have searched GitHub for duplicate or related PRs and linked
them above (none found among open PRs)
- [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 affected; rationale is documented inline in
`live-log-buffer.ts`)
- [ ] All Paperclip CI gates are green (in progress at time of writing)
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
(the only open P2 was this missing template, which this update resolves;
awaiting re-review)
- [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>
This commit is contained in:
parent
2617bee422
commit
f49a3f9924
|
|
@ -0,0 +1,35 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { appendCapped } from "./live-log-buffer";
|
||||
|
||||
describe("appendCapped", () => {
|
||||
it("returns the same array reference when there is nothing to add", () => {
|
||||
const prev = [1, 2, 3];
|
||||
expect(appendCapped(prev, [], 10)).toBe(prev);
|
||||
});
|
||||
|
||||
it("appends without trimming while under the cap", () => {
|
||||
expect(appendCapped([1, 2], [3, 4], 10)).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
it("keeps exactly the newest `max` entries when the result overflows", () => {
|
||||
expect(appendCapped([1, 2, 3], [4, 5], 4)).toEqual([2, 3, 4, 5]);
|
||||
});
|
||||
|
||||
it("trims correctly when a single append batch is larger than the cap", () => {
|
||||
expect(appendCapped([1], [2, 3, 4, 5, 6], 3)).toEqual([4, 5, 6]);
|
||||
});
|
||||
|
||||
it("returns exactly `max` entries when the result lands on the cap", () => {
|
||||
const result = appendCapped([1, 2], [3], 3);
|
||||
expect(result).toEqual([1, 2, 3]);
|
||||
expect(result.length).toBe(3);
|
||||
});
|
||||
|
||||
it("does not mutate its inputs", () => {
|
||||
const prev = [1, 2, 3];
|
||||
const additions = [4, 5];
|
||||
appendCapped(prev, additions, 3);
|
||||
expect(prev).toEqual([1, 2, 3]);
|
||||
expect(additions).toEqual([4, 5]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
/**
|
||||
* Live agent-run transcript viewers stream stdout/stderr and structured events
|
||||
* for the entire lifetime of a run — which can be hours. The viewer keeps every
|
||||
* streamed line/event in React state and renders each into a rich DOM block, so
|
||||
* an unbounded buffer becomes an unbounded live DOM tree: the tab's memory
|
||||
* footprint climbs into the multi-GB range even while the JS heap stays small
|
||||
* (the cost is in retained render objects and GPU layers, not JS objects).
|
||||
*
|
||||
* These caps bound the *live* tail retained in memory. Older output is not lost:
|
||||
* it stays in the persisted run log on the server and remains reachable for
|
||||
* terminated runs through the "Load more log" pagination, which is not subject
|
||||
* to these caps.
|
||||
*/
|
||||
export const MAX_LIVE_LOG_LINES = 5_000;
|
||||
export const MAX_LIVE_EVENTS = 2_000;
|
||||
|
||||
/**
|
||||
* Number of transcript blocks the live "nice" view mounts. Terminated runs
|
||||
* render in full so explicitly paginated history stays visible; live runs tail
|
||||
* the stream, so mounting only the most recent blocks keeps the DOM (and its
|
||||
* off-heap render memory) bounded no matter how long the run streams.
|
||||
*/
|
||||
export const LIVE_TRANSCRIPT_RENDER_LIMIT = 1_500;
|
||||
|
||||
/**
|
||||
* Append `additions` to `prev`, dropping the oldest entries so the result never
|
||||
* exceeds `max`. Returns `prev` unchanged when there is nothing to add, so React
|
||||
* state setters can bail out of a re-render, and never mutates its inputs.
|
||||
*/
|
||||
export function appendCapped<T>(prev: T[], additions: readonly T[], max: number): T[] {
|
||||
if (additions.length === 0) return prev;
|
||||
const next = [...prev, ...additions];
|
||||
return next.length > max ? next.slice(next.length - max) : next;
|
||||
}
|
||||
|
|
@ -87,6 +87,12 @@ import { TooltipProvider } from "@/components/ui/tooltip";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { AgentIcon, AgentIconPicker } from "../components/AgentIconPicker";
|
||||
import { RunTranscriptView, type TranscriptMode } from "../components/transcript/RunTranscriptView";
|
||||
import {
|
||||
appendCapped,
|
||||
LIVE_TRANSCRIPT_RENDER_LIMIT,
|
||||
MAX_LIVE_EVENTS,
|
||||
MAX_LIVE_LOG_LINES,
|
||||
} from "../lib/live-log-buffer";
|
||||
import {
|
||||
isUuidLike,
|
||||
type Agent,
|
||||
|
|
@ -3492,7 +3498,11 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
|||
}
|
||||
|
||||
if (parsed.length > 0) {
|
||||
setLogLines((prev) => [...prev, ...parsed]);
|
||||
// Live runs stream forever, so cap the retained tail. Terminated runs are
|
||||
// paginated by the user via "Load more log" and keep their full history.
|
||||
setLogLines((prev) =>
|
||||
isLive ? appendCapped(prev, parsed, MAX_LIVE_LOG_LINES) : [...prev, ...parsed],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3661,7 +3671,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
|||
try {
|
||||
const newEvents = await heartbeatsApi.events(run.id, maxSeq, 100);
|
||||
if (newEvents.length > 0) {
|
||||
setEvents((prev) => [...prev, ...newEvents]);
|
||||
setEvents((prev) => appendCapped(prev, newEvents, MAX_LIVE_EVENTS));
|
||||
}
|
||||
} catch {
|
||||
// ignore polling errors
|
||||
|
|
@ -3738,7 +3748,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
|||
const streamRaw = asNonEmptyString(payload.stream);
|
||||
const stream = streamRaw === "stderr" || streamRaw === "system" ? streamRaw : "stdout";
|
||||
const ts = asNonEmptyString((payload as Record<string, unknown>).ts) ?? event.createdAt;
|
||||
setLogLines((prev) => [...prev, { ts, stream, chunk }]);
|
||||
setLogLines((prev) => appendCapped(prev, [{ ts, stream, chunk }], MAX_LIVE_LOG_LINES));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -3748,7 +3758,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
|||
const key = heartbeatProgressLogLineKey(line);
|
||||
if (seenProgressLogLineKeysRef.current.has(key)) return;
|
||||
seenProgressLogLineKeysRef.current.add(key);
|
||||
setLogLines((prev) => [...prev, line]);
|
||||
setLogLines((prev) => appendCapped(prev, [line], MAX_LIVE_LOG_LINES));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -3785,7 +3795,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
|||
|
||||
setEvents((prev) => {
|
||||
if (prev.some((existing) => existing.seq === seq)) return prev;
|
||||
return [...prev, liveEvent];
|
||||
return appendCapped(prev, [liveEvent], MAX_LIVE_EVENTS);
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -3928,6 +3938,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
|||
entries={transcript}
|
||||
mode={transcriptMode}
|
||||
streaming={isLive}
|
||||
limit={isLive ? LIVE_TRANSCRIPT_RENDER_LIMIT : undefined}
|
||||
emptyMessage={run.logRef ? "Waiting for transcript..." : "No persisted transcript for this run."}
|
||||
/>
|
||||
{hasMoreLog && (
|
||||
|
|
|
|||
Loading…
Reference in New Issue