Skip to content

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.

  • 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 exec agent 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 changeUseWhy
Purely syntactic (rename an import, a function, an option)A codemod alone, such as ast-grepDeterministic, 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 codebaseParallel lanesEach lane needs its own plan and its own definition of done
Fewer than about five filesOne Codex sessionPartitioning costs more than it saves
  1. Create an integration branch and write the recipe. Branch from main as chore/logger-migration. Run the recipe prompt below in a normal Codex session: it studies how the codebase uses old-logger and writes docs/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 commit docs/migrations/logger.md to chore/logger-migration: the runner creates every worktree from that branch, so the worktrees only see committed files.

  2. Land everything shared before the fan-out. Add @acme/logger to every package’s package.json and 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.

  3. Write the partition manifest before the codemod runs. List one package per line in partitions.txt. Build it now, while every package that uses old-logger still 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 4
    git grep -l old-logger -- packages/ ':!packages/*/package.json' | cut -d/ -f2 | sort -u > partitions.txt

    Reorder the lines largest package first, so that the slow partitions start early.

  4. 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 root
    ast-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.

  5. 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, then git branch -D batch/logger-PARTITION); the runner refuses to reuse either. Repeat until the pilot passes the gate and you agree with the diff.

  6. 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.

  7. 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-migration one at a time with git merge --no-ff, in manifest order, so each partition stays one revertable merge commit.

  8. 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-logger from every package.json and 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 branch
set -euo pipefail
p="$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 -A
git -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:

Terminal window
# run in your terminal at the repository root, on chore/logger-migration
chmod +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_modules and build caches, and one agent’s half-finished edit breaks another agent’s test run. codex exec --worktree creates 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 --sandbox flag. 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 .git directory, 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 diff
set -euo pipefail
p="$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 typecheck
pnpm --filter "./packages/$p" run lint
pnpm --filter "./packages/$p" run test
echo "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:

Terminal window
# run in your terminal at the repository root, for one partition
p=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:

Terminal window
jq -r '[input_filename, .status, (.files_changed | length), (.decisions | join("; "))] | @tsv' .codex-runs/*.json

Who 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.

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:

Terminal window
# run in your terminal at the repository root
git push -u origin chore/logger-migration
p=billing
codex 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_ID
git 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.sh
set -euo pipefail
repo="$1"; name=$(basename "$repo" .git); dir="$HOME/batch/$name"
recipe="${RECIPE:-$PWD/docs/migrations/logger.md}" # absolute path, resolved before any cd
git clone -q "$repo" "$dir"
git -C "$dir" switch -c chore/logger-migration
mkdir -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.

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.

SymptomCauseRecovery
Partitions solve the same case differentlyThe recipe did not cover itAdd the rule to docs/migrations/logger.md, then re-run only the partitions whose decisions mention that case
Gate: files outside the partitionThe agent “fixed” a shared file or a neighbourRemove 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 patternA usage pattern the pilot did not haveStop the run (Ctrl+C stops xargs), extend the recipe, re-pilot on one of those partitions
Conflicts in the lockfile or root configDependency changes were left to the agentsMove them into the shared commit before the fan-out (step 2), then rebase the partition branches
Gate: assertion lines removedThe agent deleted a test it could not migrateSend the gate output back with the fix prompt below; never merge a partition that lost assertions
git worktree add fails: branch already existsA previous run left the branch and worktreegit 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 haltThree installs and three test suites at onceLower -P, or move the heaviest partitions to cloud tasks
The global suite fails after all partitions passedCross-package behaviour, such as a shared log format parsed downstreamRevert 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:

Terminal window
# run in your terminal at the repository root, for one rejected partition
p=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 -A
git -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.