You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit d72c4c0
Browse filesBrowse the repository at this point in the historyBrowse files
fix(ci): settle an indeterminate label write against the board, not the response (#18144)
Closes#17984
`Clause-②: no`
## The defect, and the premise that turned out to be false
On PR #17982 `Auto Label` went red for work it had **completed**. Step
#3 POSTed `tests`, the API answered HTTP 500, the script exited 1 — and
the PR's label set read `size/s, skip-changeset, tests` immediately
afterwards. A 500 is not evidence the write failed.
The job's red said *"the response failed"*; every reader takes it to
mean *"the label is missing"*. Those are different facts, and that gap
is the defect.
⚠️ **One premise in the card is false, and it is worth stating
plainly.** The card reads "**No retry on 5xx.** A 500 from the GitHub
API is transient by definition". `scripts/pr-labels.mjs` has had a
**bounded exponential-backoff retry on 5xx since the day the file
landed** (#10777, 2026-08-22) — 4 attempts, `2 ** attempt * 500` ms,
with 4xx-other-than-429 breaking out as fatal. Verified against the
tree: the file's last commit before the incident is 2026-09-06, and the
loop is present in the 2026-08-22 blob.
So **two of the card's three suggested-shape bullets were already
implemented** (retry-on-5xx, 4xx-stays-fatal). The card's incidental
observation that "the same window produced at least four" 500s is most
likely *those four retry attempts*: `runPlan` logs the POST once and
`ghRequest` reports only `lastError`, so four internal attempts render
as exactly the one-POST-one-500 log the card read.
The one bullet that was genuinely missing is the one the card itself
identified as the actual defect: **idempotency-awareness**. Nothing ever
re-read the board.
## What changed
One file, `scripts/pr-labels.mjs`. Failures are now **classified**
rather than uniformly fatal:
| class | statuses | treatment |
|:--|:--|:--|
| **INDETERMINATE** — the server may have acted before the answer was
lost | any `5xx`; a fetch that **threw** | re-read the PR's labels and
judge the step's **post-condition** |
| **DETERMINATE** — the server refused and did not act | `4xx`, **`429`
included** | fatal, loud, byte-identical error message to before |
Three new pure exports carry the decision:
- `failureIsIndeterminate({ status, threw })`
- `postconditionOf(step)` — reads the wanted state **off the step
itself** (`POST` ⇒ its labels present; `DELETE` ⇒ the one named label
absent), so it cannot drift from what the step asks for
- `settleWriteFailure({ step, liveLabels, indeterminate })`
`ghRequest` now tracks indeterminacy **stickily across attempts** — if
any attempt could have reached the server's state, the whole request is
indeterminate even when a later attempt came back with a clean 4xx.
`runPlan` routes a failed write through `settleOrRethrow`.
Two deliberate non-relaxations:
- a **determinate 4xx stays fatal even when the board satisfies the
post-condition**. A `403` is a broken token and a `422` is a label that
does not exist in the repo; the label being there by some other hand
does not make the token work.
- a **settling re-read that itself fails settles nothing** — the write
is reported `UNVERIFIED`, and the *original* error is raised (AGENTS.md
*Route & surface ownership* §3: absence must be loud, prefer failing to
falling back).
## The two-direction test, and its verdicts
`--self-test` gains a 16-case battery, `the #17982 indeterminate write,
settled against the board`, pinned in `SELF_TEST_BATTERIES`;
`SELF_TEST_BATTERY_FLOOR` goes 6 → 7 so the new battery cannot be
silently deleted.
A battery that only proved the settle succeeds where the write landed
would be the same exit-0-by-construction shape this card is about. So
**both legs were ablated**, each proven to land on disk by an occurrence
count on the mutated anchor before the run:
| ablation | anchor before → after | self-test |
|:--|:--|:--|
| **A** — delete the 4xx-stays-loud leg (`if (!indeterminate)` → `if
(false)`) | 1 → 0 old, 1 new | **exit 1**, `FAIL a determinate 4xx is
NOT settled, even with the label present` · `FAIL …and it says so in
those words` |
| **B** — make 5xx read as determinate (`return Number(status) >= 500` →
`return false`) | 1 → 0 old, 1 new | **exit 1**, `FAIL a 500 is
indeterminate` · `FAIL so is a 503` |
Both restores were settled by `git hash-object` against the HEAD blob
(`24e4c5098d6b…`, matched) plus an empty `git diff HEAD` — ⛔ not by a
`trap`, and not by a restore command's exit code. Unmutated verdict:
`VERDICT: pr-labels self-test PASSED`.
## The sweep — population, criterion, controls
The card asked for the population, not a "I also checked others".
**Criterion counted by:** a tracked file that (a) names a GitHub API
host (`api.github.com` / `GITHUB_API_URL`) **and** (b) issues a non-GET
verb, then judged on whether its write failure handling **treats the
response status as the verdict with no post-condition re-read**.
**Population:** `git grep -lE "api\.github\.com|GITHUB_API_URL"` over
tracked files → **23** under `scripts/**`, **3** elsewhere
(`.claude/hooks/guard-governed-enqueue.{sh,selftest.sh}`,
`.claude/settings.json`), **0** under `packages/**`. Of the 23, **7**
contain a write verb.
**Controls.** Firing control: `scripts/pr-labels.mjs` — the known
positive, still present, 38 953 bytes — appears in both the population
and the write-verb narrowing. Nonsense control: the same probe for
`api.gitlab.com|GITLAB_API_URL` reads **0**.
| file | verdict |
|:--|:--|
| `scripts/check-whole-set-label-write.mjs` | **not a writer.** Its 8
verb hits are literals in its own detector vocabulary and fixtures.
Excluded with evidence. |
| `.claude/settings.json`,
`.claude/hooks/guard-governed-enqueue.selftest.sh` | **not writers.**
Verb hits are *matcher patterns* for a read-only guard hook (the hook
itself: 0 verb hits). |
| `scripts/pm/label-write.mjs` | ⭐ **already correct, and it is the
in-repo reference for this cure.** Nothing there throws on an HTTP
status; `classifyHttp` routes, and exit 0 requires *"the write landed
AND the read-back matched the target"*, with a second read-back after a
re-add. |
| `scripts/pm/sweep-stale-finding.mjs` | **deliberate, documented
no-retry design** — stops on the first `403`/`429` and prints a resume
cursor; carries a `read-back-mismatch` stop reason. Not this shape. |
| `scripts/pm/post-stamped.mjs` | **has the shape.** Bare `if (!res.ok)
throw` in `rest()`, no retry, no post-condition re-read; its existing
read-back verifies stored *bytes* on the success path only. |
| `scripts/pm/sweep-closed-cards.mjs` | **has the shape.**
Byte-identical `rest()` helper to the above. |
| `scripts/release-github-releases.mjs` | **has the shape** on
`POST`/`PATCH` release. Release-lane, Prime Directive #15 territory. |
| `.github/workflows/**` inline (5 files) | `github.rest.issues.*` via
`actions/github-script`; **not swept further** — out of this card's file
surface. |
**What was fixed vs. left as a card candidate.** Fixed:
`scripts/pr-labels.mjs` only. ⛔ Deliberately **not** extracted into a
shared helper and swapped into the other three: they are **seat-invoked
CLI tools**, where a human or agent reads the output and re-runs, not
unattended CI jobs whose red blocks a PR — the severity that makes this
card worth fixing does not carry over, and `scripts/pm/label-write.mjs`
already shows the repo has the discipline where the stakes are highest.
The three are reported to the PM as a **card candidate**, not widened
into this PR.
## Reverse-read, both directions
- **Currently-true sentence this makes false:**
*"`scripts/pr-labels.mjs` fails the job on any non-2xx answer from the
labels API."* Now false for the 5xx/thrown class — false only when the
board proves the post-condition holds.
- **Currently-false sentence this makes true:**
*"`scripts/pr-labels.mjs` re-reads the PR's labels after a failed write
and judges the write by the board's state."*
- **A zero, reported as required:** the change moves **no** sentence
about 4xx behaviour. A `4xx` threw and exited 1 before, and rethrows the
**same error object with the same message** now. That invariance is
asserted by ablation A rather than asserted in prose.
## Scope, gates, publishing
- ⛔ **This PR does NOT touch `.github/workflows/**`.** The fix is
entirely inside the script, which both `lint.yml` and
`pr-automation.yml` already invoke via `--self-test`. No workflow arming
problem for the PM seat.
- **Gates:** `node scripts/pm/dispatch-gates.mjs --commands` derived
**32** families from the change set at the final tree; all 32 run, **all
exit 0**. `--ran` reconciliation with per-command exit codes: `✓ 32
derived famil(ies) accounted for — 32 run, 0 NOT-MEASURED (a DERIVED
zero)`.
- **Lint:** the full repo-wide union was run rather than narrowed —
`eslint . --no-inline-config --format json` over **6 751** files, **0
errors, 0 warnings**, exit 0, at `1bc65ff50e`. (`eslint.config.mjs`
declares no `parserOptions.project` and no typed rules, so the narrowing
question is moot in any case.)
- **`skip-changeset`, measured not asserted:** every
`pnpm-workspace.yaml` glob roots under `packages/*` / `apps/*` /
`examples/*`; npm `files[]` resolves relative to a package dir and
cannot reach outside it; `scripts/pr-labels.mjs` sits at the repo root
inside **no** package, and the root manifest is `private: true`.
Positive control: `packages/spec` is non-private with a real `files[]`,
so the probe can distinguish. ⇒ nothing publishes ⇒ label, not a
changeset.
## 验收备注
- noted, not filed: `ghRequest`'s 4-attempt / `2 ** attempt * 500` ms
budget is a hard-coded literal with no env override. Not a defect and
not in scope; whoever next tunes the labeler's patience will meet it.
承接者:无 — no queued PR touches this file.
---
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU
---
_Generated by [Claude
Code](https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU)_
Co-authored-by: Claude <noreply@anthropic.com>
0 commit comments