Repository navigation
Conversation
…e disk round trip
Remove the paired-estimator memory harnesses (2 h / 4 h runner budgets, one hung at 0% CPU) and the serial acceptance scheduling that existed only for them. Runner and bun-test-policy files return to origin/main. The repeated-turn journal-retained-growth proof replaces them; behavioral childaccept, wholepeak, media-peak and target tests stay.
They required BODY_EVIDENCE_TEST_SCRATCH, which nothing sets. Each file now creates and removes its own real-path scratch directory. The evidence writer stays because about 25 surviving byte-equivalence tests import it.
Compression summaries call the provider with request rows outside the chat send seam, so openai-responses failed with 'requires a prepared projection token'. The provider now prepares the same projection itself. The one-shot and middle-out summary BODY byte tests pass again (12 each).
…per test It asserted original-row identity across a paused writer, the old contract the journal no longer provides. The four GC-retention cases still prove the trap holds the full fold. The helper stays: three child workloads use it.
…eclaim cases to the durable contract
…live writer; retire forensic detached memory harness
… in-place caller mutation case
…string oracle to sizes tiktoken can encode
acoliver
force-pushed
the
issue854-journal
branch
from
October 14, 2026 06:56
ef596b7 to
5f7e7aa
Compare
The chat projection no longer parses the spooled body into a request object on every estimate. The same pass that writes each message to the spool writes it to the estimator-form messages file, tools follow, and the estimator reads the sealed Gpt56SourceProjection. Unsupported media is a ledger appended per row instead of a scan over an array. Token counts are unchanged (72 parity cases over three chat routes), so PROJECTION_REVISION stays 4. The send peak probe now covers the estimate step, with a trap that holds the history while estimating and must fail.
…ends a second spool D3c: the chat request dump is written from the spool one chunk at a time (re-indented by prettyJsonBytes) instead of parsing the whole body, so a dump no longer holds the body as a value. D4: when a streaming reply holds tool calls and no text, the continuation request is built as a second spool from the same rows plus the acknowledgement tail, and sent with Content-Length equal to the sealed spool size, instead of re-parsing the first body. The deleted OpenAIChatSpooledBody reader is gone from the production path. Request and dump bytes are unchanged; the chatDumpGolden and chatContinuationGolden fixtures were captured against the code at D3b and pass against it. The sendPeak chat measurement is split into sendPeak.openaiChatTextOnly and sendPeak.openaiChatToolHeavy. sharedSendPeak runs one child process for the shapes a file lists, and the first test awaited that whole run, so one test carried both shapes against the 170 s per-test limit and timed out on a loaded machine (the D3b code timed out the same way). One shape per file gives each child half the work. Assertions, estimate, rounds and bounds are unchanged. Lint fixes in touched files: prettyJsonBytes state moved into a class, golden tests assert in the test body, continuation text loop moved to OpenAIStreamChunkText to keep OpenAIProvider under the line limits.
…ed sends The retained-growth gate compared the settled reading after send 6 with the one after send 1, so two readings decided the outcome. Under load (load average 25) sendPeak.openaiChatTextOnly read 1,099,512 bytes on 1 of 3 runs, 51 KB over the 1 MiB bound, from noise in one reading. The probe already takes a settled reading after each of the 6 sends. The gate now uses the Theil-Sen slope of those readings (median of all pairwise slopes) times sends minus one, taken for heap and arrayBuffers separately, and judges the larger. A steady per-send leak gives a steady positive slope and still breaks the bound; one outlier moves few of the 15 pairwise slopes and cannot move the median much. SEND_PEAK_BOUND_BYTES, the peak-growth gate and the traps are unchanged. verdict.retainedGrowth is now the trend value and verdict.retainedEndpointGrowth keeps the old first-to-last difference, so the SEND_PEAK_LOG jsonl records both for comparison. A unit test checks the estimator on a known series, a flat series with one large outlier, and a steady leak of bound/(K-2) per send.
… through a request-scoped disk index
…/res id indexes; the dump stream uses it
…ble one row at a time
… message table The Anthropic request body is built into the disk-backed message table one row at a time, finished and cache-marked on the table, and emitted into a request-scoped spool posted with Content-Length equal to its sealed size. No history array or whole request object is built on the send path. Bytes are unchanged (wire golden corpus, cache 1h and off). sendPeak Anthropic bounds now pass (+18.8/+25.7 MB before); the probe is split per shape so each file has headroom under the per-test limit.
…iles The Anthropic projection writes estimator-form segment files from the same table pass that fills the spool, estimates through the source projection's anthropic-messages protocol, and records unsupported media per row. Dumps stream from the spool. Token counts are identical (estimate parity over the golden histories, caching and OAuth variants) and dump bytes are unchanged (dump golden captured from the previous code). The Anthropic peak probes now include the estimate step, with an estimate trap that must fail.
…get instead of keeping a body copy The recovery retry no longer reads the sent body back, rewrites it and keeps the copy in the shared retry context. The context holds the recovery budget (a number or two); the retry, and any later outer attempt, builds the spool again from the rows and replaces oversized images in the finished message table before the body is written. Retry bytes and dumps are unchanged (golden from the old code). A later outer attempt used to send the kept body after the first attempt's lease had spliced its arrays, i.e. an empty messages array; it now sends the retry bytes.
…nd the Gemini plugin still collect
…nts in one pass (closes #3857) Replacements are ranked and decided in one pass over a disk-backed running estimate and published as one change, instead of re-projecting the candidate and writing the journal after every replacement. When shedding every tool response cannot reach the limit, the stage fails fast with ContextOverflowError. Results equal the previous per-replacement stage on the differential corpus. Measured on the bench fixture: 1,024 rows went from 722 s to 84 ms; 2,048 and 8,192 rows never finished before and now take 227 ms and 537 ms.
… append before tests unlink the journal prepareParity left the last row's appendFile in flight. When a test then unlinked the journal, the late append recreated the path, which failed the existsSync assertion and orphaned the paused fold (its scratch root was removed, surfacing as ENOENT in the next test). Reproduced 3/60 on the unmodified code; 0/200 after waiting for durability.
…crementally instead of rescanning the journal
…stead of once per resolved row Every row a semantic purge record resolves points at the same journal line, and the origin lookup parsed that whole line per row, so the bytes parsed grew with the square of the row count (16x for 4x rows). The reader now keeps the origin of the latest and of the longest line.
…iteSync per JSON piece
…s instead of four small I/O calls per row
…ow batch and reader start instead of statting on every row
…and rehash through resident pages instead of one syscall per slot
…I/O tests typecheck without casts
…nd-only log read by position instead of one hashed key lookup per row
…ag keys from a fixed-size membership filter instead of probing the index
…tead of three zod objects per lookup
…slice and awaits only on a full chunk or an oversized piece
…ount already fits the token limit A BPE token covers at least one byte, so output with no more bytes than the effective token limit cannot exceed it. The check is exact and avoids encoding every small tool result on the send path.
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
TLDR
Conversation history now lives in an on-disk journal instead of in memory.
HistoryServiceis a facade over that journal, and subagents get their own journal files. On every provider send, rows are streamed from the journal through the provider transformers into the request body. The only request-sized data is the per-request body, released when the call ends. Long sessions no longer keep a context-sized copy of history in the heap between turns.Request bytes match main for the same history. Resume,
--continue, chat restore and package import/export stream from disk.Dive Deeper
Requirements and how they are covered (from
project-plans/issue854/finish-plan.md):R1, no context-sized structure at rest. History membership, pending tickets, the provider normalization snapshot, density folds and the resume resolver are all disk-backed. The in-memory state between turns is scalars plus state bounded by mutation events.
R2, journal-fed default send. The array send route and its enforcement are deleted. Every provider family (OpenAI Responses/Codex, OpenAI chat, Anthropic, Gemini plugin, Vercel, load balancing) prepares from
requestRows. Stored media references are resolved one row at a time and released at call cleanup.R3, retained-heap proof with traps. Three files in
packages/agents/src/core/__tests__/run a realChatSessionwith compression, one worker per file so each fits the 300 s per-file budget:journal-retained-growth.test.tsis the normal run. It uses about 4 KB rows and a 30k-token context, which gives roughly 90 compressions in about 850 requests. After a warm-up that ends when settled heap converges (around turn 510), it takes six settled checkpoints 50 turns apart. Every checkpoint must stay under the 1 MiB allowance, and the fitted growth over checkpoints 3 to 6 must stay under 262,144 B. Locally the late fitted growth ranged from about -4 KB to +28 KB.journal-retained-growth-retaining-trap.test.tskeeps every row alive and must fail the allowance.journal-retained-growth-release-trap.test.tsretains rows and releases them before the end. It must fail an intermediate checkpoint and end under half the allowance.An earlier single-file version, which ran three workers concurrently, measured a one-time step of about 170 KB around turns 1,050 to 1,100 on CI. Instrumentation traced it to the bounded request-shape memory filling to its 2,000-entry cap and to JIT tier-up; no product type grew per turn over 1,750 turns. The split design measures after both have settled.
R4, unchanged bytes. Byte-equivalence suites (
providerBody*.p05b4, disk-text and source differential tests) compare against independent oracles. Reasoning-ID and active-parent tool-output cases are compared against bytes captured fromorigin/main.R5, streamed resume/continue/import. Package import has a 64 MiB per-line limit. The 100k history-count limit now also applies on export. Persisted-state JSON is parsed one file at a time. Blobs are copied one object at a time.
R6, gates. See the test plan below.
Durability. A general history replacement (restore, import-driven replacement, compensation) is written as a
replace_begin/ rows /replace_committransaction. Every journal reader, including the CLI scrollback pager, treats an incomplete transaction as absent. A crash during a replacement therefore resumes with the previous history, not an empty or partial one. Single-record shapes (a suffix cut, a clear, a compression) are written as one record. The recorder rejects journal admission when it is inactive or disposed, instead of reporting a write that never happened as durable.Speed. Measured on this machine:
Merge with main's owner/port refactor (#3842). The merge had 219 conflicts. Journal-backed code now uses the new owners and ports (session settings, hook and media owners, the runtime tokenizer factory, instance-owned telemetry). Problems the merge exposed and this branch fixes:
replace_begin, then commits or rewinds synchronously, rolls back withreplace_abort, and fails if history changes concurrently./clearfollowed by--continueno longer writes a zero-length rewind into the new session.ExportAcknowledgementMissingError. The root does not report that export a second time at flush or close; other export failures are still reported there.buntypes. That import put Bun's globals into the program the runtime-boundary analyzer walks and pushed its DOM walk from 9,160 to 11,170 nodes, past the 10,000 cap. Wrapper fetches forwardpreconnectthrough a small local helper instead.Main's #3507 tests were replaced by nine equivalents on the send seam. The p05b4 retry capture count went from 3 to 2, because main's auth policy allows one refreshed retry. The listener-count assertions in
recording-finalizer-joinwere dropped, because the journal has no listeners.Behaviour changes reviewers should know about:
AbortErrorinstead of the provider's "terminated"./set retries 0on Responses providers used to send nothing and return an empty stream. It now means one attempt. A 200 response with no body is an error.MissingSnapshotHookCallbackErroris raised forHookSystemsubclass overrides on the source route. That is not a supported extension point.Content-Length. OpenAI/Responses requests now upload chunked without Content-Length (on-demand body from #854) #3847 covers what to do if an endpoint rejects that.ui.scrollbackPagerEnabled) ships defaulted off. It projects only journal rows, so it cannot yet show toolresultDisplaytext, UI-only rows or the live pending turn. With it off, the transcript uses the fallback ledger, which is bounded by configured item and byte limits. Unified scrollback to the start of history: one disk-paged surface with the live turn, on by default #3856 tracks finishing the pager.hook_calltelemetry from there, with stdout/stderr capped at 4000 characters.density_mutationrecords of at most 64 rows or 64 KiB each.scripts/lib/scripts-test-weights.ts).SessionRecordingServiceuses backpressure instead of throwing at the queue limit. While merging main (fix(recording): keep one unreadable session from breaking --continue (Fixes #3732) #3838), the header-rebind tests that asserted throw-on-limit or prepared-batch rollback were rewritten as commit-based cases or dropped, because that behaviour no longer exists.Review. One full review found 2 blockers and 14 major findings. All were fixed. A findings-only follow-up review confirmed 11 of them resolved and 5 partially resolved; those 5 were then completed:
Known open item.
HistoryJournalStore.dispose()swallowsrecorder.disposeerrors and removes scratch asynchronously.Follow-ups filed:
Content-Length.Reviewer Test Plan
npm run typecheck,npm run lint,npm run build,npm run test,npm run test:scripts.cd plugins/google-gemini && npx tsc --noEmit && bun test.cd packages/agents && bun test src/core/__tests__/journal-retained-growth.test.ts --timeout 300000, then the same command forjournal-retained-growth-retaining-trap.test.tsandjournal-retained-growth-release-trap.test.ts. Each passes; the trap files pass by observing their trap fail its bound.npm run build:types && npm run check:runtime-boundary.bun scripts/start.ts --profile-load lunahigh "write me a haiku and nothing else". Also try a tool round trip such as "list the files in the current directory"./chat restoreto an earlier point. Quit,--continue, and page back through scrollback. Rows before the cut should remain visible and rows after it should not.Testing Matrix
Local macOS: typecheck, lint, build, the Gemini plugin and both smoke profiles (
lunahigh, andhaikuwith a tool round trip) pass. The Interactive UI tmux suite passed twice locally (19/19). On this machine several timing-sensitive tests fail only when the full suite runs alongside other sessions, and pass when run alone. CI on Linux is the authority for the full suite.Linked issues / bugs
Fixes #854