Skip to content

9 min read

The base moved between the read and the apply

On this page

The ticket said that apply_discount ignored the customer's tier and used a hardcoded 20% for everyone. The agent read api/pricing.py at turn 3, spent turns 4 and 5 reading the tier model and the tests, and then made two edits to the same file. At turn 6 it rewrote the body of apply_discount to look the rate up from the tier. At turn 9 it applied a second patch, planned back at turn 3, which inserted a line pulling the rate from configuration. Both patches applied at offset zero with zero fuzz, the file parsed, and the module's suite went green. The function it left behind was this:

python
def apply_discount(subtotal: Decimal, tier: Tier) -> Decimal:
    """Apply the customer's tier discount to a subtotal."""
    rate = tier.discount_rate                      # from the turn-6 edit
    rate = Decimal(str(config.DISCOUNT_RATE))      # from the turn-9 edit
    return (subtotal * (1 - rate)).quantize(CENTS)

config.DISCOUNT_RATE is 0.2, and the default tier's discount_rate is also 0.2, which is why the tests passed: every fixture in test_pricing.py used the default tier, so both lines produced the same number and the second assignment shadowing the first made no observable difference. Enterprise customers on the 35% tier kept getting 20% for another two weeks. Nothing in the applier's output was wrong. Hunk placement was exact, the anchors were unique, the symbol check from the previous chapter passed because the edit really did land in apply_discount. The patch was correct about a version of the file that had stopped existing three turns earlier. That version — the exact bytes an edit was planned against — is the patch's base, and every line the patch expects to find is a reference into it, so a patch is only meaningful in the company of the base it was written for.

The window nobody declares

Between the moment a tool hands file contents to the model and the moment a patch derived from those contents reaches the filesystem, there is a window measured in model turns, which in practice is tens of seconds to several minutes. The file is not frozen during that window, and the list of things that write to it is longer than most loops account for.

