AI Development Methodology

05 — Locks and parallel work

Purpose: define the file-based lock mechanism that lets multiple humans and AI agents work the same backlog without colliding. A lock has authority only where it is visible — it is git-tracked, so a lock commit binds exactly the refs that carry it. It has a time-to-live (TTL) and requires no external service. Read “What the lock does and does not guarantee” before relying on it for mutual exclusion; under a PR-only workflow the default form is cooperative signalling with late collision detection, and the shared-ref protocol is what makes it authoritative.

The lock lives on the Lock: field of the backlog item’s frontmatter table (see 04_backlog_items.md). The protocol is small enough that humans and AI agents can both follow it, and audit-friendly enough that every change is a commit.


The problem

A backlog used by more than one contributor — and especially one used by AI agents that can read and write to the same files — needs a way to answer “is anyone currently working this?” without ambiguity.

Without an answer:

The standard solutions either don’t fit or are too heavy:

The file-based lock with TTL solves these. It is the same data the rest of the backlog uses (markdown frontmatter), tracked by the same tool (git), audited by the same mechanism (commit history). No new dependencies. No new service to babysit. And the TTL makes it self-healing — a crashed lock-holder cannot wedge an item forever.


The solution: file-based locks with TTL

Every backlog item carries a Lock: field. Two valid states:

Lock: —

Unlocked. The item is free. Anyone may acquire it.

Lock: <holder>@<ISO-8601-timestamp>

Locked until the timestamp expires. The named holder is currently working the item. Until the timestamp passes, no one else may acquire it.

That is the entire mechanism. Everything below is the protocol that operates on this field.

Why an em dash for “unlocked”

The em dash (—) is visually distinct from a holder string. It is hard to mistake for a typo. Grep tools display it cleanly. Markdown tables render it cleanly. Compared to alternatives (null, empty string, none), it is more readable in a frontmatter table that humans skim.

Why an ISO-8601 timestamp

ISO-8601 is unambiguous, sortable, and timezone-aware. Compared to “2 PM Tuesday” or epoch seconds:

The recommended format is YYYY-MM-DDTHH:MMZ (UTC, minute precision). Higher precision is unnecessary for this purpose.


Why TTL

The lock has a time-to-live for one critical reason: lock-holders die.

Without a TTL, every such event would leave a lock in place indefinitely. The item would become un-pickable, and someone would have to manually decide it’s safe to clear — without good information about whether the holder is still active.

With a TTL:

The cost of refreshing the lock periodically is one commit + push. The cost of a stuck lock without a TTL is human investigation, ambiguity, and trust erosion.


The acquire protocol

Before starting work on an item, every contributor (human or AI) must follow this protocol:

1. Pull the latest changes from the repo so the Lock: field is current.
2. Read the item's Lock: field.
3. Decide:
   - If Lock: —, the item is free. Proceed to acquire.
   - If Lock: <holder>@<timestamp>:
       - If the timestamp is in the past → the lock has expired.
         Proceed to acquire (see "Expired locks" below first).
       - If the timestamp is in the future and the holder is not you →
         SKIP this item. Pick another. Never steal a live lock.
       - If the timestamp is in the future and the holder is you →
         you already have it. Proceed to work.
4. To acquire: set Lock: <your-id>@<now + TTL>. Optionally also flip the
   Status field to in-progress in the same edit. Commit. Push.
5. Pull again immediately after pushing. If the push raced with someone
   else's acquire and the file conflicts, that other contributor wins
   (or you do — see "Race conditions" below).

Why pull-before-decide

The lock state in your local checkout may be stale. If another contributor acquired the lock thirty seconds ago, your local file still shows it free. A git pull (or equivalent) before reading the field minimizes the race window.

Pull-before-decide narrows the window; it does not close it, and on the default protocol nothing does — see “What the lock does and does not guarantee”. The race can be eliminated without an external coordinator, but only by moving every lock write onto one shared ref so git’s own ref-update rules reject the loser; that is the shared-ref protocol.

Acquire-commit message template

chore(backlog): lock BL-### for <holder>

Lock acquired: BL-### -> Lock: <holder>@<timestamp>

The shape:

Keep the commit small: only the lock change. Combining the lock-acquire with substantive code changes makes the audit trail harder to follow.

Pushing the acquire

Push the acquire commit immediately. The lock has no effect on other contributors until it is visible in the shared remote. A locally-committed but unpushed acquire is useless — another contributor can still acquire the same item without seeing yours.

