Skip to content

session: an opened chat lets go of the journal lines it replayed - #2045

Merged
AbirAbbas merged 3 commits into
app/v3from
perf/engine-long-chat-memory
Nov 2, 2026
Merged

AbirAbbas merged 3 commits into
app/v3from
perf/engine-long-chat-memory

Conversation

@AbirAbbas

@AbirAbbas AbirAbbas commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator

What and why

On a Mac, opening a 1,376-entry chat made the engine hold ~730 MB: codeaf at 792-796 MB, against 66 MB idle, and still 778 MB after two idle minutes. An A/B on the same home showed it is the open itself: 73 MB with the chat listed but not opened, 766-801 MB once it is opened.

Attribution first. A symbolized engine, driven over stdio with no UI, on a 1,371-entry chat made through the real engine (480 turns x 400 tokens, a tool every 10th turn). Its journal is 157 MB: 211k lines, but only 799 unique message contents. Every compaction (315, each "folded 1") re-journals the whole kept window. A forced GC plus debug.FreeOSMemory at 15 s splits the footprint:

call footprint after GC + FreeOSMemory live heap after GC
agents.entries alone 337 MB 60 MB 7 MB
agents.open alone 358 MB 271 MB 154 MB
both (what the desktop sends) 671 MB 321 MB 154 MB

So the record cache's per-callback clone is not the holder: agents.entries keeps 7 MB. Two things are:

  1. The opened chat kept every replayed line's words.
    • After GC, ~148 MB sits under newAgent -> openSessionFile -> readJournal: 121 MB of decoded strings, plus the stamp index's 13.6 MB, plus 13.5 MB of replayed messages.
    • The stamp index (entrytime.go) is keyed by &message.Content[0]. The replay remembers a stamp for every message line, including the hundreds of thousands a later compaction marker throws away, so each key keeps its message's words alive.
    • The presentation index has the same shape and is already pruned at the open and at every live boundary that lets messages go (retainPresentationLocked). The stamps never were. So a live chat that compacts every turn also kept every copy it re-journalled.
  2. A large read's garbage stayed resident.
    • Each full read of the 157 MB journal allocates ~300 MB that is garbage afterwards.
    • The idle engine runs no collection to shrink its heap goal, and the scavenger keeps memory under the goal. So the footprint stayed at the read's peak for minutes, and indefinitely while (1) kept the goal high.

What changed (internal/session only; no wire, record or protocol change)

  • stampIndex.retain, mirroring presentationIndex.retain, through sessionFile.retainStamps. The agent prunes its journal stamps with its presentation marks, to the same messages, at the open (after the earlier region is shaped) and in retainPresentationLocked: after a compaction pass, a rewind, a turn fold.
  • A rewind's undo carries the tail's instants beside its presentation marks and puts them back, because the cut now prunes them. A steer, which the undo re-journals without a fresh stamp, keeps its original time; ordinary messages are re-stamped by their write, as before.
  • releaseAfterLargeRead: after a journal too large for the record cache to keep (recordCacheFileBytes, 16 MiB) has been read and let go of, run a runtime.GC on a background goroutine.
    • It runs when the record cache's unkept reading is released, and at the end of a resumed agent's open.
    • At most one runs per 5 s (releaseCooldown), with one trailing run, and the caller never waits.
    • A collection lowers the heap goal, and the runtime's own paced scavenger then returns the memory. That gives 82 MB 6.5 s after the open, against 73 MB at 3.8 s with debug.FreeOSMemory's forced scavenge, which round 1 of the review asked to drop.
  • Every stamp reader was audited (the list is in the Codex implementation report). None can ask for a message the prune dropped.

Contract tests

  • TestResumeRetainsOnlyLiveStamps: a resumed agent's stamp index holds stamps only for messages it holds.
  • TestResumeHeapDoesNotScaleWithDiscardedWindows: post-GC heap growth from opening a 60-pass journal is no larger than a 6-pass one. Branch: 442,600 B for 6 passes, 430,912 B for 60. Base: 1.09 MB vs 7.44 MB, i.e. the 54 extra windows' 6.3 MB stays retained.
  • TestLiveCompactionAndRewindRetainOnlyLiveStamps, TestStampRetainKeepsQueueTimes, TestResumeStampPrunePreservesShapedEntries: the shaped entries a person sees (ids, kinds, text, times) are unchanged.
  • readrelease_test.go (fake clock): the threshold; one collection for one read; a burst gives one plus at most one trailing; sustained requests (one per 0.9 s for 30 s) give at most ceil(30/5)+1; callers never block during a collection; the release follows the record callback and the resume prune.
  • rewind_test.go: after RewindKeep and UndoRewind on a resumed journal, a steer keeps its journal instant (it lost it before the undo fix).