Sequence: Model → Edit tool (read(api/pricing.py) — turn 3); Edit tool → api/pricing.py (read bytes); api/pricing.py → Edit tool (sha256 4f9c…); Edit tool → Model (contents); Model → Edit tool (edit #1, rewrite apply_discount — turn 6); Edit tool → api/pricing.py (write); Post-write hooks → F: ruff format; F: ruff format → fix; Model → Edit tool (edit #2, planned at turn 3 — turn 9); Edit tool → api/pricing.py (anchor matches, offset 0, fuzz 0).

The agent's own earlier hunk is the most common writer and the easiest to overlook, because from the model's point of view it already knows what that hunk did. Attention over a long context does not reliably reconcile two descriptions of the same twelve lines, particularly when the older one appears in full and the newer one appears as a diff. The formatting hook is next: a ruff format or prettier --write that runs after every write will reflow argument lists, normalise quotes, and collapse or expand line breaks, which changes byte offsets throughout the file and can rewrite the exact lines a later patch intends to anchor on. Code generators are a third writer, and a make schemas invoked by a test target can replace an entire module between two agent turns. The fourth is the engineer, who is sitting in front of the machine watching the run, notices a typo, and fixes it in another window.

The pattern is familiar from anywhere else that reads and writes are separated in time. An HTTP client that fetches a resource, edits it locally, and PUTs it back has the same problem, and the answer HTTP settled on is an ETag returned with the read and an If-Match sent with the write, so the server rejects the write when the representation has changed. A database transaction that reads a row, computes on it, and writes it back has the same problem, and optimistic concurrency control gives it the same answer: carry a version, compare at commit, abort and retry when it moved. An agent edit is the same shape of operation, and it usually ships without the version.

Carry the hash through the plan

The mechanism is small enough to describe completely. The read tool hashes the full file bytes and returns the digest alongside the contents. The edit tool takes that digest as a required argument, re-hashes the file immediately before applying, and refuses when they differ.

python
import hashlib, pathlib

def read_file(path: str, start: int | None = None, end: int | None = None) -> dict:
    data = pathlib.Path(path).read_bytes()
    text = data.decode("utf-8")
    lines = text.splitlines(keepends=True)
    window = "".join(lines[(start or 1) - 1 : end]) if start else text
    return {
        "path": path,
        "base": "sha256:" + hashlib.sha256(data).hexdigest(),
        "lines": f"{start or 1}-{end or len(lines)} of {len(lines)}",
        "content": window,
    }

Two details in that function matter more than they look. The digest covers the whole file even when the tool returns a window of it, because an edit's meaning depends on things outside the window — the imports at the top, another definition of the same name three hundred lines down, a decorator that changes what the function receives. A hash over the returned window would pass while the surrounding file was replaced wholesale. And the digest is over raw bytes rather than over normalised text, so a formatter run counts as a change; the temptation to normalise whitespace before hashing, so that "harmless" reformatting does not trigger a mismatch, is the beginning of a classifier that decides which changes are harmless, and that classifier is where the interesting bugs live.

The write side has to close the window rather than merely shrink it:

python
def apply_edit(path: str, patch: str, base: str, symbol: str) -> Result:
    with locked(path):                       # process-local, held across both steps
        data = pathlib.Path(path).read_bytes()
        actual = "sha256:" + hashlib.sha256(data).hexdigest()
        if actual != base:
            return Stale(
                path=path, expected=base, actual=actual,
                message=(f"{path} changed since it was read; "
                         f"re-read it and re-plan this edit"),
            )
        return guarded_apply(data.decode("utf-8"), patch, symbol)

The comparison and the apply sit inside the same lock and there is no model turn between them, because a check performed at the top of the tool call and a write performed after an intervening await is the same time-of-check-to-time-of-use gap you started with — a check whose answer can go stale in the interval between it being asked and the action it authorises being taken — only narrower. Narrower gaps are worse than obvious ones, since they fail rarely enough to look like flakes.

Cost is not a consideration here. SHA-256 over a 40 kB source file takes about 0.1 ms on a laptop; over a 2 MB generated client, about 5 ms. Against a loop where a single model turn costs seconds, the hash is free, and the file has to be read from disk anyway to apply the patch.

How often it fires is a more interesting number than what it costs. Instrumenting the check across one week of runs against an actively developed service gave 6.2% of applies hitting a stale base — 47 out of 761. The breakdown by cause is the useful part: 61% were the agent's own earlier edit to the same file, 24% were a post-write formatting hook, 9% were a code generator invoked by a test target, and 6% were the engineer editing in another window. Roughly six in seven stale bases were produced by the loop itself, which means the majority of the problem is not concurrency with the outside world but the loop failing to account for its own history.

That distribution suggests two cheap improvements on top of the check. The first is that the loop should own its hooks. If formatting runs as a post-write step inside the edit tool rather than as a file-watcher or a separate command, the tool can run the hook and then compute the digest, so the base it hands back is the base that exists after everything the loop does has finished. A formatter that fires from outside the tool guarantees that every hash the tool recorded is stale by the time the next turn starts, which produces a check that is technically correct and cries wolf on every edit.

The second is that a successful apply should return the new digest, so a sequence of edits to one file can chain without a re-read. The tool call that applied edit #1 knows exactly what the file contains afterwards, and handing that back as base for the next call means the model can make a second edit against content it has an accurate description of:

json
{"status": "applied", "path": "api/pricing.py",
 "base": "sha256:2b71e8…", "changed_lines": [41, 41],
 "note": "use this base for further edits to this file"}

The chaining is a convenience and not a substitute for reading. A digest tells the model that the file is what it thinks it is; it does not tell the model what the file now contains, and in the pricing incident the model had a perfectly accurate digest available and still misremembered its own edit three turns later. What the digest prevents is the class of failure where the disagreement is invisible. Where the model's memory of a file is the thing in doubt — long sessions, several edits to the same region, a file that another writer has touched — the correct response is still to spend the tokens and read it again, and 12 kB of file contents is cheaper than one wrong edit that applies cleanly.

Re-read and re-plan, not re-apply

What the loop does on a mismatch decides whether the mechanism helps. The reflex is to re-read the file and immediately retry the same patch against the new contents, on the theory that the patch was fine and only its anchors went stale. That reflex reproduces the bug the check was installed to catch. In the pricing example, retrying the turn-9 patch against the post-turn-6 file works perfectly — the anchor is the docstring, it is still there, the insertion lands, and the function ends up with the same two contradictory assignments. The patch applying is not evidence that the patch is still wanted.

So the mismatch has to travel back to the model as a fact about the world, with enough detail to re-plan on:

json
{
  "status": "stale_base",
  "path": "api/pricing.py",
  "expected": "sha256:4f9c1a…",
  "actual": "sha256:2b71e8…",
  "changed_by": ["edit #1 (this session, turn 6)", "post-write hook: ruff format"],
  "diff_since_read": "@@ -41,7 +41,7 @@\n-    rate = Decimal(\"0.20\")\n+    rate = tier.discount_rate\n",
  "next": "re-read api/pricing.py before proposing another edit to it"
}

Handing back the diff since the read, rather than only the fact of a mismatch, is what makes re-planning cheap. The model does not need the whole file again for a change of four lines, and giving it the delta keeps the decision — is my edit still needed, still correct, or now redundant — in front of it as an explicit question. In the pricing case the honest answer is that the second edit had been made obsolete by the first, and a re-plan that ends in "no further edit required" is a successful outcome that a blind re-apply can never reach.

Attributing the change where you can is worth the small effort. A loop that owns its own write path knows which of its edits touched the file, and a loop that runs post-write hooks knows it ran them, so the two most common causes are free to report. Anything else is an external writer, and saying so is useful on its own: an external writer during a run is a signal that the human is working in the same tree, which bears on how aggressive the loop should be about anything it does next.

Where the hash stops helping

A base hash certifies one file. The plan that produced the edit was informed by everything the agent read, and the tier model it read at turn 4 can be rewritten at turn 7 with no effect on the hash of api/pricing.py at all. Extending the check to every file the plan depends on is possible — carry a set of digests, fail if any moved — and it produces so many aborts in an active repository that loops which try it usually end up narrowing it back to the file being written. The honest position is that the check covers the file you are about to modify, which is where corruption happens, and that staleness of the model's broader picture is a different problem with worse answers.

It is also not a lock, and it should not become one. Detecting a conflicting write after the fact and refusing is cheap and composes with everything; holding a file lock across model turns means an agent that dies mid-run leaves a lock behind, and it means the engineer's editor blocks on a save while a model thinks. Optimistic detection fits the failure profile, which is that concurrent writes are rare, individually cheap to retry, and expensive only when they go unnoticed.

The mismatch check also says nothing about content. It confirms that the bytes the patch was planned against are the bytes on disk right now, which makes the candidate content the applier produces a meaningful artefact rather than a guess about a file that has moved on. What that candidate contains — whether it parses, whether it will still parse if the process is killed halfway through writing it — has not been examined at all, and examining it after it reaches the filesystem turns out to be both slower and less safe than examining it before. That is the subject of the next chapter.

Patch application and conflict handling — read the current version at https://daviddominguez.dev/learn/en/patch-application-and-conflict-handling/the-base-moved-between-the-read-and-the-apply

Back to top