Checkpoints and rollback in venom
| Field | Value |
|---|---|
| Status | Accepted |
| Accepted | 2026-11-09, authorised in advance by the maintainer for the unattended run that wrote it |
| Authors | commixon (commixon), with Claude |
| Created | 2026-11-09 |
| Issue | venom-org/venom-spec#114; implementation venom-org/venom#155 (capture), #156 (rollback), #157 (conversation), #158 (TUI) |
| Amended | 2026-11-10 (venom-org/venom-spec#141, compatible addition): section 8, claude-code’s rewind row: a take-over of a rewind fork that has not started is refused while fork_at is set, and fork_source ends as RFC-0019 section 4 says; “What RFC-0019 gains” adds the resume record’s fork_at |
| Supersedes | None; adds the rewind operation and capability to RFC-0019, which said “checkpoints are P4” |
| Superseded by | None |
Summary
venom records the state of an agent’s files as checkpoints: git commits
that no branch holds, kept under refs/venom/checkpoints/, taken before each
turn, after each turn and on request, without touching the agent’s branch or
anyone’s index. Rollback restores a chosen checkpoint’s files and, where the
harness can, the conversation as it stood then. This RFC defines when a
checkpoint is taken, what it holds, where it lives and when it goes, how a
rollback runs in a worktree and in a tree shared with a person, which states
refuse it, how it relates to agent.fork, and the verbs, events, grants and
error codes. venom implements it. Harness adapters implement one new operation.
Motivation
An agent that makes a mess today can only be stopped. RFC-0019 dropped t3code’s rollback for v1, and venom-org/venom#155 to #158 are blocked until this is written. Three facts shape the design:
- Most turns change files through commands.
sed -i, formatters and code generators leave no tool call a harness could undo. Claude Code’s own file checkpointing doesn’t undo what a Bash command did, and Codex has nothing similar, so venom looks at the tree itself. - An in-place agent shares a tree with a person. RFC-0018 section 7 allows
isolation = in_place. A person’s edits there sit beside the agent’s, and a careless rollback would destroy them without a trace. Everything in section 7 exists for this case. - The harness owns the conversation. Codex 0.167.0 has no call that shortens
a thread (
thread/rollbackis refused as an unknown method), so “undo the last turns” can only mean continuing from a copy.
A note on words: venom has two other things called checkpoints: RFC-0003’s
stream.checkpoint control item and the compaction of venom’s event log. Neither
is related to this RFC. Here, checkpoint always means a saved state of an
agent’s files.
Design
1. Overview by example
Agent a2 works in a worktree of venom-std. Each turn leaves checkpoints:
$ venom agent checkpoints --agent=a2 --json
{"agent":"a2","pending":false,"checkpoints":[{"checkpoint":"k3","reason":"turn_end","turn":"t2","through_turn":"t2","at":"2026-11-09T12:04:10.120Z","mid_turn":false,"overlap":false,"conversation":true,"repos":[{"repo":"venom-std","path":"/home/u/venom-org/venom-std-246","commit":"3b7e9d10c2a4f6815e7d9b0c1a2f3e4d5c6b7a89","head":"8f3a1c9e5d7b2046a1c3e5f708192a3b4c5d6e7f","branch":"246-frame","skipped":[],"submodules":[]}]},{"checkpoint":"k2","reason":"turn_end","turn":"t1","through_turn":"t1","at":"2026-11-09T12:01:02.300Z","mid_turn":false,"overlap":false,"conversation":true,"repos":[{"repo":"venom-std","path":"/home/u/venom-org/venom-std-246","commit":"c41d07ae96b3528f10e7c6d5b4a39281f0e1d2c3","head":"8f3a1c9e5d7b2046a1c3e5f708192a3b4c5d6e7f","branch":"246-frame","skipped":[],"submodules":[]}]},{"checkpoint":"k1","reason":"turn_start","turn":"t1","through_turn":null,"at":"2026-11-09T12:00:00.040Z","mid_turn":false,"overlap":false,"conversation":true,"repos":[{"repo":"venom-std","path":"/home/u/venom-org/venom-std-246","commit":"5e20f8b7d1c6a3940e8f7a6b5c4d3e2f1a0b9c8d","head":"8f3a1c9e5d7b2046a1c3e5f708192a3b4c5d6e7f","branch":"246-frame","skipped":[],"submodules":[]}]}]}
They are ordinary git objects, and git’s own tools can read them:
$ git -C ~/venom-org/venom-std for-each-ref refs/venom/checkpoints/a2
5e20f8b7d1c6a3940e8f7a6b5c4d3e2f1a0b9c8d commit refs/venom/checkpoints/a2/1
c41d07ae96b3528f10e7c6d5b4a39281f0e1d2c3 commit refs/venom/checkpoints/a2/2
3b7e9d10c2a4f6815e7d9b0c1a2f3e4d5c6b7a89 commit refs/venom/checkpoints/a2/3
$ git -C ~/venom-org/venom-std diff --stat refs/venom/checkpoints/a2/2 refs/venom/checkpoints/a2/3
src/proto/frame.zig | 10 +++++++---
1 file changed, 7 insertions(+), 3 deletions(-)
Turn t2 went wrong. The user asks what going back to k2 would change. A dry
run changes no file and returns a plan, and the real call must quote it:
$ venom agent rollback --agent=a2 --checkpoint=k2 --dry-run=true --json
{"agent":"a2","checkpoint":"k2","dry_run":true,"plan":"p1-6d0f4a2be19c7735","restore":"all","guarded":false,"conversation":{"rewinds":true,"through_turn":"t1","drops":["t2"]},"shared_with":[],"children":[],"repos":[{"repo":"venom-std","path":"/home/u/venom-org/venom-std-246","head":{"moves":false,"from":"8f3a1c9e5d7b2046a1c3e5f708192a3b4c5d6e7f","to":"8f3a1c9e5d7b2046a1c3e5f708192a3b4c5d6e7f","commits":0},"files":[{"path":"src/proto/frame.zig","change":"modify","class":"agent"}],"left":[],"ignored_edits":[]}]}
$ venom agent rollback --agent=a2 --checkpoint=k2 --plan=p1-6d0f4a2be19c7735 --json
{"agent":"a2","checkpoint":"k2","dry_run":false,"restore":"all","safety":"k4","after":"k5","exact":true,"conversation":{"rewinds":true,"through_turn":"t1","drops":["t2"],"harness_session":"db5b5038-2eca-45a0-bff2-ef5647c506fb"},"shared_with":[],"children":[],"repos":[{"repo":"venom-std","path":"/home/u/venom-org/venom-std-246","head":{"moves":false,"from":"8f3a1c9e5d7b2046a1c3e5f708192a3b4c5d6e7f","to":"8f3a1c9e5d7b2046a1c3e5f708192a3b4c5d6e7f","commits":0},"files":[{"path":"src/proto/frame.zig","change":"modify","class":"agent"}],"left":[],"ignored_edits":[]}]}
k4 holds the tree as it was before the rollback, so the rollback can itself be
rolled back. The event stream shows it:
$ venom events --types '["agent.checkpoint","agent.rollback_*"]' --resources '["agent:a2"]' --json
{"seq":312,"ts":"2026-11-09T12:06:00.410Z","source":"venom/5b0c8d1e-2f3a-4b5c-9d6e-7f8091a2b3c4","type":"agent.checkpoint","data":{"agent":"a2","checkpoint":"k4","reason":"pre_rollback","through_turn":"t2","repos":[{"repo":"venom-std","commit":"a90b18c7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f201"}]}}
{"seq":313,"ts":"2026-11-09T12:06:00.415Z","source":"venom/5b0c8d1e-2f3a-4b5c-9d6e-7f8091a2b3c4","type":"agent.rollback_started","data":{"agent":"a2","checkpoint":"k2","safety":"k4","restore":"all"}}
{"seq":314,"ts":"2026-11-09T12:06:00.700Z","source":"venom/5b0c8d1e-2f3a-4b5c-9d6e-7f8091a2b3c4","type":"agent.checkpoint","data":{"agent":"a2","checkpoint":"k5","reason":"rollback","through_turn":"t1","repos":[{"repo":"venom-std","commit":"71fe02d3c4b5a69788f9e0d1c2b3a4f5e6d7c8b9"}]}}
{"seq":315,"ts":"2026-11-09T12:06:00.702Z","source":"venom/5b0c8d1e-2f3a-4b5c-9d6e-7f8091a2b3c4","type":"agent.rollback_ended","data":{"agent":"a2","checkpoint":"k2","safety":"k4","restore":"all","ok":true,"after":"k5","drops":["t2"]}}
(RFC-0003’s type selectors are exact names or prefix.*, so the command above
would have to name agent.rollback_started and agent.rollback_ended, or use
agent.*. It is written short here.)
The same rollback with an in-place agent, where a person edited README.md
between turns, is refused until the person says what to do with that file:
$ venom agent rollback --agent=a5 --checkpoint=k2 --restore=files --dry-run=true --json
{"error":{"code":"conflict.outside_edits","message":"2 paths changed outside the agent's turns; choose --outside=keep or --outside=overwrite","data":{"repos":[{"repo":".","paths":["README.md","notes/todo.md"]}]}}}
$ venom agent rollback --agent=a5 --checkpoint=k2 --restore=files --outside=keep --dry-run=true --json
{"agent":"a5","checkpoint":"k2","dry_run":true,"plan":"p1-0b7c91e4a35d2f68","restore":"files","guarded":true,"conversation":{"rewinds":false,"through_turn":"t1","drops":[]},"shared_with":[],"children":[],"repos":[{"repo":".","path":"/home/u/scratch","head":{"moves":false,"from":"e17a60f2b3c4d5968778695a4b3c2d1e0f9a8b7c","to":"e17a60f2b3c4d5968778695a4b3c2d1e0f9a8b7c","commits":0},"files":[{"path":"src/main.zig","change":"modify","class":"agent"},{"path":"gen/out.txt","change":"delete","class":"turn"}],"left":[{"path":"README.md","reason":"outside"},{"path":"notes/todo.md","reason":"outside"}],"ignored_edits":[]}]}
2. Terms and invariants
| Term | Meaning |
|---|---|
| checkpoint | A saved state of the files of every repo in an agent’s scope, with an id k<n> per agent, like t<n> and c<n> (RFC-0019). |
| tree | The folder one repo of an agent’s scope is checked out in: a worktree, a copy, or the repo itself for in_place. |
| capture | Making one checkpoint commit for one tree (section 4). |
| guarded tree | A tree where a rollback could overwrite somebody else’s work: an in_place tree, or any tree another non-archived agent also uses (a fork, an in-place child). Section 6. |
| sharer | Another non-archived agent using the same tree, compared by canonical path. |
| live | An agent in queued, running, needs_you or handed_over. |
| safety checkpoint | The checkpoint (reason: pre_rollback) every rollback makes before it writes anything. |
| window | Two consecutive captures of one tree, including ones that were deduplicated (3.2). |
Invariants. An implementation MUST keep all of these, and each has a test in venom-org/venom#155 or #156:
- A capture changes nothing the user can see in the tree. Not the working
files, the index,
HEAD, a branch or a stash. It adds objects and one ref. - Nothing is written before a safety checkpoint exists. If it can’t be made, the rollback fails with nothing changed.
- A rollback never touches a file that isn’t in its plan. Ignored files, above all, are neither captured nor deleted nor overwritten.
- In a guarded tree, venom never moves
HEADor a branch and never writes the index. Those belong to the person. - Files and conversation go together or not at all, unless the caller asked for files alone.
- A checkpoint is protection against accidents, not a security boundary. An agent that can run commands can delete refs too (section 13).
3. When a checkpoint is taken
The issue offered three moments: venom takes all three. Each one answers a question the others can’t.
reason |
When | Why |
|---|---|---|
turn_start |
Before venom delivers a send of mode = "turn" that starts a turn |
What was in the tree when the agent began, and so what changed between turns. Without it, a person’s edit can’t be told from the agent’s (7.3). |
turn_end |
After turn.ended, whatever its reason (completed, interrupted, failed, limit). Also after the synthetic end of RFC-0019 section 9.2 |
What this turn produced. The usual rollback target. |
manual |
agent.checkpoint |
A point the user wants to keep, for example before approving something risky. May be taken mid-turn. |
takeover |
After release, before the take-over split opens (RFC-0019 section 7) |
The start of a stretch venom can’t see into. |
takeback |
After catch_up, before reattach (RFC-0019 section 7.4) |
All the work done in the harness’s own TUI, as one checkpoint. |
fork |
When agent.fork succeeds (section 10) |
Lets a fork go back to the files of the turn it forked at. |
pre_rollback |
First step of a rollback | The safety checkpoint. |
rollback |
Last step of a rollback | What the tree looks like afterwards, and the proof of what happened (7.4). |
3.1 Ordering and the reactor
Captures run as checkpoint.capture outbox effects, keyed
checkpoint:<agent>:<n>, like harness.fork (RFC-0019 section 4). They MUST NOT
hold the reactor: every git command is a child process the reactor polls, and the
effect steps with error.Retry-style calls until it ends. isolation.zig’s
synchronous git MUST NOT be reused here, since a capture of a large repo can take
seconds.
- An agent’s capture and rollback effects run one at a time, oldest first. Effects on one tree run one at a time across agents.
turn_startholds thesenduntil the capture ends or fails. The agent is alreadyrunning, so the input box doesn’t wait. The hold is bounded bycheckpoints.timeout, and a capture that ran out of time lets the turn go.turn_enddoesn’t holdsettled. Asendthat arrives while it is pending waits behind it, by the first rule.agent.rollbackfails withconflict.checkpoint_pendingmeanwhile, so the plan is never computed against a turn not yet recorded.- Mid-turn steering (
mode = "steer") starts no turn, and no capture. - A turn the harness starts by itself (
origin: harness), or a message Claude Code queued (steer = "queue"), starts without aturn_start. If such a turn began before the previousturn_endcapture finished, that checkpoint hasoverlap: true: it may hold part of the next turn, and it can’t rewind the conversation (section 8). - Turns imported by
catch_uphave no checkpoints of their own.takebackcovers them all.
A failed capture never stops a turn. It logs checkpoint.failed, publishes
agent.checkpoint_failed and leaves a row in the transcript. Stopping the work
to protect the work would be the wrong trade, and rollback handles the gap
conservatively: the window around it counts as outside the agent’s turns (7.3).
3.2 Deduplication
Most turn_start captures find what the previous turn_end left. When every
repo’s tree and HEAD equal the latest checkpoint’s, venom makes no new ref and
no new id. It logs checkpoint.reused {agent, checkpoint, reason, turn}, which
is internal and not published, so the window logic of 7.3 still sees the capture.
agent.checkpoint with a label sets the label on the checkpoint it reused. The
dedup never applies to pre_rollback’s successor (rollback always gets its own
record) or to fork.
3.3 through_turn
Each checkpoint records through_turn: the last turn that had ended when it was
taken, or null before the first. It says what conversation goes with these
files. A turn_start of t5 and a turn_end of t4 both have
through_turn: "t4". A mid-turn manual checkpoint has the last finished turn
too, plus mid_turn: true, so its files don’t match any point in the
conversation.
4. What a checkpoint captures
| What | Captured | Rollback |
|---|---|---|
| Tracked files, staged or not | Working-tree content | Restored |
| Untracked files not ignored | Yes | Restored, and those created since are deleted |
| Deleted files | Absent from the tree | Restored or deleted to match |
| Modes and symlinks | Yes, as git records them | Restored |
Ignored files (.gitignore, .git/info/exclude, core.excludesFile) |
No | Never touched |
| Submodules and nested repos | The commit they are at (a gitlink), nothing inside | Never touched; a difference is reported in left |
Files over checkpoints.max_file_bytes, new or changed |
No; listed in skipped |
Never touched; reported in left |
Paths marked assume-unchanged |
Yes, the working-tree content | Restored |
Paths marked skip-worktree (sparse checkout) |
The index’s content; listed in skipped |
Never touched; reported in left |
| LFS and other clean filters | What the filter gives (a pointer) | The smudge filter runs on restore |
| The index itself, stashes, in-progress merge state | No | No |
| Files outside any repo in scope | No | No |
The reasoning for the three “no”s:
- Ignored files are build output and caches, often huge, and capturing them
every turn is what makes this kind of feature unusable. They also often hold
secrets on purpose. The price: an agent’s edit to an ignored file can’t be
undone, so the plan lists any such edit it knows of (
ignored_edits, fromedittool calls). - Submodules are other repositories with their own history. A rollback that
reached into one could lose commits nothing else holds. Only the pointer is
recorded, and a dirty submodule is flagged in
submodules[].dirtyso the plan can warn about it. - Big files are skipped per file instead of failing the capture, because one
stray
core.dumpshouldn’t cost the user every other file. The checkpoint’s tree keeps what the index had for that path.
A capture whose new and changed files together pass
checkpoints.max_total_bytes, after the skips, fails with
failed.checkpoint_too_large, and data.largest names the five largest paths.
That usually means a build directory nobody ignored.
4.1 The capture recipe
For one tree. These commands were run against git 2.56.0, on a throwaway repo,
with the results listed in invariant 1; $RT is venom’s runtime directory. Every
git command runs with the configuration pinned, from the tree, with hooks off.
reference-transaction hooks would otherwise fire on update-ref, and an
fsmonitor is a program too:
git() { command git -c core.hooksPath=/dev/null -c core.fsmonitor=false \
-c core.untrackedCache=false -c core.quotePath=false \
-c status.showUntrackedFiles=normal "$@"; }
# 1. Start from a copy of the real index, so the stat cache spares hashing the
# tree. git replaces its index by rename, so the copy is never half written.
# With no index yet (a repo with no commit), start from none.
cp "$(git rev-parse --path-format=absolute --git-path index)" "$RT/checkpoint/a2-3.index"
export GIT_INDEX_FILE="$RT/checkpoint/a2-3.index"
# 2. In the copy only, clear assume-unchanged, so those edits are seen.
git ls-files -v -z | python3 -c 'import sys
for e in sys.stdin.buffer.read().split(b"\0"):
if e[:1].islower(): sys.stdout.buffer.write(e[2:] + b"\0")' \
| git update-index -z --no-assume-unchanged --stdin
# 3. Find what is over the size limit: stat each path this lists.
git status --porcelain=v1 -z --no-renames --untracked-files=all
# 4. Stage everything but the skipped paths, into the copy.
printf ':(exclude,literal)big.bin\0.\0' \
| git add -A --pathspec-from-file=- --pathspec-file-nul
tree=$(git write-tree)
unset GIT_INDEX_FILE; rm -f "$RT/checkpoint/a2-3.index"
# 5. Make the commit. commit-tree doesn't read commit.gpgsign, and needs no
# identity from the user. Leave out -p HEAD when HEAD has no commit.
export GIT_AUTHOR_NAME=venom GIT_AUTHOR_EMAIL=venom@localhost
export GIT_COMMITTER_NAME=venom GIT_COMMITTER_EMAIL=venom@localhost
commit=$(git commit-tree -p HEAD -F - "$tree" <<'MSG'
venom checkpoint a2/k3
Venom-Agent: a2
Venom-Checkpoint: k3
Venom-Reason: turn_end
Venom-Turn: t2
MSG
)
# 6. Create the ref. The empty old value makes this fail if the ref exists.
git update-ref "refs/venom/checkpoints/a2/3" "$commit" ""
(The Python in step 2 stands for venom’s own filter; there is no Python in venom.
git status in step 3 and the . pathspec keep to the tree, and venom MUST
check the tree is a git root of its own first, as isolation.zig does, since git
walks up into an enclosing repo otherwise.)
The commit’s parent is HEAD at capture time, so head is recoverable from the
commit too, and commits the agent made stay reachable from the checkpoint.
A repo with no commit yet can be captured when it is in_place: the commit has
no parent and head is null, and HEAD stays unborn.
4.2 What goes in the log
checkpoint.taken is the record, published as agent.checkpoint. Refs hold the
objects, and the log holds the truth: venom lists checkpoints from the log, and a
rollback checks that the ref still resolves to the logged commit. A ref that is
gone or has moved makes that checkpoint not_found.checkpoint. A ref no log
record names is ignored by every verb, and doctor reports it.
{"type":"checkpoint.taken","agent":"a2","checkpoint":"k3","reason":"turn_end","turn":"t2","through_turn":"t2","at":"2026-11-09T12:04:10.120Z","mid_turn":false,"overlap":false,"repos":[{"repo":"venom-std","path":"/home/u/venom-org/venom-std-246","commit":"3b7e9d10c2a4f6815e7d9b0c1a2f3e4d5c6b7a89","head":"8f3a1c9e5d7b2046a1c3e5f708192a3b4c5d6e7f","branch":"246-frame","skipped":[],"submodules":[]}]}
Member of repos[] |
Meaning |
|---|---|
repo |
Relative to the workspace, as in worktrees (RFC-0018) |
path |
The tree’s absolute path |
commit |
The checkpoint commit |
head |
HEAD’s commit, or null when the repo has none |
branch |
The branch HEAD is on; absent when detached |
skipped |
[{path, bytes, reason}], reason being too_big or skip_worktree |
submodules |
[{path, commit, dirty}], for gitlinks in the tree, nested repos included |
An effect runs at least once. A daemon that stopped between the ref and the log
record finds its ref on the retry: if the ref’s commit has this agent and id in
its trailers and is not older than the effect’s request, venom logs it as it is.
Anything else means the id was used by a log this daemon no longer has (the user
deleted it), and venom takes the next free n.
5. Storage
5.1 Namespace
refs/venom/checkpoints/<agent>/<n>
<n> is the number in k<n>. Every repo in scope gets one ref per checkpoint,
pointing at the same commit as before when that repo didn’t change. So a checkpoint
is complete in every repo it names, and pruning has no dependencies to follow.
| Isolation | Where the refs are | Survives the tree’s removal |
|---|---|---|
worktree |
The source repo. Refs under refs/venom/ are shared across linked worktrees, and git keeps no reflog for them |
Yes, until agent.clean |
in_place |
The repo itself | Yes |
copy |
The copy’s own .git |
No: they go with the folder |
A workspace with no git repos has nowhere to keep them. Automatic capture does
nothing there, and agent.checkpoint and agent.rollback fail with
unsupported.checkpoint_no_repo. Open question 3.
Things a user can notice: git log --all and gitk --all show these commits.
git log --branches --tags doesn’t. git push and git clone don’t transfer
them, but git push --mirror does, including any untracked file that wasn’t
ignored, so doctor warns when a remote has mirror set (checkpoints.mirror).
5.2 Retention
checkpoint.prune effects run after a turn_end, at daemon start and after
agent.clean. They delete refs with git update-ref --stdin, in one transaction
per repo, and log checkpoint.pruned {agent, checkpoints}. The rules, per agent:
| Checkpoint | Goes when |
|---|---|
| The newest one | Never, by pruning |
turn_start, turn_end, takeover, takeback, fork, rollback |
Beyond the newest checkpoints.keep, or older than checkpoints.keep_for |
manual |
Older than checkpoints.keep_for |
pre_rollback |
Older than checkpoints.keep_safety |
Pruning a checkpoint merges the two windows around it, which makes 7.3 more cautious, never less: a merged window spans more than one turn, so it counts as outside the agent’s turns.
venom MUST NOT run git gc, git prune or git maintenance. The objects of a
deleted ref go when the repo’s own gc gets to them.
5.3 agent.clean
agent.clean (RFC-0018 section 2) removes an agent’s checkpoints as follows, and
its result gains checkpoints: [{agent, repo, removed, kept}], counting refs.
worktree: when the worktree’s entry isremovedorgone, all the refs of that agent and of forks that borrowed the worktree go. When it iskept, they all stay: there’s still work there that may want them.copy: they go with the folder.in_place: there is no tree to remove, so cleaning an archived in-place agent removes its refs, exceptpre_rollbackcheckpoints younger thancheckpoints.keep_safety. Those may hold the only copy of a person’s edits. They stay, are counted inkept, and pruning removes them later. So the agent’s checkpoints are all gone withinkeep_safetyat the latest.
A failed deletion is reported and isn’t an error, and pruning retries it.
A checkpoint is never evidence that work is safe. cleanup.zig decides
whether a worktree’s commits exist elsewhere, and it MUST leave refs/venom/ out
of that decision, both ways: copyUnsaved MUST NOT count these refs as work to
save (a copy’s checkpoints would make it impossible to clean), and inLocalRefs
MUST NOT count them as a place the commits are saved (the commits would vanish
when the checkpoint is pruned). countUnsaved uses --branches and is already
correct.
6. Scope types
| Unguarded worktree or copy | Guarded tree (in_place, or shared) |
|
|---|---|---|
| Whose work is in it | The agent’s | Somebody else’s too |
| Restores files | Yes | Yes, path by path |
| Paths changed outside the agent’s turns (7.3) | Restored; shown in the plan | Refused until the caller chooses (--outside) |
Moves HEAD and the branch |
Yes, back to the checkpoint’s, unless --keep-head (7.5) |
Never |
| Resets the index | Yes, to the new HEAD |
Never |
| Who may call it | write on the agent, agents included (section 11.4) |
A person only |
A sharer is any other non-archived agent using the same tree, so a fork makes its source’s worktree guarded as well as its own (section 10). This is on purpose: a rollback of one would overwrite the other’s work just as it would a person’s.
7. Rollback
7.1 Preflight
Both the dry run and the real call run these checks in this order, before any capture. The first that applies is the error. Section 9 explains them.
not_found.agent;not_found.checkpoint(never existed, pruned, or its ref is gone or moved in any repo).conflict.invalid_transition: the agent isarchived.conflict.handed_over: the agent ishanded_over.conflict.turn_running: it isqueued,runningorneeds_you, a turn or compaction was asked for and hasn’t started, or a fork is running (RFC-0019 section 4).conflict.rollback_running: a rollback of this agent or on one of its trees is running.conflict.checkpoint_pending: a capture of this agent is pending.conflict.stale_requests: it has stale inbox items (RFC-0019 section 9.3).conflict.live_children: a descendant is live.conflict.tree_shared: a sharer is live.conflict.git_operation: a merge, rebase, cherry-pick, revert or bisect is under way in a tree (the state filescleanup.zigalready knows).unsupported.harness_capabilityandconflict.checkpoint_files_onlyforrestore = "all"(section 8).conflict.head_movedandconflict.commits_pushed(7.5).conflict.outside_edits(7.3).
7.2 The plan
Rollback is two calls, dry run and real, and a real call without a plan fails
validation (invalid_params.plan). The plan is what makes “what you were shown is
what happens” enforceable, in a tree that can change under a person’s editor.
A dry run captures the current state of each tree (recipe 4.1 through step 5, with
no ref) as S, computes what would change, and returns plan:
p1- followed by the first 16 hex digits of the SHA-256 of this JSON, with no
spaces:
{"checkpoint":"k2","restore":"all","outside":null,"keep_head":false,"repos":[{"repo":"venom-std","tree":"6a1f0c2d3e4b5a69788f9e0d1c2b3a4f5e6d7c8b","head":"8f3a1c9e5d7b2046a1c3e5f708192a3b4c5d6e7f"}]}
repos is sorted by repo, and tree and head are S’s. p1 is this format’s
version. Nothing is stored between the calls: the real call repeats the preflight
and capture and recomputes the digest. A different digest fails with
conflict.rollback_stale. Since the plan is only a digest, the error can’t say
which paths changed; a new dry run shows the new state. A client
MAY retry the dry run and show it again. It MUST NOT retry the real call with the
fresh plan unless a person has seen it.
7.3 Who changed what
In a guarded tree venom needs to know which paths are someone else’s. Git can’t say, but the captures of section 3 can.
Let T be the target. Walk the tree’s captures in time order, from T to S, as pairs (windows):
- A turn window: the same turn of this agent was running at both ends. A
turn_start/turn_endpair is one, and so is either half split by a mid-turnmanualcapture. No other agent using this tree may have had a turn running during it. - A rollback window: its later end has
reason: rollback. Those are venom’s own writes. - Every other window is a gap: between turns, across a take-over, across a
failed capture, across a pruned checkpoint, across a turn with no
turn_start, or shared with another agent’s turn. The last window, from the newest capture to S, is always a gap.
Then for each path that differs between S and T:
| Class | When | Meaning |
|---|---|---|
outside |
A gap changed it | Not the agent’s, or venom can’t show it was |
agent |
Not outside, and an edit tool call of a turn window names it |
The agent did it |
turn |
Anything else | It changed while the agent’s turn ran, but no edit call names it: a command, usually |
--outside says what happens to outside paths:
| Value | Effect | Default for |
|---|---|---|
refuse |
conflict.outside_edits, with the paths. Nothing happens |
Guarded trees |
keep |
Those paths stay as they are, in left with reason: outside. The rest is restored |
|
overwrite |
They are restored like the rest. Their content is in the safety checkpoint | Unguarded trees |
What this does not catch. A person who edits a file during a running turn
looks like the turn: class agent or turn, and it gets restored. Watching the
files during a turn wouldn’t help, since a write can’t be traced to a process. So
the protection for this case is the safety checkpoint and the plan, which shows
every path with its class before anything happens. A person’s edit in this window
can always be recovered: git show refs/venom/checkpoints/a5/9:src/main.zig.
7.4 The real call
Each step logs what it did, so a crash leaves evidence. Repos are handled in
repos order, and S is each tree’s state as step 1 captures it:
- Check the plan (7.2): repeat the preflight, capture S, compare digests.
- Safety. Make
pre_rollbackfrom S, the same commits: new refs, logged, published. Then logrollback.started. Any failure up to here changes nothing. - Ask for the conversation, if
restore = "all"will drop a turn (section 8): the adapter’srewind. It makes a copy, or for claude-code names one the next start makes, and changes nothing in the source. A failure ends the rollback here with the harness’s code, with no file touched. - Apply the files, by the recipe below.
- Move
HEAD, in an unguarded tree (7.5). - Capture again with
reason: rollback, which never dedups, and compare its tree with the expected one (below). A difference means something wrote during the rollback: the result saysexact: false, and the plan’s paths are reported as they are. It isn’t a failure. - Commit in one log commit:
rollback.ended, the turns dropped, and the newagent.binding. Delete nothing. Every later checkpoint stays, so the user can go forward again; section 8 says which ones can bring the conversation back.
The recipe, for paths = the differences between S and T, less everything in
left. It writes the working files and no index the user has:
export GIT_INDEX_FILE="$RT/checkpoint/a2-rollback.index"
git read-tree "$T"
# 1. The list, with its status: D for what T does not have, else a write.
git diff-tree -r -z --no-renames --name-status "$S" "$T"
# 2. Deletions first, longest paths first, each with unlink, then rmdir on its
# empty parents up to the tree's root. So a file can replace a folder.
# 3. Then every write. This runs smudge filters and sets modes and symlinks.
printf '%s\0' src/proto/frame.zig | git checkout-index -f -z --stdin
unset GIT_INDEX_FILE; rm -f "$RT/checkpoint/a2-rollback.index"
These go in left instead of files, and venom does not touch them:
reason |
When |
|---|---|
outside |
--outside=keep, and the class is outside |
too_big |
In skipped of S or T |
skip_worktree |
In skipped of S or T |
gitlink |
Mode 160000 in S or T: a submodule or nested repo, and T’s commit is in detail |
ignored_in_the_way |
A write would replace something S doesn’t hold: lstat of the path or of a parent finds a file or a non-empty folder that isn’t in S. It’s ignored, so it exists nowhere else |
The expected tree of step 6 starts from S, and for each path in files takes T’s
entry or removes the path (git update-index -z --index-info, with a
0 0000000000000000000000000000000000000000\t<path> line to remove).
A rollback’s own checks stop at the last possible moment but can’t close the race
with a person’s editor: a save between the plan check and that path’s write is
overwritten. That window is milliseconds, and step 6 reports it (exact: false).
7.5 HEAD in an unguarded tree
An agent often commits. Restoring only the files would leave HEAD ahead of them,
and every dropped change would show as a reversal waiting to be committed. So in
an unguarded tree a rollback also moves the branch, when it safely can:
git merge-base --is-ancestor "$T_HEAD" "$HEAD" # else conflict.head_moved
git update-ref -m "venom rollback a2" "refs/heads/$BRANCH" "$T_HEAD" "$HEAD"
git read-tree "$T_HEAD" # the index follows HEAD
git update-index -q --refresh
update-ref takes the old value, so it fails if the branch moved after the plan.
A detached HEAD uses git update-ref --no-deref HEAD. Nothing is lost: the safety
checkpoint’s parent is the old HEAD, so those commits stay reachable until it is
pruned.
| Situation | Code | Way out |
|---|---|---|
head equals the current HEAD |
None; nothing moves | |
HEAD is on another branch than the checkpoint recorded, or the checkpoint’s head isn’t an ancestor (the agent rebased) |
conflict.head_moved |
--keep-head |
A commit that would be dropped is on a remote-tracking ref (git for-each-ref --contains <commit> refs/remotes) |
conflict.commits_pushed |
--keep-head |
Checkpoint’s head is null |
Treated as conflict.head_moved |
--keep-head |
Moving back a branch that has been pushed would make the next push a force push,
which RFC-0019 section 6.4.2 never lets an “always” grant cover. No network call is
made: a remote-tracking ref is enough evidence to ask. With --keep-head, the
files are restored and HEAD stays, so the dropped changes show as uncommitted
reversals, and the user commits them or not. A guarded tree behaves like this
always, without the flag.
7.6 Failures and crashes
| What happened | Code | State |
|---|---|---|
Git failed while applying or moving HEAD |
failed.rollback_undone |
venom ran the recipe back from the current state to S, for each repo it had touched, and S’s capture matched again. As if it hadn’t happened |
| The way back failed too | failed.rollback_partial |
Mixed. data.repos says which repo is in which state. The safety checkpoint is whole, and the agent stays as it was. A person can roll back to it |
The daemon stopped between rollback.started and rollback.ended |
failed.rollback_interrupted |
Unknown |
A daemon that starts and finds rollback.started with no end does not run the
effect again and does not write anything. It logs rollback.ended {ok: false, code: "failed.rollback_interrupted"} and raises a review attention whose reason names
the safety checkpoint. Writing to a person’s tree at start-up, unasked, on the
memory of a plan is exactly what this RFC is against.
A copy of the conversation made in a failed rollback stays with the harness, and nothing uses it (RFC-0019 section 4 accepts the same for a fork).
A rollback never changes the agent’s state. settled stays settled, and failed
stays failed, since RFC-0018’s state diagram has no such edge. A later send
works from either.
8. Conversation
--restore takes all (the default) or files.
Files and conversation are both the state of an agent: rewinding only the files
leaves a model that remembers edits that no longer exist, and it will act on them.
So all is the default, and leaving the conversation behind has to be asked for.
What decides it. The checkpoint’s through_turn (3.3) names the conversation
to go back to. Its conversation field (in agent.checkpoints) is true when it
can bring it back:
- It isn’t
mid_turnoroverlap, and its reason isn’tfork. through_turnisn’t a dropped turn, and its turn has a harness id (harness_turnorharness_end, RFC-0019). Atakebackafter a failedcatch_uphasconversation: false, since venom doesn’t know what the harness holds.- Rewinding is needed and the adapter declares
rewind, or no rewind is needed.
With restore = "all":
| Case | Result |
|---|---|
through_turn is the agent’s latest turn |
Nothing to rewind. Works on any harness. conversation.rewinds is false |
through_turn is null: back to before the first turn |
venom clears harness_session from the binding and the next send starts a new conversation. No adapter call, so it works on any harness |
The adapter declares rewind |
RFC-0019’s rewind operation (below) |
| The adapter doesn’t | unsupported.harness_capability, and data.fix says --restore=files. Nothing is changed. |
The checkpoint can’t (conversation: false) |
conflict.checkpoint_files_only, and data.why says which rule. Nothing is changed. |
rewind (RFC-0019 section 4). {through_turn} returns {harness_session}: a
new conversation holding the turns through that one. It MUST NOT change the source
conversation, which is what lets invariant 5 hold. It can be made before a single
file moves, and if the files then fail, nothing refers to it. Only step 7’s log
commit switches the agent to it. It has the same rules as fork: it never holds
the reactor and is bounded by the adapter (timeout.harness_request, and
conflict.harness_fork_refused for a refusal).
What RFC-0019 gains, all optional and compatible (its section 14):
| Addition | RFC-0019 section | Meaning |
|---|---|---|
capability rewind |
3 | bool. The adapter implements rewind. Missing means false |
operation rewind |
4 | Between turns, {through_turn} with the harness’s ids for that turn (below), result {harness_session}. Preconditions and errors as for fork; any operation in TUI mode fails conflict.handed_over |
start’s fork_at |
4 | With fork_source: the harness’s id of the point to copy through. An adapter that copies at the first start uses it, like fork_source |
resume record’s fork_at |
7.1 | With fork_source, until the copy exists. A take-over of such a fork is refused (RFC-0019 section 4) |
turn.ended’s harness_end |
5.2 | Optional: the harness’s id of the turn’s last message, where the harness has one. Claude Code: the uuid of the turn’s last assistant line. Kept in the log and in catch_up’s events |
The ids the adapter gets for through_turn are the turn’s harness_turn and
harness_end. A turn logged without the one its adapter needs has no harness id
for this purpose (conflict.checkpoint_files_only, why: no_harness_id).
| Harness | rewind |
|---|---|
| claude-code | Like its fork ("head"): venom chooses the new id at once, with no process, and logs fork_source and fork_at with the binding. The next start runs claude -p … --resume <fork_source> --resume-session-at <harness_end> --fork-session --session-id <id>, then as usual. The binding keeps fork_source and fork_at until the copy exists (RFC-0019 section 4: that start’s session arrives, or a take-back finds the copy’s transcript), so a restart repeats it. A take-over before that start is refused with unsupported.harness_capability while fork_at is set (RFC-0019 sections 4 and 7.2); the agent is unchanged and its first start still makes the copy. A start that fails with not_found.harness_session fails the agent as RFC-0019 says for reattach; the source conversation is still there, and rolling back to a later checkpoint with --restore=files is the way out. Declared only up to tested_version: open question 1 |
| codex | thread/fork {threadId, lastTurnId: <harness_turn>, excludeTurns: true} in a live app-server, then thread/unsubscribe, exactly as its fork (RFC-0019 section 12.2). The new thread.id is the result |
| ACP | false until its adapter’s issue says otherwise |
Neither harness needs anything else, and turn ids stay valid across it. Checked on the versions RFC-0019 names:
$ claude -p --model haiku --resume c52b349d-f73b-4aa6-baf9-00281f89d3a1 --resume-session-at a136cb0e-902b-4f76-957a-e34a2115a2db --fork-session --session-id db5b5038-2eca-45a0-bff2-ef5647c506fb --output-format json "List every word I asked you to remember, comma separated, nothing else. Do not use tools."
The source conversation had been told APPLE in its first turn and BANANA in its
second, and the rewind was to the first turn’s last assistant line. The copy
answered APPLE. The source still answered APPLE, BANANA, and its transcript had
not grown. On Codex, thread/fork {threadId, lastTurnId, excludeTurns: true} gave
the same answers, and thread/turns/list on the copy returned the first turn under
its old id.
After a rewind:
- venom’s turn numbering goes on. After dropping
t4andt5, the next turn ist6. Ids are never reused, and every event already published stays true. - The log keeps everything.
agent.showmarks dropped turns withdropped: true, and the transcript shows them folded under the row of section 12. Their tokens stay inusage: they were spent. agent.fork --through-turnon a dropped turn fails withnot_found.turn.- Usage totals re-base, as after
reattach(RFC-0019 section 8.1). - Pending one-shot decisions can’t exist, because of preflight 7.
Going forward again. A later checkpoint keeps its files. Its conversation is
gone from the agent’s binding, so it has conversation: false: “undo the undo”
restores the files with --restore=files, and the note below tells the model.
Rewinding to a safety checkpoint’s dropped turns isn’t offered, even though the old
conversation is still on disk, because a rollback that swaps whole conversations
in place is a different feature nobody asked for yet.
restore = "files": the defined result. Files go back, and the conversation
stays as it is. Since the model now remembers things that aren’t true, venom
prepends checkpoints.rollback_note to the text of the agent’s next send,
once, and to the next send of each sharer. The note is part of that prompt, so
turn.prompt records it and the transcript shows it:
Note: the files were rolled back to their state after turn t3. Changes made after that point are gone, and what you remember of them may no longer be true. Read the files before you rely on them.
With through_turn: null it says “to their state before your first turn” instead.
This is the only result a harness without rewind can get, and it is why
venom-org/venom#157 can’t leave a mismatched state silently.
9. Conflicts
| Situation | Decision | Why |
|---|---|---|
A turn is running, or needs_you |
conflict.turn_running. Stop it first; a stopped turn gets its turn_end checkpoint, which is then a target |
The harness is writing, and any restored file could be overwritten a moment later. Interrupting on the caller’s behalf would turn a question about files into a side effect |
queued |
conflict.turn_running |
It is about to run |
handed_over |
conflict.handed_over, as RFC-0019 section 4 has for every operation in TUI mode. Take back first |
The harness TUI has the tree and the conversation, and venom can’t stop it writing (RFC-0019 section 7.4, “only one writer”) |
Capture while handed_over |
Allowed: agent.checkpoint reads only |
|
| Changes made during take-over | One takeback checkpoint, with the window from takeover to it being a gap, so those paths are outside (7.3). A person drove them. Imported turns have no checkpoint of their own, so going back to the middle of a take-over isn’t possible |
venom sees no turn boundaries while it isn’t driving |
| Stale requests (RFC-0019 section 9.3) | conflict.stale_requests, data.requests names them. Answer them; cancel_turn closes one and resumes nothing |
A one-shot decision answers a call by the digest of its input and cwd, and the call was planned against the files being replaced |
| A descendant is live | conflict.live_children, data.children. agent.stop --tree first |
Dropping the turn that started a child leaves a parent that doesn’t know about a child still writing |
| Settled children | Allowed. Rollback never touches their trees and doesn’t roll them back. They are listed in children, so the UI can offer the same for them |
Each agent’s state is its own, and a settled child’s result is what the user may want to keep |
| A sharer is live | conflict.tree_shared, data.agents |
Its next write would land on the restored files |
| A sharer has settled | Allowed in a guarded tree (section 6). Listed in shared_with; it gets the note |
|
| A merge or rebase is in progress | conflict.git_operation |
A half-resolved index and state files don’t belong to any checkpoint |
Running agent.send, agent.fork, agent.compact, agent.takeover or agent.mcp_off during a rollback |
conflict.rollback_running |
One rollback owns the agent until it ends |
| A second rollback on a tree | conflict.rollback_running, for the agent or a sharer |
|
archived |
conflict.invalid_transition, as for agent.fork. The commit is in agent.checkpoints for anyone who wants it through git |
Its worktree may be gone |
| Native sub-agents | Part of the parent’s turn and the parent’s tree. A background one that outlives turn.ended is out of venom’s sight (RFC-0019 section 5.2 closes only what it knows); its writes after that land in a gap |
10. Fork
agent.fork copies a conversation and does not touch files. That doesn’t change.
A fork runs in its source’s folder because that’s the only place its harness can
resume the copy (RFC-0019), so a source and its fork always see one set of files,
and through_turn names a point in the conversation only. So after a fork through
t3, the fork remembers up to t3 and the files are as they are now.
This RFC makes that visible, and fixable, without changing it:
-
agent.fork_endedgainscheckpoint: the source’s newest checkpoint whosethrough_turnis the fork’s point (the latest turn if none was named), leaving outmid_turnandoverlapones. It is absent when there is none.forks[]onagent.showgains it too. -
The fork gets its own first checkpoint,
k1withreason: fork, pointing at the same commit under its own namespace, withconversation: false:git update-ref refs/venom/checkpoints/a7/1 "$SOURCE_COMMIT" "" -
To line the files up with the fork’s conversation, the user rolls the fork back to it. The tree is guarded, since the source shares it, and every path counts as
outside, because the changes predate any turn of the fork. So it takes a person, and it’s explicit:$ venom agent rollback --agent=a7 --checkpoint=k1 --restore=files --outside=overwrite --dry-run=true --jsonThe source then has a tree that moved under it, and it gets the note.
Why not restore the files during agent.fork? Because through_turn is commonly used
to look back with the code left alone, and a fork that silently rewrites a tree another
agent is using would break invariant 3. A fork with its own tree at a checkpoint is
what users would really like, and open question 2 keeps it for later: the harnesses tie
a conversation to its folder.
11. Contract
11.1 Verbs
All have --json. The two that change or capture hold their call until they finish,
like agent.wait, and neither holds the reactor (3.1).
| Verb | Grant | Params | Result |
|---|---|---|---|
agent.checkpoints |
read |
agent, turn?, before?, limit? (default 50) |
{agent, pending, checkpoints}, newest first. pending: a capture hasn’t finished. turn filters on the checkpoint’s turn, and before is a checkpoint id |
agent.checkpoint |
write |
agent, label? |
{agent, checkpoint, reused} |
agent.checkpoint_diff |
read |
agent, from, to?, repo?, path? |
{files, patch?}. from and to are checkpoint ids; a missing to means the current files. files[]: {repo, path, change, added, removed, binary}, change being create, modify or delete. With path, patch is {text, truncated, bytes}, cut at harness.body_max |
agent.rollback |
write, and section 11.4 |
agent, checkpoint, restore?, outside?, keep_head?, dry_run?, plan? |
Below |
Each item of checkpoints is the record in 4.2, without type and agent, plus
conversation (section 8) and label if set.
agent.rollback’s result:
| Member | Meaning |
|---|---|
agent, checkpoint, restore, dry_run |
As asked |
plan |
Dry run only |
guarded |
Any tree of this agent is guarded; dry run only |
conversation |
{rewinds, through_turn, drops, harness_session?}; harness_session is the new one, when rewound |
shared_with, children |
Agent ids it doesn’t touch, and which it will note or list (section 9) |
repos[] |
{repo, path, head: {moves, from, to, commits}, files, left, ignored_edits} |
repos[].files[] |
{path, change, class} |
repos[].left[] |
{path, reason, detail?} (7.4) |
repos[].ignored_edits[] |
Paths an edit call changed since the checkpoint that are ignored, so can’t be restored |
safety, after |
The pre_rollback and rollback checkpoints; real call only |
exact |
The tree came out as expected (7.4 step 6); real call only |
dry_run and plan go in exactly one combination each: dry_run=true with no plan, or
a plan with no dry_run. Both, or neither, fail with invalid_params.plan. The
parameter fragment, in RFC-0004’s format:
.{
.name = "agent.rollback",
.summary = "Restore a checkpoint's files, and the conversation where the harness can",
.params = .{
.{ .name = "agent", .summary = "Agent ID", .type = .{ .kind = .string }, .required = true },
.{ .name = "checkpoint", .summary = "The checkpoint to go back to", .type = .{ .kind = .string }, .required = true },
.{ .name = "restore", .summary = "all: files and conversation, or refuse; files: files only", .type = .{ .kind = .string, .choices = .{ "all", "files" } }, .default = "all" },
.{ .name = "outside", .summary = "What to do with paths changed outside the agent's turns; refuse in a guarded tree and overwrite elsewhere when omitted", .type = .{ .kind = .string, .choices = .{ "refuse", "keep", "overwrite" } } },
.{ .name = "keep_head", .summary = "Restore the files and leave HEAD where it is", .type = .{ .kind = .boolean }, .default = false },
.{ .name = "dry_run", .summary = "Say what would change, and return the plan the real call must quote", .type = .{ .kind = .boolean }, .default = false },
.{ .name = "plan", .summary = "The plan from the dry run", .type = .{ .kind = .string } },
},
.grant = .write,
}
11.2 Events
Published on venom events --json, resource agent:<agent>. None has a snapshot
entity: agent.checkpoints is the current list, and a client that starts from a
snapshot reads it.
| Type | data |
From log record |
|---|---|---|
agent.checkpoint |
{agent, checkpoint, reason, turn?, through_turn, label?, repos: [{repo, commit}]} |
checkpoint.taken |
agent.checkpoint_failed |
{agent, reason, code, turn?, repo?}. code is from 11.3 |
checkpoint.failed |
agent.rollback_started |
{agent, checkpoint, safety, restore} |
rollback.started |
agent.rollback_ended |
{agent, checkpoint, safety, restore, ok, code?, after?, drops?} |
rollback.ended |
agent.fork_ended gains checkpoint? (section 10). The event agent.checkpoint
shares its name with the verb that takes one, as agent.context does in RFC-0019;
the event is published for every capture, not only the ones the verb asks for.
checkpoint.reused and checkpoint.pruned are internal log records, and so is
checkpoint.removed {agent, repo, removed, kept} from agent.clean. A dry run logs
nothing. Effects are checkpoint.capture, checkpoint.prune and
checkpoint.rollback, which stay unpublished, like every effect.*.
11.3 Errors
The class fixes the exit code (RFC-0002 section 7.2).
| Code | Exit | When | data |
|---|---|---|---|
not_found.checkpoint |
3 | Never existed, pruned, or its ref no longer holds the logged commit | {checkpoint, repo?} |
invalid_params.plan |
2 | Both or neither of dry_run and plan |
|
denied.agent_caller |
4 | An agent asked for a real rollback of a guarded tree, or of anything but its own descendant (11.4) | |
conflict.turn_running |
5 | 7.1 item 4 (RFC-0019) | |
conflict.handed_over |
5 | 7.1 item 3 (RFC-0019) | |
conflict.invalid_transition |
5 | Archived, as agent.fork |
|
conflict.rollback_running |
5 | A rollback owns the agent or tree | {agent} |
conflict.checkpoint_pending |
5 | A capture of this agent hasn’t finished. Retry | |
conflict.stale_requests |
5 | Stale inbox items | {requests} |
conflict.live_children |
5 | A descendant is live | {children} |
conflict.tree_shared |
5 | A sharer is live | {agents} |
conflict.git_operation |
5 | A merge or similar is under way | {repo, operation} |
conflict.checkpoint_files_only |
5 | restore = "all", on a checkpoint that can’t |
{why}: mid_turn, overlap, fork, dropped_turn, no_harness_id, catch_up_failed |
conflict.head_moved |
5 | 7.5 | {repo, expected, actual, branch?} |
conflict.commits_pushed |
5 | 7.5 | {repo, commits, refs} |
conflict.outside_edits |
5 | 7.3 | {repos: [{repo, paths}]} |
conflict.rollback_stale |
5 | The tree, HEAD or a choice differs from the plan |
{checkpoint} |
unsupported.harness_capability |
10 | restore = "all" needs rewind |
{fix} |
unsupported.checkpoint_no_repo |
10 | The agent’s scope has no git repo | |
failed.checkpoint_git |
1 | A git command of the capture failed, or the tree is missing | {repo, reason} |
failed.checkpoint_too_large |
1 | Past checkpoints.max_total_bytes |
{repo, bytes, largest} |
failed.rollback_undone |
1 | 7.6 | {repo, reason} |
failed.rollback_partial |
1 | 7.6 | {repos, safety} |
failed.rollback_interrupted |
1 | 7.6. Only on agent.rollback_ended |
{safety} |
timeout.checkpoint |
7 | A capture passed checkpoints.timeout |
{repo} |
timeout.harness_request, conflict.harness_fork_refused, not_found.harness_session |
From rewind (RFC-0019) |
agent.send, agent.fork, agent.compact, agent.takeover and agent.mcp_off gain
conflict.rollback_running.
11.4 Grants
Every verb needs its grant on the agent, as RFC-0005 section 5 scopes it. Rollback writes
files in a tree the agent already had write over, and worktree isolation already made
those trees under write (RFC-0018), so the verb has no files right of its own. Two
rules go further, and the daemon applies them from the connection’s resolved principal,
never from params:
- An agent never rolls back a guarded tree. The real call fails with
denied.agent_caller. A person’s work can only be overwritten by a person. This covers--outside=keeptoo: a dry run is allowed. - An agent may roll back only its own descendants’ unguarded trees. Itself is impossible
(its own turn is running, so
conflict.turn_running), and through another agent is a way round the rule above. An agent calling for anything else getsdenied.agent_caller.
11.5 Config
checkpoints.* are user-only keys (RFC-0018 section 7), so a cloned repo can’t weaken them.
They can be changed with config set.
.{
.checkpoints = .{
.enabled = true,
.max_file_bytes = 52428800,
.max_total_bytes = 536870912,
.keep = 100,
.keep_for = "14d",
.keep_safety = "30d",
.timeout = "30s",
.rollback_note = "Note: the files were rolled back to their state {when}. Changes made after that point are gone, and what you remember of them may no longer be true. Read the files before you rely on them.",
},
}
enabled = false stops automatic capture only. agent.checkpoint and agent.rollback are
explicit asks, and they still work. {when} becomes after turn t3, or before your first turn.
checkpoints.timeout applies to one capture. A rollback’s writes have no time limit, like
git worktree remove in cleanup.zig, since stopping halfway would be worse than waiting.
12. TUI
venom-org/venom#158 builds this. Every screen is a view over the verbs (RFC-0018).
-
space a bopens the checkpoint list of the selected agent, newest first: id, reason, turn, age, and files changed since the previous one.⏎showsagent.checkpoint_diff.space a ktakes amanualcheckpoint. -
Ron a checkpoint runs the dry run and shows its result as a confirmation screen: files by class, thenleft, then what happens toHEADand the conversation.outsidepaths come first, ingold.yconfirms by quoting the plan. -
A
conflict.outside_editsrefusal offers keep those files (default) and overwrite them, then shows the new dry run. There is no way to say yes without seeing a plan. -
A harness without
rewindoffers files only, and says the model will be told. -
Transcript row, always open, with dropped turns folded under it:
↺ Rolled back to k2 (after t1) · 1 file · conversation too · undo: k4
The new keys join RFC-0018 section 13.4’s tree through its configurable leader table. They don’t change any existing binding.
13. Security and limits
- Not a security boundary. Refs and objects live in a repo the agent can write. It can
delete them, or move a ref, which venom notices (4.2) and reports as
not_found.checkpoint, but it can’t restore what’s lost. What’s in the log can’t be forged from there. - Untracked secrets. A
.envthat nobody ignored goes into the repo’s objects, under a ref, for at least as long as retention says, and stays until gc after that. It doesn’t leave the machine unless something pushesrefs/venom/(5.1). - Repo code still runs. Hooks are off, but clean and smudge filters are the repo’s
configuration and run during capture and restore, exactly as they do for
git statusandgit checkout. venom doesn’t run on repos the user hasn’t added. - Disk. Every capture of a changed file writes a blob.
max_file_bytesandmax_total_bytesbound one capture, andkeepbounds the count, but a deleted ref’s objects stay until the repo’s gc runs.doctorreports the ref count per repo. - Files outside the repos in scope aren’t captured. A whole-workspace agent that edits a file beside the repos can’t get it back.
Agent-contract impact
-
Verbs:
agent.checkpoints,agent.checkpoint,agent.checkpoint_diff,agent.rollback(11.1).agent.clean’s result gainscheckpoints. No change to existing params. -
--jsonand exit codes: the codes in 11.3, all in existing classes, so no new exit codes. -
Events: four new
agent.*types andcheckpointonagent.fork_ended(11.2). -
skill: venom’s skill documents rollback’s two calls, and that a person must run a real rollback of a guarded tree. -
doctor:Check id ok when Fix checkpoints.gitgitonPATH, at 2.31 or newer (--path-format)The system’s update command checkpoints.refsNo repo has refs no log record names, or more than keepper agent (warn)venom agent cleancheckpoints.mirrorNo remote of a repo in a workspace has mirrorset (warn)git config --unset remote.<name>.mirrorharness.<h>.rewindwarnwhen an adapter withholdsrewindfor a harness newer thantested_versionThe harness’s or venom’s update command -
config: thecheckpoints.*keys (11.5), all user-only. -
Grants: 11.4. The default stays read-only.
-
Headless, OSC 7501 / OSC 133: no change; all of this works without the TUI.
-
RFC-0019: amended by this RFC, with a line in its header: the
rewindcapability and operation,start’sfork_at,turn.ended’sharness_end, andcheckpointonagent.fork_ended. All are optional and compatible (RFC-0019 section 14), soadapterstays1.
Alternatives
- Only at turn end. One ref per turn, simplest, and what t3code does. Rejected: it can’t tell a person’s edit between turns from the agent’s, and that distinction is the whole of 7.3. Thanks to 3.2 the extra capture usually makes no ref at all.
- Only on request. Cheap, and useless at the moment you need one, because you didn’t ask.
- Capture with
git stash create. It leaves untracked files out, and-uwrites to the real index’s state andrefs/stash. Rejected. git worktree adda clean tree andrsync. Would avoid touching the index by a different route, at a far higher cost per turn.- A private object store (
GIT_OBJECT_DIRECTORYunder venom’s state, with alternates). It would keep the user’s repo entirely clean, with nogit log --allnoise and no secret in the repo’s objects. Rejected for v1: restore, diff and the “just use git” story all get harder, and the ecosystem doc already chose hidden refs. Worth revisiting if the noise annoys anyone. - Capture ignored files, or a list of them. See section 4. A later, opt-in list per repo wouldn’t break anything here.
git checkout <commit> -- .orgit reset --hardto restore. They rewrite the real index, andresetmovesHEAD. In a guarded tree that breaks invariant 4, and neither deletes files created since. Rejected for the path-by-path recipe, which the guarded case needs anyway. Using one recipe in both scopes also means one thing to test.- Refuse an in-place rollback outright. Safest and simplest, and it would leave in-place agents, where a bad turn hurts most, with nothing. The classes and the safety checkpoint make it safe enough.
- Attribute paths by watching the filesystem (inotify during turns). It can’t name the process either, misses events on overflow, and is a daemon-wide watcher per tree. Rejected.
- Make rollback one call with
--yes. Rejected: a confirmation that doesn’t bind to what was shown protects nothing in a tree that changes. The plan does. - Automatically interrupt a running turn on rollback. Rejected: see section 9.
- Harness-native file undo (Claude Code’s
rewindFiles,--rewind-files). Its own documentation says it doesn’t undo what a Bash command did, it tracks only tool edits, and Codex has no equivalent. Rejected as the mechanism. - Shorten the conversation in place. Codex 0.167.0 has no such call. Claude Code’s
--resume-session-atwithout--fork-sessionwould continue on the same id. Rejected: a copy is what lets a failed rollback change nothing. - Do nothing. Users would use
git resetby hand, in a tree shared with an agent’s conversation that wouldn’t know.
Open questions
- Is
--resume-session-ata fair channel for claude-code’srewind? Decided for now, with doubts recorded: #120. The Agent SDK reference documents theresumeSessionAtoption, but the CLI flag behind it isn’t in the CLI reference or inclaude --help, and RFC-0019 asks for documented channels. It’s the vendor’s own binary under the user’s own sign-in, and a flag the SDK itself passes, so this isn’t spoofing of any kind, and venom bundles no SDK. Until the question is settled, the adapter declaresrewindonly for versions up to itstested_version.doctorwarns when it withholds it, and--restore=fileskeeps working. The maintainer may prefer to wait for documentation, in which case claude-code declaresrewind: false, and nothing else in this RFC changes. - A fork in its own tree at a checkpoint. Deferred:
#121. It is what “try again from here, and
keep what I have” wants, and it needs a worktree created from a checkpoint commit and a
conversation resumable from another folder. Codex’s
thread/forktakes acwd. Claude Code keeps a session per folder, and moving one isn’t an official operation. - Checkpoints in a workspace with no git. Deferred:
#122. A hidden
GIT_DIRunder venom’s state could capture such a folder, but ignore rules and size limits have nothing to go by there. Until then, section 5.1’sunsupported.checkpoint_no_repois the defined result.