Skip to content

feat(agents): give Claude Code snapshot/v1's changelog - #296

Merged
earakely-scale merged 5 commits into
mainfrom
feat/claude-code-changelog
Oct 22, 2026
Merged

earakely-scale merged 5 commits into
mainfrom
feat/claude-code-changelog

Conversation

@earakely-scale

@earakely-scale earakely-scale commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator

What and why

The shipped Claude Code agent served snapshot/v1's save and load, but not its changelog. So a run on it couldn't be rewound to a tool call and resumed there, which the wrapper this agent was ported from could do. This adds enable-changelog and apply-changelog.

  • Capture:
    • enable-changelog installs a capture hook for both PostToolUse and PostToolUseFailure in the CLI's user settings.
    • After each tool call, whether it succeeded or failed, the hook archives that call's writes as a GNU incremental tar named <position:06d>.tgz, the position being the call's zero-based ordinal. It uploads the archive through the namespace grant with the protocol's NamespaceUploader.
    • Each archive also carries a small metadata file: the position, the tool call's session, and the capture roots removed since the last stored capture. The file lives in its own directory, outside every capture root.
    • The incremental state moves forward only once an archive is stored. If tar fails (exit 2 or more), an upload fails, or the grant's limits refuse an archive, the next archive carries those writes.
    • A reservation file keeps the grant's object and byte limits across hook processes.
    • enable-changelog refuses (409) while a turn runs. Once capture is on, turns run one at a time, so two contexts can't interleave in one sequence.
  • Apply:
    • apply-changelog downloads the granted increments in order and refuses one that isn't a readable archive (422).
    • It extracts each increment at / with --incremental, so deletions replay too, and leaves the capture's metadata out of the extraction.
    • It replays the removed roots and drops any capture hook restored from the agent that captured.
    • With resume_conversation, it binds the target context to the transcript of the last applied tool call's session, under a fresh session id; a target already bound is rebound.
    • It refuses while a turn runs (409).
    • A snapshot load also drops restored capture hooks.
  • Where: only on a host whose tar is GNU tar, which the image has. An install into Alpine or busybox serves the snapshot without the changelog.
  • Changelog directory: one per port, outside every capture root, and 0700. It's refused if it's a link, not a directory, or owned by another user, since its config holds the grant.

The module is tar_changelog.py, not changelog.py, so it doesn't clash with the pi agent's changelog module when both load in one interpreter.

Known limitation: the hook runs before the CLI appends the tool call's result to the transcript. So increment k holds tool call k's writes but not its result, and a resumed conversation is missing the last applied call's result. The wrapper this agent was ported from behaves the same way. Fixing it means archiving at the next tool call, the turn's end or the CLI's exit, which is left for a follow-up.

