Skip to content

cli: skip checkpoint writes when the state is unchanged, and one fsync group per drain - #1584

Merged
obadakhalili merged 2 commits into
CodebuffAI:mainfrom
Salhi-zh:cli/checkpoint-skip-unchanged
Oct 30, 2026
Merged

obadakhalili merged 2 commits into
CodebuffAI:mainfrom
Salhi-zh:cli/checkpoint-skip-unchanged

Conversation

@Salhi-zh

Copy link
Copy Markdown
Contributor

What

Checkpoint drains used to unconditionally rewrite the whole persisted state: two multi-MB JSON.stringify passes + 2 file fsyncs + 2 dir fsyncs for run-state.json and chat-messages.json, plus a third atomic write (own file fsync + dir fsync) and an event-loop statSync for the chat-meta sidecar — 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: messages array identity + length + tail-message identity (sound because snapshotSessionState shares message objects and producers mutate through new containers) + runState identity + 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/commitAtomicWrites in write-file-atomic.ts): both tmp files fsync'd before any rename, one dir fsync after both renames. The chat-meta sidecar leaves the drain path and is refreshed at turn end — readChatMeta already validates sidecars by size/mtime with a full-parse fallback, so staleness between drains is the case that validator was written for.

Net

  • Unchanged tick: was ~2× transcript bytes + 6 fsyncs → 0.
  • Changed drain: 6 fsyncs → 3, one removed atomic write, no render-thread 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.

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.
@obadakhalili
obadakhalili merged commit 0d8a85a into CodebuffAI:main Oct 30, 2026
1 of 2 checks passed
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.

2 participants