Repository navigation
ci: a per-package suite-duration ratchet — Test Core reds a suite over its measured ceiling - #22480
Merged
objectstack-fleet[bot] merged 5 commits intoOct 9, 2026
Conversation
…on ceilings Each package's ceiling is its slowest executed run in the refresh window times 1.25, written as a `ceilings` field beside the median weights, with `uncapped` naming the packages that get none (provisional, or carried with no executed run in the window). A refresh holds every ceiling it already set: it never raises one (a raise is a ruling) and never lowers one on a quieter window. The spread table the refresh PR carries gains the ceiling column. Claude-Session: https://claude.ai/code/session_0115N1oNnQS5WqofZ2DzaT3q Co-authored-by: Claude <noreply@anthropic.com>
…st its ceiling Reads the per-shard timing captures, sums a file-sliced package's parts across shards, and grades every executed package against the generated ceiling in scripts/test-shard-timings.json: over its ceiling, or absent from the dataset, is red, naming the package and its slowest test files; a provisional or carried package prints why it has no ceiling and is not red; a dataset without the `ceilings` field reads NOT MEASURED. A ceiling rises only by a RULED_CEILING_RAISES entry naming the ruling comment. Claude-Session: https://claude.ai/code/session_0115N1oNnQS5WqofZ2DzaT3q Co-authored-by: Claude <noreply@anthropic.com>
…esh PR body Test Core's aggregator runs check-test-suite-ceilings.mjs as its last step, with no continue-on-error, over the shard captures it already downloads; lint.yml runs the check's self-test. The refresh lane's PR body says whether it writes the first ceiling table or holds the existing one, and names the uncapped packages and any package that ran over its held ceiling in the window. Claude-Session: https://claude.ai/code/session_0115N1oNnQS5WqofZ2DzaT3q Co-authored-by: Claude <noreply@anthropic.com>
Importing report-test-timings.mjs from a gate family stops that family inheriting the module's declared populations in the dispatch-gates derivation (the module is a gate file only through its self-test), which the derivation's promotion invariant reds on. The fold is the same sum of slice parts with the same completeness mark, and a capture in an unknown schema is named and left out. Claude-Session: https://claude.ai/code/session_0115N1oNnQS5WqofZ2DzaT3q Co-authored-by: Claude <noreply@anthropic.com>
objectstack-fleet
Bot
deleted the
claude/issue-16468-suite-duration-ratchet
branch
October 9, 2026 13:40
This was referenced Oct 9, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of #16468
Clause-②: no
The per-package suite-duration ratchet.
Test Corenow grades every executed package's test suite against a measured ceiling and goes red when one runs past it, naming the package and its slowest test files. The ceilings are generated by the shard-timings refresh intoscripts/test-shard-timings.json, and a ceiling rises only by a ruling.This PR is
Part ofthe card because no seat can write the first ceiling table (see "Bootstrap" below). The first scheduled run of.github/workflows/shard-timings-refresh.ymlafter this merges writes it. Until that refresh PR merges, the check prints NOT MEASURED on every run, with a warning annotation. What finishes the card: that refresh PR merged, and aTest Corerun onmainprintingsuite-ceiling: OKover graded packages. The held-versus-refreshed question below should also be answered before the second refresh.Authority (maintainer, quoted in the card body): 「同意你的建议,你负责执行派发所有可行的优化」
The ruling (comment 6079579826, quoted verbatim)
What changed
scripts/measure-test-shard-timings.mjs(the generator).ceilingsfield, mapping each package to seconds. A package's ceiling is its slowest executed run in the refresh window ×CEILING_HEADROOM(1.25), rounded up to the hundredth. For a file-sliced package, the "run" is the per-run sum of its slices.uncappedfield, naming each package that gets no ceiling and why.provisionalmeans fewer than 3 executed runs.carriedmeans a cache HIT re-confirmed the package but no run in the window executed it.provenance.ceilingHeadroomrecords the headroom.packagesare unchanged, and so is every reader of them (the partitioner and the drift step).ceiling scolumn.scripts/check-test-suite-ceilings.mjs(the check, new).report-test-timings.mjs --capturealready writes, and sums a sliced package's parts across shards.provisional: printsno ceiling: provisional, not red;carried: printsno ceiling: carried, not red;ceilingsfield, the whole run reads NOT MEASURED and the step exits 0.RULED_CEILING_RAISESis the ruled-raises record. Each entry is a package, its raised seconds and the ruling's comment URL. An entry without a well-formed ruling URL is refused, and the step goes red..github/workflows/ci.yml.Check the per-package suite-duration ceilingsis a new last step of theTest Coreaggregator job,if: always(), with nocontinue-on-error.needs:, the matrix, the shardtimeout-minutes(45), the drift step and its constants (1.5× red, 1.3× warning), the seven required contexts, and ci(test-shards): the shard balance is derived on full-run sums while PR and merge_group runs use the affected set — the CLI shard (1/6) measures 34–36 min against 10–20 for the others and sets CI and queue wall time #22075's split (FILE_SHARDED_PACKAGES, the density cap,planShards())..github/workflows/lint.yml. New stepSuite-duration ceiling check self-test(wiring forcheck-self-test-wired)..github/workflows/shard-timings-refresh.yml.The table: a new field in the dataset, not a new file
The claim left this choice to me. I chose a field, for three reasons:
cmp,git addand PR body cover both. Nothing can refresh one without the other.ceilingskey means a dataset written before this generator version. The dataset already uses the same pattern forprovisional. After the first refresh the key is always present, so deleting it would mean hand-editing a generated file.packagesis untouched, and so is every reader of it.Bootstrap: no seat can write the first table (assumption 1, confirmed)
GET /actions/artifacts/{id}/zipanswered302toproductionresultssa3.blob.core.windows.net. The container's egress then answeredCONNECT tunnel failed, response 403, and the proxy loggedconnect_rejected ... 403for that host.download_workflow_run_artifactreturns the same blob URL.ceilings. It opens its PR with the "FIRST suite-duration ceiling table" paragraph and the ceiling column.pull_requestrun of the refresh lane (it touches the generator) is a dry run on real artifacts, so its step summary shows the table a scheduled run would write. Nothing is pushed from it.A's false-red rate (build order, before the check was built)
Over the 21-run window: 0, by construction. Every reading in the window is at most the window maximum, and the ceiling is the window maximum × 1.25.
On the runs after the window (assumption 2), the measurement that can say something:
scheduleruns ofci.ymlafter the window's newest run (37850732030). That is 37857021445 through 37921147590; 12 of them ran after the dataset refresh landed (040184752c).Test Coretiming table, read from the aggregator job log (13 MCPget_job_logsreads). It lists the 10 slowest executed packages with turbo's own window. The same function reads those windows for the dataset, for the check, and for the PR chore(ci): refresh the Test Core shard-timings dataset #22368 spread table.c64130bafa, reverted at806b03e2ae) is in the commits of 37897571511 and 37902633826. Every negative ancestry check has a control leg (040184752cis an ancestor of every commit, exit 0).081e6a09d4), the one run in which round 2's split executed every package:@objectstack/service-automation: 567.21 s against 497.55 s (1.14×);@objectstack/metadata-protocol: 769.55 s against 717.28 s (1.07×).@objectstack/plugin-approvalsread 218.90 s against 222.47 s (0.98) on 37869710053. That is 1.23× its window maximum, within a day of the window.@objectstack/plugin-authread 522.15 s against 537.76 s (0.97) on 37886662177.The CLI's slices: summed across shards (assumption 3)
Test Coreaggregator, which already downloads every shard's capture. The ruling's "after the shard's tests" is met as "after all shards' tests".Density (assumption 4)
Slowest files (assumption 5)
These come from the per-file capture of #16454 that already runs in each shard, the vitest module lines in each capture's
files. The check imports no reader of its own for them: it reads the captures the aggregator already downloads.A decision this PR makes, flagged for a ruling: a refresh HOLDS a ceiling
The ruled clauses meet head-on at the second refresh:
This PR holds: a refresh writes a ceiling only for a package that has none (new, or newly non-provisional), and never raises or lowers an existing one. Raising follows the ruling. Not lowering is my call, for one reason: a quieter window is variance, not a faster suite, and a ceiling ratcheted down on variance is red on the next ordinary run. The plugin-approvals reading above is that variance, 1.23× its window maximum within a day.
The alternatives are a one-line change in
suiteCeilings():The first table is identical under all three, so this only needs answering before the second refresh. The open question is in my report on the card.
A consequence of clause 3 the card should know
main, and a package whose PR is red never reachesmain.RULED_CEILING_RAISES, which the check honours for a package absent from the dataset.Gates (head
caffe8376d, after oneorigin/mainmerge)node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackoncaffe8376dderived 66 commands: 34pnpm, 32 directnode, over 5 paths against the merge basef66c440de. That is the same list as before the merge.caffe8376d, each with its exit code captured before any pipe. The 22-minutepnpm check:pm-dispatch-gatesbattery ran in the background, wrote its exit code to a file, and I waited on it in the foreground: exit 0,dispatch-gates self-test: 2011 cases pass.--ran:Run reconciliation — 66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN, then✓ dispatch-gates --ran: 66 derived famil(ies) accounted for — 66 run, 0 NOT-MEASURED (a DERIVED zero — all 66 recorded an exit code and none of them is 3). All 66 exited 0. They include:node scripts/check-test-suite-ceilings.mjs --self-test:self-test OK (35 cases across 7 batteries);node scripts/measure-test-shard-timings.mjs --self-test:self-test OK. Its new batterysuite-duration ceilings (#16468)has 14 cases, and the roster floor goes 3 to 4;node scripts/partition-test-shards.mjs --self-test:bins 1772.63/1771.02/1772.63/1770.95/1771.09/1772.46s within the 1772.65s density cap, file-level slices: @objectstack/cli x3. The split is unchanged;node scripts/ci/select-shard-timings-run.mjs --self-test, which drives the refresh lane's real generate step with the new generator;check-self-test-wired,check-self-test-workflow-commands,check:required-contexts,check:shard-attestation,check-aggregator-roster,check-step-collectors,check:stall-guard-budget,check:stall-guard-headroomandcheck:nul-bytes.8ab0fb8f0a. The same battery went red, 1 of 2011 cases, on the import described in the acceptance notes. The check was changed at68ca4e88d7, and the rerun above is green.caffe8376d.eslint --no-inline-config --format jsonover the two changed scripts reported 2 files, 0 errors and 0 warnings.isPathIgnoredis false for both scripts and true forci.yml, and workflow YAML is not an eslint input.parserOptions.projectand noprojectService. So no type-aware rule runs, and this diff cannot move an untouched file's verdict.pnpm lintis CI's.Ablations (the code committed first; each through
node scripts/ablation-replace.mjs, restore proven)Each mutation was made at head
68ca4e88d7. Each was confirmed on disk by the tool (anchor 1 → 0, blob changed) before the self-test ran. Each was restored with blob == HEAD and an emptygit diff HEAD. These are plain node scripts, with no build ordist/step.row.status = seconds > row.ceiling ? 'over' : 'ok';becamerow.status = 'ok';(blob1f01cb9c9543→40c38e79e976).Error: over: exit 0, verdict OK. Restored to1f01cb9c9543.if (inDataset && table.provisional.has(name)) {becameif (false) {(blob →e34689295a71).Error: provisional: exit 0, uncapped. Restored.uncappedfalls to the uncapped row ("no ceiling: provisional"), still not red. A provisional package that still holds a ceiling turns red.--holdon a one-off fixture (a provisional package at 9999 s holding a 12 s ceiling): restored code gaveNOT MEASURED 0, the mutated code gaveOVER 1. Restored, blob == HEAD.Math.max(...values)becamemedian(values)inceilingOf()(blob4c08f3d8463b→44bda0d56fe4).25.00(median × 1.25) instead of37.50. Restored.if (!Object.hasOwn(dataset, 'ceilings')) return { table: null };line deleted (blob →94844d5c01e1).`ceilings` is undefined, instead of NOT MEASURED. Restored.complete: row.sliceCount === null || parts.length === row.sliceCountbecamecomplete: true(blob →4eb8e8b191c1).Error: slices: an incomplete set read over / OVER. Restored.Acceptance notes
check:pm-dispatch-gatescaught a real interaction. The first draft of the check importedmergeCapturesfromreport-test-timings.mjs. That module is a gate file only through its own self-test, so a gate family importing it stops inheriting its declared populations in the dispatch-gates derivation. The battery's promotion invariant went red on exactly that (1 of 2011 cases). The check now folds the captures itself (foldCaptures): the same sum and completeness mark, and an unknown capture schema is named and left out. The turbo-summary reader is still the sharedsamplesFromSummary, which runs on the shard when the capture is taken.report-test-timings.mjs's header sayssamplesFromSummary"reads thetesttask only". Since thetest:repofold it sums both tasks. That file is outside this card's surface. Comment only; noted, not filed.skip-changeset: rootscripts/and workflows publish nothing.Generated by Claude Code