feat(autoland): per-repo settings + lander runs (auto-validate, auto-merge on clean PASS) #184

Merged
dominik.polakovics merged 2 commits from afk/181 into main 2026-07-20 15:18:09 +02:00

Closes #181

The autoland engine's first half, on ADR-0048's contract: per-repo settings and the lander run kind, so a clean AFK-authored PR lands with zero human touches.

What ships

Settings (migration 0012, store → reposvc → PATCH API → repo settings UI): autoland_enabled (default off), max_fix_attempts (default 2 — stored only, enforced by #182), auto_merge (default on), lander_provider (NULL = the repo's own provider chain). Cross-field invariant both directions: autoland_enabled can only be true on a forge binding (the builtin binding has no PR-comment listing for the poller to read), and the binding can't flip away from forge while it's on.

Lander run kind (migration 0013 widens the runs.kind CHECK — sqlite via a foreign-keys-off table rebuild, postgres via constraint swap): checks out the existing PR head branch into its own worktree (gitx.AddWorktreeExisting, hard-aligned to origin/<head>; launch rollback never deletes an adopted branch — it is the claim), fresh run credentials, seed prompt wrapping the embedded validation-core doc, AFK budget clock + token expiry verbatim, counts toward the three-strikes pause, neutral Stop parks. Startup/naming reconciliation learned lander labels so re-adoption never tears down a live lander's worktree.

Verdict plumbing: the marker grammar ([autoland] verdict: — first line, exact prefix) moved to its single code home internal/tracker/verdict.go (agentapi rewired, zero behavior change); the Tracker seam gained PullComments (forgejo with its un-paginated-endpoint quirk handled, github paginated, builtin ErrUnsupported, decorators + fakes updated).

Poller (AutolandOnce, riding the reaper tick right after ReapOnce — the reap frees the authoring AFK run's row first, so a fresh PR gets its lander within one tick): state-derived and poll-only per ADR-0015. Spawn rule is the pure, table-tested ShouldSpawnLander: autoland on ∧ forge-bound ∧ ready ∧ not paused ∧ under cap ∧ PR open ∧ head matches the claim pattern ∧ no live review ∧ no verdict marker ∧ no active run on the branch. Forge-read failures fail closed (skip the pull this tick).

Reaper classification: a lander's done-signal is the forge-observable state the run produced — PR merged, or a pass/reject marker (fix-done is a fix run's signal, never a lander's) — fed into the same v0-pinned Classify table; one bounded PullComments read per active lander per tick, unreadable comments skip classification rather than guess. The done-signal push stays AFK-only (its copy is "opened PR #n"; a lander opened nothing).

Acceptance criteria → proof

  • Settings exist per repo, default off, editable in the settings surface — TestRepoAutolandColumnsRoundTrip, TestRepoAutolandSettings, RepoSettings vitest block
  • Fresh AFK PR → lander within one poll tick; clean PR → approved + merged, no human — TestAutolandCycleIntegration/clean_PASS_with_auto_merge_merges (real sqlite/git/tmux/forgejo-client/agentapi/labctl seams) + TestReaperLoop_carriesAutolandSweep
  • auto_merge off (or CONCERNS) → approving review, no merge — .../auto_merge_off_ends_approved_and_unmerged (CONCERNS cap is the validation core's rubric, carried by the seed prompt)
  • Failing PR → rejection review with the findings — .../FAIL_ends_rejected_with_the_findings
  • Human-branch PRs / disabled repos never touched; live run suppresses — .../suppression_sweep_spawns_nothing, TestAutolandOnce_suppressions, TestShouldSpawnLander flip-one-term table
  • Budget clock + three-strikes + forge-observable classification — .../lander_death_strikes_and_a_paused_repo_stops_spawning, TestLanderDone, budget_deadline asserted on the spawned row

Decisions worth review

  • Clean PASS = pr approve then pr merge (the issue's spec + AC) rather than ADR-0048's merge-with-no-comment: the pass marker before the merge also keeps a done-signal on the PR if the merge fails transiently. One extra comment on the happy path.
  • "No review" is conservative: ANY non-dismissed native review suppresses a spawn (not just verdict-bearing ones). ADR-0048's hybrid rejected-state fold is #182's to refine.
  • Any verdict marker (any word) marks a PR non-virgin#181 spawns only on virgin PRs; marker-state transitions (fix-done clearing a lander rejection) are the fix-forward loop's.

Verification

gofmt clean · go build ./... · golangci-lint run (2.12.2, CI's pin): 0 issues · go test ./... green except the pre-existing environmental internal/tmuxx failures (identical on the unmodified base; the afk tmux-driven cycle suites pass here) · web: eslint, prettier, vitest 46 files / 869 tests, tsc --noEmit + vite build clean · migration parity + gapless-numbering tests green (0012 + 0013 in both dialect trees).

Closes #181 The autoland engine's first half, on ADR-0048's contract: per-repo settings and the lander run kind, so a clean AFK-authored PR lands with zero human touches. ## What ships **Settings** (migration 0012, store → reposvc → PATCH API → repo settings UI): `autoland_enabled` (default **off**), `max_fix_attempts` (default 2 — stored only, enforced by #182), `auto_merge` (default on), `lander_provider` (NULL = the repo's own provider chain). Cross-field invariant both directions: `autoland_enabled` can only be true on a forge binding (the builtin binding has no PR-comment listing for the poller to read), and the binding can't flip away from forge while it's on. **Lander run kind** (migration 0013 widens the `runs.kind` CHECK — sqlite via a foreign-keys-off table rebuild, postgres via constraint swap): checks out the existing PR head branch into its own worktree (`gitx.AddWorktreeExisting`, hard-aligned to `origin/<head>`; launch rollback never deletes an adopted branch — it is the claim), fresh run credentials, seed prompt wrapping the embedded validation-core doc, AFK budget clock + token expiry verbatim, counts toward the three-strikes pause, neutral Stop parks. Startup/naming reconciliation learned lander labels so re-adoption never tears down a live lander's worktree. **Verdict plumbing**: the marker grammar (`[autoland] verdict:` — first line, exact prefix) moved to its single code home `internal/tracker/verdict.go` (agentapi rewired, zero behavior change); the Tracker seam gained `PullComments` (forgejo with its un-paginated-endpoint quirk handled, github paginated, builtin `ErrUnsupported`, decorators + fakes updated). **Poller** (`AutolandOnce`, riding the reaper tick right after `ReapOnce` — the reap frees the authoring AFK run's row first, so a fresh PR gets its lander within one tick): state-derived and poll-only per ADR-0015. Spawn rule is the pure, table-tested `ShouldSpawnLander`: autoland on ∧ forge-bound ∧ ready ∧ not paused ∧ under cap ∧ PR open ∧ head matches the claim pattern ∧ no live review ∧ no verdict marker ∧ no active run on the branch. Forge-read failures fail closed (skip the pull this tick). **Reaper classification**: a lander's done-signal is the forge-observable state the run produced — PR merged, or a `pass`/`reject` marker (`fix-done` is a fix run's signal, never a lander's) — fed into the same v0-pinned `Classify` table; one bounded `PullComments` read per active lander per tick, unreadable comments skip classification rather than guess. The done-signal push stays AFK-only (its copy is "opened PR #n"; a lander opened nothing). ## Acceptance criteria → proof - Settings exist per repo, default off, editable in the settings surface — `TestRepoAutolandColumnsRoundTrip`, `TestRepoAutolandSettings`, RepoSettings vitest block - Fresh AFK PR → lander within one poll tick; clean PR → approved + merged, no human — `TestAutolandCycleIntegration/clean_PASS_with_auto_merge_merges` (real sqlite/git/tmux/forgejo-client/agentapi/labctl seams) + `TestReaperLoop_carriesAutolandSweep` - `auto_merge` off (or CONCERNS) → approving review, no merge — `.../auto_merge_off_ends_approved_and_unmerged` (CONCERNS cap is the validation core's rubric, carried by the seed prompt) - Failing PR → rejection review with the findings — `.../FAIL_ends_rejected_with_the_findings` - Human-branch PRs / disabled repos never touched; live run suppresses — `.../suppression_sweep_spawns_nothing`, `TestAutolandOnce_suppressions`, `TestShouldSpawnLander` flip-one-term table - Budget clock + three-strikes + forge-observable classification — `.../lander_death_strikes_and_a_paused_repo_stops_spawning`, `TestLanderDone`, budget_deadline asserted on the spawned row ## Decisions worth review - **Clean PASS = `pr approve` then `pr merge`** (the issue's spec + AC) rather than ADR-0048's merge-with-no-comment: the pass marker before the merge also keeps a done-signal on the PR if the merge fails transiently. One extra comment on the happy path. - **"No review" is conservative**: ANY non-dismissed native review suppresses a spawn (not just verdict-bearing ones). ADR-0048's hybrid rejected-state fold is #182's to refine. - **Any verdict marker (any word) marks a PR non-virgin** — #181 spawns only on virgin PRs; marker-state transitions (fix-done clearing a lander rejection) are the fix-forward loop's. ## Verification `gofmt` clean · `go build ./...` · `golangci-lint run` (2.12.2, CI's pin): 0 issues · `go test ./...` green except the pre-existing environmental `internal/tmuxx` failures (identical on the unmodified base; the afk tmux-driven cycle suites pass here) · web: eslint, prettier, vitest 46 files / 869 tests, `tsc --noEmit` + vite build clean · migration parity + gapless-numbering tests green (0012 + 0013 in both dialect trees).
feat(autoland): per-repo settings, lander run kind, and the state-derived poller
All checks were successful
ci / native (pull_request) Successful in 7m16s
edbb5ec7aa
The autoland engine's first half (ADR-0048): a repo opted in deliberately
gets its AFK-authored claim PRs validated and landed with zero human
touches.

- Settings, per repo beside the AFK scheduler config (migration 0012):
  autoland_enabled (default off, forge-binding cross-field invariant),
  max_fix_attempts (default 2, stored for #182), auto_merge (default on),
  lander_provider (NULL = the repo's own provider chain); surfaced in the
  repo settings UI.
- Lander run kind (migration 0013 widens the runs.kind CHECK): adopts the
  existing PR head branch into its own worktree (gitx.AddWorktreeExisting;
  rollback never deletes an adopted branch), fresh run credentials, seed
  prompt wrapping the embedded validation core, AFK budget clock and
  three-strikes accounting, neutral Stop parks the claim.
- Verdict grammar's single code home moves to internal/tracker (verdict.go);
  the Tracker seam gains PullComments (forgejo + github; builtin stays
  ErrUnsupported — hence the forge-only invariant).
- Poller: AutolandOnce rides the reaper tick after ReapOnce, spawning a
  lander for each virgin claim PR (open, head matches the claim pattern, no
  live review, no verdict marker, no active run on the branch) — ADR-0015
  poll-only, every decision a pure table-tested predicate.
- Reaper: lander done-signal is the state the run produced (PR merged, or a
  pass/reject marker) fed into the one Classify table; the done-signal push
  stays AFK-only.

Proven at three levels: decide tables, engine fixtures, and a full
TestAutolandCycleIntegration over real seams (sqlite, git, tmux, forgejo
client against a stateful fake forge, agentapi, labctl verdict verbs).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fix(autoland): adopt the PR head DETACHED so a parked claim can't wedge the poller
All checks were successful
ci / native (pull_request) Successful in 6m38s
cf072bb68f
The lander's adopt checked out the claim branch itself and hard-reset it to
origin/<branch>. Two defects fell out of holding that ref, both latent while
autoland_enabled defaults off:

- A parked claim worktree blocked the adopt forever. A dirty teardown keeps
  worktree AND branch (decideTeardown), as does an operator Stop, so afk/<N>
  stays checked out with the run row terminal. Every spawn predicate term
  then stayed true while `worktree add` refused the second holder: the poller
  respawned each tick with no backoff and no failure accounting, burning a
  fetch plus two forge reads per stuck PR until a restart. Nothing cleared
  it — the same unbounded forge-load shape #176 exists to remove.
- `reset --hard origin/<branch>` moved the BRANCH ref, not just the tree, so
  a success-path reap that parked committed-but-unpushed work (the case
  RemoveWorktree's own comment names) lost it silently.

The lander only needs the ref for its one committing step, so it stops taking
it: `worktree add --detach` at origin/<branch>, and the seed prompt pushes
`HEAD:refs/heads/<branch>` explicitly. A parked worktree becomes irrelevant
and a detached checkout cannot move a branch, so both defects go with it.
"Validates exactly what the forge sees" is unchanged — that was the point of
the reset, and origin/<branch> is now checked out directly. The adopt also
creates no local branch at all, which makes launch rollback's skip-the-delete
both safe and complete.

Also corrects three comments that asserted things the code did not do: the
agentapi verdict gate is NOT airtight (handleCommentCreate reaches the same
comment thread ungated — a known, accepted gap, now written down as one), the
lander worktree path's disjointness was never what unblocked a parked claim,
and idx_runs_outcome does not narrow ActiveRunOnBranch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/coding-lab!184
No description provided.