Large mechanical migrations are the best thing agents do, and the way most teams attempt them throws that advantage away. Pointing an agent at ten thousand call sites is slow, expensive, non-deterministic and unreviewable, and it spends the judgment-capable tool on work a syntax tree handles perfectly. The design that works splits by determinism: a codemod takes the mechanical majority in one reviewable diff, and the agent gets only the residue that needed a decision.
"Fan an agent out over the whole codebase" sounds like the thing agents are for. It is the one design that gets you the worst properties of both tools.
Run an agent over ten thousand files and every single one becomes non-deterministic. Two runs produce different diffs. A file that was correct on Tuesday gets rewritten differently on Thursday. Nothing is reviewable in bulk, because no two changes are textually identical even where the transformation was identical. And you have paid a per-file inference cost for nine thousand transformations that a syntax tree would have done perfectly, for free, in eleven seconds.
The opposite failure is just as common. A team writes a codemod, it handles eighty percent, and the remaining two thousand sites become a spreadsheet that three engineers grind through for a quarter. That is the work agents are genuinely good at, and it is being done by hand because the codemod was framed as the whole solution rather than the first stage.
ALL AGENT 10,412 units · non-deterministic · unreviewable in bulk
pays inference for 9,100 transforms an AST does for free
ALL CODEMOD 9,100 done in one diff · 1,312 left in a spreadsheet
a quarter of three engineers grinding the tail by hand
SPLIT codemod: 9,100 in one reviewable diff, deterministic
agent: 1,312 units, one decision each, individually reviewed
the expensive tool only touches the expensive problem
Split the work by whether it has one correct answer, not by directory, team, or file count. A transformation with one correct output belongs in a codemod forever, however many of them there are. A transformation that requires choosing belongs to an agent, however few.
You do not have one migration. You have four, and two of them are not migrations at all.
Before any transform, write a script that classifies every call site into a shape. Not a sample: all of them. This costs an afternoon and it is the difference between a plan and a hope.
| Shape | Example | Sites | Class |
|---|---|---|---|
| A · arithmetic | total * qty, a + b on money fields |
6,800 | mechanical |
| B · display | f"${total:.2f}", division by 100 for output |
2,300 | mechanical |
| C · boundary rounding | invoice totals, carrier settlement, tax lines | 890 | residue |
| D · tolerance comparison | abs(a - b) < 0.01 |
422 | residue |
| mechanical 9,100 (87%) · residue 1,312 (13%) | 10,412 | — | |
The 890 boundary sites cannot start until someone with authority writes down the rounding rule. Attempting them first produces 890 individually plausible decisions that are mutually inconsistent, which is worse than the float problem you started with and much harder to detect.
The most important thing the codemod does is decline to touch anything it is not certain about, and write down what it declined.
def transform(node, ctx):
if not is_money_expr(node):
return None # not ours
if has_tolerance_compare(node) or crosses_boundary(node, ctx):
ctx.refuse(node, reason="shape-C" if ... else "shape-D")
return None # hand to the agent, do not guess
return to_cents(node) # one correct output
A codemod that tries hard produces the worst outcome available: a diff where most changes are provably correct and a few are silently wrong, mixed together, at a scale nobody can review. A codemod that refuses cleanly produces two artifacts, one of which you can review in bulk and one of which is a worklist.
{"path":"billing/invoice.py","line":211,"reason":"shape-C",
"expr":"round(subtotal * tax_rate, 2)","sha":"6c1a2e0"}
{"path":"billing/recon.py","line":88,"reason":"shape-D",
"expr":"abs(paid - owed) < 0.01","sha":"91be004"}
Nine thousand changes are reviewable if they are textually uniform: read twenty of shape A, confirm they are identical in form, and the argument covers all 6,800. That property is exactly what an agent-produced diff destroys, and it is the single biggest reason to keep this stage deterministic.
The unit of agent work is the smallest thing that can be independently correct, reviewed, and reverted.
UNCLEAR with a question. Roughly one in twenty will, and those are the genuinely interesting ones. An agent forced to always produce a change will produce a plausible wrong one.
Without it, every ambiguous site gets a confident answer, and confident answers to ambiguous questions are indistinguishable from correct ones in a diff. With it, you get a shortlist of sixty sites that need a human and eleven hundred that do not, which is the whole value proposition of the exercise.
This is the factory pointed at a finite backlog. The only new problem is that the backlog is finite, which makes idempotence cheap and restarts routine.
def work(item):
# idempotence key: path + the sha it was queued against
key = f"{item.path}@{item.sha}"
if done.has(key):
return SKIP # re-runs are free
if head_sha(item.path) != item.sha:
return REQUEUE # file moved under us
result = agent_unit(item)
if result.status == "UNCLEAR":
dlq.put(item, result.question) # human worklist
return
commit(result.diff, msg=f"cents: {item.path} ({result.branch})")
done.put(key)
Twenty workers, not two hundred. The limit that binds first is human review throughput: there is no value in generating fourteen hundred commits on Monday if the team can review sixty a day. Match the fleet to the reviewers and the queue drains at exactly the same date, with less rework.
A migration is the one situation in software where you have a perfect reference implementation of the correct behaviour, and almost nobody exploits it.
Unit tests check the cases somebody thought of. A differential test checks the cases that actually happen, which for a billing system means a year of real transactions. Replay them through both implementations and compare.
for txn in replay(last_365_days): # 4.1M recorded inputs
old = legacy.total(txn) # float dollars
new = cents.total(txn) / 100 # integer cents, for compare
if abs(old - new) > 0.005:
report(txn, old, new) # a real behaviour change
# Expected output is not "zero differences". It is a small set of
# differences you can explain one by one, because the point of the
# migration was that the old behaviour was wrong.
If the new implementation agrees with the old one everywhere, either the migration is cosmetic or the replay is not exercising the paths that matter. The valuable output is a list of two hundred transactions where the answers differ, each of which you either explain ("old code lost a cent on this rounding, that was the bug") or investigate.
Where no recorded inputs exist, generate them: property-based testing over the input domain, comparing old and new. Weaker than real traffic and vastly stronger than the unit tests you already have, because both implementations are available to disagree with each other.
A ten thousand file change cannot be reverted usefully, which means it cannot be merged safely, however green it is.
total_cents field written in parallel with total, both maintained. Nothing reads the new one yet.
Running both representations in parallel feels wasteful and is the cheapest insurance available. It gives you a production differential test: every write compares the two answers, and a mismatch alerts. That catches the paths your replay corpus missed, which are precisely the paths nobody thought about.
Not when the new one works. Teams routinely reach ninety-five percent, declare victory, and leave both representations in place forever, which is strictly worse than either one alone. Put the deletion on the calendar at the start, with a name against it.
The interesting number is not the time saved. It is where the human attention went.
| Stage | Sites | Machine | Human |
|---|---|---|---|
| Census | 10,412 | 1 script, minutes | half a day reading the output |
| Policy decision | 890 | none | one meeting, one written rule |
| Codemod | 9,100 | seconds per run | 2 days to write, 1 day to review by shape |
| Agent residue | 1,252 | 1,252 units | ~5 days of individual review |
| Escalated UNCLEAR | 60 | none | 2 days, and worth every hour |
| Total | 10,412 | — | ~11 working days |
Two thirds of the human time went to the thirteen percent that needed judgment, and the sixty escalations got two full days. That distribution is the entire point. The comparison is not against doing it by hand, it is against the all-agent version, which would have cost eight times the inference to produce a diff nobody could review in bulk and would have answered those sixty questions confidently rather than asking them.
The reflex to reach for agents on anything large is the mirror of the reflex to reach for a gate when a sentence would do.
Write the census script and nothing else. One afternoon, no commitment, and it answers the only question that determines the shape of the entire project: what fraction of this has one correct answer? Everything on this page follows from that number, and teams that skip it are choosing an architecture before they have the fact that decides it.