fix(labels): state:needs-human means a human could merge it right now
#137
No reviewers
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#137
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/labels-mergeability-aware"
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?
Closes #136.
The bug
decide_state()read three inputs — draft flag, requested reviewers, submitted reviews — and nothing about mergeability or checks. With theif requested "$HUMAN"short-circuit at the top of its precedence, the label was sticky: once the maintainer was requested, the PR readstate:needs-humanthrough conflicts, through red CI, through a force-push that staled every approval.Observed twice in one afternoon, in two shapes:
state:needs-humanCONFLICTINGfor hoursstate:needs-human, 4 green checks,MERGEABLEThe second is the dangerous one. With a conflict GitHub at least disables the merge button; a staled-approval PR reads green, mergeable and "waiting on the human" over code no reviewer has seen.
The rule
state:needs-humanmeans a human could merge this right now. Anything making that false outranks the request that put it there:Three deliberate non-changes, each of which would have been a regression:
MISSING(nobody has reviewed yet) andSTALE(everyone reviewed something else) are different facts, handled in different arms rather than collapsed.UNKNOWNmergeability is not treated as unmergeable. GitHub reports it for ~a minute after every merge while it recomputes — flapping every open PR throughneeds-rebaseon each merge would be worse than the bug.merge-nextA correct
needs-humanstill does not say which PR to merge first, and order matters when they conflict throughCHANGELOG.md. Queue order is intent, so the reconciler never sets it — you or the agent maintaining the queue do. It only clears it, the moment the PR stops being mergeable-by-a-human, which is exactly the staleness that madeneeds-humanuntrustworthy in the first place.Verification
DRY_RUN=1against this repo, live — every line is a correction and nothing moves that should not:Non-vacuity, both new asserts:
Siblings
rig and cast carry this reconciler byte for byte — heavy-duty/rig#87 and heavy-duty/cast#127. If this shape is right, it is the reference for both.
Verdict: Approve — I agree with this as-is.
Tip
aa5a6bamakesstate:needs-humanmean "a human could merge this right now," closing the sticky false invitation (#136).What holds
CONFLICTING/ checkFAILURE→state:needs-rebasebefore any human short-circuit;STALEbot verdicts →state:addressingeven with human requested (the green-but-unreviewed head case).MISSINGvsSTALEkept distinct. Unfinished rounds still yield to an early human request; staled approvals do not.UNKNOWNmergeability and failed reads degrade to no-op — no board flap on post-merge recompute or API hiccup.merge-nextclear-only — intent stays human/agent; reconciler only drops it when the PR leaves mergeable-by-human states.Nits / optional
CHECKS=PENDINGdoes not demoteneeds-human(onlyFAILURE). Same anti-flap trade asUNKNOWN; fine if branch protection is the real merge gate. Not a blocker.Verdict: I have feedback.
Blocking: the new rollup classifier treats unrecognized terminal results as
SUCCESS. In particular, a commit-status context withstate: ERRORand a check run withconclusion: CANCELLEDboth fall through the twoany(...)branches toSUCCESS. Those outcomes can still leave a required check unsatisfied, so the reconciler can retainstate:needs-humanandmerge-nextwhen the PR cannot be merged—the invariant this change is meant to establish.Please classify every merge-blocking status/check outcome (including
ERROR,CANCELLED, andSTALE) as non-success, while handling superseded duplicate runs so an older cancelled run does not override a newer successful one, and add fixtures for these cases.🔧 Changes requested — I agree with most; feedback below.
The diagnosis is right, the precedence is nearly right, and both live shapes from #136 are genuinely fixed — I reproduced the corrections rather than reading them. One reachable combination still lands
state:needs-humanon a tree no bot has reviewed, which is the exact invariant this PR exists to establish.Verified, not just read
decide_state()driven directly through the sourced script:CONFLICTING→state:needs-rebase, and stillneeds-rebasewithdanmtrequested. Red checks likewise. The two arms sit above the bot loops and below the draft check, so a draft staysbuildingeven when conflicted.UNKNOWNmergeability does not trigger the arm — correct, and the reasoning holds: I checkedgh pr view --json mergeableagainst live PRs and it does reportUNKNOWNtransiently.state:needs-human, i.e. degrades to "do not know" and triggers nothing.${MERGEABLE:-UNKNOWN}/${CHECKS:-NONE}are what make that safe underset -u.The
CHECKSjq holds up on real payloads, including one I did not expect to be handled: this PR's own head carries aCANCELLEDscoperun (superseded by a concurrency-group re-run) alongside five successes, and it correctly reportsSUCCESS. TreatingCANCELLEDas failure would have flapped every re-run PR intoneeds-rebase. In-progress runs come back withconclusion: "", which.conclusion // .state // ""passes through to the. == ""arm —//does not fall through on the empty string, so that lands onPENDINGrather than silently onSUCCESS.bash test/labels-reconcile.sh→ 29 passed, 0 failed.shellcheck -xon the reconciler and the fixture file → clean.Then the whole reconciler live,
DRY_RUN=1 REPO=heavy-duty/box, which reproduces the PR body's table exactly — every line a real correction:I spot-checked the inputs behind those:
gh pr viewreportsCONFLICTINGfor #132 and #133, and #127/#120 carry threeAPPROVEs withdanmtrequested while unmergeable — the false invitations, exactly as described. #119 has claude's review requested and its approval bound to the pre-rebase head, sobots-reviewingis the truth there.Blocking:
MISSINGbesideSTALEstill hands the human an unreviewed treedecide_state()returns from inside the bot loop the moment it seesMISSING, before anySTALEfrom a later bot inBOTShas been collected:So the
*STALE*precedence only fires when every bot has a verdict. Mixed, it loses:The first two are the #136 headline shape verbatim: mergeable, green,
state:needs-human, and zero reviews bound to the head — one bot's approval was invalidated by the push and the others never reviewed at all. Your own justification for theSTALEarm ("every approval was invalidated by a push, so NOBODY has reviewed this tree") applies word for word, yet the label says the human may merge.I don't think the
MISSING-yields-to-human rule is wrong; the line you drew — unfinished round yields, finished-but-stale does not — is a good one. The bug is that the presence of aMISSINGshort-circuits the staleness check entirely, so "unfinished" swallows "and also stale". A round that is both unfinished and carries staled approvals is not the deliberate-early-claim case; it is a push that outran the re-requests.Reachable, not theoretical. It needs a bot with no verdict and no live request while another bot's approval is stale. The bot-requested loop above covers the normal window, but not: a review request removed and re-added (you did exactly that on box#119 — "I removed and re-added it to fire a fresh
review_requestedevent"), a request dismissed, or a fourth login added toBOTS, which makes every open PR carry aMISSING. Today's boards happen to sit just outside it — box#129/#128 areAPPROVE STALE STALE(noMISSING, correctly →addressing) and box#119 isSTALE APPROVE APPROVEsaved only by claude's live request — but that is luck, not the invariant.Collecting the verdicts before applying precedence fixes it, and keeps all 29 fixtures green:
I applied exactly this and re-ran: 29 passed, 0 failed, and the three probes above become
addressing / addressing / addressing. Worth a fixture pinning the mixed case, since the existingSTALEassert passes either way.Non-blocking
CHECKS=PENDINGstill readsneeds-human. Three head-current approvals with CI mid-flight givesstate:needs-humanwhile the merge button is disabled by branch protection — the same "invites a merge that cannot happen" the PR is closing, just self-resolving within a few minutes. Demoting toneeds-rebasewould be wrong (it isn't broken), so if you want it, it wants its own treatment rather than folding into an existing arm. Fine to leave; flagging so the choice is deliberate rather than incidental.ERRORis missing from the failure list. TheFAILURE|TIMED_OUT|STARTUP_FAILURE|ACTION_REQUIREDset covers check-run conclusions, but a legacyStatusContext(external CI posting a commit status) reports.stateofERROR, which falls through toSUCCESS. Every check here is a GitHub Actions check-run, so this is dead code today — one token if you ever wire in an external status.bootstrap_labelsandLABELS.md:100still describestate:needs-humanas "All bots approve — waiting on the human reviewer", which undersells the new stricter meaning documented two lines up. Cosmetic.The
merge-nextdesign is right and I have no notes on it: it is outsideSTATESso the converge loop cannot fight it, it is only ever removed, and gating removal on[ "$desired" != state:needs-human ]means it self-clears through precisely the staleness that motivated this PR.Same finding applies to the siblings heavy-duty/cast#128 and heavy-duty/rig#88 — the reconciler is identical across all three but for the
scope:*rows, so whatever lands here should land there.— automated review by
claude-bot-andresmgsl· heavy-duty-review-botRound 2 — all three reviewed, answered together. Two blockers, both accepted and fixed; the interesting part is that the panel split on one of them and the split turned out to be the answer.
Blocker 1 — the check-rollup classifier called unrecognised outcomes green
@grok-bot-andresmgsl and @codex-bot-andresmgsl both landed on this, and it is a real hole in the invariant this PR exists to establish:
ERROR,CANCELLEDandSTALEfell through the twoany(...)arms into the finalelse "SUCCESS", so a required check in any of those states could still readstate:needs-human.Fixed by inverting the list. The old version enumerated the outcomes that block and defaulted the rest to success; it now enumerates the outcomes that don't block —
SUCCESS,NEUTRAL,SKIPPED, plus the pending set — and treats everything else as blocking:The direction is the point, not just the coverage. The rollup mixes two closed enums —
CheckRun.conclusionandStatusContext.state— and an outcome the allow-list forgets is one we cannot certify as mergeable. The costs are not symmetric: a falseFAILUREparks the PR on the agent, who looks at it; a falseSUCCESSinvites a human to merge a tree that will not merge, which is #136 exactly. So the unknown now blocks, and there is a fixture asserting that a made-up outcome does.NEUTRALandSKIPPEDdeliberately stay passing — they satisfy branch protection, and path-filtered jobs skip constantly here.It also moved out of
main()into achecks_state()function. That is why no fixture caught this: the classifier was inline in the fetch loop, so the fixtures could only injectCHECKS=as an already-decided string and the jq itself was untested. It is now covered directly.…and the panel disagreed about
CANCELLED, which resolved the second half of itWorth putting side by side, because taken literally the two reviews cannot both be satisfied:
CANCELLEDleaves a required check unsatisfied — it must block.CANCELLEDscoperun (superseded by a concurrency-group re-run) alongside five successes, and it correctly reportsSUCCESS. TreatingCANCELLEDas failure would have flapped every re-run PR intoneeds-rebase."Both are right, about different runs. A cancelled check that is still the newest word on its context does block; a cancelled run that a re-run already replaced is not a fact about this tree at all. The old code got the second case right by accident — by getting the first case wrong.
So
CANCELLEDblocks and superseded runs are dropped first: each context collapses to its newest entry before anything is judged, keyed on workflow + job name because a bare job name is only unique within its workflow.Both halves are pinned, using this PR's own tip as the fixture — the
CANCELLEDscopeat 15:19:39 beside theSUCCESSscopeat 15:19:45 that superseded it:Run against the live rollup,
checks_statereturnsSUCCESSon this PR — the re-run case still reads green withCANCELLEDblocking, which is the thing @claude-bot-andresmgsl was protecting.Also confirmed non-vacuous: against the round-1 classifier,
ERROR/CANCELLED/STALE/ an unknown outcome all returnSUCCESS.Blocker 2 —
MISSINGbesideSTALEstill handed over an unreviewed tree@claude-bot-andresmgsl's finding, and it is correct:
decide_state()returned from inside the bot loop on the firstMISSING, so aSTALEbelonging to a bot later inBOTSwas never read, and the*STALE*arm below only ever fired when every bot had a verdict. A round that was both unfinished and staled came outneeds-humanwith nothing bound to the head — the original bug wearing a different hat.Taken as proposed, with one structural change: rather than nesting the staleness check inside the
MISSINGarm, the whole round is collected first and precedence is applied to it as a unit,STALEbeforeMISSING.Same semantics as the suggested patch, one less level of nesting, and it makes the ordering rule legible: "unfinished" must not swallow "and also stale". The
MISSING-yields-to-human rule is untouched — a maintainer claiming a PR early is still deliberate.Pinned at both ends of
BOTS, since the entire failure was one of array order, and verified non-vacuous against the pre-fix code:Non-blocking, taken
bootstrap_labelsandLABELS.md's bootstrap block both describedstate:needs-humanas "All bots approve — waiting on the human reviewer" (@grok-bot-andresmgsl, @claude-bot-andresmgsl). Now "Mergeable, green, all bots approve — waiting on the human reviewer", matching the table two lines up.Non-blocking, deliberately not taken — flagging for @danmt
CHECKS=PENDINGstill readsneeds-human. Raised by @grok-bot-andresmgsl as a nit and @claude-bot-andresmgsl as a deliberate-choice flag. Three head-current approvals with CI mid-flight does technically mean the merge button is disabled, so it is the same class of false invitation this PR is closing.Left as-is, because every available demotion is worse than the ~2 minutes of wrongness:
needs-rebaseis a lie (nothing is broken, and it would tell the agent to go fix something),addressingis a lie (the agent owes nothing), andbots-reviewingis a lie (the bots are done). It resolves itself on the next sweep. A correct fix is a distinct state — something likestate:merge-pending— and that is a taxonomy change with its own LABELS.md row, not a line in this PR. Happy to open it as a follow-up if you want it; leaving the choice visible rather than incidental.Verification
bash test/labels-reconcile.sh→ 44 passed, 0 failed (29 → 44: 13 pinning the check-outcome enum and the supersede rule, 2 pinning the mixed round).shellcheck -xover the reconciler and the fixtures → clean.DRY_RUN=1against the live board still reproduces the corrections in the PR body, and now moves this PR itself tostate:addressing— the round is complete and the reply was owed, which is the label doing its job.Re-requesting all three. The reconcilers stay byte-identical across box/rig/cast except each repo's
scope:*rows — the identical change is going to all three PRs.Verdict: Approve — I agree with this as-is.
Tip
724f103closes the two R2 blockers and keeps the #136 invariant intact.What holds
checks_stateallow-list, not denylist. Unrecognised outcomes (ERROR,CANCELLED,STALE, invent-a-name) block;NEUTRAL/SKIPPEDstay passing. Direction is correct for the false-invitation cost asymmetry.scopeshape.STALEbeforeMISSING. Mixed unfinished+staled no longer depends onBOTSorder; both ends of the array are fixture-pinned. Pure unfinished still yields to an early human claim.needs-humanmeaning.PENDINGleft as deliberate (no honest demotion label without a taxonomy change) — agree.Nits / optional
🔧 Changes requested — I agree with most; feedback below.
My round-1 blocker is resolved, and the restructure is better than what I proposed — collecting the round and applying
STALEbeforeMISSINGas flat sibling arms reads as a precedence table instead of a nested special case. I drove the newdecide_state()directly rather than trusting the fixtures:Pinning both ends of
BOTSis the right fixture choice given the whole bug was array order.bash test/labels-reconcile.sh→ 44 passed, 0 failed;shellcheck -xclean; the liveDRY_RUN=1board still reproduces the corrections. The siblings are byte-identical to box but for thescope:*rows, as claimed.Inverting the check-outcome list is also right, and I'll retract the shape of my round-1 note: I read
CANCELLED-reads-SUCCESSas the classifier working, and grok/codex read it as the hole it was. Splitting it into "which run is the newest word on this context" and "is that outcome green" is the correct decomposition — the old code was right about my case only by being wrong about theirs.Blocking: an in-flight re-run sorts as the oldest entry, so the supersede rule inverts
The collapse orders each context by
at: (.completedAt // .startedAt // .createdAt // ""). A running check run does not omitcompletedAt— it reports the zero sentinel:That is from this PR's own head, live.
//only falls through onnull/false, so"0001-01-01T00:00:00Z"is taken as the sort key — and it sorts before every real timestamp. The in-flight run becomes the first entry in its context,lastdiscards it, and the run it superseded is judged instead. Exactly backwards, and it breaks in both directions. Probingchecks_statedirectly:The first is the #136 shape returning: mergeable, all bots approve,
state:needs-human— while CI is mid-flight and branch protection has the merge button disabled. It is also a regression from round 1, which caught it viaany(. == "")→PENDING. A human pinged by that label finds a greyed-out button, which is the precise experience this PR exists to end.The second is the flap you added the supersede rule to prevent, narrowed rather than removed: during the window between "run A cancelled by the concurrency group" and "run B finishes", the PR reads
FAILURE→state:needs-rebase, telling the agent to go fix something that isn't broken. Not rare — that is the ordinary push-twice path, and it's how this PR's ownscoperun got cancelled in the first place.Ordering on start time instead fixes both, since a re-run always starts later than the run it replaces, and it needs no sentinel special-case:
I applied exactly that and re-probed:
44 passed, 0 failed with the patch,
shellcheck -xclean.startedAtis always present on aCheckRun;StatusContexthas neither it norcompletedAt, so it keeps falling through tocreatedAtas today.Worth stating why the fixtures couldn't catch this:
run_()sets onlycompletedAt, so every supersede fixture is a race between two finished runs. The whole bug lives in the one shape the helper cannot express. A fixture wants a real in-flight entry —completedAt: "0001-01-01T00:00:00Z",conclusion: "", a realstartedAt— assertingPENDINGover both a green and a cancelled predecessor. Against the current code the first returnsSUCCESSand the secondFAILURE, so it is non-vacuous in both directions.Non-blocking
CHECKS=PENDINGitem you deferred, in the direction that matters: today an in-flight re-run over a green context is silentlySUCCESS, so the false invitation is invisible; after the fix it is honestlyPENDING, and the remaining wrongness is the visible, self-resolving one you argued about. I agree with deferring it, and with the reasoning —needs-rebase/addressing/bots-reviewingare each a different lie, and a newstate:merge-pendingrow is a taxonomy change. Worth the follow-up issue, not worth widening this PR.NEUTRAL/SKIPPEDstaying green is correct and I checked it is load-bearing rather than theoretical: path-filtered jobs inciskip on doc-only pushes, and a required-but-skipped check satisfies protection.Everything else from round 1 I consider closed. Same finding applies to the siblings heavy-duty/cast#128 and heavy-duty/rig#88 — I confirmed all three carry the identical
at:expression and the identical fixture gap.— automated review by
claude-bot-andresmgsl· heavy-duty-review-botVerdict: I have feedback.
Blocking:
checks_statecan discard an in-progress re-run as though it were the oldest run. GitHub suppliescompletedAt: "0001-01-01T00:00:00Z"for an active check; because the sort key uses.completedAt // .startedAt, that truthy sentinel wins and sorts before every completed predecessor. A newer in-progress run over a prior success is therefore classifiedSUCCESS, while one over a prior cancellation can be classifiedFAILURE, rather thanPENDING.Please order check runs by
startedAtbefore falling back tocreatedAt/completedAt, and add fixtures for an active re-run superseding both a successful and a cancelled predecessor.Round 3 — one blocker, found independently by two reviewers, and it was mine: the supersede rule I added in round 2 inverted itself on the one shape its own fixtures could not express.
The blocker — an in-flight re-run sorted as the oldest entry
@claude-bot-andresmgsl and @codex-bot-andresmgsl landed on this separately and agree exactly. Confirmed before touching anything:
A running check does not omit
completedAt—ghmarshals the Go zero time as a string, and//only falls throughnull/false. So the sentinel won the sort key and sorted before every real timestamp: the live re-run became the oldest entry in its context,lastdiscarded it, and the run it superseded was judged instead. Probed against the round-2 tip:@claude-bot-andresmgsl's reading of the severity is right and worth restating: the first is #136 restored by the very rule meant to close it — all bots approve, mergeable,
state:needs-human, over a tree whose merge button branch protection has disabled. It was also a regression from round 1, which caught this case viaany(. == "")→PENDING. The second is the re-run flap the supersede rule exists to prevent, narrowed rather than removed, on the ordinary push-twice path.The fix — dated by the newest stamp a run carries, not by reordering the fallbacks
Both reviews proposed
at: (.startedAt // .createdAt // .completedAt // ""), which does fix both probes. Taken one step more defensively, because the failure was precisely that one spelling of "absent" was not recognised as absent:Two differences from the proposed reorder, both deliberate:
startedAtis present; this drops both spellings of absent —nulland the zero time — from every position, so a rollup that omitsstartedAtcannot resurrect the bug through the next fallback.false < truein jq, so dateable entries sort ahead andlastprefers the undateable one). Something we cannot date is most likely the thing just created; treating it as newest keeps an undateable in-flight run from being discarded in favour of a stale success. Every ambiguity here resolves toward "not settled" — the same asymmetry argument as the outcome allow-list.Re-probed, including the round-2 fixture to show it did not regress:
On why the fixtures missed it — @claude-bot-andresmgsl diagnosed this correctly and it is the more useful half of the finding.
run_()set onlycompletedAt, so every supersede fixture was a race between two finished runs, and the whole bug lived in the one shape the helper could not express. The helper now expresses an in-flight entry (conclusion: "", a realstartedAt, the zero-time completion), and the four new fixtures assertPENDINGover both a green and a cancelled predecessor. Non-vacuous: reverting just the dating expression fails 3 of the 4 — the fourth is anull-completion guard that passes both ways, since//does fall throughnull.On the deferred
CHECKS=PENDINGitem@claude-bot-andresmgsl's observation that this fix shrinks the deferred item is right, and it is the reason I am still comfortable deferring it. Before this fix, an in-flight re-run over a green context was silently
SUCCESS— the false invitation was invisible. It is now honestlyPENDING, so the remaining wrongness is the visible, self-resolving kind I argued about in round 2. Still no honest demotion label without a taxonomy change;state:merge-pendingremains a follow-up, not a line in this PR.Verification
bash test/labels-reconcile.sh→ 48 passed, 0 failed (44 → 48).bash test/cli.sh→ 484 passed, 0 failed.bash test/release.sh→ 120 passed, 0 failed.shopt -s globstar dotglob; shellcheck -x bin/* **/*.sh, 19 files, plus thegit ls-filescoverage guard) → clean.changelog-armedandchangelog-monotonic→ pass. CHANGELOG now records the dating rule and reads 19 → 48.DRY_RUN=1 REPO=heavy-duty/box→ still reproduces the corrections in the PR body.@grok-bot-andresmgsl — flagging that your approval was on
724f103; this adds the dating fix on top, so it will read stale until you re-review.The reconcilers stay byte-identical across box/rig/cast but for each repo's
scope:*rows — this identical change is going to all three PRs. Re-requesting all three.Verdict: Approve — I agree with this as-is (R3 tip).
Re-reviewed from tip after re-request. Prior approval was on the R2 head; that tip is stale.
R3 — in-flight dating (closes the supersede regression)
The R2 collapse used
completedAt // startedAt // createdAt. An in-flight run does not omitcompletedAt—ghmarshals Go zero time as the string0001-01-01T00:00:00Z, which//does not fall through. That sorted the live re-run as oldest, solastjudged the superseded finished run: green + re-run mid-flight →SUCCESS(#136 restored);CANCELLED+ replacement mid-flight →FAILURE(flap).Tip fix is correct and slightly stronger than a plain field reorder:
nulland the zero sentinel — then takemaxof remaining stamps.sort_by([(.at == ""), .at]) | last) so an undateable in-flight entry is preferred over a stale success.Fixtures
inflight_()fills the gaprun_()could not express (realstartedAt, empty conclusion, zero/null completion). Four pins: zero-sentinel re-run → PENDING; null completion → PENDING; CANCELLED + in-flight → PENDING; fully undateable in-flight → PENDING. Round-2 supersede fixtures still hold.Deferred (unchanged, OK)
CHECKS=PENDINGstill readingneeds-humanremains a taxonomy follow-up (state:merge-pending). This fix shrinks the invisible false-green case; remaining wrongness is visible and self-resolving.No blockers. Approve.
🔧 Changes requested — I agree with most; feedback below.
My round-3 blocker is resolved, and the fix is stronger than the field reorder I proposed: discarding both spellings of absent (
nulland the zero sentinel) and sorting an undateable entry last are the right calls.bash test/labels-reconcile.sh→ 48 passed, 0 failed;shellcheck -xclean; the liveDRY_RUN=1board still reproduces the corrections (#133 #132 #129 #128 #127 #120→needs-rebase).But choosing
maxover the reorder reopens the same hole in a narrower window, and I can date the window from this PR's own run history.Blocking:
maxmixes "when it started" with "when it ended", so a slow predecessor out-dates its live replacementFor a finished run that resolves to
completedAt; for an in-flight one, tostartedAt. Those are different quantities, so the comparison is not an ordering on runs. A predecessor cancelled by the concurrency group does not stop the instant its replacement starts — the runner has to receive the signal and wind down — sopredecessor.completedAt > successor.startedAtis the ordinary case, not a corner.Not hypothetical.
aa5a6ba, this PR's own round-1 tip:The superseding run started at 15:19:38; the run it cancelled did not finish until 15:19:51. Thirteen seconds in which the rollup holds a CANCELLED predecessor dated newer than the in-flight successor that replaced it. Probing
checks_stateon the tip with exactly that shape:Same two failure modes as round 3, same severity ordering: the second is #136 — mergeable, all bots approve,
state:needs-human, over a tree whose merge button branch protection has disabled because CI is mid-flight. The first is the re-run flap. Round 3 narrowed the window from "the whole life of the re-run" to "the predecessor's drain time"; 13s on a fastlabelsjob, but the drain is as long as the cancelled step's cleanup, and thecirun on that same head took over five minutes.Every fixture spaces the predecessor's completion before the successor's start (
run_ … 15:00:00Zvsinflight_ … 15:10:00Z), which is why 48/48 stays green through it — the same blind spot as round 2'srun_(), one field over.The fix is one word
max→first, keeping the sentinel filtering exactly as written. The list is already in preference order, and after theselectthe survivors are precisely the stamps the run actually carries, sofirstreads as "date it by when it began, and fall back only if it never recorded a beginning":Verified on the tip: 48 passed, 0 failed, and all three probes above go to
PENDING. It also keeps the case the supersede rule exists for —run_()fixtures carry onlycompletedAt, so a finished run still dates by completion and still beats an earlier in-flight entry.Worth a fixture whose predecessor completes after the successor's
startedAt, since that is the shape none of the current 48 can express.Siblings
Same defect, same one-word fix, verified in both: heavy-duty/cast#128 (49/49 with the patch, including its reverse-direction fixture) and heavy-duty/rig#88 (48/48). The reconcilers are byte-identical to box's apart from the
scope:*rows, as claimed.— automated review by
claude-bot-andresmgsl· heavy-duty-review-botVerdict: I have feedback.
Blocking: checks_state dates each run with the maximum of startedAt, createdAt, and completedAt. That compares a finished predecessor by completion time with an in-flight replacement by start time. A cancelled or successful predecessor can finish after its replacement starts, so the predecessor sorts newer and the active re-run is discarded; the context becomes FAILURE or SUCCESS instead of PENDING.
Please choose a consistent run-order timestamp, preferring startedAt and falling back to createdAt/completedAt, and add a fixture where the predecessor completes after the replacement starts.
Amendment to the round-3 reply — one added fixture, and a correction to a verification claim I made above.
Added fixture (test-only, reconciler unchanged). The supersede fixtures pinned "an in-flight re-run is the newer word". Nothing pinned the converse: a run that finished after an earlier in-flight entry is also the newer word, so the context is settled and reads
SUCCESSrather than stayingPENDING. That is the assertion that catches an over-correction — widening "an undateable entry sorts last" into "an in-flight entry always wins". Confirmed it fails under exactly that mutation, and that nothing else in the suite does. 48 → 49.Correction. Above I wrote that reverting the dating expression fails 3 of the 4 new fixtures. That is wrong — I had reverted the dating expression and the sort tiebreak together. Reverting only the dating expression fails exactly 2:
The other two are guarded by the sort tiebreak rather than the dating expression — the
null-completion case passes either way, since//does fall throughnull. The two code changes are now separately pinned and the fixture comments say which guards which. The claim was overstated, not the fix; both changes remain load-bearing, just for different fixtures than I said.bash test/labels-reconcile.sh→ 49 passed, 0 failed;test/cli.sh484,test/release.sh120; CI's exact shellcheck sweep clean;changelog-armedandchangelog-monotonicpass. CHANGELOG reads 19 → 49.Reviewers already on this PR: no new question for you beyond re-reviewing the tip (
cbc8f4f). Going to all three repos identically.Verdict: Request changes — tip still dates with
max.Tip
cbc8f4f(test-only amendment after R3). Reconciler still has:Blocker
maxis not an ordering on runs. Finished runs contributecompletedAt; in-flight ones contributestartedAt. A concurrency-group cancel does not stop the moment its replacement starts —predecessor.completedAt > successor.startedAtis the ordinary drain window (13s on this PR's own motivating tip). Undermax, the dying predecessor out-dates the live replacement for that whole window, so the collapse keeps the wrong entry and can re-certify green/needs-humanmid re-run (#136 again).Fix already landed on the siblings
cast#128 / rig#88 use preference-ordered
first(start before complete) plus drain-window fixtures. Port that expression here; keep the undateable-last sort and the converse finished-after-inflight fixture you already added.Still holds
Allowlist invert, zero-sentinel filter, STALE>MISSING, merge-next clear-only — not reopening those.
Round 4 — @claude-bot-andresmgsl and @codex-bot-andresmgsl were both right again, and my round-3 fix was still wrong. Taking the prescribed
first.The dating rule was never an ordering on runs
Round 3 replaced "date by completion" with "date by the newest stamp the run carries".
maxover[startedAt, createdAt, completedAt]resolves tocompletedAtfor a finished run andstartedAtfor a live one. Those are different quantities, so the comparison was never between like and like — it only looked correct because the round-3 fixtures compared runs in the same state.The consequence is the ordinary concurrency-group path, not an edge case. A cancelled run does not stop instantly; it drains after its replacement has already started, so
predecessor.completedAt > successor.startedAtis the normal shape. On this PR's ownaa5a6bathat window was 13 seconds. Inside it, the dead predecessor out-dated the live run replacing it:So round 3 fixed the zero-sentinel inversion and reintroduced the same false-green through a different door. Worth naming plainly: this is the second time the supersede rule restored the bug this PR exists to close, and both times the fixtures passed.
The fix
max→first, exactly as prescribed. The list is already in preference order, sofirstis "date it by when it began" — and a replacement always begins after the run it replaces, whatever order they finish in. That is a property of runs rather than of their states, which is what the previous two attempts lacked.Sentinel filtering and the undateable-sorts-last tiebreak are unchanged; this narrows the rule to a quantity that actually orders. Two fixtures pin the drain window in both colours (green predecessor and cancelled predecessor completing last). 49 → 51.
Re-probed, including every earlier fixture shape to show none regressed:
Verification
bash test/labels-reconcile.sh→ 51 passed, 0 failed.bash test/cli.sh→ 484 passed.bash test/release.sh→ 120 passed.shopt -s globstar dotglob; shellcheck -x bin/* **/*.sh, 19 files, plus thegit ls-filescoverage guard) → clean.changelog-armed,changelog-monotonic→ pass. CHANGELOG now records all three dating attempts and why the first two failed; count reads 19 → 51.checks_stateis byte-identical across box/rig/cast (md56e158b87e7ba); only each repo'sscope:*rows differ. All three PRs carry this same change.Re-requesting all three.
✅ Approved — I agree with all of this, no concerns.
Round-4 blocker resolved at
b9527d3.max→firstis the fix, and I re-probed rather than taking the reply's word — same three shapes, before and after:bash test/labels-reconcile.sh→ 51 passed, 0 failed.shellcheck -xover the reconciler and fixtures → clean. The twooverlap_fixtures pin the window none of the previous 49 could express, and the correction to the round-3 non-vacuity claim is right — reverting onlyat:fails 2, not 3, with thenull-completion case guarded by the sort tiebreak instead.Byte-identity holds again: outside the
scope:*rows, box's and cast's reconcilers diff to nothing.Non-blocking — for a follow-up, not this PR
run_()still cannot express a finished run's start, and one fixture now leans on that. Live rollups always carrystartedAton completed runs — cast#128's own tip:run_()emitscompletedAtonly, sotest/labels-reconcile.sh:290— "a finished re-run supersedes an earlier in-flight run" — is dated through thecompletedAtfallback, a path no realCheckRuntakes. Givingrun_()a realisticstartedAtflips exactly that one assertion and nothing else:PENDINGis the correct answer there: under start-ordering a run that began earlier cannot supersede one that began later, however late it finishes. So that fixture's name asserts the opposite of the rule the reconciler now implements, and it passes only because the helper is unfaithful. Shipped behaviour is right — everyoverlap_andinflight_case checks out — but this is the same helper-vocabulary blind spot for the third round running, now pointing the other way. Worth makingrun_()carry astartedAtand restating that fixture in terms of starts.— automated review by
claude-bot-andresmgsl· heavy-duty-review-botVerdict: I agree with everything and have no additional feedback.
Verdict: Approve — I agree with this as-is (R4 tip
b9527d3).Prior REQUEST_CHANGES on
cbc8f4fstood onmaxdating. Tip ports the sibling fix.R4 closes the drain window
firstover preference-ordered[startedAt, createdAt, completedAt]after dropping null/zero-sentinel — date by when the run began, not by mixed completion-vs-start stamps.overlap_fixtures pin predecessor completing after successor start (CANCELLED and green forms) →PENDING.Still holds
Allowlist invert (unknown outcomes block), latest-wins collapse by workflow+name, STALE before MISSING whole-round collect,
merge-nextclear-only when notneeds-human, UNKNOWN mergeability not treated as conflict. Reconciler matches cast/rig moduloscope:*rows. CI green. No remaining blockers.