Skip to content

feat(history): file-backed conversation history streamed to providers (Fixes #854) - #3852

Open
acoliver wants to merge 429 commits into
mainfrom
issue854-journal
Open

acoliver wants to merge 429 commits into
mainfrom
issue854-journal

Conversation

@acoliver

@acoliver acoliver commented Oct 11, 2026 •

Copy link
Copy Markdown
Collaborator

TLDR

Conversation history now lives in an on-disk journal instead of in memory. HistoryService is a facade over that journal, and subagents get their own journal files. Long sessions no longer keep a context-sized copy of history in the heap between turns.

OpenAI chat, OpenAI Responses (both the openai provider route and the openai-responses provider) and Anthropic build each request body from journal rows, one row at a time, into a request-scoped spool on disk. The spool is uploaded with Content-Length equal to its sealed size. No whole-history array or whole request object is built on the send, estimate, dump or retry paths for these providers. Vercel and the Gemini plugin still build an array per send; they are declared exceptions (#3869).

Request bytes and token counts match main for the same history.

Part of #854. The remaining #854 work is tracked under #3868.

Dive Deeper

What changed

  • History at rest. Membership, pending tickets, the provider normalization snapshot, density folds and the resume resolver are disk-backed. In-memory state between turns is scalars plus state bounded by the number of mutation events.
  • Sends. The array send route and its enforcement are deleted. Each provider prepares from requestRows. Stored media references are resolved one row at a time and released at call cleanup.
    • OpenAI chat uses a streaming tool-id mapper and a row-at-a-time body builder. The continuation request after tool calls is a second spool.
    • Responses writes the disk text route for every call. Calls that arrive without rows are staged on scratch disk first. The old progressive fill() body and LazyBoundedJsonBody are deleted (Responses progressive input materializes the whole history when a tool row is present #3858).
    • Anthropic fills a disk-backed message table row by row, applies the finishing passes and cache markers on the table, and emits it into the spool. Image-dimension recovery rebuilds the spool under a smaller image budget instead of keeping a body copy between attempts.
    • Estimates and request dumps for all three providers read the same segment files as the send.
  • Durability. A general history replacement (restore, import-driven replacement, compensation, profile adoption) is a replace_begin / rows / replace_commit transaction. Every journal reader, including the CLI scrollback pager, treats an incomplete transaction as absent, so a crash during a replacement resumes with the previous history. Single-record shapes (a suffix cut, a clear, a compression) are one record.
  • Shared-history recorders. Journal records carry their origin. Rewinds and inserts are translated into each origin's mirror, and a resumed recorder keeps its origin filter (Shared-history recorder fan-out: unowned rewinds reach every mirror and can over-remove; resumed recorder becomes unfiltered primary #3865).
  • Logging. Conversation logging never fails a completed turn. A write is abandoned only when it stops making progress, not after a fixed 5 s (Request-artifact telemetry: fixed 5 s export acknowledgement fails slow-but-healthy writes and leaves abandoned writes running #3866).
  • Disposal. Journal disposal is awaited and recorder or scratch cleanup failures are raised (HistoryJournalStore.dispose() is fire-and-forget: recorder dispose errors are swallowed and scratch is removed asynchronously #3867). Scratch roots left by dead processes are swept when a process creates its first root.

Fixes to regressions found in review

  • Concurrent o200k counts shared one global regex and miscounted. Each counter now has its own.
  • A replace transaction could interleave with another record. It is now guarded. Subscribing a recorder now waits for the previous detach.
  • A resume that failed after publishing left no recording. The previous recording is restored.
  • A repeated resume re-recorded unchanged rows. It no longer does.
  • A resumed transcript replaced rows that arrived while it loaded. It is now prepended.
  • A failing BeforeModel hook aborted the request. It is logged and the request goes out unmodified, as on main.
  • Body logging blocked requests when the exporter could not carry request artifacts. It is skipped for those exporters.
  • /restore left project files changed when the history commit failed. They are rolled back.
  • Last-resort tool-response truncation re-projected the history after every replacement. It now decides all replacements in one pass: 722 s to 84 ms at 1,024 rows (Last-resort tool-response truncation is O(candidates x rows) on the journal-backed send path #3857).
  • getStatistics, getContextRange and the last-content reads rescanned the journal from byte 0. They now read incrementally: about 1 s and 43 MB per call to 0.04 ms and 4 KB at 8,192 rows.
  • Attaching a recorder to a journal with a semantic purge parsed the purge record once per row. It is parsed once.

Defects found while getting CI green

  • Mirror durability. A history mutation resolved before the per-origin recorder mirrors had written what they were handed, so restoreTurns could return while the rewind was still in memory. Mutations now settle the fan-out after their publication scope closes (674e522). The new durability test fails 3 of 3 without the fix.
  • Per-turn cost that grew with the journal. Every mutation re-folded the whole journal from byte 0 to capture row origins for rollback. CPU per turn in the R3 workload rose from 112 ms to 257 ms over 500 turns. The origins now come from the fold the mutation already runs, which brings it back to about 105 ms, against 95 ms before origin capture existed (a0ba9c5).
  • Media lock cycles per row. Resolving stored media on the streamed send path, and admitting deferred history, took one store reservation and release cycle per row. A request now reserves each content id once, so lock work scales with distinct images (7061b52).
  • Request staging. A call aborted before it started kept its prepared OpenAI projection (212de1e). The provider normalization stage log kept its 64 KiB append buffer after it switched to reading (9a52af9).
  • Scratch cleanup. One CI run failed with EFAULT from Bun's recursive rm of a resolver scratch directory. The cause was not reproduced. Resolver directories, which hold exactly one file, are now removed with unlink and rmdir, so a recurrence reports the real errno (3353ad8). Other scratch owners still use recursive rm.

Memory proof

packages/providers/src/__tests__/sendPeak.*.test.ts sends a 1 MiB and an 8 MiB body (1.4 and 11.6 MiB tool-heavy) on every route. It requires that:

  • the bytes equal a pre-change oracle;
  • Content-Length equals the bytes sent;
  • heap growth and arrayBuffer growth between the two sizes stay under 1 MiB;
  • the Theil-Sen trend of retained heap over repeated sends stays under 1 MiB.

Trap variants hold the whole history (array, estimate, fill) or keep it alive across sends (retain), and must break the bound. In the final run every route was inside the bound, with peak growth between -0.92 and +0.50 MB. Every trap broke its bound, by 12.5x to 76.6x.

packages/agents/src/core/__tests__/journal-retained-growth*.test.ts runs a real ChatSession with compression over about 850 requests. It requires settled heap to stay under 1 MiB at six checkpoints, with fitted growth over the late checkpoints under 262,144 B. A retaining trap and a release trap must fail. Locally the normal run takes 67 to 80 s and the traps 81 and 114 s.

Several WeakRef checks require that no released row survives. JavaScriptCore scans the stack conservatively, so a stale slot can keep one dead row alive for a collection or two, and those checks failed now and then on Linux CI with a count of 1. They now count a row only if it survives several collections, each after an event-loop turn (retainedAfterSettling, and a two-consecutive-checks rule in the fan-out pump bound). The bounds stay at 0, and each converted test has a trap that holds a row and must report it.

Speed

Warm send under the context limit, real runtime wiring, local HTTP provider, median of 5, measured at 0d1051f. The numbers are CPU ms, branch against main, because the machine was heavily loaded and CPU time is the fairer comparison:

Provider 1,000 rows 4,000 rows 8,192 rows
OpenAI chat 323 / 180 (1.8x) 1,406 / 922 (1.5x) 3,924 / 3,192 (1.2x)
Responses 475 / 228 (2.1x) 1,763 / 1,042 (1.7x) 4,161 / 3,865 (1.1x)
Anthropic 469 / 222 (2.1x) 1,831 / 1,001 (1.8x) 5,175 / 3,215 (1.6x)

At 1,000 rows the branch still adds about 150 to 250 ms per send, because each send normalizes every surviving row. #3849 tracks making normalization incremental across sends. Prompt estimation at 167k tokens takes about 250 ms (main 154 ms) with identical counts.

Behaviour changes reviewers should know about

  • OpenAI chat, Responses and Anthropic uploads send Content-Length again. They briefly went out chunked earlier on this branch.
  • An abort during a send surfaces the user's AbortError instead of the provider's "terminated".
  • /set retries 0 on Responses providers sent nothing and returned an empty stream, on main as well. It now makes one attempt. A 200 response with no body is an error.
  • Model hooks (BeforeModel/AfterModel) run on the disk snapshot path and emit hook_call telemetry from there, with stdout/stderr capped at 4000 characters. MissingSnapshotHookCallbackError is raised for HookSystem subclass overrides, which are not a supported extension point.
  • With request logging in strict pre-send mode, a send waits for the request artifact to be acknowledged. Main logged fail-open.
  • Density changes are recorded as density_mutation records of at most 64 rows or 64 KiB each.
  • SessionRecordingService uses backpressure instead of throwing at the queue limit.
  • The per-turn system prompt is read at send time. The race it fixes also exists on main.
  • The file-driven scrollback pager (ui.scrollbackPagerEnabled) ships defaulted off. With it off, the transcript uses the fallback ledger, which keeps main's item and byte limits. Unified scrollback to the start of history: one disk-paged surface with the live turn, on by default #3856 tracks the unified scrollback.
  • A removed provider-normalization scratch directory is detected at the next batch read instead of the next row, with a clearer error. Rows already read from disk are served normally.
  • CI partitions changed: agents run in 3 shards and scripts in 4, balanced by measured duration.

Decisions for the owner

  1. PR link. The body says "Part of Cache and load conversation history and highlight what is in context #854", because the unified scrollback (Unified scrollback to the start of history: one disk-paged surface with the live turn, on by default #3856) and the other Journal-backed history follow-ups (#854): remaining work after #3852 #3868 items are deferred. Change it to "Fixes Cache and load conversation history and highlight what is in context #854" if you want Cache and load conversation history and highlight what is in context #854 closed on merge.
  2. Strict pre-send logging. On main, request logging is fail-open. Here, strict mode waits for the artifact acknowledgement before sending. Keep strict, or make it fail-open.
  3. retries. Main treats retries=N as N total attempts, not N retries, and the branch keeps that, clamping 0 to one attempt. Keep it, or change it to mean "N retries after the first attempt".
  4. Codex WebSocket. The Codex WebSocket path still assembles each request as one full frame in memory. The HTTP routes do not.
  5. Post-reply logging failures go only to the debug log. There is no user-visible warning channel for them. FileLogExporter is synchronous, so the progress-based liveness check does not cover it.
  6. /restore side effects. Each restore makes one shadow commit, and ignored files are not rolled back.
  7. Test changes to look at.
    • In LoadBalancingProvider.timeout.test.ts, the "linked listener removal throws" fault now fires only after the first attempt starts, because request staging also removes listeners before any attempt.
    • The p05b4 retry capture count went from 3 to 2, because main's auth policy allows one refreshed retry.
    • Files named *test-helpers* under src still import bun:test. A new guard blocks any other production file from doing so.

Review

One full independent review of this branch found 1 blocker and 2 major defects. Each was reproduced, fixed and covered by a test that failed first. A follow-up check confirmed the hook fix and found the other two fixes incomplete on sibling paths. Those paths are also fixed:

  • Unfinished replace transaction on resume (blocker). The resume path left an unfinished replace transaction open in the journal file, so the next replacement made the journal unreadable. Resume now writes the replace_abort first (1761dca). Clone and checkpoint fork, which start a recorder on a copied journal, do the same (605ff99).
  • Project-trust withdrawal. Project BeforeModel/AfterModel hooks on the disk route kept running after workspace trust was withdrawn. They now get the project-trust signal, as the command route does (d690f2f).
  • Row origins after rollback. Rolling back a failed /clear, pop, batch clear, row transform, validation repair or rollback checkpoint restored rows without their origin. A recorder for another origin attached later then received them. Every rollback now restores each row under its own origin (838ab2e, ce0686c).

Follow-ups (under #3868)

Reviewer Test Plan

  • npm run build:types, npm run lint, npm run typecheck, npm run build, npm run test, npm run test:scripts, npm run check:runtime-boundary.
  • Gemini plugin: cd plugins/google-gemini && npx tsc --noEmit && bun test.
  • Bytes: cd packages/providers && bun test src/__tests__/wireBodyGolden.test.ts. That is 315 cases over 7 routes and 22 histories, recorded before this work.
  • Memory: from packages/providers, run each src/__tests__/sendPeak.*.test.ts file on its own. From packages/agents, run each src/core/__tests__/journal-retained-growth*.test.ts file. Trap files pass by observing their trap break the bound.
  • Smoke: bun scripts/start.ts --profile-load lunahigh "write me a haiku and nothing else", plus a tool round trip.
  • Manual: run a long session with tool calls, then /chat restore to an earlier point. Quit, --continue, and confirm the history and the next request are right.

Testing Matrix

🍏 🪟 🐧
npm run ✅ ❓ ✅
npx ❓ ❓ ❓
Docker ❓ ❓ ✅
Podman ❓ - -
Seatbelt ❓ - -

Linux and Docker are covered by CI (unit shards, E2E with sandbox none and docker, tmux UI). Windows is covered only by the test-infra gate.

Gates at c5d349e. Every CI check passes. Locally: build:types, lint, format (no diff), typecheck, lint:affected-shards, lint:agents-api-surface and check:runtime-boundary pass, and the branch is 0 commits behind main. The haiku and tool round-trip smoke tests passed on the merged tree.

On earlier runs of this branch the Windows test-infra gate failed twice. Both failures were in launcher and runner meta-tests this branch does not touch: a child that sleeps 50 ms ran past its 4 s budget, and an EBUSY came from removing a temp directory. It passed on the next commit.

Linked issues / bugs

Part of #854

Fixes #3847
Fixes #3857
Fixes #3858
Fixes #3865
Fixes #3866
Fixes #3867

…ek owner or the send releases them (review F1)
…n records (review F6)

Add replace_begin/replace_commit/replace_abort records and one shared
fold rule (ReplaceTransactionFold) used by every journal reader: an
unfinished transaction folds to the prior history, a committed one to
its rows, a malformed one throws. Publication still writes the old
shape; it moves to transactions in the next commit.
…n records (review F6)

A general streamed replacement now publishes begin, tagged rows and
commit instead of a full rewind plus rows, so a crash or write failure
before the commit leaves the previous history intact. Compensation
writes replace_abort, or a restoring transaction once the replacement
was durable, and derived state follows the durable fold when
compensation fails.
# Conflicts:
#	packages/core/src/recording/journalResolver.ts
…nt enforcement (review F13)

Warm-up now runs past the per-content measurement FIFO cap (550+ turns) and
until the settled heap stops moving, then measures. Sampling is uniform every
5 turns; checkpoints are the median of 9 samples. Traps retain only after the
baseline. The 1 MiB allowance, noise bound, fitted gate and per-checkpoint
enforcement are unchanged.

Normal runs (settled delta vs baseline at +100/+200/+300/+400 turns), 3 of 3 pass:
run1 74289, 114071, 149368, 179036 (fitted 173260), 73.6 s
run2 53615, 135669, 130086, 188390 (fitted 181300), 75.6 s file total
run3 45090, 98595, 147708, 172987 (fitted 179437), 73.9 s
Traps fail as designed: trap 6.1M/13.8M/21.4M/29.0M; release-trap
6.2M/13.7M/21.4M then 0.2M.
… instead of capturing the journal a second time
…e read back

The provider normalization stage log kept its 64 KiB append buffer for the
life of the request even after every row was written and the log had moved
on to reading. Reading now flushes and drops the buffer; a later append
allocates a new one. The logging boundary memory test pays the one-time
decoder, writer and JIT costs of the same 64-row path before its baseline,
and its trap tests still fail the same gates.
…o jest/expect-expect; shell processor fs mock stubs the scratch-root sweep
…ready runs instead of folding the whole journal again

Every history mutation (four per turn in the R3 proof) captured row origins
through a cursor, which re-folds every byte of the journal from offset zero
with no checkpoint. Cost grew with journal length: R3 workload CPU per turn
rose from 112 ms (turns 1-50) to 257 ms (turns 451-500), 205 ms of a 302 ms
turn at turn 400 was this fold. At f035f19 it stayed flat at about 95 ms.

The durable fold now numbers origins itself, using a dictionary owned by the
checkpoint so restored rows keep their ids, and the pending fold exposes
originAt. HistoryRowOrigins.capture reads them from the rows the fold holds.
Row contents, request bytes and token counts are unchanged.

R3 workload, CPU ms/turn at turns 451-500: 257 -> 105 (f035f19: 95).
…r has written it

A publication scope hands its ops to the per-origin recorder mirrors when it
closes, which is after the mutation already awaited journal durability. The
mirrors only enqueue, so waitForCommit, restoreTurns and the other detached
mutations resolved while a mirror's rewind or replacement was still in
memory. Session restore then read a recording without the rewind
(session-owner-restore-checkpoint failed on CI and reproduces locally).

The fan-out now flushes every mirror when the feed settles, and both the
detached mutation path and HistoryServiceCore settle the fan-out after their
scope closes. Mirror flush failures are collected and rethrown together.
historyRecorderFanout.durability.test.ts fails 3 of 3 without this.
… a trap that must trip it

JavaScriptCore scans the machine stack conservatively, so one row of an old
batch can stay reachable until its stale slot is reused, which made the
gate fail intermittently on CI with a count of 1. A row now counts as
retained only when the same row is still reachable at two consecutive
checks; a real holder always is, a stale slot is not. The bound stays 0. A
new trap holds batch-0 rows and must be reported. 400 local runs: 0 fail.
…dir instead of a recursive walk

A Linux CI run failed with 'EFAULT: bad address in system call argument, rm .../llxprt-resolver-*'.
Bun 1.3.14 reports failures of its recursive delete walk as EFAULT, which hides the real errno.
The resolver index directories hold exactly one file the index created, so close() and the
constructor failure path now unlink that file and rmdir the directory. A missing path is
accepted (the root may already be gone); any other error surfaces with its real code.

A cross-process test pins that the dead-root sweep keeps the root of a running process and
reclaims it after the process is killed.
…keep traps that must report a held row

JavaScriptCore's conservative stack scan can keep one dead row alive briefly, which made exactly-one-survivor failures appear on Linux CI. Local repro was 0 of 25 at HEAD and 0 of 25 with the fan-out flush reverted, so no holder was found. retainedAfterSettling counts an object only if it survives several collections. Bounds stay 0, and each converted test has a trap holding a row that must be reported.
Eight *-test-helpers.ts files lived under packages/agents/src/core and
shipped in the npm package; one imported bun:test. They now live under
src/core/__tests__/support, which the package excludes, and their importers
point there. The no-bun-test-in-production-src guard now also fails on any
*test-helpers* file outside __tests__ in a published source tree; it fails
with a helper copied back under src/core. npm pack --dry-run lists no test
helpers.
turn-request-artifact.ts and turn-request-json.ts had no production
importer (checked src, agents index, package exports, plugins, packages/cli).
Only tests imported stageTurnRequestArtifact. Their memory tests read as if
they covered live request logging; production stages request artifacts in
storage (stageRequestArtifact via ConversationFileWriter), which they never
touched.

Both modules are deleted. Test files:

(a) delete, tests only the dead module:
- turn-request-artifact.test.ts
- source-logging-boundary.test.ts (boundary identity parity, rejection of
  identity graphs, release traps; the UTF-16 cap section duplicates the
  split-surrogate cap cases in telemetry-stream-cap.test.ts)

(c) memory test that only exercised the dead writer, deleted:
- source-logging-boundary-memory.test.ts. Live-path memory coverage stays in
  telemetry-stream-memory.test.ts (exporter and reader residency with row and
  chunk traps) and the chunk-sink release test in
  telemetry-request-artifact-sink.test.ts.

(b) live telemetry path, switched to __tests__/support/request-artifact-fixture.ts
(a minimal row-local safe-JSON stager used only to produce the descriptor):
- telemetry-stream-ack.test.ts
- telemetry-stream-reader.test.ts
- telemetry-stream-cap.test.ts
- telemetry-stream-consumer.test.ts
- source-preflight-abort.test.ts
- source-preflight-http.test.ts
- telemetry-request-artifact-sink.test.ts (renamed from
  turn-request-artifact-sink.test.ts; the "request-wide identity protocol"
  case asserted the dead writer's behaviour and is removed)
- source-logging-identity-required.test.ts (live BoundarySnapshotDisk
  identity checks kept; the boundary-identity case that only the dead writer
  could satisfy is removed)
- __tests__/support/source-strict-logger-fixture.ts
- __tests__/support/runtime-request-lifecycle-test-helpers.ts
- __tests__/support/telemetry-stream-memory-worker.ts
…on, array cache-anchor) out of the shipped tree
Production summarizes only through OneShotStrategy.compressDisk. Behavior
tests now run the disk route through a support adapter. The byte-equivalence
helpers and the scripts summary-body test keep an independent array oracle
under __tests__/support.
…ast row

The send path scanned the whole journal every turn to find the last row for
IDE context, before checking whether IDE mode was on. It now returns when IDE
mode is off and otherwise uses the journal's last-content read.
…yMaxBytes

resumeHistoryWindow built its ledger with the default 400 item / 4 MiB limits,
so a resume trimmed to 400 rows whatever ui.historyMaxItems said. The callers
now pass the limits the settings select; unset settings keep main's defaults.
…not a materialized array

The owner-mode commands called session.getHistory() to read a length. They now
read the history count from the journal-backed session stats.
…ream, not an array

AgentSessionControl.restoreTurns collected the whole remaining history into an
array for its only caller, which replays it into the UI one row at a time. The
result is now an AsyncIterable over the journal; the type change is inside
AgentSessionControl and adds no root export.
… configured historyMaxItems

replayUiHistory used the default 400 item window like the other resume paths.
hydrateRecordedMedia called store.admitObjects([]) for every replayed
row, even when the row had no media. Each call takes the exclusive media
store lock, ensures the store directories and fsyncs, so resuming N text
rows cost N lock cycles.

It now returns before admission when nothing needs copying.

resumeHistoryLimits "applies the configured limit to an owner-session
resume" (load average 35-65), resume step only:
  500 rows:  13.4 s -> 0.55 s
  1000 rows: 28.6 s -> 0.76 s
  2000 rows: ~70 s  -> 1.1 s (whole test 2.5 s)

resume-text-rows-media-store.test.ts counts admitObjects calls on a
store with a recording archive for 25 and 100 text rows; it saw 25 before
the fix and 0 after.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment