Skip to content

Latest commit

 

History

4 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

lanes

Stop two AI coding agents from editing the same files at the same time, and let the next one pick up where a dead session stopped. A lease you take before you work, a proof you produce before you close, an append only ledger that outlives the session that wrote it.

tests python dependencies license

For anyone running Claude Code, Codex, Cursor or a mix of them in more than one terminal against one repo. Pure Python standard library, zero dependencies, one directory per repo, no server and no daemon.

$ lanes claim auth-refactor --agent claude-1 --files 'src/auth/*.py' \
    --session sess-7 --summary 'token refresh keeps 401ing'
lane auth-refactor claimed by claude-1
  expires: 2026-08-22T09:04:08Z (in 4h)
  files:   src/auth/*.py
  about:   token refresh keeps 401ing

$ lanes claim api-docs --agent codex-2 --files 'src/auth/session.py'
lanes: files src/auth/session.py collide with lane auth-refactor held by claude-1 (pattern src/auth/*.py)
$ echo $?
2

Different lane name, same file. Refused before either agent wrote a byte, with an exit code the second agent can branch on.

Why this exists

Run two agents at once for a week and the same three things happen:

  1. Two agents open src/auth/session.py four minutes apart. The second one's write lands on top of the first one's. No conflict marker, no error, no way to notice until the tests you did not run would have caught it.
  2. You ask "what is running right now" and the only honest answer is to read three scrollbacks and guess.
  3. A session hits its context limit at the exact moment it finally reproduced the bug. The next session starts again from the issue title.

lanes is the smallest thing that fixes all three. A claim is refused if somebody holds the lane or the files, so 1 stops before the write. lanes list answers 2 in one line per lane. lanes handoff answers 3 from the ledger rather than from a chat log.

I did not design this in the abstract. lanes is the extraction of a coordination pattern that has been running in my own agent fleet, where the internal predecessor recorded 313 lanes over the last 30 days, 283 of them closed with proof, with 30 gate blocks recorded. Those numbers belong to that private system, not to this package, which is new.

Install

Zero dependencies, so every path is boring. Pick one:

# one off, nothing installed
uvx --from git+https://github.com/vojtechroyce/lanes lanes list

# on your PATH, isolated
pipx install git+https://github.com/vojtechroyce/lanes

# from a clone, to hack on it
git clone https://github.com/vojtechroyce/lanes && cd lanes
python3 -m venv .venv && .venv/bin/pip install -e .

Then check it, in a repo or anywhere else:

$ lanes list
no lanes yet

That is the whole setup. The store is created on the first claim, in the nearest .lanes directory upwards from the working directory, otherwise at the git root.

Or watch the whole tour below run for real before installing anything: make demo from a clone replays it in a temp directory (the script lives in examples/two-agents-one-file/).

Or one file, no install at all

Needs nothing but a Python 3.9+ interpreter on the far end, which makes it easy to drop onto a box or into a container:

git clone https://github.com/vojtechroyce/lanes && cd lanes
mkdir -p build && cp -R lanes build/lanes
printf 'import sys\nfrom lanes.cli import main\nsys.exit(main())\n' > build/__main__.py
python3 -m zipapp build -o lanes.pyz -p "/usr/bin/env python3" && chmod +x lanes.pyz
./lanes.pyz list

The generated __main__.py matters. python -m zipapp -m "lanes.cli:main" writes a launcher that throws the return value away, and every exit code collapses to 0.

Sixty second tour

Same store as the block at the top, a few seconds later. Agent two picks a lane whose files do not collide, and gets it:

$ lanes claim api-docs --agent codex-2 --files 'docs/*.md'
lane api-docs claimed by codex-2
  expires: 2026-08-22T09:04:08Z (in 4h)
  files:   docs/*.md

Going straight at the held lane is refused too, with the holder and the remaining time:

$ lanes claim auth-refactor --agent codex-2
lanes: lane auth-refactor is held by claude-1, expires in 3h 59m
$ echo $?
2

Findings go into the lane as they happen, not into a scrollback nobody will read:

$ lanes note auth-refactor 'refresh token TTL is shorter than the session TTL'
noted on auth-refactor
$ lanes note auth-refactor 'repro: sleep 3600 then hit /me, always 401'
noted on auth-refactor
$ lanes list
active    auth-refactor claude-1         expires in 3h 59m
active    api-docs      codex-2          expires in 3h 59m

The handoff

This is the reason the ledger exists. The session holding auth-refactor crashes. The next one runs one command and starts where the dead one stopped:

$ lanes handoff auth-refactor
HANDOFF auth-refactor
=====================
status:  active
agent:   claude-1
session: sess-7
about:   token refresh keeps 401ing
opened:  2026-08-22T05:04:08Z (0s ago)
expires: 2026-08-22T09:04:08Z (in 3h 59m)
files:   src/auth/*.py

trail (2 notes):
  [2026-08-22T05:04:08Z] claude-1: refresh token TTL is shorter than the session TTL
  [2026-08-22T05:04:08Z] claude-1: repro: sleep 3600 then hit /me, always 401

lanes handoff <lane> --json gives the same thing as a structured object, including the raw event list, which is what you paste into the next agent's context.

Closing needs proof

A lane does not close because an agent says it is done. It closes because something backs that claim:

$ lanes done auth-refactor
lanes: refusing to close lane auth-refactor without proof, pass --proof with what backs it
$ echo $?
1

$ lanes done auth-refactor --proof 'pytest -q tests/test_auth.py: 12 passed' \
    --next 'backport to the mobile client'
lane auth-refactor closed by claude-1 after 1m
  proof: pytest -q tests/test_auth.py: 12 passed
  next:  backport to the mobile client

The proof text is not validated, and it is not supposed to be. It is a forcing function: an agent that has to type the command it ran is an agent that has to run one.

Trust model

lanes coordinates agents that you own, running as you, on your machine. It is a cooperative coordination layer, not a security boundary, and that is a deliberate design decision rather than an unfinished one.

--agent is an assertion, not a credential. Nothing authenticates it, because in a store your own agents can write to directly there is nothing to authenticate it against. What the flag does is turn an assertion into a check:

$ lanes done auth-refactor --agent codex-2 --proof 'x'
lanes: lane auth-refactor is held by claude-1, not by codex-2
$ echo $?
2

$ lanes note auth-refactor --agent codex-2 'trying to write under a foreign name'
lanes: lane auth-refactor is held by claude-1, refusing to write a note under a foreign name
$ echo $?
2

Leave --agent out and you are saying "I am whoever holds this lane". done then closes a live lane held by somebody else, and note appends under the holder's name, because that is what you asked for. So make your wrappers always pass --agent. It costs one flag, and it converts a silent misattribution into an exit code you can branch on.

claim is the exception that carries the weight: it always requires --agent and always checks, because that is the gate that prevents the collision in the first place.

What the lease protects against is concurrency and accident. Two processes writing leases.json in the same millisecond. An agent that lost track of which lane it was on. A wrapper that fires done twice. It does not protect against an agent that wants the lane badly enough to lie about its name, and it should not pretend to. Authenticating the caller would be theatre: anything that can run lanes can also open .lanes/leases.json in an editor. If you need a real boundary, put it where boundaries work, in file permissions, in separate users, or in whatever process spawns the agents.

Exit codes

code meaning
0 done
1 error (no proof, unknown lane, bad arguments, inconsistent store)
2 refused, somebody else holds this lane or these files

Two is separate from one so a wrapper can tell "you cannot have this" from "you did it wrong":

if lanes claim build --agent "$AGENT" --files 'src/**'; then
  ./do-the-work.sh && lanes done build --agent "$AGENT" --proof "$(pytest -q | tail -1)"
else
  [ $? -eq 2 ] && echo "someone else owns it, picking another task"
fi

Commands

command what it does
lanes claim <name> --agent <id> [--files a,b] [--ttl 4h] [--session s] [--summary t] take a lane, fail closed
lanes done <name> --proof <text> [--agent id] [--next text] close it, proof required
lanes list [--active] [--json] who holds what, and for how long
lanes note <name> <text> [--agent id] append a finding to the lane
lanes handoff <name> [--json] full context for the next session
lanes gc drop expired leases
lanes doctor verify the store against its own ledger

Every command takes --json for machine callers and --dir to point at a specific store. Discovery order otherwise: $LANES_DIR, the nearest .lanes directory upwards from the working directory, the git root, then the working directory.

Re-claiming a lane you already hold renews it instead of failing, so a long running agent can extend its own lease without a special command.

Storage

.lanes/
  ledger.jsonl   append only history, one JSON object per line
  leases.json    current holders, a cache that is always rebuildable from the ledger
  lock           flock target, never unlinked

The ledger is never rewritten, only appended to, so history is auditable and git diff on it is always a pure addition. leases.json is derived state. lanes doctor replays the ledger and compares the result against the snapshot, so a hand edited or corrupted lease file is a detected inconsistency, not a silent wrong answer:

$ lanes doctor
store:  ~/dev/api/.lanes
5 events, 2 lanes, 1 active
verdict: consistent
$ echo $?
0

Doctor exits 1 on any inconsistency. Expired but not yet collected leases are a warning, not a failure, because that is the normal state between the expiry and the next gc.

Concurrency

Two agents really do write at the same moment, so mutations happen under fcntl.flock on .lanes/lock. The lock file is created once and never deleted: a holder that unlinked it on release would let a second process lock the orphaned inode while a third locked a freshly created file, putting two writers inside the critical section at once. flock also releases when a process dies, which a lock built out of O_EXCL does not.

The concurrency tests spawn eight real processes that synchronise on a shared start timestamp and hit one store simultaneously. Note that a small append to a JSONL file does not interleave on POSIX even without a lock, so a test that counts well formed lines proves nothing. What the lock actually protects is the read modify write over leases.json, which is what tests/test_concurrency.py attacks. That test was verified by removing the lock and confirming it goes red.

File overlap is deliberately paranoid

--files takes globs and the overlap check uses fnmatch in both directions, where a star crosses a slash. So src/*.py is treated as overlapping src/api/handlers.py. That is a false positive, and it is the intended direction of error: a fail closed gate should refuse too often rather than too rarely. Name your lanes' files narrowly if it gets in the way.

lanes and RoyceOS

RoyceOS is the operating model behind this: boot from truth, split roles, require evidence, hand off durably. It is prose and shell, model agnostic, and it describes how the work should be run.

lanes is two of those primitives made executable in one repo:

  • require evidence is lanes done --proof, which refuses to close a lane until something backs the claim.
  • hand off durably is lanes handoff, which rebuilds a dead session's context from an append only ledger instead of from a chat log.

Read RoyceOS for the model. Install lanes when you want the model to have teeth inside a particular repo.

What this is not

Not a scheduler, not a queue, not a git wrapper, not a daemon, not a server. It holds no opinion about what your agents do inside a lane. There is no --force flag and no steal command, but read that as a missing feature rather than a guarantee: see Trust model for what a lease does and does not stop.

Development

python3 -m pytest tests/ -q

No dependencies to install beyond pytest itself. CI runs the same command on Linux for Python 3.9 to 3.13, and on macOS for 3.11 to 3.13.

License

MIT, Vojtech Royce.

About

Claim and lease coordination for parallel AI coding agents. Take a lease before you touch files, close with proof, hand off durably. Python stdlib, zero dependencies.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages