slowbench
RetrievalInferenceEvalsAgentsFindingsSeriesBenchmarksArchive
Agents6 min1,180 words

Four traps when merging parallel agent worktrees into one trunk

Git worktree for parallel agents: sessions edit one repo in separate worktrees and a person merges by cherry-pick. The traps are the tests, the classifier, patch-ids and the leftovers.

Contents · 6 sections
  1. 1Trap 1: tests that migrate the shared database
  2. 2Trap 2: the worker cannot pull the trunk into itself
  3. 3Trap 3: resolving a conflict changes the patch-id
  4. 4Trap 4: worktrees outlive their branches
  5. 5What the four have in common
  6. 6Frequently Asked Questions

The bot's own repository is worked on by several agent sessions at once. Each gets a git worktree and a branch, writes code and tests there, and reports back. A person (me, in the role of the merging session) cherry-picks the commits onto the trunk branch, runs the gates, and deploys from a separate deploy worktree. On 2026-09-18 four workers landed in one afternoon; on 09-22, three more. The mechanics are ordinary git. The traps were not in git.

Trap 1: tests that migrate the shared database

Every worktree ran npm test against the same local database, and the test suite applies the schema on boot. One worker added two columns. Its tests passed. Then the trunk's tests, and another worktree's tests, each failed by exactly one: an assertion listing the columns of a table, which now had two it did not know about.

The failure looks like the other worktree's bug. It is not; it is the schema of a branch that has not merged yet, applied by a test run, to a database everyone shares. The tell is the content of the failure (column names that belong to a branch), and the rule is that one unexplained failure in a neighbour's suite is environmental until the column names say otherwise.

Since 2026-09-21 each worker gets its own copy database, autodev_<worker>, created and seeded from the trunk's config files when the worktree is made. That also removed a subtler version of the same trap that showed up the same day: a test that backfilled rows in one file deadlocked with another file's boot-time ALTER TABLE (Postgres 40P01) in 2 of 15 runs, because both were touching one database from parallel test processes. The fix there was to take the same advisory lock the schema boot takes.

Trap 2: the worker cannot pull the trunk into itself

The natural way to keep a long-lived worker current is for it to cherry-pick the trunk into its own worktree. The CLI's auto-mode classifier refused that twice, as an operation on a branch the session did not create. Creating a new worktree from the trunk's current head is allowed, and merging or resolving conflicts on the trunk itself is the merging session's job in any case.

So the direction is fixed: trunk pulls from workers, never the reverse. A worker that needs newer trunk code is closed and reopened from a fresh worktree with a new brief. In practice this is also the right size of unit; a worker that has drifted far enough from trunk to need a sync has usually been open too long.

Trap 3: resolving a conflict changes the patch-id

The trunk has a drift check. One of its items compares every worker branch to the trunk by patch-id and reports commits not yet applied. After a cherry-pick with a conflict, the resolved commit has a different patch-id from the original, so the check reports "1 unapplied" for a branch whose work is entirely on the trunk.

git range-diff <old>^..<old> <base>..<new>

range-diff shows the two commits side by side and marks which hunks differ. If the only differences are in the conflict region, the branch is merged and can go. On 2026-09-22 two of three workers went through exactly this: the worktree helper refused to delete their branches (git branch -d sees unmerged commits), range-diff showed only the conflict hunks, and they were deleted with --force.

The trap is that "unapplied 1" and "genuinely forgot a commit" look identical in the report. The range-diff is the two-minute check that tells them apart, and it belongs in the removal procedure rather than in someone's memory.

Trap 4: worktrees outlive their branches

On the morning of 2026-09-19 I counted the worktrees under .claude/worktrees/: nine, from 09-07 through 09-18, every one already fully on the trunk. Nobody had deleted them because deletion was a separate step from merging and nothing complained.

Nine stale worktrees cost disk (each has its own links and, since 09-21, its own copy database), confuse the drift check, and make "which one is live" a question. Three checks before deleting one:

git cherry <trunk> <branch>     no lines starting with "+"   (every commit is on trunk)
git status                      nothing uncommitted
untracked files                 only the node_modules symlink

And one exception: the deploy worktree, which is not a worker and is never removed.

The whole thing is now a helper with two verbs. add <name> creates the worktree and branch from the trunk's head, links node_modules, .env and five config files, and creates the copy database. rm <name> runs the three checks, closes the session tab whose working directory is that worktree, and deletes the worktree, the branch and the database. The helper exists because I once did the seven links by hand and missed one; the worker's first npm test died with "config/repos.json not found", which is a fine error message for a problem that should not have been possible.

What the four have in common

Each one is a place where two worktrees share something they should not: a database, a permission boundary, an identity for a commit, a directory. Git isolates the source tree well and isolates nothing else. Every other shared thing needs its own isolation, or its own check at the moment of merging.

The fifth shared thing, the one I have not written up here, is a symlink to an absolute path that pointed every worktree at the same file. That story belongs to a different post.

Frequently Asked Questions

Why cherry-pick instead of merging the worker branches?

Because the trunk's history should read as a sequence of reviewed changes, and because the merging session reads every commit as it picks it. A merge commit of a branch with twelve commits is one review; twelve picks are twelve. It is slower and it is the point.

Was there a fifth trap on 09-22?

Two candidates. A test in the bot's repository pinned line numbers in files of a service repository it inspects, and five assertions broke when that repository's main branch moved under it; the fix was to re-anchor, and the lesson is that a test which reads another repository is a test of that repository's history. And a permission hook that scans shell commands for a Slack delete API matched the API's name inside a documentation comment in a heredoc, and held a worker for three hours until a person looked; the hook now only matches call-shaped text inside heredocs.

How many workers run at once?

Three to six, depending on the day. The copy database per worker is what made six comfortable; four on one shared database were already producing the trap-1 failures.

Does the merging session run the workers' tests again?

Yes, on the trunk, after every pick, and then the full gate set once at the end: type check, lint, the test suite, a Slack payload check, a config-reference check and a smoke run of every script. A worker's green result is evidence about the worker's worktree. The trunk is a different tree.