Codex batch operations: one change across many packages
A Codex batch operation applies one well-defined change, such as swapping a logging library, across many packages or repositories. A codemod handles the mechanical bulk, one pilot partition proves the recipe, and parallel codex exec runs in separate Git worktrees handle the rest. Every partition passes the same scripted gate before it merges.
Your team has adopted @acme/logger, and old-logger is still imported in 47 files across 12 packages. A regex replace handles the plain imports and mangles the rest: custom transports, logger instances passed through constructors, and tests that assert on log output. One Codex session working through all 12 packages runs out of context halfway. Twelve unsupervised sessions produce twelve different interpretations of “migrate”. This page is for the developer who runs the migration and the tech lead who has to sign off on 12 pull requests without reading every line of them.
What a Codex batch run gives you
Section titled “What a Codex batch run gives you”- A decision rule for when a change is a batch, a set of parallel feature lanes, or a plain codemod.
- A recipe file and a partition manifest that make every agent apply the same change the same way.
- A runner that starts one
codex execagent per partition, each in its own worktree and branch, with a concurrency cap. - A strict JSON report per partition and a gate script that proves each partition without anyone reading its diff.
- Four copy-paste prompts: recipe, partition worker, reviewer, and gate-failure fix.
Commands and flags on this page were checked against Codex CLI 0.157.1 (codex --help, codex exec --help, codex cloud exec --help) on 2026-09-26.
Is your change a batch, a set of lanes, or a codemod?
Section titled “Is your change a batch, a set of lanes, or a codemod?”A batch is one recipe applied to many places. That is different from the parallel feature work described in Codex multi-agent workflows, where each lane builds something different. The distinction decides how you verify the result: in a batch, every partition must satisfy the same invariant, so one gate checks all of them.
| The change | Use | Why |
|---|---|---|
| Purely syntactic (rename an import, a function, an option) | A codemod alone, such as ast-grep | Deterministic, reviewable as one rule, costs no agent time |
| One recipe, but each site needs judgment (call signatures, config, tests) | A Codex batch (this page) | The codemod does the bulk; agents handle the long tail the same way everywhere |
| Several different features that share a codebase | Parallel lanes | Each lane needs its own plan and its own definition of done |
| Fewer than about five files | One Codex session | Partitioning costs more than it saves |
Run a batch migration with Codex
Section titled “Run a batch migration with Codex”-
Create an integration branch and write the recipe. Branch from
mainaschore/logger-migration. Run the recipe prompt below in a normal Codex session: it studies how the codebase usesold-loggerand writesdocs/migrations/logger.md, a before-and-after rulebook with one reference implementation. Read and correct this file. It is the one artifact every agent obeys, so your review time is best spent here. Then commitdocs/migrations/logger.mdtochore/logger-migration: the runner creates every worktree from that branch, so the worktrees only see committed files. -
Land everything shared before the fan-out. Add
@acme/loggerto every package’spackage.jsonand update the lockfile in one commit on the integration branch. Install dependencies before any agent runs, so no partition depends on the agent having network access. The agents must not touch the root lockfile either: if twelve agents each edit it, you get twelve conflicts. -
Write the partition manifest before the codemod runs. List one package per line in
partitions.txt. Build it now, while every package that usesold-loggerstill names it. After the codemod, a package whose only reference was a default import no longer matches, although its call sites still use the old API:Terminal window # run in your terminal at the repository root, before step 4git grep -l old-logger -- packages/ ':!packages/*/package.json' | cut -d/ -f2 | sort -u > partitions.txtReorder the lines largest package first, so that the slow partitions start early.
-
Run the codemod for the mechanical part. Swap the plain imports with ast-grep (
npm install -g @ast-grep/cli, version 0.45.3 on npm on 2026-09-26), then commit:Terminal window # run in your terminal at the repository rootast-grep run -l ts -p "import \$X from 'old-logger'" -r "import \$X from '@acme/logger';" -U packages/The codemod leaves every site where the old API is actually called. Those sites are the agents’ work, and the codemod has made each partition smaller.
-
Pilot one partition and read its whole diff. Run the runner below on a single partition, choosing a package that uses the logger in several ways. This is the one diff you read line by line. Every mistake you find becomes a new rule in
docs/migrations/logger.md, not a note to that one agent. After each recipe edit, commit it, then remove the pilot worktree and branch before re-running (git worktree remove --force ../wt-logger-PARTITION, thengit branch -D batch/logger-PARTITION); the runner refuses to reuse either. Repeat until the pilot passes the gate and you agree with the diff. -
Fan out the remaining partitions. Run the runner over the rest of the manifest, without the pilot’s line, with a concurrency cap of three. Each agent works in its own worktree on its own branch, so a failed partition never blocks or corrupts another.
-
Gate each partition, then merge. Run the gate script on every partition. A partition that fails goes back to an agent with the gate output; you do not open its diff. Merge passing branches into
chore/logger-migrationone at a time withgit merge --no-ff, in manifest order, so each partition stays one revertable merge commit. -
Run the global gate and finish serially. After the last merge, run the full type check, lint and test suite once, and confirm the repository-wide invariant. Then remove
old-loggerfrom everypackage.jsonand the lockfile in one final commit, and open one pull request with the evidence summary from the section on proof below.
Fan out partitions from a script with codex exec
Section titled “Fan out partitions from a script with codex exec”The runner creates one worktree and branch per partition from the integration branch, installs dependencies outside the agent’s sandbox, runs the agent with a strict output schema, and commits the result. Save it as scripts/batch/run-one.sh and add .codex-runs/ to .gitignore.
#!/usr/bin/env bash# run-one.sh PARTITION: migrate one package in its own worktree and branchset -euo pipefailp="$1"base="${BASE:-chore/logger-migration}"root=$(git rev-parse --show-toplevel)runs="$root/.codex-runs"wt="$root/../wt-logger-$p"mkdir -p "$runs"
git worktree add -q -b "batch/logger-$p" "$wt" "$base"(cd "$wt" && pnpm install --frozen-lockfile --silent) # install before the agent runs; the partition must not depend on agent network access
sed "s|{{PARTITION}}|$p|g" "$root/prompts/logger-partition.md" | codex exec -C "$wt" \ -c default_permissions=":workspace" \ --output-schema "$root/prompts/partition-report.schema.json" \ -o "$runs/$p.json" \ --json - > "$runs/$p.jsonl"
git -C "$wt" add -Agit -C "$wt" commit -q -m "chore(logger): migrate packages/$p (codex exec)" || echo "$p: no changes"Make the scripts executable once, then start the runner from the repository root with a concurrency cap:
# run in your terminal at the repository root, on chore/logger-migrationchmod +x scripts/batch/*.sh./scripts/batch/run-one.sh api # the pilot (step 5)xargs -P 3 -n 1 ./scripts/batch/run-one.sh < partitions.txt # the fan-out (step 6)For the fan-out, drop the pilot’s line from partitions.txt first, or remove its worktree and branch, because git worktree add -b refuses a branch that already exists.
Four choices in the runner are deliberate:
- One worktree per partition, created by the script. Parallel agents in one checkout share a working tree,
node_modulesand build caches, and one agent’s half-finished edit breaks another agent’s test run.codex exec --worktreecreates a managed worktree, but a batch needs a named branch per partition and a known path for the gate, so the script creates them itself. Worktree setup and cleanup are covered in isolated Codex tasks with Git worktrees. -c default_permissions=":workspace"selects the built-in workspace permission profile, which OpenAI prefers over the legacy--sandboxflag. Profiles are beta (Codex CLI 0.138.0 and later), so pin the CLI version your script runs.-reads the prompt from stdin, so one template file serves every partition, and{{PARTITION}}is the only thing that changes.- The script commits, not the agent. The result does not depend on whether the sandbox can write to the shared
.gitdirectory, and every branch ends in exactly one commit you can revert.
Save the report schema as prompts/partition-report.schema.json. Codex validates the final message strictly, so every object lists all its properties in required and sets additionalProperties to false:
{ "type": "object", "properties": { "status": { "type": "string", "enum": ["migrated", "partial", "needs_human"] }, "files_changed": { "type": "array", "items": { "type": "string" } }, "remaining_references": { "type": "array", "items": { "type": "object", "properties": { "file": { "type": "string" }, "line": { "type": ["integer", "null"] }, "reason": { "type": "string" } }, "required": ["file", "line", "reason"], "additionalProperties": false } }, "decisions": { "type": "array", "items": { "type": "string" } }, "commands_run": { "type": "array", "items": { "type": "string" } } }, "required": ["status", "files_changed", "remaining_references", "decisions", "commands_run"], "additionalProperties": false}The decisions field is the one that saves review time. It lists every choice the agent made that the recipe did not cover, which tells you exactly which partitions need a human look and which rule the recipe is missing.
How do you prove each partition without reading every diff?
Section titled “How do you prove each partition without reading every diff?”Each partition passes six checks before a person looks at it, cheapest first. The report is the agent’s claim; checks two to six test that claim with signals the agent cannot write for itself. Save this as scripts/batch/gate.sh and run it once per partition:
#!/usr/bin/env bash# gate.sh PARTITION: prove one partition without reading its diffset -euo pipefailp="$1"base="${BASE:-chore/logger-migration}"root=$(git rev-parse --show-toplevel)report="$root/.codex-runs/$p.json"cd "$root/../wt-logger-$p"fail() { echo "FAIL $p: $*"; exit 1; }
# 1. The agent's own verdict. The schema guarantees its shape, not its truth.if [ -f "$report" ]; then jq -e '.status == "migrated"' "$report" >/dev/null || fail "agent reported $(jq -r .status "$report")"elif [ "${CLOUD:-0}" = 1 ]; then echo "SKIP $p: no report (cloud partition), check 1 skipped"else fail "no report at $report"fi
# 2. Nothing uncommitted, nothing outside the partition.[ -z "$(git status --porcelain)" ] || fail "uncommitted changes"stray=$(git diff --name-only "$base"...HEAD | grep -v "^packages/$p/" || true)[ -z "$stray" ] || fail "files outside packages/$p: $stray"
# 3. The invariant: no import of the old library is left in this package.# The codemod already removed the plain imports, so this mostly catches require()# and re-exports; check 6 proves the call sites.if git grep -n -E "from ['\"]old-logger['\"]|require\(['\"]old-logger['\"]\)" -- "packages/$p"; then fail "old-logger is still imported"fi
# 4. No weakened tests: fewer assertion lines than before is a red flag.removed=$(git diff "$base"...HEAD -- "packages/$p" | grep -c '^-.*expect(' || true)added=$(git diff "$base"...HEAD -- "packages/$p" | grep -c '^+.*expect(' || true)[ "$removed" -le "$added" ] || fail "$removed assertion lines removed, $added added"
# 5. Diff budget: a logger swap is small; a rewrite is not.lines=$(git diff --numstat "$base"...HEAD | awk '{s+=$1+$2} END {print s+0}')[ "$lines" -le "${MAX_LINES:-400}" ] || fail "$lines changed lines (budget ${MAX_LINES:-400})"
# 6. Re-run the package's own gates. Never trust "tests pass" in the report.pnpm --filter "./packages/$p" run typecheckpnpm --filter "./packages/$p" run lintpnpm --filter "./packages/$p" run testecho "PASS $p"Check three alone proves little after the codemod has run: a package can import @acme/logger and still call the old API. The typecheck and tests in check six are what back the invariant, because a call that the new library does not export fails to compile.
We ran the runner and the gate against a two-package test repository (with codex and pnpm stubbed) on 2026-09-26: the gate rejected a partition that edited a second package and one that left an import behind, and passed the corrected one.
For partitions whose report lists decisions, add a read-only reviewer agent. In Codex CLI 0.157.1, codex exec review --base cannot take custom instructions (the argument '--base <BRANCH>' cannot be used with '[PROMPT]', checked 2026-09-26), so the reviewer is a plain codex exec run under the read-only profile. The report lives in the main checkout’s gitignored .codex-runs/, which the reviewer’s worktree does not contain, so the command appends the decisions to the prompt instead of pointing at the file:
# run in your terminal at the repository root, for one partitionp=api{ sed "s|{{PARTITION}}|$p|g" prompts/logger-review.md jq -r '.decisions[] | "- " + .' ".codex-runs/$p.json"} | codex exec -C "../wt-logger-$p" -c default_permissions=":read-only" \ -o "$PWD/.codex-runs/$p-review.md" -Once every partition has passed, one command turns the reports into the evidence table for the pull request description:
jq -r '[input_filename, .status, (.files_changed | length), (.decisions | join("; "))] | @tsv' .codex-runs/*.jsonWho signs off on what. The tech lead approves the recipe and the pilot diff before the fan-out, and reads diffs only for partitions with non-empty decisions or reviewer findings. Everything else is signed off by the gate and by the global run of the full suite after the last merge. That is the whole point of a batch: human attention goes to the rules and the exceptions, not to 47 near-identical files.
Send heavy partitions to Codex cloud
Section titled “Send heavy partitions to Codex cloud”A partition whose build or test suite is too slow for a laptop, or one that failed locally twice, fits a cloud task. Push the integration branch first, because codex cloud exec runs on your current branch unless you pass --branch, and the container clones from your remote:
# run in your terminal at the repository rootgit push -u origin chore/logger-migrationp=billingcodex cloud exec --env ENV_ID --branch chore/logger-migration --attempts 2 \ "$(sed "s|{{PARTITION}}|$p|g" prompts/logger-partition.md)"codex cloud list --env ENV_ID # find the TASK_IDgit worktree add -b "batch/logger-$p" "../wt-logger-$p" chore/logger-migration(cd "../wt-logger-$p" && codex cloud diff TASK_ID --attempt 1 && codex cloud apply TASK_ID --attempt 1)ENV_ID is your cloud environment’s ID or label, and TASK_ID is the ID that codex cloud exec prints. codex cloud is marked experimental in 0.157.1. A cloud task has no --output-schema, so there is no report file: commit the applied diff yourself with git -C "../wt-logger-$p" add -A, then git -C "../wt-logger-$p" commit. Then run the gate as CLOUD=1 ./scripts/batch/gate.sh "$p". With CLOUD=1, check one skips the missing report and prints SKIP, so checks two to six decide. Without it, a missing report fails the partition, which catches a local run whose report was lost. Environment setup and when best-of-N attempts are worth paying for are covered in Codex cloud environments.
Run the same batch across many repositories
Section titled “Run the same batch across many repositories”When the partitions are repositories, not packages, the runner changes in three places: it clones instead of adding a worktree, the branch lives in each repository, and the gate’s boundary check becomes “no files outside src/ and test/” or whatever your repositories share. Keep the recipe in one place and copy it into each clone before the run, so every repository gets the same rules:
#!/usr/bin/env bash# run-one-repo.sh REPO_URL: the multi-repository variant of run-one.shset -euo pipefailrepo="$1"; name=$(basename "$repo" .git); dir="$HOME/batch/$name"recipe="${RECIPE:-$PWD/docs/migrations/logger.md}" # absolute path, resolved before any cdgit clone -q "$repo" "$dir"git -C "$dir" switch -c chore/logger-migrationmkdir -p "$dir/docs/migrations" && cp "$recipe" "$dir/docs/migrations/"(cd "$dir" && pnpm install --frozen-lockfile --silent)The codex exec call and the commit are the same as in run-one.sh, with -C "$dir". Open one pull request per repository after its gate passes, so each owning team reviews and merges its own.
How many partitions, and which model?
Section titled “How many partitions, and which model?”Set concurrency by what you can gate, not by what your plan allows. Start at three parallel agents; raise it only when the first round passes the gate without rework. Check /usage in an interactive session before a large run, because a batch multiplies usage roughly by the number of partitions. Plan allowances and their five-hour and weekly windows are covered in Codex cost management.
Codex runs GPT-6 Astra by default. Follow the site-wide rule: stay on the default model, tune effort first (-c model_reasoning_effort="medium"), and switch model only when your own evaluation says so. A batch gives you that evaluation for free. Re-run the pilot partition with -m gpt-6-sol or -m gpt-6-luna, and pin the same -c model_reasoning_effort=... level on both the Astra run and the challenger run, because in Codex Astra defaults to low effort while Sol and Luna default to medium, and a comparison at default settings measures model and effort at once. If the cheaper model passes the same gate with the same decisions, use it for the remaining partitions. The models hub lists current Codex models and prices.
When a Codex batch run breaks
Section titled “When a Codex batch run breaks”| Symptom | Cause | Recovery |
|---|---|---|
| Partitions solve the same case differently | The recipe did not cover it | Add the rule to docs/migrations/logger.md, then re-run only the partitions whose decisions mention that case |
| Gate: files outside the partition | The agent “fixed” a shared file or a neighbour | Remove the worktree and branch (git worktree remove --force ../wt-logger-PARTITION && git branch -D batch/logger-PARTITION), then re-run run-one.sh; if the shared edit is needed, commit it on the integration branch first |
Many partitions report needs_human on the same usage pattern | A usage pattern the pilot did not have | Stop the run (Ctrl+C stops xargs), extend the recipe, re-pilot on one of those partitions |
| Conflicts in the lockfile or root config | Dependency changes were left to the agents | Move them into the shared commit before the fan-out (step 2), then rebase the partition branches |
| Gate: assertion lines removed | The agent deleted a test it could not migrate | Send the gate output back with the fix prompt below; never merge a partition that lost assertions |
git worktree add fails: branch already exists | A previous run left the branch and worktree | git worktree remove ../wt-logger-PARTITION, then git branch -d batch/logger-PARTITION (use -D only after checking it holds nothing you want) |
| The laptop grinds to a halt | Three installs and three test suites at once | Lower -P, or move the heaviest partitions to cloud tasks |
| The global suite fails after all partitions passed | Cross-package behaviour, such as a shared log format parsed downstream | Revert partition merges one at a time with git revert -m 1 MERGE_SHA until the suite passes; the last one reverted is the culprit |
Save the fix prompt below as prompts/logger-fix.md and render it with the same sed line as the other templates; the command appends the gate output. The fix run uses the same --output-schema and -o as run-one.sh, so it overwrites .codex-runs/$p.json with a fresh report; otherwise check one would keep reading the original partial or needs_human verdict and the partition could never pass. You commit the result yourself before gating again:
# run in your terminal at the repository root, for one rejected partitionp=api{ sed "s|{{PARTITION}}|$p|g" prompts/logger-fix.md ./scripts/batch/gate.sh "$p" 2>&1 | tail -n 60} | codex exec -C "../wt-logger-$p" -c default_permissions=":workspace" \ --output-schema "$PWD/prompts/partition-report.schema.json" \ -o "$PWD/.codex-runs/$p.json" -git -C "../wt-logger-$p" add -Agit -C "../wt-logger-$p" commit -q -m "fix(logger): gate fixes for packages/$p"./scripts/batch/gate.sh "$p"After the last merge, clean up: git worktree remove each partition worktree, delete merged branches with git branch -d, and run git worktree prune.
Where to go next with Codex batch work
Section titled “Where to go next with Codex batch work”- Before this page: isolated Codex tasks with Git worktrees and writing
AGENTS.mdfor Codex. Every partition readsAGENTS.mdfor its commands. - Scripting in depth, including the JSONL event stream in
.codex-runs/*.jsonl: non-interactive Codex withcodex exec. - Different work in parallel rather than one recipe: Codex multi-agent workflows.
- Large migrations as a programme, with characterization tests and cut-over: legacy modernization with agents and codebase modernization with Codex.
- Monorepo structure that keeps partitions independent: monorepo management with AI agents.
- The Claude Code equivalent: batch operations in Claude Code.