Claude Code and coding agents

Why my Claude Code review loop didn't converge

In short

Fix only the findings that break something written down (the spec, CLAUDE.md, a check, what the merge request says it does), and leave the rest. Before that rule, my loop fixed nearly every finding that held (47 of 50). Each fix was new code for the next fresh reviewer, and both larger tasks ran all five rounds. After the change, 25 of 28 loops ended before the cap.

I run Claude Code unattended: it takes a task and turns it into a merge request (MR), which I review before it’s merged. I call the setup the factory (loosely, since it ends in my review on purpose), and how it runs is a post of its own. Before my review, each change goes through a review loop. A fresh Claude Code session reviews the change, and the session that wrote the code fixes what the review found. The next fresh session reviews only that fix commit, and so on for at most five rounds.

On November 4, 2026, the first two larger changes through this loop both ran all five rounds. Their last rounds found more than their first. One task went from 4 findings in round 1 to 8 in each of rounds 4 and 5, the other from 3 to 5 and 7. The code is in a private repo for now, so the prompts below are verbatim excerpts. MR numbers like !74 are GitLab notation.

Every finding that held got fixed

A round is one review job and one fix job. The review job runs Claude Code’s built-in /code-review (back then at its medium effort level). It runs in a new session that has no GitLab or GitHub token and may not edit files. It labels each finding code, test, docs or wording (labels from my prompt, not from the skill). The fix job resumes the session that implemented the change. It sorts every finding into one of four buckets, fixes what it sorted fix and pushes one commit. The loop ends when a review comes back clean, when a fix round has nothing to fix, or when a fix round fixed only wording.

The first version of the fix prompt (November 3) defined the buckets like this:

- `fix`: it holds; fix it, with a test where the finding is about behaviour;
- `reject`: it doesn't hold; give the evidence in `evidence` (a test, a command and its output, the line that shows
  it), never an opinion;
- `leave`: a suggestion outside this MR's spec; a follow-up, named in `evidence`;
- `ask`: only Christian can decide it (a decision outside the spec); say what he must decide in `evidence` and in
  `questions`.

So any finding that held was a fix, and in these two tasks the fix jobs found that 49 of 50 held:

Task (MR) Findings per round Sorted fix
A resumed job’s own cost, with migration 0003 (!74) 4, 5, 5, 8, 8 28 of 30
Plan resumes its session after a reply (!75) 3, 6, 6, 5, 7 19 of 20

That makes 47 of the 50 sorted findings fixed, 2 left and 1 rejected. The fifth review of !75 found 7 more, and those were never sorted. Its first fix job had run before any review, so its five fix jobs were already used up. (The reviewer isn’t always right, though. The one task before these two, a small CLI change, ended after two rounds. That was mainly because its fix job rejected 5 of the 8 findings in round 1, with evidence.)

Each fix was new code for the next reviewer

The clearest case is migration 0003 in !74, a one-off SQL migration of the factory’s job table. It recomputes each job’s own cost from the running total of its session. Its step 3b checks the stored session IDs before step 4 casts them to UUIDs. From round 2 on, every round found something in step 3b.

Round Finding (shortened) What the fix did
2 3b checks that the source session is there, not that it’s a valid UUID, so step 4’s cast fails with an error that names no job added a regex for the UUID’s form
3 the regex takes only the hyphenated form; Postgres’s ::uuid and pydantic also take hyphenless and braced forms widened it to every form Postgres casts
4 it now takes forms Postgres casts but pydantic rejects narrowed it to the forms both read
5 its comment says no row passes that the Python side can’t read, but the Python side also rejects valid UUIDs that break its rules for new, resumed and forked sessions made 3b repeat those rules

The fix job reported reproducing each of the four on Postgres 16 before it changed anything, so they all held. But each fix was also code that no reviewer had seen yet, since round N+1 reviewed exactly the commit that round N pushed. And rounds 3 and 4 argued about forms of a UUID that never occur in that table. Every session ID in it is a hyphenated UUID, made by uuid4() in Python or reported by Claude Code.

A second chain ran through the Python code that records a finished job. From round 2 on, the main finding there in each round was about the previous round’s fix. The one in round 5 was about a bare assert, which python -O strips. Over all of !74, 23 of the 26 findings in rounds 2 to 5 point at lines the previous fix had changed (give or take three lines). The other 3 point at other lines in files that fix touched.

Fresh reviewers also keep finding things in code that earlier rounds had already reviewed. A review of all of !74 at its last commit raised three more points on paths the factory had never taken, none of them among the 30 from the rounds. (That review is recorded in the spec; its transcript is gone.) With diff-only review, this matters only while fix rounds keep pushing commits. A round that pushes nothing leaves the next reviewer nothing to look at, and the loop ends.

Diff-only re-review and a round cap weren’t enough

