Skip to content

Landing queue runbook (ARGUS-342)

The serial rebase -> CI -> merge state machine itself lives in internal/hub/landing (split out of internal/hub by ARGUS-642); the HTTP surface (internal/hub/landing_api.go), CI-failure diagnosis adapter (internal/hub/landing_diag.go), and Forgejo forge calls (internal/hub/landing_forge.go) stay in internal/hub. Routes: POST /landing/{owner}/{name}/ready (enqueue/re-signal), GET /landing/{owner}/{name}/status (one repo's queue: in_flight, queued, recent -- the last 5 terminal entries repo-wide), DELETE /landing/{owner}/{name}/ready/{pr} (withdraw a queued-but-not-yet-in-flight entry), POST /landing/{owner}/{name}/rerun-ci/{pr} (see below), GET /landing/stats.json (aggregate counters).

GET /landing/{owner}/{name}/status?task_id=<id> (ARGUS-1150) answers that one task's FULL landing_queue history for the repo instead of the repo-wide view: {"task_id": "<id>", "history": [<LandingEntry>, ...]}. The plain recent list above is capped at the last 5 terminal entries repo-wide, so a task whose attempt lands amid a burst of other PRs can scroll out of it -- this query param retrieves that task's own entries directly rather than requiring an operator to infer whether/how a task landed from deploy timing.

Authority contract (ARGUS-910)

Every mutation route above sits behind the SAME single credential tier: the capture bearer (requireBearer(reportToken) in server.go). There is deliberately no separate "admin" tier reachable over this API -- whatever a caller can do to the queue, every agent holding the shared bearer can already do.

action trigger identity condition audit receipt
ready POST .../ready any bearer holder repo registered landing_queue row + queue_position
retry POST .../rerun-ci/{pr} any bearer holder entry failed, repo not busy landing_queue row re-armed at ci
withdraw DELETE .../ready/{pr} any bearer holder entry status=queued or failed (not in-flight) landing_queue row -> withdrawn
rebase SYSTEM ONLY (stageRebase, the worker loop) the hub's own Forgejo token entry status=rebasing landing_queue row head_sha update
merge SYSTEM ONLY (stageMerge, the worker loop) the hub's own Forgejo token status=merging AND CI green landing_queue row -> landed

Withdrawing a failed entry (ARGUS-1418) acknowledges/clears it, same as removing a still-queued one -- an in-flight entry still answers 400 (mid-operation).

rebase and merge are performed exclusively by the queue's own worker (ProcessRepo in internal/hub/landing/landing.go), using the hub's own Forgejo token -- a caller-supplied credential never reaches LandingForge. POST /landing/{owner}/{name}/merge and .../rebase exist on the HTTP surface ONLY as denial endpoints (handleLandingDirectAction): every call to either is refused unconditionally with a 403, regardless of body or queue state. This is the structural guardrail against the literal-minded-merge failure class identified in the harness-engineering evaluation (run 1566): an agent told to "merge" a PR must never find an admin/direct bypass that satisfies the verb while skipping the queue's CI gate. TestLandingDirectMergeBypassDenied in internal/hub/landing_api_test.go proves the refusal and that the PR is never actually merged by the attempt.

Every refusal from a landing route is a denial, not a bare failure. The JSON body always carries error (back-compat text), denied_reason (the violated condition, same text as error), and safe_next_action (the exact call that resolves it the sanctioned way) -- so a caller is never left without a path back into the queue that doesn't involve going around it. Direct merge/rebase denials are also logged via slog.Warn ("landing: direct action denied (authority contract, ARGUS-910)", with action, repo, pr, task, remote) as the audit receipt for that attempt.

Opening the PR that feeds the queue (ForgeSource.OpenPR)

Before an entry exists at all, the runner pushes refs/heads/<branch> over SSH and calls the hub's POST /pulls/open (the hub holds the Forgejo token the runner box lacks, ARGUS-382) to open the PR that POST /landing/{owner}/{name}/ready then enqueues. OpenPR (internal/hub/landing_forge.go) is idempotent per head branch (ARGUS-582): a resumed builder re-opening a PR for a branch that already has one no longer hits Forgejo's raw 409 --

  • An existing open PR for that head is reused, and (ARGUS-1488) PATCHed with the caller's current title/body -- a stray-PR correction re-POST is never silently discarded.
  • An existing closed-unmerged PR for that head is reopened (the branch advanced, so its new commits belong on the same PR, not a duplicate) and likewise PATCHed with the caller's current title/body.
  • An existing closed-merged PR for that head returns ErrPRAlreadyMerged naming the merged PR (ARGUS-1149, PR #819 incident) instead of falling through to create -- Forgejo happily opens a brand-new PR against an already-merged head regardless of history, and when the branch carries no new commits since the merge that new PR is a zero-diff duplicate that auto-queues into landing with nothing to land. Callers must push a fresh branch or edit the merged PR directly; OpenPR never guesses which. POST /pulls/open surfaces this as 409 ("open pr failed: ...", naming the merged PR) rather than the 502 a genuine forge failure gets -- a caller must not retry the same call on a 409.
  • No match falls through to a normal create; reused=true on the reuse/reopen paths, false for a freshly created PR.

OpenPR returns (number, url, reused, persistedBody, err) -- persistedBody (ARGUS-1488) is read back from Forgejo's own PATCH/POST response, never assumed to equal what the caller sent, and is what openAndQueuePR's Closes-marker auto-queue check inspects.

PR evidence at land time (AttachPREvidence, internal/hub/landing_evidence.go) -- ARGUS-1662

AttachPREvidence runs at merge time to attach a pr evidence row for the landing queue's bound task_id. As of ARGUS-1662 it satisfies the row only when the merged PR's own title/body genuinely closes the bound task (closesReference, autolink.go); a non-closing landing (a Partial: declared-partial marker, ARGUS-1051; a bare Refs ARGUS-N no-close binding) creates the row but leaves it unsatisfied. The old behavior -- force satisfied=true unconditionally from landing_queue.task_id, trusting it as "a fact, not a guess" -- is gone: the queue also binds task_id for entirely honest non-closing shapes, and the self-heal (verify sweep) is not guaranteed to run before something else observes the wrongly-satisfied row (exactly what verified a Phase-0 FEATURE off a docs-only Partial: landing while three of its four child tasks were still unbuilt, observed 2026-08-29).

A genuinely closing landing (the common case: the entry's own task_id came from a real Closes ARGUS-N the builder wrote) still attaches satisfied immediately, unchanged. prs nil, or its PR-body fetch erroring, fails CLOSED: the row is still created (idempotent, checker-attributed) but left unsatisfied -- a later verify sweep's own live poll re-derives the true state rather than this call ever force-satisfying on unconfirmed data. Idempotent with itself and with AutoLinkPRs (an existing row for the same repo+PR is left alone); an empty task_id (a landing signaled without one) is a clean no-op.

The deploy_log side of the same hole was closed in the same commit: ProcessLandingHostCheckouts and the docker/CI deploy-hook loops in cmd/hub/main.go no longer attach ANY deploy_log evidence (satisfied or not) to a landing's bound task_id when that landing doesn't actually close it -- even the unsatisfied "hold" path is unsafe, since a later unrelated deploy of the same hook satisfies every outstanding row for it, project-wide.

Landing-queue binding markers (closesBindingSatisfied)

The landing-queue signal's pre-check (closesBindingSatisfied in internal/hub/landing_api.go) accepts three line-start markers in the PR body:

  • Closes ARGUS-N (or any line containing Closes ARGUS-N) -- a genuine closing binding; the PR is expected to complete the task.
  • Partial: ARGUS-N (line starts with Partial:) -- a declared-partial slice (ARGUS-1051); the PR is bound to the task but explicitly does not complete it.
  • Refs ARGUS-N (line starts with Refs) -- an explicit no-close binding (ARGUS-1662); a doc-only PR that closes nothing no longer has to borrow the more ambiguous Partial: marker just to get a safe id to bind to.

A POST /landing/{owner}/{name}/ready call whose PR body carries none of these markers and no matching Closes line gets a 400 naming the task id the queue cannot bind to.

Posting the argus/acceptance gate status (PostStatus)

PostStatus (internal/hub/landing_forge.go, ARGUS-1282) lets ForgejoCISource double as a StatusPoster: POST /repos/{repo}/statuses/{sha} {"context","state","description"}, the write side of the same combinedCommitState read CombinedStatus uses. It sends no target_url -- the hub has no per-PR web view for the acceptance gate to link to today. This is what posts the argus/acceptance required commit status onto every open PR's head sha in registry repos; see docs/runbooks/argus-acceptance-required-context.md for the full contract (required-context branch protection, break-glass removal, live proofs). The landing queue itself never calls PostStatus directly -- it only ever reads the resulting combined status via CombinedStatus/CheckRollup, same as any other required check.

When the "MAIN IS RED after landing" page fires

The post-merge net detected the one failure the serial queue cannot prevent: two changes each green alone, broken together. The page names the landing (repo, PR, task, merge sha).

Superseded-by-cancellation check (ARGUS-1968). Before treating a combined "failure" state on a merge sha as a genuine post-merge break, WatchPostCI first checks whether the failure is actually a cancellation caused by a newer landing's push CI cancelling the older merge's still-running jobs via the shared concurrency group on main. StatusContext.Description (Forgejo's free-text explanation of a context's state) carries the cancellation text ("Has been cancelled") on every cancelled job. A combined failure is classified as superseded -- not real -- only when EVERY failing context's Description carries that cancellation text; a genuine failure sitting next to an unrelated cancellation still counts as real. When superseded, WatchPostCI falls back to consulting main's CURRENT head (via the same base-branch name CombinedStatus/ListMigrations already accept as a ref) instead of trusting the now-stale merge sha: green resolves quietly (no page, no alert), failing still pages (off the resolved current-head sha, via the newly wired ResolveRef, not the stale one), and still-pending leaves the entry unresolved for the next sweep rather than guessing. This closed the 2026-09-10/11 incident where three CRITICAL post_merge_break pages fired while main was actually healthy throughout.

Manual revert procedure (deliberately NOT automated yet -- measure first, see /landing/stats.json):

  1. Confirm on the Forgejo commit status page for the named merge sha.
  2. Revert: git revert -m 1 <merge sha> on a branch, PR it, and land it through the queue (POST /landing/{owner}/{name}/ready) -- the revert goes through the same door as everything else.
  3. Cancel the reverted task: POST /tasks/{id}/lifecycle {"to":"cancelled", ...} and note the break in its why via PATCH. Do NOT send "to":"ready" -- ARGUS-719 added a hard gate (a bulk lifecycle change twice resurrected a VERIFIED task back into the dispatchable pool, 2026-07-16/17) that refuses any lifecycle transition except cancelled on a task whose verified flag is still true, and the original PR's own evidence (merge + CI-green) does not retroactively flip just because a later commit reverts it on main -- so the task is very likely still verified=true at this point and a "to":"ready" call 400s. cancelled is the explicit un-do path the checker leaves open for exactly this case. File a fresh follow-up task for whatever work replaces the reverted change; don't try to resurrect the cancelled one.
  4. The counter already recorded the break (post_state=failure). If /landing/stats.json shows post_merge_breaks trending past ~1/month, revisit the bors batching tier (artifact #87's upgrade trigger).

A post_merge_break (critical) alert fires alongside the page and needs no manual clear: it auto-resolves the next time the SAME repo lands with a green post-merge observation -- not necessarily the landing that fixed the break (TestPostMergeNet, internal/hub/landing_integration_test.go).

Other failure states

An entry's detail field carries the human-facing reason; Stats/ StatsSince key their failure-kind counters on detail LIKE 'rebase conflict%' / 'CI failed%' prefixes, so these strings are exact, not paraphrased:

  • rebase conflict -- resolve on the branch and re-signal ready -- plain rebase conflict. As of ARGUS-687, the queue first dispatches ONE bounded cheap-model auto-resolution run per entry before failing (detail reads rebase conflict -- dispatched a cheap-model resolution run (agent run <id>) while that's in flight); only if that attempt fails, or the resolution skill has no runner to claim it, does the entry land in this terminal state (rebase conflict -- automatic resolution did not clear it; resolve on the branch and re-signal ready). Either way, remediation is the same: resolve on the branch, push, re-signal ready.
  • CI failed on the rebased head <sha> -- the entry's failing_check/excerpt (ARGUS-572, best-effort via ForgeSource) usually names the red check and a log excerpt right there, so check those before going to Forgejo. failure_evidence may also carry a step=/run_id=/job_id= block (CIFailureDiagnoser's third return value, jobEvidenceExtra in internal/hub/landing_diag.go, ARGUS-1494) naming the exact CI job/step it resolved -- check that too. This detail also fires a ci_red (warning) alert that needs no manual clear -- it auto-resolves on the same repo's next successful landing (TestCIRedAutoResolvesOnNextLanding, internal/hub/landing_integration_test.go). Before assuming a real red: ARGUS-645 already ran this failure past classifyCIFailure and auto-retried it ONCE via a CI re-kick if it matched a known transient shape (a Go test-timeout panic, a runner/infra blip, or a 429/rate-limit) -- so a CI failed entry has already survived one automatic retry when it reaches you. If you still suspect a flake (the excerpt looks transient but didn't match the regexes, or Forgejo is just serving a stale cached combined-status for an unchanged sha), POST /landing/{owner}/{name}/rerun-ci/{pr} (ARGUS-646) re-arms the same failed entry and forces a fresh CI run on its current head WITHOUT requiring a new commit -- cheaper than faking a commit by hand. Otherwise: fix, push, re-signal ready.
  • branch kept moving during CI (N attempts) -- stop pushing mid-landing; re-signal when the branch is final. Bounded at 3 attempts (landingMaxAttempts).
  • CI did not finish within 45m0s -- CI-stage timeout backstop. Check whether CI is actually wedged on Forgejo (a stuck runner) before re-signaling; re-signaling alone won't help if the runner itself is dead.
  • rebase did not complete within 10m0s (persistent forge error) -- rebasing-stage timeout backstop (ARGUS-380): the Forgejo rebase API call itself kept erroring. Check Forgejo's health before re-signaling.
  • PR closed without merging -- someone closed the PR while it was queued/in-flight. Reopen it and re-signal ready, or abandon it.
  • merge did not take (merged=false on verify) (empty failure_class) -- the merge call itself reported success (or at least didn't error) but the post-call verify read still shows merged=false: a genuine forge race or lie, with no error text to attribute it to. Check the PR's actual state on Forgejo; re-signal ready if it's still open and mergeable.
  • merge refused: <forge error> (failure_class deterministic:merge-refused) -- ARGUS-1392: the merge call itself errored (branch protection blocking on a required status context, a merge conflict Forgejo caught server-side, etc.) AND the verify read confirms it never merged. detail and failure_evidence both carry the forge's own error text verbatim, so the actual refusal reason is on the row instead of only in a slog.Warn line an operator has to go dig up separately (the JANUS-18 drill hit exactly this: janus-smoke PR6 no-opped twice as the anonymous merged=false state above before anyone noticed the real cause was a dead required status context, orphaned by the shadow cutover -- see "janus-smoke branch protection" below). Read failure_evidence for the exact forge response, fix the actual cause it names (branch protection setting, conflict, etc.), and re-signal ready.
  • Deploy failed (page from ARGUS-343): the task is HELD unverified; fix the deploy cause and re-enqueue by landing any follow-up PR, or run the hook manually (~/.argus/deploys/<hook>) and satisfy via a re-sweep.

janus-smoke branch protection: dead janus/queue context (ARGUS-1392, JANUS-18)

runonyourown/janus-smoke's branch protection still lists janus/queue as a required status check -- a context orphaned by the janus shadow cutover that nothing posts anymore. This is INTENTIONAL, not an oversight: the JANUS-18 drill deliberately restored the repo to its as-found state after exercising it, and removing the dead requirement is a Forgejo branch- protection admin change outside anything the hub or a build box can reach headlessly (no generic branch-protection endpoint exists on either the Forgejo REST API surface the hub proxies or LandingForge). Any hub landing attempt against janus-smoke will surface as deterministic:merge-refused above until either (a) the dead context is removed by hand in Forgejo's branch protection settings, or (b) a non-shadow janus phase returns and starts posting janus/queue again. Do not spend a retry chasing this as a bug -- it is expected and tracked here.

Not a failure state, but landing-adjacent: internal/hub/landing_path_collision.go fires only on a fresh Ready() call and, when two live PRs in the same repo's queue add a colliding new path, writes an advisory finding -- it never blocks, retries, or fails a landing entry itself. It surfaces exclusively as a review-kind item in the inbox feed (GET /inbox.json, see docs/reference/api-conventions.md's Inbox feed section), not through any /landing/* endpoint.

Janus shadow-diff classifier (GET /landing/divergence) -- ARGUS-1269

internal/hub/landing_divergence.go is the measurement half of the janus shadow diff, entirely separate from the landing queue's own state machine above: ARGUS-1268 captures janus's timestamped opinions into janus_shadow_events; internal/hub/landing_divergence_sweep.go (see its own subsection below) joins them against landing_queue (the incumbent's ground truth) into landing_divergences (migrations/0228) -- one row per decision point, disagreement or agreement alike (see D0 below). Deliberately minimal HTTP surface per the phase-6 plan: ONE read endpoint (no dashboard yet -- the parked ARGUS-1235 UI renders this JSON when it un-parks) plus an adjudication action reusing the existing human-only review tier.

Two decision kinds are classified independently: landing (should THIS PR have landed -- janus's would_merge/would_hold opinion vs. the incumbent's landing_queue outcome for a landed PR) and halt (should the queue have stopped landing after main went red post-merge -- janus's would_halt opinion vs. whether the incumbent kept landing anyway; it always does today, onPostRed only pages).

Classes (migrations/0228's CHECK constraint, widened by migrations/0261_landing_divergences_d0_agreement.sql to admit D0):

class meaning
D0 plain agreement -- persisted explicitly since ARGUS-1476 (previously represented by the row's absence; see below).
D1 janus's blessed SHA differs from what the incumbent actually landed -- critical.
D2 same SHA, janus held, incumbent landed it anyway -- janus over-conservative.
D3 janus's opinion formed after the incumbent acted but still agrees -- benign, order-only.
D4 no janus opinion exists, or the pair falls inside the clock-skew window with inconclusive ordering -- taints the sample.
D5 post-merge main red: janus would halt, the incumbent kept landing.

D0 rows (ARGUS-1476). Before this change, plain agreement got no row at all, so class_counts/d4_percent only ever summarized the divergent side of the sample -- a repo agreeing 52/53 times read as "100% D4" if that one pair happened to be a genuine gap. classifyLandingCandidate/ classifyHaltCandidate (landing_divergence_sweep.go) now turn an empty classifyDivergence result ("no divergence") into an explicit, persisted D0Agreement row instead of skipping the write, so class_counts/ d4_percent are honest aggregates over the whole sample, not just its divergent slice. d4_percent/d4_percent_windowed stay all-time, all-repo, and drive only the phase-6 D4<=2% cutover gate -- agreement_rate (from the ShadowScorecard below, scoped to the shadow repo and a trailing 7-day window) is the field to read for "how well is janus doing," not d4_percent.

A decision pair within landingDivergenceSkewWindow (90s, tunable via WithSkewWindow) of each other is classified by direct content comparison rather than by which side decided first (ARGUS-1304 -- two independently polling/webhook-driven systems reacting to the same trigger routinely decide seconds apart, and that used to sink every such pair into D4 regardless of whether the opinions actually agreed). Outside the skew window, janus must have formed its opinion BEFORE the incumbent acted to count as agreement; an opinion that arrives later but still matches is D3, not agreement.

D4 rows carry a detail.reason: a genuine in-window gap ("janus never saw it"), pre_shadow (the landing predates any janus_shadow_events row for that decision kind -- observation was never structurally possible), or repo not observed by janus shadow (ARGUS-1330 -- a repo janus's single-repo shadow instance was never pointed at, e.g. runonyourown/argus-app; the shadow instance only ever watches defaultJanusShadowRepo, runonyourown/argus). class_counts (all-time) stays inclusive of every D4 regardless of reason; class_counts_windowed excludes both structurally-unobservable reasons so the phase-6 D4<=2% cutover gate isn't permanently diluted by history it could never have measured; d4_by_reason (ARGUS-1330) breaks the all-time D4 total out by reason for the UI split.

Who was RIGHT (the adjudication axis) is a separate, orthogonal question the classifier never answers itself -- adjudication starts pending and only a human (or a deterministic post-hoc check using the same credential) sets it via the endpoint below.

  • GET /landing/divergence (bearer, same requireBearer(reportToken) tier as the other /landing/* routes) returns every row plus server-computed aggregates, including a ShadowScorecard (ARGUS-1476, scoped to defaultJanusShadowRepo and a trailing landingDivergenceScorecardWindow window, 7 days) promoted onto the same top-level object:
{
  "rows": [ ... ],
  "class_counts": {"D0": 52, "D1": 0, "D2": 3, "D4": 61},
  "total": 116,
  "d4_percent": 52.6,
  "class_counts_windowed": {"D0": 52, "D2": 3, "D4": 2},
  "total_windowed": 57,
  "d4_percent_windowed": 3.5,
  "d4_by_reason": {"repo not observed by janus shadow": 59, "janus never saw it": 2},
  "repo": "runonyourown/argus",
  "window_days": 7,
  "observed_landings": 53,
  "agreements": 52,
  "agreement_rate": 98.1,
  "observed_landings_all_time": 187,
  "agreements_all_time": 179,
  "agreement_rate_all_time": 95.7,
  "real_divergences": {"D1": 1, "D2": 0, "D3": 0},
  "d4_bookkeeping": {},
  "shadow_clock": {"observed": 187, "target": 300}
}

observed_landings_all_time/agreements_all_time/agreement_rate_all_time (ARGUS-1678) are observed_landings/agreements/agreement_rate's companions with no window predicate at all -- same repo, same "landing" decision kind, same underlying rows. Twice on 2026-08-29 the windowed-only figures read to Aaron as data loss whenever a busy day's rows aged out of the 7-day window, even though the underlying rows only ever grew; these three give the app an honest all-time figure to render alongside the windowed one instead of misreading a shrinking window as data loss. The existing windowed fields are unchanged in name and meaning. observed_landings_all_time is always equal to shadow_clock.observed (both are the same all-time, all-class count of "landing" decision points for the shadow repo) -- kept as a separate top-level field rather than pointing callers at shadow_clock because it belongs next to its windowed sibling for pairing, not nested under a different concept (the 300-landing graduation counter).

shadow_clock tracks the phase-6 graduation counter (shadowClockTarget, 300 observed landings). 503 if no DivergenceStore is configured (WithDivergenceStore not wired in). - POST /landing/divergences/{id}/adjudicate (the separate human-only ARGUS_APPROVE_TOKEN tier, same boundary as task Approve/reject and live_check review) -- body {"adjudication": "janus_correct"|"janus_wrong"|"inconclusive"}. actor is derived server-side from the authenticated credential, never from the request body. 400 on an invalid adjudication value, 404 if the row doesn't exist.

DivergenceStore.PendingReview (the D1/D2/D5 rows still adjudication: pending) is what a future review surface would list -- D0/D3/D4 never need adjudication, they never represent a disagreement worth a human verdict.

Divergence sweep + backoff (internal/hub/landing_divergence_sweep.go) -- ARGUS-1269/1476/1507

The classifier above describes the shape of a divergence row; this file is what actually produces one. DivergenceClassifier.Run(ctx, tick) ticks Sweep on a 30s interval (divergenceClassifier.Run(ctx, 30*time.Second), cmd/hub/main.go) -- the same cadence as the landing queue's own worker loop. Each tick runs two independent passes, both bounded to the trailing landingDivergenceSweepWindow (7 days):

  • sweepLanding -- candidates are landing_queue rows with status='landed'.
  • sweepHalt -- candidates are rows with post_state='failure', matched via a LATERAL join to the next landed row in the same repo.

resolveOpinion gates each candidate through two checks before it ever queries janus_shadow_events for a matching opinion: repo eligibility against the shadow repo (d4ReasonRepoNotShadowed if unwatched), then the observation window via shadowObservationStart (d4ReasonPreShadow if the landing predates the earliest recorded janus opinion of that kind). Only a candidate that clears both gates queries nearestOpinion for a real D0-D5 verdict; an in-tick opinionTickCache memoizes those lookups so sweepLanding/sweepHalt never double-query the same (pr_number, kinds) pair within one Sweep() call.

Backoff (ARGUS-1507, landing_divergence_backoff_test.go, migrations/0257_landing_divergence_attempts.sql extended by migrations/0265_landing_divergence_attempts_backoff.sql). Every classification attempt is recorded in landing_divergence_attempts via DivergenceStore.recordAttempt. A candidate whose gap reason is pre_shadow is marked terminal=true and permanently excluded from re-attempt -- it can never resolve differently. A non-terminal, still-pending candidate backs off exponentially (attemptBackoff: 30s, 1m, 2m, 4m, ... doubling per attempt, capped at 30m -- landingDivergenceBackoffBase=30s, landingDivergenceBackoffCap=30m); the sweep's candidate queries exclude any decision point where terminal or next_eligible_at > now(). ResetBackoffForRepo clears backoff (attempt_count=0, next_eligible_at=now()) for every non-terminal attempt in a repo, called from the hub's landed-PR hook (cmd/hub/main.go) on the theory that a fresh landing is new information that could change a still-pending classification.