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, so a file that was correct on Tuesday comes back changed on Thursday. Nothing is reviewable in bulk, because no two changes match in text even where the transformation was the same. And you have paid inference for nine thousand transformations that a syntax tree would do for free, in eleven seconds.
The opposite failure is just as common. A team writes a codemod, it handles eighty percent, and two thousand sites remain. Those sites become a spreadsheet that three engineers grind through for a quarter. Agents are good at that work. The team is doing it by hand, because it framed the codemod as the whole solution, not 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. That script 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 | — | |
Until someone with authority writes down the rounding rule, do not start the 890 boundary sites. Start them first and you get 890 decisions, each one plausible and none of them consistent with the rest. That inconsistency is worse than the float problem you started with, and much harder to detect.
The codemod's most important job is to refuse. It refuses anything it is not certain about, and it writes the refusal down.
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. Most changes in the diff are correct, a few are wrong, and nothing marks which is which. Nobody can review a diff of that shape at ten thousand files. A codemod that refuses produces two artifacts instead: one you can review in bulk, and one 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 when every change has the same form. Read twenty of shape A, confirm they match, and the argument covers all 6,800. An agent-produced diff destroys that uniformity, which is the biggest reason to keep this stage deterministic.
The unit of agent work is the smallest thing that can be correct, reviewed, and reverted on its own.
UNCLEAR with a question. Roughly one in twenty will, and those are the 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 in a diff, nothing separates a confident answer to an ambiguous question from a correct one. With it, you get a shortlist of sixty sites that need a human and eleven hundred that do not.
The fan-out 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. Human review throughput is the limit that binds first, so fourteen hundred commits on Monday buy nothing if the team can review sixty a day. Match the fleet to the reviewers: the queue drains on exactly the same date, with less rework.
In a migration, a perfect reference implementation of the correct behaviour already exists. Almost nobody uses it.
Unit tests check the cases somebody thought of. A differential test checks the cases that 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.
Suppose the new implementation agrees with the old one everywhere. Then the migration is cosmetic, or the replay misses the paths that matter. Instead, the valuable output is a list of two hundred transactions where the answers differ. Explain each difference ("old code lost a cent on this rounding, that was the bug") or investigate it.
Where no recorded inputs exist, generate them: property-based testing over the input domain, comparing old and new. Generated inputs are weaker than real traffic and much stronger than your existing unit tests, because both implementations can disagree.
Nobody can revert a ten thousand file change, so nobody should merge one, 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 test catches the paths your replay corpus missed, which are the paths nobody thought about.
Not when the new one works. Teams reach ninety-five percent, declare victory, and leave both representations in place forever. That end state is worse than either representation alone. Put the deletion on the calendar at the start, with a name against it.
The interesting number is not the time saved but 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. The sixty escalations got two full days, and that distribution is the entire point. The comparison here is not against doing the work by hand: it is against the all-agent version. That version would cost eight times the inference and produce a diff nobody can review in bulk. It would answer those sixty questions instead of asking them.
Reaching for agents on anything large is the same reflex as reaching for a gate when a sentence would do.
Write the census script and nothing else. One afternoon, no commitment. It answers the one question that shapes the whole project: what fraction of this migration has one correct answer? Everything on this page follows from that number, and teams that skip the census choose an architecture before they have the fact that decides it.