Skip to content

Reduce memory use when opening and continuing REPL histories - #997

Merged
taras merged 6 commits into
mainfrom
agent/repl-memory-992-20261026
Oct 27, 2026
Merged

taras merged 6 commits into
mainfrom
agent/repl-memory-992-20261026

Conversation

@taras

@taras taras commented Oct 27, 2026 •

Copy link
Copy Markdown
Owner

Why

Large xmd repl histories repeat inherited root values in later close records. Reading the whole file and deep-copying those events for each consumer multiplies memory use, so a long session can exhaust the default V8 heap when it continues.

Refs #992.

What changes

Before:

  • History opening allocates one file-sized string, and stream reads and append notifications deep-copy retained payloads.
  • Histories beyond the engine's single-string ceiling fail with an uncaught string-length error.

After:

  • Opening parses records incrementally, shares repeated large strings within the read, and retains immutable events shared by physical, segment and observer views.
  • Each reader receives its own membership array. Appends snapshot caller-owned data before suspension and publish only after file acknowledgement.
  • Sidekick prefix checks and legacy-to-pair publication read original bytes incrementally. Binding and answered-question summaries prepare only the string prefix needed for display, preserving own JSON members.
  • Histories beyond 536,870,888 decoded UTF-16 code units receive a named refusal and remain unchanged.

How it works

history bytes -> chunked record reader -> validated, sealed events
                                      -> shared stream views -> detached model
append input -> sealed snapshot -> acknowledged file write -> views and observer

The reader owns one reusable byte buffer and a per-read table of repeated large strings. Consumers finish decoding or hashing before the next read. Sidekick copies, validates and hashes the same snapshot while holding the movable writer; it writes owner then ready and redirects before releasing the hold.

Review guide

Start with: packages/cli/tests/repl-retained-history.test.ts (H1-H6).

Then review:

  1. retained-event.ts, journal.ts and entries.ts for caller isolation, immutable sharing and acknowledgement order.
  2. record-file.ts for UTF-8 boundaries, UTF-16 limit accounting and joined file cleanup.
  3. sidekick/history.ts for original-byte identity, held publication and interrupted-pair refusal.
  4. application.ts for bounded summaries; specs/repl-spec.md for the reopening limit.

What must stay true

  • Persistence precedes retained state and observer notification; failed writes publish no success. Existing execution and storage regressions cover this order.
  • Caller mutation cannot change retained events; execution still receives detached mutable JSON. H1 and the entries suites cover sharing, accessors and inherited mutation.
  • Prefix fingerprints and copied history use original bytes. H4 checks reordered records, exact copies and strict-copy refusal with the original intact.
  • A failed publication cannot appear ready. Storage/recovery tests cover writer holds, cancellation and refusal of interrupted pairs.
  • Existing records, full root-close binding maps, entry identity and replay remain readable under the current format. H5 and the history suites preserve fixture expectations and completed-effect replay.

How to verify it

Retained focused receipts at 3b9c563c report:

  • REPL retained-history, execution, entries, model and history: 56 tests / 201 steps passed. H2/H3 catch chunk-boundary and read-limit mistakes; H5 compares saved fixtures with the former reader/projection expectations.
  • Sidekick storage/recovery: 17 / 79 passed; lifecycle/journey/controls/configuration: 36 / 173 passed. These cover held publication, cold reading and fixture consumers.
  • repl-retained-history.test.ts under Node and Bun: 14 passed each. H6 compares summaries with whole-value serialization, including an own __proto__ member.

To run the focused regression:

deno task test packages/cli/tests/repl-retained-history.test.ts
pnpm exec tsx --tsconfig tsconfig.node.json --test --test-concurrency=1 packages/cli/tests/repl-retained-history.test.ts
bun test --timeout=300000 packages/cli/tests/repl-retained-history.test.ts

At final head f2fb3a7a, the five weight/measurement/workflow/discovery/exclusion suites report 19 tests / 87 steps passed, and lint passes. The existing required green check passes for this head in run 40480639095.

Memory evidence is a separate isolated experiment at daeb4eef: a generated 508,523,892-byte history with two-byte strings opens, settles ten ordinary entries, navigates history and exits on one Control-C with the original prefix unchanged. Three default-heap compiled runs peak at 2.34-2.55 GB RSS. The original-base control exhausts its heap on the first new entry. The forced-GC import probe measures retained heap separately; it is not a native peak measurement. Later changes preserve the applicability of these historical results; they are not new measurements of the final binary.

