Skip to content

Anchor checkpoints to a message id instead of a position (#57) - #59

Merged
webmatze merged 1 commit into
mainfrom
feat/checkpoint-message-ids
Aug 9, 2026
Merged

Anchor checkpoints to a message id instead of a position (#57)#59
webmatze merged 1 commit into
mainfrom
feat/checkpoint-message-ids

Conversation

@webmatze

@webmatze webmatze commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Closes #57. The last follow-up from #53.

The problem

A checkpoint recorded how many messages the transcript held when it was taken (checkpoints.cr:122), and a rewind cut there (cli.cr:1258). That number is only meaningful as long as nothing in front of it moves — and compaction moves it, by replacing a prefix of turns with a single summary.

#55 handled that by shifting every stored index whenever compaction removed messages. It worked, and it left two rough edges:

  1. A position inside the replaced prefix could not be recovered. Those messages were gone, so the entry was marked transcript_lost and its rewind restored files without touching the transcript. Honest, but bookkeeping that existed only to compute an answer the data could have given directly.
  2. Every future change that reorders or removes messages would have had to remember to shift too. Nothing about message_index : Int32 says "this breaks if anyone edits the array in front of it". That invisibility is why the bug sat latent for exactly as long as the summarizer was unreachable.

The change

LLM::Message carries an id, assigned on creation and preserved wherever compaction rewrites a message rather than removing it — a truncated tool result is still the same message. A checkpoint stores that id; a rewind resolves it against the current transcript through Session::Transcript.index_after.

Compaction then needs no bookkeeping at all. A message that survived still resolves; one that was summarized away does not, which is the same answer the shifting used to compute and the honest one either way.

So these go away: Store#shift_message_indices, Entry#transcript_lost, the agent's call into them, and Context::Result#removed_prefix.

Resolution lives with the caller rather than in the store, which is what the store's own comment already asked for — "The registry has no business knowing about the transcript, so the position is handed in rather than looked up." RestoreResult carries the id; the CLI turns it into an index.

Migration: checkpoints written before ids existed keep their message_index and keep working — the pre-#55 behaviour, rather than being silently wrong. Entry#message_index is nilable now and never written for new entries.

Random ids rather than a counter: smith fork copies a transcript, and two sessions handing out the same numbers would collide.

The id never leaves smith. Every provider builds its request from the role and the blocks, and the estimator does not count it, so nothing about the wire format or the token budget changes.

Testing

857 examples, green.

  • A checkpoint still names the same message after a prefix became a summary — the case the shifting existed for, now with nothing to shift.
  • A checkpoint whose message was summarized away resolves to no position, and its files still restore.
  • Compaction preserves message ids across a rewritten tool result.
  • Ids survive a JSON round trip; a message saved before ids existed gets one on the way in.
  • Checkpoints written before ids existed still load and still name their index.

🤖 Generated with Claude Code

A checkpoint recorded how many messages the transcript held when it was
taken, and a rewind cut there. That number is only meaningful as long as
nothing in front of it moves — and compaction moves it, by replacing a
prefix of turns with a single summary.

#55 handled that by shifting every stored index whenever compaction
removed messages, which worked but left two rough edges. A position
inside the replaced prefix could not be recovered at all, so it was
marked lost. And every future change that reorders or removes messages
would have had to remember to shift too: nothing about `message_index :
Int32` says "this breaks if anyone edits the array in front of it", which
is why the bug sat latent for exactly as long as the summarizer was
unreachable.

LLM::Message now carries an id, assigned on creation and preserved
wherever compaction rewrites a message rather than removing it. A
checkpoint stores that id; a rewind resolves it against the current
transcript. Compaction needs no bookkeeping at all — a message that
survived still resolves, and one that was summarized away does not, which
is the same answer the shifting used to compute and the honest one either
way.

So Store#shift_message_indices, Entry#transcript_lost, the agent's call
into them, and Context::Result#removed_prefix all go away. Checkpoints
written before ids existed keep their index and keep working.

The id never leaves smith: every provider builds its request from the
role and the blocks, and the estimator does not count it.

Closes #57.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@webmatze
webmatze merged commit 63ef57c into main Aug 9, 2026
2 checks passed
@webmatze
webmatze deleted the feat/checkpoint-message-ids branch August 9, 2026 16:04
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.

Checkpoints: anchor to a stable message id instead of an absolute index

1 participant