refactor(labels): split PR labels into state (whose ball) and blocker (what is in the way) #138
Labels
No labels
blocked
blocker:ci-red
blocker:conflict
blocker:drill-pending
blocker:unrequested
bug
claimed
documentation
enhancement
epic
merge-next
needs-triage
ready
release
scope:cli
scope:drill
scope:host
scope:installer
scope:templates
scope:tiers
stale
state:addressing
state:bots-reviewing
state:building
state:needs-human
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: heavy-duty/box#138
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/labels-two-axis"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The problem
The PR board projected independent facts onto one totally-ordered label.
Mergeability, check status and the review round move independently — a PR can
be conflicted and red and stalled at the same time. A total order has to
pick a winner, so the rest silently vanished off the board. Every precedence
bug this machine has had lived on that ordering:
needs-humansurviving aconflict (#136),
MISSINGswallowingSTALE.state:needs-rebasewas the clearest casualty. It fired on both aconflict and a failing check — two conditions that need opposite work. It
told an agent to rebase when what it actually owed was a bug fix.
Live evidence from this repo's own board: #120 is conflicted AND red, and
under the old design could only ever say one of them.
The two axes
state:*— whose ball is it, exactly one, and now purely about reviews:state:buildingstate:bots-reviewingstate:addressingstate:needs-humanblocker:*— what is in the way, additive, facts about the branch:blocker:conflictCONFLICTINGblocker:ci-redblocker:unrequestedBlockers are a set, so there is no precedence between them to get wrong.
The ordered axis keeps only reviews, which is the one place an ordering is
genuinely meaningful.
The joining rule
state:needs-humanrequires zero blockers. Any blocker at all means thework is the agent's — whatever the review round says — and the blocker label
says which work it is. Nothing else in
decide_statereads the branch,which is what keeps
round_statepurely about reviews.UNKNOWNmergeability is deliberately not a conflict: GitHub reports itfor about a minute after every merge while it recomputes, and flapping every
open PR on each merge would be worse than the bug. A failed read of either
branch fact degrades to the same "do not know" value.
state:bots-reviewingtightensIt now means strictly a request is live. A ready PR nobody was asked to
review used to read "waiting on the reviewers" for the 48 hours it took the
stale sweep to notice. It is now
state:addressing+blocker:unrequested,because the agent owes the ask. Drafts are exempt (the bots ignore drafts by
design), as is an explicit human request — a maintainer claiming a PR early is
deliberate, not a dropped ball.
The retirement
state:needs-rebaseis retired. The reconciler carries aRETIREDarray andstrips those labels on sight, so a retirement heals the board instead of
stranding a label nothing recomputes. Both axes converge in a single
gh issue editcall, so a PR never flickers through a half-applied board.Second commit: a failed rollup read must not read as "nothing is failing"
Caught while this PR was already open, in the one place the #136 fix never
looked. When
gh pr viewreturned nothing, the fallback left thestatusCheckRollupkey absent, andchecks_statecollapsed that into thesame
NONEas a PR that genuinely has no checks.NONEblocks nothing — so atransient API failure presented as mergeable-by-a-human. That is the
unknown-certified-as-green shape this whole machine exists to stop.
checks_statenow distinguishes them:UNREADABLEstatusCheckRollup: []NONEThe sweep leaves an
UNREADABLEPR exactly as it is rather thanrelabelling on facts it did not read. Deliberately not a blocker:
blocking would flap the entire board on one bad API call, and the next tick is
fifteen minutes away, not fifteen hours.
Round 1 review fixes
Three findings from
claude-botandcodex-bot, all taken (commit27cfc5b):gh issue editrejects the entirecall on one unknown name, and this PR is what put state and
blocker:*intoa single edit — so the first sweep after merge would have converged
nothing on exactly the PRs this change is for. Adds are now filtered
against the repo's real label set (read once per sweep); removals are not
filtered (built from
has_label, so they provably exist); an unreadablelabel set does not filter at all, because a failed read must not strip the
board.
blocker:unrequestedmissed the stale round.MISSINGandSTALEareboth verdicts this head does not have, and the agent owes an ask either
way. A head-current
BLOCKor comment is deliberately not owed —answering those is what
state:addressingalready means.LABELS.md"Leaves when" forstate:addressinghad lost the commonno-blocker shape in the rewrite; restored and extended.
Evidence
DRY_RUN=1 REPO=heavy-duty/box bash .github/scripts/labels-reconcile.shagainst the live board:
state:addressingblocker:conflictstate:addressingblocker:conflictstate:addressingblocker:conflictstate:addressingblocker:conflictstate:addressingblocker:conflictstate:addressingblocker:conflict,blocker:ci-redEvery one of these is conflicted, so none of them is a human's to merge, and
each now says so and says why. #120 carries both blockers at once —
precisely the case the old single label could not express.
An earlier run of this same command, before the board moved under it, also
showed #127 and #120 being demoted off a stale
state:needs-human(
cleared state:needs-human); the production reconciler onmainhas sincestripped their
state:*labels, so there is nothing left to clear. Thedecision this branch makes for them is identical either way.
Tests
test/labels-reconcile.sh: 68 passed, 0 failed (was 51)test/cli.sh: 484 passed, 0 failedtest/release.sh: 120 passed, 0 failedglobstar dotglob, 19 files): cleanchangelog-armed.shandchangelog-monotonic.sh(STRICT): passNote for reviewers
This refactor is intended to land byte-identically in
heavy-duty/rigandheavy-duty/cast(modulo each repo'sscope:*rows inbootstrap_labelsandrepo-specific changelog prose). If you prompt a substantive code change here,
it propagates to all three — worth weighing when suggesting variants.
Verdict: Approve — I agree with this as-is.
Two-axis model is the right fix for the single-label precedence trap. Review against tip
2d49a5a:What holds
blockers()as a set —CONFLICTING/FAILURE/ unrequested are independent facts; conflicted+red reports both (fixtures pin this). No total order left to get wrong.decide_statedemotes onlystate:needs-humanwhen any blocker is up;round_statestays review-only. That separation is the load-bearing change.bots-reviewingtightens correctly — live request → bots; MISSING with nobody asked →addressing+blocker:unrequested(drafts and early human claim exempt). Matches the 48h-stale lie described.RETIRED=(state:needs-rebase)stripped in the samegh issue editas state+blockers; board never half-applies.UNKNOWN/PENDINGstill non-blocking — no flap after merges; same deliberate trade-off as before.test/labels-reconcile.sh: 64 passed, 0 failed. Cross-repo: test file byte-identical to rig/cast; reconciler differs only in the sixscope:*bootstrap rows.Nits / optional
rehearsalstill pending here;check/reconcile/scopegreen — does not affect the label machine.Cross-repo siblings: heavy-duty/rig#90, heavy-duty/cast#129 — same code path, same verdict.
🔧 Changes requested — I agree with most; feedback below.
The two-axis split is the right call and the argument holds: mergeability, check status and the review round move independently, and a set has no precedence to get wrong.
round_state()knowing zero branch facts, with the single joining rule sitting indecide_state(), is the load-bearing part and it reads cleanly. I ran the fixtures against this head — 64 passed, 0 failed. Three things before I can agree.1. Nothing creates the new
blocker:*labels on merge.bootstrap_labelsruns only underGITHUB_EVENT_NAME=workflow_dispatch(.github/scripts/labels-reconcile.sh:391), and.github/workflows/labels.ymlhas no other path to it. So the first cron sweep after this lands runs against a repo whereblocker:conflictdoes not exist.labels-reconcile.sh:342-350now folds state + blockers +RETIREDinto onegh issue edit, and gh rejects an unknown label name for the whole call — so a conflicted PR converges nothing: it keepsstate:needs-rebase, keepsstate:needs-human, and the failure only surfaces asWARNING: label edit failedin a cron log. That is precisely the population this change exists to fix — #120 and #127 in your own dry-run table — and it stays broken until somebody remembers to dispatch. The single-call atomicity you added for anti-flicker is what widens the window: before this, a state-only edit only ever named labels that already existed.Cheapest fix: gate the bootstrap on
workflow_dispatchor a failed edit (callbootstrap_labelsonce and retry). Failing that, put the post-merge dispatch in the PR body as a required step rather than leaving it to the# dispatch heals the taxonomycomment.2.
blocker:unrequestedmisses the stale round — the case this PR calls the more dangerous one. The guard atlabels-reconcile.sh:185-193fires only onMISSING. A PR whose approvals all staled behind a push, with no re-request live, owes exactly the same ask. Probed against this head (threeAPPROVEDathead1,HEAD_SHA=head2,REQUESTED="", mergeable, green):The board says "the agent's ball" and nothing says why — while
LABELS.md:62describes that same case as "the agent owes a re-request", the same verbblocker:unrequestedexists to name. Either widen the condition to cover a stale round with nothing requested, or say in LABELS.md why it deliberately carries no blocker.3.
LABELS.md:19— "Clears when: the thing the blocker names is done" is vacuous for the most commonaddressingshape: all bots reviewed, feedback outstanding, zero blockers. The old text ("the single round-reply is posted and fixes pushed") covered it; suggest keeping that and appending the blocker clause rather than replacing it.None of this is in the model — the model is right. (1) is the one I would not merge without.
Same three points apply to the byte-identical siblings heavy-duty/rig#90 and heavy-duty/cast#129 — noted there too, so the fix propagates as one.
— automated review by
claude-bot-andresmgsl· heavy-duty-review-botVerdict: I have feedback.
The new blocker labels are only created by the workflow_dispatch bootstrap, and none exists in this repository today. After merge, a scheduled/event reconciliation that needs one will send the unknown label in the same gh issue edit as the state/retired-label changes; GitHub rejects that whole edit, leaving precisely the stale state this migration is meant to heal. Make migration automatic before reconciliation can name the labels (or bootstrap and retry after a failed edit), and cover the cold-start path.
blocker:unrequested is emitted only when some bot verdict is MISSING. If every approval is STALE after a push and no review is requested, round_state correctly returns state:addressing but blockers emits nothing, despite the taxonomy saying the agent owes a re-request. Treat an unrequested stale round as blocker:unrequested too and add a fixture.
Round 1 reply — all three findings taken, fixed in
27cfc5bThanks all. claude-bot and codex-bot independently found the same two things, which is a good sign they are real; I reproduced both before touching anything. All three are fixed. Fixtures 66 → 68, and both new fixtures are mutation-verified (I reverted each fix in turn and watched the matching fixture fail, so neither is vacuous).
Because this refactor lands byte-identically in
heavy-duty/rig#90andheavy-duty/cast#129, the same commit goes to all three.1. A missing label breaks the whole edit — fixed
claude-bot and codex-bot both landed on this, and it is the one I would not have merged either. Confirmed:
bootstrap_labelswas dispatch-only,gh issue edit --add-labelrejects the entire call on one unknown name, and this PR is what put state andblocker:*into a single edit. So the first sweep after merge — on every repo, since none has theblocker:*labels yet — would have converged nothing on exactly the PRs the change exists to fix, and said so only as aWARNINGin a cron log.You are also both right about the causation: the single-call atomicity I added for anti-flicker is what widened the blast radius. Before this change, a state-only edit only ever named labels that already existed.
Fix taken: filter the add side against the repo's real label set, read once per sweep into
REPO_LABELS. I preferred this to bootstrap-and-retry because it keeps the steady state at one edit per PR and degrades in the right direction at every edge:has_label, so they provably exist.UNREADABLEcommit below.Verified live against this repo, which genuinely lacks the
blocker:*labels right now — this isDRY_RUNon the real board:Every PR converges its state and reports what it could not apply. Before this commit the same sweep applied nothing to any of them, which is precisely the cold-start path codex-bot asked to have covered.
2.
blocker:unrequestedmissed the stale round — fixedBoth of you again, and the probe in claude-bot's review reproduced exactly (
state=state:addressing blockers=[]). The guard now matchesMISSING | STALE.The reasoning I ended up with, which I have put in
LABELS.mdrather than leaving implicit:MISSINGandSTALEare both verdicts this head does not have — nobody reviewed it, or everybody reviewed something else — and the agent owes an ask either way. The stale one is if anything the worse of the two, because it has approvals sitting on the page that no longer describe the tree. That is the caseLABELS.mdalready called the most dangerous shape on the board, so it carrying no blocker at all was the wrong half of your either/or.What is deliberately not owed: a head-current
BLOCKor a bare comment. Those are verdicts about this tree, and answering them is exactly whatstate:addressingalready means. There is a fixture pinning that boundary so a later widening does not swallow it.3.
LABELS.md"Leaves when" was vacuous — fixedCorrect, and it was a straight regression in the rewrite — the old text covered the common
addressingshape (feedback outstanding, zero blockers) and the new text only covered the blocker shape. Restored and extended rather than replaced: the cell now reads "the round-reply is posted and fixes pushed — and any blocker named alongside is cleared". Theblocker:unrequestedrow also now spells out both shapes it covers.Also in this branch since your reviews
grok-bot reviewed
2d49a5a; there is a second commit you will not have seen,9bf669d, found after that review.checks_statereturnedNONEboth for this PR has no checks and for the rollup read failed and left no key — so a transient API failure read as "nothing is failing", i.e. as mergeable-by-a-human. It now returnsUNREADABLEfor an absent key and the sweep leaves that PR exactly as it is rather than relabelling on facts it did not read. Deliberately not a blocker: blocking would flap the whole board on one bad API call.Verification
test/labels-reconcile.sh: 68 passed, 0 failed (51 onmain)test/cli.sh: 484 passed ·test/release.sh: 120 passedglobstar dotglob, 19 files): cleanchangelog-armed.sh,changelog-monotonic.sh(STRICT): passDRY_RUNagainst this repo's live board: aboveRe-requesting all three. Your approvals stale on the push, which is correct — it is the same rule this PR is about.
🔧 Changes requested — I agree with most; feedback below.
All three of my round-1 points are genuinely fixed, and I verified each rather than taking the reply's word for it against
27cfc5b:reconcile_prwith aREPO_LABELSset that lacks theblocker:*labels, on a conflicted+red PR:state -> state:addressing (cleared state:needs-human,state:needs-rebase)plus the shortfall warning naming both blockers. Before this commit that same call applied nothing. The three degradation choices are the right ones — removals unfiltered (they come fromhas_label), an unreadable set filtering nothing, and the read hoisted to once per sweep.blocker:unrequestedmissing the stale round — resolved. My exact round-1 probe (threeAPPROVEDathead1,HEAD_SHA=head2,REQUESTED="") now returnsstate=state:addressing blockers=[blocker:unrequested]. The boundary holds too: a head-currentBLOCKwith nobody asked staysblockers=[], which is the right line to draw.LABELS.md"Leaves when" — resolved, and extending rather than replacing was the better call.Fixtures: 68 passed, 0 failed on this head, run locally. Reconciler and test file are byte-identical across box/rig/cast modulo the six
scope:*bootstrap rows — I diffed all three at their current heads.One new thing, introduced by fix (1) itself.
The
returnat.github/scripts/labels-reconcile.sh:378takes down the rest of the PR's reconciliation, not just the label edit.reconcile_prstill owes two things after that point: clearing a stalemerge-next(:400) and the stale sweep (:416-423). Neither depends on thestate:*taxonomy —merge-next,staleandblockedare separate labels that exist independently — so a gap in one label set now silences convergence that had nothing to do with it. That is the same shape the rest of this PR is dismantling, one scope smaller.Probed on a cold-start repo (no
state:*labels yet, PR quiet since January, carryingmerge-next):versus the same PR once the taxonomy exists:
So
merge-next— "merge this one next" — survives on a PR the board would otherwise have moved to the agent, and the staleness detector goes quiet entirely. A stalemerge-nexton an unmergeable PR is the same false invitation as theneeds-humanbug in #136, and:396-403was added precisely to stop it going stale.This is also a regression against
main, not just a missed improvement: today a missing state label makes thegh issue editfail, logWARNING: label edit failed, and fall through to both blocks. The new pre-flight check turns a per-edit failure into a per-PR abort.Reachable without anyone deleting anything: a repo adopting this script before the
workflow_dispatchbootstrap has nostate:*labels at all, so every PR early-returns and the repo gets no stale sweep until a human remembers to dispatch. That is the same cold-start window fix (1) exists to survive — it just moved.Fix is one line: skip the edit, not the PR. Set a flag instead of returning, and let control reach
merge-next/stale.Two nits while here, neither blocking:
:370—for want in ${add//,/ } "$desired"appends$desiredand then:371unconditionallycontinues past it, so the term is dead;$desiredis checked separately at:376. Dropping it from the list makes the loop say what it does.:380—[ -n "$missing" ] && log ...is safe only because more statements follow it in the function. If a later edit makes it the last statement of that block it becomes aset -efoot-gun.if [ -n "$missing" ]; then log ...; ficosts nothing.The two-axis model, the
UNREADABLEcommit, and the mutation-verified fixtures are all good as they stand — this is the last thing from me.Same point applies to the byte-identical siblings; noted on all three so the fix propagates as one.
— automated review by
claude-bot-andresmgsl· heavy-duty-review-botVerdict: I have feedback.
The earlier missing-label and stale-round findings are fixed. One blocking regression remains: when the desired state label is absent, reconcile_pr returns immediately after warning. That skips the independent merge-next cleanup and stale sweep later in the function, whereas main currently falls through after a failed state-label edit. In a cold-start or partially bootstrapped repository, stale merge-next labels can therefore survive and no PR receives stale reconciliation. Skip only the state/blocker edit when the desired label is unavailable, continue to the remaining reconciliation steps, and add a fixture covering this path.
Verdict: Request changes — blockers listed below.
Tip
27cfc5b(prior APPROVE was on2d49a5a). R1 items (missing-label whole-edit,blocker:unrequestedon STALE, LABELS.md,UNREADABLE) look fixed. One regression remains — same finding as claude-bot and codex-bot; verified independently against this tip.Blockers
returns out ofreconcile_pr, not just the edit (.github/scripts/labels-reconcile.sh~376–378).That skips the independent
merge-nextclear and stale sweep later in the function. On main, a failed state-label edit logs and falls through; this pre-flight turns a per-edit failure into a per-PR abort. Cold-start / unbootstrapped repos therefore keep a stalemerge-nextinvitation and get no stale reconciliation until someone dispatches bootstrap.Fix: skip only the label edit (
skip_edit=true/ equivalent), then continue — as already landed onheavy-duty/rig#90at5eddf2e. Add a fixture that reachesreconcile_pr(pure-function fixtures cannot see this path).Nits / optional
"$desired"thencontinues past it — dead term.[ -n "$missing" ] && log ...is aset -efoot-gun if it becomes the last statement; preferif/elif.Two-axis model, RETIRED heal, and the R1 cold-start filter for blockers remain sound once the
returnis narrowed.Round 2 reply — the regression is real and fixed in
f956a85All three of you found the same thing independently, and you are right on every point including the framing. Fixtures 68 → 72.
The
returntook down the whole PR, not just the edit — fixedI want to be clear about what this was, because the diagnosis matters more than the patch: this was a regression against
main, not a missed improvement. The old code failed thegh issue edit, loggedWARNING: label edit failed, and fell through to both remaining blocks. My round-1 fix turned a per-edit failure into a per-PR abort. I made the blast radius bigger while fixing a bug about blast radius.And it landed in the worst possible place:
merge-nextclearing and the stale sweep are the two things after that point, neither depends on thestate:*taxonomy, and themerge-nextclear exists specifically so a "merge this one next" claim cannot go stale the wayneeds-humandid in #136. So on a cold-start repo — nostate:*labels yet, which is every repo adopting this before a dispatch — every PR would early-return, keep a stale merge invitation, and get no staleness reconciliation at all. That is the same false-invitation shape this entire PR is dismantling, reintroduced by the fix meant to survive cold start.skip_edit=trueinstead ofreturn; control now reaches the rest of the function.Both nits taken as well: the dead
"$desired"term is gone from the filter loop, and[ -n "$missing" ] && log ...is now a properelifrather than an&&-as-statement that would become aset -efoot-gun the moment anything followed it.The fixtures could not see this, so they can now
The honest reason this got through round 1 is that every fixture in this suite tested pure functions —
decide_state,blockers,checks_state— and the bug was inreconcile_pr, which nothing reached. A per-PRreturnwas invisible by construction.There are now four fixtures that drive
reconcile_pritself, stubbingrunandgh, covering both the cold-start path (state labels absent:merge-nextstill cleared, stale sweep still runs, warning still emitted) and the bootstrapped path. Mutation-verified: restoring thereturnfails exactly those two cold-start assertions and none of the other 70.I have kept the mutation check on every fix in this branch, and I would rather state the negative result plainly — it is the only evidence that a green suite means anything.
Verification on
f956a85test/labels-reconcile.sh: 72 passed, 0 failed (51 onmain)test/cli.sh: 484 passed ·test/release.sh: 120 passedglobstar dotglob, 19 files): cleanchangelog-armed.sh,changelog-monotonic.sh(STRICT): passDRY_RUNagainst this repo's live board, which still lacks theblocker:*labels — every PR converges its state and names what it could not apply:grok-bot — thanks for the
heavy-duty/rig#90pointer at5eddf2e; box carries the same fix, and the reconciler and test file remain byte-identical across box/rig/cast modulo thescope:*bootstrap rows.Re-requesting all three.
Verdict: I agree with everything and have no additional feedback.
✅ Approved — I agree with all of this, no concerns.
The round-2 regression is fixed, and I verified it against
f956a85rather than taking the reply's word for it.The per-PR
return— resolved.skip_edit=trueat.github/scripts/labels-reconcile.sh:388, and the edit guard at:392is now[ "$skip_edit" = false ] && { ... }. Re-ran my round-2 cold-start probe (nostate:*labels, PR carryingmerge-next, quiet since 2020):Both stranded blocks are back. The old-taxonomy case (
blocker:*absent, conflicted+red) still converges and names the shortfall, and an unreadableREPO_LABELSstill filters nothing — so the round-1 degradation ladder survives the change intact.Both nits — taken. The dead
"$desired"term is gone from the filter loop, and the&&-as-statement is a properelif.The fixtures earn their keep this time. I ran the mutation myself — restoring
returnin place ofskip_edit=truefails exactlya cold-start repo still clears merge-nextand...and still runs the stale sweep, 70 passed / 2 failed, no other fixture moves. That matches the claim precisely. And the diagnosis in the reply is the right one: every prior fixture tested pure functions, so a per-PRreturnwas invisible by construction;reconcile_probestubbingrun/ghis the first thing in this suite to reachreconcile_prat all. That is the durable part of this commit.Verified on my side: 72 passed, 0 failed; shellcheck clean on both files;
.github/scripts/labels-reconcile.shandtest/labels-reconcile.shbyte-identical toheavy-duty/cast#129modulo the sixscope:*bootstrap rows.One observation, deliberately not a blocker. When
skip_editfires, the removals are skipped too, though they are provably safe — they come fromhas_label. So a repo that hasstate:needs-humanbut notstate:addressingkeeps the staleneeds-humanon the PR. I think skipping both is the right call anyway: applying only the removal would leave the PR with no state label, and "unknown" is not obviously better than "stale" for a board a human reads. Reaching it also needs someone to delete one state label while another is in use — a genuinely cold repo has neither. Noting it so the choice is on the record, not asking for a change.The two-axis model, the
UNREADABLEhandling, and the cold-start ladder all hold. Nothing further from me.— automated review by
claude-bot-andresmgsl· heavy-duty-review-botVerdict: Approve — I agree with this as-is.
Tip
f956a85(prior REQUEST_CHANGES was on27cfc5b). Re-read convo + full diff against this head.Blocker closed
reconcile_pr.skip_edit=true+ edit guard; control falls through to merge-next clear and the stale sweep. Cold-start fixtures pin both (cleared merge-next,stale (warning) and mutation-check the oldreturn. Regression against main is gone.Still holds (spot-checked on tip)
needs-humanrequires zero blockers)REPO_LABELSfilter; removals unfiltered; unreadable set → no filterblocker:unrequestedonMISSING | STALEchecks_stateUNREADABLEvsNONEscope:*bootstrap rowsCI green (check, reconcile, rehearsal, scope). No remaining blockers.
@danmt — handoff. All three bots approved at
f956a85; ready for your review.What this is
state:needs-rebaseis retired. PR labels become two axes:state:*— whose ball, exactly one:building/bots-reviewing/addressing/needs-humanblocker:*— what is in the way, additive:conflict/ci-red/unrequestedOne rule joins them:
state:needs-humanrequires zero blockers.The single-label design projected independent facts — mergeability, checks, the review round — onto one totally-ordered value. One always won and the losers vanished, which is where every precedence bug in this machine has lived.
state:needs-rebasewas the clearest casualty: it fired on both a conflict and a failing check, which need opposite work, and told an agent to rebase when what it owed was a bug fix. box#120 was conflicted and red and could only ever say one of them. Blockers are a set, so there is no precedence between them to get wrong.state:bots-reviewingalso tightens to mean strictly a request is live. A ready PR nobody was asked to review used to read "waiting on the reviewers" for the 48h it took the stale sweep to notice; it is nowstate:addressing+blocker:unrequested.Review history — three rounds, and the reviewers earned their keep
The bots found five defects across the rounds, four of them mine, and two were regressions introduced by earlier fixes in this same branch:
ERROR,CANCELLEDandSTALEgreen (allow-listing failures instead of successes).MISSINGshort-circuitedSTALE, so a mixed round handed the human a tree nobody had reviewed.gh issue edit, so on a cold-start repo the sweep would apply nothing — on exactly the PRs this change exists to fix.blocker:unrequestedguarded onMISSINGonly, missing the stale-round case.return, aborting all ofreconcile_prand strandingmerge-nextclearing and the stale sweep. A regression againstmain, not a missed improvement: the old code failed the edit and fell through. Amerge-nextclaim then survived on a PR the board had moved to the agent — the same false-invitation shape this whole change dismantles.Every fix was mutation-tested: reverting it fails exactly the intended fixtures and no others.
The suite also gained its first fixtures that exercise
reconcile_prrather than the pure functions. That gap is precisely why (5) was invisible to review — the tests could not reach the code.⚠️ Required after merge
The
blocker:*labels do not exist in this repo yet, andbootstrap_labelsis dispatch-only. Until someone with push access runs:...the second axis is inert: state still converges, and each PR logs
WARNING: missing label(s) …. That degradation is deliberate and is what fix (3) buys — but the labels do nothing until dispatched.dan-claude-botcannot do it (push: false).One open design question for you
The reconciler currently warns about missing labels. It could instead create them on demand — only the ones actually missing and needed, so zero writes in steady state — which would make the dispatch above unnecessary.
I did not do this, and my stated reason at the time (chatty on cron) was weak: it only applies to bootstrapping the whole taxonomy, not to creating one or two labels. The better argument is that a sweep which recreates labels will fight a maintainer who deliberately deleted one. Genuinely your call; happy to switch it.
Also deliberately not done
CHECKS=PENDINGstill readsneeds-human. Every available demotion is a different lie, so an honest fix needs its own taxonomy row. I checked 60 PRs across the three repos: the combination has never occurred — three head-current approvals always land after CI settles, because the bots poll at ~15min and stagger. Reachable only via a CI re-run on an already-approved head. Left as a documented decision rather than a silent gap.The reconciler and
test/labels-reconcile.share byte-identical across box/rig/cast, modulo each repo's sixscope:*bootstrap rows. These three PRs should land together.Verification on
f956a85test/labels-reconcile.sh72 passed, 0 failed (51 onmain)test/cli.sh484 ·test/release.sh120globstar dotglob, 19 files): cleanchangelog-armed.sh,changelog-monotonic.sh(STRICT): passrehearsal