AI disclosure: I wrote this article directly in English — there's no Japanese original behind it. I used AI to help draft the text and to review it for accuracy across several rounds before publishing; the stop-condition design, the incident that prompted it, and the conclusions below are my own.
I spent a while getting permission levels right. What an agent may read, what it may write, what needs a human. That work was worth doing, and it did not save me here.
The gap is simple to state and easy to miss: permission levels answer what may this agent do. They say nothing about what happens when the task itself changes after the agent already has permission.
The situation
When work is handed to an agent, the handoff carries a task definition, a scope, and the conditions that count as done. The agent takes it and starts.
Then I edited the task. Not maliciously, not carelessly. I noticed something while the agent was still setting up, and I added a comment that changed what "done" meant.
At that moment I had an agent holding valid authority for a task that no longer existed in that form. It had permission. Its permission was correct. Its instructions were stale.
Static permission levels do not catch this. The agent is doing exactly what it was allowed to do. The problem is that "what it was allowed to do" was defined against a version of reality that I had just replaced.
Why "just ask the human" is the wrong fallback
The obvious fix is to have the agent check in when something looks off. I do not think this works, for two reasons.
First, the agent cannot see the ambiguity. From inside the handoff, the stale task reads as perfectly coherent. There is no contradiction to notice. The instructions are complete, the scope is clear, and the acceptance conditions are stated. It just happens that a newer version exists elsewhere.
Second, if the agent asks me every time it feels uncertain, I have rebuilt the bottleneck I delegated to avoid. Interruptions that fire on vague signals train you to approve them without reading, which is worse than not having them.
The check has to be mechanical, and it has to run at a specific moment rather than continuously.
The stop condition
What I added is small:
- Bind the task revision at dispatch. The handoff record states which version of the task it authorizes, not just the task contents. "The task" means every authority-bearing input, not just the main body — a comment that changes acceptance criteria has to bump the revision the same as an edit to the body does, or the mechanism misses exactly the kind of change that motivated it in the first place.
- Re-read the live task immediately before every write, starting with the first. Not at claim time, not at plan time — right before the agent is about to change something real, and again before each write after that if the task has more than one.
- If the live revision differs from the bound revision, stop. Do not re-plan against the new version. Do not merge the two. Do not ask which one to follow.
- Return a typed result saying why, so the dispatcher can tell a safe stop apart from a crash.
Point three is the one worth arguing about. When the task has changed, the agent is holding authority that was granted for a different question. Re-planning against the new text would mean the agent granted itself authority for the new version, which is exactly the thing the whole structure exists to prevent. Stopping is not the agent giving up. It is the agent declining to promote itself.
What this looks like in code
Prose about "binding a revision" is easy to nod along with and easy to get wrong in practice, so here is a minimal runnable version — a sketch, not a production system, but every line of it runs and the shape matches the four steps above.
from dataclasses import dataclass
from typing import Literal, Union
@dataclass
class Task:
task_id: str
revision: int
body: str
class TaskStore:
"""Wherever tasks actually live: issue tracker, DB row, a file."""
def __init__(self):
self._tasks: dict[str, Task] = {}
def create(self, task_id: str, body: str) -> Task:
task = Task(task_id, revision=1, body=body)
self._tasks[task_id] = task
return task
def read(self, task_id: str) -> Task:
return self._tasks[task_id]
def edit(self, task_id: str, new_body: str) -> Task:
# A human changes the task; this bumps the revision.
current = self._tasks[task_id]
updated = Task(task_id, current.revision + 1, new_body)
self._tasks[task_id] = updated
return updated
@dataclass(frozen=True)
class Handoff:
task_id: str
bound_revision: int
body_at_dispatch: str
def dispatch(store: TaskStore, task_id: str) -> Handoff:
task = store.read(task_id)
return Handoff(task.task_id, task.revision, task.body)
@dataclass(frozen=True)
class Written:
status: Literal["written"] = "written"
@dataclass(frozen=True)
class StoppedStale:
status: Literal["stopped_stale_task"] = "stopped_stale_task"
bound_revision: int = 0
live_revision: int = 0
AgentResult = Union[Written, StoppedStale]
def run_agent(store: TaskStore, handoff: Handoff, sink: list[str]) -> AgentResult:
plan = f"implement: {handoff.body_at_dispatch}" # planning happens here
# Gate: re-read the live task immediately before the first write.
live_task = store.read(handoff.task_id)
if live_task.revision != handoff.bound_revision:
return StoppedStale(bound_revision=handoff.bound_revision, live_revision=live_task.revision)
sink.append(plan) # the first write happens only after the gate passes
return Written()
Run it with a human editing the task in between dispatch and the write, which is the case this whole thing exists for:
store = TaskStore()
store.create("T-2", "add a retry to the fetch call")
handoff = dispatch(store, "T-2")
store.edit("T-2", "add a retry to the fetch call, but only for 5xx responses")
sink = []
run_agent(store, handoff, sink)
# StoppedStale(status='stopped_stale_task', bound_revision=1, live_revision=2)
# sink == []
Without the edit, the same call returns Written(status='written') and sink ends up holding the plan. sink stands in for every side effect a real write could cause: a file change, a commit, an API call. In this toy example the gate sits in exactly one place, after planning and before the single append to sink, because the task only produces one write. Any earlier and it can't see edits that land while the agent is still planning. Any later and the agent has already mutated something before learning its authority was stale.
A task that produces several writes needs the same check at each one, not just the first — the code changes shape (the gate moves inside whatever loop or step sequence does the writing), but the principle does not: never perform a write on an authority you have not just confirmed still exists. A gate that only runs once, before the first of several writes, protects the first write and nothing after it — which is a different, weaker guarantee, and worth stating as such rather than implying the whole task is covered.
That per-write check still leaves a narrow gap: the task can change in the instant between the re-read and the write itself. Optimistic concurrency control, If-Match, and non-fast-forward pushes close that exact gap because the version check and the write happen as one atomic operation at the authoritative store. A re-read-then-write pair is not atomic, so the gap here is real, just made small rather than closed. Closing it fully means moving the check into the write path itself — a lease or fencing token the write call validates at commit time, or staging the work and promoting it atomically only if the revision still matches — which is more machinery than this sketch needs to make the point, but is what a production version should reach for.
What a safe stop looks like
The stop fired, and what it produced is worth describing precisely. The agent performed no mutations and triggered no external side effects — no file change, no commit, no mutating API call. And it returned exactly one result saying why it had stopped.
That last part matters as much as the rest. A stop that leaves no trace is indistinguishable from an agent that died, and you will waste time investigating it. A stop that returns one typed result — I halted because the task revision changed after dispatch — is a fact the dispatcher can route on.
Producing nothing matters because a partial write is worse than either outcome. An agent that gets halfway through an implementation based on stale requirements and then stops leaves you a working tree you have to inspect before you can trust anything in it. The evidence is contaminated by the incident. In the case that motivated this article, and in any task where the stop fires on the first write, that's a genuine zero-mutation stop: the next attempt starts from a clean, known state, no inspection required.
That guarantee is specific to stopping before the first write, and it does not automatically extend to a task that has already committed one or more valid writes by the time a later one gets stopped. In that case the honest result is not "zero mutations" but "no new mutation on stale authority" — the dispatcher gets a typed partial-stop result naming what was already written under the old revision and what got blocked under the new one, and decides from there whether the partial work is still usable. The stronger guarantee — that no task output reaches the real target despite several staged writes — needs the staged-then-atomically-promoted design mentioned earlier: nothing lands in the real target until the final promotion checks the revision one last time.
The part that looks like failure
The broader pipeline this stop condition lives in produces a lot of returns instead of shipped code — most attempts on a given task end in a design decision handed back rather than a completed diff, this stop condition being one specific source among several. The agent comes back holding a question rather than a result.
For a while I read that as the pipeline underperforming. I have come around to reading it differently. A return like this — the one quoted above, task revision bound at 1, live revision moved to 2, zero files changed, one typed result — is a case where the agent could have produced something plausible and wrong, and instead surfaced the decision to the person who could actually make it. That return was not the pipeline failing to work. It was the pipeline working on a task whose definition I had not finished thinking through.
The number I would actually worry about is the opposite one: attempts that sailed through and produced confident output on instructions nobody had checked.
"Why not just re-plan against the new version?"
This is the fair objection. If the agent has already re-read the live task and can see exactly what changed, why not fold the edit into the plan and continue, instead of stopping?
Two reasons. A plan built against the old task carries assumptions formed before the edit — about ordering, scope, what "done" excludes — and a live re-plan does not reliably discard those; it tends to patch them, silently mixing two versions of the task in a way nobody asked for and nobody will see in the output. More basically, an agent that decides for itself how to reconcile an old plan with a new instruction has just granted itself authority over the new version, the same self-authorization the stop condition exists to remove.
Sometimes the edit really is trivial — a typo fix — and stopping for it looks like overkill in hindsight. But the agent cannot reliably tell a trivial edit from a load-bearing one by reading the task text, because both read as coherent from the inside. That is the same blind spot that ruled out "ask the human when something looks off": not that the agent judges badly, but that it has no signal to judge on. A revision counter does not require that judgment — it does not care what changed, only that something did — which is exactly why it is cheap enough to run before every write.
Isn't stopping wasteful?
Measured one way, yes. Every stop throws away the planning work done up to that point, and the task has to be redispatched. That is a real cost.
Compare it to the alternative. An agent that reasons about a stale task and ships anyway produces output that looks exactly like a correct diff, because from the inside it was reasoning correctly about the wrong version of the problem. Nothing in the pull request flags it as suspect. Someone has to notice the mismatch after the fact, understand why the change does not match current intent, and unwind it — a review cycle nobody knew to be suspicious of, plus whatever already got built on top of the merged result. A stopped run costs one redispatch on a clean tree with a typed reason attached. A silently stale run costs a review you did not know you needed until something else broke.
Where the check runs matters
The stop condition only works because of when it runs, not just that it exists. Three placements, compared:
| Check placement | What it catches | What still gets through |
|---|---|---|
| No check | Nothing | Every edit made after dispatch; the agent always finishes on whatever it read at the start |
| Check at claim time only | Edits that land before the agent picks up the task | Edits made after claim but before any write — which is most of them, since claiming happens moments after dispatch and writes happen after planning, sometimes much later |
| Check immediately before every write | Every edit that lands during dispatch, claim, planning, and between writes | The narrow window between each check and its write call, which can be made small but not zero — and, for a task with earlier writes already committed, those earlier writes themselves are not undone by a later stop |
The middle row is the trap. A check at claim time feels like it solves the problem, because it is easy to build into whatever already assigns work to agents, and it does catch some edits. But claiming happens close to dispatch, before there has been time for a human to notice anything worth editing. The actual editing happens later, while the agent is mid-flight — reading, planning, sometimes taking a while to get to its first write. That is precisely the span a claim-time check does not cover.
Prior art, precisely
Nothing about "read a version, refuse to write if it moved" is new. What's worth being precise about is how each precedent signals the conflict and what it hands back to the caller.
Optimistic concurrency control in an ORM attaches a version column to a row and includes it in the write itself. Entity Framework Core's docs describe the token as "loaded and tracked when an entity is queried," and on save the generated UPDATE adds it to the WHERE clause — WHERE [PersonId] = @p1 AND [Version] = @p2 — so the row only updates if the version hasn't moved. If it has, zero rows match and the framework raises a typed DbUpdateConcurrencyException. Check and write are the same database operation, which an agent pipeline can't get for free — there's no transaction wrapping "read the task" and "change the file" — so a deliberately placed re-read is the practical substitute.
HTTP conditional requests put the same idea on the wire. A client holds the ETag it last read and sends it back with If-Match. Per MDN, if the resource has changed, "a 412 Precondition Failed response is returned instead" — a distinct precondition-failure signal that the caller can handle by refetching and deciding again, rather than retrying blind into a generic error.
Git's non-fast-forward rejection is the version most developers already carry intuition for. git push refuses to update a branch when the pushed commit isn't a descendant of the branch's current tip, shows the rejection explicitly marked ! and labeled rejected, and the docs are specific about what comes next: pull and resolve, or rebase, and reach for --force only "if you are certain that nobody in the meantime fetched your earlier commit... and started building on top of it."
All three name the conflict as a distinct, typed outcome instead of folding it into a generic failure, and all three hand the caller a re-read-then-decide path instead of reconciling versions on its behalf. That's the part worth copying; the status codes and version numbers are incidental.
Agent pipelines skip this because the agent feels like a collaborator rather than a writer to a shared resource — it reads instructions, reasons, explains itself, so the instinct is to handle conflicts through conversation. But at the moment it writes to your repository, it is a concurrent writer holding a version it read earlier, and the same question the boring database answer asks — has the version I'm holding moved? — still applies. The database gets to ask and act on that atomically; a re-read-then-write agent has to settle for asking just before acting, which is weaker but is the version available without rebuilding the write path as a transaction.
What to copy
If you are running delegated agents, the useful generalization is not my specific mechanism. It is this: an agent's authority is bound to a version, not to a task.
Concretely, three questions:
- When you hand an agent work, does the handoff record which version of the task it authorizes, or just the contents?
- Is there a moment before every real write, not just the first, where the agent re-reads the source of truth?
- When those two disagree, does your system stop, or does it improvise?
If the answer to the last one is "improvise," it will produce correct results most of the time. That is the difficult part. The failure mode is rare, it looks like normal output when it happens, and you find it later in a diff you did not expect.
I would rather have an agent that halts on a technicality than one that finishes on an assumption.
Top comments (1)
"Permission levels answer what may this agent do. They say nothing about what
happens when the task itself changes after the agent already has permission."
That's the sentence. Most of the agent-safety tooling I've seen still only has
an answer for the first half.
The typed stop reason is the part I'd steal — a halt you can't attribute is
indistinguishable from a flake.