Skip to content

File locks & coordination

You kick off a background agent to refactor auth.rs while you keep working in the same repo with your foreground agent. At some point you both reach for that file. One write lands on top of the other, the working tree ends up in a state neither of you intended, and nothing tells you it happened. This is the everyday case: the moment a second agent is loose in your repo, uncoordinated writes are a correctness problem — and it only gets worse the more agents you run in parallel.

flowgate makes the collision impossible at the runtime level with a global repo file-lock: no two holders can write-lock the same file at the same time. That holds however many agents you run — two of them, or a fleet of parallel workflows and sub-workflows — because the repository itself is the protected unit. It’s the same guardrail idea as the rest of flowgate: instead of asking agents to stay out of each other’s way, the runtime makes stepping on a held file something they simply can’t do.

A transition declares the files its executor will touch with owned_files:

states:
editing_auth:
transitions:
apply_patch:
target: verifying
executor:
kind: agent
owned_files:
- src/auth.rs
- src/session.rs

Before the executor runs, the runtime acquires a lock on those files (holder = the workflow instance, with a TTL). It runs, then releases them. Acquisition is atomic and all-or-nothing: a transition that wants three files and can get two gets none and waits — which is what prevents the classic multi-file circular-wait deadlock, because a waiter never holds anything while it waits. For a parallel executor, the locked set is the union of its branches’ owned_files.

This is the part that matters. When a transition can’t get its files because another agent holds one, it does not fail and it does not proceed. It durably suspends: the runtime persists a _lock_wait record on the instance and returns a waiting_on_lock status.

{ "result": { "status": "waiting_on_lock" },
"context": { "_lock_wait": { "files": ["src/auth.rs"] } } }

The moment the holder finishes and releases the file, the runtime re-drives the first suspended workflow whose full file-set is now free, in FIFO order, and the transition runs as if nothing happened. No polling, no retry loop, no lost work.

Three properties make this safe to rely on in production:

  • Durable. The suspension lives in the workflow instance, not in a held thread. It survives a restart — on startup the runtime re-registers everything that was waiting (list_waiting_on_lock) so it auto-resumes when its files free.
  • No deadlock. Locks carry a TTL and are reaped on read, so a holder that dies never blocks the queue forever.
  • No notification needed. Waiting is a routine, expected event — there’s nothing for an operator to handle. LOCK_* errors are reserved for genuine misuse, not contention.

Responses surface the lock state: the locks a workflow currently holds (file + holder + expiry) and what it’s waiting_on. And the lifecycle is audited — a suspension emits lock.wait.suspended and a resume emits lock.resumed, both threaded with the workflow’s correlation id, so a stuck or slow agent is visible in the same event stream as everything else.

File locks are the enforcement layer under the coordination story: the shared context holds state every agent can read, optimistic versioning (STALE_WORKFLOW_VERSION) stops an agent acting on a stale read, and parallel execution fans independent work out concurrently. The repo lock is what lets that fan-out touch the filesystem safely — the branches run at once, and any two that would collide on a file are serialized automatically, in order, with no one stomping anyone.