From 1c9a82aaf066b75b5d8a4fa82951515debc8e4fe Mon Sep 17 00:00:00 2001 From: claude-bot-andresmgsl Date: Sat, 25 Jul 2026 09:08:54 +0000 Subject: [PATCH] fix: blocked excludes state:needs-human in decide_state (#180) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit During the ceremony#111 freeze, rig#126/#128 carried blocked beside state:needs-human — the round had finished, but the hold said the merge must not happen, and rig#126 was merged seven minutes after the reconciler wrote the green label. decide_state() only joined the two axes through blockers(), which emits branch facts; the hand-set blocked label was never consulted. blocked becomes the second exclusion on state:needs-human, exactly parallel to needs-ruling: round says needs-human + has_label blocked -> state:addressing. Deliberately not a blockers() emission — BLOCKERS is machine-owned and the converge loop would strip the live hold on the next tick, the same trap #51 names for needs-ruling. Ruling record: discussion 122, armed default A fired 2026-07-25T09:00Z. Co-Authored-By: Claude Fable 5 --- LABELS.md | 2 +- actions/labels-reconcile/labels-reconcile.sh | 14 ++++++++++ changelog.d/180.md | 3 +++ test/labels-reconcile.test.sh | 28 ++++++++++++++++++++ 4 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 changelog.d/180.md diff --git a/LABELS.md b/LABELS.md index 2db8ba5..7bdba38 100644 --- a/LABELS.md +++ b/LABELS.md @@ -65,7 +65,7 @@ comments, unassigns the stale owner, and restores `ready`. | Label | Color | Meaning | |---|---|---| | `stale` | `#B60205` | no activity for 48h — sweep-managed, never hand-applied | -| `blocked` | `#6A737D` | (see above — same label serves PRs waiting on another PR/issue; legitimately quiet, the staleness sweep skips it) | +| `blocked` | `#6A737D` | (see above — same label serves PRs waiting on another PR/issue; legitimately quiet, the staleness sweep skips it). The reconciler refuses `state:needs-human` while `blocked` stands — the PR falls to `state:addressing` (#180) | | `offsite` | `#CFD3D7` | issue deliverable is a PR in another repository; set by the builder with the draft link and cleared by the builder at handoff | | `needs-ruling` | `#D4C5F9` | a human-owned decision is required; use BUILDER.md's ruling template and ladder. Set by triage or the builder; a state, not a signal — it clears on agreement, not on a reply | | `attention` | `#D93F0B` | issue-only demand parked for the assignee; hand-set, and never written by the machine | diff --git a/actions/labels-reconcile/labels-reconcile.sh b/actions/labels-reconcile/labels-reconcile.sh index 4d1ddc0..214e8e7 100755 --- a/actions/labels-reconcile/labels-reconcile.sh +++ b/actions/labels-reconcile/labels-reconcile.sh @@ -389,6 +389,20 @@ decide_state() { # → the one state:* label this PR should carry if [ "$s" = state:needs-human ] && has_label needs-ruling; then echo state:addressing; return fi + + # A directed hold disqualifies it the same way (#180): `blocked` is hand-set + # intent — triage sets it, anyone may correct it — and during the #111 + # freeze rig#126/#128 carried it beside state:needs-human, so the board said + # "mergeable right now" about PRs a hold said must not merge (rig#126 was + # merged seven minutes later). Not a BLOCKERS entry, deliberately: that + # array is machine-owned and the converge loop strips whatever the facts do + # not re-derive, so emitting the label there would strip a live hold on the + # next 15-minute tick — the same trap #51 names for `needs-ruling`. + # state:addressing is the accepted imprecision: under a hold the builder + # owes nothing, but "a human could merge this now" must not lie. + if [ "$s" = state:needs-human ] && has_label blocked; then + echo state:addressing; return + fi echo "$s" } diff --git a/changelog.d/180.md b/changelog.d/180.md new file mode 100644 index 0000000..2d619e1 --- /dev/null +++ b/changelog.d/180.md @@ -0,0 +1,3 @@ +- `decide_state()` refuses `state:needs-human` while the hand-set `blocked` + label stands — the PR falls to `state:addressing`, exactly parallel to the + `needs-ruling` exclusion; never emitted by `blockers()` (#180). diff --git a/test/labels-reconcile.test.sh b/test/labels-reconcile.test.sh index 2c41628..ab73d41 100755 --- a/test/labels-reconcile.test.sh +++ b/test/labels-reconcile.test.sh @@ -553,6 +553,34 @@ LABELS="needs-ruling" expect "needs-ruling adds nothing to blockers()" blocker:conflict "$(blockers)" MERGEABLE=MERGEABLE LABELS="" +# --------------------------------------------------------------------------- +# blocked (#180): a directed hold. Same shape as needs-ruling — hand-set +# intent the machine reads and never writes, an EXCLUSION on needs-human, +# never a blocker and never a latch. During the #111 freeze rig#126/#128 +# carried blocked beside state:needs-human, and rig#126 was merged seven +# minutes after the reconciler wrote the green label. +# --------------------------------------------------------------------------- +DRAFT=false HEAD_SHA=head1 REQUESTED="" REVIEWS_JSON="$ALL_APPROVE" MERGEABLE=MERGEABLE CHECKS=SUCCESS +LABELS="" +expect "the hold-free fixture hands off (control)" state:needs-human "$(decide_state)" +LABELS="blocked" +expect "a directed hold excludes needs-human" state:addressing "$(decide_state)" +LABELS="" +expect "...and clearing it hands off again — an exclusion, not a latch" state:needs-human "$(decide_state)" +LABELS="blocked" DRAFT=true +expect "a draft carrying blocked is still building" state:building "$(decide_state)" +DRAFT=false + +# blockers() must not know this label exists either: it is not a branch fact, +# and the converge loop strips every BLOCKERS entry the facts do not +# re-derive — emitting it there would strip a live hold on the next tick. +MERGEABLE=CONFLICTING +LABELS="" +expect "conflict fixture emits its blocker (control)" blocker:conflict "$(blockers)" +LABELS="blocked" +expect "blocked adds nothing to blockers()" blocker:conflict "$(blockers)" +MERGEABLE=MERGEABLE LABELS="" + # The guard the other fixtures cannot see: an UNGUARDED has_label read under # set -u does not go red — bash treats the unset expansion inside the # herestring redirection as a redirection error (bash 5.2: rc 127, the shell