How it was tested

  • Unit tests: tst/unit/agents/claude_code/test_changelog.py covers:
    • capture:
      • a real hook process end to end, where each tool call's increment holds only its own writes and the replay restores the final state, deletions included;
      • a failed call taking its own position;
      • a failed tar, failed upload or refused upload leaving its writes to the next increment;
      • limits holding across hook processes;
    • metadata:
      • every capture archives its metadata;
      • a failed metadata write still captures;
      • deleting the CLI's state directory doesn't stop the capture of the other roots;
      • a user file with the metadata's name is restored;
    • apply:
      • removed roots replaying at any position, custom roots included;
      • resuming the last tool call's session over a larger transcript;
      • enable refusing while a turn runs, and a turn that arrives during enable being serialized;
      • the GNU tar gate and both cards;
      • directory refusal;
      • hook cleanup after an apply and after a snapshot load.
  • Test runs:
    • The GNU tar tests skip on macOS and run on Linux. tst/unit/agents passes on Linux with GNU tar 1.35: 293 passed, 0 skipped.
    • CI's unit command passes on macOS: 9483 passed, 49 skipped. ruff check is clean.
  • Live, on a dev deployment. The image was built from main (with fix(agents): open the 1M window for native-1M Claude models passed without [1m] #294) plus this branch, with the protocol pinned as a release pins it.
    • A2A validator: every check matches the agent this replaces, including the changelog's capture, apply and marker-read round trip.

    • Rewind to a tool call onto a fresh agent:

      • after three calls, the workspace holds exactly the first two calls' files;
      • when the middle call fails, the cut is still at the right call;
      • a capture root that a tool removed is gone after replay;
      • after a long conversation on one context and a tool call on another, the resumed agent continues the second.

      The wrapper failed the last three: it brought back the third call's file, kept the removed root, and resumed the wrong conversation.

    • Chaos battery against the agent it replaces, identical faults:

      • model 529, 500, reset and stall faults, a 401, the endpoint dropped mid-turn, the CLI and the container killed mid-turn, an install, a reinstall mid-turn, and an install killed mid-install;
      • the same outcome on every one, the next prompt passing, no CLI left running and no key in any output.

🤖 Generated with Claude Code

RetriggerView in GreptileConfidence Score: 5/5

The PR appears safe to merge; the previous finding is fixed and no new actionable issues were found.

Summary

Adds Claude Code changelog capture and replay through snapshot/v1.

  • Claude Code saves each tool call as an ordered changelog step.
  • Claude Code can replay changelog steps and resume their conversation.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  Tool[Tool call succeeds or fails] --> Hook[Capture hook]
  Hook --> Tar[GNU incremental tar]
  Tar --> Grant[Upload through namespace grant]
  Grant --> Apply[Apply granted increments]
  Apply --> Files[Restore writes and deletions]
  Apply --> Resume[Optionally bind restored conversation]
Loading

Reviews (4) · Last reviewed commit: "fix(agents): only treat the capture's re..." · Reviewed by Greptile

earakely-scale and others added 2 commits October 21, 2026 17:31
The shipped Claude Code agent served snapshot save and load but not the
changelog, so a run couldn't be rewound to a tool call and resumed there.

Where the host's tar is GNU tar, the agent now serves enable-changelog and
apply-changelog:

- enable writes a capture script and baselines a GNU incremental tar of the
  capture roots (the workspace, the CLI's state with its transcripts, home,
  /tmp/data, /usr/local and /etc), then installs the script as the CLI's
  PostToolUse hook. After each tool call the hook archives that call's writes
  and uploads them through the namespace grant as <position:06d>.tgz, using
  the protocol's NamespaceUploader; a reservation file keeps the grant's
  object and byte limits across hook processes.
- apply downloads the increments in order, refuses one that isn't a readable
  archive, extracts each at /, drops a capture hook restored from the agent
  that captured, and with resume_conversation binds the target context to the
  restored transcript under a fresh session. It refuses while a turn runs and
  onto a context already bound, as a snapshot load does.
- The changelog directory is one per port, outside every capture root, and
  readable by the agent's user alone. Where tar isn't GNU tar (an install into
  Alpine or busybox), the agent serves the snapshot without the changelog.

The module is tar_changelog.py, so it doesn't share a name with the pi
agent's changelog module when both load in one interpreter.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…aude Code's changelog

- Capture after a failed tool call too. PostToolUse runs only after one
  that succeeded, so a failed call took no position and its writes went
  uncaptured. The capture is now registered for PostToolUseFailure as well,
  and a restored hook is removed from both events.
- Don't promote the incremental-tar state before its archive is stored. A
  position now reserves and uploads while it holds its turn; when the grant's
  limits refuse it or the upload fails, the state stays at the last stored
  archive, so the next position's archive carries the writes.
- Drop a capture hook a snapshot load restores, as an apply does.
- Rebind a target context that's already bound, as a snapshot load and other
  agents do, instead of refusing it.
- While a changelog is captured, run one turn at a time, so two contexts'
  tool calls can't interleave in one position sequence.
- Refuse a changelog directory that's a link, not a directory, or owned by
  another user, before writing the grant's config into it or applying from it.
- after_apply is required, directory is a Path, and the docstrings are shorter.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@earakely-scale
earakely-scale requested a review from a team as a code owner October 22, 2026 01:14
Comment thread src/agent_env/agents/agents/claude-code/a2a_server.py Outdated
Comment thread src/agent_env/agents/agents/claude-code/a2a_server.py Outdated
Comment thread src/agent_env/agents/agents/claude-code/tar_changelog.py Outdated
Comment thread src/agent_env/agents/agents/claude-code/tar_changelog.py Outdated
…laude Code's changelog

- Resume the conversation the rewind cut. Each capture records the session
  of the tool call it archives, which the CLI's post-tool hooks receive, and
  apply resumes the last applied increment's session. It falls back to the
  largest restored transcript only when an increment records none.
- Before tar, each capture writes its position, that session and the roots
  removed since the last stored capture into a small metadata file in the
  CLI's state directory, so the increment carries it. Apply reads it after
  extracting each increment and replays the removals: only an absolute,
  normalized path that exists and isn't / or a top-level system directory.
  The roots present at the last stored capture (the baseline at enable) are
  promoted with the incremental state, so a removal survives a capture that
  wasn't stored.
- Keep tar's stderr and check its status: exit 1 (a file changed as tar read
  it) is stored with a warning; 2 or more fails the capture, which stores
  nothing, keeps the state for the next position and still advances the turn.
- Admit turns through one lock: a turn reads whether it's captured, and an
  uncaptured one is counted, under the lock enable holds while it refuses a
  running turn (409), enables and turns the capture on. No turn can start
  uncaptured while a capture begins.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Comment thread src/agent_env/agents/agents/claude-code/tar_changelog.py Outdated
Comment thread src/agent_env/agents/agents/claude-code/tar_changelog.py Outdated
Comment thread src/agent_env/agents/agents/claude-code/tar_changelog.py Outdated
…e roots

- Each capture's metadata (its position, its tool call's session and the
  roots removed since the last stored capture) now lives in its own 0700
  directory beside the changelog's, outside every capture root, instead of in
  the CLI's state. The hook writes it there and hands it to tar as one more
  member, so removals and the session replay with custom roots too, and a tool
  deleting the CLI's state no longer stops the capture. A metadata write that
  fails is logged, and that capture goes on without it.
- Apply finds the metadata in the listing it already makes, by its basename,
  so a target with another temp directory or port still reads it. It reads the
  member to stdout and leaves it out of the extraction, so nothing lands on the
  target.
- Apply logs the number of increments it applied, and the first few tar
  exited non-zero on, instead of every increment.
- The hook's and module's limits and intervals are named constants carrying
  their unit.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Comment thread src/agent_env/agents/agents/claude-code/tar_changelog.py
…e Code changelog metadata

Apply excluded every archive member named claude-code-changelog-meta.json, so a user file of that name inside a
capture root was skipped on replay. The metadata now lives in a directory named with a reserved suffix
(.changelog-meta), and apply treats only that file in such a directory as the capture's metadata, wherever the
capturing agent kept it, so any other file of that name is restored like the rest of the layer.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@earakely-scale
earakely-scale merged commit 8ce23f1 into main Oct 22, 2026
15 checks passed
@earakely-scale
earakely-scale deleted the feat/claude-code-changelog branch October 22, 2026 06:11
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.

1 participant