CodeFleet · Open source

Building a file lock for parallel Claude Code agents

Several Claude Code agents on one repo at once, without them overwriting each other's work. Every file is leased and the lock is enforced at the write, so a collision is refused before it lands rather than reconciled afterwards.

Claude Agent SDKPythonFastAPISQLite

All of the code is on GitHub.

The problem: two agents, one file

You want multiple Claude Code agents working on one codebase at the same time. Then two of them reach for the same file: one is adding routes to api.py, and the other was told to write a middleware module and register it, and registering it means editing api.py. Both read it, both edit it against different versions, and one agent’s work disappears. Both sessions report success, and you find out from the diff.

The standard answer is a git worktree per agent and a merge at the end, which doesn’t remove the collision so much as move it to merge time. CodeFleet takes the other trade: one shared working tree, no branches, and the second write refused at the moment it’s attempted, with the losing task requeued to run again once the file is free. You lose parallelism on any file two agents both want, and in exchange you never reconcile two versions of one file.

Getting that refusal enforced rather than merely requested is where the work turned out to be. Below is the design, then the five things building it taught us, in the order we hit them.


Five tasks, three agents, one working tree. The red VETO is the moment two agents reached for the same file — the second one was refused, requeued, and finished on retry. That run cost $0.12 and took under a minute; --dry-run does the same graph with scripted sessions and no API key.

What we’re building

Declare the graph, run it in parallel, and refuse the write when two agents collide.

A task graph. A YAML file of tasks: a description, dependencies, and a file_scope — a guess at which files the task will touch.

A coordination server. It holds the graph, dispatches tasks to whichever agent is free, unblocks each task when its dependencies finish, and owns a table of per-file leases. One process, SQLite underneath, bound to loopback.

Runners, deliberately thin. A runner registers, heartbeats, asks for an assignment, runs exactly one Claude Code session, and reports what happened. It holds no queue and decides nothing about who does what — all of that is server-side, in one file you can read.

Then the lock, which is the reason any of this exists: before an agent writes to a file, something checks whether another agent is already holding it and refuses the write if so. Not a declared intention checked up front, but a check at the write.

The CLI, one coordination server, thin runners each wrapping an SDK session behind the hook, and one shared working tree underneath.

You can run the whole thing:

git clone https://github.com/ashnkumar/code-fleet && cd code-fleet
uv sync
export ANTHROPIC_API_KEY=sk-ant-...
uv run codefleet demo

That copies a small demo repo into a scratch workspace, runs three agents over a five-task graph, and finishes by running the demo repo’s own test suite. About $0.12, under a minute, and your own checkout is untouched.

The agents are launched from a Python process, NOT a shell

We use the Claude Agent SDK to launch Claude Code sessions from inside our own Python process: query() with our own model, our own working directory, our own tool list, and our own hooks wired into each session’s tool calls. The SDK ships the Claude Code CLI as a bundled binary and runs it as a subprocess underneath, but we never invoke it ourselves.

That’s what makes the lock possible. A shell script can read a session’s output and kill it afterward, whereas a host program can be asked, mid-turn, whether one specific tool call may proceed, and answer no.


Lesson 1: A declared file scope can’t be the lock

The first thing we tried was to make sure the collision never got scheduled at all. The graph asks the task author which files each task will touch, and the scheduler refuses to co-schedule two tasks whose declared scopes overlap. That’s free collision avoidance for the easy cases and it earns its place in the design.

It’s also a list written by somebody who hasn’t read the code yet, about work that hasn’t happened yet, to be carried out by an agent that will make its own decisions about how to do it. Here’s the demo graph’s T4:

examples/demo-tasks.yaml
  - id: T4
    title: "Add request logging middleware and enable it"
    description: |
      Wire request logging into the dispatcher, then implement it.

      Step one, in linkstash/api.py: add `from linkstash.middleware import
      log_requests` and decorate the `handle` dispatcher with `@log_requests`.
      Do this first, before writing the middleware itself, so the call site is
      settled before the implementation is.
      ...
    priority: 5
    file_scope: ["linkstash/middleware.py"]
    depends_on: []

