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
ErrPRAlreadyMergednaming 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;OpenPRnever guesses which.POST /pulls/opensurfaces this as409("open pr failed: ...", naming the merged PR) rather than the502a genuine forge failure gets -- a caller must not retry the same call on a409. - No match falls through to a normal create;
reused=trueon the reuse/reopen paths,falsefor 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 containingCloses ARGUS-N) -- a genuine closing binding; the PR is expected to complete the task.Partial: ARGUS-N(line starts withPartial:) -- a declared-partial slice (ARGUS-1051); the PR is bound to the task but explicitly does not complete it.Refs ARGUS-N(line starts withRefs) -- an explicit no-close binding (ARGUS-1662); a doc-only PR that closes nothing no longer has to borrow the more ambiguousPartial: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):
- Confirm on the Forgejo commit status page for the named merge sha.
- 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. - 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 exceptcancelledon a task whoseverifiedflag is stilltrue, 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 stillverified=trueat this point and a"to":"ready"call 400s.cancelledis 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. - 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 readsrebase 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'sfailing_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_evidencemay also carry astep=/run_id=/job_id=block (CIFailureDiagnoser's third return value,jobEvidenceExtraininternal/hub/landing_diag.go, ARGUS-1494) naming the exact CI job/step it resolved -- check that too. This detail also fires aci_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 pastclassifyCIFailureand 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 aCI failedentry 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)(emptyfailure_class) -- the merge call itself reported success (or at least didn't error) but the post-call verify read still showsmerged=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_classdeterministic: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.detailandfailure_evidenceboth carry the forge's own error text verbatim, so the actual refusal reason is on the row instead of only in aslog.Warnline an operator has to go dig up separately (the JANUS-18 drill hit exactly this: janus-smoke PR6 no-opped twice as the anonymousmerged=falsestate above before anyone noticed the real cause was a dead required status context, orphaned by the shadow cutover -- see "janus-smoke branch protection" below). Readfailure_evidencefor 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, samerequireBearer(reportToken)tier as the other/landing/*routes) returns every row plus server-computed aggregates, including aShadowScorecard(ARGUS-1476, scoped todefaultJanusShadowRepoand a trailinglandingDivergenceScorecardWindowwindow, 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 arelanding_queuerows withstatus='landed'.sweepHalt-- candidates are rows withpost_state='failure', matched via aLATERALjoin 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.