Treat a Coding-Agent Step as a Revision-Fenced Command, Not a Prompt
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 mod

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.
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.
# fence_sim.py - deterministic, no LLM involved 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 " Enter fullscreen mode Exit fullscreen mode 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 ) # ack lost; client replays s1 against a fresh executor, same persisted workspace 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 " )) Enter fullscreen mode Exit fullscreen mode 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 Enter fullscreen mode Exit fullscreen mode 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 ): # P1: every applied step saw base_rev == rev at apply time assert all ( r. rev_at_apply == r. base_rev for r in results if r. status == " applied " ) # P2: journal is append-only per (step_id, base_rev) assert ws. journal == set ( ws. journal ) # P3: no path content was replaced by a step older than the current rev assert all ( r. base_rev <= r. rev_at_apply for r in results ) Enter fullscreen mode Exit fullscreen mode 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.
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?