Batch land
Use when a deep ready-to-land or ready-for-agent PR backlog is draining too slowly through the serialized Landing Queue and you want to collapse N PRs into ONE validated integration PR, or when the landing agent has built an exact owner candidate ref/sha for conflict-repaired batch landing. Dry-run first; never bypasses or races the queue.From its SKILL.md
npx -y skills add ottto-ai/agent-skills --skill batch-landAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
24.0 KB, ~6.1k tokens by cl100k_base, as published. Nobody here has run it
batch-land
Collapse a deep ready-to-land or owner-mode ready-for-agent PR backlog into
one validated integration PR, draining N PRs through a single trusted-queue
slot. It can either build the integration tree itself from source PR heads or
validate an owner-built candidate ref/sha that carries conflict fixes.
When to use this
The repo's trusted Landing Queue is serialized: it lands one PR at a time.
On a busy day, agents produce dozens of ready-to-land PRs and the queue lands
them one-by-one over hours. If a single queue run hangs, everything behind it
wedges (the failure mode that motivated this skill: one 3h hang wedged 17 PRs).
batch-land is the durable drain. It builds one integration branch off the
current origin/master, merges each eligible PR head into it, validates the
combined tree with the same validators the queue runs, writes a
manifest, and (only with --apply) opens a single integration PR that rides the
existing trusted queue. N PRs then land through one queue slot.
Use it when:
- there is a deep
ready-to-landbacklog (say 8+ green, trusted-queue-safe PRs); - the queue is making slow serial progress or recovering from a wedge;
- you want one reviewable, validated artifact instead of N separate landings.
Do not use it to bypass review, to land operator-gated infra/workflow changes, or to "go faster" on a small backlog the queue will clear in minutes.
Why batch-land drains a backlog the queue can't
This is the crux. The Landing Queue serializes on a concurrency lock
(concurrency: group: landing-queue-master, cancel-in-progress: false). At any
instant exactly one queue run is in_progress — actually fetching master,
merging its PR, pushing. Every other dispatched run is pending/queued,
serialized behind that lock, often for hours, and frequently stale (a
pending run whose PR was already landed or fast-laned out of band simply
no-ops when it finally gets the lock).
The queue's own ACTIVE_QUEUE_STATUSES ({queued, in_progress, pending,
requested, waiting}) answers "work the queue will eventually do". batch-land
needs the narrower question "which PR is being merged right now", because a PR
that is only sitting in a pending run is exactly the stuck backlog batch-land
exists to drain. So batch-land protects only the in_progress run
(MERGING_QUEUE_STATUSES = {"in_progress"}), plus a small FIFO head+next buffer
as a race margin in legacy ready-to-land mode. In owner-mode
ready-for-agent, the reasoning lander is the serial owner, so there is no FIFO
head+next buffer; otherwise a small backlog can strand forever. The dozens of
pending PRs remain batchable — when the batch lands them, their stale pending
runs no-op. (Reusing the queue's broad set here would over-protect and exclude
the entire backlog, defeating the tool.)
What it does NOT do
- It does not modify, patch, or race the Landing Queue. Queue code is
Codex's lane.
batch-landonly consumes the shared landing policy (landing_policy) and the queue's own validator selection (landing_queue), so its combined-tree gate is byte-identical to what the queue would run. - It does not touch the PR being merged right now (the single
in_progressqueue run). In legacyready-to-landmode it also preserves the FIFO head+next race buffer. In owner-modeready-for-agent, there is no FIFO buffer because the lander is the serial owner. A PR merely sitting in apending/queuedrun IS batchable (see above). - It never force-pushes and never resets or rebases
master. - Candidate-ref mode never pushes directly to
master: it validates the exact owner candidate tree, rewrites only the commit shape for queue validators, and opens an integration PR that still rides the queue. - Combined-tree validation runs PR-controlled code, so it uses the queue's
token-stripped
candidate_env()(noGH_TOKEN/ AWS creds in the validation environment) — a maliciousready-to-landPR cannot exfiltrate operator secrets during dry-run or apply.
Safety model (non-negotiable)
--dry-runis the default. Dry-run integrates + validates + writes a manifest entirely in a scratch worktree. It never pushes, opens a PR, applies a label, or mutates any PR.- Exclude-don't-block, log everything. A PR that conflicts, fails checks, is
out of scope, touches an operator-gated infra/workflow path, or breaks the combined tree is
EXCLUDED with a logged reason and surfaced in the manifest. One bad apple
never sinks the batch. There are no silent caps:
--maxtruncation and queue head/next protection are logged exclusions, not hidden drops. - Idempotent / resumable. Already-landed PRs are skipped. Re-running after a partial batch re-selects only what is still open and unlanded.
- Worktree rule. All integration work happens in a dedicated scratch git
worktree under the repo's ignored
.local-state/batch-land/tree, never the main checkout and nevermaster. - No red PRs. It never opens an integration PR on a tree whose combined
validation is not green. The bisect first drops the offending PR(s) and opens a
PR for the MAX green subset (the breakers fall back to the serial queue). If NO
green subset survives, the
--applyrun is a clean no-op: it lands nothing and exits 0 (reported asred_no_opin the JSON), NOT a failure -- the serial queue drains those PRs. A nonzero--applyexit means a genuine engine error (a git/gh fault), never "the tree was red". This is what keeps the scheduled drainer -- which fires on every queue completion -- from storm-failing on a red backlog and false-tripping the liveness watchdog.
Invocation
The engine is .agents/skills/batch-land/scripts/batch_land.py (Python 3, run
with the repo's uv/python3). Run it from the repo root (or pass
--repo-root).
# 1) DRY RUN (default): see what would be batched, validate the combined tree,
# write the manifest. No push, no PR.
python3 .agents/skills/batch-land/scripts/batch_land.py --dry-run
# Smoke / fast dry run: skip the heavy backend/frontend lanes, but still run
# docs/KB plus lane-ownership + CI-policy guards (manifest flags skipped lanes).
python3 .agents/skills/batch-land/scripts/batch_land.py --dry-run --skip-heavy
# Restrict to specific PRs. --prs still enforces ALL the same gates as the
# default path -- including the required `ready-to-land` label, head/next
# protection, scope, sensitivity, and checks. It cannot batch an unapproved PR.
python3 .agents/skills/batch-land/scripts/batch_land.py --prs "1620,1621,1623"
# Cap the batch size (oldest first; the rest are logged as deferred):
python3 .agents/skills/batch-land/scripts/batch_land.py --max 8
# Include PRs whose paths are outside trusted-queue scope (operator-gated
# infra/workflow paths are STILL excluded). Use only when you understand the
# combined scope:
python3 .agents/skills/batch-land/scripts/batch_land.py --include
# 2) APPLY: after a green combined-tree validation, open the integration PR and
# apply ready-to-land so it rides the trusted queue. Requires agent metadata.
python3 .agents/skills/batch-land/scripts/batch_land.py --apply \
--session-id <claude-session-uuid> \
--session-name '<terminal title / aiTitle>' \
--session-source '<output of agent_metadata_doctor>'
# Owner candidate branch mode: use when the reasoning lander has pushed an exact
# conflict-repaired candidate ref. The command verifies current master, source PR
# heads, candidate ref -> SHA, that each source patch is represented in the
# candidate tree, and that the final candidate diff is queue-safe, then opens the
# integration PR. Requires explicit --prs for provenance.
python3 .agents/skills/batch-land/scripts/batch_land.py --apply \
--prs "1620,1621" \
--required-label ready-for-agent \
--candidate-ref landing-agent/batch-20260630-01 \
--candidate-sha <40-hex-sha> \
--session-id <session-uuid> \
--session-name '<name>' \
--session-source '<source>'
# Owner dirty-source repair mode: use only under LANDING_AGENT_OWNS_QUEUE with
# the owner intake label. DIRTY/CONFLICTING PRs are allowed past SELECT so the
# scratch integration merge can prove whether they actually merge, auto-resolve
# generated/union conflicts, or need a concrete deferral.
python3 .agents/skills/batch-land/scripts/batch_land.py --apply \
--required-label ready-for-agent \
--allow-conflicting-sources \
--session-id <session-uuid> \
--session-name '<name>' \
--session-source '<source>'
# JSON output for orchestration:
python3 .agents/skills/batch-land/scripts/batch_land.py --dry-run --json
# 3) RECONCILE (after the integration PR MERGES): close the source PRs.
python3 .agents/skills/batch-land/scripts/batch_land.py --reconcile-pr <integration-pr>
--session-* can also come from BATCH_LAND_SESSION_ID,
BATCH_LAND_SESSION_NAME, BATCH_LAND_SESSION_SOURCE env vars. They are
required for --apply because the integration PR is agent-authored and the
queue enforces agent metadata. Resolve them via
.github/scripts/agent_metadata_doctor.py --agent claude-code --session-id <id> --session-name <name> and the session-id rules in
.agents/skills/repo-task-lifecycle/SKILL.md.
Operator workflow (dry-run -> review -> apply)
- Dry-run. Run
--dry-run(add--skip-heavyfor a fast first look). Read the printed summary and the manifest at.local-state/batch-land/<runlabel>.md. - Review. Confirm the included set is what you expect, the excluded set has
sensible reasons, anything flagged
[HUMAN]is genuinely human-only (operator-gated infra/workflow paths, destructive migration markers, hold labels), and the combined-tree validation is green. - Apply. If green and reviewed, rerun with
--applyand the agent metadata. It pushes the integration branch, opens one PR titledbatch-land: N PRs (#A #B ...)with the manifest as the body, appliesready-to-landto it, and then removesready-to-landfrom the source PRs (so they step out of the serialized queue, in that order so a failure never orphans them). The trusted queue then merges that single PR. - Land. Follow the integration PR through the queue with
pr-landing(.github/scripts/pr_landing.py land --pr <n>/pr_watch_until_merged.py). - Reconcile (post-merge). batch-land does NOT block waiting on the queue,
so once the integration PR has MERGED, run the reconcile step to close the
source PRs:
It is idempotent: it refuses unless the integration PR is merged, derives the source PRs from the integration PR body'spython3 .agents/skills/batch-land/scripts/batch_land.py --reconcile-pr <integration-pr>Closes (on merge): #A #B ...line, and skips already-closed ones, closing each withLanded via batch-land #<n> (<sha>). (The orchestrator can run this automatically after detecting the merge.)
The algorithm
- SELECT — default: all open
ready-to-landPRs (or--prs). The requiredready-to-landlabel is enforced on EVERY path (including--prs), so an unapproved PR can never be folded in and wrapped with the label. EXCLUDE, logging each reason:- (a) the PR(s) a queue run is merging right now (the single
in_progressrun, single OR batch[pr:A,B]title). In legacyready-to-landmode, also protect a FIFO head+next race buffer (the 2 oldestready-to-land). In owner-modeready-for-agent, do not reserve a FIFO buffer because the lander owns serialization. This is intentionally narrower than the queue's active set —pending/queuedruns do NOT protect their PRs (see "Why batch-land drains a backlog the queue can't"); - (b) non-green required blocker checks (
landing_policy); - (b2) PRs the queue itself would not land — conflicting with master
(
CONFLICTING/DIRTY) or held by branch-protection / required review (BLOCKED,REVIEW_REQUIRED,CHANGES_REQUESTED) — mirroring the queue'svalidate_prrefusals so the approval boundary holds for every source PR; - (c) paths outside trusted-queue-eligible prefixes
(
TRUSTED_QUEUE_ALLOWED_PREFIXES) unless--include; - (d) sensitive paths (infra/terraform, OIDC, production workflows) and
destructive migration markers in migration
upgrade()bodies -> excluded and flagged for a human. Paired index rebuilds and paired unique-constraint rebuilds inupgrade()are not destructive by marker alone; unmatched drops, non-unique constraint drops, table/column drops, truncates, deletes, and raw destructive markers remain human-gated; - (e) labels
needs-review/hold/do-not-merge. - (f) PRs missing the required
ready-to-landlabel (enforced on every path, including--prs, so an unapproved PR can never be folded in and relabeled); - (g) agent-authored PRs missing
Agent session id/name/source(reusing the queue'sagent_metadata_blockers), so the per-source audit requirement the queue enforces is not bypassed by folding them into the integration PR; - (h) PRs whose
ready-to-landlabel is unauthorized or stale — reusing the queue'sverify_ready_label_actor(label applied by a non-writer, or the head changed after the label was applied). Fail-closed: an events-API read failure excludes rather than assuming authorized.
- (a) the PR(s) a queue run is merging right now (the single
- INTEGRATE — fetch, branch off the current
origin/master. - MERGE — each candidate in FIFO order. First fetch the PR's
pull/<n>/head(every time) and verify the head. A head that cannot be fetched is excluded ashead unavailable— fail-closed even if a local object with the selected SHA exists, because a transient fetch failure cannot prove the PR's current head still equals the validated SHA (merging a stale local SHA and later closing the source as landed would be unsafe). A head that has advanced since selection (currentpull/<n>/head!= the SHA whose checks were validated) is excluded ashead moved. Both are distinct fromconflictand never silently dropped. Thengit merge --no-ff <head sha>to stack each PR. On CONFLICT: attempt a safe auto-resolve for generated/union-attributed files (.gitattributesmerge=union/keep-generated). In owner mode (--allow-conflicting-sourceswithready-for-agent), also attempt a three-way union repair for high-churn textual docs/KB/effort-board files, especiallydocs/efforts/current-efforts.html; code, tests, workflow, infra, migration, and runtime conflicts remain exclusions unless an owner builds an explicit reviewed candidate ref. Otherwisegit merge --abortand EXCLUDE that PR (reasonconflict), then continue. If git reports a nonzero merge but rerere already staged a complete generated-doc resolution and no unmerged paths remain, commit that staged merge and keep the PR. After stacking, refresh generated KB source cards/indexes in the scratch candidate when the combined diff includes Markdown; the repair may change onlydocs/ai/kb/sources/artifacts and preserves existing source-card ingest dates unless a card is actually stale. Then collapse the per-PR merges into ONE merge commit whose first parent isorigin/master(remaining parents = the PR heads, for attribution). This gives the queue's single-candidate shape soHEAD^1 == base: validators that compute changed paths fromHEAD^/HEAD^1(e.g.test_backend_ci_blocker.sh) see the FULL combined change, not just the last PR (a stack of merge commits would pointHEAD^1at the previous batch commit and under-validate). Owner candidate-ref mode: when--candidate-ref/--candidate-shaare supplied, batch-land fetches that ref, requires it to resolve to the exact advertised SHA, requires it to be based on currentorigin/master, verifies every explicit source PR head still matches GitHub, and requires every source PR to be represented in the candidate tree before it can later be closed. Representation is proven either by source-head ancestry (the preferred path: actually merge the source PR and resolve conflicts in the owner branch) or by a cleangit merge-treeno-op against the candidate tree. It also checks the final candidate diff for sensitive paths, destructive migration markers, and unsupported paths before opening the integration PR or stepping source labels. Then batch-land rewrites only the commit shape: the new integration commit has the candidate tree unchanged, current master as first parent, source PR heads plus the candidate SHA as additional parents. This preserves the exact conflict-fixed tree while keeping queue validators reliable. - VALIDATE the COMBINED tree — using the queue's own lane selection
(
landing_queue.blocker_lane_labels->candidate_blocker_commands): the backend blocker, frontend blocker, docs/KB, and CI-policy lanes the changed paths imply — the same commands the queue runs on that tree. If the full set is green (the common case) it is one build. On FAILURE -> greedy forward construction: rebuild from empty, adding candidates one at a time in FIFO order and keeping each only if the set stays green; a PR that turns it red is dropped (broke combined validation). This drops only true breakers — for ANY number of independent breakers — and never excludes an innocent PR, in O(n) builds. Exclusions include the failing lane and a short validation tail so authors get concrete evidence such as stale source-card paths. Exclusions come only from the final accepted build (no speculative-trial leakage), so a PR can never be both included and excluded. NEVER opens a PR on a red tree. When NO green subset survives,--applyis a clean no-op (lands nothing, exits 0,red_no_opin the JSON) and the serial queue drains the PRs -- it is not a failure. Lane ownership (scripts/lane_check.py) is computed on the final tree and recorded as a non-gating advisory in the manifest — a batch legitimately transports Codex-/shared-owned PRs through one queue slot, so cross-lane ownership is expected and never blocks landing. - MANIFEST — write
.local-state/batch-land/<runlabel>.md(included table #/title/author + excluded + reasons + validation results); reuse it as the integration PR body. - OPEN — one integration PR
batch-land: N PRs (#A #B ...)with the manifest + agent-metadata bullets; applyready-to-land.--fast-lane(ff-only, no force,--fast-lane-reasonrequired) is taken only when explicitly passed and operator-approved. - STEP SOURCES OUT OF THE QUEUE — after the integration PR/commit exists
as the replacement, remove
ready-to-landfrom each included source PR (with a note) so their already-queued runs do not land one-by-one ahead of (or racing) it. Done in this order so a push/create/label failure never orphans the sources (they keep their queue slot until the replacement is real). Each removal is verified; any that fail to step out are surfaced as a warning (a possible racer) — not a wedge, since the replacement is already queued/landed. The sources stay open and are closed in step 8. - RECONCILE (separate post-merge mode) — batch-land does not block on the
queue. Once the integration PR has MERGED, run
batch_land.py --reconcile-pr <n>to close each included source PR withLanded via batch-land #<n> (<sha>). Idempotent; derives the sources from the integration PR body'sCloses (on merge): ...line. (The--fast-lanepath has no integration PR, so it closes the sources directly during apply.)
Steps 2-5 always run (dry-run included, locally). Steps 6-7 require --apply;
step 8 is the separate --reconcile-pr mode run after the PR-path merge
(fast-lane closes sources inline).
Fast lane
--fast-lane (with --apply) does an ff-only push of the validated
integration commit straight to master (no force, no reset). It requires
--fast-lane-reason "<why>": before the push it stacks a metadata-carrying
commit on the validated tip so the commit that lands on master carries the
repo-required audit footer (Agent / session id / name / source +
Fast lane reason) — that footer cannot be added after a direct-master push
without rewriting history. It re-checks that origin/master has not moved since
the branch was built; if it moved, it refuses rather than forcing. This is the
documented repo-task-lifecycle direct-master
fast lane and is appropriate only with explicit operator approval (e.g.
unblocking a wedged queue under time pressure). Default and preferred is the PR +
trusted queue path (no --fast-lane).
python3 .agents/skills/batch-land/scripts/batch_land.py --apply --fast-lane \
--fast-lane-reason "unblock wedged queue: 17 PRs stuck 3h behind a hung run" \
--session-id <uuid> --session-name '<name>' --session-source '<source>'
Worked example
A backlog of 15 ready-to-land PRs, the oldest two already owned by / next for
the queue:
$ python3 .agents/skills/batch-land/scripts/batch_land.py --dry-run --skip-heavy
batch-land [dry-run] run 20260628-141500
base origin/master: bb3170d27b...
included: 9 excluded: 6
INCLUDED:
+ #1608 test: flag stats client budget evidence gaps (@dev)
+ #1614 Clarify Claude Code quota source gap (@dev)
...
EXCLUDED:
- #1617 ...: protected: in-flight queue work -- Landing Queue run is merging it now
- #1587 ...: protected: in-flight queue work -- FIFO queue head/next-in-line buffer
- #1598 ...: protected: in-flight queue work -- FIFO queue head/next-in-line buffer
- #1609 ...: conflict -- merge conflict against the integration branch
- #1623 ...: sensitive path -- .github/workflows/backend-deploy.yml [HUMAN]
...
VALIDATION (combined tree):
SKIP backend blocker
PASS CI policy
ADVISORIES (non-gating):
2 combined-tree file(s) belong to a Codex/shared-owned lane (expected for a
multi-agent batch; landing not gated on this):
BLOCKED backend/app/features/pricing/x.py -> lane 'pricing' owned by codex
combined-tree green: True
manifest: .local-state/batch-land/20260628-141500.md
Review the manifest, then:
$ python3 .agents/skills/batch-land/scripts/batch_land.py --apply \
--session-id 13ea164c-... --session-name 'orchestrator loop' \
--session-source 'local transcript'
... integration PR: #1640
Then land #1640 through the queue with the pr-landing skill.
Reuse / boundaries
batch_land.py reuses, rather than re-implements:
landing_policy— operator-gated sensitive-path, trusted-queue-scope, and expected blocker-check predicates (shared with the queue);landing_state— thePullRequestshape and check helpers;landing_queue—blocker_lane_labels/candidate_blocker_commandsand theDEFAULT_*_BLOCKER_COMMANDconstants, so the combined-tree validation matches the queue's.
This means batch-land can never select a tree the queue would reject for
scope/sensitivity, and never validate it more loosely than the queue. If the
queue's policy changes, batch-land inherits it automatically.
Tests
.agents/skills/batch-land/scripts/tests/test_batch_land.py covers the
selection/exclusion rules, real-git conflict-exclusion (hard conflict +
generated-union auto-resolve), bisect-on-failure, and manifest generation. Run:
python3 .agents/skills/batch-land/scripts/tests/test_batch_land.py # standalone
# or
cd backend && uv run --with pytest pytest \
../.agents/skills/batch-land/scripts/tests/test_batch_land.py -q
See docs/dev/2026-06-28-batch-land-skill.md
for the design rationale and the first smoke-test run.
What ships with it: 7 files
405.2 KB alongside SKILL.md, 6 of them executable
scripts/
- batch_drainer_watchdog.pyruns45.2 KB
- batch_land.pyruns136.8 KB
- landing_batch_drainer.pyruns30.3 KB
- tests/test_batch_drainer_watchdog.pyruns40.4 KB
- tests/test_batch_land.pyruns125.7 KB
- tests/test_landing_batch_drainer.pyruns25.3 KB
- README.md1.5 KB