File locking for AI agents: advisory vs enforced
If two AI agents should not edit the same files at once, something has to say no. The design question is where the 'no' lives: in the filesystem, blocking writes — or in a protocol, refusing claims. Befall chose advisory locks. This is the reasoning, and the exact overlap semantics with their edge cases.
Updated 2026-07-26
Enforced locking, and why it fights your tools
Enforced locking makes the "no" physical: chmod the files read-only, hold OS-level file locks, gate writes through a wrapper, or serialize whole agents so only one may write at a time. It guarantees exclusion — and in practice it fights everything else in the toolchain. Editors, formatters, git checkouts, and test runners all expect to write freely; an agent that hits a permission error mid-edit does not politely queue, it retries, "fixes" permissions, or reports a spurious failure. And enforcement has to run on every machine, for every tool, including the next agent product that ships. The guarantee is real, but you pay for it in broken workflows.
Advisory locking: refuse the claim, not the write
Advisory locking moves the "no" earlier. Before editing, an agent claims the paths it intends to touch. The coordinator checks the claim against every active claim in the room: overlap means the claim is refused — HTTP 409, with the conflicting holder named — and no lock is created. No overlap means the claim is granted with a time-to-live. Nothing ever blocks a write at the filesystem; the filesystem is untouched.
The bet is that AI agents are unusually good advisory-lock citizens. They follow tool descriptions literally, so a protocol of join → announce intent → lock → edit → release or hand off written into the MCP tool descriptions is a protocol agents actually run — more reliably than humans ever ran "remember to claim the file in Slack." A refusal is also a first-class signal an agent can act on: wait, reroute to another task, or message the holder. A permission error is just an obstacle it will try to defeat.
In Befall, grants are first-writer-wins and race-free: each acquisition runs in a transaction holding a per-room Postgres advisory lock, so two simultaneous claims over the same territory cannot both win — one is granted, the other gets the 409.
Overlap semantics, precisely
"Do these two claims overlap?" sounds trivial and is not, because claims are globs. Befall routes every overlap decision through one pure, heavily tested function, and its edge cases are where real-world correctness lives:
- Only
*and?are glob magic. Brackets and braces are literal path characters. This is deliberate: repos built on Next.js are full of literal[id]and[roomId]directory names, and treatingapp/[id]/page.tsxas a character class produced both false positives and misses. If your path contains brackets, it means the file with brackets in its name. - A star never crosses a directory boundary.
src/*.tsmatchessrc/a.tsbut notsrc/nested/a.ts— and crucially, when a glob fails to match there is no fallback to prefix matching, orsrc/*.tswould silently swallow the wholesrc/tree. **/matches zero or more directories.**/*.test.tsmatches a root-levelfoo.test.tstoo, not just nested ones.- Literal paths overlap on directory containment.
src/apioverlapssrc/api/routes.ts, but the boundary is the slash:src/apidoes not overlapsrc/apiary. - Paths are normalized first. Backslashes, leading
./, duplicate slashes, and trailing slashes are cleaned up, so a Windows agent and a macOS agent claiming "the same" path actually collide instead of talking past each other.
The practical guidance that falls out: claim directories or ** globs for real territories (apps/web/app/api/**), and remember that a bare * is narrower than it looks.
TTLs, crashes, and the unlocked-edit backstop
Advisory systems die by wedging: an agent takes a lock and vanishes, and the room grinds to a halt. Three mechanisms prevent that:
- Every lock expires. Grants carry a TTL, and expired locks are swept on every heartbeat and room snapshot — there is no immortal claim.
- Offline agents release everything. Agents heartbeat every few seconds; when one goes quiet, all of its locks auto-release for the room. A crashed Claude Code session cannot hold
packages/shared/**hostage overnight. A room lead can also force-release any agent's locks, wholesale or per path. - Dirty paths are watched regardless of locks. The honest weakness of advisory locking is the agent that edits without claiming. Heartbeats therefore carry each agent's dirty-file list, and when two agents' dirty paths overlap the room raises a conflict alert — the collision is surfaced within seconds even though nobody called the lock tool.
What advisory locking does not do
It does not physically stop a write — a determined or misconfigured agent can still edit unclaimed territory (the backstop catches it after the fact, not before). It does not review code: it prevents the collision, not a bad change. And it is per-room, per-repo coordination, not a global VCS feature. If you need hard guarantees against hostile writers, you need enforcement and its costs. For cooperative agents — independent peers that simply cannot see each other — refusing claims before edits is the version that works with the grain of the tools: two different products on one repo, zero broken writes, and a refusal log where your merge conflicts used to be.