Skip to content

Commit 63f55e3

Browse files
feat(pm): pace every seat write — wire the throttle into the five write transports
Each tool's own `rest`/`restLive` calls `paceWrite` before a POST/PATCH/PUT/DELETE and `noteResponse` after the answer, reads untouched. `classifyHttp`'s verdict is handed to the throttle where a caller already has it, never re-derived. Claude-Session: https://claude.ai/code/session_012GcsUbuqFGBibkEDMRC1eE Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 5fc9ab4 commit 63f55e3

7 files changed

Lines changed: 93 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)