Repository navigation
cli: skip checkpoint writes when the state is unchanged, and one fsync group per drain - #1584
Merged
obadakhalili merged 2 commits intoOct 30, 2026
Merged
Conversation
The SDK's snapshot timer fires at the interval whether or not the run made progress, and every drain used to re-serialize both documents and rewrite them through writeFileAtomicAsync: two multi-MB JSON.stringify passes plus 2 file fsyncs + 2 dir fsyncs for bytes identical to the ones already on disk. At a 1 MB transcript on the scaled 5s cadence that is ~1.4 GB/hr of rewritten bytes while the run sits waiting on a tool call. Checkpoint drains now keep the state identity of the last successful persist per chat dir and compare before serializing. The snapshot the SDK emits shares its message objects with the live run (snapshotSessionState, PR CodebuffAI#1571): producers mutate through new containers (blocks: [...], tail copy, fresh array), so array identity + tail message identity + length is an O(1) changed-check instead of a defensive full re-serialize. The run state gets no such discipline from its producers, so its mutation-relevant scalars (context token count, credits, steps remaining, agent type, compaction summary text) are digested by content. A fingerprint failure errs toward writing. Both files in a drain also used to pay a directory fsync each — and it is the same directory. The drain prepares both tmp files (contents durable before either name exists), publishes both renames, then flushes the directory once via new prepareAtomicWrite/commitAtomicWrites helpers in write-file-atomic. Durability ordering is unchanged: every tmp file is fsync'd before its rename, and the directory fsync lands after all renames. Authoritative saves (saveChatState — turn completion, interrupt, exit flush) still write unconditionally: they carry the final state the user loses if a crash lands before the next checkpoint, and paying one write at turn boundaries costs nothing next to the run that produced it. Net for an unchanged tick: 0 serializes, 0 writes, 0 fsyncs; for a changed drain: 2 file fsyncs + 1 dir fsync instead of 2 + 2.
writeChatMeta ran inside every async checkpoint drain: a third atomic write (its own file fsync + dir fsync) plus a statSync of the multi-MB chat-messages.json on the render thread, all to refresh a sidecar that exists to summarize a settled chat for /history — and that readChatMeta validates against the messages file's size/mtime with a full-parse fallback by design. A sidecar that reads stale between drains is the case that validator was written for. The drain path no longer writes it. It is refreshed at the completion boundary instead: saveChatState (turn completion, interrupt, error, exit flush) keeps writing it after the messages file, and the completion path in use-send-message refreshes it directly so even a turn whose final save skipped entirely (a sponsored turn whose exact state was already checkpointed) leaves a fresh sidecar behind. Net for a mid-turn drain: one fewer atomic write, one fewer statSync on the render thread, and 2 fewer fsyncs — while /history kept its fallback for the staleness window and loses nothing at turn end.
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.
What
Checkpoint drains used to unconditionally rewrite the whole persisted state: two multi-MB
JSON.stringifypasses + 2 file fsyncs + 2 dir fsyncs forrun-state.jsonandchat-messages.json, plus a third atomic write (own file fsync + dir fsync) and an event-loopstatSyncfor thechat-metasidecar — every drain, ~720/hour. At a 1 MB transcript on the scaled cadence that's ~1.4 GB/hr of rewritten bytes, much of it while the run sits waiting on a tool call with nothing changed.Changes (2 commits)
1. Skip unchanged drains. Per chat dir, keep the identity fingerprint of the last successful persist:
messagesarray identity + length + tail-message identity (sound becausesnapshotSessionStateshares message objects and producers mutate through new containers) +runStateidentity + a cheap digest of the mutation-relevant scalars (contextTokenCount,creditsUsed,stepsRemaining,agentType,compactionSummary.text). Unchanged → 0 serializes, 0 writes, 0 fsyncs. Fingerprint failure errs toward writing. Authoritative saves (saveChatState— completion / interrupt / exit flush) always write.2. Coalesce durability; move the sidecar to turn end. The two drain files now share one directory fsync (
prepareAtomicWrite/commitAtomicWritesinwrite-file-atomic.ts): both tmp files fsync'd before any rename, one dir fsync after both renames. Thechat-metasidecar leaves the drain path and is refreshed at turn end —readChatMetaalready validates sidecars by size/mtime with a full-parse fallback, so staleness between drains is the case that validator was written for.Net
statSync.Tests
7 new tests (skip-on-repeat, one-pass-on-change, 1-char-append re-writes, scalar-change re-writes, sidecar-not-in-drains, shared dir-fsync syscall assertion, sidecar stale-mid-turn/fresh-at-turn-end). Full cli suite: 4340 pass / 46 skip / 0 fail + typecheck clean.
Builds on the message-sharing from #1571.