Skip to content

fix(release): re-derive release-verify-npm read-back budget from the 18.0.0 reading (35 min, flat) - #24989

Merged
objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-24888-verify-npm-budget
Oct 20, 2026
Merged

objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-24888-verify-npm-budget

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #24888
Clause-②: no

scripts/release-verify-npm.mjs waited 15 minutes for npm's read path to settle. On 18.0.0 the last package became readable 16m22s after the first read, so a release that was complete read red. This PR re-derives the budget from the 18.0.0 reading and sets it to 35 minutes, one flat number for the group. It still fails closed. The self-test now replays the 18.0.0 window on npm's own clock and pins main()'s exit code.

The measurements, re-read

Reading Source Value
Publish job Actions API, job 118624531794 (run 39421537775, head 3fbef41b4a) 12:24:14Z to 12:57:40Z, failure
Step 21, "Publish to npm + push version tags" same 12:36:08Z to 12:57:38Z, failure; steps 22 and 23 skipped
The verdict check-run annotations of job 118624531794 publish ran but 1 of 73 package(s) are still not readable on npm after 19 round(s) over 900s: @objectstack/console@18.0.0
console readable npm view @objectstack/console time --json "18.0.0": "2026-10-20T12:57:59.327Z"
Verification start the card's reading of the job log 12:41:37Z

The start time is not re-read here. The job-log download redirects to a blob host that this container's egress proxy refuses (403, a policy denial, not retried). The readings above corroborate it:

  • 900 s of waits that end at 12:57:38Z put the start at or before 12:42:38Z.
  • Replaying the 19 rounds against npm's time for all 73 packages gives 408 probes. The card's start implies 61 s of probing, about 150 ms per probe.

Spread: 12:41:37Z to 12:57:59.327Z = 982.3 s (16m22s).

Sizes come from npm's dist fields and the tarball bytes, downloaded:

Package Packed Unpacked Files Readable at
@objectstack/console 9,399,458 B 37,040,621 B 2282 +16m22s (last)
@objectstack/spec 42,118,133 B 197,756,260 B 2100 +10m04s

The card's "42424 KB" for console lives in the job log, so it could not be re-read. It is not console's tarball, which is 9.4 MB. The release's largest tarball is spec's.

Shape: flat, 35 minutes. The deciding axis: actual business need (实际业务需求)

Derivation. 17.3.0's margin was 900 s / 427 s = 2.11x. The same margin over the new spread is 982.3 s x 2.11 = 2070 s (34m30s). Rounded up, that is 35 minutes (2100 s, 2.14x the 18.0.0 spread). The header records this next to the 17.3.0 derivation.

Axis Flat budget Size-scaled allowance per package
Actual business need (实际业务需求), measured: decides Covers the group's spread, which is what the 18.0.0 red was. Its premise is that the slow package was slow because it is large. The measurement says otherwise: spec (42.1 MB packed, the largest) was readable six minutes before console (9.4 MB). On 17.5.0 the order was reversed (console 08:05:24Z, spec last at 08:09:33Z), and on 17.3.0 the last package was runtime. Any monotonic size scale gives the slowest 18.0.0 package a smaller allowance than one that settled six minutes earlier.
Long-term soundness (项目长远合理性) One constant, re-derived from readings recorded in the header. Adds a second model (size to latency) that has no reading behind it and would need its own re-derivation.
Guarding against AI mistakes (防 AI 写代码犯错) One number that a reader can check against the header. Needs the size of a package the registry cannot serve yet, so the size has to come from a new local input. Each missing or wrong size becomes a per-package fallback, which is the lenient direction.
No scope spread at startup stage (创业阶段不扩散需求) Changes one constant. Adds a new mechanism and a new input.

The budget is a ceiling, not a wait. The loop returns on the first round that finds the last target, so a good publish pays only its own settle time. Replayed on this schedule, 18.0.0 goes green at +17m15s (battery 14 pins that figure). Only a publish that is really broken spends all 35 minutes.

Fit against the caller

release.yml was read, not edited. Neither the publish job (:1565 to :2132) nor its step "Publish to npm + push version tags" declares timeout-minutes. The only timeout-minutes in the file is full-battery's 10, at :1536. GitHub's default therefore bounds both: 360 minutes per job. The release-integrity audit calls the script with --probe (:1251). That path passes a budget of 0 and is unchanged; battery 10 pins its single round.

Measured on 18.0.0:

Segment Time
Before step 21 (build 4m44s, vendored Console SPA 5m44s) 11m54s
Step 21 before verification (pnpm run release: publish plus tag push) 5m29s
Verification at the old budget 16m01s (900 s of waits plus about 61 s of probing)
Steps 22 and 23 on a green run 1m55s: 1m48s plus 7s, measured on the same two scripts in the backfill, run 39426677839, job 118640898073

Worst case under the new budget, using budget plus poll interval plus the rest of the step:

