Skip to content

Commit 863213a

Browse files
gates: check-issue-citations retries a transient 5xx or connection reset on a board read before refusing (#23438)
Fixes #23361 Clause-②: no ## What this changes `scripts/check-issue-citations.mjs` read ONE HTTP 500 from the board as PREREQUISITE NOT MET (exit 3) with no retry, so a GitHub blip turned the lint lane red and ejected a PR from the merge queue (run 38496246256). Every board read now goes through one helper, `readBoard`, shared by both strategies (`probeBoard`'s head probe and per-number probes, `enumerateBoard`'s pages), with ONE retry budget per run created in `buildBoard`. - **Retried (closed set):** an HTTP 5xx; a thrown connection failure whose cause chain carries `ECONNRESET` or `UND_ERR_SOCKET` (`RETRYABLE_NETWORK_CODES`). - **Not retried:** every 4xx (a 404 keeps its meaning, "absent"; a 401/403/429 is an answer about the credential or the rate limit), and every other thrown error (`ECONNREFUSED`, `ENOTFOUND`, a proxy refusing the tunnel, a body that is not JSON). Those are rethrown unchanged, so they keep the exit path they had. - **Never a weaker gate:** a read still failing after its ladder is handed back as a failure; `run()`'s existing catch turns it into `prerequisiteRefusal`, exit 3, never 0. The refusal now names the read, the answer and the attempt count, for example `GET issues?per_page=1 -> HTTP 500 after 3 attempts (every attempt one read is allowed, so not one blip)`. - **The retried unit is the request AND its JSON body.** A reset can land while the body streams; measured, `res.json()` then throws `TypeError: terminated` with the same cause codes. - `run()` and `buildBoard` take optional `fetchImpl` / `sleep` (defaults: the global `fetch`, a real `setTimeout` sleep). Only the self-test passes them. - `makeTransferProbe` deliberately does **not** share the retry. Its 3xx is a verdict and stays one. A failed read there never exits 3: it degrades to a `null` verdict, which classifies as `allocated-but-absent`, still a finding. It is only reached under `--probe-cause`, which no workflow runs. - The header docblock and line 204 (`dispatch-gates: local-env`, pinned by line number in `dispatch-gates.self-test.mjs`) are untouched. All edits sit below it. ## Retry policy and worst-case added wall time `BOARD_READ_RETRY`: 3 attempts per read (one read plus two retries), backoff 500 ms then 1000 ms, and **4 retries per run** shared by every read the run makes. | board | added sleep | extra requests | then | |---|---|---|---| | answers normally | 0 | 0 | unchanged | | stays down (every read 5xx or reset) | 1500 ms | 2 | exit 3 | | flaps (every read fails twice, then answers): the worst case | at most 3000 ms | at most 4 | the next 5xx is final, then exit 3 | - **Board that stays down:** the first read (the head probe, or page 1 under `--enumerate`) spends its ladder and the board build ends, so the cost does not depend on how many numbers the change cites. The self-test pins it with 400 cited numbers (the most `probeBoard` takes before switching to enumeration) and with the enumerating strategy: 3 reads and 1500 ms in both. - **Board that flaps:** the per-run budget is what bounds a 400-number probe or a 159-page enumeration. Pinned: sleeps `500, 1000, 500, 1000` (3000 ms total), then `... after 1 attempt (this run's budget of 4 retries is spent)`. - Before this change a board that stays down cost 1 request and 0 ms; now it costs 3 requests and 1.5 s. Request latency itself is not bounded, before or after: no read in this file sets a timeout. ## Which connection-failure shapes are retried, as measured Measured on node v22.22.0 (CI runs node 22) against a local socket server, both directly and through a local CONNECT proxy under `--use-env-proxy` (the gate's own re-exec route): | what the peer does | what `fetch` throws | retried | |---|---|---| | reset (RST) after the request bytes, or on accept | `TypeError: fetch failed`, cause code `ECONNRESET` | yes | | close (FIN) with no response | `fetch failed`, cause code `UND_ERR_SOCKET` ("other side closed") | yes | | reset mid-body | `TypeError: terminated` from the body read, cause `ECONNRESET` | yes | | close mid-body | `terminated`, cause `UND_ERR_SOCKET` | yes | | tunnel reset or closed by the proxy | `fetch failed`, cause `ECONNRESET` / `UND_ERR_SOCKET` | yes | | proxy answers CONNECT with 502/503 | `fetch failed`, cause chain ending in `UND_ERR_ABORTED` | no | | connection refused (control) | cause `ECONNREFUSED` | no | | DNS failure (control) | cause `ENOTFOUND` | no | ## Verification (head `3becc14a`) - **Self-test:** `node scripts/check-issue-citations.mjs --self-test`, exit 0, `(215 cases, 10 batteries)`. It was 173 cases and 9 batteries. The new `retry` battery has 42 cases (floor 42), and `SELF_TEST_BATTERY_FLOOR` goes from 9 to 10. - The card's cases: one transient 500 then a 200 resolves, on the head probe and on a per-number probe. Persistent 500s are refused after exactly 3 attempts, and **exit 3 end to end**, read off a real child process. That child is offline: its `fetchImpl` is a stub, and it judges a real temp-repo diff. A 404 is asked once, never retried, and is still a finding. - Also: reset then 200, and body-stream close then 200, resolve. A transient 502 on enumerate page 2 still yields the whole board. 401/403/429 are refused on the first attempt. Unnamed errors are rethrown as the identical error object. The worst-case wall readings are pinned as above. A table of 11 persistent failures (500, 502, 503, 504, ECONNRESET, UND_ERR_SOCKET, UND_ERR_SOCKET mid-body, 401, 403, 429, ECONNREFUSED) is applied at the head probe, at a per-number probe and at an enumerated page: **none yields a board**. - **Battery wall:** 5 interleaved runs each on this shared box. Before (the base file, blob `c732ce2e`, read from a checkout at `4f81f315`): median 5418 ms. After: median 5722 ms. The +0.3 s is the offline child process and its temp repo. Backoff sleeps are injected, so the new cases add no sleep. - **Reverse verification (one-time, no permanent file):** each mutation went through `scripts/ablation-replace.mjs` in WRAP mode, at HEAD `3becc14a`. Each restore was proven by blob == HEAD (`d2a21576`) and an empty `git diff HEAD`. Every leg went red by name: - A1 `attemptsPerRead: 1` (no retry): red at "one transient 500 on the HEAD probe, then a 200, RESOLVES ... it refused instead: GET issues?per_page=1 -> HTTP 500 after 1 attempt". - A2 `run()`'s board-read catch returns 0 instead of refusing: red at "persistent 500s EXIT 3 end to end, never 0 — got exit 0". - A3 4xx made retryable: red at "a 404 keeps its meaning: asked ONCE, never retried". - A4 run budget deleted: red at "a board that FLAPS spends at most the run's 4 retries (3000 ms)", which slept `500, 1000` six times. - A5 an exhausted read reported as a board: red at "persistent 500s are NOT a board". - **Real-process readings** (not kept as tests): - Live board, `--base 4f81f31~15`: 60 citations across 33 files, `probed (16 citations), frontier #23434`, exit 0. This is `readBoard` over real responses. - Through a local proxy that resets every tunnel: exit 3 after 3 CONNECTs and 2.7 s of wall, refusal `GET issues?per_page=1 -> ECONNRESET after 3 attempts (...)`. - Through a proxy that answers CONNECT with 503: exit 3 after 1 CONNECT, not retried, refusal `fetch failed` as before. - **This gate on this diff:** `node scripts/check-issue-citations.mjs`, exit 0: `no issue citations added against 4f81f31 (0 file(s) read)`. The diff is all under `scripts/**`, a deferred surface. - **Derived gate union at `3becc14a`:** `dispatch-gates --commands --repo objectstack-ai/objectstack` derived 32 commands. All 32 ran and all exited 0. `--ran` with exit codes reconciles: "32 derived famil(ies) accounted for — 32 run, 0 NOT-MEASURED (a DERIVED zero)". - That includes `pnpm check:pm-dispatch-gates` (fast tier, 1841 cases), whose census pin `scripts/check-issue-citations.mjs:204 local-env` and live local-env rows for this script both pass. - The battery's slow tier (6 sections) is NOT MEASURED locally. Its home is push and schedule runs. - **Lint, narrowed:** `eslint --no-inline-config --format json scripts/check-issue-citations.mjs` gave 1 file, 0 errors, 0 warnings. - Population: `eslint.config.mjs`'s `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` object covers the file. - Invariance: the resolved config's `parserOptions` are `ecmaVersion` and `sourceType` only, with no `project` and no `projectService`. Type-aware linting is off, so this diff cannot move another file's verdict. - Full `pnpm lint` is CI's run. - **Declared to CI, not run here:** 4 workflow-valued families (`--census` among them), 11 wide-population families, 55 artifact-roster families, the path-scheduled `Test Core` job and the type-check lanes. No package is touched. - The 15 pending-changeset families do not apply: this PR carries `skip-changeset`, because root `scripts/` publishes nothing. ## Acceptance notes - Under `--probe-cause`, a transfer probe that reads a 5xx yields a `null` verdict, whose detail text says "re-run with --probe-cause" even though the run already passed it. That is report wording, in a mode no workflow runs; it never passes a board. Noted, not filed. Carrier: none. - No board read sets a request timeout, so a hung read waits out the job timeout. This predates the change and is unchanged. It was not observed in the incident. Noted, not filed. - A thrown error outside the retried set still surfaces as a bare `fetch failed`, which does not name the read. It is kept byte-identical so those exit paths are unchanged. - `scripts/check-shard-attestation.mjs` carries its own `githubFetchJson` (one retry on 5xx, 429 or any network error). It was not reused: this ruling excludes 429 and unnamed network errors. --- _Generated by [Claude Code](https://claude.ai/code/session_01B7mrh59UAGffd7s9asUfoY)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2659901 commit 863213a

1 file changed

Lines changed: 392 additions & 21 deletions

File tree

0 commit comments

Comments
 (0)