refactor(labels): split PR labels into state (whose ball) and blocker (what is in the way) #138

Merged
dan-claude-bot merged 5 commits from fix/labels-two-axis into main 2026-07-20 18:30:04 +00:00
dan-claude-bot commented 2026-07-20 17:29:38 +00:00 (Migrated from github.com)

The problem

The PR board projected independent facts onto one totally-ordered label.

Mergeability, check status and the review round move independently — a PR can
be conflicted and red and stalled at the same time. A total order has to
pick a winner, so the rest silently vanished off the board. Every precedence
bug this machine has had lived on that ordering: needs-human surviving a
conflict (#136), MISSING swallowing STALE.

state:needs-rebase was the clearest casualty. It fired on both a
conflict and a failing check — two conditions that need opposite work. It
told an agent to rebase when what it actually owed was a bug fix.

Live evidence from this repo's own board: #120 is conflicted AND red, and
under the old design could only ever say one of them.

The two axes

state:*whose ball is it, exactly one, and now purely about reviews:

Label Whose ball
state:building the agent, still building (draft)
state:bots-reviewing the bots — a request is live and an answer is coming
state:addressing the agent — reply, fix, or ask
state:needs-human the human reviewer

blocker:*what is in the way, additive, facts about the branch:

Label Means The agent owes
blocker:conflict GitHub says CONFLICTING a rebase
blocker:ci-red a check failed a fix (which a rebase will not provide)
blocker:unrequested somebody owes a verdict and nobody was asked the ask

Blockers are a set, so there is no precedence between them to get wrong.
The ordered axis keeps only reviews, which is the one place an ordering is
genuinely meaningful.

The joining rule

state:needs-human requires zero blockers. Any blocker at all means the
work is the agent's — whatever the review round says — and the blocker label
says which work it is. Nothing else in decide_state reads the branch,
which is what keeps round_state purely about reviews.

UNKNOWN mergeability is deliberately not a conflict: GitHub reports it
for about a minute after every merge while it recomputes, and flapping every
open PR on each merge would be worse than the bug. A failed read of either
branch fact degrades to the same "do not know" value.

state:bots-reviewing tightens

It now means strictly a request is live. A ready PR nobody was asked to
review used to read "waiting on the reviewers" for the 48 hours it took the
stale sweep to notice. It is now state:addressing + blocker:unrequested,
because the agent owes the ask. Drafts are exempt (the bots ignore drafts by
design), as is an explicit human request — a maintainer claiming a PR early is
deliberate, not a dropped ball.

The retirement

state:needs-rebase is retired. The reconciler carries a RETIRED array and
strips those labels on sight, so a retirement heals the board instead of
stranding a label nothing recomputes. Both axes converge in a single
gh issue edit call, so a PR never flickers through a half-applied board.

Second commit: a failed rollup read must not read as "nothing is failing"

Caught while this PR was already open, in the one place the #136 fix never
looked. When gh pr view returned nothing, the fallback left the
statusCheckRollup key absent, and checks_state collapsed that into the
same NONE as a PR that genuinely has no checks. NONE blocks nothing — so a
transient API failure presented as mergeable-by-a-human. That is the
unknown-certified-as-green shape this whole machine exists to stop.

checks_state now distinguishes them:

Input Result Means
key absent UNREADABLE the read failed — we know nothing
statusCheckRollup: [] NONE this PR honestly has no checks

The sweep leaves an UNREADABLE PR exactly as it is rather than
relabelling on facts it did not read. Deliberately not a blocker:
blocking would flap the entire board on one bad API call, and the next tick is
fifteen minutes away, not fifteen hours.

Round 1 review fixes

Three findings from claude-bot and codex-bot, all taken (commit 27cfc5b):

  1. A missing label broke the whole edit. gh issue edit rejects the entire
    call on one unknown name, and this PR is what put state and blocker:* into
    a single edit — so the first sweep after merge would have converged
    nothing on exactly the PRs this change is for. Adds are now filtered
    against the repo's real label set (read once per sweep); removals are not
    filtered (built from has_label, so they provably exist); an unreadable
    label set does not filter at all, because a failed read must not strip the
    board.
  2. blocker:unrequested missed the stale round. MISSING and STALE are
    both verdicts this head does not have, and the agent owes an ask either
    way. A head-current BLOCK or comment is deliberately not owed —
    answering those is what state:addressing already means.
  3. LABELS.md "Leaves when" for state:addressing had lost the common
    no-blocker shape in the rewrite; restored and extended.

Evidence

DRY_RUN=1 REPO=heavy-duty/box bash .github/scripts/labels-reconcile.sh
against the live board:

PR New state Blockers
#133 state:addressing blocker:conflict
#132 state:addressing blocker:conflict
#129 state:addressing blocker:conflict
#128 state:addressing blocker:conflict
#127 state:addressing blocker:conflict
#120 state:addressing blocker:conflict, blocker:ci-red

Every one of these is conflicted, so none of them is a human's to merge, and
each now says so and says why. #120 carries both blockers at once
precisely the case the old single label could not express.

An earlier run of this same command, before the board moved under it, also
showed #127 and #120 being demoted off a stale state:needs-human
(cleared state:needs-human); the production reconciler on main has since
stripped their state:* labels, so there is nothing left to clear. The
decision this branch makes for them is identical either way.

Tests

  • test/labels-reconcile.sh: 68 passed, 0 failed (was 51)
  • test/cli.sh: 484 passed, 0 failed
  • test/release.sh: 120 passed, 0 failed
  • CI's exact shellcheck sweep (globstar dotglob, 19 files): clean
  • changelog-armed.sh and changelog-monotonic.sh (STRICT): pass

Note for reviewers

This refactor is intended to land byte-identically in heavy-duty/rig and
heavy-duty/cast (modulo each repo's scope:* rows in bootstrap_labels and
repo-specific changelog prose). If you prompt a substantive code change here,
it propagates to all three — worth weighing when suggesting variants.

## The problem The PR board projected **independent facts onto one totally-ordered label**. Mergeability, check status and the review round move independently — a PR can be conflicted *and* red *and* stalled at the same time. A total order has to pick a winner, so the rest silently vanished off the board. Every precedence bug this machine has had lived on that ordering: `needs-human` surviving a conflict (#136), `MISSING` swallowing `STALE`. `state:needs-rebase` was the clearest casualty. It fired on **both** a conflict and a failing check — two conditions that need *opposite* work. It told an agent to rebase when what it actually owed was a bug fix. Live evidence from this repo's own board: **#120 is conflicted AND red**, and under the old design could only ever say one of them. ## The two axes `state:*` — **whose ball is it**, exactly one, and now purely about reviews: | Label | Whose ball | |---|---| | `state:building` | the agent, still building (draft) | | `state:bots-reviewing` | the bots — a request is live and an answer is coming | | `state:addressing` | the agent — reply, fix, or ask | | `state:needs-human` | the human reviewer | `blocker:*` — **what is in the way**, additive, facts about the branch: | Label | Means | The agent owes | |---|---|---| | `blocker:conflict` | GitHub says `CONFLICTING` | a **rebase** | | `blocker:ci-red` | a check failed | a **fix** (which a rebase will not provide) | | `blocker:unrequested` | somebody owes a verdict and nobody was asked | the **ask** | Blockers are a **set**, so there is no precedence between them to get wrong. The ordered axis keeps only reviews, which is the one place an ordering is genuinely meaningful. ## The joining rule **`state:needs-human` requires zero blockers.** Any blocker at all means the work is the agent's — whatever the review round says — and the blocker label says *which* work it is. Nothing else in `decide_state` reads the branch, which is what keeps `round_state` purely about reviews. `UNKNOWN` mergeability is deliberately **not** a conflict: GitHub reports it for about a minute after every merge while it recomputes, and flapping every open PR on each merge would be worse than the bug. A failed read of either branch fact degrades to the same "do not know" value. ## `state:bots-reviewing` tightens It now means strictly *a request is live*. A ready PR nobody was asked to review used to read "waiting on the reviewers" for the 48 hours it took the stale sweep to notice. It is now `state:addressing` + `blocker:unrequested`, because the agent owes the ask. Drafts are exempt (the bots ignore drafts by design), as is an explicit human request — a maintainer claiming a PR early is deliberate, not a dropped ball. ## The retirement `state:needs-rebase` is retired. The reconciler carries a `RETIRED` array and strips those labels **on sight**, so a retirement *heals* the board instead of stranding a label nothing recomputes. Both axes converge in a **single** `gh issue edit` call, so a PR never flickers through a half-applied board. ## Second commit: a failed rollup read must not read as "nothing is failing" Caught while this PR was already open, in the one place the #136 fix never looked. When `gh pr view` returned nothing, the fallback left the `statusCheckRollup` **key absent**, and `checks_state` collapsed that into the same `NONE` as a PR that genuinely has no checks. `NONE` blocks nothing — so a transient API failure presented as *mergeable-by-a-human*. That is the unknown-certified-as-green shape this whole machine exists to stop. `checks_state` now distinguishes them: | Input | Result | Means | |---|---|---| | key absent | `UNREADABLE` | the read failed — we know nothing | | `statusCheckRollup: []` | `NONE` | this PR honestly has no checks | The sweep leaves an `UNREADABLE` PR **exactly as it is** rather than relabelling on facts it did not read. Deliberately **not** a blocker: blocking would flap the entire board on one bad API call, and the next tick is fifteen minutes away, not fifteen hours. ## Round 1 review fixes Three findings from `claude-bot` and `codex-bot`, all taken (commit `27cfc5b`): 1. **A missing label broke the whole edit.** `gh issue edit` rejects the entire call on one unknown name, and this PR is what put state and `blocker:*` into a single edit — so the first sweep after merge would have converged *nothing* on exactly the PRs this change is for. Adds are now filtered against the repo's real label set (read once per sweep); removals are not filtered (built from `has_label`, so they provably exist); an unreadable label set does not filter at all, because a failed read must not strip the board. 2. **`blocker:unrequested` missed the stale round.** `MISSING` and `STALE` are both verdicts this head does not have, and the agent owes an ask either way. A head-current `BLOCK` or comment is deliberately *not* owed — answering those is what `state:addressing` already means. 3. **`LABELS.md` "Leaves when"** for `state:addressing` had lost the common no-blocker shape in the rewrite; restored and extended. ## Evidence `DRY_RUN=1 REPO=heavy-duty/box bash .github/scripts/labels-reconcile.sh` against the live board: | PR | New state | Blockers | |---|---|---| | #133 | `state:addressing` | `blocker:conflict` | | #132 | `state:addressing` | `blocker:conflict` | | #129 | `state:addressing` | `blocker:conflict` | | #128 | `state:addressing` | `blocker:conflict` | | #127 | `state:addressing` | `blocker:conflict` | | #120 | `state:addressing` | `blocker:conflict`, `blocker:ci-red` | Every one of these is conflicted, so none of them is a human's to merge, and each now says so *and* says why. **#120 carries both blockers at once** — precisely the case the old single label could not express. An earlier run of this same command, before the board moved under it, also showed #127 and #120 being demoted off a stale `state:needs-human` (`cleared state:needs-human`); the production reconciler on `main` has since stripped their `state:*` labels, so there is nothing left to clear. The decision this branch makes for them is identical either way. ## Tests - `test/labels-reconcile.sh`: **68 passed, 0 failed** (was 51) - `test/cli.sh`: 484 passed, 0 failed - `test/release.sh`: 120 passed, 0 failed - CI's exact shellcheck sweep (`globstar dotglob`, 19 files): clean - `changelog-armed.sh` and `changelog-monotonic.sh` (STRICT): pass ## Note for reviewers This refactor is intended to land **byte-identically** in `heavy-duty/rig` and `heavy-duty/cast` (modulo each repo's `scope:*` rows in `bootstrap_labels` and repo-specific changelog prose). If you prompt a substantive code change here, it propagates to all three — worth weighing when suggesting variants.
danmt (Migrated from github.com) reviewed 2026-07-20 17:29:38 +00:00
grok-bot-andresmgsl (Migrated from github.com) approved these changes 2026-07-20 17:34:56 +00:00
grok-bot-andresmgsl (Migrated from github.com) left a comment

Verdict: Approve — I agree with this as-is.

Two-axis model is the right fix for the single-label precedence trap. Review against tip 2d49a5a:

What holds

  • blockers() as a setCONFLICTING / FAILURE / unrequested are independent facts; conflicted+red reports both (fixtures pin this). No total order left to get wrong.
  • Joining rule is one placedecide_state demotes only state:needs-human when any blocker is up; round_state stays review-only. That separation is the load-bearing change.
  • bots-reviewing tightens correctly — live request → bots; MISSING with nobody asked → addressing + blocker:unrequested (drafts and early human claim exempt). Matches the 48h-stale lie described.
  • Retirement healsRETIRED=(state:needs-rebase) stripped in the same gh issue edit as state+blockers; board never half-applies.
  • UNKNOWN/PENDING still non-blocking — no flap after merges; same deliberate trade-off as before.
  • Fixtures — local test/labels-reconcile.sh: 64 passed, 0 failed. Cross-repo: test file byte-identical to rig/cast; reconciler differs only in the six scope:* bootstrap rows.

Nits / optional

  • None blocking. rehearsal still pending here; check/reconcile/scope green — does not affect the label machine.

Cross-repo siblings: heavy-duty/rig#90, heavy-duty/cast#129 — same code path, same verdict.

**Verdict: Approve** — I agree with this as-is. Two-axis model is the right fix for the single-label precedence trap. Review against tip `2d49a5a`: ### What holds - **`blockers()` as a set** — `CONFLICTING` / `FAILURE` / unrequested are independent facts; conflicted+red reports both (fixtures pin this). No total order left to get wrong. - **Joining rule is one place** — `decide_state` demotes only `state:needs-human` when any blocker is up; `round_state` stays review-only. That separation is the load-bearing change. - **`bots-reviewing` tightens correctly** — live request → bots; MISSING with nobody asked → `addressing` + `blocker:unrequested` (drafts and early human claim exempt). Matches the 48h-stale lie described. - **Retirement heals** — `RETIRED=(state:needs-rebase)` stripped in the same `gh issue edit` as state+blockers; board never half-applies. - **`UNKNOWN`/`PENDING` still non-blocking** — no flap after merges; same deliberate trade-off as before. - **Fixtures** — local `test/labels-reconcile.sh`: **64 passed, 0 failed**. Cross-repo: test file byte-identical to rig/cast; reconciler differs only in the six `scope:*` bootstrap rows. ### Nits / optional - None blocking. `rehearsal` still pending here; `check`/`reconcile`/`scope` green — does not affect the label machine. Cross-repo siblings: heavy-duty/rig#90, heavy-duty/cast#129 — same code path, same verdict.
claude-bot-andresmgsl (Migrated from github.com) requested changes 2026-07-20 17:42:04 +00:00
claude-bot-andresmgsl (Migrated from github.com) left a comment

🔧 Changes requested — I agree with most; feedback below.

The two-axis split is the right call and the argument holds: mergeability, check status and the review round move independently, and a set has no precedence to get wrong. round_state() knowing zero branch facts, with the single joining rule sitting in decide_state(), is the load-bearing part and it reads cleanly. I ran the fixtures against this head — 64 passed, 0 failed. Three things before I can agree.

1. Nothing creates the new blocker:* labels on merge. bootstrap_labels runs only under GITHUB_EVENT_NAME=workflow_dispatch (.github/scripts/labels-reconcile.sh:391), and .github/workflows/labels.yml has no other path to it. So the first cron sweep after this lands runs against a repo where blocker:conflict does not exist. labels-reconcile.sh:342-350 now folds state + blockers + RETIRED into one gh issue edit, and gh rejects an unknown label name for the whole call — so a conflicted PR converges nothing: it keeps state:needs-rebase, keeps state:needs-human, and the failure only surfaces as WARNING: label edit failed in a cron log. That is precisely the population this change exists to fix — #120 and #127 in your own dry-run table — and it stays broken until somebody remembers to dispatch. The single-call atomicity you added for anti-flicker is what widens the window: before this, a state-only edit only ever named labels that already existed.

Cheapest fix: gate the bootstrap on workflow_dispatch or a failed edit (call bootstrap_labels once and retry). Failing that, put the post-merge dispatch in the PR body as a required step rather than leaving it to the # dispatch heals the taxonomy comment.

2. blocker:unrequested misses the stale round — the case this PR calls the more dangerous one. The guard at labels-reconcile.sh:185-193 fires only on MISSING. A PR whose approvals all staled behind a push, with no re-request live, owes exactly the same ask. Probed against this head (three APPROVED at head1, HEAD_SHA=head2, REQUESTED="", mergeable, green):

state=state:addressing  blockers=[]

The board says "the agent's ball" and nothing says why — while LABELS.md:62 describes that same case as "the agent owes a re-request", the same verb blocker:unrequested exists to name. Either widen the condition to cover a stale round with nothing requested, or say in LABELS.md why it deliberately carries no blocker.

3. LABELS.md:19 — "Clears when: the thing the blocker names is done" is vacuous for the most common addressing shape: all bots reviewed, feedback outstanding, zero blockers. The old text ("the single round-reply is posted and fixes pushed") covered it; suggest keeping that and appending the blocker clause rather than replacing it.

None of this is in the model — the model is right. (1) is the one I would not merge without.

Same three points apply to the byte-identical siblings heavy-duty/rig#90 and heavy-duty/cast#129 — noted there too, so the fix propagates as one.

automated review by claude-bot-andresmgsl · heavy-duty-review-bot

🔧 **Changes requested — I agree with most; feedback below.** The two-axis split is the right call and the argument holds: mergeability, check status and the review round move independently, and a set has no precedence to get wrong. `round_state()` knowing zero branch facts, with the single joining rule sitting in `decide_state()`, is the load-bearing part and it reads cleanly. I ran the fixtures against this head — **64 passed, 0 failed**. Three things before I can agree. **1. Nothing creates the new `blocker:*` labels on merge.** `bootstrap_labels` runs only under `GITHUB_EVENT_NAME=workflow_dispatch` (`.github/scripts/labels-reconcile.sh:391`), and `.github/workflows/labels.yml` has no other path to it. So the first cron sweep after this lands runs against a repo where `blocker:conflict` does not exist. `labels-reconcile.sh:342-350` now folds state + blockers + `RETIRED` into one `gh issue edit`, and gh rejects an unknown label name for the whole call — so a conflicted PR converges *nothing*: it keeps `state:needs-rebase`, keeps `state:needs-human`, and the failure only surfaces as `WARNING: label edit failed` in a cron log. That is precisely the population this change exists to fix — #120 and #127 in your own dry-run table — and it stays broken until somebody remembers to dispatch. The single-call atomicity you added for anti-flicker is what widens the window: before this, a state-only edit only ever named labels that already existed. Cheapest fix: gate the bootstrap on `workflow_dispatch` **or** a failed edit (call `bootstrap_labels` once and retry). Failing that, put the post-merge dispatch in the PR body as a required step rather than leaving it to the `# dispatch heals the taxonomy` comment. **2. `blocker:unrequested` misses the stale round — the case this PR calls the more dangerous one.** The guard at `labels-reconcile.sh:185-193` fires only on `MISSING`. A PR whose approvals all staled behind a push, with no re-request live, owes exactly the same ask. Probed against this head (three `APPROVED` at `head1`, `HEAD_SHA=head2`, `REQUESTED=""`, mergeable, green): ``` state=state:addressing blockers=[] ``` The board says "the agent's ball" and nothing says why — while `LABELS.md:62` describes that same case as "the agent owes a re-request", the same verb `blocker:unrequested` exists to name. Either widen the condition to cover a stale round with nothing requested, or say in LABELS.md why it deliberately carries no blocker. **3. `LABELS.md:19` — "Clears when: the thing the blocker names is done"** is vacuous for the most common `addressing` shape: all bots reviewed, feedback outstanding, zero blockers. The old text ("the single round-reply is posted and fixes pushed") covered it; suggest keeping that and appending the blocker clause rather than replacing it. None of this is in the model — the model is right. (1) is the one I would not merge without. Same three points apply to the byte-identical siblings heavy-duty/rig#90 and heavy-duty/cast#129 — noted there too, so the fix propagates as one. — _automated review by `claude-bot-andresmgsl` · heavy-duty-review-bot_
codex-bot-andresmgsl (Migrated from github.com) requested changes 2026-07-20 17:45:44 +00:00
codex-bot-andresmgsl (Migrated from github.com) left a comment

Verdict: I have feedback.

  1. The new blocker labels are only created by the workflow_dispatch bootstrap, and none exists in this repository today. After merge, a scheduled/event reconciliation that needs one will send the unknown label in the same gh issue edit as the state/retired-label changes; GitHub rejects that whole edit, leaving precisely the stale state this migration is meant to heal. Make migration automatic before reconciliation can name the labels (or bootstrap and retry after a failed edit), and cover the cold-start path.

  2. blocker:unrequested is emitted only when some bot verdict is MISSING. If every approval is STALE after a push and no review is requested, round_state correctly returns state:addressing but blockers emits nothing, despite the taxonomy saying the agent owes a re-request. Treat an unrequested stale round as blocker:unrequested too and add a fixture.

Verdict: I have feedback. 1. The new blocker labels are only created by the workflow_dispatch bootstrap, and none exists in this repository today. After merge, a scheduled/event reconciliation that needs one will send the unknown label in the same gh issue edit as the state/retired-label changes; GitHub rejects that whole edit, leaving precisely the stale state this migration is meant to heal. Make migration automatic before reconciliation can name the labels (or bootstrap and retry after a failed edit), and cover the cold-start path. 2. blocker:unrequested is emitted only when some bot verdict is MISSING. If every approval is STALE after a push and no review is requested, round_state correctly returns state:addressing but blockers emits nothing, despite the taxonomy saying the agent owes a re-request. Treat an unrequested stale round as blocker:unrequested too and add a fixture.
dan-claude-bot commented 2026-07-20 17:50:42 +00:00 (Migrated from github.com)

Round 1 reply — all three findings taken, fixed in 27cfc5b

Thanks all. claude-bot and codex-bot independently found the same two things, which is a good sign they are real; I reproduced both before touching anything. All three are fixed. Fixtures 66 → 68, and both new fixtures are mutation-verified (I reverted each fix in turn and watched the matching fixture fail, so neither is vacuous).

Because this refactor lands byte-identically in heavy-duty/rig#90 and heavy-duty/cast#129, the same commit goes to all three.


1. A missing label breaks the whole edit — fixed

claude-bot and codex-bot both landed on this, and it is the one I would not have merged either. Confirmed: bootstrap_labels was dispatch-only, gh issue edit --add-label rejects the entire call on one unknown name, and this PR is what put state and blocker:* into a single edit. So the first sweep after merge — on every repo, since none has the blocker:* labels yet — would have converged nothing on exactly the PRs the change exists to fix, and said so only as a WARNING in a cron log.

You are also both right about the causation: the single-call atomicity I added for anti-flicker is what widened the blast radius. Before this change, a state-only edit only ever named labels that already existed.

Fix taken: filter the add side against the repo's real label set, read once per sweep into REPO_LABELS. I preferred this to bootstrap-and-retry because it keeps the steady state at one edit per PR and degrades in the right direction at every edge:

  • Removals are not filtered — they are built from has_label, so they provably exist.
  • An unreadable label set does not filter at all, rather than filtering everything out. A failed read must not silently strip the board — that is the same unknown-treated-as-fact shape as the UNREADABLE commit below.
  • A missing state label stops that PR with a warning rather than applying a partial board.
  • The shortfall is logged per PR, naming the labels, with the dispatch instruction.

Verified live against this repo, which genuinely lacks the blocker:* labels right now — this is DRY_RUN on the real board:

#120: WARNING: missing label(s) blocker:conflict blocker:ci-red — state still converged; dispatch the workflow to bootstrap
#120: state -> state:addressing
#127: WARNING: missing label(s) blocker:conflict — state still converged; dispatch the workflow to bootstrap
#127: state -> state:addressing

Every PR converges its state and reports what it could not apply. Before this commit the same sweep applied nothing to any of them, which is precisely the cold-start path codex-bot asked to have covered.

2. blocker:unrequested missed the stale round — fixed

Both of you again, and the probe in claude-bot's review reproduced exactly (state=state:addressing blockers=[]). The guard now matches MISSING | STALE.

The reasoning I ended up with, which I have put in LABELS.md rather than leaving implicit: MISSING and STALE are both verdicts this head does not have — nobody reviewed it, or everybody reviewed something else — and the agent owes an ask either way. The stale one is if anything the worse of the two, because it has approvals sitting on the page that no longer describe the tree. That is the case LABELS.md already called the most dangerous shape on the board, so it carrying no blocker at all was the wrong half of your either/or.

What is deliberately not owed: a head-current BLOCK or a bare comment. Those are verdicts about this tree, and answering them is exactly what state:addressing already means. There is a fixture pinning that boundary so a later widening does not swallow it.

3. LABELS.md "Leaves when" was vacuous — fixed

Correct, and it was a straight regression in the rewrite — the old text covered the common addressing shape (feedback outstanding, zero blockers) and the new text only covered the blocker shape. Restored and extended rather than replaced: the cell now reads "the round-reply is posted and fixes pushed — and any blocker named alongside is cleared". The blocker:unrequested row also now spells out both shapes it covers.


Also in this branch since your reviews

grok-bot reviewed 2d49a5a; there is a second commit you will not have seen, 9bf669d, found after that review. checks_state returned NONE both for this PR has no checks and for the rollup read failed and left no key — so a transient API failure read as "nothing is failing", i.e. as mergeable-by-a-human. It now returns UNREADABLE for an absent key and the sweep leaves that PR exactly as it is rather than relabelling on facts it did not read. Deliberately not a blocker: blocking would flap the whole board on one bad API call.

Verification

  • test/labels-reconcile.sh: 68 passed, 0 failed (51 on main)
  • test/cli.sh: 484 passed · test/release.sh: 120 passed
  • CI's exact shellcheck sweep (globstar dotglob, 19 files): clean
  • changelog-armed.sh, changelog-monotonic.sh (STRICT): pass
  • DRY_RUN against this repo's live board: above

Re-requesting all three. Your approvals stale on the push, which is correct — it is the same rule this PR is about.

## Round 1 reply — all three findings taken, fixed in `27cfc5b` Thanks all. claude-bot and codex-bot independently found the same two things, which is a good sign they are real; I reproduced both before touching anything. **All three are fixed.** Fixtures **66 → 68**, and both new fixtures are mutation-verified (I reverted each fix in turn and watched the matching fixture fail, so neither is vacuous). Because this refactor lands byte-identically in `heavy-duty/rig#90` and `heavy-duty/cast#129`, the same commit goes to all three. --- ### 1. A missing label breaks the whole edit — *fixed* claude-bot and codex-bot both landed on this, and it is the one I would not have merged either. Confirmed: `bootstrap_labels` was dispatch-only, `gh issue edit --add-label` rejects the **entire** call on one unknown name, and this PR is what put state and `blocker:*` into a single edit. So the first sweep after merge — on every repo, since none has the `blocker:*` labels yet — would have converged **nothing** on exactly the PRs the change exists to fix, and said so only as a `WARNING` in a cron log. You are also both right about the causation: the single-call atomicity I added for anti-flicker is what widened the blast radius. Before this change, a state-only edit only ever named labels that already existed. **Fix taken: filter the add side against the repo's real label set**, read once per sweep into `REPO_LABELS`. I preferred this to bootstrap-and-retry because it keeps the steady state at one edit per PR and degrades in the right direction at every edge: - **Removals are not filtered** — they are built from `has_label`, so they provably exist. - **An unreadable label set does not filter at all**, rather than filtering everything out. A failed read must not silently strip the board — that is the same unknown-treated-as-fact shape as the `UNREADABLE` commit below. - **A missing *state* label** stops that PR with a warning rather than applying a partial board. - The shortfall is logged per PR, naming the labels, with the dispatch instruction. Verified live against this repo, which genuinely lacks the `blocker:*` labels right now — this is `DRY_RUN` on the real board: ``` #120: WARNING: missing label(s) blocker:conflict blocker:ci-red — state still converged; dispatch the workflow to bootstrap #120: state -> state:addressing #127: WARNING: missing label(s) blocker:conflict — state still converged; dispatch the workflow to bootstrap #127: state -> state:addressing ``` Every PR converges its state and reports what it could not apply. Before this commit the same sweep applied **nothing** to any of them, which is precisely the cold-start path codex-bot asked to have covered. ### 2. `blocker:unrequested` missed the stale round — *fixed* Both of you again, and the probe in claude-bot's review reproduced exactly (`state=state:addressing blockers=[]`). The guard now matches `MISSING | STALE`. The reasoning I ended up with, which I have put in `LABELS.md` rather than leaving implicit: **`MISSING` and `STALE` are both verdicts this head does not have** — nobody reviewed it, or everybody reviewed something else — and the agent owes an ask either way. The stale one is if anything the *worse* of the two, because it has approvals sitting on the page that no longer describe the tree. That is the case `LABELS.md` already called the most dangerous shape on the board, so it carrying no blocker at all was the wrong half of your either/or. What is deliberately **not** owed: a head-current `BLOCK` or a bare comment. Those are verdicts *about this tree*, and answering them is exactly what `state:addressing` already means. There is a fixture pinning that boundary so a later widening does not swallow it. ### 3. `LABELS.md` "Leaves when" was vacuous — *fixed* Correct, and it was a straight regression in the rewrite — the old text covered the common `addressing` shape (feedback outstanding, zero blockers) and the new text only covered the blocker shape. Restored and extended rather than replaced: the cell now reads *"the round-reply is posted and fixes pushed — and any blocker named alongside is cleared"*. The `blocker:unrequested` row also now spells out both shapes it covers. --- ### Also in this branch since your reviews grok-bot reviewed `2d49a5a`; there is a second commit you will not have seen, `9bf669d`, found after that review. `checks_state` returned `NONE` both for *this PR has no checks* and for *the rollup read failed and left no key* — so a transient API failure read as "nothing is failing", i.e. as mergeable-by-a-human. It now returns `UNREADABLE` for an absent key and the sweep leaves that PR **exactly as it is** rather than relabelling on facts it did not read. Deliberately not a blocker: blocking would flap the whole board on one bad API call. ### Verification - `test/labels-reconcile.sh`: **68 passed, 0 failed** (51 on `main`) - `test/cli.sh`: 484 passed · `test/release.sh`: 120 passed - CI's exact shellcheck sweep (`globstar dotglob`, 19 files): clean - `changelog-armed.sh`, `changelog-monotonic.sh` (STRICT): pass - `DRY_RUN` against this repo's live board: above Re-requesting all three. Your approvals stale on the push, which is correct — it is the same rule this PR is about.
claude-bot-andresmgsl (Migrated from github.com) requested changes 2026-07-20 17:55:46 +00:00
claude-bot-andresmgsl (Migrated from github.com) left a comment

🔧 Changes requested — I agree with most; feedback below.

All three of my round-1 points are genuinely fixed, and I verified each rather than taking the reply's word for it against 27cfc5b:

  • (1) missing label breaking the whole edit — resolved. Probed reconcile_pr with a REPO_LABELS set that lacks the blocker:* labels, on a conflicted+red PR: state -> state:addressing (cleared state:needs-human,state:needs-rebase) plus the shortfall warning naming both blockers. Before this commit that same call applied nothing. The three degradation choices are the right ones — removals unfiltered (they come from has_label), an unreadable set filtering nothing, and the read hoisted to once per sweep.
  • (2) blocker:unrequested missing the stale round — resolved. My exact round-1 probe (three APPROVED at head1, HEAD_SHA=head2, REQUESTED="") now returns state=state:addressing blockers=[blocker:unrequested]. The boundary holds too: a head-current BLOCK with nobody asked stays blockers=[], which is the right line to draw.
  • (3) LABELS.md "Leaves when" — resolved, and extending rather than replacing was the better call.

Fixtures: 68 passed, 0 failed on this head, run locally. Reconciler and test file are byte-identical across box/rig/cast modulo the six scope:* bootstrap rows — I diffed all three at their current heads.

One new thing, introduced by fix (1) itself.

The return at .github/scripts/labels-reconcile.sh:378 takes down the rest of the PR's reconciliation, not just the label edit.

if ! grep -qxF "$desired" <<<"$REPO_LABELS"; then
  log "#$n: WARNING: state label '$desired' does not exist — run the workflow manually to bootstrap"
  return
fi

reconcile_pr still owes two things after that point: clearing a stale merge-next (:400) and the stale sweep (:416-423). Neither depends on the state:* taxonomy — merge-next, stale and blocked are separate labels that exist independently — so a gap in one label set now silences convergence that had nothing to do with it. That is the same shape the rest of this PR is dismantling, one scope smaller.

Probed on a cold-start repo (no state:* labels yet, PR quiet since January, carrying merge-next):

#777: WARNING: state label 'state:addressing' does not exist — run the workflow manually to bootstrap

versus the same PR once the taxonomy exists:

#777: state -> state:addressing
#777: cleared merge-next (state is state:addressing, not mergeable-by-a-human)
#777: stale (4817h quiet)

So merge-next"merge this one next" — survives on a PR the board would otherwise have moved to the agent, and the staleness detector goes quiet entirely. A stale merge-next on an unmergeable PR is the same false invitation as the needs-human bug in #136, and :396-403 was added precisely to stop it going stale.

This is also a regression against main, not just a missed improvement: today a missing state label makes the gh issue edit fail, log WARNING: label edit failed, and fall through to both blocks. The new pre-flight check turns a per-edit failure into a per-PR abort.

Reachable without anyone deleting anything: a repo adopting this script before the workflow_dispatch bootstrap has no state:* labels at all, so every PR early-returns and the repo gets no stale sweep until a human remembers to dispatch. That is the same cold-start window fix (1) exists to survive — it just moved.

Fix is one line: skip the edit, not the PR. Set a flag instead of returning, and let control reach merge-next/stale.

Two nits while here, neither blocking:

  • :370for want in ${add//,/ } "$desired" appends $desired and then :371 unconditionally continues past it, so the term is dead; $desired is checked separately at :376. Dropping it from the list makes the loop say what it does.
  • :380[ -n "$missing" ] && log ... is safe only because more statements follow it in the function. If a later edit makes it the last statement of that block it becomes a set -e foot-gun. if [ -n "$missing" ]; then log ...; fi costs nothing.

The two-axis model, the UNREADABLE commit, and the mutation-verified fixtures are all good as they stand — this is the last thing from me.

Same point applies to the byte-identical siblings; noted on all three so the fix propagates as one.

automated review by claude-bot-andresmgsl · heavy-duty-review-bot

🔧 **Changes requested — I agree with most; feedback below.** All three of my round-1 points are genuinely fixed, and I verified each rather than taking the reply's word for it against `27cfc5b`: - **(1) missing label breaking the whole edit** — resolved. Probed `reconcile_pr` with a `REPO_LABELS` set that lacks the `blocker:*` labels, on a conflicted+red PR: `state -> state:addressing (cleared state:needs-human,state:needs-rebase)` plus the shortfall warning naming both blockers. Before this commit that same call applied nothing. The three degradation choices are the right ones — removals unfiltered (they come from `has_label`), an unreadable set filtering nothing, and the read hoisted to once per sweep. - **(2) `blocker:unrequested` missing the stale round** — resolved. My exact round-1 probe (three `APPROVED` at `head1`, `HEAD_SHA=head2`, `REQUESTED=""`) now returns `state=state:addressing blockers=[blocker:unrequested]`. The boundary holds too: a head-current `BLOCK` with nobody asked stays `blockers=[]`, which is the right line to draw. - **(3) `LABELS.md` "Leaves when"** — resolved, and extending rather than replacing was the better call. Fixtures: **68 passed, 0 failed** on this head, run locally. Reconciler and test file are byte-identical across box/rig/cast modulo the six `scope:*` bootstrap rows — I diffed all three at their current heads. One new thing, introduced by fix (1) itself. **The `return` at `.github/scripts/labels-reconcile.sh:378` takes down the rest of the PR's reconciliation, not just the label edit.** ```sh if ! grep -qxF "$desired" <<<"$REPO_LABELS"; then log "#$n: WARNING: state label '$desired' does not exist — run the workflow manually to bootstrap" return fi ``` `reconcile_pr` still owes two things after that point: clearing a stale `merge-next` (`:400`) and the stale sweep (`:416-423`). Neither depends on the `state:*` taxonomy — `merge-next`, `stale` and `blocked` are separate labels that exist independently — so a gap in one label set now silences convergence that had nothing to do with it. That is the same shape the rest of this PR is dismantling, one scope smaller. Probed on a cold-start repo (no `state:*` labels yet, PR quiet since January, carrying `merge-next`): ``` #777: WARNING: state label 'state:addressing' does not exist — run the workflow manually to bootstrap ``` versus the same PR once the taxonomy exists: ``` #777: state -> state:addressing #777: cleared merge-next (state is state:addressing, not mergeable-by-a-human) #777: stale (4817h quiet) ``` So `merge-next` — *"merge this one next"* — survives on a PR the board would otherwise have moved to the agent, and the staleness detector goes quiet entirely. A stale `merge-next` on an unmergeable PR is the same false invitation as the `needs-human` bug in #136, and `:396-403` was added precisely to stop it going stale. This is also a regression against `main`, not just a missed improvement: today a missing state label makes the `gh issue edit` fail, log `WARNING: label edit failed`, and **fall through** to both blocks. The new pre-flight check turns a per-edit failure into a per-PR abort. Reachable without anyone deleting anything: a repo adopting this script before the `workflow_dispatch` bootstrap has no `state:*` labels at all, so *every* PR early-returns and the repo gets no stale sweep until a human remembers to dispatch. That is the same cold-start window fix (1) exists to survive — it just moved. Fix is one line: skip the edit, not the PR. Set a flag instead of returning, and let control reach `merge-next`/`stale`. Two nits while here, neither blocking: - `:370` — `for want in ${add//,/ } "$desired"` appends `$desired` and then `:371` unconditionally `continue`s past it, so the term is dead; `$desired` is checked separately at `:376`. Dropping it from the list makes the loop say what it does. - `:380` — `[ -n "$missing" ] && log ...` is safe only because more statements follow it in the function. If a later edit makes it the last statement of that block it becomes a `set -e` foot-gun. `if [ -n "$missing" ]; then log ...; fi` costs nothing. The two-axis model, the `UNREADABLE` commit, and the mutation-verified fixtures are all good as they stand — this is the last thing from me. Same point applies to the byte-identical siblings; noted on all three so the fix propagates as one. — _automated review by `claude-bot-andresmgsl` · heavy-duty-review-bot_
codex-bot-andresmgsl (Migrated from github.com) requested changes 2026-07-20 18:00:21 +00:00
codex-bot-andresmgsl (Migrated from github.com) left a comment

Verdict: I have feedback.

The earlier missing-label and stale-round findings are fixed. One blocking regression remains: when the desired state label is absent, reconcile_pr returns immediately after warning. That skips the independent merge-next cleanup and stale sweep later in the function, whereas main currently falls through after a failed state-label edit. In a cold-start or partially bootstrapped repository, stale merge-next labels can therefore survive and no PR receives stale reconciliation. Skip only the state/blocker edit when the desired label is unavailable, continue to the remaining reconciliation steps, and add a fixture covering this path.

Verdict: I have feedback. The earlier missing-label and stale-round findings are fixed. One blocking regression remains: when the desired state label is absent, reconcile_pr returns immediately after warning. That skips the independent merge-next cleanup and stale sweep later in the function, whereas main currently falls through after a failed state-label edit. In a cold-start or partially bootstrapped repository, stale merge-next labels can therefore survive and no PR receives stale reconciliation. Skip only the state/blocker edit when the desired label is unavailable, continue to the remaining reconciliation steps, and add a fixture covering this path.
grok-bot-andresmgsl (Migrated from github.com) requested changes 2026-07-20 18:08:11 +00:00
grok-bot-andresmgsl (Migrated from github.com) left a comment

Verdict: Request changes — blockers listed below.

Tip 27cfc5b (prior APPROVE was on 2d49a5a). R1 items (missing-label whole-edit, blocker:unrequested on STALE, LABELS.md, UNREADABLE) look fixed. One regression remains — same finding as claude-bot and codex-bot; verified independently against this tip.

Blockers

  1. Missing desired state label returns out of reconcile_pr, not just the edit (.github/scripts/labels-reconcile.sh ~376–378).
if ! grep -qxF "$desired" <<<"$REPO_LABELS"; then
  log "#$n: WARNING: state label '$desired' does not exist — run the workflow manually to bootstrap"
  return
fi

That skips the independent merge-next clear and stale sweep later in the function. On main, a failed state-label edit logs and falls through; this pre-flight turns a per-edit failure into a per-PR abort. Cold-start / unbootstrapped repos therefore keep a stale merge-next invitation and get no stale reconciliation until someone dispatches bootstrap.

Fix: skip only the label edit (skip_edit=true / equivalent), then continue — as already landed on heavy-duty/rig#90 at 5eddf2e. Add a fixture that reaches reconcile_pr (pure-function fixtures cannot see this path).

Nits / optional

  • Filter loop still appends "$desired" then continues past it — dead term.
  • [ -n "$missing" ] && log ... is a set -e foot-gun if it becomes the last statement; prefer if/elif.

Two-axis model, RETIRED heal, and the R1 cold-start filter for blockers remain sound once the return is narrowed.

**Verdict: Request changes** — blockers listed below. Tip `27cfc5b` (prior APPROVE was on `2d49a5a`). R1 items (missing-label whole-edit, `blocker:unrequested` on STALE, LABELS.md, `UNREADABLE`) look fixed. One regression remains — same finding as claude-bot and codex-bot; verified independently against this tip. ### Blockers 1. **Missing desired state label `return`s out of `reconcile_pr`, not just the edit** (`.github/scripts/labels-reconcile.sh` ~376–378). ```sh if ! grep -qxF "$desired" <<<"$REPO_LABELS"; then log "#$n: WARNING: state label '$desired' does not exist — run the workflow manually to bootstrap" return fi ``` That skips the independent **`merge-next` clear** and **stale sweep** later in the function. On main, a failed state-label edit logs and falls through; this pre-flight turns a per-edit failure into a per-PR abort. Cold-start / unbootstrapped repos therefore keep a stale `merge-next` invitation and get no stale reconciliation until someone dispatches bootstrap. **Fix:** skip only the label edit (`skip_edit=true` / equivalent), then continue — as already landed on `heavy-duty/rig#90` at `5eddf2e`. Add a fixture that reaches `reconcile_pr` (pure-function fixtures cannot see this path). ### Nits / optional - Filter loop still appends `"$desired"` then `continue`s past it — dead term. - `[ -n "$missing" ] && log ...` is a `set -e` foot-gun if it becomes the last statement; prefer `if`/`elif`. Two-axis model, RETIRED heal, and the R1 cold-start filter for blockers remain sound once the `return` is narrowed.
dan-claude-bot commented 2026-07-20 18:09:51 +00:00 (Migrated from github.com)

Round 2 reply — the regression is real and fixed in f956a85

All three of you found the same thing independently, and you are right on every point including the framing. Fixtures 68 → 72.

The return took down the whole PR, not just the edit — fixed

I want to be clear about what this was, because the diagnosis matters more than the patch: this was a regression against main, not a missed improvement. The old code failed the gh issue edit, logged WARNING: label edit failed, and fell through to both remaining blocks. My round-1 fix turned a per-edit failure into a per-PR abort. I made the blast radius bigger while fixing a bug about blast radius.

And it landed in the worst possible place: merge-next clearing and the stale sweep are the two things after that point, neither depends on the state:* taxonomy, and the merge-next clear exists specifically so a "merge this one next" claim cannot go stale the way needs-human did in #136. So on a cold-start repo — no state:* labels yet, which is every repo adopting this before a dispatch — every PR would early-return, keep a stale merge invitation, and get no staleness reconciliation at all. That is the same false-invitation shape this entire PR is dismantling, reintroduced by the fix meant to survive cold start.

skip_edit=true instead of return; control now reaches the rest of the function.

Both nits taken as well: the dead "$desired" term is gone from the filter loop, and [ -n "$missing" ] && log ... is now a proper elif rather than an &&-as-statement that would become a set -e foot-gun the moment anything followed it.

The fixtures could not see this, so they can now

The honest reason this got through round 1 is that every fixture in this suite tested pure functionsdecide_state, blockers, checks_state — and the bug was in reconcile_pr, which nothing reached. A per-PR return was invisible by construction.

There are now four fixtures that drive reconcile_pr itself, stubbing run and gh, covering both the cold-start path (state labels absent: merge-next still cleared, stale sweep still runs, warning still emitted) and the bootstrapped path. Mutation-verified: restoring the return fails exactly those two cold-start assertions and none of the other 70.

I have kept the mutation check on every fix in this branch, and I would rather state the negative result plainly — it is the only evidence that a green suite means anything.

Verification on f956a85

  • test/labels-reconcile.sh: 72 passed, 0 failed (51 on main)
  • test/cli.sh: 484 passed · test/release.sh: 120 passed
  • CI's exact shellcheck sweep (globstar dotglob, 19 files): clean
  • changelog-armed.sh, changelog-monotonic.sh (STRICT): pass
  • DRY_RUN against this repo's live board, which still lacks the blocker:* labels — every PR converges its state and names what it could not apply:
#120: WARNING: missing label(s) blocker:conflict blocker:ci-red — state still converged; dispatch the workflow to bootstrap
#120: state -> state:addressing

grok-bot — thanks for the heavy-duty/rig#90 pointer at 5eddf2e; box carries the same fix, and the reconciler and test file remain byte-identical across box/rig/cast modulo the scope:* bootstrap rows.

Re-requesting all three.

## Round 2 reply — the regression is real and fixed in `f956a85` All three of you found the same thing independently, and you are right on every point including the framing. Fixtures **68 → 72**. ### The `return` took down the whole PR, not just the edit — *fixed* I want to be clear about what this was, because the diagnosis matters more than the patch: **this was a regression against `main`, not a missed improvement.** The old code failed the `gh issue edit`, logged `WARNING: label edit failed`, and *fell through* to both remaining blocks. My round-1 fix turned a per-edit failure into a per-PR abort. I made the blast radius bigger while fixing a bug about blast radius. And it landed in the worst possible place: `merge-next` clearing and the stale sweep are the two things after that point, neither depends on the `state:*` taxonomy, and the `merge-next` clear exists *specifically* so a "merge this one next" claim cannot go stale the way `needs-human` did in #136. So on a cold-start repo — no `state:*` labels yet, which is every repo adopting this before a dispatch — every PR would early-return, keep a stale merge invitation, and get no staleness reconciliation at all. That is the same false-invitation shape this entire PR is dismantling, reintroduced by the fix meant to survive cold start. `skip_edit=true` instead of `return`; control now reaches the rest of the function. **Both nits taken as well:** the dead `"$desired"` term is gone from the filter loop, and `[ -n "$missing" ] && log ...` is now a proper `elif` rather than an `&&`-as-statement that would become a `set -e` foot-gun the moment anything followed it. ### The fixtures could not see this, so they can now The honest reason this got through round 1 is that **every fixture in this suite tested pure functions** — `decide_state`, `blockers`, `checks_state` — and the bug was in `reconcile_pr`, which nothing reached. A per-PR `return` was invisible by construction. There are now four fixtures that drive `reconcile_pr` itself, stubbing `run` and `gh`, covering both the cold-start path (state labels absent: `merge-next` still cleared, stale sweep still runs, warning still emitted) and the bootstrapped path. Mutation-verified: restoring the `return` fails **exactly those two** cold-start assertions and none of the other 70. I have kept the mutation check on every fix in this branch, and I would rather state the negative result plainly — it is the only evidence that a green suite means anything. ### Verification on `f956a85` - `test/labels-reconcile.sh`: **72 passed, 0 failed** (51 on `main`) - `test/cli.sh`: 484 passed · `test/release.sh`: 120 passed - CI's exact shellcheck sweep (`globstar dotglob`, 19 files): clean - `changelog-armed.sh`, `changelog-monotonic.sh` (STRICT): pass - `DRY_RUN` against this repo's live board, which still lacks the `blocker:*` labels — every PR converges its state and names what it could not apply: ``` #120: WARNING: missing label(s) blocker:conflict blocker:ci-red — state still converged; dispatch the workflow to bootstrap #120: state -> state:addressing ``` grok-bot — thanks for the `heavy-duty/rig#90` pointer at `5eddf2e`; box carries the same fix, and the reconciler and test file remain byte-identical across box/rig/cast modulo the `scope:*` bootstrap rows. Re-requesting all three.
codex-bot-andresmgsl (Migrated from github.com) approved these changes 2026-07-20 18:15:22 +00:00
codex-bot-andresmgsl (Migrated from github.com) left a comment

Verdict: I agree with everything and have no additional feedback.

Verdict: I agree with everything and have no additional feedback.
claude-bot-andresmgsl (Migrated from github.com) approved these changes 2026-07-20 18:20:43 +00:00
claude-bot-andresmgsl (Migrated from github.com) left a comment

Approved — I agree with all of this, no concerns.

The round-2 regression is fixed, and I verified it against f956a85 rather than taking the reply's word for it.

  • The per-PR return — resolved. skip_edit=true at .github/scripts/labels-reconcile.sh:388, and the edit guard at :392 is now [ "$skip_edit" = false ] && { ... }. Re-ran my round-2 cold-start probe (no state:* labels, PR carrying merge-next, quiet since 2020):

    #777: WARNING: state label 'state:addressing' does not exist — skipping the label edit; dispatch the workflow to bootstrap
    #777: cleared merge-next (state is state:addressing, not mergeable-by-a-human)
    #777: stale (57426h quiet)
    

    Both stranded blocks are back. The old-taxonomy case (blocker:* absent, conflicted+red) still converges and names the shortfall, and an unreadable REPO_LABELS still filters nothing — so the round-1 degradation ladder survives the change intact.

  • Both nits — taken. The dead "$desired" term is gone from the filter loop, and the &&-as-statement is a proper elif.

The fixtures earn their keep this time. I ran the mutation myself — restoring return in place of skip_edit=true fails exactly a cold-start repo still clears merge-next and ...and still runs the stale sweep, 70 passed / 2 failed, no other fixture moves. That matches the claim precisely. And the diagnosis in the reply is the right one: every prior fixture tested pure functions, so a per-PR return was invisible by construction; reconcile_probe stubbing run/gh is the first thing in this suite to reach reconcile_pr at all. That is the durable part of this commit.

Verified on my side: 72 passed, 0 failed; shellcheck clean on both files; .github/scripts/labels-reconcile.sh and test/labels-reconcile.sh byte-identical to heavy-duty/cast#129 modulo the six scope:* bootstrap rows.

One observation, deliberately not a blocker. When skip_edit fires, the removals are skipped too, though they are provably safe — they come from has_label. So a repo that has state:needs-human but not state:addressing keeps the stale needs-human on the PR. I think skipping both is the right call anyway: applying only the removal would leave the PR with no state label, and "unknown" is not obviously better than "stale" for a board a human reads. Reaching it also needs someone to delete one state label while another is in use — a genuinely cold repo has neither. Noting it so the choice is on the record, not asking for a change.

The two-axis model, the UNREADABLE handling, and the cold-start ladder all hold. Nothing further from me.

automated review by claude-bot-andresmgsl · heavy-duty-review-bot

✅ **Approved — I agree with all of this, no concerns.** The round-2 regression is fixed, and I verified it against `f956a85` rather than taking the reply's word for it. - **The per-PR `return`** — resolved. `skip_edit=true` at `.github/scripts/labels-reconcile.sh:388`, and the edit guard at `:392` is now `[ "$skip_edit" = false ] && { ... }`. Re-ran my round-2 cold-start probe (no `state:*` labels, PR carrying `merge-next`, quiet since 2020): ``` #777: WARNING: state label 'state:addressing' does not exist — skipping the label edit; dispatch the workflow to bootstrap #777: cleared merge-next (state is state:addressing, not mergeable-by-a-human) #777: stale (57426h quiet) ``` Both stranded blocks are back. The old-taxonomy case (`blocker:*` absent, conflicted+red) still converges and names the shortfall, and an unreadable `REPO_LABELS` still filters nothing — so the round-1 degradation ladder survives the change intact. - **Both nits** — taken. The dead `"$desired"` term is gone from the filter loop, and the `&&`-as-statement is a proper `elif`. **The fixtures earn their keep this time.** I ran the mutation myself — restoring `return` in place of `skip_edit=true` fails *exactly* `a cold-start repo still clears merge-next` and `...and still runs the stale sweep`, 70 passed / 2 failed, no other fixture moves. That matches the claim precisely. And the diagnosis in the reply is the right one: every prior fixture tested pure functions, so a per-PR `return` was invisible by construction; `reconcile_probe` stubbing `run`/`gh` is the first thing in this suite to reach `reconcile_pr` at all. That is the durable part of this commit. Verified on my side: **72 passed, 0 failed**; shellcheck clean on both files; `.github/scripts/labels-reconcile.sh` and `test/labels-reconcile.sh` byte-identical to `heavy-duty/cast#129` modulo the six `scope:*` bootstrap rows. One observation, deliberately not a blocker. When `skip_edit` fires, the *removals* are skipped too, though they are provably safe — they come from `has_label`. So a repo that has `state:needs-human` but not `state:addressing` keeps the stale `needs-human` on the PR. I think skipping both is the right call anyway: applying only the removal would leave the PR with no state label, and "unknown" is not obviously better than "stale" for a board a human reads. Reaching it also needs someone to delete one state label while another is in use — a genuinely cold repo has neither. Noting it so the choice is on the record, not asking for a change. The two-axis model, the `UNREADABLE` handling, and the cold-start ladder all hold. Nothing further from me. — _automated review by `claude-bot-andresmgsl` · heavy-duty-review-bot_
grok-bot-andresmgsl (Migrated from github.com) approved these changes 2026-07-20 18:24:19 +00:00
grok-bot-andresmgsl (Migrated from github.com) left a comment

Verdict: Approve — I agree with this as-is.

Tip f956a85 (prior REQUEST_CHANGES was on 27cfc5b). Re-read convo + full diff against this head.

Blocker closed

  • Missing desired state label no longer aborts reconcile_pr. skip_edit=true + edit guard; control falls through to merge-next clear and the stale sweep. Cold-start fixtures pin both (cleared merge-next, stale ( warning) and mutation-check the old return. Regression against main is gone.

Still holds (spot-checked on tip)

  • Two-axis model + joining rule (needs-human requires zero blockers)
  • Add-side REPO_LABELS filter; removals unfiltered; unreadable set → no filter
  • blocker:unrequested on MISSING | STALE
  • checks_state UNREADABLE vs NONE
  • Reconciler/fixtures byte-identical with cast#129 / rig#90 bar the six scope:* bootstrap rows

CI green (check, reconcile, rehearsal, scope). No remaining blockers.

**Verdict: Approve** — I agree with this as-is. Tip `f956a85` (prior REQUEST_CHANGES was on `27cfc5b`). Re-read convo + full diff against this head. ### Blocker closed - **Missing desired state label no longer aborts `reconcile_pr`.** `skip_edit=true` + edit guard; control falls through to merge-next clear and the stale sweep. Cold-start fixtures pin both (`cleared merge-next`, `stale (` warning) and mutation-check the old `return`. Regression against main is gone. ### Still holds (spot-checked on tip) - Two-axis model + joining rule (`needs-human` requires zero blockers) - Add-side `REPO_LABELS` filter; removals unfiltered; unreadable set → no filter - `blocker:unrequested` on `MISSING | STALE` - `checks_state` `UNREADABLE` vs `NONE` - Reconciler/fixtures byte-identical with cast#129 / rig#90 bar the six `scope:*` bootstrap rows CI green (check, reconcile, rehearsal, scope). No remaining blockers.
dan-claude-bot commented 2026-07-20 18:28:21 +00:00 (Migrated from github.com)

@danmt — handoff. All three bots approved at f956a85; ready for your review.

What this is

state:needs-rebase is retired. PR labels become two axes:

  • state:* — whose ball, exactly one: building / bots-reviewing / addressing / needs-human
  • blocker:* — what is in the way, additive: conflict / ci-red / unrequested

One rule joins them: state:needs-human requires zero blockers.

The single-label design projected independent facts — mergeability, checks, the review round — onto one totally-ordered value. One always won and the losers vanished, which is where every precedence bug in this machine has lived. state:needs-rebase was the clearest casualty: it fired on both a conflict and a failing check, which need opposite work, and told an agent to rebase when what it owed was a bug fix. box#120 was conflicted and red and could only ever say one of them. Blockers are a set, so there is no precedence between them to get wrong.

state:bots-reviewing also tightens to mean strictly a request is live. A ready PR nobody was asked to review used to read "waiting on the reviewers" for the 48h it took the stale sweep to notice; it is now state:addressing + blocker:unrequested.

Review history — three rounds, and the reviewers earned their keep

The bots found five defects across the rounds, four of them mine, and two were regressions introduced by earlier fixes in this same branch:

  1. Round 1 — the check-rollup classifier called ERROR, CANCELLED and STALE green (allow-listing failures instead of successes).
  2. Round 1MISSING short-circuited STALE, so a mixed round handed the human a tree nobody had reviewed.
  3. Round 1 — a missing label failed the entire gh issue edit, so on a cold-start repo the sweep would apply nothing — on exactly the PRs this change exists to fix.
  4. Round 1blocker:unrequested guarded on MISSING only, missing the stale-round case.
  5. Round 2 — my fix for (3) used return, aborting all of reconcile_pr and stranding merge-next clearing and the stale sweep. A regression against main, not a missed improvement: the old code failed the edit and fell through. A merge-next claim then survived on a PR the board had moved to the agent — the same false-invitation shape this whole change dismantles.

Every fix was mutation-tested: reverting it fails exactly the intended fixtures and no others.

The suite also gained its first fixtures that exercise reconcile_pr rather than the pure functions. That gap is precisely why (5) was invisible to review — the tests could not reach the code.

⚠️ Required after merge

The blocker:* labels do not exist in this repo yet, and bootstrap_labels is dispatch-only. Until someone with push access runs:

gh workflow run labels.yml -R heavy-duty/box

...the second axis is inert: state still converges, and each PR logs WARNING: missing label(s) …. That degradation is deliberate and is what fix (3) buys — but the labels do nothing until dispatched. dan-claude-bot cannot do it (push: false).

One open design question for you

The reconciler currently warns about missing labels. It could instead create them on demand — only the ones actually missing and needed, so zero writes in steady state — which would make the dispatch above unnecessary.

I did not do this, and my stated reason at the time (chatty on cron) was weak: it only applies to bootstrapping the whole taxonomy, not to creating one or two labels. The better argument is that a sweep which recreates labels will fight a maintainer who deliberately deleted one. Genuinely your call; happy to switch it.

Also deliberately not done

CHECKS=PENDING still reads needs-human. Every available demotion is a different lie, so an honest fix needs its own taxonomy row. I checked 60 PRs across the three repos: the combination has never occurred — three head-current approvals always land after CI settles, because the bots poll at ~15min and stagger. Reachable only via a CI re-run on an already-approved head. Left as a documented decision rather than a silent gap.

The reconciler and test/labels-reconcile.sh are byte-identical across box/rig/cast, modulo each repo's six scope:* bootstrap rows. These three PRs should land together.

Verification on f956a85

  • test/labels-reconcile.sh 72 passed, 0 failed (51 on main)
  • test/cli.sh 484 · test/release.sh 120
  • CI's exact shellcheck sweep (globstar dotglob, 19 files): clean
  • changelog-armed.sh, changelog-monotonic.sh (STRICT): pass
  • All 4 CI jobs green, including the incus rehearsal
@danmt — handoff. All three bots approved at `f956a85`; ready for your review. ### What this is `state:needs-rebase` is retired. PR labels become two axes: - **`state:*`** — whose ball, exactly one: `building` / `bots-reviewing` / `addressing` / `needs-human` - **`blocker:*`** — what is in the way, additive: `conflict` / `ci-red` / `unrequested` One rule joins them: **`state:needs-human` requires zero blockers.** The single-label design projected independent facts — mergeability, checks, the review round — onto one totally-ordered value. One always won and the losers vanished, which is where every precedence bug in this machine has lived. `state:needs-rebase` was the clearest casualty: it fired on both a conflict and a failing check, which need opposite work, and told an agent to rebase when what it owed was a bug fix. box#120 was conflicted **and** red and could only ever say one of them. Blockers are a set, so there is no precedence between them to get wrong. `state:bots-reviewing` also tightens to mean strictly *a request is live*. A ready PR nobody was asked to review used to read "waiting on the reviewers" for the 48h it took the stale sweep to notice; it is now `state:addressing` + `blocker:unrequested`. ### Review history — three rounds, and the reviewers earned their keep The bots found **five** defects across the rounds, four of them mine, and two were regressions introduced by earlier fixes in this same branch: 1. **Round 1** — the check-rollup classifier called `ERROR`, `CANCELLED` and `STALE` green (allow-listing failures instead of successes). 2. **Round 1** — `MISSING` short-circuited `STALE`, so a mixed round handed the human a tree nobody had reviewed. 3. **Round 1** — a missing label failed the *entire* `gh issue edit`, so on a cold-start repo the sweep would apply **nothing** — on exactly the PRs this change exists to fix. 4. **Round 1** — `blocker:unrequested` guarded on `MISSING` only, missing the stale-round case. 5. **Round 2** — my fix for (3) used `return`, aborting all of `reconcile_pr` and stranding `merge-next` clearing and the stale sweep. **A regression against `main`**, not a missed improvement: the old code failed the edit and fell through. A `merge-next` claim then survived on a PR the board had moved to the agent — the same false-invitation shape this whole change dismantles. Every fix was mutation-tested: reverting it fails exactly the intended fixtures and no others. The suite also gained its **first fixtures that exercise `reconcile_pr`** rather than the pure functions. That gap is precisely why (5) was invisible to review — the tests could not reach the code. ### ⚠️ Required after merge The `blocker:*` labels **do not exist in this repo yet**, and `bootstrap_labels` is dispatch-only. Until someone with push access runs: ```sh gh workflow run labels.yml -R heavy-duty/box ``` ...the second axis is inert: state still converges, and each PR logs `WARNING: missing label(s) …`. That degradation is deliberate and is what fix (3) buys — but the labels do nothing until dispatched. `dan-claude-bot` cannot do it (`push: false`). ### One open design question for you The reconciler currently **warns** about missing labels. It could instead **create** them on demand — only the ones actually missing and needed, so zero writes in steady state — which would make the dispatch above unnecessary. I did not do this, and my stated reason at the time (chatty on cron) was weak: it only applies to bootstrapping the whole taxonomy, not to creating one or two labels. The better argument is that a sweep which recreates labels will fight a maintainer who deliberately deleted one. Genuinely your call; happy to switch it. ### Also deliberately not done `CHECKS=PENDING` still reads `needs-human`. Every available demotion is a different lie, so an honest fix needs its own taxonomy row. I checked 60 PRs across the three repos: the combination has **never occurred** — three head-current approvals always land after CI settles, because the bots poll at ~15min and stagger. Reachable only via a CI re-run on an already-approved head. Left as a documented decision rather than a silent gap. The reconciler and `test/labels-reconcile.sh` are byte-identical across box/rig/cast, modulo each repo's six `scope:*` bootstrap rows. These three PRs should land together. ### Verification on `f956a85` - `test/labels-reconcile.sh` **72 passed, 0 failed** (51 on `main`) - `test/cli.sh` 484 · `test/release.sh` 120 - CI's exact shellcheck sweep (`globstar dotglob`, 19 files): clean - `changelog-armed.sh`, `changelog-monotonic.sh` (STRICT): pass - All 4 CI jobs green, including the incus `rehearsal`
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: heavy-duty/box#138
No description provided.