Term Value
Waits exactly 2100 s (38 waits, 39 rounds); battery 1 pins that the schedule sums to the budget
Poll interval 60 s, the cap
Probing when nothing reached npm 39 rounds x 73 probes x 150 ms = 427 s (7m07s)
Rest of the step 5m29s
Step 5m29s + 35m00s + 1m00s + 7m07s = 48m36s
Job 11m54s + 48m36s + 1m55s = 62m25s, against 360 minutes: 297 minutes of margin (5.8x)

The same terms at the old budget gave 38m46s.

One case lies outside that formula and existed before this change: a registry where every request hangs to the 15 s per-request ceiling. Each round then costs 73 x 15 s = 18m15s. That totals 347 minutes of probing at the old 19 rounds and 712 minutes at the new 39. In both cases GitHub's 360-minute default cancels the job. The job is red, so the run still fails closed, but the cancellation does not name a package. The outcome is the same before and after this change.

Self-test and ablation

The self-test goes from 115 cases across 13 batteries to 131 cases across 14 batteries, with every battery floored.

Battery 1 gains two cases: the budget outlasts the 18.0.0 spread, and it keeps the 2.11x margin over it.

Battery 14 ("The 18.0.0 publish window: console readable at +16m22s, past the old 900 s") has 14 cases. It replays npm's own time for sdui-parser, cli, spec and console on a clock that the stub sleep drives. Probing is taken as instant, so a green in the replay is one the real step would also have seen. No case sleeps.

  • The old budget (900 s, passed as a parameter) reds and names only console, after 19 rounds over 900 s, which matches the run's annotation. Its last read of console came before console landed.
  • The production budget is never passed in, so it is the default. It goes green on the first round after console landed, at +17m15s, past the round where 900 s ran out.
  • A package readable after 900 s and before the new budget passes: main() over a fixture workspace exits 0 and prints the ::notice:: line.
  • An absent package exits non-zero and is named: with console never landing, main() exits 1. Its ::error:: line names @objectstack/console@18.0.0 and "1 of 4", and names none of the three packages that landed. It concludes only after the waits sum to exactly RETRY_BUDGET_MS.

This needed one seam change. main() now accepts the verifier's own probe and sleep seams; the defaults are the production ones. Its exit code and naming can therefore be pinned without the network or a real wait.

The case of a large and a small package, each held to its own allowance, does not apply, because there is no per-package allowance.

Ablation. I committed the fix first (a9e61579a3). I then ran scripts/ablation-replace.mjs in wrap mode, replacing export const RETRY_BUDGET_MS = 35 * 60 * 1000; with 15 * 60 * 1000;.

  • Proof that the mutation landed: anchor count went from 1 to 0, replacement count from 0 to 1, blob 7f259d67ff62 became c36e8cf51a07.
  • node scripts/release-verify-npm.mjs --self-test then exited 1 with 8 of 131 cases failed, exactly the cases that need more than 900 s (one JSON detail is shortened):
  x the budget outlasts the 16m22s spread measured on the 18.0.0 burst -- 900000 > 982327
  x and keeps the 17.3.0 margin (2.11x) over it -- 900000 >= 2070478 (0.92x)
  x THIS BUDGET sits through the 16m22s spread and goes GREEN -- {"ok":false,"missing":[{"name":"@objectstack/console",...}],"rounds":19,"waitedMs":900000,"checked":4}
  x ...on a round the old budget never reached -- waited=900000
  x ...the FIRST round after console landed, not by spending the budget -- 2026-10-20T12:55:52.000Z .. 2026-10-20T12:56:37.000Z
  x ...at +17m15s, the figure the header quotes -- waited=900000
  x main() exits 0 on the 18.0.0 replay, under the production budget -- code=1 ::error::publish ran but 1 of 4 package(s) are still not readable on npm after 19 round(s) over 900s: @objectstack/console@18.0.0
  x ...and its notice says the whole group settled --   1 of 4 package(s) not readable yet after round 18; waiting 45s (900s of 900s budget spent).
x release-verify-npm self-test: 8 of 131 case(s) failed.

The red went in the expected direction. The old-budget repro and the absent-package cases stayed green, as they should.

The restore was proven on disk: the blob after the restore equals the HEAD blob (7f259d67ff62), git diff HEAD was empty and git status --porcelain printed nothing.

Changeset: skip-changeset, measured

scripts/release-verify-npm.mjs belongs to the root package @objectstack/spec-monorepo, which is private: true and has no files. No non-private workspace package lists a files[] entry under scripts or with ... No tracked symlink points into the root scripts/.