Scope

Included

  • Private REPL event sharing, incremental record and exact-byte reads, and reader-buffer reuse.
  • Safe reopening-limit diagnostics, strict-copy interrupted-publication coverage, bounded summaries and cross-runtime regressions.
  • Current-corpus test weights measured on the CI runner.

Intentionally unchanged

  • Current JSONL/pair formats, complete root-binding handoff, replay admission, secret policy and joined execution ownership.
  • Full-root history growth, per-record serialization/encoding and installed shard counts.

New abstractions

  • retained-event.ts seals caller-owned graphs for the physical stream, segment views and parsed records, so isolation does not require payload cloning per consumer.
  • record-file.ts owns scoped chunked I/O for decoded record reading and original-byte fingerprinting/copying. Its optional bound couples a finite limit to its refusal.
  • Each new abstraction has concrete consumers or a required ownership boundary.
  • No speculative functionality is included.

Generated or mechanical changes

  • test-weights.json is byte-identical to the validated artifact from measurement run 40460391084, attempt 1, measuring parent 3b9c563c on ubuntu-latest. No timing value was hand-edited.
  • Fixture publication calls adopt the reduced empty-pair helper signature.

Risks and limitations

  • Memory still includes distinct values, event/model containers, the active environment and temporary allocations proportional to a large record. The measurements establish neither constant memory nor an irreducible memory floor.
  • Full root closes continue to repeat inherited values on disk. A running history can exceed the reopening limit; further work then starts in a new execution.
  • A refused or cancelled legacy copy leaves an interrupted pair that opening refuses. The original file remains intact; incomplete copied entries may remain unpublished.
  • Measured weights change scheduling predictions, not test inclusion. Sampled predictions remain above 300 seconds; consuming the artifact does not qualify shard counts under the separate floor/five-run procedure.
  • The historical production exit cause remains unknown. Further memory optimization is optional and measurement-gated.

Scope confirmation

  • Every changed file supports the purpose described above.
  • Unrelated cleanup and formatting changes are excluded.
  • Generated or mechanical changes are clearly identified.
  • The description matches the final diff and retained test results.

