Marginalia, Side Effects & Loose Threads

Several coding agents, one git checkout: what actually broke

I often have four to six coding-agent sessions, sometimes more than a dozen, Claude Code and Codex side by side, working in the same repository at once, all in the same working copy. Worktrees would isolate them, but the long-running services serve code from the main checkout, so that’s where the work lands. This post covers what went wrong with git, measured, and the habits that fixed each problem.

1. Explicit paths protect your files, not your changes

Problem one was the obvious one. Session A runs git add on its files. Before it commits, session B runs a bare git commit, and B’s commit takes A’s staged work under B’s title. A commit meant to carry 9 files went out with 64.

The usual advice is to commit with explicit paths: git commit -m "..." -- a.py b.py. That limits which files go in. Then the subtler version hit.

A session committed with explicit paths, including a file another session was editing at the same moment. Its staged diff was 27 added lines. The commit contained 46. The extra 19 were a third session’s unfinished edit.

The reason is in git’s own documentation, if you look. git commit -- <paths> is git commit --only <paths>, and that commits the current working-tree contents of those paths, read when the commit runs. It doesn’t commit what you staged. Explicit paths limit which files, never whose changes inside a file.

Another session reproduced it independently in a throwaway repo. Stage your change, so the staged diff is non-empty and the unstaged diff is empty and a pre-commit comparison reports clean. Have a “peer” append to the file. Commit with the path. The commit holds both changes.

So a check before the commit is advisory at best. It looks at a state the commit doesn’t use, and it can’t see a write that hasn’t happened yet. On one evening this race fired four times between three sessions.

The detector is after the commit: git show HEAD -- <file>, every time. If you took someone’s lines, check that they’re complete (does the file parse and import?), leave the title alone rather than rewriting shared history, and tell the other session not to re-apply its change, or you’ll get a duplicate.

Check the subject line too, with git log -1. A diff can be right while the message is someone else’s, and a commit like that reads as a correct record forever.

2. Claim files, don’t announce them

What reduced collisions was claiming files before editing: Conclave keeps a shared registry of claims, and a guard makes another session’s Edit or Write on a claimed path fail. Broadcasting “I’m editing X” did almost nothing; the post on hooks has the numbers. Claim files, not topics. Two sessions briefed onto “different” tasks can still collide on the same file.

3. A tool timeout in the middle of a commit leaves debris

Claude Code tool calls have a hard time limit. If a git commit runs past it, the process is killed mid-way through writing the index. That leaves a next-index-<pid>.lock file of several MB, and sometimes a stale index.lock that blocks every commit in the repo.

The next-index files are invisible: git status never mentions them. Between mid-June and late July, 22 of them piled up, 96 MB in total, before anyone noticed.

The fix was boring. Don’t chain git commit && git push (or a status check, or another repo) in one tool call, because the combined command is what crosses the limit. Commit in one call, push in the next. I also turned off inline gc --auto and run maintenance on a nightly schedule instead, so a commit never stops to pack objects.

4. The empty lock that kept coming back

Then came a different shape: a zero-byte index.lock, no git process alive, blocking every commit. On one day it happened 11 times.

A millisecond process ledger found the chain:

  • A directory of regenerated vector-index files had been committed to git months earlier. A nightly job rewrites them, and they’d grown to 174 MB, so the checkout permanently carried 174 MB of modified tracked content.
  • That made a content-level git diff take about 5.0 seconds on an idle machine.
  • Claude Code runs a git diff after tool calls. In the ledger, every one of those diffs that left a lock lived between 5.00 and 5.12 seconds. The two that finished on their own took 4.77 and 4.78 seconds.
  • A git diff takes index.lock for roughly the last third of its run, even with --no-optional-locks.
  • A hard kill inside that window leaves the lock behind. Reproduced: killing the diff with taskkill /F at 4.8 seconds orphaned a lock 2 times out of 3. Killing it earlier, between 2.5 and 4.2 seconds, orphaned none in 9 tries. A polite timeout that lets git clean up orphaned none in 15.

The 5 seconds turned out to be a fixed timeout on those background git calls. On Windows a timed-out child process is terminated outright, so git never gets to remove its lock. I reproduced that half in a throwaway repo. I held the lock with a git commit waiting on a slow editor, then killed git the way the exec library does on timeout. 5 of 5 kills left the lock behind, under both Node and Bun. When git was left to exit by itself, it cleaned up every time. The fix: untracking that directory, plus 30 MB of rotated logs that had been committed by accident, cut the diff to 2.04 seconds and the modified tracked content from 296 MB to 91 MB. Four of four diffs at the cutoff then finished on their own. A check now warns when modified tracked content passes 150 MB.

The general lesson: anything your tools run in the background has a time budget you didn’t set. Large tracked files that change all the time make a slow git, and a slow git near someone else’s timeout leaves locks.

5. A tool call isn’t a terminal

Same family: a session started the service stack (scheduler, agents, watchdog) from a foreground tool call. At exactly 600 seconds the harness killed the process tree, watchdog included, and nothing restarts the watchdog. Everything stayed down for 1 hour 47 minutes. A dead scheduler and a quiet healthy one leave the same evidence: no output.

Long-running services are now started by a human from a real terminal. A round 600 in an uptime field means a tool timeout.

Habits worth copying

  • Commit with explicit paths, add and commit in one call, then run git show HEAD -- <file> and git log -1 straight away. Read both.
  • Commit and push in separate tool calls.
  • Only ever git pull --ff-only. Set pull.ff=only so a bare pull errors instead of quietly merging.
  • Never checkout, stash or reset --hard in a checkout that something live serves code from.
  • Keep regenerated artifacts out of git. Watch the size of modified tracked files, not just their count.
  • Never delete someone else’s lock on “waiting didn’t clear it”. A lock can be held by a live process that’s simply idle. Prove the creator is dead first.
  • Don’t start services from an agent’s tool call.

← All entries