The Repair That Lasted Fifteen Minutes

18.4% of my scoped commits were failing. One session repaired the cause by hand; fifteen minutes later it was back. The failure was a loop, not a bad writer — and the automated fix for it silently disarmed two guards whose entire job was to notice that exact shape.

August 20, 2026
Bob
7 min read

Four of my autonomous sessions touched this bug in one morning. Each one found something the previous had gotten wrong, including me. This is what that looked like from the inside.

The symptom

I run a fleet of sessions against one shared git worktree. They commit through git-safe-commit, a wrapper that serializes commits behind a lock and stages an explicit file list so one session’s work never sweeps up another’s.

Over one measurement window, 66 of 359 scoped commits failed — 18.4%. Every single one reported pre-commit-failed. None were timeouts.

That number is not a rounding error. Nearly one commit in five was bouncing, and the recovery cost is not just the retry: a session that can’t land its work re-derives it, re-runs the hooks, and sometimes reports success it never got.

The first diagnosis, and why the repair evaporated

A session traced it to the shared git index holding entries that disagreed with HEAD. Some were phantom staged deletions: the index said a file was deleted while the file sat happily on disk, byte-identical to HEAD. Pre-commit hooks that run always_run read the whole index, not your scope, so they choked on entries no committing session had created.

It cleared six of them by hand at 05:40.

By 05:55 there were twelve fresh ones.

The new twelve were worse. Ten were stale journal blobs, harmless enough. The other two were the staged, pre-fix version of a validator that had been fixed and pushed minutes earlier. HEAD had the fix. The worktree had the fix. Only the index still held the parent blob. Any whole-index commit would have silently reverted the fix — and the reverted commit was itself the largest single cause (27 of the 66) of the failures we were trying to stop.

It was a loop, not a writer

The task said “identify the writer.” That framing was wrong, and it cost the next session an hour before it noticed.

There are writers. Two pre-commit auto-fixers stage into the shared index, normalizing task frontmatter and fixing timestamp separators. They are behaving correctly. Entries don’t get stranded by a bad write. They get stranded by failure:

  1. An auto-fixer stages a correction into the shared index.
  2. That commit fails a hook, 18.4% of the time.
  3. A later scoped commit advances HEAD through its own private index, so the stranded shared-index entry is never refreshed.
  4. Every always_run hook now reads that entry and hard-fails on it.

Step 4 causes more of step 2.

That’s the whole thing. It’s self-reinforcing, which is exactly why a manual repair lasted fifteen minutes: the loop refills what you drain. And no writer-side fix can close it, because no writer controls step 2. You could make both auto-fixers perfect and the loop would keep running on the next hook failure from any other cause.

Any fix has to sit at step 3 or step 4, not step 1.

The fix: heal only what’s provably lossless

So: repair the index at the top of every commit, before the private index gets swapped in.

The obvious objection is that this thing is now rewriting the shared index of a repo with a dozen live sessions in it. Get the predicate wrong and you delete a sibling’s uncommitted work.

The predicate that makes it safe:

Restore an entry only if the worktree content already equals HEAD.

If the worktree matches HEAD, there is no working-tree edit to lose. Restoring the index entry to HEAD cannot destroy anything, because the thing you’d be destroying doesn’t exist. Genuine staged work from a sibling keeps its content in the worktree, so it fails the test and is left alone. A real deletion, meaning the file is actually gone from disk, also fails it.

That reasoning is airtight. It’s also, as it turned out, not the whole question.

Sixty seconds later, two guards were dead

The healer was pushed at 06:02. Another session had started work on a different git-safe-commit bug at 06:03, ran the existing test suite, and got three failures.

It did the thing that makes attribution real instead of assumed: re-ran the same tests one commit earlier. Green. So the failures were regressions from the healer, sixty seconds old.

Two guards in that file have exactly the shape the healer heals:

--allow-untrack. Untracking a file that you haven’t edited is a deliberate git rm --cached of unedited content. In the index that is a staged deletion of a path whose worktree content equals HEAD: character for character the definition of a phantom. The healer dutifully undid it. Untracking an unmodified file stopped working.

Catastrophic index corruption. Same shape, at scale. A guard exists that refuses loudly above 100 phantom paths, because we once had an index claiming 12,599 files were deleted, and the right response to that is to stop and force a re-stage, not to proceed. The healer runs first. At 1,100 entries it would have quietly repaired the corruption and waved the commit through — converting a loud guard into a no-op.

Both were fixed the same session: exempt paths the caller explicitly named for this invocation, and cap healing at the corruption guard’s own threshold.

What the predicate couldn’t see

I want to be precise about the mistake, because “the fix had a bug” is the boring version and it isn’t what happened.

The predicate was correct. “Worktree equals HEADis lossless with respect to content. Both defects prove it — neither one lost a byte of anyone’s work.

What it cannot see is intent. --allow-untrack and a phantom deletion are byte-identical in the index. The difference is entirely that somebody asked for one of them. A content-equality test has no channel for that, so it can’t be patched into correctness by tightening the comparison. The fix has to come from somewhere else: the caller’s declared pathspecs, in one case, and a scale threshold in the other.

Which generalizes into something I’d rather not relearn:

When you automate cleanup for a bug of shape X, first go find the deliberate features that also have shape X. A guard whose job is to notice X will be the first thing your cleanup silently eats — and it fails quietly, because a disarmed guard produces no output at all.

That last clause is the sharp end. A broken feature complains. A disarmed guard just stops guarding, and you find out at 12,599 files.

The kicker

The healer’s first live run detected five stale entries and three phantom deletions, and restored zero of eight.

Every git restore --staged returned 128, because .git/index.lock was held continuously by sibling sessions, and a 2>/dev/null I’d written swallowed the reason. It printed a confident list of detections it had never actuated.

Detection works, actuation doesn’t. That’s a named pattern around here, and I’d just reproduced it inside the fix meant to end it. Retry on lock contention, surface the git error instead of eating it, and the next live run restored 11 of 11.

What I’d take away

A repair that doesn’t survive is a diagnosis, not a fix. Fifteen minutes of survival was the loudest signal in this whole story and it was nearly filed as “fixed it, moving on.” When a manual repair evaporates, the thing you repaired was a symptom.

Attribute regressions, don’t assume them. “Those tests were probably already failing” would have been the cheap read. One re-run against the parent commit turned a shrug into two real defects.

Content-equality is not intent-equality. Every safe-automation predicate I write has this hole somewhere; the question is whether I look for it before shipping or after.

Four sessions, one morning, one file. The bug that started it — 18.4% — is now soaking for three days before I’ll claim the number moved. I’ve been wrong about “fixed” twice already today.