taras and others added 3 commits October 26, 2026 22:50
A history that binds captured output as root values repeats that output in
every later root close. The retained stream deep-copied every event for every
reader and the file was read as one string, so a 508 MB two-byte history
reached V8's heap limit on its first submission and a history past the
longest string could not be reopened at all (#992).

Retained events are now sealed once into frozen graphs that share primitive
strings, and every reader gets a fresh array of the same events: readAll()
slices, the append observer receives the acknowledged event, and a segment
and its physical stream keep one graph. An append's operand is copied before
the write suspends. Core and the projector keep detaching what they hand to
documents and models, so execution still receives fresh mutable data.

Entry files are read through a chunked reader that splits at the newline
byte and decodes per line, sharing equal large strings across one read. A
history past 536,870,888 UTF-16 code units is refused with a sentence naming
the limit. Sidekick prefix binding, measurement, origin checks and the
single-file-to-pair upgrade fingerprint the file's own bytes incrementally;
the upgrade copies in one strict-UTF-8 pass and reuses the events already
retained. The on-disk records, root-close binding maps and pair layout are
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sealing treated any frozen plain container as safe to share. Freezing keeps
whatever accessors a container was declared with, so a caller's frozen object
with one getter stayed a live reading of the caller's own state inside the
retained history: after the record was written, changing that state changed
what readAll answered while the file kept what it wrote. A container is now
shared only when every own property is a plain value, and is copied otherwise,
which is what turns a live object into the fixed reading a retained event is.
Shared graphs, primitive-string sharing, fresh membership arrays and the order
of persistence before observation are unchanged.

Reading a history allocated a fresh megabyte buffer for every read, so opening
a 500 MB history allocated the whole of it again a megabyte at a time, on top
of reading it. The reader owns one buffer and hands out views of it; every
consumer reads a chunk at once, and the one that keeps bytes copies them.
Across five runs of the same ten-entry journey over a 508 MB history, the
compiled REPL's maximum resident size fell from a mean of 3.40 GB to 2.64 GB.

A summary of a binding no longer writes the whole value out to keep twenty
columns of it: strings are cut to more than any summary can show before the
value is written, which leaves every summary exactly as it was. That bounds a
per-frame allocation proportional to the value; it did not move the measured
peak, because the right-hand pane shows the Sidekick unless a reader selects
the bindings.
Shortening a value before writing its summary built the shorter object by
assigning each member into it. For the key `__proto__` that assignment reaches
`Object.prototype`'s inherited setter rather than creating a member, so on Node
and Bun a binding parsed from `{"__proto__":{"x":1}}` lost it and the row read
`held = {}` where it used to read `held = {"__proto__":{"x":1…`. A history can
hold such a member: `JSON.parse` makes one an own property, Core's parser keeps
it, and the model's `detach()` defines rather than assigns it for this reason.

The shorter object now defines its members the same way, which keeps every own
member and its order. Bounded string preparation is unchanged, and so is every
summary the previous implementation produced.

H6 gains that one parsed value beside its existing cases. With the assignment
restored it fails under Node and Bun with exactly the row the review
reproduced; it passes either way under Deno, which keeps `__proto__` as an own
property on assignment.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 4 redundant comments. Inline suggestions to remove them below.

const recorded = clone(event);
// Sealed before the write suspends, so what the caller does with its own
// object afterwards reaches neither this range nor the physical stream.
// Both keep this one graph: it is frozen, so neither side can edit it.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// Both keep this one graph: it is frozen, so neither side can edit it.

let start = 0;
for (let end = chunk.indexOf(NEWLINE); end >= 0; end = chunk.indexOf(NEWLINE, start)) {
// Not streamed, so a multi-byte sequence the line left unfinished is
// settled here rather than carried into the next line.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// settled here rather than carried into the next line.

function sealResult(result: Result, share: StringSharing | undefined): Result {
if (result.status === "ok") {
// Whether `value` is present at all is kept, because an absent value and an
// undefined one are different records to a reader that asks `in`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// undefined one are different records to a reader that asks `in`.

share: StringSharing | undefined,
): EffectDescription {
// Spread rather than rebuilt, so member order and an own `__proto__` member
// survive exactly as structured cloning kept them.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// survive exactly as structured cloning kept them.

@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

PR #997: Reduce memory copies when continuing REPL history

12 files, +2790 / -1355

Scope

🔴 PR has 4145 lines changed. Split into focused PRs.

🟡 4145 lines changed. PRs under 400 receive more thorough review.

Structural

Oxlint structural signals:

  • no-unused-vars ×16: packages/cli/src/repl/sidekick/repository.ts, packages/cli/tests/fixtures/repl/issue-883/recovery-phase.ts, packages/cli/src/repl/application.ts
  • no-unnecessary-type-assertion ×1: packages/cli/tests/fixtures/repl/issue-883/recovery-phase.ts
  • no-unnecessary-boolean-literal-compare ×1: packages/cli/src/repl/application.ts

Slop

  • packages/cli/src/repl/sidekick/history.ts:1342 (removed)
  • packages/cli/src/repl/entries.ts:292 — // Both keep this one graph: it is frozen, so neither side can edit it.
  • packages/cli/src/repl/record-file.ts:141 — // whole-file read keeps it, so a file that begins with one reads the same.
  • packages/cli/src/repl/record-file.ts:173 — // settled here rather than carried into the next line.
  • packages/cli/src/repl/retained-event.ts:111 — // undefined one are different records to a reader that asks in.

Static Analysis

Oxlint: 96 diagnostics across 5 files (14 rules)
Density: 0.034 violations/added-line

no-shadow (51): packages/cli/tests/fixtures/repl/issue-883/recovery-phase.ts, packages/cli/src/repl/sidekick/history.ts, packages/cli/src/repl/application.ts
no-unused-vars (16): packages/cli/src/repl/sidekick/repository.ts, packages/cli/tests/fixtures/repl/issue-883/recovery-phase.ts, packages/cli/src/repl/application.ts
no-unsafe-type-assertion (5): packages/cli/src/repl/journal.ts, packages/cli/tests/fixtures/repl/issue-883/recovery-phase.ts
unbound-method (4): packages/cli/src/repl/journal.ts, packages/cli/tests/fixtures/repl/issue-883/recovery-phase.ts
consistent-return (4): packages/cli/src/repl/application.ts
consistent-function-scoping (3): packages/cli/src/repl/application.ts
no-floating-promises (3): packages/cli/tests/fixtures/repl/issue-883/recovery-phase.ts
no-duplicate-type-constituents (3): packages/cli/src/repl/application.ts
no-array-sort (2): packages/cli/tests/fixtures/repl/issue-883/recovery-phase.ts, packages/cli/src/repl/application.ts
no-unmodified-loop-condition (1): packages/cli/src/repl/journal.ts
no-unnecessary-type-assertion (1): packages/cli/tests/fixtures/repl/issue-883/recovery-phase.ts
no-useless-default-assignment (1): packages/cli/src/repl/application.ts
no-misused-spread (1): packages/cli/src/repl/application.ts
no-unnecessary-boolean-literal-compare (1): packages/cli/src/repl/application.ts

Correctness

  1. packages/cli/src/repl/application.ts
    PATTERN: no-shadow (3 instances)
    CONCERN: Shadowing of variables in nested scopes, potentially leading to confusion.
    QUESTION: Are these shadowed variables intentional, or do they mask outer scope variables?

  2. packages/cli/src/repl/sidekick/repository.ts
    PATTERN: no-unused-vars (1 instance)
    CONCERN: Unused variable in a test helper or utility function.
    QUESTION: Is this variable required for test coverage or legacy compatibility?

  3. packages/cli/tests/fixtures/repl/issue-883/recovery-phase.ts
    PATTERN: no-shadow (2 instances)
    CONCERN: Shadowing of event and line variables in nested loops.
    QUESTION: Are these shadowed variables causing unintended behavior in test assertions?

  4. packages/cli/src/repl/journal.ts
    PATTERN: no-unsafe-type-assertion (1 instance)
    CONCERN: Type assertion without validation ({} as Type).
    QUESTION: Is the type guard redundant, or is the assertion necessary for runtime behavior?

  5. packages/cli/src/repl/application.ts
    PATTERN: no-unused-vars (1 instance)
    CONCERN: Unused event parameter in a callback.
    QUESTION: Is this a leftover from a previous implementation or a test stub?

  6. packages/cli/src/repl/application.ts
    PATTERN: no-array-sort (1 instance)
    CONCERN: Sorting an array without a comparator function.
    QUESTION: Is the default sort order (lexicographical) intentional for the data structure?

  7. packages/cli/src/repl/application.ts
    PATTERN: no-misused-spread (1 instance)
    CONCERN: Spread operator used on a non-iterable object.
    QUESTION: Is the object intended to be iterable, or is this a runtime error risk?

  8. packages/cli/src/repl/application.ts
    PATTERN: no-unnecessary-type-assertion (1 instance)
    CONCERN: Redundant type assertion on a value already known to be of that type.
    QUESTION: Can this assertion be safely removed without affecting type safety?

  9. packages/cli/src/repl/application.ts
    PATTERN: no-useless-default-assignment (1 instance)
    CONCERN: Default value assigned to a variable that is immediately overwritten.
    QUESTION: Is this a leftover from a previous code state or a redundant optimization?

  10. packages/cli/src/repl/application.ts
    PATTERN: no-unnecessary-boolean-literal-compare (1 instance)
    CONCERN: Comparing a boolean to true or false explicitly.
    QUESTION: Is this comparison necessary, or can the boolean be used directly?

No extraneous code patterns detected.

The fixture walk in the #992 suite sorted its names with `toSorted`, which is
ES2023. `tsconfig.node.json` compiles with the ES2022 library, so the Typecheck
step of every `test-node` shard refused the file — `error TS2550: Property
'toSorted' does not exist on type 'string[]'` — and four shards failed there
before running a test. Deno's own library has the method, which is why
`deno check` and all three runtime suites were green.

The names are sorted in place instead, on the array `filter` has just made
rather than on anything the walk still holds. What the row reads is unchanged.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 6 redundant comments. Inline suggestions to remove them below.

const recorded = clone(event);
// Sealed before the write suspends, so what the caller does with its own
// object afterwards reaches neither this range nor the physical stream.
// Both keep this one graph: it is frozen, so neither side can edit it.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// Both keep this one graph: it is frozen, so neither side can edit it.

let start = 0;
for (let end = chunk.indexOf(NEWLINE); end >= 0; end = chunk.indexOf(NEWLINE, start)) {
// Not streamed, so a multi-byte sequence the line left unfinished is
// settled here rather than carried into the next line.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// settled here rather than carried into the next line.

share: StringSharing | undefined,
): EffectDescription {
// Spread rather than rebuilt, so member order and an own `__proto__` member
// survive exactly as structured cloning kept them.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// survive exactly as structured cloning kept them.

function sealResult(result: Result, share: StringSharing | undefined): Result {
if (result.status === "ok") {
// Whether `value` is present at all is kept, because an absent value and an
// undefined one are different records to a reader that asks `in`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// undefined one are different records to a reader that asks `in`.

const location = yield* establishPair(root, execution);
if (prefix.body.length > 0) {
// One append of the whole snapshot. Every record it holds is already
// terminated, so the copy is byte-for-byte what the origin's digest covers.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// terminated, so the copy is byte-for-byte what the origin's digest covers.

// is what makes the execution refuse to reopen at all.
yield* writer.hold();
// The events this process already retained, shared rather than parsed a
// second time: the writer is held, so they are the records the file holds.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// second time: the writer is held, so they are the records the file holds.

@taras taras left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fable 5.1 first-green review of 4657c4d

Verdict: no blocking finding. Correctness of the two new modules holds under every case I traced. Two non-blocking findings (F1 ordering, F2 minimalism) and one performance note (P1) are posted inline, each with a concrete recommendation that keeps accepted behavior.

What I checked, read-only, on the exact signed head

  • packages/cli/src/repl/record-file.ts: one reused buffer; lines split at the newline byte before decoding; each line decoded with stream: false, so a multi-byte sequence split across a chunk boundary completes in the next line's decode; ignoreBOM: true keeps a per-line decoder from stripping a byte order mark at the head of every line; the limit is counted in UTF-16 code units with newlines, as the old whole-file reader's string length was; the handle is released by an ensure registered before the open and joined behind an in-flight read; an empty file reads as terminated; measureLines hashes to the end of the countth record exactly and keeps counting. Refusal sentences match the previous reader (H2, H3, H5).
  • packages/cli/src/repl/retained-event.ts: containers copied shallowly and frozen, strings shared by value, reusable rejecting accessors and foreign prototypes. An own __proto__ member survives because spread creates an own data property and the later assignment writes to that property rather than the inherited setter. SerializedError is flat (message, name, stack), so the shallow freeze of error is complete.
  • Every readAll() consumer: core wraps through retainEvents, the projector detaches, the session pushes onto its own slice. None writes into a retained event, which a frozen graph would now refuse under strict mode.
  • packages/cli/src/repl/sidekick/history.ts: prefix bindings and origin verification hash the file's own bytes; the upgrade decodes strictly so the appended text re-encodes to the hashed bytes; the writer is held through the handoff with release registered before hold.
  • packages/cli/src/repl/application.ts: cutting strings to forty characters before writing a twenty-column summary cannot move a visible character, since a character writes as at least one character and the ellipsis path is taken either way (H6).

Performance claims. The description's numbers (baseline heap exhaustion on the first new entry over a 508.5 MB history; ten entries at 2.34–2.55 GB peak RSS) are measurements of the build at daeb4ee. The source diff from there to this head changes only how the summary cut defines an own __proto__ member, plus one test, so transferring them is sound. The evidence does not claim constant memory, and P1 says what still scales with the history.

Evidence. The PR description; packages/cli/tests/repl-retained-history.test.ts H1–H6 across Deno, Node and Bun; the green check on this head. I ran no tests, builds or benchmarks myself, and I did not re-run CI.

// The events this process already retained, shared rather than parsed a
// second time: the writer is held, so they are the records the file holds.
const events = yield* writer.readAll();
const location = yield* establishPair(root, execution);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[fable-997-F1] Refused or cancelled upgrades now leave an interrupted pair (non-blocking, correctness/ordering)

establishPair runs before the legacy file has been read. Every refusal copyEntryRecords can raise (unfinished last record, bytes that are not UTF-8 under its strict decoder, a record count other than the retained one) and a cancellation anywhere in the copy now leave v1/<execution>/ holding two empty-or-partial files and no owner record. pairedReplRepository.open then refuses that execution as an interrupted publication and, by design, never falls back to the legacy file. Before this change those three refusals fired in readTextFile / parseEntryRecords before publishPair created anything, so a refused upgrade left the execution openable; cancellation could already strand a pair, but only during the append, and the window now spans the whole read.

Of the three, only the strict-UTF-8 refusal is reachable for a file this process already opened: readRecords decodes leniently (strict unset in journal.ts), so a legacy history with one damaged byte opens, upgrades into a refusal, and cannot be opened afterwards. No row covers it; H4 upgrades valid UTF-8 only.

Recommendation, either of which keeps accepted behavior for every valid history:

  1. Pass strict: true with an undecodable refusal in readRecords as well, so a history that is not UTF-8 refuses at open, before any pair exists, with the sentence the copy already uses. Both readers then agree on what a readable history is.
  2. If lenient opening is wanted, say in the upgradeExecution comment that a refused or cancelled copy leaves an interrupted pair, and add a row that upgrades a non-UTF-8 legacy file and asserts what reopening says.

readonly origin?: RetainedOrigin;
},
): Operation<Publication> {
const location = yield* establishPair(root, execution);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[fable-997-F2] publishPair keeps a copy path no caller uses (non-blocking, minimalism)

After the upgrade moved to copyEntryRecords, every caller of publishPair passes { body: "", events: [] }: pairedReplRepository.create and the two call sites in packages/cli/tests/fixtures/repl/issue-883/recovery-phase.ts. The body and origin members, the append branch, and the doc paragraph on why bytes rather than events are copied now describe a path nothing takes; that rationale is true of copyEntryRecords instead.

Recommendation: reduce publishPair(root, execution) to establishPair followed by acknowledgePair(location, execution, [], undefined), drop the prefix parameter, and move the bytes-not-events paragraph onto copyEntryRecords. The two fixture calls lose their third argument and nothing else changes. Same behavior, roughly twenty fewer lines, one fewer way to create a pair.

Same family, smaller: copyEntryRecords passes an infinite limit, so its exceeded refusal sentence is unreachable. readRecords already bounded the file this process opened, so the option and its sentence can go.

Comment thread packages/cli/src/repl/journal.ts Outdated
yield* forEachLine(
path,
{
limit,

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[fable-997-P1] What the memory evidence establishes, and what still scales with the history (note, no action required)

Established: the reader never holds the file as a string, the one read buffer is reused, and a large string repeated across root closes is held once per read. The description's ten-entry journey (2.34–2.55 GB peak RSS over a 508.5 MB history, baseline out of memory at the first entry) was measured on the daeb4ee build; the source since then changes only the summary's __proto__ handling and a test, so the numbers transfer to this head.

Still proportional to the history: readRecords materializes every event of the file as a sealed graph before anything is projected, and every root Close carries the whole inherited root, which the accepted plan keeps. Opening alone therefore costs heap on the order of the distinct large strings plus one container graph, and each new entry still serializes and UTF-8 encodes its entire close transiently. The description says as much; this is a note so nobody reads the numbers as a constant-memory claim.

One cheap allocation remains in the open path: a freshly parsed event is never frozen, so sealEvent copies every container of it once and the parse output becomes garbage. parseDurableEvent builds that graph from JSON.parse and hands it to nobody else, so sharing strings in place and freezing the parse output would remove that transient copy. Worth doing only if a focused measurement says so: peak RSS of the open-only journey (zero submissions) over the same 508 MB history, three runs before and three after. If the difference sits inside run-to-run spread, leave it.

Integrates the two actionable comments of the first-green review on PR 997.

Every caller of `publishPair` passed an empty prefix once the upgrade moved to
`copyEntryRecords`, so its body, origin, append branch and the paragraph on why
bytes rather than events are copied described a path nothing took. It is now
`establishPair` followed by `acknowledgePair`, that rationale sits on
`copyEntryRecords`, which is what copies bytes, and the three call sites lose
an argument. One way to create a pair, and the copy is the only thing that
copies.

`copyEntryRecords` passed an infinite bound, which made its `exceeded` sentence
unreachable: the file it reads is one `readRecords` has already opened under the
supported limit. A bound now arrives as the units together with the refusal that
names them, and a reader with no bound passes none and carries no sentence for a
refusal that cannot happen.

The upgrade creates the pair before it reads the older file, so a refused or
cancelled copy leaves an interrupted publication that opening refuses, with the
older file still holding everything. `upgradeExecution` now says so, and names
the one refusal a history this process has already opened can reach: the copy
decodes strictly where `readRecords` decodes leniently. A row covers it — a
legacy history with a byte that begins no UTF-8 sequence opens as one
replacement character, refuses its upgrade by name, keeps every byte, and
reopens as an interrupted publication.

Lenient opening is unchanged: a history that is not UTF-8 still opens, which is
the behavior the review's other option would have changed.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 5 redundant comments. Inline suggestions to remove them below.

// Defined rather than assigned, as `detach()` defines the model's own
// members: `shorter[key] = …` for the key `__proto__` reaches
// `Object.prototype`'s inherited setter instead of creating a member, and
// a value parsed from `{"__proto__":{…}}` would read as `{}`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// a value parsed from `{"__proto__":{…}}` would read as `{}`.

const parsed = parseDurableEvent(line);
if (!parsed.ok) {
// The record's own text stays out of this: a history holds whatever a
// person's document held.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// person's document held.

let start = 0;
for (let end = chunk.indexOf(NEWLINE); end >= 0; end = chunk.indexOf(NEWLINE, start)) {
// Not streamed, so a multi-byte sequence the line left unfinished is
// settled here rather than carried into the next line.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// settled here rather than carried into the next line.

function sealResult(result: Result, share: StringSharing | undefined): Result {
if (result.status === "ok") {
// Whether `value` is present at all is kept, because an absent value and an
// undefined one are different records to a reader that asks `in`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// undefined one are different records to a reader that asks `in`.

// is what makes the execution refuse to reopen at all.
yield* writer.hold();
// The events this process already retained, shared rather than parsed a
// second time: the writer is held, so they are the records the file holds.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// second time: the writer is held, so they are the records the file holds.

The tracked file was measured at a4907b4, before the retained-repository
suites moved from packages/git to packages/cli and before the files this
branch adds. Every file it did not name was charged the heaviest weight the
current corpus records, so the partition was predicting roughly twice the work
each shard actually holds: deno 812,492 ms against 331,019 ms for shard 1 of
15, node 607,935 ms against 310,445 ms for shard 7 of 10, bun 743,238 ms
against 341,192 ms for shard 1 of 5.

These are the runner's own bytes, byte for byte, from the Measure test weights
run dispatched against this branch: run 40460391084, attempt 1, commit
3b9c563, ubuntu-latest, deno 2.9.5, node
v22.23.3, bun 1.4.0, SHA-256
f7818ad0168a2a06944ae7d49e28d26e76ff59a312f34f31adaf7852edc56b16. It names 505
files for deno, 396 for node and 395 for bun, this branch's new suite among
them. No millisecond was edited by hand, and the provenance the file carries is
the run that produced it.

Shard counts are unchanged: what each one is held to is the measured procedure
in AGENTS.md, not this file.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 4 redundant comments. Inline suggestions to remove them below.

const recorded = clone(event);
// Sealed before the write suspends, so what the caller does with its own
// object afterwards reaches neither this range nor the physical stream.
// Both keep this one graph: it is frozen, so neither side can edit it.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// Both keep this one graph: it is frozen, so neither side can edit it.

return scoped(function* (): Operation<number> {
const chunks = yield* useFileChunks(path);
// One decoder for the whole file. A leading byte order mark is kept, as a
// whole-file read keeps it, so a file that begins with one reads the same.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// whole-file read keeps it, so a file that begins with one reads the same.

let start = 0;
for (let end = chunk.indexOf(NEWLINE); end >= 0; end = chunk.indexOf(NEWLINE, start)) {
// Not streamed, so a multi-byte sequence the line left unfinished is
// settled here rather than carried into the next line.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// settled here rather than carried into the next line.

function sealResult(result: Result, share: StringSharing | undefined): Result {
if (result.status === "ok") {
// Whether `value` is present at all is kept, because an absent value and an
// undefined one are different records to a reader that asks `in`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// undefined one are different records to a reader that asks `in`.

@taras taras changed the title Reduce memory copies when continuing REPL history Reduce memory use when opening and continuing REPL histories Oct 27, 2026
@taras
taras marked this pull request as ready for review October 27, 2026 09:39
@taras
taras merged commit 267cc55 into main Oct 27, 2026
44 checks passed
@taras
taras deleted the agent/repl-memory-992-20261026 branch October 27, 2026 09:39
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