Anthropic’s Claude Code team describes the same problem in How the Claude Code team does code review (October 15, 2026): “One CLI PR went through more than fifty rounds of review and fixes overnight before its author stopped it.” When they ran git blame on what the reviewer kept flagging, “about 59% of it was code that had been there since the reviewer’s first pass.” They made four changes:

  1. A round cap of three, after which the reviewer asks the author to reconsider the approach.
  2. Re-reviewing only the diff after the first few passes.
  3. Flagging the class of a bug, so the author fixes every instance.
  4. No repeats of findings already posted.

My loop had three of these from the start. From round 2 on, a review sees only the last fix commit. Every finding that an earlier round rejected or left goes to the next reviewer under “Not findings”, so it isn’t raised again. And five rounds are a hard cap, enforced in code.

So why did both loops still run to the cap? At Anthropic, the reviewer kept flagging old code, which a diff-only review leaves out. My reviewers kept flagging the newest code, and a diff-only review looks at nothing else. Nothing told the fix job which of those findings were worth a fix, so it fixed nearly all of them.

Of their four changes, the one I didn’t have is the third, flagging the class of a bug. Rounds 3 to 5 of the UUID chain were one class of bug (which session values both the SQL check and the Python side accept). A reviewer that named the class in round 3 might have saved two rounds, but I haven’t tried that.

The rule: fix only what breaks something written down

On November 4 I changed the fix prompt (commit a5a17d3, merged as !76). Now the fix job reproduces every finding first and then sorts it:

Reproduce every finding first (a test, a command and its output, the line that shows it), then sort it, each one of:

- `fix`: it holds, and it breaks something written down: the task's spec or its decisions, a requirement, decision
  or acceptance item of the repo's specs, the repo's CLAUDE.md, a check, what this MR's own change says it does (a
  test that fails on the branch, a case the code gets wrong on a path the spec names), or a text that disagrees with
  the code or the record (a spec line, a comment, a docstring). Give the reproduction and what you changed in
  `evidence`, and fix it, with a test where the finding is about behaviour. A finding of the kind `wording` is `fix`
  only where the text says something false or contradicts the spec, not where it could read better;
- `reject`: it doesn't hold; give the evidence in `evidence` (a test, a command and its output, the line that shows
  it), never an opinion;
- `leave`: it holds, but it breaks nothing written down: an edge case on a path no requirement names, a refactor, a
  cleanup, a text that could read better, a test that could be stronger, or a suggestion outside this MR's spec; a
  follow-up, named in `evidence`. A `leave` alone doesn't keep the review loop going;
- `ask`: only Christian can decide it (a decision outside the spec); say what he must decide in `evidence` and in
  `questions`.

I left the stop rule as it was; the new prompt changes how many findings reach fix. A left finding still goes to the next reviewer under “Not findings” (an excerpt from the template of the review prompt):

## Not findings

- The decisions in the task's spec, below.
{%- for f in jobs if f.template == 'fix' and f.state == 'finished' %}
{%- for d in f.result.dispositions if d.disposition in ['reject', 'leave'] %}
- `{{ d.file }}`: {{ d.finding }} ({{ 'rejected in an earlier round, with this evidence: ' ~ d.evidence if d.disposition == 'reject' else 'left as a follow-up in an earlier round' }})
{%- endfor %}
{%- endfor %}

“Written down” is a test the fix job can apply without relying on taste. Take the UUID chain. The spec that covers migration 0003 says: “Data the rule doesn’t cover stops it with an error that names the job”. The finding in round 2 breaks that line (a malformed ID alone in its task wasn’t refused at all), so under the rule it stays a fix. The finding in round 3 is where I’d draw the line. Refusing a hyphenless UUID (a form the factory never writes) breaks nothing I would call written down. I’d expect a leave there, and the chain to stop.

The rule wasn’t new to me. The review loops I run by hand in gamble (the data pipeline behind Shibui Finance) have had it since October 2026. There it is a “Leave” bucket for style and cleanup that “doesn’t keep the loop going”. In one of those loops, rounds 2 to 5 found only docs, a test name and a missing case (October 26). On October 28 I added a second rule: “stop the review after the first wording round”. The first version of the fix prompt lost that distinction, since there fix meant “it holds”.

Did the loops end after the rule?

Most did: after the rule, 3 of 28 loops ran to the cap, against 2 of 3 before. On November 6 the review got deeper, and from then on round 1 ran /code-review at high instead of medium. So I split the tasks after the rule by the level of the review each fix round sorted.

  Before, medium (Nov 3 to 4) After, medium (Nov 4 to 6) After, high (Nov 6 to 10)
Tasks with a review loop 3 10 18
Findings the fix rounds sorted 60 32 161
sorted fix 50 (83%) 3 (9%) 66 (41%)
sorted leave 3 16 83
sorted reject 7 10 11
sorted ask 0 3 1
Loops that ran to the cap of 5 2 0 3

