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.
1. The event order that silently wins
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?
2. The invariant, stated once
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.
3. Declared assumptions
- One writer per file at a time; concurrent multi-writer edits are out of scope and need a merge protocol, not a fence.
- The workspace has a monotonic revision counter and a per-file content hash that are both persisted with the workspace itself.
- A step is planned from a readable snapshot and carries
(step_id, base_rev, path, pre_hash, patch). - Execution may be preempted at any point between "patch written" and "client received the result".
- Model identity may change inside a session, because routing, fallback, or a free-tier allowance running out can move you to another model without warning.
4. Data flow and failure domains
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.
5. The fixture
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"
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.
6. Injecting the three failures
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"))
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.
7. Making it a property, not a demo
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)
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.
8. Where the free tier changes the arithmetic
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.
9. Tradeoffs I would write down
MechanismBuys youCosts youFails whenNo fenceThroughput, simplicitySilent lost updatesAny concurrent editPlan-level fenceLoud, cheap rejectionRe-planning whole plans, spurious retriesModel swap or long plansStep-level fencePrecise staleness detectionA re-stamp on every dispatchSemantic overlap between stepsPersisted journalIdempotent retry across preemptionDurable key storage, growth controlJournal not persisted atomically with the writeCheckpoint per stepFast compensationStorage, restore latencyLarge binaries, long test suites
10. Verdicts: reject, replay, or compensate
Event orderCorrect verdictWhyStep arrives,
base_rev < current rev, no overlapReject, re-plan from current revThe plan is stale; the patch is not recoverableStep arrives, journal contains
(step_id, base_rev)Replay the stored resultThe work is done; the ack is what was lostStep applied, tests for touched files failCompensate to the checkpointFence passed, semantics did notNew model re-plans mid-session from current revAccept if re-stamped and preconditions holdDrift is expected, staleness is notTwo steps with overlapping hunks in one planReject the plan at admissionA fence cannot order siblings
11. What I would change next
- Persist the revision counter, file hashes, and journal in one atomic write, so a crash cannot leave a patch without its key.
- Stamp
base_rev at dispatch, never at plan time, and log both values — the delta is your drift signal. - Bound the journal by session and drop it only after a checkpoint the workspace itself can prove.
- Run the fuzz job in CI with fences on, and store the minimal counterexample from
fence="none" as a named regression seed. - Add a sibling-overlap check at plan admission, because per-step fences order nothing within a plan.
12. Limits, and who should skip this
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?#ai #architecture #distributed #testing #software #coding #development #engineering #inclusive #community
Treat a Coding-Agent Step as a Revision-Fenced Command, Not a Prompt