Lacunari

Writing

Why atomic claims beat merge conflicts

"Conflict-free" is a marketing word until you can point at the statement that makes it true. Here is the statement, what Postgres actually does with it under concurrent load, and what a merge conflict is doing differently that makes it structurally worse for this problem.

Two workers, one task, no coordinator. That is the situation every fleet of agents eventually produces, and the two ways of resolving it are not variations on a theme — they disagree about the one thing that actually matters, which is when the collision is discovered.

What a merge conflict actually is

Strip away the tooling and a merge conflict is this: two parties independently read the same starting state, each did real work against their own copy, and only afterward — at merge time — does anything compare the two results. Git's three-way merge is genuinely excellent at the comparison. It is not the part that's slow. The part that's slow is everything before it: two full implementations already written, two sets of tokens already spent, two agents already convinced they finished, before either one learns the other existed.

For two humans working over hours, that cost is a Slack message and ten minutes reconciling a diff. For eleven agents running unattended overnight, the same shape produces two complete, divergent implementations of one task, discovered the next morning, with real API spend behind both of them. The detection did not fail — it triggered exactly on schedule, at the only point git is capable of looking. Git only learns what happened when you tell it, and by the time you tell it, the token spend already happened.

What an atomic claim actually is

The mechanism is one UPDATE statement. This is the real one, unmodified, from ctask claim:

UPDATE lac_tasks SET state='claimed', owner=:'o', claimed_at=now(), heartbeat_at=now(), updated_at=now() WHERE id = :id AND state='open' AND lac_claimable(id, :'o') RETURNING id || '|' || owner;

Two workers race to claim task #12. Both send this statement to Postgres at what is, for any practical purpose, the same instant. What happens next is not application logic — it is a property of how a relational database executes a row-level write, and it holds regardless of which language, which framework, or which agent runtime is issuing the query.

The mechanism, step by step

  • Both transactions attempt to lock the same row. id = :id is the primary key, so both queries resolve to exactly one row in lac_tasks. Postgres takes a row-level write lock on it before it will let either transaction proceed to the actual update.
  • One gets the lock, the other blocks. Row-level locking is exclusive for writers — this is ordinary MVCC, not anything Lacunari added. Whichever transaction arrives first (by however many microseconds) gets the lock. The second is made to wait, not rejected outright, which matters for what happens next.
  • The winner's transaction commits. state flips from 'open' to 'claimed', owner is stamped, and the row is released.
  • The loser's transaction re-evaluates the WHERE clause against the new committed state — and finds zero rows. This is the entire trick. The predicate is state='open'. The winner just changed it to 'claimed'. The loser was never denied a lock; it simply discovers, once it can finally check, that the row it wanted no longer matches the condition it was waiting on. Postgres returns zero rows affected. No error, no exception, no retry logic required to interpret it — an empty result set is the answer.
  • The loser reads that as "gone" and asks the board for the next task. ctask claim checks whether the UPDATE returned a row; if not, it reports why — unmet dependency, missing capability, or simply already claimed — and the worker moves on. Nothing was written to disk on the losing side. No cleanup, no rollback of partial work, because no work happened yet.

The index matters here as much as the transaction semantics. id is the primary key of lac_tasks, so the row lock is acquired against a single indexed row rather than a table scan that could block unrelated claims on unrelated tasks. Two workers claiming task #12 and #47 at the same instant never contend with each other at all — the lock is scoped to the one row each statement actually touches.

The one line that does the real work

lac_claimable(id, agent) is not decorative. It is a single function that every claimer — ctask, the worker, the drainer — calls instead of re-implementing its own version of "is this task actually available":

CREATE FUNCTION lac_claimable(task_id integer, agent text) RETURNS boolean LANGUAGE sql STABLE AS $$ SELECT EXISTS ( SELECT 1 FROM lac_tasks t WHERE t.id = task_id AND t.state = 'open' AND lac_deps_met(t.id) AND (t.requires = '{}' OR lac_caps_met(t.id, agent)) ) $$;

That one function is what keeps a dependency check and a capability check from drifting out of sync between three different code paths that all need to agree on the same question. Centralizing it here means the rule changes once, in one place, rather than three times in three tools that have to happen to stay consistent with each other.

Files, not just rows

A claimed task can also declare the files it touches, and that declaration is what turns "don't claim the same task twice" into "don't touch the same file twice," which is the actual collision that costs real work. A second worker that tries to start on a file already locked by another task's claim is refused with a specific answer — dana holds core/auth.py on task #12 — before it writes a line, rather than discovering the collision in a diff after both have finished. Readers can coexist on a path; writers are exclusive. If the worker holding the claim dies, its heartbeat goes stale, the keeper releases both the task and the file locks it held, and the work goes back on the board with its history intact — nobody has to notice the crash by hand.

Where the guarantee stops

Being exact about the edges matters more than the pitch, so three of them, stated plainly:

  • This prevents duplicate claiming. It does not merge concurrent edits. If two parties genuinely both need to modify the same file — federated peers who never shared a lock, or work done offline and reconciled later — that is a real three-way merge, and Lacunari shells out to git merge-file for it rather than pretending atomic claims make merges unnecessary. The claim mechanism and the merge mechanism solve different problems; conflating them is how you end up trusting a guarantee past where it applies.
  • A lock only stops the party that checks it. A process that writes to a file without ever calling ctask claim or ctask paths first is not blocked by anything — the guarantee is cooperative at that boundary, the same way the base permission model is cooperative until team mode turns row-level security on underneath it.
  • Single-drainer-per-lane, not single-drainer-globally. The atomic claim on a task row holds regardless of how many drainers are running. The drainer's own local lock file does not — two drainer processes on different machines, both configured to work the same lane, will each acquire their own local lock and proceed, because the lock is a directory on one filesystem. The task-level claim still prevents the same task from being run twice; the assumption that breaks is one drainer per lane, and the documented fix is a Postgres advisory lock, which is not shipped yet.

Why this is the better trade for unattended work

The comparison is not "atomic claims are fast, merges are slow," though that's true. It's about where the cost lands. A merge conflict spends the work first and detects the collision after, which is tolerable when a human is watching and expensive when nobody is. An atomic claim spends nothing on the losing side — the loser's cost is one UPDATE that returned zero rows and a few milliseconds deciding what to try next. Run a hundred workers against a queue overnight and that difference compounds into the entire reason it is safe to leave them running while you sleep.


What content addressing buys you on top of this →  ·  Why not just use git?