feat(autoland): fix-forward loop — fix runs on rejection, spawn-count bound, terminal escalation #187

Merged
dominik.polakovics merged 2 commits from afk/182 into main 2026-07-20 18:25:51 +02:00

Closes #182

The second half of the autoland loop: rejection re-engages an agent, bounded, with escalation as the terminal hand-off.

What shipped

  • Fix run trigger — the autoland gather now folds each open claim PR's verdict state (marker words + native reviews, DecideAutoland in decide.go) and spawns a fix run on rejected-state: a lander reject marker or a human's changes-requested review feed the same loop. State-derived only (ADR-0015); the StageFix rank reserved by #185 is now emitted.
  • Fix run mechanics — kind fix, detached adopt of the surviving afk/<N> claim, fresh run credentials, provider/model/effort inherited from the authoring run's persisted row (repo-chain fallback on a vanished provider), budget clock + three-strikes accounting apply, seed prompt carries issue + PR + the live rejection verbatim.
  • Fix run contract — pushes to the existing branch by explicit refspec, never pr create; done-signal is labctl pr rerequest's fix-done marker. Re-requested + no live run → the poller spawns the re-validation lander (approve-only while a human rejection is outstanding — the loop never merges over one).
  • Attempt boundmax_fix_attempts counts fix-run SPAWNS per branch (runs store), so a fix run that dies on the launch pad burns an attempt instead of respawning unbounded.
  • Escalation — at the bound, an escalate run (kind escalate, lander chain) writes the round-history digest to the issue, flips ready-for-agentready-for-human, and posts the new labctl pr escalate marker (agent-executed; the engine never writes to the forge). The reaper maps that delivery to outcome escalated (migration 0014 widens both runs CHECKs) and fires the escalation push.
  • Terminal state — an escalated-outcome run row keeps the PR permanently invisible to the poller, even if the marker comment is deleted; re-entry is the interactive land-pr skill only.
  • Done-signals moved to last-word marker semantics so re-validation rounds never instant-reap on a stale round-1 marker; sound without timestamps because the active-run-per-branch gate serializes marker writers.
  • Vocabulary joined everywhere it surfaces: reconcile label parsing (restart re-adopts live fix/escalate worktrees), metrics (lab_instances_active kinds, run-outcome labels), SPA History (kind labels, escalated outcome chip), CONTEXT.md Escalation entry, ADR-0048 status.

Acceptance criteria

All six verified — unit/producer/reaper coverage plus two new end-to-end cycle tests (TestAutolandCycleFixForwardIntegration, real tmux + real labctl + real agentapi against the scripted forge): the converging loop (reject → fix → rerequest → re-validate → merge, with the fix commit being what merges) and the bound (reject ×2 → escalate: digest on the issue, label flip, marker, escalated row, exactly one push, poller silence afterward — including after the marker comment is deleted).

Verification

go build ./..., go vet ./..., gofmt clean; go test ./... green except internal/tmuxx's integration tests, which fail identically on pristine main in this sandbox (pane default-shell exits immediately — environment-only; CI's native job covers them). SPA: lint, format:check, vitest (869 tests), tsc + build all green.

