The review usually opens with a demo that looks fine. An agent plans four edits, applies them to a workspace on a remote box, and reports success; the tests are green and everyone relaxes. Two hours later a human's refactor is gone, and nobody can point at the request that removed it. Is that a model problem, a server problem, or a protocol problem?
I think it is a protocol problem almost every time, and I want to make the case with an executable fixture rather than an architecture diagram. Below is the event order I keep reconstructing in reviews, the invariant it violates, and a small simulator that will fail your design for you. Everything here is a model of a system, not a benchmark of a vendor.
A planner reads the workspace at revision 0 and emits three steps against app.py. Step 1 applies and the workspace moves to revision 1. While step 2 is in flight, either a human edits that file or the session is re-routed to a different model that re-plans the remainder; both are ordinary events, not incidents.
Step 2 still carries the content hash it was planned against. If the executor applies patches by path without checking that hash or the revision, the write lands on content the planner never saw, and the old bytes vanish. Nobody gets an error, because a patch is not a request — it is a claim about a state that may already be false.
The failure is invisible precisely when it is most expensive. The agent's summary says "updated handler and tests", the test run passes because the overwritten hunk was unrelated, and the loss only surfaces at the next merge. Who is supposed to notice that?
No step may be applied unless the state it was planned against is still the state it is applied to, or the step has been explicitly rebased onto the new state.
That is it. Note what the invariant does not say: it does not say a step is correct, and it does not say a retried step is safe. A revision fence prevents stale acceptance; idempotency prevents double application. Teams routinely build one, assume they have the other, and then argue about whose bug it is.
(step_id, base_rev, path, pre_hash, patch).
There are three state holders and they fail independently: the planner's context (lossy, model-dependent), the executor's workspace (authoritative, preemptible), and the client's view of progress (optimistic, retried). The only durable truth is the workspace plus whatever journal you persist alongside it.
Failure domains worth separating in review: stale plan (planner state diverged), lost ack (executor applied, client did not learn), duplicate delivery (client retried an applied step), and re-plan drift (a new model emits steps against an old base). They need different mechanisms, and a single retry counter hides all four.
This is deterministic Python with no model calls, so the verdicts below are properties of the protocol, not of any provider.
from dataclasses import dataclass
@dataclass(frozen=True)
class Step:
step_id: str
base_rev: int
path: str
pre_hash: str
patch: str
class Workspace:
def __init__(self):
self.rev = 0
self.files = {"app.py": "v0"}
self.journal = set() # persisted with the workspace
def hash(self, path):
return self.files[path]
def write(self, path, content):
self.files[path] = content
self.rev += 1
class Executor:
"""fence: 'none' | 'plan' | 'step'; dedupe: journal-backed idempotency"""
def __init__(self, ws, fence="step", dedupe=True):
self.ws, self.fence, self.dedupe = ws, fence, dedupe
self.plan_rev = None
def plan(self, rev):
self.plan_rev = rev
def stamp(self, s):
if self.fence == "step":
return Step(s.step_id, self.ws.rev, s.path, self.ws.hash(s.path), s.patch)
if self.fence == "plan":
return Step(s.step_id, self.plan_rev, s.path, s.pre_hash, s.patch)
return s
def exec(self, s):
s = self.stamp(s)
key = (s.step_id, s.base_rev)
if self.dedupe and key in self.ws.journal:
return "deduped"
if self.fence != "none":
if s.base_rev != self.ws.rev:
return "rejected:stale_rev"
if self.ws.hash(s.path) != s.pre_hash:
return "rejected:pre_hash"
self.ws.write(s.path, s.patch)
if self.dedupe:
self.ws.journal.add(key)
return "applied"
Two lines deserve attention. The journal lives in Workspace, not in the executor, because an in-process dedupe set dies with the server that was preempted — which is exactly the trap. And stamp is where fence granularity is decided, so you can argue about it in one place.
Run the same three scenarios across fence modes and record the verdicts. The interference classes are injected explicitly, so the denominator is the number of injections, not the number of suspicious log lines.
def scenario_concurrent_edit(fence):
ws = Workspace(); ex = Executor(ws, fence=fence); ex.plan(ws.rev)
s1 = Step("s1", 0, "app.py", "v0", "v1")
s2 = Step("s2", 1, "app.py", "v1", "v2")
assert ex.exec(s1) == "applied"
ws.write("app.py", "human-refactor") # interference: rev 2
return ex.exec(s2)
def scenario_preemption_retry(fence):
ws = Workspace(); ex = Executor(ws, fence=fence)
s1 = Step("s1", 0, "app.py", "v0", "v1")
ex.exec(s1)
return Executor(ws, fence=fence).exec(s1)
def scenario_replan_drift(fence):
ws = Workspace(); ex = Executor(ws, fence=fence); ex.plan(ws.rev)
ex.exec(Step("s1", 0, "app.py", "v0", "v1"))
ex.plan(ws.rev) # new model re-plans from rev 1
return ex.exec(Step("s2", 0, "app.py", "v0", "v1-tail"))
Expected output, because the simulation is deterministic and calls no model:
fence=none concurrent_edit -> applied preemption_retry -> applied twice replan_drift -> applied
fence=plan concurrent_edit -> rejected:stale_rev preemption_retry -> rejected:stale_rev replan_drift -> rejected:stale_rev
fence=step concurrent_edit -> rejected:stale_rev preemption_retry -> rejected:stale_rev replan_drift -> applied
dedupe on concurrent_edit -> rejected:stale_rev preemption_retry -> deduped replan_drift -> applied
Read the second and third rows together, because that is the argument. A plan-level fence turns every retry into a spurious rejection and every legitimate re-plan into a dead end; it is loud, but it is loud in the wrong places. A step-level fence plus a persisted journal gets you the two properties you actually want, and it costs you a durable set of (step_id, base_rev) keys.
Three scenarios are an anecdote. Turn them into invariants and let a random interleaving search hunt for violations — this is the part I would block a design review on.
def invariants_hold(ws, results):
assert all(r.rev_at_apply == r.base_rev for r in results if r.status == "applied")
assert ws.journal == set(ws.journal)
assert all(r.base_rev <= r.rev_at_apply for r in results)
Then fuzz: shuffle {human_edit, preempt, retry, replan, duplicate_delivery} over a fixed step list, run 5,000 seeds, and assert P1 and P2 for every seed with fences on. Run the same search with fence="none" to extract a minimal counterexample, and put that event order in the PR description as the regression case. A protocol claim without a counterexample search is a preference.
If your executor is a shared, preemptible environment and your planner can move between models at any time, then re-plan drift and lost acks are not edge cases — they are the steady state. That is the actual architectural consequence of running agents on free resources, and it argues for cheap, persisted fences rather than expensive coordination.
I use MonkeyCode as the concrete instance of that environment in this review: it is an open-source coding agent whose operator supplies free model access (currently advertised as a 10M-token allowance) and a free server option. Treat those numbers as operator-supplied and re-verify them on the project page before you design around them, because allowances and routing change faster than any architecture does. My interest here is narrow: a free server makes preemption ordinary, and free model access makes mid-session re-routing ordinary, so both of my injected interferences become Tuesday.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
| Mechanism | Buys you | Costs you | Fails when |
|---|---|---|---|
| No fence | Throughput, simplicity | Silent lost updates | Any concurrent edit |
| Plan-level fence | Loud, cheap rejection | Re-planning whole plans, spurious retries | Model swap or long plans |
| Step-level fence | Precise staleness detection | A re-stamp on every dispatch | Semantic overlap between steps |
| Persisted journal | Idempotent retry across preemption | Durable key storage, growth control | Journal not persisted atomically with the write |
| Checkpoint per step | Fast compensation | Storage, restore latency | Large binaries, long test suites |
| Event order | Correct verdict | Why |
|---|---|---|
Step arrives, base_rev < current rev, no overlap |
Reject, re-plan from current rev | The plan is stale; the patch is not recoverable |
Step arrives, journal contains (step_id, base_rev) |
Replay the stored result | The work is done; the ack is what was lost |
| Step applied, tests for touched files fail | Compensate to the checkpoint | Fence passed, semantics did not |
| New model re-plans mid-session from current rev | Accept if re-stamped and preconditions hold | Drift is expected, staleness is not |
| Two steps with overlapping hunks in one plan | Reject the plan at admission | A fence cannot order siblings |
base_rev at dispatch, never at plan time, and log both values — the delta is your drift signal.fence="none" as a named regression seed.
The fixture models file-level hashing and a single writer; it says nothing about merge semantics, and it does not evaluate whether a patch was any good. If your model swap also changes plan quality, this protocol will happily accept a worse plan that happens to be revision-correct. That is a separate evaluation problem, and no fence will solve it for you.
Skip this if your agent is single-turn and single-file with a human watching every apply, if your platform already gives each step an atomic worktree or VM snapshot, or if you cannot persist a journal durably — half of this mechanism without the journal is worse than none, because it rejects retries without making them safe.
So here is the question I would leave in the review thread: which event order in your system breaks the invariant — a human edit racing step two, an ack lost after the write, or a re-plan against a stale base — and for that specific order, should the executor reject, replay, or compensate?