The week I went from one developer to a swarm of parallel AI coding agents, my test suite started failing at random. Each agent works in its own git worktree — its own checkout of the repository — and each one runs the tests as it goes. Within a day, no test run was safe.
The failures looked unrelated. One run died with AdminShutdown — Postgres reporting that someone had shut the server down mid-test. The next couldn't find the database at all: could not translate host name "db_test". Another lost its connection halfway through wiping tables between tests. And a migration would hang forever, waiting on a lock held by a process from a worktree I had never heard of. Nothing in the test code had changed.
The root cause was one line: a hardcoded Docker Compose project name — the label Compose uses to decide which containers belong together. Because it was hardcoded, every worktree — every agent — was booting and sharing the same test database container. One agent's teardown was another agent's mid-test crash.
# the problem: every worktree, every agent, boots this exact stack
docker compose -p myapp-test up -d db_test
# ^^^^^^^^^^ same project name everywhere =
# one shared container for the whole machineThe fix is to derive the project name from the worktree instead:
# the fix: key the stack to the worktree
WORKTREE_ID=$(git rev-parse --show-toplevel | shasum | cut -c1-8)
docker compose -p "myapp-test-${WORKTREE_ID}" up -d db_test
# N worktrees -> N project names -> N isolated containersThe fix looks small. It wasn't. Getting to real isolation took four more failures, each one hiding behind the previous fix, and each one taught me something about what "isolated" actually means. This is the story, including the parts where I was wrong.
The false fix: isolating the database inside the container
The first instinct was to give each worktree its own database name inside the shared container. It's less disruptive, and it appears to work — until any worktree recreates the container. Databases don't protect you when the thing that dies is the process serving all of them. The container is the shared resource, so the container is what has to be per-worktree.
The real fix is to derive the compose project name from the worktree path. Every worktree gets its own container, its own lifecycle, its own failures. That was the theory. Then the assumptions started falling.
The port collision
I was sure the database published no host port, so containers couldn't collide. It published one — a port for web end-to-end tests that had been there so long nobody thought of it as a port anymore. Two worktrees fought over 0.0.0.0:15543 and the loser's stack failed to boot. The fix: a deterministic per-worktree base port, probed forward to the first free one. Any port you publish is a host-wide singleton. Treat every one of them that way.
The shared storage trap
Separate containers means separate everything, right? It doesn't. The containers were per-worktree, but they all pointed at the same host PGDATA bind mount. Two PostgreSQL instances writing one data directory corrupt it almost instantly. This was the killer.
The expensive part: it never says "corruption". It says unhealthy. It says WAL crash-recovery on boot. It says too many open files. I blamed ports and file descriptors for hours because every symptom pointed somewhere else.
Keying the data directory by worktree stopped the bleeding — provided your reaper rm -rfs the mounts itself, because compose down --volumes doesn't touch bind mounts. But the real fix was killing that bind mount entirely. A schema with tens of thousands of files, pushed through Docker Desktop's host-to-VM file-sharing layer (virtiofs), crashed the whole Docker daemon on a fresh migrate — "Docker Desktop is unable to start", with 19 GB of RAM free. A named volume lives inside the VM, skips the file-sharing layer completely, and is still per-worktree. The container was never the bottleneck. The host-to-VM bridge was.
The cold-start tax
Per-worktree containers migrate from scratch. Fine, I thought — that's what containers are for. Except a fresh migration replay resurfaced a bug that only fails on from-scratch replay, hiding for months behind the shared, already-migrated container. And every new worktree paid a 30-minute migrate before its first test. Isolation forced work I hadn't planned: a squashed schema baseline so a new stack boots light and fast. If fresh boot is slow, isolation is unusable, and people will quietly route around it back to sharing.
Out of files — for the whole machine
With boot time sorted, I figured what was left was a speed problem. It wasn't. The schema has roughly 3,000 partitions — tens of thousands of files per database — and a few isolated databases booting at once exhausted the system-wide file descriptor limit: ENFILE, "too many open files in system". Not the per-process ulimit -n everyone reaches for first. The host's file table is itself a shared resource, and N isolated stacks multiplied straight into it. The fix: the light boot, one database at a time, and a reaper that garbage-collects stale stacks by an owner-worktree label — and never touches a live one.
Isolation doesn't remove shared resources — it relocates them
That's the pattern across all four failures. Every shared singleton you split reveals the next shared resource underneath: the container gave way to the host port, the port to the data directory, the data directory to the file descriptor table, the file table to the migration chain — and after all that, a fixed Docker network name meant one worktree's DB hostname could resolve to a sibling's container. "Database does not exist", while it plainly existed. In someone else's stack.
You find these one incident at a time, unless you go looking. The audit that would have saved me days is boring: list every resource your test stack touches that lives on the host — ports, mounts, volumes, networks, project names, file handles — and ask of each one, "what happens when ten of these run at once?"
The fix that ends it is a test, not a patch
Every fix above is a change someone can undo without noticing. The thing that actually ended the incidents is a one-screen architecture test: run the resolver functions for two different worktree roots and assert that the project name, the ports, the network, and the data location all differ. It would have caught the worst of these bugs on the exact commit that shipped it.
Isolation is not a change you make once. It's a property you assert, on every commit, forever.
The multi-agent era is going to hand this class of bug to a lot of teams. Shared mutable test infrastructure was fine when one person ran the suite — it's a correctness problem when ten agents do. I still think the swarm is worth it. But check what your test harness quietly assumed back when it was just you.