Skip to content

Commit 7189fb2

Browse files
chore(pm): write-pace rule ② default 40 → 80 writes per token-hour (#31363)
Fixes #31302 Clause-②: no Metadata-protocol: none Skills-docs: none — internal PM tooling under `scripts/pm/`; nothing a metadata author writes moves, and no `skills/**` or docs page states the write budget ## 维护者速读 - 改了什么:车队写入节流 `write-pace.mjs` 的每令牌每小时写入上限默认值 40 → 80;脚本头部补上裁决原文与残余风险说明。 - 为什么改:席位与它派出的开发共用同一令牌、同一份计数,席位临时用测试专用变量提到 80 后开发仍按 40 被拒;改默认值让所有进程自动一致(按「写入配额临时增加到80」与「同意」方案 C)。 - 风险与回滚:防封靠的最小间隔、全队单写租约、批量间隔、遇平台退避即停四条都没动;残余风险是同一令牌跑多个容器时每个容器各算 80,会叠加。回滚 = 把常量改回 40 并同步自测里那一处字面钉。 - 先看:`scripts/pm/write-pace.mjs` —— 常量那一行、头部「Why ② is 80」一节、自测 limits 组新增的字面钉。 - 两分钟验证:`node scripts/pm/write-pace.mjs --self-test`,预期 `✓ write-pace self-test: 119 cases pass across 12 batteries`;`node scripts/pm/write-pace.mjs --status` 显示 `budget : N of 80`。 - 需要你做的:无,席位落地。 ## What changes - `scripts/pm/write-pace.mjs`: `DEFAULT_HOURLY_MAX` 40 → 80. Rule ② header text now reads 80 / "the 81st is REFUSED". A new header subsection, "Why ② is 80, and what that number does not cover", quotes the ruling (「写入配额临时增加到80」, then 「同意」 to changing the default rather than a per-role override) and states the residual risk: the budget is per token PER CONTAINER, N containers on one token add up, so keep concurrent containers per token small. The reserve-then-sleep paragraph no longer names "the 40th". - Rules ① min gap (3 s), ③ back-off stop marker (30 min), ④ fleet-wide single in-flight lease and ⑤ batch gap are byte-unchanged. `OS_PM_WRITE_HOURLY_MAX` keeps its FOR-TESTS meaning (untouched). - Self-test: every case name tied to the cap now follows the constant (battery `rule ②: the first write past the hourly budget is REFUSED, with a prescription`; cases `${DEFAULT_HOURLY_MAX} writes in the window are allowed` and `write ${DEFAULT_HOURLY_MAX + 1} is refused`; the success line). One deliberate literal pin was added in the limits battery, `rule ②'s default is the ruled 80 writes per token-hour` (`DEFAULT_HOURLY_MAX === 80`), so the ruled value cannot drift without a red; that battery's floor rises 10 → 11. - No changeset: the diff is `scripts/pm/**` only and publishes nothing from any released package, so this PR carries `skip-changeset`. ## Tests <details><summary>write-pace self-test at 441a43c</summary> ```text $ node scripts/pm/write-pace.mjs --self-test # head 441a43c, exit 0 ✓ write-pace self-test: 119 cases pass across 12 batteries — the per-token key that never carries the token, the gap remainder, the refusal past the hourly budget and its prescription, the stop marker a 429 writes, the prune that keeps a long retry-after alive, the lease that kept one write in flight across real processes, the batch gap one process announced and two others obeyed, the shell door, and the nine write transports that call both halves. ``` </details> <details><summary>Ablation: constant set back to 40 (one-shot, not kept)</summary> Run through `scripts/ablation-replace.mjs` against the committed head, with a trap restore. The anchor `export const DEFAULT_HOURLY_MAX = 80;` counted 1 before; the mutated anchor `= 40;` counted 1 on disk during the run; the self-test exited 1: ```text ✗ rule ②'s default is the ruled 80 writes per token-hour — got 40, want 80 ✗ write-pace self-test: 1 of 119 case(s) failed. ablation-replace: ok restored: blob == HEAD (c5e4330) and `git diff HEAD` is empty ``` Exactly one case went red, the literal pin, which shows every other cap-tied case follows the constant. After restore the anchor counts were new=1 / old=0, and the `git hash-object` of the file equalled the HEAD blob `c5e4330b3e7bc7e130e189e4660ba253c8418291`. </details> ## Acceptance notes - Repo-wide grep for the cap (`41st`, `40` near hourly/budget/per-token phrasing, `OS_PM_WRITE_HOURLY_MAX`) across `scripts/`, `.claude/`, `AGENTS.md`, `docs/`, `content/docs/`: no other place states the write cap. `.claude/skills/pm-dispatch/**` and `.claude/agents/os-dev.md` do not quote it, so neither is touched. - One stale quote is left on purpose: `.github/workflows/lint.yml:1327`, in the comment above the write-pace self-test step, still says "the 41st write in an hour is REFUSED". That file is a Tier H governed surface and outside this claim's file surface, so editing it would turn this PR governed. It is a comment only (the step runs `--self-test`, which follows the constant), and it is handed to the seat. - `check-governed-merges --branch`: NOT governed, 1 path, +32 / -11. ## Verification <details><summary>Derived gate families (dispatch-gates --commands --repo objectstack-ai/objectstack, at 441a43c)</summary> ```text 31 derived commands, each run with $? captured before any pipe, at head 441a43c: exit 0 node scripts/check-closing-keyword-parity.mjs exit 0 node scripts/check-closing-keyword-parity.mjs --self-test exit 0 node scripts/check-comment-mask-corpus.mjs exit 0 node scripts/check-declaration-mirrors.mjs exit 0 node scripts/check-declaration-mirrors.mjs --self-test exit 0 node scripts/check-self-test-wired.mjs exit 0 node scripts/check-self-test-wired.mjs --self-test exit 0 node scripts/check-self-test-workflow-commands.mjs exit 0 node scripts/check-self-test-workflow-commands.mjs --self-test exit 0 node scripts/check-whole-set-label-write.mjs exit 0 node scripts/check-whole-set-label-write.mjs --self-test exit 0 node scripts/ci/plan-test-core-shards.mjs --self-test exit 0 node scripts/pm/bare-root-worklist.mjs --self-test exit 0 pnpm check:agent-test-spelling exit 0 pnpm check:bash32-floor exit 0 pnpm check:ci-filter-parity exit 0 pnpm check:cli-command-ids exit 0 pnpm check:cross-package-test-inputs exit 0 pnpm check:driver-memory-census exit 0 pnpm check:entry-guard exit 0 pnpm check:gitlink-declared exit 0 pnpm check:nul-bytes exit 0 pnpm check:parse-guard exit 0 pnpm check:pm-dispatch-gates exit 0 pnpm check:pm-write-pace exit 0 pnpm check:pnpm-filter-targets exit 0 pnpm check:pnpm-flag-spellings exit 0 pnpm check:ratchet-remedy-authority exit 0 pnpm check:refd-timer-probe exit 0 pnpm check:scripts-symbol-anchors exit 0 pnpm check:watch-hint-literal Run reconciliation — 31 derived, 31 run, 0 NOT-MEASURED, 0 UNRUN. ✓ dispatch-gates --ran: 31 derived famil(ies) accounted for — 31 run, 0 NOT-MEASURED (a DERIVED zero — all 31 recorded an exit code and none of them is 3). ``` </details> --- _Generated by [Claude Code](https://claude.ai/code/session_012dn2AdARJtc4RgABPH7PHk)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 82e6da1 commit 7189fb2

1 file changed

Lines changed: 32 additions & 11 deletions

File tree

‎scripts/pm/write-pace.mjs‎

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@
3232
* ① at least `DEFAULT_MIN_GAP_MS` (3 s) between two writes by the same
3333
* token. The remainder is SLEPT, and the sleep prints one line saying so —
3434
* a pause nobody can see is indistinguishable from a hang;
35-
* ② at most `DEFAULT_HOURLY_MAX` (40) writes by that token in the trailing
36-
* 60 minutes. The 41st is REFUSED — exit `EXIT_WRITE_PACE_REFUSED`, with a
35+
* ② at most `DEFAULT_HOURLY_MAX` (80) writes by that token in the trailing
36+
* 60 minutes. The 81st is REFUSED — exit `EXIT_WRITE_PACE_REFUSED`, with a
3737
* prescription naming the count, when the window reopens, and what to fold
3838
* into fewer writes. ⛔ Never a silent wait: an hour-long sleep inside a
3939
* write path is a hang with a good excuse, and the seat that needs to hear
@@ -48,6 +48,23 @@
4848
* as a non-negative integer is IGNORED and said out loud, because an override
4949
* that silently loosens a budget is worse than one that is refused.
5050
*
51+
* ### Why ② is 80, and what that number does not cover
52+
*
53+
* The account owner's ruling: 「写入配额临时增加到80」, then 「同意」 to changing
54+
* the DEFAULT rather than adding a per-role environment override. The budget
55+
* is per token over one shared log, so a seat and every dev it fans out spend
56+
* ONE count; raising only the seat's (through the FOR-TESTS override above)
57+
* left its devs refused at the old number on the very same token. Changing the
58+
* default moves every process on the token together, and the override keeps
59+
* its tests-only meaning. ② was always the SECOND guard: the suspensions this
60+
* file answers were a RATE shape, and what stops a rate shape is ① the gap,
61+
* ③ the stop marker, ④ the lease and ⑤ the batch gap — none of which moved.
62+
*
63+
* ⚠️ Residual risk: 80 is per token PER CONTAINER (see "How far fleet-wide
64+
* reaches" below). N containers on one token are N budgets of 80 that add up
65+
* against one identity, and nothing here sees the sum — so keep the number of
66+
* concurrent containers per token small.
67+
*
5168
* ## Rules ④ and ⑤ — the lease and the batch gap, and the reading behind them
5269
*
5370
* The three rules above were written per PROCESS, and the fleet is many
@@ -157,7 +174,8 @@
157174
* the instant it was decided. So a second process asking while the first is
158175
* still sleeping sees the reservation and paces behind it. Sleeping under the
159176
* lock would serialise every seat behind one 3-second pause; deciding without
160-
* reserving would let two seats agree they are both the 40th.
177+
* reserving would let two seats agree they are both the last write the budget
178+
* allows.
161179
*
162180
* The lock is a `mkdir`, and it FAILS OPEN: a lock that cannot be taken within
163181
* `LOCK_WAIT_MS`, or one left behind by a process that died, never blocks a
@@ -227,7 +245,7 @@ export const EXIT_WRITE_PACE_REFUSED = 10;
227245

228246
/** The contract. Both are overridable FOR TESTS only; see the header. */
229247
export const DEFAULT_MIN_GAP_MS = 3_000;
230-
export const DEFAULT_HOURLY_MAX = 40;
248+
export const DEFAULT_HOURLY_MAX = 80;
231249

232250
/** The trailing window rule ② is measured over, and the marker rule ③ writes. */
233251
export const WINDOW_MS = 60 * 60 * 1000;
@@ -559,7 +577,7 @@ const FOLD_ADVICE = [
559577
' Read your budget before the next batch: node scripts/pm/write-pace.mjs --status',
560578
].join('\n');
561579

562-
/** Rule ② — the 41st. */
580+
/** Rule ② — the first write past `DEFAULT_HOURLY_MAX`. */
563581
export function exhaustedText(d) {
564582
return [
565583
`✗ write-pace: REFUSED — ${d.count} write(s) by this token in the trailing hour is the budget (${d.hourlyMax}),`,
@@ -1289,9 +1307,9 @@ export function statusReport({ token } = {}, deps = {}) {
12891307
const SELF_TEST_BATTERIES = Object.freeze({
12901308
'the token key: enough to separate identities, useless as a credential': 6,
12911309
'the write-verb predicate: a read is never paced': 5,
1292-
'the limits: overrides are for tests, and a malformed one never loosens': 10,
1310+
'the limits: overrides are for tests, and a malformed one never loosens': 11,
12931311
'rule ①: the remainder is slept, and the pause is said out loud': 7,
1294-
'rule ②: the 41st is REFUSED, with a prescription': 8,
1312+
'rule ②: the first write past the hourly budget is REFUSED, with a prescription': 8,
12951313
'rule ③: what writes a stop marker, how long it lasts, what it refuses': 19,
12961314
'the prune: the trailing hour for writes, the expiry for markers': 8,
12971315
'the log, --status and the CLI: what lands on disk, and what must never': 14,
@@ -1427,6 +1445,9 @@ export async function selfTest() {
14271445
// ── the limits ────────────────────────────────────────────────────────────
14281446
battery('the limits: overrides are for tests, and a malformed one never loosens');
14291447
t('the defaults are the contract', [limitsFrom({}).minGapMs, limitsFrom({}).hourlyMax], [DEFAULT_MIN_GAP_MS, DEFAULT_HOURLY_MAX]);
1448+
// The ONE literal for rule ②, on purpose: every other case follows the constant, so this pin is
1449+
// what turns red when the ruled value moves without a new ruling (「写入配额临时增加到80」).
1450+
t('rule ②\'s default is the ruled 80 writes per token-hour', DEFAULT_HOURLY_MAX, 80);
14301451
t('both overrides are honoured', [limitsFrom({ OS_PM_WRITE_MIN_GAP_MS: '5', OS_PM_WRITE_HOURLY_MAX: '2' }).minGapMs, limitsFrom({ OS_PM_WRITE_MIN_GAP_MS: '5', OS_PM_WRITE_HOURLY_MAX: '2' }).hourlyMax], [5, 2]);
14311452
t('⛔ a malformed override falls back to the DEFAULT, never to unlimited', limitsFrom({ OS_PM_WRITE_HOURLY_MAX: '4O' }).hourlyMax, DEFAULT_HOURLY_MAX);
14321453
t('…and says so, so a loosened budget is never silent', limitsFrom({ OS_PM_WRITE_HOURLY_MAX: '4O' }).notes.length, 1);
@@ -1460,7 +1481,7 @@ export async function selfTest() {
14601481
}
14611482

14621483
// ── rule ② ──────────────────────────────────────────────────────────────
1463-
battery('rule ②: the 41st is REFUSED, with a prescription');
1484+
battery('rule ②: the first write past the hourly budget is REFUSED, with a prescription');
14641485
{
14651486
const file = join(dir, 'budget.jsonl');
14661487
const env = { OS_PM_WRITE_MIN_GAP_MS: '0' };
@@ -1469,9 +1490,9 @@ export async function selfTest() {
14691490
await paceWrite({ token: TOKEN, kind: 'label-write POST' }, h.deps);
14701491
h.state.ms += 1000;
14711492
}
1472-
t('forty writes in the window are allowed', h.state.exits, []);
1493+
t(`${DEFAULT_HOURLY_MAX} writes in the window are allowed`, h.state.exits, []);
14731494
const refused = await paceWrite({ token: TOKEN, kind: 'label-write POST' }, h.deps);
1474-
t('the 41st is refused', refused.verdict, 'exhausted');
1495+
t(`write ${DEFAULT_HOURLY_MAX + 1} is refused`, refused.verdict, 'exhausted');
14751496
t('…with the distinct exit code, ⛔ not the host tool\'s', h.state.exits, [EXIT_WRITE_PACE_REFUSED]);
14761497
t('…and it did NOT sleep instead', h.state.slept.length, 0);
14771498
const said = h.text();
@@ -1838,7 +1859,7 @@ export async function selfTest() {
18381859
}
18391860
console.log(
18401861
`✓ write-pace self-test: ${cases.length} cases pass across ${declared.length} batteries — the per-token key that ` +
1841-
'never carries the token, the gap remainder, the 41st refusal and its prescription, the stop marker a 429 writes, ' +
1862+
'never carries the token, the gap remainder, the refusal past the hourly budget and its prescription, the stop marker a 429 writes, ' +
18421863
'the prune that keeps a long retry-after alive, the lease that kept one write in flight across real processes, ' +
18431864
'the batch gap one process announced and two others obeyed, the shell door, and the nine write transports ' +
18441865
'that call both halves.',

0 commit comments

Comments
 (0)