From b4ca6c2b80a75cf1d0219b6b96fb5161a65b137b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 08:17:33 +0000 Subject: [PATCH 1/3] fix(gates): check-issue-citations retries a transient board read before refusing A 5xx, an ECONNRESET or an UND_ERR_SOCKET from a board read now gets a bounded ladder (3 attempts per read, 500/1000 ms backoff, 4 retries per run) before the gate concludes PREREQUISITE NOT MET. A 4xx and every other thrown error are answers and are not retried; a read still failing after its ladder keeps its exit-3 path, and the refusal names the attempt count. Claude-Session: https://claude.ai/code/session_01B7mrh59UAGffd7s9asUfoY Co-authored-by: Claude --- scripts/check-issue-citations.mjs | 168 +++++++++++++++++++++++++++--- 1 file changed, 152 insertions(+), 16 deletions(-) diff --git a/scripts/check-issue-citations.mjs b/scripts/check-issue-citations.mjs index c732ce2e21c..21d7763e1ad 100644 --- a/scripts/check-issue-citations.mjs +++ b/scripts/check-issue-citations.mjs @@ -694,21 +694,153 @@ function authHeaders(token) { return h; } +/** + * ⭐ A RETRYABLE BOARD READ (#23361). One HTTP 500 from GitHub used to end the + * run as PREREQUISITE NOT MET: measured on a merge-queue group whose lint lane + * went red on `GET issues/4045 -> HTTP 500`, and on a local re-run that died on + * the head probe instead, while plain reads of both answered 200 that minute. + * A server error is retryable by definition, so a board read now gets a short, + * bounded ladder before the gate concludes the board cannot be read. + * + * What is retried -- a closed set, each member named: + * + * HTTP 5xx any status 500..599 the board answers. + * ECONNRESET the peer reset the connection. node's fetch throws a + * `TypeError` (`fetch failed`, or `terminated` when the reset + * lands while the body streams) whose `cause.code` is this. + * UND_ERR_SOCKET the peer closed the socket before the answer was whole + * (`other side closed`) -- the same failure without the RST. + * + * MEASURED on node 22.22.0 against a local socket server, directly and through + * a CONNECT proxy under `--use-env-proxy`: a reset before the answer and one + * mid-body arrive as those two codes on the cause chain, nowhere else. Because + * a reset can land while the body streams, the unit retried is the request AND + * its JSON body, never the request alone. + * + * ⛔ What is NOT retried, each an answer rather than a blip: any 4xx (a 404 is + * this gate's "absent" and keeps that meaning; a 401/403/429 is about the + * credential or the rate limit, and asking again does not change it); any other + * thrown error -- `ECONNREFUSED`, `ENOTFOUND`, a proxy that refuses the tunnel + * (`UND_ERR_ABORTED`, measured), a body that is not JSON. Those reach the + * caller unchanged, on the exit path they always had. + * + * ⛔ NOT A WEAKER GATE. A retry only ever buys another attempt at the same read; + * a read still failing after its ladder is handed back as the failure it is, + * and every caller turns that into `prerequisiteRefusal` -- exit 3, never 0. + * The refusal text names the attempt count, so a red in the queue says it was + * not one blip. + * + * The budget is bounded per READ and per RUN, so a lint lane never trades a + * fast exit 3 for a slow one: + * + * a board that stays down the first read spends its ladder (500 + 1000 ms + * of sleep, 2 extra requests) and the run refuses -- + * however many numbers the change cites, because the + * first failed read ends the board build. + * a board that flaps `retriesPerRun` caps the whole run at 4 retries, + * so at most 2 full ladders: 3000 ms of sleep and 4 + * extra requests, whether the run probes 1 number or + * 400 or enumerates 159 pages. Past the budget the + * next 5xx is final. + * + * Request latency itself is not bounded here (no read in this file sets a + * timeout, before or after this change). + */ +export const BOARD_READ_RETRY = Object.freeze({ + /** One read plus up to two retries. */ + attemptsPerRead: 3, + /** The sleep before retry 1, retry 2. One entry per retry a read may take. */ + backoffMs: Object.freeze([500, 1000]), + /** Retries the whole run may spend, across every read it makes. */ + retriesPerRun: 4, +}); + +/** The connection failures a read is retried on. ⛔ Closed: a code not here is an answer. */ +export const RETRYABLE_NETWORK_CODES = Object.freeze(['ECONNRESET', 'UND_ERR_SOCKET']); + +/** The named connection failure on `err`'s cause chain, or `null` when there is none. */ +export function retryableNetworkCode(err) { + for (let e = err, depth = 0; e && depth < 4; e = e.cause, depth += 1) { + if (typeof e.code === 'string' && RETRYABLE_NETWORK_CODES.includes(e.code)) return e.code; + } + return null; +} + +const sleepMs = (ms) => new Promise((done) => { setTimeout(done, ms); }); + +/** + * One RUN's retry budget, shared by every board read the run makes -- the + * per-run half of the bound above. `sleep` is injectable so `--self-test` + * spends no wall time on a backoff it only needs to observe. + */ +export function boardReadBudget({ policy = BOARD_READ_RETRY, sleep = sleepMs } = {}) { + return { policy, sleep, retriesSpent: 0 }; +} + +/** + * One board read -- the request and, when it is answered OK, its JSON body -- + * retried on a 5xx or a named connection failure while both the read's ladder + * and the run's budget allow. + * + * Always resolves to a result; never decides a verdict. `ok`/`status`/`body` + * are the LAST attempt's, `status` is `null` when that attempt threw, and + * `stoppedBy` says why there was no further attempt: + * + * 'answer' the read was answered with something that is not retried; + * 'attempts' the read spent its own ladder; + * 'budget' the run's retry budget was already spent. + * + * ⛔ An error that is not a named connection failure is rethrown unchanged. + */ +export async function readBoard(fetchImpl, url, init, budget) { + const { policy } = budget; + for (let attempt = 1; ; attempt += 1) { + let read; + try { + const res = await fetchImpl(url, init); + read = { ok: res.ok, status: res.status, headers: res.headers, body: null, code: null, cause: null, attempts: attempt }; + if (res.ok) read.body = await res.json(); + } catch (err) { + const code = retryableNetworkCode(err); + if (!code) throw err; + read = { ok: false, status: null, headers: null, body: null, code, cause: err, attempts: attempt }; + } + const retryable = read.status === null || (read.status >= 500 && read.status <= 599); + if (!retryable) return { ...read, stoppedBy: 'answer' }; + if (attempt >= policy.attemptsPerRead) return { ...read, stoppedBy: 'attempts' }; + if (budget.retriesSpent >= policy.retriesPerRun) return { ...read, stoppedBy: 'budget' }; + budget.retriesSpent += 1; + await budget.sleep(policy.backoffMs[attempt - 1]); + } +} + +/** The error a failed read becomes. Its text names the attempts -- never a tracker number. */ +export function readFailure(label, read, policy = BOARD_READ_RETRY) { + const answer = read.status === null ? read.code : `HTTP ${read.status}`; + const tries = `after ${read.attempts} attempt${read.attempts === 1 ? '' : 's'}`; + const why = { + answer: 'not retried: only a 5xx or a connection reset is', + attempts: `the per-read cap of ${policy.attemptsPerRead} attempts, so not one blip`, + budget: `this run's budget of ${policy.retriesPerRun} retries is spent`, + }[read.stoppedBy]; + return new Error(`${label} -> ${answer} ${tries} (${why})`, read.cause ? { cause: read.cause } : undefined); +} + /** * Strategy A -- enumerate the whole board once (cursor pagination), then judge * every citation offline. 159 requests on this repo, and the right shape for * `--census`, where the alternative is one request per distinct number. */ -export async function enumerateBoard({ ownerRepo, token, fetchImpl = fetch, pageCap = 400 }) { +export async function enumerateBoard({ ownerRepo, token, fetchImpl = fetch, pageCap = 400, budget = boardReadBudget() }) { const numbers = []; const pulls = []; let url = `${API}/repos/${ownerRepo}/issues?state=all&per_page=100&sort=created&direction=asc`; let pages = 0; while (url && pages < pageCap) { pages += 1; - const res = await fetchImpl(url, { headers: authHeaders(token) }); - if (!res.ok) throw new Error(`GET ${url} -> HTTP ${res.status}`); - const batch = await res.json(); + const res = await readBoard(fetchImpl, url, { headers: authHeaders(token) }, budget); + if (!res.ok) throw readFailure(`GET ${url}`, res, budget.policy); + const batch = res.body; if (!Array.isArray(batch)) throw new Error(`GET ${url} -> not a list (${JSON.stringify(batch).slice(0, 200)})`); for (const row of batch) { numbers.push(row.number); @@ -731,19 +863,20 @@ export async function enumerateBoard({ ownerRepo, token, fetchImpl = fetch, page * other on the live board on 2026-09-14, 28 of 28 numbers agreed (16 probes * chosen as the card's instances and controls, 12 sampled from the census). */ -export async function probeBoard(wanted, { ownerRepo, token, fetchImpl = fetch }) { - const head = await fetchImpl(`${API}/repos/${ownerRepo}/issues?state=all&per_page=1&sort=created&direction=desc`, { headers: authHeaders(token) }); - if (!head.ok) throw new Error(`GET issues?per_page=1 -> HTTP ${head.status}`); - const newest = await head.json(); +export async function probeBoard(wanted, { ownerRepo, token, fetchImpl = fetch, budget = boardReadBudget() }) { + const head = await readBoard(fetchImpl, `${API}/repos/${ownerRepo}/issues?state=all&per_page=1&sort=created&direction=desc`, { headers: authHeaders(token) }, budget); + if (!head.ok) throw readFailure('GET issues?per_page=1', head, budget.policy); + const newest = head.body; if (!Array.isArray(newest) || newest.length === 0) throw new Error('probeBoard: could not read the allocation frontier'); const frontier = newest[0].number; const numbers = []; const pulls = []; for (const n of [...new Set(wanted)].sort((a, b) => a - b)) { - const res = await fetchImpl(`${API}/repos/${ownerRepo}/issues/${n}`, { headers: authHeaders(token) }); + const res = await readBoard(fetchImpl, `${API}/repos/${ownerRepo}/issues/${n}`, { headers: authHeaders(token) }, budget); + /* A 404 is an ANSWER -- this number is absent -- and is never retried. */ if (res.status === 404) continue; - if (!res.ok) throw new Error(`GET issues/${n} -> HTTP ${res.status}`); - const row = await res.json(); + if (!res.ok) throw readFailure(`GET issues/${n}`, res, budget.policy); + const row = res.body; numbers.push(n); if (row.pull_request) pulls.push(n); } @@ -955,10 +1088,13 @@ export function boardWanted(rows, ownerRepo) { return [...new Set(rows.filter((r) => namesThisRepository(r, ownerRepo)).map((r) => r.number))]; } -async function buildBoard({ rows, ownerRepo, token, strategy }) { +async function buildBoard({ rows, ownerRepo, token, strategy, fetchImpl = fetch, sleep = sleepMs }) { const wanted = boardWanted(rows, ownerRepo); - if (strategy === 'enumerate' || wanted.length > 400) return enumerateBoard({ ownerRepo, token }); - return probeBoard(wanted, { ownerRepo, token }); + /* ONE budget for the whole run, whichever strategy reads -- the per-run + * bound `BOARD_READ_RETRY` documents. */ + const budget = boardReadBudget({ sleep }); + if (strategy === 'enumerate' || wanted.length > 400) return enumerateBoard({ ownerRepo, token, fetchImpl, budget }); + return probeBoard(wanted, { ownerRepo, token, fetchImpl, budget }); } /** @@ -1015,7 +1151,7 @@ function prerequisiteRefusal(message) { process.exit(EXIT_PREREQUISITE_NOT_MET); } -export async function run({ root = process.cwd(), scope = 'diff', base = null, json = false, probeCause = false, ownerRepo = 'objectstack-ai/objectstack', strategy = 'auto' } = {}) { +export async function run({ root = process.cwd(), scope = 'diff', base = null, json = false, probeCause = false, ownerRepo = 'objectstack-ai/objectstack', strategy = 'auto', fetchImpl = fetch, sleep = sleepMs } = {}) { let resolvedBase = null; if (scope === 'diff') { const attempt = resolveDiffBase({ root, base }); @@ -1033,7 +1169,7 @@ export async function run({ root = process.cwd(), scope = 'diff', base = null, j const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN || ''; let board; try { - board = await buildBoard({ rows, ownerRepo, token, strategy }); + board = await buildBoard({ rows, ownerRepo, token, strategy, fetchImpl, sleep }); } catch (err) { /* The route is re-armed at the dispatch, BEFORE this read, so on any run * that could re-arm `plan.rearm` is already false here. What is left to say From 27383275da9b6cd8317000dce8617bdab94ff042 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 08:20:35 +0000 Subject: [PATCH 2/3] test(gates): pin the citations gate's board-read retry in its self-test A new `retry` battery (42 cases, floor raised to 10 batteries): one transient 500 then a 200 resolves on the head probe and on a per-number probe; persistent 500s are refused after exactly the ladder and exit 3 end to end; a 404 is asked once and stays a finding; every other 4xx and every unnamed thrown error is not retried; the per-run budget caps a flapping board at 3000 ms of backoff; no scripted failure at any read yields a board. Claude-Session: https://claude.ai/code/session_01B7mrh59UAGffd7s9asUfoY Co-authored-by: Claude --- scripts/check-issue-citations.mjs | 241 +++++++++++++++++++++++++++++- 1 file changed, 236 insertions(+), 5 deletions(-) diff --git a/scripts/check-issue-citations.mjs b/scripts/check-issue-citations.mjs index 21d7763e1ad..779b59a3f6e 100644 --- a/scripts/check-issue-citations.mjs +++ b/scripts/check-issue-citations.mjs @@ -171,7 +171,7 @@ import { execFileSync, spawnSync } from 'node:child_process'; import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { gitFreeEnv } from './git-env.mjs'; import { globToRegExp } from './glob-match.mjs'; @@ -1260,10 +1260,11 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'proxy-rearm': 10, qualifier: 40, spellings: 56, + retry: 42, }); /** Deleting a roster entry silences its floor, so the roster's size is pinned too. */ -const SELF_TEST_BATTERY_FLOOR = 9; +const SELF_TEST_BATTERY_FLOOR = 10; /** * ⭐ THE ONE ENUMERATION OF CITATION SPELLINGS (#20636). Every spelling this @@ -1354,16 +1355,33 @@ const UNATTRIBUTED_BATTERY = '(no battery open)'; */ const SELF_TEST_VERDICT = 'check-issue-citations self-test reached its verdict'; -/** A `fetch` stand-in over a declared routing table. No network, ever. */ +/** + * A connection failure in the shape node 22's fetch throws it: a `TypeError` + * whose `cause.code` names the failure (measured -- see `BOARD_READ_RETRY`). + */ +function connectionFailure(code, message = 'fetch failed') { + const detail = code === 'UND_ERR_SOCKET' ? 'other side closed' : `read ${code}`; + return new TypeError(message, { cause: Object.assign(new Error(detail), { code }) }); +} + +/** + * A `fetch` stand-in over a declared routing table. No network, ever. A reply + * may `throws` (a code, spelled as `connectionFailure` builds it, or an error + * to throw as-is) or `bodyThrows` (the same, raised while the body is read). + */ function stubFetch(routes) { return async (url) => { for (const [pattern, reply] of routes) { if (!pattern.test(url)) continue; const r = typeof reply === 'function' ? reply(url) : reply; + if (r.throws) throw typeof r.throws === 'string' ? connectionFailure(r.throws) : r.throws; return { ok: (r.status ?? 200) < 400, status: r.status ?? 200, - json: async () => r.body, + json: async () => { + if (r.bodyThrows) throw typeof r.bodyThrows === 'string' ? connectionFailure(r.bodyThrows, 'terminated') : r.bodyThrows; + return r.body; + }, headers: { get: (k) => (r.headers ?? {})[k.toLowerCase()] ?? null }, }; } @@ -1691,6 +1709,219 @@ export async function selfTest() { 'every "not a citation" row must say why'); } + /* 10. THE RETRY (#23361). A transient board read is retried inside a bounded + * budget, and everything else keeps its exact old meaning. ⛔ The card's + * hardest line closes the battery: no scripted failure lets an unread + * board pass. No case sleeps -- the backoff is observed through `sleep`. */ + battery('retry'); + { + const OWN = 'o/r'; + const P = BOARD_READ_RETRY; + const recorder = () => { + const slept = []; + return { slept, sleep: async (ms) => { slept.push(ms); }, budget: boardReadBudget({ sleep: async (ms) => { slept.push(ms); } }) }; + }; + /* A reply sequence for one route: each call takes the next reply, the last one sticks. */ + const seq = (...replies) => { + let calls = 0; + const next = () => replies[Math.min(calls++, replies.length - 1)]; + next.calls = () => calls; + return next; + }; + const rejection = async (promise) => { try { await promise; return null; } catch (err) { return err; } }; + const HEAD = { body: [{ number: 500 }] }; + const LIVE_100 = { body: { number: 100 } }; + const probe = (wanted, budget, routes) => probeBoard(wanted, { ownerRepo: OWN, token: '', budget, fetchImpl: stubFetch(routes) }); + + /* The predicate, against the shapes measured on node 22.22.0. */ + check(retryableNetworkCode(connectionFailure('ECONNRESET')) === 'ECONNRESET', 'a reset (`fetch failed`, cause ECONNRESET) is a named connection failure'); + check(retryableNetworkCode(connectionFailure('UND_ERR_SOCKET', 'terminated')) === 'UND_ERR_SOCKET', 'a socket the peer closed mid-body (`terminated`, cause UND_ERR_SOCKET) is one too'); + check(retryableNetworkCode(new TypeError('fetch failed', { cause: new Error('x', { cause: Object.assign(new Error('y'), { code: 'ECONNRESET' }) }) })) === 'ECONNRESET', + 'the code is found down the cause chain, not only one level deep'); + check(retryableNetworkCode(connectionFailure('ECONNREFUSED')) === null && retryableNetworkCode(connectionFailure('ENOTFOUND')) === null, + '⛔ a refused connection and a failed lookup are route answers, not blips: not named, not retried'); + check(retryableNetworkCode(new Error('plain')) === null && retryableNetworkCode(null) === null, 'an error with no code, and no error at all, name nothing'); + + /* The card's case: one transient 500, then a 200, resolves -- on both reads the measured failures hit. */ + { + const { slept, budget } = recorder(); + const head = seq({ status: 500 }, HEAD); + const board = await probe([100], budget, [[/per_page=1/, head], [/issues\/100$/, LIVE_100]]); + check(board.frontier === 500 && board.has(100) && head.calls() === 2 && slept.join() === '500', + '⭐ one transient 500 on the HEAD probe, then a 200, RESOLVES: one retry after one 500 ms backoff'); + } + { + const { slept, budget } = recorder(); + const one = seq({ status: 500 }, LIVE_100); + const board = await probe([100], budget, [[/per_page=1/, HEAD], [/issues\/100$/, one]]); + check(classifyCitation({ number: 100, qualifier: null }, board, { ownerRepo: OWN }).cause === CAUSE.RESOLVES && one.calls() === 2 && slept.join() === '500', + '⭐ one transient 500 on a PER-NUMBER probe, then a 200, RESOLVES the citation: the read the queue ejection died on'); + } + { + const { budget } = recorder(); + const reset = seq({ throws: 'ECONNRESET' }, LIVE_100); + const board = await probe([100], budget, [[/per_page=1/, HEAD], [/issues\/100$/, reset]]); + check(board.has(100) && reset.calls() === 2, 'a connection reset, then a 200, resolves'); + } + { + const { budget } = recorder(); + const cut = seq({ ...LIVE_100, bodyThrows: 'UND_ERR_SOCKET' }, LIVE_100); + const board = await probe([100], budget, [[/per_page=1/, HEAD], [/issues\/100$/, cut]]); + check(board.has(100) && cut.calls() === 2, 'a socket closed while the BODY streams is retried too: the unit retried is the request and its body'); + } + { + const { budget } = recorder(); + const page1 = { body: [{ number: 1 }], headers: { link: '; rel="next"' } }; + const page2 = seq({ status: 502 }, { body: [{ number: 4, pull_request: {} }] }); + const board = await enumerateBoard({ ownerRepo: OWN, token: '', budget, fetchImpl: stubFetch([[/page=2/, page2], [/issues\?state=all/, page1]]) }); + check(board.frontier === 4 && board.isPull(4) && page2.calls() === 2, 'the ENUMERATING strategy shares the retry: a transient 502 on page 2 still yields the whole board'); + } + + /* Persistent failure: refused after exactly the ladder, with a refusal that names it. */ + { + const { slept, budget } = recorder(); + const head = seq({ status: 500 }); + const err = await rejection(probe([100], budget, [[/per_page=1/, head]])); + check(err !== null && head.calls() === P.attemptsPerRead && slept.join() === P.backoffMs.join(), + `⭐ persistent 500s are NOT a board: refused after exactly ${P.attemptsPerRead} attempts and ${P.backoffMs.join(' + ')} ms of backoff`); + check((err?.message ?? '').startsWith('GET issues?per_page=1 -> HTTP 500 after 3 attempts'), + `the refusal names the attempt count, so a red says it was not one blip; got ${JSON.stringify(err?.message)}`); + check(!/#\d/.test(err?.message ?? ''), 'the refusal text is runtime prose and carries no tracker number'); + } + { + const { slept, budget } = recorder(); + const reset = seq({ throws: 'ECONNRESET' }); + const err = await rejection(probe([100], budget, [[/per_page=1/, HEAD], [/issues\/100$/, reset]])); + check(err !== null && reset.calls() === 3 && slept.length === 2, 'persistent resets are refused after the same ladder'); + check((err?.message ?? '').startsWith('GET issues/100 -> ECONNRESET after 3 attempts') && retryableNetworkCode(err?.cause) === 'ECONNRESET', + '…naming the read and the code, with the original failure kept as its cause'); + } + + /* A 4xx is an answer. ⛔ A 404 keeps its meaning: absent, a FINDING, asked once. */ + { + const { slept, budget } = recorder(); + const gone = seq({ status: 404, body: {} }); + const board = await probe([150], budget, [[/per_page=1/, HEAD], [/issues\/150$/, gone]]); + check(gone.calls() === 1 && slept.length === 0 && !board.has(150) + && classifyCitation({ number: 150, qualifier: null }, board, { ownerRepo: OWN }).cause === CAUSE.ALLOCATED_BUT_ABSENT, + '⭐ a 404 keeps its meaning: asked ONCE, never retried, and the citation is still a FINDING'); + } + for (const status of [401, 403, 429]) { + const { slept, budget } = recorder(); + const denied = seq({ status }); + const err = await rejection(probe([100], budget, [[/per_page=1/, HEAD], [/issues\/100$/, denied]])); + check(err !== null && denied.calls() === 1 && slept.length === 0 && err.message.includes(`HTTP ${status} after 1 attempt (not retried`), + `a ${status} is an answer, not a blip: refused on the first attempt, with no backoff`); + } + + /* Every other thrown error reaches the caller UNCHANGED, on its old exit path. */ + const unnamed = [ + ['a refused connection (ECONNREFUSED)', connectionFailure('ECONNREFUSED')], + ['a failed lookup (ENOTFOUND)', connectionFailure('ENOTFOUND')], + ['a proxy refusing the tunnel (UND_ERR_ABORTED, two causes deep)', new TypeError('fetch failed', { + cause: new Error('Request was cancelled.', { cause: Object.assign(new Error('Proxy response (502) !== 200 when HTTP Tunneling'), { code: 'UND_ERR_ABORTED' }) }), + })], + ]; + for (const [label, thrown] of unnamed) { + const { slept, budget } = recorder(); + const refused = seq({ throws: thrown }); + const err = await rejection(probe([100], budget, [[/per_page=1/, refused]])); + check(err === thrown && refused.calls() === 1 && slept.length === 0, `${label} is not retried, and is rethrown as the very error it was`); + } + { + const { budget } = recorder(); + const garbled = new SyntaxError('Unexpected token < in JSON'); + const err = await rejection(probe([100], budget, [[/per_page=1/, { ...HEAD, bodyThrows: garbled }]])); + check(err === garbled, 'a body that is not JSON is not a blip either: rethrown unchanged'); + } + + /* The budget, per READ and per RUN -- the bound the lint lane relies on. */ + check(P.backoffMs.length === P.attemptsPerRead - 1, 'one backoff per retry a read may take, no more and no fewer'); + const ladder = P.backoffMs.reduce((a, b) => a + b, 0); + check(ladder === 1500 && (P.retriesPerRun / (P.attemptsPerRead - 1)) * ladder === 3000, + `the policy matches the documented worst cases: 1500 ms for a board that stays down, 3000 ms for one that flaps (ladder ${ladder} ms)`); + for (const [strategy, count] of [['auto', 400], ['enumerate', 3]]) { + const { slept, sleep } = recorder(); + let reads = 0; + const down = async () => { reads += 1; return { ok: false, status: 503, json: async () => ({}), headers: { get: () => null } }; }; + const rows = Array.from({ length: count }, (_, i) => ({ number: 100 + i, qualifier: null })); + const err = await rejection(buildBoard({ rows, ownerRepo: OWN, token: '', strategy, fetchImpl: down, sleep })); + check(err !== null && reads === P.attemptsPerRead && slept.reduce((a, b) => a + b, 0) === ladder, + `⭐ a board that STAYS DOWN costs one ladder (${ladder} ms, ${P.attemptsPerRead} reads) and then refuses — ${strategy === 'auto' ? `${count} cited numbers` : 'the enumerating strategy'} included; got ${reads} reads`); + } + { + /* A board that FLAPS: every read fails twice, then answers. Each read + * would spend its whole ladder, so the run budget is what stops it. */ + const { slept, sleep } = recorder(); + const perUrl = new Map(); + const flap = (url) => { + if (!perUrl.has(url)) perUrl.set(url, seq({ status: 500 }, { status: 500 }, /per_page=1/.test(url) ? HEAD : { body: { number: Number(url.split('/').pop()) } })); + return perUrl.get(url)(); + }; + const rows = [100, 101, 102, 103, 104].map((number) => ({ number, qualifier: null })); + const err = await rejection(buildBoard({ rows, ownerRepo: OWN, token: '', strategy: 'auto', fetchImpl: stubFetch([[/./, flap]]), sleep })); + check(err !== null && slept.length === P.retriesPerRun && slept.reduce((a, b) => a + b, 0) === 3000, + `⭐ a board that FLAPS spends at most the run's ${P.retriesPerRun} retries (3000 ms) and then refuses; slept ${slept.join(', ')}`); + check((err?.message ?? '').includes(`after 1 attempt (this run's budget of ${P.retriesPerRun} retries is spent)`), + `…and the refusal says the run budget, not the read, is what ran out; got ${JSON.stringify(err?.message)}`); + } + + /* ⛔ NOT A WEAKER GATE: no scripted failure, at any read, yields a board. */ + const persistent = [ + ['HTTP 500', { status: 500 }], ['HTTP 502', { status: 502 }], ['HTTP 503', { status: 503 }], ['HTTP 504', { status: 504 }], + ['ECONNRESET', { throws: 'ECONNRESET' }], ['UND_ERR_SOCKET', { throws: 'UND_ERR_SOCKET' }], + ['UND_ERR_SOCKET mid-body', { body: [], bodyThrows: 'UND_ERR_SOCKET' }], + ['HTTP 401', { status: 401 }], ['HTTP 403', { status: 403 }], ['HTTP 429', { status: 429 }], + ['ECONNREFUSED', { throws: 'ECONNREFUSED' }], + ]; + for (const [label, reply] of persistent) { + const rows = [{ number: 100, qualifier: null }]; + const placements = [ + ['the head probe', 'auto', [[/./, reply]]], + ['a per-number probe', 'auto', [[/per_page=1/, HEAD], [/./, reply]]], + ['an enumerated page', 'enumerate', [[/./, reply]]], + ]; + const passed = []; + for (const [where, strategy, routes] of placements) { + const { sleep } = recorder(); + if (await rejection(buildBoard({ rows, ownerRepo: OWN, token: '', strategy, fetchImpl: stubFetch(routes), sleep })) === null) passed.push(where); + } + check(passed.length === 0, `⛔ a persistent ${label} never yields a board — it passed at: ${passed.join(', ') || 'nowhere'}`); + } + + /* …and end to end: a persistent 500 EXITS 3, read off a real process. The + * child is offline -- its `fetchImpl` is a stub -- and judges a real diff. */ + const tmp = mkdtempSync(join(tmpdir(), 'check-issue-citations-retry-')); + try { + const git = (...args) => execFileSync('git', args, { cwd: tmp, env: gitFreeEnv(), stdio: ['ignore', 'pipe', 'pipe'] }); + mkdirSync(join(tmp, 'packages/p/src'), { recursive: true }); + writeFileSync(join(tmp, 'packages/p/src/thing.ts'), 'export const x = 1;\n'); + git('init', '-q'); + git('config', 'user.email', 'selftest@example.invalid'); + git('config', 'user.name', 'selftest'); + git('add', '-A'); + git('commit', '-qm', 'base'); + const base = git('rev-parse', 'HEAD').toString().trim(); + writeFileSync(join(tmp, 'packages/p/src/thing.ts'), '// blocked on #100 landing\nexport const x = 1;\n'); + const childSource = [ + `const { run } = await import(${JSON.stringify(pathToFileURL(SELF_PATH).href)});`, + "const fetchImpl = async () => { process.stderr.write('stub-read\\n'); return { ok: false, status: 500, json: async () => ({}), headers: { get: () => null } }; };", + `process.exit(await run({ root: ${JSON.stringify(tmp)}, base: ${JSON.stringify(base)}, fetchImpl, sleep: async () => {} }));`, + ].join('\n'); + const env = gitFreeEnv(); + delete env.GITHUB_TOKEN; + delete env.GH_TOKEN; + const child = spawnSync(process.execPath, ['--input-type=module', '-e', childSource], { cwd: tmp, env, encoding: 'utf8' }); + const reads = ((child.stderr ?? '').match(/^stub-read$/gm) ?? []).length; + check(child.status === EXIT_PREREQUISITE_NOT_MET && EXIT_PREREQUISITE_NOT_MET === 3, + `⭐ persistent 500s EXIT 3 end to end, never 0 — got exit ${child.status}${child.status === EXIT_PREREQUISITE_NOT_MET ? '' : `: ${(child.stderr ?? '').slice(-400)}`}`); + check(reads === P.attemptsPerRead && (child.stderr ?? '').includes('PREREQUISITE NOT MET') && (child.stderr ?? '').includes('HTTP 500 after 3 attempts'), + `…after exactly ${P.attemptsPerRead} reads, and the refusal it prints names them (read ${reads})`); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + } + /* ── The floor: every declared battery RAN, and ran its cases ───────────── */ const floorMessages = []; const floorFailure = (m) => { floorMessages.push(m); }; @@ -1719,7 +1950,7 @@ export async function selfTest() { assert(!breached, floorMessages.join('\n ')); const total = [...batterySeen.values()].reduce((a, b) => a + b, 0); - console.log(`✅ check-issue-citations --self-test: grammar narrowed, every spelling enumerated, qualifier a closed set of repositories, four 404 causes kept apart, both board strategies agree, diff scope red AND green, scope contract pinned (${total} cases, ${declared.length} batteries)`); + console.log(`✅ check-issue-citations --self-test: grammar narrowed, every spelling enumerated, qualifier a closed set of repositories, four 404 causes kept apart, both board strategies agree, a transient board read retried inside a bounded budget and never passed unread, diff scope red AND green, scope contract pinned (${total} cases, ${declared.length} batteries)`); return SELF_TEST_VERDICT; } From 3becc14af9e0b1b1798944a3bf39c62b8ffa0581 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 08:22:39 +0000 Subject: [PATCH 3/3] test(gates): a citations retry case that must resolve names itself when it refuses Every resolve case settles its promise, so a regression reds that case by name rather than escaping as an uncaught throw; the per-read refusal reads "every attempt one read is allowed, so not one blip". Claude-Session: https://claude.ai/code/session_01B7mrh59UAGffd7s9asUfoY Co-authored-by: Claude --- scripts/check-issue-citations.mjs | 44 +++++++++++++++++-------------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/scripts/check-issue-citations.mjs b/scripts/check-issue-citations.mjs index 779b59a3f6e..d2a21576f89 100644 --- a/scripts/check-issue-citations.mjs +++ b/scripts/check-issue-citations.mjs @@ -820,7 +820,7 @@ export function readFailure(label, read, policy = BOARD_READ_RETRY) { const tries = `after ${read.attempts} attempt${read.attempts === 1 ? '' : 's'}`; const why = { answer: 'not retried: only a 5xx or a connection reset is', - attempts: `the per-read cap of ${policy.attemptsPerRead} attempts, so not one blip`, + attempts: 'every attempt one read is allowed, so not one blip', budget: `this run's budget of ${policy.retriesPerRun} retries is spent`, }[read.stoppedBy]; return new Error(`${label} -> ${answer} ${tries} (${why})`, read.cause ? { cause: read.cause } : undefined); @@ -1728,7 +1728,11 @@ export async function selfTest() { next.calls = () => calls; return next; }; - const rejection = async (promise) => { try { await promise; return null; } catch (err) { return err; } }; + /* A case that must RESOLVE settles its promise, so a refusal reds that case BY NAME + * instead of escaping as an uncaught throw that names no case at all. */ + const settle = async (promise) => { try { return { board: await promise, err: null }; } catch (err) { return { board: null, err }; } }; + const rejection = async (promise) => (await settle(promise)).err; + const refused = (err) => (err ? `; it refused instead: ${err.message}` : ''); const HEAD = { body: [{ number: 500 }] }; const LIVE_100 = { body: { number: 100 } }; const probe = (wanted, budget, routes) => probeBoard(wanted, { ownerRepo: OWN, token: '', budget, fetchImpl: stubFetch(routes) }); @@ -1746,35 +1750,35 @@ export async function selfTest() { { const { slept, budget } = recorder(); const head = seq({ status: 500 }, HEAD); - const board = await probe([100], budget, [[/per_page=1/, head], [/issues\/100$/, LIVE_100]]); - check(board.frontier === 500 && board.has(100) && head.calls() === 2 && slept.join() === '500', - '⭐ one transient 500 on the HEAD probe, then a 200, RESOLVES: one retry after one 500 ms backoff'); + const { board, err } = await settle(probe([100], budget, [[/per_page=1/, head], [/issues\/100$/, LIVE_100]])); + check(board?.frontier === 500 && board.has(100) && head.calls() === 2 && slept.join() === '500', + `⭐ one transient 500 on the HEAD probe, then a 200, RESOLVES: one retry after one 500 ms backoff${refused(err)}`); } { const { slept, budget } = recorder(); const one = seq({ status: 500 }, LIVE_100); - const board = await probe([100], budget, [[/per_page=1/, HEAD], [/issues\/100$/, one]]); - check(classifyCitation({ number: 100, qualifier: null }, board, { ownerRepo: OWN }).cause === CAUSE.RESOLVES && one.calls() === 2 && slept.join() === '500', - '⭐ one transient 500 on a PER-NUMBER probe, then a 200, RESOLVES the citation: the read the queue ejection died on'); + const { board, err } = await settle(probe([100], budget, [[/per_page=1/, HEAD], [/issues\/100$/, one]])); + check(board !== null && classifyCitation({ number: 100, qualifier: null }, board, { ownerRepo: OWN }).cause === CAUSE.RESOLVES && one.calls() === 2 && slept.join() === '500', + `⭐ one transient 500 on a PER-NUMBER probe, then a 200, RESOLVES the citation: the read the queue ejection died on${refused(err)}`); } { const { budget } = recorder(); const reset = seq({ throws: 'ECONNRESET' }, LIVE_100); - const board = await probe([100], budget, [[/per_page=1/, HEAD], [/issues\/100$/, reset]]); - check(board.has(100) && reset.calls() === 2, 'a connection reset, then a 200, resolves'); + const { board, err } = await settle(probe([100], budget, [[/per_page=1/, HEAD], [/issues\/100$/, reset]])); + check(board?.has(100) === true && reset.calls() === 2, `a connection reset, then a 200, resolves${refused(err)}`); } { const { budget } = recorder(); const cut = seq({ ...LIVE_100, bodyThrows: 'UND_ERR_SOCKET' }, LIVE_100); - const board = await probe([100], budget, [[/per_page=1/, HEAD], [/issues\/100$/, cut]]); - check(board.has(100) && cut.calls() === 2, 'a socket closed while the BODY streams is retried too: the unit retried is the request and its body'); + const { board, err } = await settle(probe([100], budget, [[/per_page=1/, HEAD], [/issues\/100$/, cut]])); + check(board?.has(100) === true && cut.calls() === 2, `a socket closed while the BODY streams is retried too: the unit retried is the request and its body${refused(err)}`); } { const { budget } = recorder(); const page1 = { body: [{ number: 1 }], headers: { link: '; rel="next"' } }; const page2 = seq({ status: 502 }, { body: [{ number: 4, pull_request: {} }] }); - const board = await enumerateBoard({ ownerRepo: OWN, token: '', budget, fetchImpl: stubFetch([[/page=2/, page2], [/issues\?state=all/, page1]]) }); - check(board.frontier === 4 && board.isPull(4) && page2.calls() === 2, 'the ENUMERATING strategy shares the retry: a transient 502 on page 2 still yields the whole board'); + const { board, err } = await settle(enumerateBoard({ ownerRepo: OWN, token: '', budget, fetchImpl: stubFetch([[/page=2/, page2], [/issues\?state=all/, page1]]) })); + check(board?.frontier === 4 && board.isPull(4) && page2.calls() === 2, `the ENUMERATING strategy shares the retry: a transient 502 on page 2 still yields the whole board${refused(err)}`); } /* Persistent failure: refused after exactly the ladder, with a refusal that names it. */ @@ -1801,10 +1805,10 @@ export async function selfTest() { { const { slept, budget } = recorder(); const gone = seq({ status: 404, body: {} }); - const board = await probe([150], budget, [[/per_page=1/, HEAD], [/issues\/150$/, gone]]); - check(gone.calls() === 1 && slept.length === 0 && !board.has(150) + const { board, err } = await settle(probe([150], budget, [[/per_page=1/, HEAD], [/issues\/150$/, gone]])); + check(board !== null && gone.calls() === 1 && slept.length === 0 && !board.has(150) && classifyCitation({ number: 150, qualifier: null }, board, { ownerRepo: OWN }).cause === CAUSE.ALLOCATED_BUT_ABSENT, - '⭐ a 404 keeps its meaning: asked ONCE, never retried, and the citation is still a FINDING'); + `⭐ a 404 keeps its meaning: asked ONCE, never retried, and the citation is still a FINDING${refused(err)}`); } for (const status of [401, 403, 429]) { const { slept, budget } = recorder(); @@ -1824,9 +1828,9 @@ export async function selfTest() { ]; for (const [label, thrown] of unnamed) { const { slept, budget } = recorder(); - const refused = seq({ throws: thrown }); - const err = await rejection(probe([100], budget, [[/per_page=1/, refused]])); - check(err === thrown && refused.calls() === 1 && slept.length === 0, `${label} is not retried, and is rethrown as the very error it was`); + const failing = seq({ throws: thrown }); + const err = await rejection(probe([100], budget, [[/per_page=1/, failing]])); + check(err === thrown && failing.calls() === 1 && slept.length === 0, `${label} is not retried, and is rethrown as the very error it was`); } { const { budget } = recorder();