Airforge / Field note
One conditional write: coordination primitives for racing agents
Two agents ask for work in the same millisecond. Which one owns it?
Two agents ask for work in the same millisecond. Both see the same unassigned task. Which one owns it?
The mechanism decides; intentions get no vote. One careful human rarely collides with anyone, so conventions feel sufficient. Agents poll on timers and act within milliseconds of what they observe, in parallel by design. Any gap between observing “free” and recording “mine” is a gap two actors can occupy together, and its cost is the same work done twice.
The lost update
The intuitive claim reads the task, confirms it is unassigned, then writes an assignment. Between the read and the write the state can change, and under concurrency it eventually will. Databases named this failure long ago: the lost update. Two transactions read the same row, both decide from what they read, and the second write silently erases the first. The PostgreSQL documentation on transaction isolation describes the anomaly and why plain read-then-write is unsafe at default isolation.
The fix is old and general: make the decision and the write one operation. Processors call it compare-and-swap, databases a conditional update. The shape is always “write only if the state still matches what I require, and tell me whether you did.”
One conditional write
Axial’s issue claim is one such operation. The predicate is “unassigned, or the previous lease has lapsed”, and there is no separate read for the decision to rest on:
claim (codex) ──┐
├──▶ one conditional UPDATE:
claim (fable) ──┘ take ownership where unassigned or lease lapsed
│
predicate held ──▶ claimed PLAT-1 by codex exit 0
predicate gone ──▶ error[CONFLICT]: already claimed ... exit 4
The storage engine serializes the writes; the second to land finds its predicate gone. Exactly one owner, every time. The winner learns it from the echo:
$ axial issue claim PLAT-1
claimed PLAT-1 by codex
What should losing look like?
Losing the race means the system worked: it guaranteed one owner. The loser deserves a branchable outcome, so Axial reserves exit status 4 for conflict, distinct from runtime failure (1), usage error (2), and missing record (3). The one diagnostic line names the holder:
$ axial issue claim PLAT-1
error[CONFLICT]: PLAT-1 already claimed by codex
The task exists and the command was valid; someone else owns it. The correct next move is to select different work. Compare a claim that fails with exit 1 and a stack trace: the caller cannot tell a lost race from a dead database, and only one of those deserves a retry.
What happens when the winner dies?
An atomic claim creates a new liability: an unbounded claim held by a process that died is a task nobody can take until a human notices.
The classic answer is a lease: ownership with an expiry the holder must keep earning. Google’s Chubby lock service built its coarse-grained locks on this idea; a holder that stops sending keep-alives loses the lock after a bounded interval. Axial stamps the claim with an expiry in RFC3339 UTC:
$ axial issue claim PLAT-1 --lease 30m
claimed PLAT-1 by codex (lease expires 2026-07-12T01:30:00Z)
A live holder renews with a heartbeat, itself holder-guarded so a stale process cannot renew a lease it no longer owns:
$ axial issue heartbeat PLAT-1
renewed PLAT-1 lease (expires 2026-07-12T02:00:00Z)
Recovery has no reaper process; expiry mutates nothing on its own. The claim’s predicate already covers a lapsed lease, so the next claimant simply wins:
$ axial issue claim PLAT-1 # the previous holder's lease has passed
claimed PLAT-1 by fable (lease expires 2026-07-12T02:00:00Z)
The same conditional write handles the fresh claim, the lost race, and recovery from a crashed peer. A dead agent costs a bounded delay instead of a stuck task.
Selection races too
A list of available tasks is stale by the time a claim built on it lands, and a fleet piles onto the same top-ranked item. Axial folds selection into the claim: axial next picks the best actionable task and claims it in one race-safe step:
$ axial next --lease 30m
claimed PLAT-1 [todo] p:high @codex: Prereq (lease expires 2026-07-12T02:00:00Z)
An empty pool exits 3 rather than printing an empty success, so the loop id=$(axial next) && work terminates cleanly when there is nothing to do.
The implication
None of this is specific to task trackers. The same hazard sits under a queue consumer taking a message and a deploy job taking a lock. Before trusting any point where autonomous actors contend, ask: is the decision inside the write? Can a caller tell losing apart from breaking? And when the winner disappears, does ownership come back on its own? Conventions ask every participant to behave. A primitive makes the collision impossible to express.
When two actors can want the same work, put the decision inside the write.