Skip to content

Commit 31184e5

Browse files
feat(pm): pace every seat write — one shared throttle in front of the five write scripts (#19572) (#19584)
Fixes #19572 Clause-②: no Filed and dispatched by the director seat under the maintainer's verbatim ruling: > 截流应该要加 (chat, 2026-09-21) — the sentence #208 R6 requires before a new instrument. Two seat accounts were suspended by the platform earlier the same day during bulk issue operations. This PR makes pacing a property of the write path rather than of who remembered it. ## What lands **`scripts/pm/write-pace.mjs`** — one helper, two calls, no third responsibility. It reads no body, judges no card and changes nothing a write says. It answers one question, asked immediately before a request leaves: may this write go now, and if not now, after how long a pause — or not at all? The three rules, defaults as the charter states them: | rule | default | what happens | |:---|:---|:---| | ① minimum gap | 3 s per token | the remainder is **slept**, and the pause prints one line saying so | | ② trailing-hour budget | 40 writes per token | the 41st is **REFUSED** — exit `10`, with a prescription. ⛔ Never a silent wait | | ③ back-off marker | 30 min, or `retry-after` when that reaches further | every write by that token is refused, with the marker's expiry printed, until it expires | The log is `~/.cache/objectstack-pm/write-pace.jsonl` (`OS_PM_WRITE_PACE_FILE` moves it), one JSON line per write, keyed by `tokenKey()` — a 12-hex prefix of the token's SHA-256. ⛔ The token itself never reaches the file, in any field. `OS_PM_WRITE_MIN_GAP_MS` and `OS_PM_WRITE_HOURLY_MAX` override ① and ② **for tests**; a malformed override is IGNORED and said out loud, because an override that silently loosens a budget is worse than one that is refused. Exit `10` is distinct from every code the five calling tools use (their highest is 9), so a seat reading an exit code can always tell "the throttle stopped me" from anything the tool itself decided. ### Four design points worth reading before the diff - **`classifyHttp` is reused by VALUE, never re-derived.** `label-write.mjs` already owns "what does this status mean", and its `ratelimit` arm is handed to `noteResponse` as `verdict` by the two callers that already hold it. ⛔ It is not imported: that module pulls the half-states patrol in behind it, and a throttle every write path loads must not drag a 26k-line gate into every process. What the throttle adds is the signals that classifier does not answer, and none of them is a copy of it — a 429 whose quota is not exhausted, a 403 whose BODY names the secondary limit, a bare `retry-after`, and `x-ratelimit-remaining: 0` on ANY status including the 200 that spent the last unit. - **Reserve, then sleep.** The decision and the record of it are taken under one `mkdir` lock, and the sleep happens after the lock is released: the record's timestamp is the instant the write will be ISSUED. A second process asking while the first is still sleeping sees the reservation and paces behind it. The lock FAILS OPEN — it can only ever cost precision, never a seat's ability to write. - **Dry runs are excluded STRUCTURALLY.** This file has no idea what a dry run is and must not. The guard is `isWriteMethod()` in each transport, and every `--dry-run` path in those tools returns before it issues a write request at all. Proven below with a positive control, not asserted. - **Every line goes to stderr.** `post-stamped.mjs` prints the rendered body to stdout and `--json` after it; a pacing line on stdout would corrupt both. `--status` is the one command whose report is stdout, because reading it IS the output. ## The census of write sites, and each one's two calls `git grep -n "method: 'POST'|'PATCH'|'PUT'|'DELETE'" scripts/pm` finds twelve write sites in five files. The wiring is per **transport**, not per site, so all twelve are covered by five pairs: | file | transport | write sites it issues | `paceWrite` before | `noteResponse` after | |:---|:---|:---|:---|:---| | `post-stamped.mjs` | `rest` | comment `POST` (2586), body `PATCH` (2590) | 2547 | 2565 (refusal path, carrying the platform's own sentence) · 2573 (2xx path) | | `label-write.mjs` | `rest` | label `POST`/`DELETE`, assignee `POST`/`DELETE`, the whole-set `PATCH` fallback, the re-add `POST` (538–541, 585, 626) | 335 | 363, with `classifyHttp`'s verdict handed over | | `close-cards.mjs` | `rest` | the closing `PATCH` (651) | 445 | 471, with `classifyHttp`'s verdict handed over | | `sweep-closed-cards.mjs` | `rest` | one `DELETE` per residue label (622), one comment `POST` (633) | 502 | 517 (refusal path) · 522 (2xx path) | | `sweep-stale-finding.mjs` | `restLive` | the one `DELETE` that strips the stale grade (424) | 360 | 374 (refusal path) · 381 (2xx path) | Line numbers are the post-merge ones. `close-cards.mjs` posts its comment by spawning `post-stamped.mjs` as a child, so that write is paced by post-stamped's own transport rather than twice. `ensure-pm-labels.sh` runs through `gh` under the maintainer's hand and stays out, as the card directs. On the two sweeps and post-stamped's refusal path the response body is now read before the throw — only to be CLASSIFIED, because a secondary-rate-limit 403 names itself in prose and nowhere else. The error objects carry exactly what they carried before. ## The self-test — 64 cases across 9 batteries `node scripts/pm/write-pace.mjs --self-test` — offline, injected clock, injected sleep, injected exit; nothing waits three real seconds and nothing touches the real cache file. Floor is the battery ROSTER, not a total, and the verdict sets `selfTestReachedVerdict` as its last statement. | battery | floor | what it pins | |:---|---:|:---| | the token key: enough to separate identities, useless as a credential | 6 | 12-hex prefix, determinism, two tokens are two budgets, ⛔ the key is not a prefix of the token, a token-less path still paces | | the write-verb predicate: a read is never paced | 5 | the four verbs, `GET`/`HEAD`/omitted, and that case is not a way to dodge it | | the limits: overrides are for tests, and a malformed one never loosens | 6 | defaults are the contract; `4O` falls back to 40 AND says so; `-1` is malformed; `0` is a real value | | rule ①: the remainder is slept, and the pause is said out loud | 7 | first write does not pause; a write 1 s later sleeps 2 s not 3; after the gap, zero; the gap is PER TOKEN | | rule ②: the 41st is REFUSED, with a prescription | 8 | forty pass, the 41st refuses at exit 10, it did NOT sleep instead, the text names the count / the reopen instant / what to fold, and a refused write reserves NOTHING | | rule ③: what writes a stop marker, how long it lasts, what it refuses | 10 | 429, secondary-limit 403, `x-ratelimit-remaining: 0` on a **200**, `classifyHttp`'s verdict as an input; ⛔ a plain 403 writes none; a long `retry-after` wins and a short one does not shorten; refused while live, allowed once expired | | the prune: the trailing hour for writes, the expiry for markers | 6 | old writes dropped, an EXPIRED marker dropped, ⛔ an unexpired two-hour marker survives the hour it outlives, a torn line is dropped not fatal, and the file on disk is rewritten pruned | | the log, --status and the CLI: what lands on disk, and what must never | 9 | **THE case** — the token never appears in the file; `--status` counts the window, names the marker, never prints the token and spends no budget; the CLI exits 0, and a typo is usage | | the wiring: both halves, in every write transport, on write verbs only | 7 | the five tools each import the throttle and call both halves under `isWriteMethod`; and the roster IS the set of files under `scripts/pm` that issue a write verb, so a SIXTH write path cannot land unpaced | That last battery masks comments through the shared `js-comment-mask` (imported inside the battery, so the hot path stays a throttle) and skips any line carrying a regex-delimiter spelling of the pattern — three gates in that directory pin their own report-only-ness against a regex literal of exactly this shape, and reading those as write sites would demand a throttle on a file that cannot write at all. ``` ✓ write-pace self-test: 64 cases pass across 9 batteries — the per-token key that never carries the token, the gap remainder, the 41st refusal and its prescription, the stop marker a 429 writes, the prune that keeps a long retry-after alive, and the five write transports that call both halves. ``` ## A real `post-stamped.mjs --dry-run`, unchanged Run against `#19572` with a body carrying one quoted reading: ``` $ node scripts/pm/post-stamped.mjs --repo=objectstack-ai/objectstack --comment=19572 \ --file=dryrun-body.md --dry-run :: exit 0 post-stamped: DRY RUN — nothing was written. Substituted with `2026-09-21T09:44Z` — 0 {{NOW}}, 1 quoted · verbatim: 0 opener(s) inside a quoted span, left exactly as written (none). Stamp verdict: stamped-by-declaration — nothing was substituted and a `{{WAS:…}}` rendered from its own declaration, so the artefact is stamped as a reading of something else. Target would be #19572 (comment). ``` Two readings, because "unchanged" deserves better than a glance: 1. **Against the pre-change tree.** The same command run from a detached worktree at the merge base `b3615f1a4c` produces stdout and stderr **byte-identical** to the run above, with only the clock stamp normalised. 2. **A positive control, which is the stronger half.** Re-running the dry run under `OS_PM_WRITE_HOURLY_MAX=0 OS_PM_WRITE_MIN_GAP_MS=600000` — settings that would refuse ANY paced write at exit 10, or hang it for ten minutes — gives **exit 0 and byte-identical stdout and stderr**, and `OS_PM_WRITE_PACE_FILE` is **never created**. A dry run that had reached the throttle could not produce that result. Structural exclusion, measured. ## Gates Derived on this branch's own diff, exit codes captured before any pipe: ``` node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack -> 68 command(s), 8 path(s) vs merge base b3615f1, 1082 changed lines node scripts/pm/dispatch-gates.mjs --ran ran.list :: exit 0 -> 68 derived famil(ies) accounted for — 63 run, 5 NOT-MEASURED (5 DERIVED from a recorded exit 3), 0 UNRUN. ``` **63 of 68 green.** The other five all answered `PREREQUISITE NOT MET` at **exit 3** — NOT MEASURED, neither a pass nor a finding — because each reads a built `dist/` closure this container has not built: `check:dts-closure`, `check:dual-build-cjs-loads`, `check:lean-entry-closure`, `check:sourcemap-no-sources-content`, `check:type-check-debt`. This diff contains **zero** paths under `packages/**`, so none of the five has an input this branch can move; CI builds the closure and runs all five. `check:pm-dispatch-gates` was run detached per its own header (the battery took 831.4 s on this box, past the container's foreground cap): `✓ dispatch-gates self-test: 1883 cases pass.` :: exit 0. Also run, named in the dispatch and outside the derived 68: `pnpm check:pm-settings-deny-roster` (exit 0) and `pnpm check:pm-governed-prose` (exit 0). The five wired tools' own self-tests are in the 68 and all green — post-stamped 544 cases, close-cards 102, sweep-stale-finding 91, sweep-closed-cards 87, label-write 75. Fourteen further families "apply once this card's changeset exists" — see the next section. ## Landing tier ``` $ node scripts/pm/check-governed-merges.mjs --branch claude/issue-19572-write-pace :: exit 0 derived from `git diff --name-only --no-renames b3615f1 63f55e3` (three-dot): 8 path(s). origin/main = b3615f1, claude/issue-19572-write-pace = 63f55e3, merge-base = b3615f1. size: +1082 / -0 over 8 file(s) (0 binary, counted 0) — `git diff --numstat --no-renames` on the same range. governed-surface predicate: 0 of 8 path(s) hit the register (6 surfaces, repo-agnostic). ✅ NOT governed — ordinary queue landing applies to a PR with exactly this file list. Derived from GOVERNED_SURFACES, not recalled. Re-run on the FINAL file list: the register has grown several times in two days, and a reading taken earlier in the session is recall. ⚠️ head repo: NOT MEASURED — this verdict cannot tell a fork PR from an internal one; `--pr PR-NUMBER` reads it. size: 1082 changed line(s) (+1082 / -0) ≤ 5000 — under the human-merge threshold (generated files included in the count). ``` (Verbatim, with one exception: the tool prints its `--pr` placeholder in angle brackets, respelled here as `PR-NUMBER` because this platform's body sanitizer eats short angle-bracket fragments, fenced or not.) ⚠️ **The card says Tier S; the register says NOT governed.** #19572's body reads "`scripts/pm/**`, Tier S — the director seat lands it after an at-tier read", but `GOVERNED_SURFACES` does not list `scripts/pm/**`, and AGENTS.md Prime Directive #14 makes that table — not a card — the authority ("adding a surface is an edit *there*"). This is reported, not acted on in either direction: no seat here arms anything, and nothing was added to the register on this branch. The owning seat decides whether the discrepancy is a card that overstated the tier or a register that is missing a row. ## Changeset `skip-changeset`. The diff is `scripts/pm/**`, `.github/workflows/lint.yml` and one root `package.json` script alias — **nothing under `packages/**`**, so no released package's `files[]` ships one byte of it and there is nothing to publish. ## 维护者速读(草稿) **改了什么。** 给 PM 的五个写工具装了一道共享节流阀:每次 POST/PATCH/PUT/DELETE 发出之前先问它 "现在能写吗",答完再把平台的回答喂回去。同一个 token 两次写之间至少隔 3 秒(不够就补睡),一小时 最多 40 次,第 41 次直接拒绝并打印处方;平台一旦发出 429 或二级限流信号,30 分钟内(或它要求的更长 时间)这个 token 的写全部被拒。 **为什么改。** 维护者 2026-09-21 的裁决「截流应该要加」。当天有两个席位账号在批量操作 issue 时被 平台停用。此前五个工具里没有任何一个会计数或限速——只有 `label-write.mjs` 会在事后把 429 归类。 **风险与代价(含回滚)。** 最大的代价是:预算用尽时席位会被硬拒(退出码 10),而不是等。这是刻意的 ——在写路径里睡一小时是带借口的挂起。回滚成本很低:这是一次纯增量,五个工具各只多了两行调用和一个 import,`git revert` 即可;真要临时放宽,`OS_PM_WRITE_HOURLY_MAX` 可以在不改代码的情况下抬高(该 开关声明为"仅供测试",抬它属于维护者的行为,不是席位的)。锁是 fail-open 的,拿不到锁绝不会挡住 写入,只会让计数不那么精确。 **席位意见。** (待席位填写) **你要做的。** 只有一件:确认 40 次/小时与 3 秒这两个数字是你想要的。它们现在是合同(env 覆盖只对 测试成立),改它们要动代码。另外请看上面「Landing tier」一节标出的卡片与登记表不一致。 --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_012GcsUbuqFGBibkEDMRC1eE --- _Generated by [Claude Code](https://claude.ai/code/session_012GcsUbuqFGBibkEDMRC1eE)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 37ed9ae commit 31184e5

8 files changed

Lines changed: 1160 additions & 0 deletions

File tree

‎.github/workflows/lint.yml‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1336,6 +1336,31 @@ jobs:
13361336
# this step for. It shares `H56_STAMP_TOLERANCE_MIN` and the position
13371337
# reader with `check-half-states.mjs`, so the write side and the read-side
13381338
# patrol cannot come to disagree about what a stamp is.
1339+
# Write-pace throttle self-test (#19572) — the shared throttle the four
1340+
# steps below and `sweep-stale-finding` all issue their writes through,
1341+
# under the maintainer's 「截流应该要加」. Two seat accounts were suspended
1342+
# during bulk issue operations; this file is what makes pacing a property
1343+
# of the write path rather than of who remembered it.
1344+
#
1345+
# It has no production run at all: the live path is every OTHER tool's
1346+
# write, so the self-test is not "the half CI can hold", it is the only
1347+
# instrument there is. What it exercises is exactly what a clean tree
1348+
# cannot — that the 41st write in an hour is REFUSED with a distinct exit
1349+
# code and a prescription rather than slept on (a silent hour-long wait
1350+
# inside a write path is a hang with a good excuse); that a 429 writes a
1351+
# stop marker every later write is refused against; that an unexpired
1352+
# marker survives the trailing-hour prune by its own expiry; and that the
1353+
# token itself never reaches the cache file, only a SHA-256 prefix of it.
1354+
# Weakening any of them leaves every ordinary invocation just as green —
1355+
# the throttle would simply stop throttling — which is the shape
1356+
# `check:self-test-wired` requires this step for.
1357+
#
1358+
# Its last battery is the wiring: the five write transports each call both
1359+
# halves, and the roster IS the set of files under scripts/pm that issue a
1360+
# write verb, so a SIXTH write path cannot land unpaced.
1361+
- name: Write-pace throttle self-test
1362+
run: pnpm check:pm-write-pace
1363+
13391364
- name: Stamped-post helper self-test
13401365
run: pnpm check:pm-post-stamped
13411366

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@
8787
"check:pm-prior-rulings": "node scripts/pm/check-prior-rulings.mjs --self-test",
8888
"check:pm-closed-card-sweep": "node scripts/pm/sweep-closed-cards.mjs --self-test",
8989
"check:pm-stale-finding": "node scripts/pm/sweep-stale-finding.mjs --self-test",
90+
"check:pm-write-pace": "node scripts/pm/write-pace.mjs --self-test",
9091
"check:pm-post-stamped": "node scripts/pm/post-stamped.mjs --self-test",
9192
"check:pm-label-write": "node scripts/pm/label-write.mjs --self-test",
9293
"check:pm-close-cards": "node scripts/pm/close-cards.mjs --self-test",

‎scripts/pm/close-cards.mjs‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ import {
141141
resolveSweepRepo,
142142
} from './check-half-states.mjs';
143143
import { classifyHttp, parseOptions as parseLabelWriteOptions, runLabelWrite } from './label-write.mjs';
144+
import { isWriteMethod, noteResponse, paceWrite } from './write-pace.mjs';
144145
import {
145146
EXIT_NOT_STORED as POST_STAMPED_EXIT_NOT_STORED,
146147
STAMP_TOKEN,
@@ -437,6 +438,11 @@ export function skipReason(card, { expectState, openPrs = [] } = {}) {
437438
// ---------------------------------------------------------------------------
438439

439440
async function rest(path, { method = 'GET', body = null } = {}) {
441+
// ⏱ The throttle (#19572), on the write verbs only — the `PATCH` that
442+
// closes a card. The comment this tool posts goes through `post-stamped.mjs`
443+
// as a child process, which is paced by its own transport.
444+
const paced = isWriteMethod(method);
445+
if (paced) await paceWrite({ token: TOKEN, kind: `close-cards ${method}` });
440446
let res;
441447
try {
442448
res = await fetch(`${API}${path}`, {
@@ -460,6 +466,16 @@ async function rest(path, { method = 'GET', body = null } = {}) {
460466
json = null;
461467
}
462468
}
469+
// ⏱ …and the other half, with the verdict `classifyHttp` already reached.
470+
if (paced) {
471+
noteResponse({
472+
token: TOKEN,
473+
status: res.status,
474+
headers: res.headers,
475+
body: json,
476+
verdict: classifyHttp({ status: res.status, rateRemaining: rateRemaining === null ? null : Number(rateRemaining) }),
477+
});
478+
}
463479
return {
464480
status: res.status,
465481
rateRemaining: rateRemaining === null ? null : Number(rateRemaining),

‎scripts/pm/label-write.mjs‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ import { readFileSync } from 'node:fs';
103103
import { fileURLToPath } from 'node:url';
104104

105105
import { isEntrypoint } from '../invoked-as.mjs';
106+
import { isWriteMethod, noteResponse, paceWrite } from './write-pace.mjs';
106107
import {
107108
EXIT_PREREQUISITE_NOT_MET,
108109
PM_EXCLUSIVE_STATE_LABELS,
@@ -327,6 +328,11 @@ const render = (values) => (values.length ? values.map((v) => `\`${v}\``).join('
327328
// ---------------------------------------------------------------------------
328329

329330
async function rest(path, { method = 'GET', body = null } = {}) {
331+
// ⏱ The throttle (#19572), on the write verbs only. `paceWrite` reserves
332+
// this write's slot and sleeps the minimum gap; a spent budget or a live stop
333+
// marker refuses here and the request is never made.
334+
const paced = isWriteMethod(method);
335+
if (paced) await paceWrite({ token: TOKEN, kind: `label-write ${method}` });
330336
let res;
331337
try {
332338
res = await fetch(`${API}${path}`, {
@@ -350,6 +356,18 @@ async function rest(path, { method = 'GET', body = null } = {}) {
350356
json = null;
351357
}
352358
}
359+
// ⏱ …and the other half: a back-off signal in this answer writes the stop
360+
// marker every later write by this token is refused against. `classifyHttp`'s
361+
// own verdict is handed over rather than re-derived there.
362+
if (paced) {
363+
noteResponse({
364+
token: TOKEN,
365+
status: res.status,
366+
headers: res.headers,
367+
body: json,
368+
verdict: classifyHttp({ status: res.status, rateRemaining: rateRemaining === null ? null : Number(rateRemaining) }),
369+
});
370+
}
353371
return {
354372
status: res.status,
355373
rateRemaining: rateRemaining === null ? null : Number(rateRemaining),

‎scripts/pm/post-stamped.mjs‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -770,6 +770,7 @@ import {
770770
threadReadField,
771771
} from './check-half-states.mjs';
772772
import { readClause2Line } from './check-clause2-carriers.mjs';
773+
import { isWriteMethod, noteResponse, paceWrite } from './write-pace.mjs';
773774

774775
const SELF_PATH = fileURLToPath(import.meta.url);
775776
const API = 'https://api.github.com';
@@ -2539,6 +2540,11 @@ export function parseOptions(argv) {
25392540
// ---------------------------------------------------------------------------
25402541

25412542
async function rest(path, { method = 'GET', body = null } = {}) {
2543+
// ⏱ The throttle (#19572), on the write verbs only — the comment `POST` and
2544+
// the body `PATCH`. A spent budget or a live stop marker refuses here, before
2545+
// the request is made and therefore before anything can be half-written.
2546+
const paced = isWriteMethod(method);
2547+
if (paced) await paceWrite({ token: TOKEN, kind: `post-stamped ${method}` });
25422548
const res = await fetch(`${API}${path}`, {
25432549
method,
25442550
headers: {
@@ -2554,11 +2560,17 @@ async function rest(path, { method = 'GET', body = null } = {}) {
25542560
// the refusal reports carry it either way: a 3 that prints only a status
25552561
// code hides the one line that says what went wrong.
25562562
const said = platformRefusalText(await res.text().catch(() => ''));
2563+
// ⏱ …and the sentence is what names a SECONDARY rate limit, so the marker
2564+
// is written from the failing answer BEFORE this throws past every caller.
2565+
if (paced) noteResponse({ token: TOKEN, status: res.status, headers: res.headers, body: said });
25572566
const err = new Error(`${method} ${path} -> HTTP ${res.status}`);
25582567
err.status = res.status;
25592568
err.refusalText = said;
25602569
throw err;
25612570
}
2571+
// ⏱ A 2xx carries a signal too: `x-ratelimit-remaining: 0` on the answer that
2572+
// spent the last unit is the one reading that precedes the first refusal.
2573+
if (paced) noteResponse({ token: TOKEN, status: res.status, headers: res.headers });
25622574
if (res.status === 204) return null;
25632575
return res.json();
25642576
}

‎scripts/pm/sweep-closed-cards.mjs‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ import {
153153
proxyRearmPlan,
154154
resolveSweepRepo,
155155
} from './check-half-states.mjs';
156+
import { isWriteMethod, noteResponse, paceWrite } from './write-pace.mjs';
156157

157158
const SELF_PATH = fileURLToPath(import.meta.url);
158159
const API = 'https://api.github.com';
@@ -495,6 +496,10 @@ export function summariseRun(rows) {
495496
// ---------------------------------------------------------------------------
496497

497498
async function rest(path, { method = 'GET', body = null } = {}) {
499+
// ⏱ The throttle (#19572), on the write verbs only — one `DELETE` per residue
500+
// label and one comment `POST`. The listing walk is a read and is never paced.
501+
const paced = isWriteMethod(method);
502+
if (paced) await paceWrite({ token: TOKEN, kind: `sweep-closed-cards ${method}` });
498503
const res = await fetch(`${API}${path}`, {
499504
method,
500505
headers: {
@@ -505,10 +510,16 @@ async function rest(path, { method = 'GET', body = null } = {}) {
505510
...(body ? { body: JSON.stringify(body) } : {}),
506511
});
507512
if (!res.ok) {
513+
// ⏱ The body is read only to be CLASSIFIED: a secondary-rate-limit 403
514+
// names itself in prose and nowhere else. The error carries the status, as
515+
// it always has.
516+
const said = paced ? await res.text().catch(() => '') : '';
517+
if (paced) noteResponse({ token: TOKEN, status: res.status, headers: res.headers, body: said });
508518
const err = new Error(`${method} ${path} -> HTTP ${res.status}`);
509519
err.status = res.status;
510520
throw err;
511521
}
522+
if (paced) noteResponse({ token: TOKEN, status: res.status, headers: res.headers });
512523
if (res.status === 204) return null;
513524
return res.json();
514525
}

‎scripts/pm/sweep-stale-finding.mjs‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@ import {
171171
resolveSweepRepo,
172172
staleFindingScreen,
173173
} from './check-half-states.mjs';
174+
import { isWriteMethod, noteResponse, paceWrite } from './write-pace.mjs';
174175

175176
// dispatch-gates: no-path-population -- this tool reads no file in the tree at all; its whole input is the GitHub API (one label-scoped issue listing per board, plus the per-card label read-back each write verifies itself against), so no card's file surface can predict it and the honest derivation is a repo-wide undetermined one (#16904)
176177

@@ -353,6 +354,10 @@ export function namesOf(payload) {
353354
// ---------------------------------------------------------------------------
354355

355356
async function restLive(path, { method = 'GET', body = null } = {}) {
357+
// ⏱ The throttle (#19572), on the write verbs only — the one `DELETE` that
358+
// strips the stale grade. Steps 1 and 4 are reads and are never paced.
359+
const paced = isWriteMethod(method);
360+
if (paced) await paceWrite({ token: TOKEN, kind: `sweep-stale-finding ${method}` });
356361
const res = await fetch(`${API}${path}`, {
357362
method,
358363
headers: {
@@ -363,12 +368,17 @@ async function restLive(path, { method = 'GET', body = null } = {}) {
363368
...(body ? { body: JSON.stringify(body) } : {}),
364369
});
365370
if (!res.ok) {
371+
// ⏱ The body is read only to be CLASSIFIED — a secondary-rate-limit 403
372+
// names itself in prose. The error keeps carrying exactly what it did.
373+
const said = paced ? await res.text().catch(() => '') : '';
374+
if (paced) noteResponse({ token: TOKEN, status: res.status, headers: res.headers, body: said });
366375
const err = new Error(`${method} ${path} -> HTTP ${res.status}`);
367376
err.status = res.status;
368377
err.retryAfter = res.headers?.get?.('retry-after') ?? null;
369378
err.rateRemaining = res.headers?.get?.('x-ratelimit-remaining') ?? null;
370379
throw err;
371380
}
381+
if (paced) noteResponse({ token: TOKEN, status: res.status, headers: res.headers });
372382
if (res.status === 204) return null;
373383
return res.json();
374384
}

0 commit comments

Comments
 (0)