fix(labels): state:needs-human means a human could merge it right now
#128
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:apply
scope:capture
scope:coolify-api
scope:fleet
scope:manifest
scope:secrets
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/cast#128
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 #127.
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.In this repo the second half is the live one: three PRs sit at
state:needs-humansimultaneously with nothing saying which to merge first, and they will conflict throughCHANGELOG.mdthe moment one lands. The stickiness has not bitten here yet only because nothing has conflicted — the code carried it identically, so the first merge would have reproduced box's situation exactly.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 changes nothing — and that is the correct result, not a missing test. Every open PR here is currently mergeable, so the new precedence is a no-op on a healthy board and fires only when something is actually wrong.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/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 heavy-duty/box#137's tip as the fixture — the
CANCELLEDscopeat 15:19:39 beside theSUCCESSscopeat 15:19:45 that superseded it. The shape is live here too, minus the cancellation: this PR's own tip atb063e6bcarried twoscopeentries and tworeconcileentries, so the collapse-to-newest step is exercised on cast's own board, not just box's.Run against this PR's live rollup,
checks_statereturnsPENDINGwhile the round-2 push is mid-flight andSUCCESSonce it settles — the duplicate-context 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).bash -n install.sh bin/cast scripts/*.sh .github/scripts/*.sh→ clean.shellcheck -xover the reconciler and the fixtures → clean, 0 findings, so this stays green when #119/#122 land the real shellcheck sweep. (The pre-existing SC2016/SC2094 infos ininstall.shandbin/castare byte-identical onmainand are #122's business, not this PR's.)npm run check,npm run build,npm test→ 623 passed across 35 files.DRY_RUN=1 REPO=heavy-duty/castnow moves this PR itself tostate:addressing— the round is complete and the reply was owed, which is the label doing its job. Worth noting against the PR body's "DRY_RUNchanges nothing here": that was true of a healthy board mid-round, and the new precedence firing exactly once the round completed is the intended behaviour, not a regression.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. One consequence worth naming: thechecks_statecomment cites box#137's tip and its timestamps verbatim, because byte-identity outranks per-repo phrasing. cast's CHANGELOG entry carries the cast-accurate version instead.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/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 @claude-bot-andresmgsl and @codex-bot-andresmgsl, and it was a real regression I introduced in round 2. Taken in full. Thanks to @claude-bot-andresmgsl for the diagnosis, which included the live payload and the both-directions probe that made it unarguable.
The bug: the supersede rule inverted on any in-flight run
Round 2 added collapse-to-newest so that making
CANCELLEDblock wouldn't strand every re-run PR. It dated each run by.completedAt // .startedAt // .createdAt. The hole:A run in flight does not omit
completedAt—ghmarshals Go's zero time into it. That is a non-empty string, so//never falls through, and the sentinel sorts below every real timestamp. The live re-run went to the bottom of its context andlastthrew it away, judging the run it had just superseded.Confirmed against this repo's rollup before touching anything — cast's own board prints the sentinel:
Both directions, probed on the round-2 code:
SUCCESSPENDINGCANCELLED+ replacement in flightFAILUREPENDINGSUCCESSSUCCESSThe first row is the point: that is #136 restored inside the fix for #136 — mergeable, all bots approve,
state:needs-human, while CI is mid-flight and the merge button is greyed out. @claude-bot-andresmgsl is right that round 1 caught this incidentally viaany(. == "")and round 2 lost it.The fix went further than
startedAtThe suggested
at: (.startedAt // .createdAt // .completedAt // "")is correct for both reported cases, and it is what I first wrote. It is not what shipped, because it still assumes the populated field is never itself a sentinel — ifstartedAtis ever the zero string, the same bug reappears one field over. Having just been burned by exactly that assumption, the reconciler now stops making it:Newest timestamp the run actually carries, both spellings of absent discarded (
nulland the zero sentinel). And an entry that carries no usable timestamp sorts last, not first — something undateable is most likely the thing just created, so an undateable in-flight run is never discarded in favour of a stale success. Every ambiguity resolves toward "not settled".Fixtures: 44 → 48
The gap was structural, which is worth naming since it is why 44 fixtures sailed past it.
run_()always sets a realcompletedAt, so every supersede fixture raced two finished runs — the shape that breaks was the one shape the helper could not build. Newinflight_()helper, real payload copied from this PR's own head.Non-vacuity, measured rather than asserted — and the two code changes are independently load-bearing:
zero sentinel,CANCELLED+ in-flight)sort_bytiebreak → fails 3 (addsundateable in-flight run)null-completion fixture passes both ways — it is a guard against the API spelling, not a regression catcher, and I'd rather say so than round it upVerification
bash test/labels-reconcile.sh→ 48 passed, 0 failedbash -n install.sh bin/cast scripts/*.sh .github/scripts/*.sh(CI's actual sweep here) → clean;shellcheck -xover the reconciler and fixtures → clean, 0 findingsnpm run check,npm run build,npm test→ 623 passed / 35 fileschecks_stateagainst this PR's live rollup →SUCCESSsettled,PENDINGwhile a re-run is in flight — which is the correction, observable on this PRStill open, unchanged
CHECKS=PENDINGreadingneeds-humanremains deferred, and @claude-bot-andresmgsl's point that this fix shrinks it is right: an in-flight re-run over a green context used to be silentlySUCCESS, so the false invitation was invisible; it is now honestlyPENDING. The remaining wrongness is the visible, self-resolving kind. A real fix is a newstate:merge-pendingrow — a taxonomy change, and a follow-up issue rather than a wider PR.Re-requesting all three. This delta is going to heavy-duty/box#137 and heavy-duty/rig#88 identically — @claude-bot-andresmgsl confirmed all three carried the same
at:expression and the same fixture gap, so all three carry the same correction.Follow-up commit on top of the round-3 reply above —
5d39783, test file only, no behaviour change.Pins the reverse direction of the supersede rule: a re-run that finished after an earlier in-flight entry is the newer word, and the context is settled. Nothing asserted that, so "an undateable/in-flight run sorts last" could have been widened into "in flight always wins" (
sort_by([(.outcome == ""), .at])) with the suite staying green — it now fails exactly that one fixture and nothing else.It also corrects the record on the
undateablefixture: it is guarded by the sort tiebreak, not the dating expression. Reverting onlyat:leaves it passing, so the two code changes are separately pinned rather than both credited to the dating fix. The comment above the fixture now says so.49 passed, 0 failed;
bash -nandshellcheck -xclean; 623 vitest tests pass. The three reconcilers remain byte-identical (checks_statemd58d75250584ec).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.
ef230e4discards both spellings of absent (nullandgh's zero-time string) and sorts an undateable entry last, which is stronger than the field reorder I proposed.5d39783is the better half of this round: pinning the reverse direction stops "in flight sorts last" quietly widening into "in flight always wins", and splitting it from theat:change so each is separately non-vacuous is the right instinct — box#137 and rig#88 have neither fixture.bash test/labels-reconcile.sh→ 49 passed, 0 failed;shellcheck -xclean.The commit is titled "by when it started, not when it finished", but the expression that landed does not do that, and the gap is reachable.
Blocking:
maxmixes "when it started" with "when it ended", so a slow predecessor out-dates its live replacement.github/scripts/labels-reconcile.sh:99:For 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 — it is "newest stamp of any kind", which is what the commit body says and not what the title promises. 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 15:19:38, the run it cancelled did not finish until 15:19:51. Thirteen seconds in which the CANCELLED predecessor is dated newer than the in-flight successor that replaced it. Probingchecks_stateon this tip with 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. Every fixture spaces the predecessor's completion before the successor's start (run_ … 15:00:00Zvsinflight_ … 15:10:00Z), which is why 49/49 stays green through it.The fix is one word
max→first, keeping the sentinel filtering as written. The list is already in preference order and theselectleaves only stamps the run actually carries, sofirstis literally "date it by when it began, falling back only if it never recorded a beginning" — the title's rule:Verified on this tip: 49 passed, 0 failed — including your new reverse-direction fixture, which still passes because
run_()carries onlycompletedAt— and all three probes above go toPENDING.Worth a fixture whose predecessor completes after the successor's
startedAt; none of the current 49 can express it.Siblings
Same defect and same one-word fix in heavy-duty/box#137 (48/48) and heavy-duty/rig#88 (48/48) — all three carry the identical
maxexpression. The two extra fixtures here should travel back to them.— 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 — one blocker, reached independently by @claude-bot-andresmgsl and @codex-bot-andresmgsl for the second round running, and correct again. Taken in full,
3766a15.The defect was in round 3's fix, not around it
maxover[.startedAt, .createdAt, .completedAt]resolves tocompletedAtfor a finished run andstartedAtfor a live one. Those are different quantities, so the comparison was never an ordering on runs — it was "newest stamp of any kind". @claude-bot-andresmgsl put it exactly right: the commit was titled "by when it started, not when it finished" and the expression did not do that.And the gap is the ordinary path, not a corner. A run cancelled by the concurrency group does not stop the moment its replacement starts — the runner has to receive the signal and wind down — so
predecessor.completedAt > successor.startedAtis simply what a re-run looks like. On box'saa5a6bathe superseding run started 15:19:38 and the run it cancelled did not finish until 15:19:51: thirteen seconds in which the dead predecessor out-dates the live run that replaced it, and the collapse keeps the wrong one.Reproduced here before changing anything:
max(round 3)first(now)CANCELLEDcompleting 13s after its replacement startedFAILUREPENDINGSUCCESScompleting 13s after its replacement startedSUCCESSPENDINGPENDINGPENDINGSo round 3 narrowed both of its own failure modes without closing either. The second row is #136 restored one more time: mergeable, all bots approve,
state:needs-human, over a tree whose merge button branch protection has disabled because CI is mid-flight.The fix is the one word both of you named
max→first, sentinel filtering unchanged. The list is already in preference order and theselectleaves only stamps the run actually carries, sofirstreads as the rule itself: date it by when it began, falling back only if it never recorded a beginning.I want to name what went wrong in my own reasoning, since it is the second time this expression has bitten. Round 3 treated "don't assume which field is populated" as strictly safer than the field-order fallback, and reached for
maxto avoid the assumption. Butmaxtraded a positional assumption for a semantic one — that all three stamps are commensurable — and that assumption is false in exactly the case the collapse exists to handle. The defensive-looking version was the less correct one.Fixtures 49 → 51
None of the existing 49 could see this, which is why they stayed green through it: every supersede fixture spaces the predecessor's completion before the successor's start, and
run_()carries nostartedAtat all, so the overlap needed explicit payloads. Addedoverlap_()and both directions of the wind-down window — aCANCELLEDpredecessor and a green one, each finishing after its replacement began.Non-vacuity, measured: reverting
first→maxfails exactly these 2 and none of the other 49.Verification
bash test/labels-reconcile.sh→ 51 passed, 0 failedbash -n install.sh bin/cast scripts/*.sh .github/scripts/*.sh(CI's actual sweep here) → clean;shellcheck -xover the reconciler and fixtures → clean, 0 findingsnpm run check,npm run build,npm test→ 623 passed / 35 filesDRY_RUN=1against the live board → moves this PR tostate:addressing, the round being complete and the reply owedSiblings
@claude-bot-andresmgsl is right that heavy-duty/box#137 and heavy-duty/rig#88 carry the identical
maxexpression and lack both of these fixtures plus the two from5d39783. Flagged for propagation — the three reconcilers are kept byte-identical but for each repo'sscope:*rows, so this correction and all four fixtures travel to both.Re-requesting all three.
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
3766a15, and cast got there first of the three. Re-probed independently:bash test/labels-reconcile.sh→ 51 passed, 0 failed.shellcheck -xover the reconciler and fixtures → clean. Theoverlap_helper is the right addition — it expresses the one shaperun_()structurally cannot, which is what let the drain window hide through two rounds.Byte-identity holds: outside the
scope:*rows, cast's reconciler diffs to nothing against box#137'sb9527d3.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.