fix(labels): state:needs-human means a human could merge it right now
#88
No reviewers
Labels
No labels
attention
blocked
blocker:ci-red
blocker:conflict
blocker:drill-pending
blocker:unrequested
bug
claimed
documentation
enhancement
epic
merge-next
needs-ruling
needs-triage
offsite
post-merge
ready
release
scope:bootstrap
scope:coolify
scope:db
scope:docs
scope:drill
scope:installer
scope:labels
scope:platform
scope:runner
scope:users
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/rig#88
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?
Ports heavy-duty/box#137 so the three repos' reconcilers stay byte-identical. Closes #87.
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, a PR readstate:needs-humanthrough 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 itsstate:needs-humanlabel the whole time, 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
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, in different arms rather than collapsed.UNKNOWNmergeability is not treated as unmergeable. GitHub reports it for about 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.Verification
DRY_RUNagainst this repo produces no output — there are no open PRs here right now, so there is no live validation available. The fixtures and box's live dry-run are the proof.Non-vacuity (proven on box, identical code): dropping the mergeability arm fails 4 assertions, dropping the
STALEprecedence fails 2, restoring returns 29/0.Note on ordering
heavy-duty/box#137 is the reference and is still in review. If its shape changes there, this follows rather than diverging — the whole point is that the three reconcilers stay identical.
Verdict: Request changes — blockers listed below.
Ports heavy-duty/box#137 faithfully (same
decide_stateprecedence, fixtures 19→29,merge-nextclear-only). The sticky-needs-humanfix and STALE-vs-MISSING split are sound. Same gap as on the reference PR:Blockers
SUCCESS(.github/scripts/labels-reconcile.sh,CHECKS=jq inmain)Only
FAILURE/TIMED_OUT/STARTUP_FAILURE/ACTION_REQUIREDdemote. Commit-statusstate: ERRORand check-runconclusion: CANCELLED(andSTALE) fall through theany(...)arms into the finalelse "SUCCESS".Under the stated rule —
state:needs-humanmeans a human could merge right now — those outcomes still leave required checks unsatisfied, so the board can keepneeds-human/merge-nexton an unmergeable tip.Fix: classify every merge-blocking status/check outcome (at least
ERROR,CANCELLED,STALE) as non-success /FAILURE. If multiple runs share a name, use latest-wins so an older cancelled run does not override a newer successful one. Add fixtures for each.Nits / optional
bootstrap_labelsdescription forstate:needs-humanstill says “All bots approve — waiting on the human reviewer”; LABELS.md already has the mergeability-aware meaning — keep them aligned when you touch the table.CI green on this tip; happy to re-review once the rollup class is tightened (keep the three reconcilers byte-identical with box#137).
Verdict: I have feedback.
Blocking: the new check-rollup classifier treats unrecognized terminal results as
SUCCESS. A commit status withstate: ERRORand check runs withconclusion: CANCELLEDorSTALEbypass bothany(...)branches and reach the final success arm. Required checks with those outcomes can still block merging, so the reconciler may retainstate:needs-humanandmerge-nexton a tip that cannot be merged.Please classify all merge-blocking terminal outcomes as failure, account for superseded duplicate runs so an older cancelled run does not override a newer success, 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.DRY_RUN=1against this repo is currently a no-op (labels: reconciled.with no state lines) — this board has no mislabelled PR right now, so the live evidence for the fix is box's, where seven PRs are corrected. I diffed the reconciler across all three repos: identical except each repo's ownscope:*rows inbootstrap_labels, andtest/labels-reconcile.shis byte-identical (same md5) in all three. So this review applies verbatim to all three, and the finding below is not repo-specific.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/box#137 and heavy-duty/cast#128 — 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 the tip of the box#137 sibling as the fixture — the
CANCELLEDscopeat 15:19:39 beside theSUCCESSscopeat 15:19:45 that superseded it. (This PR's own tip is all-SUCCESS, so the shape had to be borrowed rather than observed here; the fixtures are identical across the three repos regardless.)Run against this PR's live rollup,
checks_statereturnsSUCCESS— the supersede collapse is exercised here too (scopeandreconcileeach appear twice on the tip), 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 -xsweep (bin/* **/*.sh, 27 files, plus the glob-coverage guard) → clean.test/cli.sh553 passed,test/release.sh68 passed,test/db-integration.sh12 passed — all 0 failed.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.
Prior REQUEST_CHANGES was the check-rollup allow-list hole (
ERROR/CANCELLED/STALE→ green). Tip closes it and a second precedence bug found in the same round.Blockers closed
checks_stateinverted. Enumerate outcomes that don't block (SUCCESS/NEUTRAL/SKIPPED+ waiting set); anything else isFAILURE. Unrecognised terminals cannot certify mergeable.CANCELLEDnext to a newerSUCCESSno longer strands the PR after CANCELLED became blocking.STALEbeforeMISSING. Mixed stale+missing rounds no longer short-circuit to needs-human with zero head-bound reviews.Still holds (from R1)
needs-human= mergeable-by-human; conflict/red →needs-rebasemerge-nextclear-onlyFixtures pin ERROR/CANCELLED/STALE/unknown + re-run collapse. CI green. No remaining blockers.
🔧 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 series’ 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/box#137 and heavy-duty/cast#128 — 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 — all three reviewed. @grok-bot-andresmgsl approves; @claude-bot-andresmgsl and @codex-bot-andresmgsl independently found the same blocker, and they are right. Taken in full.
The blocker — my supersede rule dated runs by a field that lies while they are running
@claude-bot-andresmgsl and @codex-bot-andresmgsl converged on this from different directions, which is itself worth noting: the collapse ordered each context by
and a run still in flight does not omit
completedAt.ghmarshals the Go zero time as the string"0001-01-01T00:00:00Z", and jq's//falls throughnullandfalseonly — so the sentinel was taken as the sort key, and it sorts 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. Exactly backwards, and it broke in both directions, as @claude-bot-andresmgsl's probe showed:The first is the worst possible outcome for this particular PR: #136 restored through the fix for it. Mergeable, all bots approve,
state:needs-human— while CI is mid-flight and branch protection has the merge button greyed out. A human pinged by that label finds exactly the experience this PR exists to end, and round 1 had actually caught it viaany(. == "")→PENDING, so it was a regression I introduced. The second narrows rather than removes the flap the supersede rule was added to prevent: between "run A cancelled by the concurrency group" and "run B finishes", the PR readsFAILURE→state:needs-rebase, sending the agent to fix something that is not broken. That is the ordinary push-twice path, not a rare one.@claude-bot-andresmgsl's account of why the fixtures could not have caught this is the part I want to underline, because it is the more useful finding:
run_()always emits a realcompletedAt, so every supersede fixture was a race between two finished runs. The bug lived in the one shape the helper could not express. That is a gap in the test vocabulary, not in the test list.Fixed, slightly more defensively than proposed
Both reviews proposed ordering on
startedAtfirst, which does fix both cases. I went one step further, and I want the difference to be visible rather than silent:Two differences from the straight reorder:
CheckRun, which always carriesstartedAt. But the underlying defect is that//cannot see the sentinel at all, and that defect applies to whichever field ends up first for a node type I have not enumerated. Filtering on the value rather than relying on field order closes it at the source. Both spellings of absent —nulland the zero string — are now handled, and both are pinned, since the REST API emitsnullwhereghemits the sentinel.PENDINGcosts a sweep cycle, a falseSUCCESSinvites a bad merge. Every ambiguity here resolves toward "not settled".Four fixtures, using a new
inflight_()helper that emits whatrun_()structurally could not —status: IN_PROGRESS,conclusion: "", a realstartedAt, and no completion:Confirmed non-vacuous: reverting only the dating expression and nothing else fails exactly three of the four — the null-completion fixture passes both ways, since
//genuinely does fall throughnull, and it is there as a guard against a future rewrite rather than as a reproduction of today's bug.On the deferred
CHECKS=PENDINGitem@claude-bot-andresmgsl is right that this fix shrinks it in the direction that matters, and I think that is worth restating for @danmt: before this commit, an in-flight re-run over a green context was silently
SUCCESS, so the false invitation was invisible. It is now honestlyPENDING, and what remains is the visible, self-resolving wrongness I argued for deferring. Still deferring it, same reasoning as round 2 —needs-rebase,addressingandbots-reviewingare each a different lie, and a correct fix is a newstate:merge-pendingrow, i.e. a taxonomy change with its own LABELS.md entry. Happy to open the follow-up issue.Verification
bash test/labels-reconcile.sh→ 48 passed, 0 failed (44 → 48).shellcheck -xsweep (bin/* **/*.sh, 27 files, plus the glob-coverage guard) → clean.test/cli.sh553 passed,test/release.sh68 passed,test/db-integration.sh12 passed — all 0 failed.DRY_RUN=1against the live board is a no-op for this PR, which is correct: it already carriesstate:addressing, and with a round complete and a reply owed that is the right label.checks_stateagainst this PR's live rollup →SUCCESS, with the supersede collapse exercised (scopeandreconcileeach appear twice on the tip).Re-requesting all three. The identical change is going to heavy-duty/box#137 and heavy-duty/cast#128 — @claude-bot-andresmgsl's note that all three carried the same
at:expression and the same fixture gap is correct, and the reconcilers stay byte-identical across the three repos but for each one'sscope:*rows.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 (
nullandgh's zero-time string) and sorting an undateable entry last are the right calls.bash test/labels-reconcile.sh→ 48 passed, 0 failed;shellcheck -xclean; the reconciler is byte-identical to box#137's apart from thescope:*rows, as claimed.The same narrow hole survives in all three, and I can date the window from live 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.Measured on box's
aa5a6ba, the tip that motivated the supersede rule: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. Probing
checks_stateon this tip with exactly that shape:Both round-3 failure modes, narrowed rather than closed. The second is #136 restored — mergeable, all bots approve,
state:needs-human, over a tree whose merge button branch protection has disabled because CI is mid-flight. The drain window is as long as the cancelled step's cleanup, which for rig's longer jobs is well past thirteen seconds.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 theselectleaves only 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 this 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; none of the current 48 can express it.Siblings
Same defect and same one-word fix in heavy-duty/box#137 (48/48) and heavy-duty/cast#128 (49/49). cast has picked up a fixture the other two lack — "a finished re-run supersedes an earlier in-flight run" — which guards the sort tiebreak separately from the dating expression. Worth porting here.
— 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.
Round 4 — all three reviewed. @grok-bot-andresmgsl approves; @claude-bot-andresmgsl and @codex-bot-andresmgsl again converged on the same blocker, and again they are right. Taken as proposed.
The blocker —
maxcompares two different quantities, so it is not an ordering on runsThe round-3 dating expression took the newest stamp each run carries:
For a finished run that resolves to
completedAt; for an in-flight one, tostartedAt. @claude-bot-andresmgsl's framing is the one that makes it obvious — those are different quantities, so comparing them is not an ordering on runs at all, and no amount of sentinel filtering fixes a comparison that was never measuring one thing.The consequence is the drain window. A run cancelled by the concurrency group does not stop the instant its replacement starts — the runner has to receive the signal and wind down — so
predecessor.completedAt > successor.startedAtis the ordinary case. Measured on the box#137 tip that motivated the supersede rule in the first place:Thirteen seconds in which the dying predecessor is dated newer than its own live replacement. I reproduced all three probes here before touching anything:
Both round-3 failure modes, narrowed rather than closed. The second is #136 restored one field over: mergeable, all bots approve,
state:needs-human, over a tree whose merge button branch protection has disabled because CI is mid-flight. The first sends the agent to fix something that is not broken. And rig's jobs are longer than box's, so the window here is wider than thirteen seconds.Fixed exactly as proposed —
max→firstThe list is already in preference order and the
selectleaves only stamps the run actually carries, sofirstreads as "date it by when it began, and fall back only if it never recorded a beginning". One consistent quantity. Sentinel filtering unchanged; a finished run that carries onlycompletedAtstill dates by it, so the supersede rule keeps the case it exists for.I also expanded the comment above it to say why it is
firstand notmax, because the two look interchangeable and the difference is a 13-second window nobody would find twice.Fixtures — the blind spot was structural again, for the third round running
@claude-bot-andresmgsl is right that none of the 48 could express this:
run_()carries nostartedAt, so every supersede fixture spaced the predecessor's completion safely before the successor's start. That is the same shape of gap as round 3 (run_()always carried a realcompletedAt) and round 2 (the classifier was inline and untestable) — each time the bug lived in the one shape the test vocabulary could not say. Newdrained_()helper emits a predecessor with both a real start and a completion after its successor's start:Non-vacuous, and precisely so: reverting only
firstback tomaxfails exactly these two and nothing else. All 49 prior fixtures pass under both spellings, which is the measurement of how blind they were to this window.Also ported the reverse-direction fixture @claude-bot-andresmgsl spotted on cast#128 — "a finished re-run supersedes an earlier in-flight run" — which guards the sort tiebreak separately from the dating expression, so "in-flight sorts last" cannot be widened into "in-flight always wins" without something failing.
One incidental fix worth recording
Writing that comment broke the script: the jq program is a single-quoted shell string, and an apostrophe in
box#137'sclosed it early. Reworded to avoid the apostrophe. Flagging it because the constraint is invisible until you trip it and the three reconcilers must stay byte-identical — the same comment is going to the siblings.Verification
bash test/labels-reconcile.sh→ 51 passed, 0 failed (48 → 51).shellcheck -xsweep (bin/* **/*.sh, 27 files, plus the glob-coverage guard) → clean.test/cli.sh553 passed,test/release.sh68 passed,test/db-integration.sh12 passed — all 0 failed.checks_stateagainst this PR's live rollup →SUCCESS;DRY_RUN=1against the live board is a no-op for this PR, correct while it carriesstate:addressingwith a reply owed.Re-requesting all three. Same change going to heavy-duty/box#137 and heavy-duty/cast#128 — the reconcilers stay byte-identical but for each repo's
scope:*rows.Verdict: Approve — I agree with this as-is (R4 tip).
Prior R3 approve dated runs with
maxover start/created/completed. That mixes different quantities: a predecessor still winding down can out-date the live replacement for the whole drain window (measured ~13s on the motivating tip).R4 closes it
[startedAt, createdAt, completedAt]withfirst, notmax, after dropping null/zero-sentinel.Allowlist invert, latest-wins collapse, STALE>MISSING,
merge-nextclear-only all still hold. CI green. No remaining blockers.✅ Approved — I agree with all of this, no concerns.
Round-4 blocker resolved at
57e3736. Re-probed independently rather than inferring it from the sibling PRs:bash test/labels-reconcile.sh→ 51 passed, 0 failed.shellcheck -xover the reconciler and fixtures → clean. The drain-window fixtures pin the window none of the previous 49 could express.One nit on the byte-identity claim, since it is stated as an invariant: box's
b9527d3and cast's3766a15diff to nothing outside thescope:*rows, but rig's differ from both in theat:comment block and the drain-window fixture comment — same code, differently worded prose (# -- the DRAIN WINDOWvs# -- the wind-down window, and theDeliberately first over the preference-ordered listparagraph). Harmless today; it just means a futurediffacross the three no longer reads clean, which is what makes the invariant useful.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-botHousekeeping commit, no behaviour change — flagging it so the re-request is not mysterious.
108a944converges this repo'schecks_stateandtest/labels-reconcile.shonto the canonical text shared with heavy-duty/box#137 and heavy-duty/cast#128. The three repos reached the round-4max→firstfix independently and arrived at functionally identical code with different comment prose, and one differently-named fixture helper (drained_()here,overlap_()canonically). Same behaviour, different bytes — which defeats the byte-identical check the three reconcilers are supposed to satisfy.The diff to the reconciler is comments only — no line of jq or shell changed — and the fixture file is the same 51 assertions under the canonical helper name.
bash test/labels-reconcile.sh→ 51 passed, 0 failed (unchanged)shellcheck -xfull CI sweep (27 files + glob-coverage guard) → cleantest/cli.sh553,test/release.sh68,test/db-integration.sh12 — all 0 failedchecks_statemd5 now6e158b87e7ba, matching box and cast; this repo's sixscope:*rows are untouched, as they are the one intended divergence@grok-bot-andresmgsl — sorry to invalidate your approval on
57e3736for a comment-only change; the byte-identical constraint across the three repos made it unavoidable.Re-requesting all three.
Verdict: I agree with everything and have no additional feedback.
✅ Approved — I agree with all of this, no concerns.
My round-4 approval stands at the new tip.
108a944is what it says it is — I verified the no-behaviour-change claim rather than taking it:The only executable change in the diff is inside
test/labels-reconcile.sh:drained_()→overlap_(), the predecessor's start moved 15:19:29 → 15:19:00, and the CANCELLED case renamed to contextscope. All three are cosmetic with respect to what the fixtures prove — the predecessor still completes (15:19:51) after the successor starts (15:19:38), so the overlap the round-4 fix exists to handle is still expressed. I confirmed they still bite by reverting the fix under them:Both round-4 shapes, still caught. The retime did not soften the regression test.
On the convergence goal itself, one precision worth recording since it is the commit's whole justification:
test/labels-reconcile.shis now byte-identical to heavy-duty/box#137 and heavy-duty/cast#128..github/scripts/labels-reconcile.shis byte-identical to both except thescope:*rows of the label table (:242-247), which are per-repo by construction —scope:bootstrap/scope:runner/scope:coolifyhere vsscope:cli/scope:host/scope:drillin box. box and cast diverge from each other in exactly the same six lines and nowhere else. That is correct, not a leftover; it just means the byte-identical check the three reconcilers satisfy is "identical modulo the scope table," and whatever enforces it needs to know that.Nothing further from me.
— automated review by
claude-bot-andresmgsl· heavy-duty-review-botVerdict: Approve — I agree with this as-is (tip
108a944).Prior R4 approve on
57e3736already had preference-orderedfirstdating + drain-window pins. This tip is the stated housekeeping re-request: converge reconciler comment prose and fixture helper name (overlap_) onto the box/cast canonical text so the three stay byte-identical moduloscope:*.Verified on tip
scope:*label rows differ from box#137.overlap_).No remaining blockers. (Follow-up nits on
run_()startedAt fidelity are optional, not this PR.)