Race conditions

Two contributors may try to acquire the same item at the same time. The outcomes:

Races are rare because each acquire is a small, fast commit; the window of vulnerability is seconds. Disciplined pull-before-acquire keeps the rate low enough that the merge-conflict path is the fallback, not the norm.


What the lock does and does not guarantee

A lock has authority only where it is visible. A lock committed to a feature branch binds that branch and nothing else.

This matters because it collides with a rule this methodology states elsewhere. 09 requires every change to land on the trunk via a pull request and forbids direct trunk commits. So the acquire commit goes to the agent’s own feature branch — and a contributor who pulls the trunk cannot see it. Two agents branch from the same trunk state, each reads Lock: —, each commits a different lock value, each pushes to a different ref. Both pushes succeed. Both do the work. The conflict appears when the branches merge, which is after the duplication, not before it.

Git rejects a non-fast-forward update only when two writers update the same ref. The default protocol has them updating different refs, so there is no compare-and-swap on the backlog row and nothing to lose the race against.

What the default protocol therefore provides, stated plainly:

Property Default protocol With the shared-ref protocol
Announces intent to anyone reading the same ref ✅ ✅
Survives a crashed holder (TTL) ✅ ✅
Auditable — every state change is a commit ✅ ✅
Detects a collision At merge, after the work At acquire, before the work
Prevents two contributors starting the same item ❌ ✅

Cooperative signalling with late collision detection is a real and often sufficient property. On a small team with short items and a shared habit of pulling first, duplicated pickups are rare and cheap. What it is not is mutual exclusion, and a project that needs the stronger guarantee — because agents run unattended, or because items are long enough that duplicated work is expensive — must use the shared-ref protocol or assign items out of band.

Never state the weaker property as the stronger one. An adopter who believes the lock is authoritative when it is advisory will size their concurrency to a guarantee they do not have.

The shared-ref protocol: real mutual exclusion without a service

Opt-in. It costs one extra round-trip per pickup and provides genuine compare-and-swap, still with no database, no lock service, and no new dependency.

The rule is one sentence: every lock write targets one ref that every contributor reads.

Pick the ref once, per project — a long-lived locks branch, or a dedicated refs/locks/backlog — and record it in the project instruction file. Then:

1. Fetch the lock ref. Read the item's Lock: field from it (never from your
   feature branch).
2. If it is held and unexpired by someone else, SKIP. Pick another item.
3. If it is free or expired, commit your acquire on the lock ref and push it
   to the lock ref.
4. If the push is REJECTED (non-fast-forward), someone acquired between your
   read and your write. You lost. Re-fetch and go to step 2 — do not retry
   blindly, and do not force.
5. Only after the push succeeds do you start work.

Step 4 is the whole mechanism. Because both writers target the same ref, the remote rejects the second one, and the rejection is the compare-and-swap — the same primitive a lock service would give you, supplied by git’s ref update rules.

Three things to get right:

The cost is honest: one fetch and one push before work starts, and a second place to look. That is the price of the guarantee, and a project that does not need the guarantee should not pay it.

Choosing between them

Situation Use
Small team, short items, everyone pulls before starting Default — the cost of a rare duplicate is lower than the cost of the ceremony
Unattended or overnight agent runs Shared-ref — nobody is watching to notice the collision
Items long or expensive enough that duplicated work hurts Shared-ref
An external tracker already owns assignment atomically Neither — let the tracker be the authority and mirror it into the item

The release protocol

When the work on a locked item is done (passed Definition of Done), abandoned, or merged, release the lock:

1. Set Lock: — in the item's frontmatter.
2. Commit and push.
3. Best practice: include the release in the same PR that lands the work.
   This ensures the lock is released atomically with the work it covered.

Release-commit message template

When the release is its own commit:

chore(backlog): release lock on BL-###

Lock released: BL-### -> Lock: —

When the release rides along with the PR that closes the item, the PR’s merge commit captures the release naturally. Either path is fine; the rule is that the lock must end at — once the work is concluded.

Forgetting to release

A common failure: the work is done, the PR is merged, but the Lock: field still shows the holder’s timestamp. The lock will eventually expire on its own, but in the meantime no one knows whether the item is genuinely free.

Defenses:

Abandoning an item mid-flight