Closes #182 The second half of the autoland loop: rejection re-engages an agent, bounded, with escalation as the terminal hand-off. ## What shipped - **Fix run trigger** — the autoland gather now folds each open claim PR's verdict state (marker words + native reviews, `DecideAutoland` in decide.go) and spawns a fix run on rejected-state: a lander `reject` marker or a human's changes-requested review feed the same loop. State-derived only (ADR-0015); the `StageFix` rank reserved by #185 is now emitted. - **Fix run mechanics** — kind `fix`, detached adopt of the surviving `afk/<N>` claim, fresh run credentials, provider/model/effort inherited from the authoring run's persisted row (repo-chain fallback on a vanished provider), budget clock + three-strikes accounting apply, seed prompt carries issue + PR + the live rejection verbatim. - **Fix run contract** — pushes to the existing branch by explicit refspec, never `pr create`; done-signal is `labctl pr rerequest`'s fix-done marker. Re-requested + no live run → the poller spawns the re-validation lander (approve-only while a human rejection is outstanding — the loop never merges over one). - **Attempt bound** — `max_fix_attempts` counts fix-run SPAWNS per branch (runs store), so a fix run that dies on the launch pad burns an attempt instead of respawning unbounded. - **Escalation** — at the bound, an escalate run (kind `escalate`, lander chain) writes the round-history digest to the issue, flips `ready-for-agent` → `ready-for-human`, and posts the new `labctl pr escalate` marker (agent-executed; the engine never writes to the forge). The reaper maps that delivery to outcome `escalated` (migration 0014 widens both runs CHECKs) and fires the escalation push. - **Terminal state** — an escalated-outcome run row keeps the PR permanently invisible to the poller, even if the marker comment is deleted; re-entry is the interactive land-pr skill only. - Done-signals moved to **last-word marker semantics** so re-validation rounds never instant-reap on a stale round-1 marker; sound without timestamps because the active-run-per-branch gate serializes marker writers. - Vocabulary joined everywhere it surfaces: reconcile label parsing (restart re-adopts live fix/escalate worktrees), metrics (`lab_instances_active` kinds, run-outcome labels), SPA History (kind labels, `escalated` outcome chip), CONTEXT.md **Escalation** entry, ADR-0048 status. ## Acceptance criteria All six verified — unit/producer/reaper coverage plus two new end-to-end cycle tests (`TestAutolandCycleFixForwardIntegration`, real tmux + real labctl + real agentapi against the scripted forge): the converging loop (reject → fix → rerequest → re-validate → merge, with the fix commit being what merges) and the bound (reject ×2 → escalate: digest on the issue, label flip, marker, `escalated` row, exactly one push, poller silence afterward — including after the marker comment is deleted). ## Verification `go build ./...`, `go vet ./...`, gofmt clean; `go test ./...` green except `internal/tmuxx`'s integration tests, which fail identically on pristine main in this sandbox (pane default-shell exits immediately — environment-only; CI's native job covers them). SPA: lint, format:check, vitest (869 tests), tsc + build all green.
feat(autoland): fix-forward loop — fix runs on rejection, spawn-count bound, terminal escalation
All checks were successful
ci / native (pull_request) Successful in 6m31s
01bcf7f067
The autoland gather (internal/afk/autoland.go) now emits every autoland
candidate: DecideAutoland (decide.go) folds a claim PR's verdict state — the
marker stream via LastVerdictWord, the hybrid human half via HumanRejected
(per-reviewer latest verdict-bearing native review) — into one per-PR action.
A virgin PR gets its round-1 lander (#181 unchanged); fix-done gets the
re-validation lander, approve-only under an outstanding human
changes-requested; rejected-state gets a fix run while fix spawns-ever <
max_fix_attempts and the terminal escalate run at the bound; an escalated PR
gets nothing, forever. Case order is load-bearing: the re-validation round
outranks the fix trigger so a fix run's fix-done done-signal is never already
true at spawn.

A fix run (kind 'fix', label fix-<N>) adopts the claim branch DETACHED,
inherits the authoring run's persisted provider/model/effort (warn +
repo-chain fallback keeps the loop alive), carries the live rejection
verbatim below its seed prompt as the work order, and ends with `labctl pr
rerequest` — the fix-done marker is its done-signal (FixDone), so the
attempt bound counts SPAWNS and a fix run that dies on the launch pad burns
one instead of respawning unbounded. Fix outcomes feed the three-strikes
counter like the AFK kinds; lander and escalate move it in neither direction.

An escalate run (kind 'escalate', lander chain) writes the round-history
digest to the linked issue, flips ready-for-agent → ready-for-human, and
posts the new `labctl pr escalate` verdict marker LAST; the reaper promotes
that delivery to outcome 'escalated' (migration 0014 widens both runs
CHECKs), fires the escalation push, and the escalated row keeps the PR
permanently invisible to the poller even if the marker comment is later
deleted — re-entry is the interactive land-pr skill alone. Every verdict
done-signal moved to last-word semantics (LanderDone rework) so
re-validation rounds never instant-reap on a stale round-1 marker; the
active-run-per-branch gate is what makes marker order sufficient without
timestamps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fix(autoland): bound the escalate arm, and burn attempts on spawn intent
All checks were successful
ci / native (pull_request) Successful in 6m42s
ef284fdda9
Two unbounded respawn loops in the fix-forward loop, both from the same
root cause: the attempt bound counted runs ROWS, which record a spawn that
reached a live session — not a spawn that was attempted.

1. A fix launch that failed on the launch pad burned no attempt. Start's
   rollback deletes the run row on every failure after CreateRun, and the
   failures before it (a stale worktree at the fix label, a seeding failure,
   a secrets read) never write one. A deterministically-failing launch
   therefore retried every tick forever, never reaching escalation — the
   exact unbounded respawn max_fix_attempts exists to prevent, and a miss
   against #182's acceptance criterion 3.

2. The escalate arm had no bound at all. Terminality is written only once
   the escalate run POSTS its marker, so an escalate run that died first
   left the PR rejected-at-bound and was re-escalated every tick. Nothing
   braked it: reapRun excludes escalate from the consecutive-failure counter
   in both directions, and the candidate sorts at StageFix — ahead of new
   AFK work under drain-before-fill — so one wedged branch would starve its
   repo's new-work pipeline indefinitely.

Both are fixed by counting spawn INTENTS in a table of their own
(autoland_attempts, migration 0015, keyed on repo+branch+kind), incremented
at autolandLaunch — the pass's single launch chokepoint — for the bounded
kinds. ErrOverCap is checked first and never burns: at cap the pass never
reached the launch pad. Every other outcome burns, success and launch-pad
failure alike. FixRunCountForBranch is replaced by AutolandAttempts.

The escalate arm gains MaxEscalateAttempts (3): escalation is one comment,
one label flip and one marker, so three failures are systemic and retrying
is not the fix. Past the bound the poller goes quiet on the PR and logs at
error level, since nothing downstream would otherwise say so; the PR keeps
its rejected state and the interactive land-pr skill is still a way in.

Regression tests: TestSpawnOnce_failedFixLaunchBurnsAttempt (launch fails
with no surviving row, attempt still burns, next pass escalates) and
TestSpawnOnce_escalateArmIsBounded (three undelivered escalate rounds, then
silence). Both verified to fail against the unfixed code. CONTEXT.md's
duplicate Escalation glossary entry — two conflicting definitions from
#180 and #182 — is collapsed into one while documenting the new bound.

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!187
No description provided.