Round 2 review found two ways the "a human could merge this right now"
invariant still leaked, both of which let state:needs-human land on a PR
the button would refuse.
The check-rollup classifier enumerated the outcomes that block and
defaulted the rest to SUCCESS, so ERROR, CANCELLED and STALE fell through
into green. Inverted to an allow-list of the outcomes that DON'T block
(SUCCESS, NEUTRAL, SKIPPED, plus the pending set); everything else,
including an outcome neither enum has today, blocks. The rollup mixes
CheckRun.conclusion with StatusContext.state and an outcome the list
forgets is one we cannot certify as mergeable — a false FAILURE parks the
PR on the agent, a false SUCCESS invites a bad merge. The classifier also
moved out of main() into checks_state(), which is why no fixture caught
this: it was inline in the fetch loop and the jq itself was untestable.
Once CANCELLED blocks, superseded runs must be dropped first — a re-run
does not evict the run it replaced, and judging every entry would strand
every re-run PR in needs-rebase. Each context now collapses to its newest
entry, keyed on workflow + job name because a bare job name is only
unique within its workflow.
decide_state() returned from inside the bot loop on the first MISSING, so
a STALE belonging to a later bot in BOTS was never read: a round that was
both unfinished and staled came out needs-human with nothing bound to the
head. The whole round is now collected before any precedence is applied,
STALE ahead of MISSING. The MISSING-yields-to-an-explicit-human-request
rule is untouched.
Fixtures 29 -> 44, pinning the whole check-outcome enum, the supersede
rule in both orders, and the mixed round at both ends of BOTS.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ported from heavy-duty/box#137 (heavy-duty/box#136) so the three repos'
reconcilers stay byte-identical. The state machine here was byte-identical to
box's before this change, and remains so after -- only the scope:* taxonomy
differs, correctly.
decide_state() derived state from three inputs -- draft flag, requested
reviewers, submitted reviews -- and read NOTHING about mergeability or checks.
With the `if requested "$HUMAN"` short-circuit at the top of its precedence,
the label was sticky: once the maintainer was requested, a PR read
state:needs-human through conflicts, through red CI, through a force-push that
staled every approval.
This repo paid for it directly. During the ten-PR batch merged today, every
merge re-conflicted the PRs below it through CHANGELOG.md, and each kept its
state:needs-human label throughout -- inviting merges that could not happen.
It was caught only by opening them one at a time, which is the work the label
exists to save.
The rule the label now keeps: state:needs-human means a human could merge this
RIGHT NOW, so anything making that false outranks the request that put it
there.
CONFLICTING or failing checks -> state:needs-rebase (new; the agent's to fix)
approvals staled by a push -> state:addressing (nobody reviewed this tree)
An UNFINISHED round still yields to an explicit human request -- MISSING
(nobody has reviewed yet) is a different fact from STALE (everyone reviewed
something else). UNKNOWN mergeability is NOT treated as unmergeable: GitHub
reports it for about a minute after every merge, and flapping every open PR
through needs-rebase on each merge would be worse than the bug. A failed read
degrades to the same "do not know" value.
Also adds merge-next: queue order is intent, so the reconciler never sets it,
only CLEARS it once the PR stops being mergeable-by-a-human.
Fixtures 19 -> 29, including that UNKNOWN does not trigger needs-rebase and a
draft outranks a conflict. No live dry-run evidence here -- this repo has no
open PRs right now -- so the fixtures and box's live dry-run are the proof.
Closes#87
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The trait was named for who lives on a box; what it decides is whether
root SSH stays open as the control plane's automation door. Those are
different questions, and `dev-server` proved it: an unattended VM-host
appliance nobody lives on, correctly class=human because its root door
must close. After #76 gave `-server` the job of naming the machine
family, that box carried a suffix saying server and a trait saying
human. `dev-server --root-door closed` says what is true, once.
Unlike #76's role rename this field is read back on live machines, so
the compat read is mandatory rather than courteous: one resolver,
root_door_of, reads both vocabularies and every consumer goes through
it — close-root's gate, apply's note, and bootstrap-tenant's
machine-marker guard, which used the presence of `class=` as its "is
this a real fleet machine?" test and would otherwise have let a tenant
converge clobber a live box. New markers are written as `root-door=`
only. Markers carrying both fields in disagreement, or neither, fail
closed with a re-run-bootstrap repair.
Fixture markers are kept deliberately at the retired spelling (the
convention #76's pre-rename-cp fixture established) and pinned at both
consumers; deleting the compat arm turns ten checks red.
Closes#77
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Maintainer direction: body-parsing agreement was a guess, and the machine
must not guess. COMMENTED is now unconditionally a non-verdict; the judgment
that a comment-only reviewer's round passed belongs to the PR AUTHOR, who
escalates by requesting the human's review — an explicit request is a fact,
and it is the machine's top-precedence input. Auto-request survives only for
the no-judgment case: three formal head-current approvals. CONTRIBUTING and
LABELS.md state the handoff; fixtures updated (14 transitions, including
author-escalation and the three-formal-approvals path).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The machinery LABELS.md promised. labels.yml runs the reconciler on a
15-minute cron plus PR events (pull_request_target — every PR here is from a
fork, where pull_request gets a read-only token; no PR code is ever checked
out). The script derives each open PR's state:* from GitHub's own facts and
converges labels statelessly; stale is judged from real activity (commits,
comments, reviews), never label churn, so the sweep cannot un-stale its own
mark. actions/labeler applies scope:* from changed paths. CONTRIBUTING.md is
the guideline: the PR loop, and who sets which labels. Rehearsed with
DRY_RUN=1 against the live repo; shellcheck-clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
All three bot reviewers flagged it: gh label delete is not an upsert, so a
second run of the safe-to-run block errored on the trim lines. Loop + '|| true'.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Documents the shared heavy-duty label design at the root: state labels
that answer 'who is the ball with' across the bot-review loop, sweep-managed
stale + blocked, rig's scope set, and the bootstrap block. State labels are
automation-owned; the reconciler workflow is a follow-up.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>