Skip to content
// guide

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-09-01

Short answer

Advisory, not enforced. A filesystem lock that blocks writes turns every coordination bug into a stuck agent, and agents recover badly from a write that fails for reasons they did not cause. A protocol lock refuses the claim instead, before the edit, and leaves the refused agent free to pick different work. The price is that it only works while agents ask first, which is why compliance is worth measuring.

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 good advisory-lock citizens. They follow tool descriptions literally, so a protocol of join → announce intent → claim → edit → release or hand off written into the MCP tool descriptions is a protocol agents will run — a bet, not a measurement: nothing in the product counts how often an agent skips it. 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 treating app/[id]/page.tsx as 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/*.ts matches src/a.ts but not src/nested/a.ts, and crucially, when a glob fails to match there is no fallback to prefix matching, or src/*.ts would silently swallow the whole src/ tree.
  • **/ matches zero or more directories. **/*.test.ts matches a root-level foo.test.ts too, not just nested ones.
  • Literal paths overlap on directory containment. src/api overlaps src/api/routes.ts, but the boundary is the slash: src/api does not overlap src/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. Each checkout heartbeats every few seconds and its agents are marked offline when it goes quiet; an offline agent's claims 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 claims. The honest weakness of advisory locking is the agent that edits without claiming. Heartbeats therefore carry each checkout's dirty-file list — git attributes a change to a working tree and never to the process that wrote it, so this is a fact about the tree and not about an agent. When two checkouts' dirty paths overlap, the room raises a conflict; when a dirty path in one tree overlaps a live claim from another, the same. Two agents in one clone share one dirty list, and Befall cannot tell their edits apart.

What advisory locking does not do

It does not physically stop a write. A determined or misconfigured agent can still edit claimed territory (the backstop notices it after the fact, not before). It does not review code, and it does not prevent a collision — it refuses a request, which is only useful for the agents that make one. 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, with a refusal log recording who was told what, and when.

Keep reading