Capstone 09 — Code Migration Agent (Repo-Level Language / Runtime Upgrade)
Amazon's MigrationBench (Java 8 to 17) and Google's App Engine Py2-to-Py3 migrator set the 2026 bar. Moderne's OpenRewrite does deterministic AST rewrites at scale, and Grit targets the same problem with a codemod-style DSL. Production combines both: a deterministic substrate for the safe rewrites, an agent layer for the ambiguous cases, a sandbox for per-branch builds, and a test harness that goes green before the PR opens. The capstone is to migrate 50 real repos and publish a pass rate with a failure taxonomy.
Learning Objectives
- Split a repo migration into a deterministic recipe pass (OpenRewrite or libcst) and an agent loop that handles only the failures the recipes leave behind.
- Enforce the per-repo limits of 30 minutes, $8, and 20 agent turns, and gate every branch on green tests and a coverage drop of no more than 2%.
- Classify every failed repo into a failure taxonomy class (such as dep_upgrade_required or budget_exhausted) with an exemplar diff attached.
- Measure pass rate, coverage preservation, and cost per repo across a 50-repo run, and compare the result against a deterministic-only baseline.
Problem
A code migration agent takes a repo on an old language or runtime version (Java 8, Python 2) and produces a branch that builds and passes its tests on the new one. It is one of the cleanest production uses of 2026 coding agents. The ground truth is unambiguous: either the test suite passes after the migration or it doesn't. The payoff is real, since a Java 8 fleet migration is a headcount-scale project. And the benchmarks are public, including the MigrationBench 50-repo subset.
Moderne's OpenRewrite covers the deterministic side. The agent layer picks up what OpenRewrite recipes cannot: ambiguous rewrites, build-system drift, long-tail syntax, and transitive dependency breakage.
You will build an agent that takes a Java 8 repo (or a Python 2 repo) and produces a green-CI migrated branch. You will measure pass rate, test-coverage preservation, and cost per repo, and you will build a failure taxonomy. Then you run the same repos through a deterministic-only baseline. The gap between the two is where the agent's value actually lives.
Concept
The pipeline has two layers. The deterministic substrate is a recipe engine (OpenRewrite for Java, libcst for Python) that applies declarative AST rewrites. It handles the bulk of the mechanical work safely: imports, method signatures, null-safety edits, try-with-resources, deprecated API replacements. It is fast, and its diffs are auditable. The agent layer (OpenAI Agents SDK or LangGraph over Claude Opus 4.7 and GPT-5.4-Codex) takes the cases recipes cannot: build-file upgrades (Maven, Gradle, pyproject), transitive dependency conflicts, test flakes, and custom annotations.
Each repo gets its own Daytona sandbox with the target runtime preinstalled. The agent loops: run the build, classify the failures, apply a fix, rerun. Three hard limits bound every repo: 30 minutes, $8, and 20 agent turns. If all tests pass and coverage has not dropped by more than 2%, the branch opens a PR. Otherwise the repo is filed under a failure class with evidence attached.
The failure taxonomy is the deliverable. Across 50 repos, what broke? Transitive deps, custom annotations, build tool versions, or test flakes unrelated to the migration? Each class gets a count and an exemplar diff, so future recipe authors know which three to target first.
Architecture
target repo
|
v
OpenRewrite / libcst deterministic recipes
(safe, fast, auditable, ~70-80% of fixes)
|
v
Daytona sandbox per branch
|
v
agent loop (Claude Opus 4.7 / GPT-5.4-Codex):
- run build -> capture failures
- classify failures (build, test, lint)
- apply fix (patch or retry recipe)
- rerun
- budget: 30 min, $8, 20 turns
|
v
test + coverage delta gate
|
v (passed)
open PR
|
v (failed)
file under failure class + attach repro
Stack
- Deterministic substrate: OpenRewrite (Java) or libcst (Python)
- Agent: OpenAI Agents SDK or LangGraph over Claude Opus 4.7 + GPT-5.4-Codex
- Sandbox: Daytona devcontainers per branch, pre-installed target runtime (Java 17 / Python 3.12)
- Build systems: Maven, Gradle, uv (Python)
- Benchmarks: Amazon MigrationBench 50-repo subset (Java 8 to 17), Google App Engine Py2-to-Py3 repos
- Test harness: parallel runner, coverage via Jacoco (Java) or coverage.py (Python)
- Observability: Langfuse + trace bundle per repo with every diff chunk
- Dashboard: failure-taxonomy dashboard with per-class counts and exemplar diffs
Build It
-
Recipe pass. Run the OpenRewrite (Java) or libcst (Python) recipes first. They catch the 70-80% of migrations that are mechanical. Commit the result as a "recipe" commit.
-
Build trial. Start a Daytona sandbox, install the target runtime, and run the build. If it is green, skip ahead to tests. If it is red, hand off to the agent.
-
Agent loop. Build a LangGraph agent with five tools:
run_build,read_file,edit_file,run_test,git_diff. The agent classifies the failure (dep, syntax, test, build-tool), applies a targeted fix, and reruns. -
Budget caps. Enforce 30 minutes wall-clock, $8, and 20 agent turns per repo. Any breach halts the run and files the repo under "budget_exhausted" along with the current diff.
-
Test + coverage gate. Once the build is green, run the test suite and compare coverage to the base repo. If coverage dropped by more than 2%, file the repo under "coverage_regression".
-
PR open. On success, push the branch and open the PR. The description carries the diff, which recipes applied, and which commits the agent authored.
-
Failure taxonomy. Tag every failed repo with one class:
dep_upgrade_required,build_tool_drift,custom_annotation,test_flake,syntax_edge_case, orbudget_exhausted. Then build a dashboard on top of the tags. -
50-repo run. Execute the whole pipeline across the MigrationBench subset. Report per-class pass rate, cost per repo, and coverage preservation, plus a comparison against the deterministic-only baseline.
Use It
$ migrate legacy-java-service --target java17
[recipe] 27 rewrites applied (JUnit 4->5, HashMap initializer, try-with-resources)
[build] FAIL: cannot find symbol sun.misc.BASE64Encoder
[agent] turn 1 classify: removed_jdk_api
[agent] turn 2 apply: sun.misc.BASE64Encoder -> java.util.Base64
[build] OK
[tests] 412/412 passing; coverage 84.1% -> 84.3%
[pr] opened #1841 cost=$3.20 turns=4
Ship It
outputs/skill-migration-agent.md is the deliverable. Given a repo, it runs the deterministic recipes, then the agent loop, and produces either a green migrated branch or a filing under a taxonomy class.
| Weight | Criterion | How it is measured |
|---|---|---|
| 25 | MigrationBench pass rate | 50-repo subset pass@1 |
| 20 | Test-coverage preservation | Mean coverage delta vs base |
| 20 | Cost per migrated repo | $/repo on passing runs |
| 20 | Agent / deterministic-tool integration | Fraction of fixes that OpenRewrite handled vs agent authored |
| 15 | Failure analysis write-up | Taxonomy completeness with exemplars |
| 100 |
My bet is that the baseline run decides how much agent you need. I'd do Exercise 1 first, OpenRewrite only with no agent, before writing the agent loop in step 3. The Problem section says the gap between the two is where the agent's value lives, and I'd rather learn that number up front than after building the agent loop.
I'd also treat the coverage gate in step 5 as the check on the agent itself. It is the one gate that compares the migrated branch to the base repo, and a drop of more than 2% files the repo under "coverage_regression" instead of opening a PR. I'd keep that threshold fixed while tuning everything else, because a pass rate measured against a loosened gate isn't comparable to one measured against the original.
On the taxonomy, I'd keep "test_flake" narrow. It is for flakes unrelated to the migration, and I'd file a repo there only with evidence attached, since the failure write-up is worth 15 points on the rubric.
Exercises
-
Run the migrate pipeline with OpenRewrite only (no agent). Compare pass rate to the full pipeline. Identify the cases where the agent alone is the difference.
-
Implement a "lint-clean" check: after migration, run a style linter (spotless for Java, ruff for Python). Fail the PR if new lint errors appear. Measure the coverage-preserved-but-style-regressed rate.
-
Add a "minimal-diff" optimizer: after the agent's branch passes tests, trim unnecessary changes with a second pass. Report diff-size reduction.
-
Extend to a third migration: Node 18 to Node 22. Reuse the sandbox wrapping; swap the recipe layer for a custom codemod.
-
Measure time-to-first-green-build (TTFGB) as a UX metric. Target: p50 under 10 minutes.
Key Terms
| Term | What people say | What it actually means |
|---|---|---|
| Deterministic substrate | "Recipe engine" | OpenRewrite / libcst: declarative AST rewrites with safety guarantees |
| Codemod | "Code-modifying program" | A rewrite rule that changes source code mechanically |
| Build drift | "Tool version skew" | Subtle Maven / Gradle / uv behavior changes between major versions |
| Failure class | "Taxonomy bucket" | A labeled reason a repo did not migrate: dep, syntax, test, build-tool, budget |
| Coverage delta | "Coverage preservation" | Change in test coverage % from base to migrated branch |
| Agent turn | "Tool-call round" | One plan -> act -> observe cycle in the agent loop |
| Budget exhaustion | "Hit the ceiling" | The repo consumed its 30-min / $8 / 20-turn limit without passing |
Further Reading
- Amazon MigrationBench: the canonical 2026 benchmark
- Moderne.io OpenRewrite platform: the deterministic substrate reference
- OpenRewrite documentation: recipe authoring
- Grit.io: alternate codemod DSL
- OpenAI sandboxed migration cookbook: the Agents SDK reference
- Google App Engine Py2 to Py3 migrator: alternate migration benchmark
- libcst: Python deterministic substrate
- Daytona sandboxes: reference per-branch sandbox
Code
- main.py (python, 211 lines)
- ts/package.json (json, 24 lines)
- ts/README.md (markdown, 40 lines)
- ts/src/cost.ts (typescript, 29 lines)
- ts/src/index.ts (typescript, 56 lines)
- ts/src/migrations.ts (typescript, 146 lines)
- ts/src/server.ts (typescript, 83 lines)
- ts/src/types.ts (typescript, 43 lines)
- ts/tests/cost.test.ts (typescript, 42 lines)
- ts/tests/migrations.test.ts (typescript, 60 lines)
- ts/tsconfig.json (json, 18 lines)