git grep -l RETRY_BUDGET_MS over packages/**, apps/** and skills/** returns 0 hits (exit 1). The positive control over scripts/** returns scripts/release-verify-npm.mjs (exit 0).

Gates (local, at a9e61579a3)

  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 33 commands for this one-file diff. All 33 ran and each exited 0, check:pm-dispatch-gates included (fast tier, 1913 cases).
  • --ran reconciliation: 33 derived, 33 run, 0 NOT-MEASURED, 0 UNRUN.
  • npx eslint --no-inline-config scripts/release-verify-npm.mjs exited 0.
  • git merge-tree --write-tree origin/main HEAD at origin/main 78ab6474ca exited 0. The commits since the base touch none of this script, release-github-releases.mjs, workspace-enumerator.mjs or release.yml.

CI owns the rest of the farm. lint.yml's "Post-publish npm verification self-test" step runs this self-test.

Acceptance notes

  • Stale comments in a governed file, not edited here. Two comments in .github/workflows/release.yml still describe the old budget:

    • :2077 to :2080 says the verifier "retries with bounded backoff for 15 minutes, derived from ... 7m07s";
    • :1236 says "instead of waited on for 15 minutes".

    Both now read stale. .github/workflows/** is governed, so this needs a separate comment-only edit if wanted. Carrier: none.

  • Pre-existing. The hung-registry worst case above exceeds GitHub's 360-minute default at either budget, and is cancelled red without naming a package. This is an inference from the constants, not a reproduction. Carrier: none.


Generated by Claude Code

…e 18.0.0 reading

The 18.0.0 publish read red with every package on npm: @objectstack/console
became readable at 12:57:59.327Z, 16m22s after the first read and 21 s after
the 15-minute budget ran out. The budget is re-derived with the 17.3.0 margin
(900 s / 427 s = 2.11x) over the 18.0.0 spread: 982 s x 2.11 = 34m30s, so
RETRY_BUDGET_MS is 35 minutes. It stays one flat number: on 18.0.0 the largest
tarball (spec, 42 MB packed) settled six minutes before console (9.4 MB), so
size does not predict who settles last.

It still fails closed. main() now takes the verifier's probe and sleep seams,
so battery 14 pins its exit code and its ::error:: naming on npm's replayed
clock: the 18.0.0 window reds under the old 900 s, goes green under the
production budget, and an absent package exits 1 named after the whole budget.

Claude-Session: https://claude.ai/code/session_01FM29ZqLTrnPNXxijioZwMp
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Seat review

ACCEPT · domain:devx seat 1 · os-warren · session_01FM29ZqLTrnPNXxijioZwMp · 2026-10-20T22:07Z · head a9e61579a3 · card #24888 (claim 6301913542)

  • Form. Draft, base main. The first line is Fixes #24888, then Clause-②: no. Assignee os-warren; labels size/m, skip-changeset. node scripts/pm/check-governed-merges.mjs --pr 24989 → NOT governed. One file, scripts/release-verify-npm.mjs. git merge-tree against main 7adfaec2a1 is clean.
  • The budget, read. RETRY_BUDGET_MS goes from 15 to 35 minutes.
    • The header records the derivation next to 17.3.0's: the 17.3.0 margin is 900 s / 427 s = 2.11x; applied to the 18.0.0 spread of 982.3 s, it gives 2070 s, rounded up to 2100 s.
    • The budget is a ceiling, not a wait: the loop returns on the first round that finds the last target.
  • The flat shape is accepted, on the axis the dev named (actual business need, measured). On 18.0.0, spec's tarball is the largest (42.1 MB packed) and was readable at +10m04s, six minutes before console (9.4 MB). 17.5.0 and 17.3.0 ended on other packages. A per-size allowance would give the slowest package a smaller budget than one that settled earlier. The card's "42 MB console" is corrected: that is the job log's dist size, not the packed tarball.
  • It fails closed, still. main() gains probe and sleep parameters that default to the production probeVersion and realSleep (:520). So the self-test pins main()'s exit code without a real sleep. With console absent, it exits 1, names only @objectstack/console@18.0.0, and does so only after the waits sum to the budget.
  • It fits the caller, read in release.yml (not edited). The only timeout-minutes in the file is full-battery's 10 (:1536). Neither the publish job nor its publish step declares one, so GitHub's 360-minute job default bounds them. The PR's worst case is 62m25s for the job, 5.8x inside that default. The --probe path (release-integrity, :1251) passes a budget of 0 and is unchanged.
  • Self-test and ablation.
    • --self-test: 131 cases across 14 batteries, up from 115 across 13. Battery 14 replays npm's own time for four 18.0.0 packages on a sleep-driven clock.
    • The ablation restores 15 * 60 * 1000 and turns exactly the 8 after-900 s cases red. The restore is proven by an identical blob and an empty git diff HEAD.
  • Noted, not routed.
    • Two comments in the governed release.yml (:1236, :2077–2080) still say "15 minutes". A comment-only governed edit is the maintainer's call; it is listed in the PR's acceptance notes.
    • The hung-registry worst case exceeds the 360-minute default at either budget, and is cancelled red. That is pre-existing.
  • Gates.
    • The dev's dispatch-gates union: 33 derived, 33 run, all exit 0, pnpm check:pm-dispatch-gates included; --ran reconciles.
    • Check-runs at a9e61579a3: 45, 32 success, 13 skipped, 0 failing or pending. node scripts/pm/check-expected-skips.mjs --pr 24989 → OK (13 skipped, every one in the roster). No review, no comment.

This seat readies the PR and queues it with auto-merge. Landing it publishes nothing. The next publish is the first that reads under the new budget.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants