Repository navigation
perf(history): serve long threads from stored turn slices and keep agent state to the window - #511
Conversation
|
Important Review skippedToo many files! This PR contains 178 files, which is 28 over the limit of 150. To get a review, reduce the PR to 150 files or fewer by splitting it into smaller PRs or changing its base branch. Upgrade to a paid plan to raise the limit. This review couldn't start because sufficient usage credits or metered capacity aren't available. Add credits or update usage-based reviews in the billing tab, then retry. ⚙️ Run configuration
📒 Files selected for processing (178)
You can disable this status message by setting the
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
Review scope note: GitHub refuses to serve this diff (over 20,000 lines), so I could not review it in full. I read the PR description and spot-checked No blocking issues found in that file. It checks coverage by content identity, reads slice rows in batches of 32 with the decoding run off the event loop ( I did not review the rest: the migrations (064/065), the operator scripts, |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: cbc98d2ac6
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Migration 064 adds turn_slices, one row per settled turn holding what the turn added to its checkpoints, the checkpoints it was cut from and its projected replay lines, and task_run_slices, the same for each subagent run. Migration 065 adds a nullable replay_facts column to conversation_responses and subagent_runs for what only the server observes around a run. Both tables hold derived data that the next read rebuilds, and their store modules are the only code that touches them here.
1ad657c to
df6fcc8
Compare
cbc98d2 to
192ff51
Compare
|
Review scope: this PR's diff exceeds GitHub's 20,000-line limit (176 files), so No blocking issues found in what I read. 💡 Non-blocking
The deploy ordering in the description (facts backfill, then slices |
|
Claude Opus 5.5, automated agent reply Per item, no change in this round:
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 192ff51b39
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
@codex review |
|
Review status: not a full review. The PR is about 21k lines. GitHub refuses to serve the diff (HTTP 406, over 20,000 lines) and the checkout has no base commit to diff against. I could not read the code line by line, so I'm not signing off on it. What I did check:
I found no blocking issues, but that is because I could not review the code, not because I verified it. 💡 Non-blocking: split the PR. The five parts (turn slices, replay facts, sliding window, operator scripts, crossed-thread repair) are independently reviewable and have different risk profiles.
Splitting them, or at least stacking each part as its own PR, would allow a real line-level review. The commits are described as each passing the suite, so a split looks feasible. Areas I'd want a human to look at:
🤖 Generated with Claude Code |
|
Claude Opus 5.5, automated agent reply
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 192ff51b39
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
192ff51 to
0930c2e
Compare
|
Review scope note: No blocking issues found in the code I read. The trim is gated on a server-side identity check that the stored slices hold the dropped runs. It falls back to no trim when the server can't be asked. Slice reads are batched (32 per query) and decoding runs off the event loop. 💡 Non-blocking
LGTM on what I could inspect. Please get a human review of the unreviewed parts, especially the multi-worker paths (finalize, refresh and the CAS commit pointer). Per |
|
@codex review |
|
Claude Opus 5.5, automated agent reply
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0930c2ed18
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
The replay endpoint reads light rows, walks the branch in Postgres and serves each turn from its stored slice and cached lines, projecting only a turn whose slice or lines key no longer matches, so a repeat open no longer loads checkpoint state or stored SSE events. A settled turn, and the turn that launched a settled subagent run, are projected in the background, the Redis projection cache and its stream gate are removed, and the endpoint accepts before_turn and limit with a leading history_page frame. Replay shows only what a turn's checkpoints committed, and the history reader, the fork route, the automation excerpt and the parity script all read through the same walk and slices.
A settled run's row now carries its replay facts: reasoning durations keyed by committed message id, the images each subagent lane captured, and steering a lane handed back unread, which leaves Redis only once its fact has landed. Public shares, the Chief of Staff's agent output, provenance and the automation credit pause read from the owner's replay assembly, committed slices, per-lane writes and finalize metadata instead of stored events. The producer's stream errors, event archive and reasoning durations move into their own modules, and finalize takes the producer's record whole on RunOutcome.
backfill_replay_facts.py resolves what only a legacy turn's stored events supplied (widget data, evicted results, reasoning durations, positioned signal rows and captured images) into the row's replay_facts.legacy, after which its replay never reads those events. It writes only when the row version is unchanged and every row places the same from the new facts, and counts by default until given --apply.
After a summary, the compaction middleware cuts the main agent's messages back to the start of the transcript run holding the summary's boundary, once the server confirms by content digest that stored slices hold every run it drops. Turn numbers, offload markers, notes, the subagent switch notice and transcripts count from an absolute run base, so the model request stays byte-identical across a trim, and MainAgentState declares every private field so no writer erases one. Manual compaction and offload write at the checkpoint they read and advance the commit pointer by compare-and-set.
backfill_turn_slices.py finds threads with turns lacking stored lines or keyed under an old projection epoch or slice key, confirms each with the read path's own unserved_turns test and projects only those turns, newest thread first, counting by default until given --apply. --recheck confirms every selected thread, for bulk row changes the SQL pass cannot see, and backfill_transcripts.py selects its threads through the same shared harness.
thin_message_snapshots.py drops a messages snapshot only when every checkpoint loading it replays byte for byte from the next kept one, keeping branch tips, the newest turns' anchors and one snapshot per 1000 updates, and checks every affected load in one REPEATABLE READ transaction per thread before committing. With --keep all it drops nothing: it flags every stored pre-delta message list and seeds each walk that crossed one, so no migrated thread replays legacy writes from its root. It runs dry by default and refuses any langgraph or saver release it was not verified against.
MESSAGES_SNAPSHOT_FREQUENCY moves from 50 to 250, which on the bench export stores about a fifth of the snapshot bytes while a tip load still reads one scan page. A thread from before DeltaChannel walks to its root until its first snapshot, so thin_message_snapshots.py --keep all runs over those threads before this ships.
A thread whose first DeltaChannel turn replayed its legacy writes holds its oldest messages in another order than its turn slices, so the window's coverage check refuses every trim and its checkpoint keeps growing. repair_crossed_threads.py does that trim offline with the window taken from the slices, writes it through a graph node so the new tip stores a snapshot, and moves the pointer by compare-and-set, refusing unless the model and summary requests stay byte-identical. point_app_pool_at moves to _db.py so both ops scripts share it.
0930c2e to
8d7c5ae
Compare
|
@codex review |
|
No blocking issues found, but this review is partial. The diff is 21k lines across 178 files, and GitHub refuses to serve it (HTTP 406, over 20,000 lines). I read only the migrations (064/065 header and 064 body), 🚫 Blocking: none in what I read. The trim is guarded by anchor, run boundary and a server-side coverage check before it drops anything, and failures leave the thread untrimmed. Migration 064 is idempotent, sets 💡 Non-blocking
I did not review the replay/refresh pipeline, |
|
Claude Opus 5.5, automated agent reply
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8d7c5ae75a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
Review scope: partial. GitHub's API refuses this diff (21k lines, 178 files, over the 20,000-line limit), and CodeRabbit skipped it for the same reason. I read the PR description and migrations 064 and 065 only. I did not review the rest of the code. No blocking issues in the migrations. 💡 Non-blocking
|
Sandbox Integration Test MetricsDaytona
Total: 51 ops, 26.32s, 100% success Generated by sandbox-integration CI |
Hot Path Integration Test Results⏱️ Operation TimingsActual operation duration vs pytest total (which includes fixture setup and cold path prerequisites)
✅ Cold / Warm Session PathSession resolution with real PostgreSQL + Daytona sandbox
✅
|
| Test | Duration | Result |
|---|---|---|
| no session cached | 6ms | ✅ |
| session not initialized | 5ms | ✅ |
| sandbox none | 9ms | ✅ |
| sandbox not ready | 5ms | ✅ |
| full ready state | 7.88s | ✅ |
✅ Conditional update_workspace_activity
60-second conditional SQL UPDATE behavior
| Test | Duration | Result |
|---|---|---|
| first call writes | 180ms | ✅ |
| immediate second call skips | 190ms | ✅ |
| nonexistent workspace returns false | 175ms | ✅ |
| deleted workspace returns false | 181ms | ✅ |
Total: 17 tests, 62.75s, 100% pass
Generated by backend-integration CI
Note
Claude Opus 5.5 writing on behalf of Zexi
Branch
perf/long-thread-history, basefix/fork-turn-and-reasoning-space. Nine commits in five parts. Each commit passes the suite on its own, so it reads well commit by commit:2c8974ff2feat(history): add the turn slice and replay facts tablesed075e392perf(history): replay a thread from stored turn slices916292ce2feat(runs): record replay facts when a run settlese7f80f939feat(scripts): backfill replay facts for turns settled before them16bb69fa0perf(agent): keep the main agent's state to the window the model sees34326ffecfeat(scripts): bring every turn's stored replay up to date0953391a4feat(ops): thin message snapshots on old checkpointsbe2166a91perf(agent): store a full messages snapshot every 250 updates8d7c5ae75perf(ops): repair crossed threads so the window can trim themOverview
A long thread cost three things that grew with its length:
messageshistory, though after a summary the model sees only the summary and what follows. On the longest bench thread that is 1,823 messages and 9.1 MB loaded to send about a twentieth of it.messages: a snapshot every 50 updates, and on threads from before DeltaChannel a full copy per update.1. Turn slices
When a turn settles, what it added to its checkpoints (new messages, ui records, compaction and offload changes) is cut once and stored in
turn_slices, keyed by the turn's response row, together with its projected replay lines. A subagent run gets the same intask_run_slices. Opening a thread now:sse_events);A slice is valid while its input and tail checkpoints match the walk and its
slice_keynames the code that cut it. The lines key is a deploy epoch (projection version, a digest of every module that shapes the text, the token threshold) plus a digest of the turn's rows, so a deploy that changes projection code re-projects from slices without loading any state.GET /api/v1/threads/{id}/messages/replayacceptsbefore_turnandlimit. With either, ahistory_pageframe comes first so the client knows where the page goes. The web PR on top uses them.walk_current_branch_boundariesreturns typedBoundaryrecords from Postgres, with no silent in-process fallback. The history reader,/turns(pair_turns), the automation excerpt and the parity script all read through it.MainAgentState. The main agent and the history reader share one state schema, so a ui record appended by the reader keeps every private field.projection_cache.py,REPLAY_PROJECTION_CACHE_TTL_SECONDS), the stream gate, the task lane rebuild and the cross-turn segment claimer. Legacy launches with no ledger stamp are claimed once per thread and pinned inturn_slices.run_claims.2. Replay facts
A few things a turn shows on replay were only ever in the stream it emitted, never in its checkpoints: how long each reasoning block took, which sandbox images a subagent captured to which links, and steering a subagent handed back unread. Replay, public shares, the Chief of Staff and automation settlement all read them back from
conversation_responses.sse_events, a second full record of every turn.A settled run now carries them as replay facts on its own row (
replay_facts, migration 065), merged in after the status settles. Reasoning durations are keyed by the committed message id, so a model call that committed nothing takes its durations with it. Captured images are recorded per subagent lane, so image capture no longer writes a root checkpoint after finalize and a settled turn's tail stays put. Returned steering leaves the Redis queue only once its fact has landed.Every other reader moved off the stored events. Public share pages are built by the owner's replay assembly, with error events dropped and error text stripped. The Chief of Staff's
agent_outputreads the text each run committed from its turn slice, main lane only. Provenance is written per lane, and automation credit pause messages are stamped into the response's metadata at finalize, by recovery too.sse_eventsis still written exactly as before, so no stored data changes that a rollback would miss.Along the way
sse_producer.pyshrinks from 2,060 lines on main to 1,718 (stream errors, the event archive and reasoning durations move to their own modules) and task chain repair moves out ofsubagent_runs.py(951 to 831 lines) intosubagent_repair.py.scripts/utils/backfill_replay_facts.pyresolves what only a legacy turn's stored events supplied (widget data, evicted results, reasoning durations, positioned signal rows, captured images) intoreplay_facts.legacy, after which that turn's replay never reads its events. It writes only when the row version is unchanged and the new facts place every row the same.3. Sliding window
The main agent's live state now equals the model's view. After a summary, the compaction middleware's
before_agenthook cutsmessagesback to the start of the transcript run holding the summary's boundary: the window the model already sees, rounded back to a run boundary so the current turn never loses a message. It runs on every fresh turn and orchestrator re-entry, and trims only when the summary event has an anchor, the cut is past the first run, and the server confirms within 5 s, by a chained content digest rather than a message count, that stored slices hold every run the cut drops (services/history/window.runs_held). A refusal is kept on the anchor and asked again only after the next summary. The trim never runs in a subagent's namespace or without the server to ask (the CLI).What the model reads stays byte-identical across a trim, because everything that numbers turns counts from one absolute run base kept in state: turn maps, offload markers, the summary span and index, the earlier turns heading, notes' tool call counts and the transcript pointer. Transcripts render trimmed runs from the stored manifest or read them back from slices, refusing a copy from another branch by its identity digest.
MainAgentStatedeclares every private field the middleware keep, so a write through a build that lacks one (thread maintenance, the reader's ui append) no longer erases it. Manual/compactand offload write at the checkpoint they read and advance the thread's commit pointer by compare-and-set.4. Operator scripts
scripts/utils/backfill_turn_slices.pyprojects every turn lacking stored lines or keyed under an old epoch or slice key, confirmed by the read path's ownunserved_turnstest, newest thread first.--recheckconfirms every selected thread, for bulk row changes the SQL pass cannot see, such as the facts backfill.scripts/ops/thin_message_snapshots.pydrops amessagessnapshot only when every checkpoint loading it replays byte for byte from the next kept one. It always keeps branch tips, the current tip, the newest two turns' anchors and one snapshot per 1,000 updates. Each thread is one REPEATABLE READ transaction that loads every affected checkpoint through the server's reader before and after the write and rolls back on any digest change, any longer protected walk or any change to the turn anchors. It skips threads with unserved turns or a run in progress, runs dry by default (--rehearse,--apply), and refuses any langgraph or saver release it was not verified against.--keep alldrops nothing. It flags every stored pre-delta message list as a snapshot and seeds the top of each walk that crossed one, so no migrated thread replays legacy writes from its root. Those writes carry no ids and come back reordered, and the next snapshot would keep that order for good.MESSAGES_SNAPSHOT_FREQUENCYmoves from 50 to 250, about a fifth of the snapshot bytes on a long thread while a tip load still reads one scan page.All three scripts count or rehearse until given
--apply, and share one harness (scripts/utils/_thread_job.py), whichbackfill_transcripts.pynow uses too.5. Crossed-thread repair
--keep allstops new crossings, but a thread that already crossed (its first DeltaChannel turn replayed id-less legacy writes from the root, and a snapshot then kept the reordered head) holds its oldest messages in another order than its turn slices. The window's coverage check compares the two, so it refuses every trim on that thread and its checkpoint keeps growing.scripts/ops/repair_crossed_threads.pydoes that trim offline, with the window taken from the slices. It writes the trimmed state through a graph node so the new tip stores a snapshot, removes the step's writes from the old tip so no load replays them, and moves the thread's commit pointer by compare-and-set as the last step before commit, so a user's message never waits on its verification. It refuses unless the next model call, the call after an idle pause, the summary request and the call after the summary all stay byte-identical, and it refuses a thread with a run in progress or a turn before the cut with no stored slice. Like the others it is dry by default (--rehearse,--apply).Size
By module, production only (tests excluded), +7,214 net:
src/server/services/history/replay/package:cold.py,turn.py,run_lane.py,keys.py,lines.py,refresh.py,stored_events.py,facts.py,legacy.py;slices.py,task_streams.py,committed.py,window.py;reader.py675 to 473 lines;projection_cache.py,task_lane.py,segment_claim.pyremovedscripts/ops/thin_message_snapshots.py, its planner_thin_plan.pyand_delta_loads.py;repair_crossed_threads.py;point_app_pool_atshared in_db.pyscripts/backfill_replay_facts.py,backfill_turn_slices.py,_thread_job.py,replay_parity.py(--compare sse, sse-merged, facts; a stopped turn is excused only for output streamed past its last checkpoint),_errors.pysrc/server/database/turn_slices.pystore, light rows inreplay_rows.py,replay_facts.py, per-laneprovenance.py,RunOutcomeinruns/lifecycle.py,subagent_repair.pysplit out; the app-data pool tags its timeouts, so a replay still falls back to stored events when only the checkpointer pool is busysrc/ptc_agent/agent/compaction/window.pyand the middleware hook;transcript/identity.py;main_state.py; snapshot frequency instate.py; returned steering recorded before it leaves Redissrc/server/services/runs/:stream_errors.py,event_archive.py,reasoning_facts.pyout ofsse_producer.py, facts at finalize and recovery; the coordinator re-projects a launching turn; image capture, settlement, transcriptssrc/server/utils/checkpoint_helpers.py: the Postgres branch walk,Boundary,update_at_commitmigrations/turn_slices,task_run_slices; 065replay_facts; both withlock_timeout = 5ssrc/tools/secretary/agent_outputsrc/server/contracts/classify_interruptssrc/server/handlers//turnspairs through the shared walk; maintenance writes at the read checkpointsrc/server/app/public.pythrough the owner's assembly; the replay endpoint's pagingsrc/config/Measured
On a bench copy of 113 long threads, the same harness, container and data as a run of
main(31 threads timed over HTTP). The two largest are a 97-turn compacted thread and a 71-turn thread with 5,038 replay items. A gate run on this final tree was flat against the first complete build (HTTP full open, cold and warm: 9.35 and 0.80 s to 8.70 and 0.79 s).Opening a thread
A cold open fetches 0 bytes of
sse_events. Cold full opens are 3 to 10% slower than main: the claims pass for legacy launches, now paid once per turn instead of on every open. The derived store is 48.9 MB on disk for the 31 threads, against main's expiring Redis entries.Turn start (real trim path)
The model request is byte-identical on every trimmed thread. The trim costs 152 ms once per summary, almost all of it the coverage check. Live on a dev stack, a new turn on the 71-turn thread ran on the window with state going from 1,823 to 126 messages. Before the repair, the 97-turn thread refused every trim: it crossed onto DeltaChannel before seeding existed. After it, a turn start on that thread goes from 672 to 228 messages, 6.47 to 1.36 MB fetched, 46.9 to 24.4 ms p50, and the coverage check from 91 ms refused to 0.2 ms.
Storage and finalize
--verify all--keep all--verify all1,431 checkpoints, 0 mismatches; a rerun finds nothingThe Chief of Staff's committed text read is slower on the giant threads (warm, one turn, 8.0 to 31.7 ms), because it now runs the full validity check; the text is identical on all 404 turns.
Parity against main, 109 bench threads: 104 of 109 match in full, 104 of 109 in the last ten turns, 108 of 109 in the last two, identical with the replay cache off and on, after the facts backfill with
sse_eventsset to NULL, after thinning, and on the repaired thread before and after its repair. Every turn that differs was decided: five where a stopped or errored turn loses its uncommitted output or an errored resume puts its error after the committed tool result, and seven legacy turns whose images had never resolved, which now show their committed tool calls and resolved image links instead of stream-only chunks. Reasoning and all other text are identical.What a user sees differently
/compactand offload report a message count for the window, not the whole history.source=checkpointon replay answers 409 when a legacy claims pass fails and 500 when the Postgres walk fails.source=auto, which the web uses, still falls back to stored events with 200.Deploy
Migrations 064 and 065 run first. Then, inside the backend container, in this order:
backfill_replay_facts.py --apply.backfill_turn_slices.py --apply --recheck. The facts backfill is a bulk UPDATE, so it stales every lines key; on the bench a plain--applyfound 1 thread and--recheckfound 108, and until then thinning refused 16 threads.thin_message_snapshots.py --keep all --apply, right after the deploy. This PR also moves the snapshot frequency to 250, and until step 3 runs, a migrated thread with no snapshot yet walks to its root for up to 250 updates instead of 50. That is a load cost, not a correctness one, and it is the same walk such threads take on main today.thin_message_snapshots.py --applyin batches (--thread,--limit), whenever convenient. Disk returns only after VACUUM FULL or pg_repack.repair_crossed_threads.py --apply, after steps 2 and 3. A thread that crossed before its first summary has nothing to trim yet; rerun the script after its first anchored compaction. Do not runreplay_parity.py --cache onbeforehand: it deletes the thread's slices, and the repair then refuses.Existing turns are projected on their first read if step 2 has not reached them. Rerun step 1 immediately before the
sse_eventscolumn is ever dropped, for rows written in between. A thread starts trimming only after its next anchored summary and once its slices are stored.Manual verification suggested
?limit=2returns thehistory_pageframe first.scripts/multiworker_gate/).[Compaction] window trimmed N messages, runs up to K; the first model call's input tokens match a regenerate on a copy without slices. Delete the thread'sturn_slicesrows and the log sayswindow waits for the next summaryinstead.alembic downgrade 063rolls 064 and 065 back cleanly.Covered by the DB suites
tests/integration/test_turn_slices_db.py,test_checkpoint_walk_db.py,test_replay_walk_failure_db.py,test_finalize_run_db.py,test_replay_facts_db.py,test_legacy_facts_db.py,test_window_identity_db.py,test_main_state_db.py,test_backfill_turn_slices_db.py,test_thin_message_snapshots_db.pyandtest_repair_crossed_threads_db.py(twin threads, one crossed with six parallel tools per legacy turn, refused before the repair and sending the same model and summary requests as its uncrossed twin after). The window has a deterministic 12-turn harness on a realcreate_agentgraph that byte-compares every model and summary request with the trim on and off (test_window_identity.py), andtest_projection_sources.pyfails when a digested module imports a text-shaping module that is neither digested nor excluded with a reason.Place in the stack
2 of 3, on the fixes PR (#510). The web PR on top (#512) pages the transcript through this PR's
before_turnandlimit; with this PR alone the web client still loads the whole history, from slices.