At the same level, the share sorted fix fell from 83% to 9%. How much of that is the rule, and how much the size of the tasks? The tasks at medium after the rule were all smaller (under 450 added lines; !74 and !75 added 863 and 801). At high the share rose to 41% again, on bigger tasks (the Linear integration, a Kubernetes backend for the factory’s workers). The loop still runs long on the larger tasks. Of the 9 tasks after the rule with 600 or more added lines, 3 ran to the cap (those are all three capped loops). The other 6 ended after 1, 1, 2, 3, 3 and 4 rounds.

After the rule, 14 of the 28 loops needed one round, 6 needed two, 3 needed three, 2 needed four and 3 ran to the cap. The most common end was a fix round with nothing to fix (15 loops), then a clean review (7) and a wording-only round (3). At the median, a review round took 2.0 minutes and cost $0.79; a fix round took 6.3 minutes and cost $2.00. (The cost is each job’s own: Claude Code’s total_cost_usd, at API prices, minus what its session had cost before.)

The comparison has its limits: different tasks, 23 of the 28 on the factory’s own repo, one developer (me), one week, Claude Code 2.1.316 and Opus 5.5 reviewing and fixing throughout.

What still doesn’t work

Three loops still ran to the cap, and by then they were about text. In their last two rounds, 18 of the 20 findings were docs or wording. The wording-only stop counts only findings that the reviewer labels wording. A spec sentence that was true but could be said more exactly came back labelled docs. Under the rule, a docs finding that holds is fix (“a text that disagrees with the code or the record”), so those rounds never counted as wording-only. The worst case was a change that added a token check to the factory’s API. Its last two rounds were about how one spec decision words the parsing of the token header.

In the same task, every test still passed with a plain strip() in place of strip(_OWS) in the token check. (_OWS is spaces and tabs only, as RFC 9110 has it. With strip(), a header with a vertical tab at its edge gets through instead of a 401.) None of the five review rounds found that, and the next task added the missing test. Why did two rounds argue about how the spec words this parsing, while none of the five noticed that the tests also pass with the wrong one?

More limits:

  • The fix job judges its own work. It resumes the session that wrote the code, so a leave is the author’s call. A reject needs evidence, a leave only a named follow-up. I went through the 42 code findings left after the rule. Of those, 19 are duplication or structure and 5 are efficiency. The other 18 are behaviour on paths no requirement names (state that grows without bound, a race made smaller but not closed, a Kubernetes Job whose pod still lists its container as running). Some of those 18 may be real bugs, and the rule moves them from the next round to my review of the MR. Would I spot the real ones there?
  • The fix prompt has no exception for security. By the letter of the rule, a hole on a path no spec names is a leave and shows up only in the MR note. So far, reviews have found one hole: a job could forge a task’s status (found at high in an interactive session, not in this loop). That hole broke what its change said it did, so under the rule it would have been a fix.
  • The rule depends on what is written down. My repos have specs with numbered requirements and decisions, and every task starts with a spec I approve. In a repo with only a thin README, I’d expect “breaks nothing written down” to cover nearly every finding, so a real bug on an unwritten path would get left. (I haven’t run the loop on such a repo.)

What I changed on November 11

The loop got three more rules (!106):

  1. A fifth kind of finding, precision, for a text that is true but could be more exact.
  2. A round whose fixed findings are all wording or precision ends after its fix.
  3. Every review round names mutations of the changed code (a change the tests wouldn’t catch) and runs a few of them, to find tests that pass on wrong code. For that, the review job may now edit through Bash: it applies each mutation and undoes it.

No task has run on these rules yet. The first thing I’ll look at is the next loop that reaches a prose-only round. Does it end there, and does its mutation step find a gap like the strip() one?

Questions

How many rounds should an AI code review loop run?

Fewer than I expected, once the fix round is strict about what it fixes. Of my 28 loops after that change, 14 needed one round and 23 had ended by round 3. Anthropic’s Claude Code team caps its loop at three rounds, after which the reviewer asks the author to reconsider the approach. Mine is five, enforced in code. At the cap the factory asks me about the rest, since after five rounds I’d rather look at the change myself.

Do left findings come back in later rounds?

Rarely. Every later reviewer gets them under “Not findings” and moves a finding that matches one of them into a separate suppressed list, which the fix job never sees. In the 30 later rounds after the rule, 14 findings were suppressed this way, counting matches with the spec’s own decisions. The left findings also stay in the note on the merge request, listed per round.

Why not let the reviewer decide what's worth fixing?

Anthropic’s reviewer does part of that: it drops findings that are only about style or cleanup and tags the rest Important, Nit or Pre-existing. My reviewer is the stock /code-review, and I don’t change its recipe, so the decision sits with the fix job. The fix job has to reproduce each finding anyway, and it has the spec in its session. A separate agent as judge would be another option; I haven’t tried that.

What if my repo has no spec to point the fix round at?

Then the task itself has to carry the decisions. In my setup every task starts with a spec I approve, and the fix prompt lists that spec first. Without one, the repo’s CLAUDE.md, its tests and checks, and the merge request’s own description still count. If a finding shows that the code doesn’t do what the description says, it gets fixed. An edge case none of them names gets left, and if it matters, it belongs in a spec.