If you decide not to continue the work — change of priorities, blocker discovered, scope misjudged:

  1. If no work has been pushed: release the lock and revert the item’s Status back to ready (or blocked, with a Blocker: line).
  2. If partial work has been pushed: do the same. Add a Notes: line summarizing where the work got to and what’s left. The next contributor can pick up cleanly.
  3. Do not leave the lock in place. Holding a lock you do not intend to use blocks everyone else.

When NOT to lock

Not every action requires a lock. Locks are for exclusive work claims on an item. They are overhead, and overhead applied to wrong cases adds friction without benefit.

Do not lock for:

Do lock for:


Expired locks

When a lock’s timestamp is in the past, it is expired — the previous holder either finished without releasing, abandoned the work, or died mid-task.

Expired locks are free to acquire. But before acquiring, do one thing:

Investigate what partial work the previous holder may have left.

The previous holder may have:

How to investigate

If you find useful partial work, decide:

If you find nothing, just acquire cleanly.


Holder ID format

The holder field identifies who holds the lock. The format:

<agent-or-user>-<short-session-id>

Examples:

Rules

What if the same human has multiple agents acting on their behalf?

Give each agent its own holder ID. claude-session-a4f2 and claude-session-b193 are separate; both might be initiated by the same human, but each is a distinct lock-holder for protocol purposes. If they need to coordinate (e.g., the human is running two parallel agents on different items), the coordination happens above the lock layer.


TTL recommendations

The TTL is the duration of the lock — how long from acquire until the lock expires unless refreshed.

If unsure, use 2 hours. That’s the right default for ~80% of cases. The table below is for when you have a reason to deviate.

Holder type Recommended TTL Rationale
AI agent session (default) 2 hours Matches a typical session length. If the agent finishes faster, it releases. If it runs longer, it refreshes (which is one commit).
Human contributor — focused work 4 hours Comfortably covers a focused work block without forcing refreshes.
Human contributor — open-ended 8 hours (one workday) Covers a full day’s intermittent attention on the item.
Automation (CI, scripted backfill) 30 minutes Automation is fast and crashes loud; short TTL bounds blast radius.
Coordinated multi-day work (rare) Maximum 24 hours, refreshed daily The TTL ceiling for rare cases — NOT the default. Long work should be split into smaller items or refreshed daily; a TTL of 24h chosen because “the work might take that long” is the anti-pattern.

Picking a TTL

If unsure, default to 2 hours (see callout above). It is small enough that a forgotten lock is forgiven quickly; it is long enough to cover a non-trivial unit of work.

Refreshing

To refresh: commit a new Lock: value with a later timestamp. Same holder, later expiration. This signals “still working” to other contributors.

Refresh before the existing lock expires, not after. A lock that has already expired is — at that moment — anyone’s to acquire; refreshing it after expiration races against any contributor who may already be trying to take it.


Subagent delegation

A common pattern: an orchestrator agent picks up an item, holds the lock, and delegates specific subtasks to subagent sessions that do focused work. The subagent does not touch the lock; the orchestrator remains the lock-holder throughout.

The pattern

Orchestrator agent:
  1. Acquires lock on BL-###.
  2. Plans the work; identifies independent subtasks.
  3. Spawns subagent(s) — e.g., one for research, one for testing,
     one for documentation.
  4. Subagent does its task; returns results to orchestrator.
  5. Orchestrator integrates results; produces the change.
  6. Orchestrator releases lock when work is complete.

Why subagents don’t touch the lock

When this pattern is useful

When NOT to use subagents

Coordination

When the orchestrator spawns multiple subagents in parallel:


Audit trail

Every lock change is a git commit. This makes the lock history fully auditable.

Useful queries

Why audit trails matter


A worked example of lock contention

This example walks through a realistic scenario with two AI agent sessions and one human. All times in UTC.

Setup

Sequence

14:00. claude-session-a4f2 pulls the repo. Reads BL-0428. Sees Lock: —. Decides to take it.

14:00. claude-session-a4f2 commits:

chore(backlog): lock BL-0428 for claude-session-a4f2

Lock acquired: BL-0428 -> Lock: claude-session-a4f2@2026-04-18T16:00Z

(TTL = 2 hours; expiration is 16:00.) Pushes.

14:02. claude-session-b193 pulls. Reads BL-0428. Sees Lock: claude-session-a4f2@16:00Z. Timestamp is in the future and the holder is not them. Skips. Picks a different item.

14:30. alice-laptop pulls. Reads BL-0428. Same conclusion: locked. Skips.

15:50. claude-session-a4f2 is still working. The lock will expire at 16:00. They refresh:

chore(backlog): refresh lock on BL-0428

Lock refreshed: BL-0428 -> Lock: claude-session-a4f2@2026-04-18T18:00Z

Pushes.

16:30. alice-laptop pulls again. Reads BL-0428. Sees Lock: claude-session-a4f2@18:00Z. Still in the future. Still skips.

17:15. claude-session-a4f2 finishes the work. Opens a PR. The PR’s diff includes the lock release:

| Lock | — |

And the item’s Status flips to to-be-tested.

17:30. PR is reviewed, tests pass, merged. Lock is now — on master.

17:40. alice-laptop pulls. Sees the lock is released. Item is to-be-tested. Alice picks up the UI verification step. Locks the item to herself:

chore(backlog): lock BL-0428 for alice-laptop

Lock acquired: BL-0428 -> Lock: alice-laptop@2026-04-18T21:40Z

Performs UI verification. Sets Test: pass. Flips Status: done. Releases the lock. Moves the item to ARCHIVE.md.

What the audit trail shows

A git log -p -- backlog/.../BACKLOG.md filtered to BL-0428 lines, in order:

14:00  Lock: — -> claude-session-a4f2@16:00Z   (acquire)
15:50  ...@16:00Z -> ...@18:00Z                (refresh)
17:15  Lock: claude-session-a4f2@18:00Z -> —   (release; embedded in PR)
17:40  Lock: — -> alice-laptop@21:40Z          (acquire)
18:30  Lock: alice-laptop@21:40Z -> —          (release)

Every state change is recorded. Anyone reviewing the history can reconstruct what happened, when, and by whom.

What would have gone wrong without locks

At 14:30, alice-laptop would have read BL-0428 with no lock indication and may have started parallel work. By the time both pushed their work, conflicts would have appeared. The waste: one of them did the work for nothing, and the merge took longer than the change itself.


How locks connect to the rest of the methodology


Common mistakes around locks

Mistake Fix
Acquire commit not pushed; you start working with only a local lock. Push immediately after acquire. Local-only locks protect no one.
Pulled before acquire was skipped; you race another contributor. Always pull, then read, then decide. The pull is the cheapest race-mitigation.
Stole a live lock because “they’re probably done by now.” Never steal a live lock. Skip. If the lock genuinely seems abandoned, wait for expiration (or contact the holder out-of-band).
Lock left in place after work completed. Release as part of the PR that lands the work. A periodic sweep script catches forgotten releases.
Same lock ID used across two sessions. Use a unique session ID per session. Two of your sessions are not the same holder for protocol purposes.
TTL set to 24 hours by default. Use the recommended TTL for the holder type. Long defaults waste recovery time when the holder dies.
Lock refresh forgotten; lock expires mid-work; another contributor takes the item. Refresh proactively, before expiration. If you do lose the lock and another contributor takes the item, coordinate out-of-band; do not commit on top of theirs.
Acquire commit bundled with substantive code changes. Keep the lock commit small. One acquire = one tiny commit. Substantive code goes in subsequent commits.
Lock holder ID is “anon” or empty. Use an identifiable holder ID. Anonymous locks are unhelpful in incident triage.
Lock present on a done or rejected item. Bug. Release immediately. Check whether the work actually finished or was abandoned.

Variants and adaptations

The lock mechanism described here is one point in a small design space. A project may adapt for its own needs:

The file-based lock with TTL is the recommended default because it has the lowest moving parts and works equally well for humans and AI agents.


Authority

Locks bind work — no contributor may start in-progress work on an item without holding its lock. The lock outranks intent; “I was going to work this” is not the same as “I hold this lock.”

Binding is not the same as enforcing. The rule above is an obligation on contributors; whether the mechanism can stop a second contributor depends on which protocol is in use — see “What the lock does and does not guarantee”. Under the default protocol the obligation is real and the enforcement is not; that is a discipline, not a guarantee, and it should be described as one.

Locks do not bind discussion, research, or design. Anyone may read, discuss, or sketch ideas about an item without acquiring its lock. The lock applies to exclusive work claims that produce committed changes.

A user (with explicit authority over the project) may override a lock — for example, releasing a clearly-stuck lock that an agent forgot to release, or breaking a lock during an incident. The override should be recorded as a commit with a clear message; the original holder is informed if reachable.

AI agents do not override locks. They follow the protocol exactly. If they observe what appears to be a wrongly-held lock, they surface the observation; they do not take action on it.


Next: 06 — Working principles