…he response
`Auto Label` went red on PR #17982 for work it had COMPLETED: the `--paths`
step 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 — not a missing
retry — is the defect. A bounded 5xx retry with exponential backoff has been in
this file since #10777 and did not close it.
Failures are now classified: 5xx and a thrown fetch are INDETERMINATE (the
server may have acted before the answer was lost) and are settled by re-reading
the PR's labels and judging the step's post-condition; 4xx including 429 stays
DETERMINATE, fatal and loud, even when the board happens to satisfy the
post-condition — a 403 is a broken token and a 422 is a label that does not
exist in the repo. A settling re-read that itself fails settles nothing: the
write is reported UNVERIFIED and the original error is raised.
`failureIsIndeterminate`, `postconditionOf` and `settleWriteFailure` are pure
and pinned by a new 16-case `--self-test` battery covering both directions.
Claude-Session: https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU
Co-authored-by: Claude <noreply@anthropic.com>
Closes #17984
Clause-②: noThe defect, and the premise that turned out to be false
On PR #17982
Auto Labelwent red for work it had completed. Step #3 POSTedtests, the API answered HTTP 500, the script exited 1 — and the PR's label set readsize/s, skip-changeset, testsimmediately 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.
scripts/pr-labels.mjshas had a bounded exponential-backoff retry on 5xx since the day the file landed (#10777, 2026-08-22) — 4 attempts,2 ** attempt * 500ms, 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:
runPlanlogs the POST once andghRequestreports onlylastError, 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:5xx; a fetch that threw4xx,429includedThree 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 forsettleWriteFailure({ step, liveLabels, indeterminate })ghRequestnow 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.runPlanroutes a failed write throughsettleOrRethrow.Two deliberate non-relaxations:
403is a broken token and a422is a label that does not exist in the repo; the label being there by some other hand does not make the token work.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-testgains a 16-case battery,the #17982 indeterminate write, settled against the board, pinned inSELF_TEST_BATTERIES;SELF_TEST_BATTERY_FLOORgoes 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:
if (!indeterminate)→if (false))FAIL a determinate 4xx is NOT settled, even with the label present·FAIL …and it says so in those wordsreturn Number(status) >= 500→return false)FAIL a 500 is indeterminate·FAIL so is a 503Both restores were settled by
git hash-objectagainst the HEAD blob (24e4c5098d6b…, matched) plus an emptygit diff HEAD— ⛔ not by atrap, 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 underscripts/**, 3 elsewhere (.claude/hooks/guard-governed-enqueue.{sh,selftest.sh},.claude/settings.json), 0 underpackages/**. 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 forapi.gitlab.com|GITLAB_API_URLreads 0.scripts/check-whole-set-label-write.mjs.claude/settings.json,.claude/hooks/guard-governed-enqueue.selftest.shscripts/pm/label-write.mjsclassifyHttproutes, 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.mjs403/429and prints a resume cursor; carries aread-back-mismatchstop reason. Not this shape.scripts/pm/post-stamped.mjsif (!res.ok) throwinrest(), no retry, no post-condition re-read; its existing read-back verifies stored bytes on the success path only.scripts/pm/sweep-closed-cards.mjsrest()helper to the above.scripts/release-github-releases.mjsPOST/PATCHrelease. Release-lane, Prime Directive #15 territory..github/workflows/**inline (5 files)github.rest.issues.*viaactions/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.mjsonly. ⛔ 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, andscripts/pm/label-write.mjsalready 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
scripts/pr-labels.mjsfails 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.scripts/pr-labels.mjsre-reads the PR's labels after a failed write and judges the write by the board's state."4xxthrew 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
.github/workflows/**. The fix is entirely inside the script, which bothlint.ymlandpr-automation.ymlalready invoke via--self-test. No workflow arming problem for the PM seat.node scripts/pm/dispatch-gates.mjs --commandsderived 32 families from the change set at the final tree; all 32 run, all exit 0.--ranreconciliation with per-command exit codes:✓ 32 derived famil(ies) accounted for — 32 run, 0 NOT-MEASURED (a DERIVED zero).eslint . --no-inline-config --format jsonover 6 751 files, 0 errors, 0 warnings, exit 0, at1bc65ff50e. (eslint.config.mjsdeclares noparserOptions.projectand no typed rules, so the narrowing question is moot in any case.)skip-changeset, measured not asserted: everypnpm-workspace.yamlglob roots underpackages/*/apps/*/examples/*; npmfiles[]resolves relative to a package dir and cannot reach outside it;scripts/pr-labels.mjssits at the repo root inside no package, and the root manifest isprivate: true. Positive control:packages/specis non-private with a realfiles[], so the probe can distinguish. ⇒ nothing publishes ⇒ label, not a changeset.验收备注
ghRequest's 4-attempt /2 ** attempt * 500ms 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.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU
Generated by Claude Code