Fail-before: with the two prune calls disabled (today's behaviour), 4 of the new tests fail. For example, "1312 stamps for 33 live identities", and "discarded windows retained 6345392 extra bytes; limit 1492992".

Byte-identical pages. Builds of the merge base and of this branch, each on its own copy of the same home:

  • The 1,371-entry chat: all 7 pages back at limit 200; limits 1, 0 and 5000; before the first, second, middle and last entry and an unknown id; the first page again after agents.open. All 17 answers are byte-identical.
  • A 180-entry chat with a tool every other turn: all 11 answers byte-identical.
  • The only difference anywhere is agents.open's per-open random model_identity.epoch.

Gates

  • The new tests and their neighbours (recordcache, presentation, entrytime, resume, rewind, turnfold, steer): 85 tests, pass x2, and x2 under -race.
  • vet for darwin, GOOS=linux and GOOS=windows; gofmt clean.
  • Code-law tests (lock-defer, commentlaw): no new findings against the base.
  • Rebased onto app/v3 7e40a79 (chat: a chat another process holds gets no sync drive from here #2046 touches only cmd/codeaf's chat sync drives).
  • Importer packages on macOS, branch vs base: PENDING.
  • CI at d6a3826 against the 7e40a79 base runs: PENDING.

Codex review

  • Round 1: two majors, no blockers. (1) A rewind undo lost a steer's time. (2) The forced collection was unbounded under sustained reads such as paging. Both are fixed in d6a3826 (was 09edb35 before the rebase), along with its test gaps: stamp completeness, the turn-fold boundary, a min-of-3 heap test.
  • Round 2: no findings at any severity. It checked that undo times match pre-PR behaviour for every message kind, found no other restoration path with the same hole, and found the cooldown free of lost runs, double scheduling, deadlock or leaks.

Manual verification

Pending: the whole app on the s7 home. The byte-identity check above was run against builds of the merge base and this branch.

Numbers

Engine only, the same 1,371-entry chat, agents.entries + agents.open at once as the desktop sends them:

base this PR
footprint 4-30 s after the open 630-655 MB 73-81 MB
footprint at 200 s 719 MB 89 MB
live heap after GC 154 MB 11 MB
peak footprint during the two concurrent reads ~650 MB ~650 MB (unchanged)
first page round trip 0.82-1.4 s 0.89-1.1 s (unchanged; parse-bound)

Whole-app s7 and longchat-ab: pending.

Not fixed here, reported

  • The first page still costs ~0.9-1.1 s, and every older page re-reads the whole journal (each ~0.9 s), because a 157 MB journal is too large for the record cache to keep.
    • Paging on the wire would not help: the whole journal has to be parsed to place any page.
    • The root cause is journal write amplification. Each compaction re-journals the whole kept window (315 passes x 362-936 messages), so a chat with ~2 MB of words is a 157 MB journal. That is a session-journal format change and belongs in its own PR.
  • The ~650 MB transient peak while agents.entries and agents.open parse that journal at the same time is the same root cause. A shared parse between the two would halve it.

🤖 Generated with Claude Code

AbirAbbas added a commit that referenced this pull request Oct 29, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@AbirAbbas
AbirAbbas force-pushed the perf/engine-long-chat-memory branch from 09edb35 to d6a3826 Compare October 29, 2026 23:18
AbirAbbas added a commit that referenced this pull request Oct 29, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@AbirAbbas

Copy link
Copy Markdown
Collaborator Author

Closing unmerged: the Mac push stopped early to save usage. What this fixes and what is left is tracked in #2060. The branch perf/engine-long-chat-memory is kept.

@AbirAbbas AbirAbbas closed this Oct 29, 2026
@AbirAbbas AbirAbbas reopened this Nov 2, 2026
AbirAbbas added a commit that referenced this pull request Nov 2, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@AbirAbbas
AbirAbbas force-pushed the perf/engine-long-chat-memory branch from d6a3826 to e7586ae Compare November 2, 2026 13:37
AbirAbbas and others added 3 commits November 2, 2026 10:46
The stamp index is keyed by a pointer into each message's content, and a
replay remembers a stamp for every message line it reads, including the
hundreds of thousands a later compaction marker throws away. So opening a
1,371-entry chat (a 157 MB journal of re-recorded windows) kept 154 MB of
their words alive for as long as the chat was open, and a live chat that
compacts kept every copy it re-journalled. The stamps are now pruned with
the presentation marks, at the open and at every boundary that lets
messages go.

And a journal too large for the record cache to keep is read into ~300 MB
that is garbage once the reading is done, but an idle engine runs no
collection to shrink its heap goal, so the footprint stayed at the read's
peak for minutes. After such a reading is let go of, one coalesced
background debug.FreeOSMemory returns it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…read is bounded

A rewind's cut now prunes stamps, so the undo carries the tail's instants
beside its presentation marks and puts them back. A steer, which the undo
re-journals without a fresh stamp, keeps its original time again; ordinary
messages are re-stamped by their write, as before.

The release after a large read is runtime.GC rather than FreeOSMemory: a
collection lowers the heap goal and the runtime's own paced scavenger
returns the memory (82 MB 6.5 s after opening the 1,371-entry chat, against
73 MB at 3.8 s with the forced scavenge). It runs at most once per five
seconds, with one trailing run, so paging back through a long journal no
longer asks for a collection per page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@AbirAbbas
AbirAbbas force-pushed the perf/engine-long-chat-memory branch from e7586ae to 35da513 Compare November 2, 2026 15:46
@AbirAbbas
AbirAbbas marked this pull request as ready for review November 2, 2026 16:41
@AbirAbbas
AbirAbbas merged commit 86ebdda into app/v3 Nov 2, 2026
12 of 20 checks passed
@AbirAbbas
AbirAbbas deleted the perf/engine-long-chat-memory branch November 2, 2026 16:41
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.

1 participant