Autoland: a state-derived poller closes ADR-0024's deferred reject→re-queue loop — verdict-marker PR comments as the lander's channel, hybrid with human native reviews, default-off¶
ADR-0024 shipped labctl pr merge and, in its own status line, deferred "the reject → re-queue loop and pr close/pr comment". So landing a claim PR is human-gated today: the land-pr skill validates an open PR — checks, conventions, conflicts — and merges it once the operator confirms in free text. Autoland closes that deferred loop without a human in the middle. A lander run validates a claim's PR and merges, approves, or rejects it; on a rejection a fix run re-engages the same claim branch, carrying the rejection's findings back as new information. No new road to the forge is built — every write still rides the run-token agent surface exactly as pr merge does (ADR-0014), and the engine decides what to spawn by reading the PR's verdict state plus the runs store (ADR-0015, poll-only), never by dispatching a message. This ADR ships the verdict verbs, their seam, and the shared validation definition (issue #180); the poller/lander engine (#181) and the fix-forward loop and escalation (#182) land behind them.
The pins, decided:
- The lander's verdict channel is an anchored keyword PR comment — not a native forge review. Verified against the deployed forge: Forgejo 15.0.5 unconditionally 422s an approving or changes-requested review from the PR's poster (
preparePullReviewType, no configuration escape hatch), and every lab PR is authored by the single server-side forge-token account — native review verdicts can never succeed same-identity. Comments on one's own PR are allowed and merge has no self-restriction, so the comment channel works under one token on any binding. A verdict comment's FIRST line is exactly
[autoland] verdict: <word> with <word> ∈ reject | pass | fix-done
and the findings / CONCERNS prose follow below the marker line. Parse rule for consumers (#181's poller): first line only, exact prefix match — a keyword quoted mid-body never triggers. escalate is reserved for #182's escalation digest (same grammar, implemented there). Attempt counts stay derived from the runs store; any "(attempt 1/2)" suffix text is informational and never parsed. This ADR is the grammar's canonical home: skills and agent docs speak in verbs only and never spell the marker.
-
Verdict verbs stay verbs; the marker is a server-side implementation detail.
labctl pr reject <n> <body>posts therejectmarker with the findings below;pr approve <n> [body]postspass(CONCERNS as the body);pr rerequest <n>postsfix-done. Injection happens in the agentapi handlers (precedent:pr create'sCloses #Ninjection) as generic compositions over the seam'sCommentPull— agents never see or type the grammar, and a verdict verb's own body lands below line 1 and is inert. The plainpr commentverb composes no marker, so it is the one path that could put agent text on line 1: it rejects a body opening with[autoland] verdict:(400). That gate is what makes the marker a trust anchor — without it a run token, which is repo-scoped and not confined to its own PR, could forge any verdict. The guard judges the body after sanitizing, because stripping an attribution line can promote a marker from line 2 to line 1. The sanitizer and secretscan see the body exactly as any comment write. The seam carries noRejectPull/ApprovePull: native review WRITES are dead by forge design (above), so those methods do not exist —Reviews()(the read),RerequestReview(the native ping), andCommentPullare the whole review-adjacent surface, andErrReviewRejectedremains the typed refusal for the ping. -
pr rerequestis the fix run's done-signal: comment first, then a best-effort human ping. It posts thefix-donecomment FIRST — the comment IS the signal, uniform on every binding — and THEN natively re-requests every reviewer whose latest verdict-bearing review is changes-requested (the forge ping that lights up a human reviewer's queue; the poster may re-request any reviewer with a prior review, verified againstIsValidReviewRequest). A ping failure is non-fatal: warn and exit 0, because the done-signal already landed. Symmetric withpr createas the AFK run's done-signal, and deliberately not PR-existence: a fix run works a claim whose PR already exists. -
Hybrid rejected-state: lander markers ∪ human native reviews. The loop's rejected-state folds BOTH sources — lander
rejectcomments and native changes-requested reviews from humans (the forge Review button works cross-identity and stays the human UX;Reviews()/pr view's review listing is the read for that half). Ownership rule: afix-doneclears a lander rejection — a fresh lander run re-validates — but a human rejection is cleared only by that human's new review, and the loop never merges over an outstanding human changes-requested. -
State-derived, poll-only (ADR-0015 stands). What to spawn next is a pure function of two already-polled facts: the PR's verdict state (marker comments plus native reviews) and the runs store. No webhooks, and no dispatch or queue message between the engine and a run — the same one-reaper, one-contract polling model every binding already runs under. (The engine that computes this is #181; this ADR pins the contract it reads, not its code.)
-
Lander verdicts map to forge actions. A clean PASS with
auto_mergeon → merge directly, no comment (the merge is the observable state). A PASS with CONCERNS, or a clean PASS withauto_mergeoff →pr approve— thepassmarker, CONCERNS below it, the forge-observable "validated, awaiting the operator's confirm"; the operator merges. A FAIL or a blocking CONCERNS →pr reject, the findings below therejectmarker. Trivial fixes the lander makes inline on the claim branch; substantive ones it bounces to a fix run rather than rewriting another run's work itself. The PASS/CONCERNS/FAIL rubric — and the checks verdict it folds in (ADR-0032) — is the validation core's, not the lander's to redefine. -
The attempt bound counts fix-run spawns per PR, default 2 — never rejection verdicts. A dead or crashing fix run posts no comment, so bounding on rejection count would let a fix run that never produces one respawn forever. Counting spawns makes every attempt — including the ones that die on the launch pad — burn down the budget.
-
Escalation is agent-executed and terminal. When the attempt bound is spent, an escalate-mode lander posts the digest comment, flips the issue to
ready-for-human, and sends the push notification — all on the run-token boundary. The engine itself never writes to a forge; keeping the escalation writes agent-side is what keeps that invariant intact. Re-entry after escalation is a manual, human decision. -
Per-repo settings, default OFF.
enabled,max_fix_attempts,auto_merge,lander_provider,lander_model, andlander_effort, all per-repo — autoland is opt-in, never a thing a repo gets by upgrading. A fix run resolves provider, model, and effort through the normal AFK chain (repo AFK override → global AFK override → base) like any AFK-kind run (amended by #189 — see Status); the lander runs underlander_provider, withlander_model/lander_effortas sibling strict per-spawn knobs added by #189. -
Fix-forward, not auto-requeue. A fix run is bounded re-engagement carrying new information — the rejection findings — onto the existing claim branch. That is categorically not the blind retry the avoid-list forbids: auto-requeue re-runs the same issue from scratch with nothing learned, and it stays rejected (twice over now). The distinction is the new information; a re-engagement carrying none is auto-requeue by another name.
-
Builtin autoland is feasible but deferred. The comment protocol removed the reason autoland was forge-only (native reviews). What gates the builtin binding now is mechanical: its
CommentPullistracker.ErrUnsupported(a lab-internal CR has no comment thread yet) and it has no PR-comment listing for the poller to read. Once builtin grows a CR comment write plus a comment listing — its own follow-up issue, not #181's scope — the verdict verbs, being generic compositions overCommentPull, work there with zero further changes. -
One validation core, shared by skill and lander. The definition of landable — the checks gate, the convention checks, the conflict policy, and the PASS/CONCERNS/FAIL rubric — lives in
assets/skills/land-pr/validation-core.md, seeded into every worktree with the land-pr skill (ADR-0012/0035) and referenced by both the interactive land-pr skill and the future lander seed template. One definition, one place, no drift between the human-confirmed path and the autonomous one.
Status¶
Accepted. Completes the deferral in ADR-0024's status line (the reject → re-queue loop and pr comment, now shipped as verdict-marker comments over pr comment's seam). Landed in three stages behind one contract: #180 the verdict verbs, the docs, and the validation core; #181 the state-derived poller and the lander (which must not enable a repo the poller cannot read); #182 the fix-forward loop and escalation — the fix run (kind fix, done-signal pr rerequest's fix-done marker), the spawn-count attempt bound, the escalate run (kind escalate, the escalate marker over the new pr escalate verb, outcome escalated as the poller's permanent-terminality gate), and the hybrid rejected-state fold (last-marker-word ∪ per-reviewer latest native review). Rides ADR-0014's run-token boundary (the verdict verbs join create | view | list | merge | checks | comment), keeps ADR-0015's poll-only model (no webhooks, no dispatch), and folds ADR-0032's checks verdict into the validation core's PASS/CONCERNS/FAIL rubric. Builtin autoland waits on builtin PR comments (own follow-up issue).
Amended by issue #189 (2026-07-20): the inheritance clause "A fix run inherits its authoring run's provider; the lander runs under lander_provider" — which #182 implemented over provider, model, AND effort (the latest afk_manual/afk_auto row's values, read via store.AuthoringRunForBranch, passed as per-spawn requests with a warn-and-fall-back ladder) — is superseded. A fix run now resolves provider, model, and effort through the NORMAL AFK chain (repo AFK override → global AFK override → base), exactly like every other AFK-kind run: the operator wants ONE AFK settings surface, not a second one shadowing it through the persisted authoring row. The reversal is by deletion, not rewiring — store.AuthoringRunForBranch, fixProvider, and the fix-launch fallback ladder are gone, and the persisted authoring run has no influence at all. The deliberate consequence: a branch authored by one provider may legitimately be fixed by another, because a fix run adopts its head branch DETACHED and nothing provider-specific carries over from the run that authored it. The lander gains two sibling per-repo knobs, lander_model and lander_effort — strict per-spawn overrides alongside lander_provider.
Amended by issue #188 (2026-08-03): the pin "Escalation is agent-executed and terminal. … Re-entry after escalation is a manual, human decision." and the Status line's outcome escalated as the poller's permanent-terminality gate are both superseded. Escalation-writing stays agent-executed and re-entry stays manual and human, but terminality stops being a permanent property of a branch or a PR and becomes a supersedable statement about a moment — "as of these N attempts, agents could not finish this" — because a human commit or a fresh set of instructions is new information that invalidates that statement exactly as a fix run's rejection findings invalidate a prior attempt. Re-entry is now two paths: the original human-run land-pr skill, and an explicit, human-only re-arm (an SPA action or the lab autoland rearm CLI verb — deliberately absent from the run-token labctl surface, since escalation means agents could not finish this and an agent able to lift its own terminal hand-off would make the bound decorative) that supersedes the escalation and, in the same atomic operation, zeroes the PR's spent fix/escalate attempt counters — clearing terminality while leaving the budget spent would re-escalate on the first rejection and read as a silent failure of the re-arm. Keeping re-arm an explicit gesture rather than an implicit trigger on a human push or an issue label flip is what keeps this fix-forward rather than auto-requeue: the gesture itself is the new information, exactly the distinction this ADR's fix-forward pin already draws. The gate itself moves from (repo, branch) to (repo, pull_number) (runs.pull_number, new), and autoland_attempts re-keys from (repo, branch, kind) to (repo, pull_number, kind) alongside it — the attempt-bound pin above, "The attempt bound counts fix-run spawns per PR, default 2," was always written per PR; #188 is the implementation catching up to the pin, not a change to it, since afk/<N> claim branches derive from the issue number and a requeued issue's brand-new PR was otherwise inheriting a discarded run's spent budget and terminality alike. Escalated run rows stay history, never deleted or rewritten — supersession, not erasure — and a fresh escalation after a re-arm is terminal again, indefinitely repeatable.
Considered options¶
- Native forge reviews as the lander's verdict channel. Rejected — verified impossible same-identity: the deployed Forgejo 15.0.5 unconditionally 422s approve ("approve your own pull is not allowed") and reject from the PR poster (
preparePullReviewType, no configuration escape), and every lab PR is authored by the single server-side forge-token account, so a lander's nativeapprove/rejectwould be inert on 100% of lab PRs. Human native reviews still feed the loop (hybrid pin above) — only the lander's writes moved to comments. - A second bot reviewer account for the lander's verdicts. Considered and rejected: a per-repo ops burden (a second credential provisioned, granted write, rotated, per autoland-enabled repo), it does nothing for the builtin binding, and the comment channel needs neither.
- Webhooks to deliver a rejection. Rejected: ADR-0015 pinned lab to polling — one reaper, one contract across every binding — and autoland reads the same forge state on the same ticks. A push model is a separate, larger change and buys nothing the poller cannot already see.
- A dispatch or queue message from the engine to a run. Rejected: the spawn decision is derived from the PR's verdict state plus the runs store, and that state survives a restart where an in-flight message would not. State-derived is re-entrant by construction; a message bus is one more thing to lose.
- Bounding on rejection count instead of fix-run spawns. Rejected: a fix run that dies or crashes posts no verdict, so a rejection-count bound never counts it and it respawns without end. The spawn is the thing that must be budgeted.
- Engine-executed escalation writes. Rejected: the engine never writes to a forge — the whole agent surface exists so that forge writes ride the run-token boundary (ADR-0014). An escalate-mode lander keeps the digest comment, the
ready-for-humanflip, and the push on that boundary. - Auto-requeue / blind retry of a rejected PR. Rejected — again. Re-running an issue from scratch with no new information is the runaway-cost nightmare the avoid-list has rejected twice; fix-forward differs precisely by carrying the rejection findings forward, and the blind form stays forbidden.
- Default-on. Rejected: autoland merges code without a human confirm, so it is per-repo opt-in — a repo turns it on deliberately, never by upgrading into it.