Skip to content

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 the reject marker with the findings below; pr approve <n> [body] posts pass (CONCERNS as the body); pr rerequest <n> posts fix-done. Injection happens in the agentapi handlers (precedent: pr create's Closes #N injection) as generic compositions over the seam's CommentPull — agents never see or type the grammar, and a verdict verb's own body lands below line 1 and is inert. The plain pr comment verb 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 no RejectPull/ApprovePull: native review WRITES are dead by forge design (above), so those methods do not exist — Reviews() (the read), RerequestReview (the native ping), and CommentPull are the whole review-adjacent surface, and ErrReviewRejected remains the typed refusal for the ping.

  • pr rerequest is the fix run's done-signal: comment first, then a best-effort human ping. It posts the fix-done comment 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 against IsValidReviewRequest). A ping failure is non-fatal: warn and exit 0, because the done-signal already landed. Symmetric with pr create as 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 reject comments 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: a fix-done clears 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_merge on → merge directly, no comment (the merge is the observable state). A PASS with CONCERNS, or a clean PASS with auto_merge off → pr approve — the pass marker, 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 the reject marker. 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, and lander_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 under lander_provider, with lander_model/lander_effort as 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 CommentPull is tracker.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 over CommentPull, 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 native approve/reject would 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-human flip, 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.