Chapter 7 of 7 · 9 min read
Restore from the snapshot before retrying a broken edit
On this page
At 02:14 an overnight run applied its fourth edit of a task to LedgerReconciler.java and produced a file that no longer parses. The replacement text carried one closing brace too few, which is an ordinary thing for a model to emit and not by itself alarming. What the loop did next cost eleven minutes and thirty-one thousand tokens:
02:14:07 edit_file(LedgerReconciler.java) -> ok
02:14:09 run_tests() -> FAILED (188 compile errors)
02:15:41 read_file(LedgerReconciler.java, 400-520) -> ok
02:16:02 edit_file(LedgerReconciler.java) -> SEARCH block not found
02:16:20 read_file(LedgerReconciler.java, 380-560) -> ok
02:16:58 edit_file(LedgerReconciler.java) -> ok
02:17:00 run_tests() -> FAILED (211 compile errors)
02:19:33 edit_file(LedgerReconciler.java) -> ok
02:19:35 run_tests() -> FAILED (243 compile errors)
02:25:10 git checkout -- .
02:25:11 run_tests() -> FAILED (14 tests)Every line after the first is the loop working on a problem it created. The compile errors climb rather than fall because each repair is written against text that is already broken, so the model is reasoning about a class whose brace structure no longer corresponds to anything it read. The SEARCH block not found at 02:16:02 is the anchor from chapter three failing for a new reason: the file it was quoted from no longer exists in that form.
The last two lines are the expensive ones. git checkout -- . restored the file, and it also discarded three edits the agent had correctly applied earlier in the same task, plus an unrelated change an engineer had left uncommitted in SettlementClient.java before going home. The 14 test failures at 02:25:11 are that engineer's work-in-progress being reverted, and nobody connected the two events until the following afternoon.
There was one correct move available at 02:14:09, and it was to put back the version of the file that existed at 02:14:06 and try the edit again.
Detect the corruption before it reaches the model
The loop above wasted its first iteration because the tool result at 02:14:07 said ok. The write succeeded, which is all a write can report, and the next thing that had an opinion about the file was a six-minute test run that reported the damage as 188 unrelated-looking compile errors.
Validation belongs inside the edit tool, between the write and the return, so that a corrupted file is never something the model has to discover:
def edit_file(path: str, search: str, replace: str, run: Run) -> ToolResult:
original = Path(path).read_text()
snapshot = run.snapshot(path, original) # content-addressed, cheap
new_text = apply_edit_text(original, search, replace) # uniqueness + scope checks
Path(path).write_text(new_text)
ok, detail = parse_check(path)
if not ok:
run.restore(snapshot)
return ToolResult(error=f"edit reverted: {path} did not parse after the edit\n{detail}")
return ToolResult(ok=True, diff=unified(original, new_text))The parse_check is deliberately the cheapest verifier that can see this failure rather than the strongest one available. A tree-sitter parse of a 2,340-line Java file takes about four milliseconds and reports the line where the brace structure went wrong; javac -proc:only, which runs the compiler's front end and its annotation processors but skips code generation, takes 30 to 60 seconds on the module; the suite takes six minutes. The cheap check catches syntactic corruption, which is what a malformed edit produces, and the expensive checks are for the semantics of the change, which is a different question asked at a different point in the loop.
| Check | Cost | Catches | Where it belongs |
|---|---|---|---|
| Parse the edited file | 2–10 ms | Unbalanced braces, truncated writes, bad indentation | Inside the edit tool |
| Line-count delta vs original | <1 ms | Truncation, elided regions | Inside the edit tool |
| Type check the module | 5–60 s | Broken references, wrong signatures | After a batch of edits |
| Full suite | 1–10 min | Behavioural regressions, wrong-place edits | End of the task |
The line-count row is not redundant with the parse, and the reason is worth spelling out. A file truncated at the output cap frequently still parses, because generation is likely to stop after a complete statement and a Python module that ends after a finished method is a syntactically valid Python module — it is simply missing four hundred lines. Java and C-family languages are more forgiving here in that unbalanced braces make truncation visible to the parser, but a Python, Ruby or YAML file can lose its entire second half and pass every syntax check you own. The delta check catches it in under a millisecond: if the file shrank by 47% and the edit claimed to change six lines, something is wrong regardless of what the parser says. A threshold that refuses any shrink larger than the replacement's own size plus a small margin is strict enough to catch truncation and elision, and loose enough not to fire on a genuine deletion the task asked for.
The two cheap rows cost under ten milliseconds combined and turn the 02:14 incident into a single failed tool call with a diagnostic the model can act on. The error message matters as much as the detection: edit reverted: LedgerReconciler.java did not parse after the edit tells the model both that its edit was wrong and that the file is back to a known state, which is the difference between a clean retry and an attempt to repair damage.
What a snapshot has to be
The snapshot is a copy of the exact bytes of the files this edit is about to touch, taken immediately before the write and held for the duration of the run, scoped to the edit rather than to the repository. It is content-addressed, meaning each version is filed under a hash of its own bytes, so storing the same file twice costs nothing the second time:
class Run:
def snapshot(self, path: str, content: str) -> Snapshot:
digest = hashlib.sha256(content.encode()).hexdigest()
(self.store / digest).write_text(content) # deduplicated by content
return Snapshot(path=path, digest=digest, step=self.step)
def restore(self, snap: Snapshot) -> None:
Path(snap.path).write_text((self.store / snap.digest).read_text())The storage cost is trivial and worth stating so that nobody optimises it away: a 2,340-line Java file is about 90 KB, a task making twenty edits across six files stores well under 2 MB before deduplication, and the content-addressed store collapses the repeated copies of files that were read but not changed.
The alternatives all fail on scope, which is the property that matters:
| Recovery mechanism | Scope | Engineer's uncommitted work | Agent's earlier good edits | Speed |
|---|---|---|---|---|
| Per-file content snapshot | One file, one step | Untouched | Preserved | ~1 ms |
git checkout -- <file> | One file, back to HEAD | Discarded if in that file | Discarded | ~10 ms |
git checkout -- . | Whole tree | Discarded | Discarded | ~100 ms |
git stash | Whole tree | Moved somewhere surprising | Moved with it | ~200 ms |
| Dedicated worktree per run | Whole tree, isolated | Untouched | Preserved | seconds to create |
git checkout of a single file is the near miss that many teams ship, and it fails because HEAD — the commit the working copy is currently sitting on — is not the state the file was in before this edit. It is the state the file was in before the task, so it silently reverts every correct edit the agent made to that file earlier in the run — and if the human had uncommitted work in it, that goes too. The distinction the snapshot encodes is between the last known-good state and the last committed state, and only the first one is what a retry needs.
A dedicated worktree per run — a git worktree is a second checkout of the same repository in its own directory, sharing one object store, so the agent's files and the engineer's files are physically separate — is the right structural answer for a different problem, which is keeping the agent out of the engineer's tree at all. It composes with snapshots rather than replacing them, because within the worktree the agent still needs step-level rollback, and creating a worktree costs seconds where a snapshot costs a millisecond.
Retrying without stacking on a partial change
Edits that span several files make the partial-application case explicit. A rename that touches three files can apply cleanly to two and fail the uniqueness check on the third, leaving the repository in a state that compiles in neither the old shape nor the new one. The applier should treat the group as one operation:
Nothing partial survives, so the model's next attempt starts from a tree identical to the one its anchors were quoted from, and the anchors for a.java and b.java that already worked will work again.
Restoring the filesystem leaves one thing unrestored, which is the model's context. The failed edit is still in the conversation as an assistant turn containing a replacement the model believes it made, followed by a tool result saying it was reverted. That is the right thing to show, since the model needs to know the attempt failed and why, and it carries a cost: the incorrect text is now part of the context and the next attempt is conditioned on it, which is how loops end up producing three variations of the same broken brace structure. Two mitigations are worth having. Make the revert message unambiguous about the current state of the file — the file is unchanged from before your edit rather than edit failed — so the model is not reasoning about a hybrid it imagines exists. And on the second consecutive failure of the same edit, drop the failed attempts from the context entirely and re-present the task with a fresh read of the region, which costs a few hundred tokens and removes the anchoring.
The retry itself has three requirements. It must be given the restored bytes rather than the model's memory of them, which in practice means the failure message instructs a re-read and the applier refuses anchors older than the last write to that file. It must carry the failure category from chapter five's taxonomy, since "matched 2 locations at lines 88 and 402" produces a different and better second attempt than "edit failed". And it must decrement a budget, because a loop that retries indefinitely on a systematically impossible edit — an anchor the model cannot reproduce because the file uses a character encoding the prompt rendering mangles — will spend the whole task allowance rediscovering the same failure. Two or three attempts per edit, then surface the task to a human with the snapshot history attached, is a defensible policy.
What restoring does not do
It does not make the edit correct, and it is not a verifier. Restore-on-parse-failure catches syntactic corruption, which is the loud half of what goes wrong; an edit that parses, applies uniquely, sits inside the target symbol, and introduces a null dereference passes every gate in this chapter untouched. That class belongs to the type checker and the suite, and to the structural diff against the intended change set that made the collateral-change category visible in chapter five.
It is also not version control, and treating it as such produces a second, worse system. Snapshots are ephemeral state for one run, deleted when the run ends, with no history, no branches and no review. The agent's work still needs to land on a branch and go through the same pull request path as everything else, and the snapshot store exists only to make the inside of a single run recoverable.
And it does not protect files the edit tool did not touch. If a formatter or a code generator runs as part of the loop and rewrites forty files, no per-file snapshot taken by edit_file covers them, which is the case where a worktree per run earns its seconds of setup cost.
What all seven chapters have been building toward is a small set of decisions you can now make with numbers rather than intuition: the cap arithmetic that rules a format out before you test it, the uniqueness and scope checks that turn silent wrong-place edits into refusals, the taxonomy and denominator that make a format comparison mean something, and the snapshot that keeps a bad edit from costing eleven minutes and an engineer's uncommitted work. Run your own fixture set against the model and repository you actually have, because every number here moves when those move, and instrument the four outcomes from the start so that the next model release is a measurement rather than an argument.
Edit formats — read the current version at https://daviddominguez.dev/learn/en/edit-formats/restore-from-the-snapshot-before-retrying-a-broken-edit