T3 declares linkstash/api.py and T4 declares linkstash/middleware.py, which are disjoint, so the scheduler runs them together — correctly, by its own rules. T4’s first action is then to edit api.py, a file its declared scope doesn’t mention.

That ordering is staged. The instruction to touch api.py first is in the fixture because we put it there. An earlier version of the task described the same work without pinning the order and the collision happened in 3 of 6 recorded live runs; with the order written in, it happens in 4 of 5. In the wild you don’t get to schedule the race, and a demo that waits around for one isn’t a demo, so we rigged it in a YAML file anyone can read.

Even rigged, it isn’t deterministic, and the agent’s freedom to reorder its own work is the finding rather than a caveat about it. A declared scope can tell the scheduler what to try and it can never be the thing that enforces anything, which is why the check has to happen where the write happens.

The veto is how the scope gets fixed

Once there’s a check at the write, a denial becomes information the scheduler didn’t have, so when a task is vetoed the server writes the truth down:

src/codefleet/store.py
async def widen_file_scope(self, task_id: str, path: str) -> None:
    """Fold a vetoed path into the task's declared scope (SPEC 4.5 step 8).

    This is the loop-closer: on the next tick the scheduler will not co-schedule
    the retry with whoever holds that path, so the retry is not a coin flip.
    """
    ...

T4’s scope was wrong, the veto discovered the real one, and now api.py is part of it permanently. The retry isn’t a second roll of the same dice — it’s scheduled after whoever holds api.py.

That only works if the release and the reassignment happen in the right order, which is why the scheduler is one pure function that returns a list rather than a loop that does things:

src/codefleet/scheduler.py — from the module docstring
...
    1. MarkAgentOffline   — a runner stopped heartbeating
    2. RequeueTask / FailTask — the work that runner was holding
    3. FailTask           — pending tasks that have run out of attempts
    4. BlockDownstream    — dependents of anything permanently failed
    5. Assign             — new work, placed against the state left by 1-4
    6. EmitFleetIdle      — nothing is left to do
...

The order is part of the contract. Step 5 is only correct against the state steps 1–4 leave behind: a lease held by a runner that just went offline is released by the same transaction, so assignment doesn’t see that path as busy. Apply the list out of order, or apply half of it, and you hand two tasks the same file.


Lesson 2: Hardening the tool list silently turns the permission callback off

The check now has to intercept a tool call, and the SDK has an obvious place to put one. A can_use_tool callback is handed each tool call before it runs and answers yes or no, and the SDK describes it as “the SDK replacement for the interactive permission prompt” — which is exactly the situation, because an unattended runner has nobody to answer a prompt. We wired the lease check into it, and it worked as advertised.

Then we pinned down the tool surface. allowed_tools=["Read", "Write", "Edit", ...], 7 names, the obvious hardening move, and the callback stopped firing. Here’s the SDK’s own code explaining why:

.venv/lib/python3.13/site-packages/claude_agent_sdk/types.py
def _get_can_use_tool_shadowed_warning(
    permission_mode: PermissionMode | None,
    allowed_tools: list[str],
) -> str | None:
    """Return the shadowing warning message for these options, or None."""
    if permission_mode == "bypassPermissions":
        return (...)
    ...
    return (
        f"can_use_tool will not be invoked for: {', '.join(shadowed)}. "
        "An allowed_tools entry that allows a whole tool auto-approves it "
        "before the callback is consulted. To gate every tool call, use a "
        "PreToolUse hook; or narrow the entry so calls fall through to "
        "can_use_tool. Allow rules from settings files can also shadow the "
        "callback but are not visible here."
    )

A bare "Write" allows the whole tool, so it’s approved before the callback is consulted, whereas a scoped entry like Bash(ls *) isn’t and falls through. Those 7 bare names meant the callback would never have fired for a single one of them, and the hardening had turned off the control it was meant to reinforce.

The SDK does tell you this, and it tells you quietly. It’s a warnings.warn rather than an exception, and the docstring is explicit about how easy that is to miss:

.venv/lib/python3.13/site-packages/claude_agent_sdk/types.py
def _warn_if_can_use_tool_shadowed(options: "ClaudeAgentOptions") -> None:
    """Warn if can_use_tool is shadowed. Called once per query construction.

    Advisory only (no raise): shadowing can be intentional, e.g. a callback
    used solely for tools outside allowed_tools.

    Emission is unconditional, but stacklevel=2 puts the warning registry in the
    SDK entry point that calls this, not in user code -- so under Python's
    default filter a given message is shown once per *process*, not once per
    calling module. ...
    """

Once per process, on stderr, in a headless runner nobody is watching — and because our three runners are asyncio tasks in a single process, they produce one warning between them.

So we moved the veto to where the warning string says to put it. The rule that falls out is worth stating on its own: if something must be consulted on every tool call, it has to be a PreToolUse hook. A callback is what runs when nothing else resolved the call, and locking down your config is precisely how you stop calls arriving there.


Lesson 3: A broken veto hook allows the write

Registering the hook is the easy half. WRITE_TOOL_MATCHER is Write|Edit|MultiEdit|NotebookEdit, joined from a StrEnum in models.py so the gated set has exactly one definition:

src/codefleet/session.py
hooks={
    "PreToolUse": [
        HookMatcher(
            matcher=WRITE_TOOL_MATCHER,
            hooks=[
                make_pre_write_hook(
                    workdir=workdir, on_pre_write=on_pre_write, recorder=recorder
                )
            ],
        )
    ],
    "PostToolUse": [...],  # records what actually landed; not part of the veto
},

The matcher does the filtering rather than a match-all hook with a Python-side conditional: the CLI does exact set membership on the |-split list.

The half that cost us the most time is the return value. A PreToolUse hook returns a dict and the shape has to be exactly right:

src/codefleet/session.py
def allow_response() -> HookJSONOutput:
    """An empty object is how a PreToolUse hook says "no opinion, proceed"."""
    return {}


def deny_response(reason: str) -> HookJSONOutput:
    """The exact shape the CLI accepts as a veto.

    Matches the `PreToolUse` hook output documented for the SDK, and is exercised
    end to end by the live tier (`tests/live/test_demo_live.py`). Note there is no
    `continue_: false` here: this stops the tool call, not the session.
    """
    return {
        "hookSpecificOutput": {
            "hookEventName": "PreToolUse",
            "permissionDecision": "deny",
            "permissionDecisionReason": reason,
        }
    }

Allow is the empty dict — there’s no {"allow": True}. The deny, by contrast, has to be exact: the nesting, the hookEventName, and the two keys under it are all part of the contract, and a response that doesn’t carry a well-formed deny doesn’t deny. There’s no error and no exception on our side of the wire, because the SDK’s Python layer forwards whatever the callback returned to the CLI and the CLI is the thing that reads it.

So the failure mode of a hook you’re using to block something is allowing it. A misspelled key is a lock that reports success and enforces nothing, and every unit test written against your own helper function will pass, because the helper and the assertion share the same typo. We didn’t test the malformed cases one by one, so we won’t tell you that a misspelled key and a wrong event name fail identically inside the CLI. The part that matters sits upstream of that: a hook shaped like this has no self-check, so the only thing that catches a broken one is a run where the denial actually has to travel. That’s what the live test is for.

The second thing we learned about that shape is that permissionDecisionReason is read by the model rather than written to a log, so whatever goes there is what the agent sees, and it’s worth writing as an instruction rather than an error message:

src/codefleet/runner.py
_DENY_INSTRUCTION = (
    "{path} is held by {holder} for task {task}. Do not retry this file or edit "
    "around it. Stop now and report that you are blocked on {path}."
)

What’s actually enforced

A deny stops the tool call and it doesn’t stop the session. There’s no stop signal in that response, so the agent keeps running and is free to reach for a different file. It gets the same answer on anything else that’s held — but a lease is mutual exclusion, not authorization, so any uncontended path is still granted, and an agent that can’t edit api.py can often accomplish the same change from a file nobody holds.

What stops that is a line in the prompt:

src/codefleet/session.py — build_prompt
- A write may be denied because another agent currently holds that file. If that
  happens the file is not yours to change: do not retry it, do not achieve the
  same change by editing a different file, and do not ask again. Stop immediately
  and say which file blocked you.

The tests said “denied” and the runner reported “blocked”, and it took watching a real session to notice that the agent had decided to stop rather than being made to. The enforced part is narrow and it’s exactly this: this write, right now, doesn’t happen. Everything past that is cooperation, and the scheduling only rests on the enforced part.


Lesson 4: Every default assumes somebody is watching the terminal

An unattended runner has nobody to answer a permission prompt, so something has to make the prompting stop. The obvious lever is permission_mode="bypassPermissions", which is what we ran first and which does the job. The SDK docs are also unambiguous about it:

Use with extreme caution. Claude has full system access in this mode. Only use in controlled environments where you trust all possible operations.

“A controlled environment where you trust all possible operations” is a fair description of a sandbox you built yourself, and not a description of a tool that points N agents at someone else’s repository, which is the entire premise here. Under bypassPermissions, allowed_tools constrains nothing either: unlisted tools aren’t matched by any allow rule, so they fall through to the mode, which approves them. Explicit deny rules and hooks still get their say — the mode isn’t a bypass of everything, it’s a bypass of the permission step — but that’s the step doing the work when a .mcp.json in the target repo, a plugin, or a settings file drops a tool into the session.

Neither of the other two escapes from prompting was any better. acceptEdits auto-accepts file edits but leaves every other tool on the standard permission path, which prompts. auto stops the prompting by handing each tool call to “a model classifier”, and a lock whose boundary is a model’s opinion is the thing this project exists not to build.

What we ended up on is the mode the docs recommend for exactly this case:

src/codefleet/session.py
return ClaudeAgentOptions(
    model=settings.model,
    cwd=str(workdir),
    permission_mode="dontAsk",
    setting_sources=[],
    strict_mcp_config=True,
    tools=list(SESSION_TOOLS),
    allowed_tools=list(SESSION_TOOLS),
    ...
)

dontAsk turns every permission prompt into a denial, so nothing hangs because nothing prompts, which is the same benefit bypassPermissions bought. What changes is the default direction: instead of approving what it wasn’t told about, it refuses it.

Every other line in that call is the same kind of correction. tools= and allowed_tools= do different jobs — what the session has versus what dontAsk approves without asking — so naming the same list twice makes an unlisted tool both absent and unapproved. setting_sources=[] stops the session loading the host user’s ~/.claude settings, agents, skills and CLAUDE.md, which is otherwise what makes the same demo behave differently on every machine.

Then there’s strict_mcp_config=True, which was missing for a while and whose absence was a real hole. setting_sources gates settings files; MCP servers arrive on a separate path, and the SDK’s default there is permissive:

.venv/lib/python3.13/site-packages/claude_agent_sdk/types.py
strict_mcp_config: bool = False
"""When ``True``, only use MCP servers passed via :attr:`mcp_servers`,
ignoring all other MCP configurations the CLI would otherwise load (e.g.
project ``.mcp.json``, user/global settings, plugin-provided servers).
..."""

The working directory is someone else’s repository. If it contains a .mcp.json, and plenty of repos do, then the session loads MCP servers nobody chose, their tools aren’t in our list, and they don’t match Write|Edit|MultiEdit|NotebookEdit so the veto hook never sees them — and under bypassPermissions they would have been auto-approved. A write path with no lock behind it, arriving through the target repository.

Moving to dontAsk closes that on its own, but the flag stays so two independent mechanisms have to fail before an unenumerated tool executes. The regression test pins our flag and the SDK’s default together:

tests/unit/test_session.py
(workdir / ".mcp.json").write_text('{"mcpServers": {"fs": {"command": "writes-files"}}}\n')

options = build_options(workdir=workdir, settings=Settings(), hooks={}, stderr=None)

assert options.strict_mcp_config is True
assert ClaudeAgentOptions().strict_mcp_config is False
assert not options.mcp_servers

Asserting the default is deliberate, because the flag only means something while the default is permissive, and a default that quietly flipped would leave this test passing for the wrong reason.

None of these defaults are wrong. They’re tuned for the common case, which is a developer running an agent on their own machine, watching the terminal, able to answer a prompt and read a warning on stderr. Every one of them points the wrong way once nobody is watching and the working directory belongs to someone else, so when you lock down an agent’s environment, enumerate every channel that can put a tool into the session rather than just the one you were thinking about.


Lesson 5: There’s no shell on the session, and that costs more than it sounds

The last thing to settle was the tool list itself, and there the argument runs the other way: not what to add, but what had to be left out.

src/codefleet/session.py
SESSION_TOOLS: tuple[str, ...] = (
    "Read",
    "Write",
    "Edit",
    "MultiEdit",
    "NotebookEdit",
    "Glob",
    "Grep",
)

Bash is missing on purpose. A shell command is a write path no tool-name matcher can see — sed -i, a redirect, a formatter, a codegen script — and a file written through Bash takes no lease and lands in no ledger row. Since the whole system rests on the hook seeing every write, a tool the matcher can’t inspect is the one thing that can’t be on the list.

The obvious objection is that this is already solved, and we spent a while on it. Claude Code ships a sandboxed Bash — Seatbelt on macOS, bubblewrap on Linux — whose boundary the operating system enforces on the command and on every child process it spawns. It’s worth being exact about which boundary, because “sandbox” sounds like more than it is: by default writes are confined to the working directory and the session temp directory, reads reach the rest of the machine unless you deny them, and network egress is a separate policy with its own allowlist. If your problem is write blast radius, that’s the correct tool and it involves no shell parsing whatsoever.

It doesn’t solve this problem, because the boundary needed here isn’t this tree, it’s this file, right now, belongs to runner-2. A sandbox policy is fixed when the session is constructed and a lease isn’t, and while the SDK’s client can move the permission mode and the model mid-session it offers nothing that moves a filesystem rule. A sandboxed shell write would stay politely inside the tree, take no lease, and land in no ledger row: the same hole, in a smaller box.

The cost of leaving Bash out is real. An agent can’t run anything inside a task — not the tests it just wrote, not a linter, not git — so every task has to be expressible as edits, and that rules out a category of work.

Verification moves to afterward, which is the seam in this argument and the place to be exact. codefleet demo finishes by running the target repo’s own pytest suite over a tree three agents just wrote, and one of the five demo tasks exists specifically to have an agent author a new test file — so it executes agent-written Python on your machine. There’s no way around that: a suite that doesn’t run isn’t verification. The no-shell rule bounds what happens during a task, not what you choose to run after one, so point the fleet at a checkout you would be willing to git checkout -- ..


What this doesn’t solve


Try it

git clone https://github.com/ashnkumar/code-fleet && cd code-fleet
uv sync
export ANTHROPIC_API_KEY=sk-ant-...
uv run codefleet demo

If you don’t want to spend anything:

uv run codefleet demo --dry-run

Same server, same scheduler, same leases, same veto, with scripted sessions instead of real ones. Free, deterministic, and what CI runs. Point it at your own code with CODEFLEET_WORKDIR and write your graph in the shape of examples/demo-tasks.yaml.

Source: https://github.com/ashnkumar/code-fleet

All projects

Let's build your next agent.

Get in touch