ci: regenerate the shard-timings dataset on a timer, and assemble a sliced package per run - #16498
Merged
os-steve merged 11 commits intoSep 7, 2026
Merged
Conversation
…median across runs
`buildDataset`'s slice ledger was keyed by (package, slice count, slice index)
with no notion of which run a summary came from, so every run collapsed into one
entry and both merge rules broke at once, silently:
* last-wins across runs. Three runs measuring 400/600/1000s recorded 1000 --
whichever run was read last -- while every unsliced package in the same
refresh correctly took its median. The one package the slicing machinery
exists for was the single least robust reading in the file.
* a cross-run splice. Run B's 1/2 and 2/2 plus run C's 1/2 summed to 800s, a
duration no run observed, with `skippedIncompleteSlices` EMPTY because from
that ledger's point of view the set was complete -- it just was not from one
run.
The ledger is now keyed by run first. Slices are summed WITHIN a run, the per-run
sums are medianed ACROSS runs exactly as an unsliced package's observations are,
and a run that cannot assemble every slice contributes no sample and is named
with its run in `skippedIncompleteSlices`.
The grouping key comes from the caller, because a run summary carries no run id
to infer one from: `--run <id>` opens a group and the summaries after it belong
to it. Summaries before any `--run` share one implicit group, which is the
ordinary single-run refresh; feeding several runs that way is REFUSED by name
with `--run` given as the remedy, never resolved by taking the last value.
`provenance.mergeRule` said "median across summaries", which was only ever true
of unsliced packages; it now states the two composed rules, and `provenance.runs`
records the declared run ids.
Self-test: 7 new cases, floor 34 -> 41 -- the three-run sliced median (600 where
the runs give 400/600/1000), the unsliced control in the same dataset (300, which
proves the median rule was alive and the sliced path alone bypassed it), the
cross-run splice refused and named, and the duplicate refusal with its remedy.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RfFHiRCSs3JXLK4cwcfox
…he PR scripts/test-shard-timings.json is the balancing input for the Test Core shard split, and it was the only part of that loop with no clock on it: generated, but only when someone remembered. It went 13 days without a refresh while ~700 test files were added, and the shard it mis-weighted was killed by the job wall twelve times in one day and ejected from the merge queue twice. The workflow runs weekly (Monday 05:30Z) and on dispatch. It selects a green run, downloads its six run-summary artifacts, re-runs the generator, and opens a PR only when the file changed by byte comparison. It never hand-edits the dataset and never touches a bound, a timeout or the shard matrix. It runs on a GitHub-hosted runner because that is where the inputs are reachable: the artifact host is denied to every agent container by egress policy, so the documented refresh path cannot be walked from a dev seat at all. Running here removes the channel from the loop rather than working around the denial. Choosing the run is the part that needed a tested unit, so it is one -- scripts/ci/select-shard-timings-run.mjs, with a 30-case self-test. "The newest run" is wrong three ways: cancel-in-progress censors most push runs on main, a run whose six shards all concluded success can still have replayed most of the workspace from the turbo cache, and artifacts expire after a day. The replay case is judged on what was MEASURED -- a package the committed dataset measured and this refresh does not has silently fallen back to the file-count estimate -- so a partial replay is rejected by name rather than by a duration threshold. The partitioner's balancing pins are RUN but never acted on. They red by design on a refresh that outgrows the acceptance bound, and the remedy they name (raise the slice count, never the bound) is a decision; the verdict is quoted into the PR body verbatim and the PR is opened either way, because withholding it would lose the measurement. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019RfFHiRCSs3JXLK4cwcfox
…ard-timings-refresh-workflow
A bare sequence under `bash -e` aborts at the first non-zero exit, so the second self-test would be neither green nor red -- and this step exists to say WHICH instrument is broken before the dataset is trusted. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019RfFHiRCSs3JXLK4cwcfox
…ndary `js` is a prefix of `json`, and the invocation pattern had a left boundary but none on its extension alternation. A workflow line naming a DATA file under scripts/ therefore matched as far as the `.js` and minted exactly the phantom key the left boundary was added to abolish: `scripts/test-shard-timings.json` was keyed as `scripts/test-shard-timings.js`, a path with no file behind it, silent in both directions -- the real file is audited by nothing, and the phantom key reconciles against no carrier. It stayed invisible only because no workflow had yet named a `.json` under scripts/. The first one to do it reddens the gate's `live corpus` battery, which is the battery that exists to catch precisely this. `(?![\w-])` is the spelling already used on `--self-test` two groups along, so both boundaries on this pattern now read the same way: a match ends where the token ends. Four cases pin it, control first (a real `.js` script is still seen), floor 4 -> 8. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019RfFHiRCSs3JXLK4cwcfox
…rite from the body A gate family reachable ONLY on a schedule appears on no card's gate list, so it is graded by nobody until the next sweep -- dispatch-gates.mjs reds on exactly that, and this lane was the first family to arrive scheduled-only. The remedy is the posture half-state-patrol.yml and required-set-patrol.yml already keep: a `pull_request` trigger scoped to this lane's own files. On such a run everything executes for real -- selection, download, regeneration, the coverage check, the byte comparison, the partitioner's verdict -- and only the WRITE is skipped, so a PR editing this workflow can never push a bot branch or open a second PR. Composing the PR body is therefore its own step, consumed either by the write or by the dry-run step that renders it to the run's step summary. A reviewer of a change to this lane sees the real output rather than the diff of the code that produces it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019RfFHiRCSs3JXLK4cwcfox
One global group would have two PRs touching this lane cancel each other's dry run and report the cancellation as though the lane were busy. Two SCHEDULED refreshes still cannot overlap, which is the race the group exists for. Also corrects a comment that described a `continue-on-error` the step does not use: the AFTER leg's exit is captured with `set +e` and read before any pipe. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019RfFHiRCSs3JXLK4cwcfox
…ard-timings-refresh-workflow
This was referenced Sep 7, 2026
…g for one that covers it The lane's first live run (34083991141) measured the fact the documented refresh procedure does not state: NO single green run measures the whole workspace. Of the seven retained green push runs on main, the best measured 52 of the 71 packages the committed dataset holds; the rest measured 2, 3, 13, 18, 22 and 49. All seven were partial cache replays, so every candidate was rejected and the job exited 1 having regenerated nothing. That is the cache design rather than luck: turbo's key is namespaced per shard and only main pushes write it, so a package whose inputs have not changed is a HIT -- and the generator refuses hits rather than recording a replayed ~0.1s window as a suite's cost. "Download six artifacts from any green run" therefore measures a SLICE of the workspace. Runs are now accumulated until coverage is satisfied, each fenced by its own `--run <id>` group. That grouping is exactly what the #16473 fix on this branch added: slices are summed WITHIN a run and the per-run sums are medianed ACROSS runs, so a package measured by three of the accumulated runs gets the median of three observations -- the property the dataset's merge rule always claimed and could not previously deliver. Feeding several runs without that grouping is refused by name, so this path could not have been taken before the rider. The argument list is rebuilt from the accepted set each round rather than appended to, so a run the generator refuses is dropped cleanly instead of poisoning every later attempt. The PR body and commit message now name every contributing run; the newest is the one the refresh is dated from. When even the whole accumulation falls short the job still refuses loudly, and its message names the remedy that would be a DECISION rather than a tuning knob: keeping the last measured weight for a package this refresh did not measure. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019RfFHiRCSs3JXLK4cwcfox
…eights on their witness
Ruled on the card after the lane's first two live runs measured that the
acceptance rule as written cannot be met: no retained run set covers the
workspace. The best single green run measured 52 of 71 packages, the
accumulation of all seven converged at 57, and the last 14 are turbo cache HITs
in every one of them -- the cache key is namespaced per shard and only main
pushes write it, so a package whose inputs have not changed is a HIT, and this
generator refuses hits rather than recording a replay as a duration.
So "regenerate" now means MERGE, not replace, and the merge is sound for one
specific reason: a cache HIT is not missing data, it is positive evidence that
the package's inputs are unchanged since the run whose output was replayed, so
its last measured weight still describes it. The file's invariant is preserved
exactly -- every number in it remains a real measurement of code as it stands,
never an estimate.
* `--merge-into <dataset>` carries a package's previous weight ONLY when a
cache HIT witnesses it. A package absent for any other reason -- never ran,
suite failed, slices unassemblable -- has no evidence behind it and is left
out for the caller to name. `skippedAsCached` is exactly the witnessed set,
so the carry reads it rather than inventing a second classification.
* Carried packages are named in a top-level `carriedOver` list beside
`skippedAsCached`. That shape rather than a per-package `measuredAt` because
the reader forces it: partition-test-shards.mjs reads `packages` as
name -> NUMBER and never opens `provenance`, so per-package dates would mean
changing that shape and every consumer of it. A single `provenance.measuredAt`
paired with the list carries the same information and needs no partitioner
change.
* Carried weights vote on `secondsPerTestFileFallback` like measured ones,
which keeps that rate derived from the numbers actually in the file.
* An all-cached pass still REFUSES: carrying can never manufacture a refresh
out of nothing.
Coverage is judged on measured union carried, and the two ways a package can be
missing are now reported separately: one that HAD a weight and has neither is a
refusal by name, while a workspace package that never had one is named as
partitioner-estimated but is not a regression, because this refresh did not
change its standing.
The header sentence "Download all six from any green queue build and re-run the
generator" is corrected in the same change: it is optimistic in a way nobody had
measured, and now states the measured fact.
Self-test: 9 new generator cases (carried / measured-not-carried / witnessed
versus absent / empty list on a plain replace / all-cached still refuses /
monotone accumulation), floor 41 -> 50; 5 new selector cases for the union
semantics, floor 30 -> 35.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RfFHiRCSs3JXLK4cwcfox
…ard-timings-refresh-workflow
os-steve
marked this pull request as ready for review
September 7, 2026 09:18
os-steve
enabled auto-merge
September 7, 2026 09:18
os-steve
deleted the
claude/issue-16464-shard-timings-refresh-workflow
branch
September 7, 2026 09:43
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.
Fixes #16464
Fixes #16473
Refs #16173
Refs #16222
Puts a clock on the Test Core balancing input, and fixes the generator defect that would have made the very first refresh untrustworthy for the one package it matters most for.
scripts/test-shard-timings.jsonis the per-package duration dataset the Test Core shard split is binned from. It is generated, but until now only when somebody remembered: it went 13 days without a refresh while ~700 test files were added, and the shard it mis-weighted was killed by the job wall twelve times in one day and ejected from the merge queue twice. The rot is one-directional and silent — suites only get slower, the table stays put, and a shard killed by the wall produces no measurement at all while the rollup reads green — which is why the fix is a timer rather than more discipline.1. The prerequisite: a sliced package was bypassing the median rule (#16473)
buildDataset's slice ledger was keyed(package, slice count, slice index)with no notion of which run a summary came from, so every run collapsed into one entry and both merge rules broke at once, silently. Reproduced by driving the exportedbuildDatasetdirectly, which is how flight C1 proved it:The first row is last-wins: the package took whichever run was read last, no matter how many were fed. Every other package in the same refresh got its median, so the heaviest suite in the workspace — the one the slicing machinery exists for — was the single least robust reading in the file, with no line of output saying so. The second row is the control that proves the median rule was alive and the sliced path alone was bypassing it.
The third row is the sharper half: a set completed from slices of different runs was summed as one measurement, producing a duration no run observed, while
skippedIncompleteSlicesstayed empty — from that ledger's point of view the set was complete, it just was not from one run.Both are this file's own signature hazard — a wrong number that reads exactly like a right one — surviving on the axis of which run.
The fix. The ledger is keyed by run first. Slices are summed within a run; the per-run sums are medianed across runs, exactly as an unsliced package's observations are; and a run that cannot assemble every slice contributes no sample and is named with its run.
The grouping key comes from the caller, because a run summary carries no run id to infer one from — the six artifacts of one CI run are named and fetched together, so the caller is the only actor that knows.
--runopens a group; summaries before any--runshare one implicit group, which is the ordinary single-run refresh. Feeding several runs without declaring them is refused by name, with--rungiven as the remedy, rather than resolved by taking the last value. That refusal is what makes the convenient default safe.provenance.mergeRulesaidmedian across summaries, which was only ever true of unsliced packages; it now states the two composed rules, andprovenance.runsrecords the declared ids.Sixteen self-test cases across the two changes it drives (seven for the per-run rule, nine for the merge below), floor 34 to 50. Exercised end to end through the real CLI as well, on synthetic summaries: declared three runs give
cli = 600/ control300; the same six files with no--runexit 1 naming the slice and the flag; one run with no--rungives 600 with no ceremony.2. The workflow
.github/workflows/shard-timings-refresh.yml— weekly (Monday 05:30Z) plusworkflow_dispatch. It selects runs, downloads theirtest-core-run-summary-N-of-6artifacts, re-runs the generator, and opens a PR only when the file changed by byte comparison.Which path it took, and why. It downloads the artifacts, per ruling 2. The alternative — generating from the runner's own fresh summaries — is not available to a separate lane: a single job would have to run the entire workspace's suite unsharded to produce a complete summary set, which is the "special measurement run" this tooling exists to avoid. Downloading is safe here and unreachable elsewhere:
GET /actions/artifacts/{id}/zipredirects toproductionresultssa*.blob.core.windows.net, which every agent container's egress policy refuses (403 on CONNECT, reproduced four ways across two days and two containers — #16222, then again on #16173), while a GitHub-hosted runner reaches that host natively. Running here is therefore not a workaround for the denial; it removes the channel from the loop.Choosing the run is the hard part, so it is a tested unit —
scripts/ci/select-shard-timings-run.mjs, 30 self-test cases. "The newest run" is wrong three ways, all measured on this repo:cancel-in-progresscensors most push runs on main — 36 of the last 60. Such a run still leaves artifacts, so "the artifacts exist" is not the test; all sixTest Core (N/6)jobs must have concludedsuccess.turbo lstoo.Runs are accumulated rather than searched: each contributes its six summaries under its own
--rungroup, and the loop stops at the first accumulation that covers the workspace. Section 4 is why that is not a nicety.What it is not allowed to do. It never hand-edits the dataset; every byte comes out of the generator. It never touches
timeout-minutes, the shard matrix,MAX_SHARD_OVER_MEAN,MAX_MEASURED_OVER_PREDICTEDorFILE_SHARDED_PACKAGES.ci.ymlis untouched by this PR.The pins are run but never acted on.
partition-test-shards.mjs --self-testgrades the refreshed dataset, and it reds by design on a refresh that outgrows the acceptance bound, naming the remedy — raise the slice count, never the bound. Its pin 3c additionally demands a decision the day the CLI comes back under the bound on its own. Both are judgement, so the workflow reports the verdict verbatim into the PR body and opens the PR either way: withholding it would lose the measurement, which is the exact silent-rot failure this lane exists to end.3. Two gate defects this diff was the first to trip
scripts/check-self-test-wired.mjs— a missing right boundary (declared as a file-surface supplement on #16464 before it was made). Its invocation extractor had a left boundary but none on its extension alternation, andjsis a prefix ofjson, so the first workflow line naming a.jsonunderscripts/matched only as far as the.js:That is precisely the phantom key the left boundary was added to abolish, arriving from the other end, and silent in both directions — the real file is audited by nothing and the phantom reconciles against no carrier. Ruling 2 requires the workflow to name that path, so no version of this deliverable avoids it. The remedy is the guard the same pattern already applies to
--self-testtwo groups along. Proven red-then-green: with the boundary removed and the mutation confirmed on disk, the production run stays green at exit 0 while--self-testgoes red naming the new pin — the shape that file's own header documents for this gate class — and restoring returns the byte-identical blob and a green self-test. Four cases, floor 4 to 8.A scheduled-only gate family.
dispatch-gates.mjsreds when a family is reachable only on a schedule, because such a family appears on no card's gate list and is graded by nobody until the next sweep. This lane was the first to arrive that way. The remedy is the posturehalf-state-patrol.ymlandrequired-set-patrol.ymlalready keep: apull_requesttrigger scoped to this lane's own files. On such a run everything executes for real — selection, download, regeneration, coverage check, byte comparison, partitioner verdict — and only the write is skipped, so a PR editing this workflow can never push a bot branch or open a second PR. Composing the PR body is therefore its own step, consumed either by the write or by a dry-run step that renders it to the run's step summary.That trigger also supplies this PR's reverse verification: opening it runs the lane end to end against live artifacts, in dry-run mode, on a real runner.
4. What the live runs measured, and the merge rule they forced
The
pull_requesttrigger ran the lane against live artifacts, and it measured a fact no ruling anticipated: not one retained green run measures the workspace, and neither does the union of all of them.14 packages are turbo cache HITs in every retained run. That is the cache design rather than luck: the key is namespaced per shard and only main pushes write it, so a package whose inputs have not changed is a HIT — and the generator refuses hits rather than recording a replayed ~0.1s window as a suite's cost. The documented procedure ("download all six from any green queue build and re-run the generator") is optimistic in a way nobody had measured; that sentence is corrected in this PR.
So a refresh MERGES rather than replaces (
--merge-into, ruled on the card). A package this pass did not measure keeps its previous weight — but only when a cache HIT witnesses it, and that witness is the whole argument: a HIT is not missing data, it is positive evidence that the package's inputs are unchanged since the run whose output was replayed, so its last measured weight still describes it. The file's invariant survives intact — every number in it is a real measurement of code as it stands, never an estimate.The witness requirement is what stops that from becoming "keep whatever was there". A package absent for any other reason — never ran, suite failed, slices unassemblable — has no evidence behind it, is not carried, and the coverage check names it as a refusal. Both directions are pinned, and both were exercised against the real dataset: two runs measuring 55 packages carried the other 16 and passed; withholding two packages entirely (no MISS, no HIT) refused by name.
Carried packages are listed in a top-level
carriedOver, besideskippedAsCached. That shape rather than a per-packagemeasuredAtbecause the reader forces it:partition-test-shards.mjsreadspackagesas name → number (timings.packages[name] / sliceCount) and never opensprovenanceat all, so per-package dates would mean changing that shape and every consumer of it. A singleprovenance.measuredAtpaired with the list carries the same information and needs no partitioner change.Coverage is judged on measured ∪ carried, and the two ways a package can be missing are reported separately, because only one is a regression: a package that had a weight and has neither is a refusal by name; a workspace package that never had one is named as partitioner-estimated but is not a regression, since this refresh did not change its standing. Nothing is silently estimated in either case.
Verification
Everything below was run on head
94eeaa19b1, so the union is a reading about the tree this PR actually proposes rather than an earlier one.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderives for this four-path change set were run and reconciled with--ran. The list was derived by the script from its own merge-base change set, not hand-assembled, and the derivation reported no stale tree.pnpm lint(eslint . --no-inline-config) through the shared verify lock, slotissue-16464:LINT-EXIT=0. Lock held 90s after an 86s wait — shared-box seconds, not idle-box.check-governed-merges --teston the final four-path file list:NOT governed — ordinary queue landing applies to a PR with exactly this file list.05cb57b43c, twice, both clean and to the same tree — and that PR has since landed and merged into this branch cleanly.pull_requesttrigger, on real artifacts — the reverse verification. The first two reached the coverage check and refused, which is what measured section 4. The run on this head is green: it covered the workspace from a single run plus the carry (RUN_COUNT: 1, run34087495912atc383352cb7), composed a 70-line PR body and rendered it to the step summary with no branch pushed and no PR opened. ItsPARTITIONER_EXIT: 1is the designed behaviour from section 2 arriving live: the balancing pins red on an honest refresh, and the lane reports the verdict into the body rather than acting on it.Two gate reds during development were fixed rather than worked around, and both are described above:
check-step-collectors(a bare self-test sequence underbash -e) andcheck-self-test-wired --self-testpluscheck:pm-dispatch-gates(the two defects in section 3).The dataset itself is not touched by this PR.
scripts/test-shard-timings.jsonis written only by a workflow run, never by hand — which is the whole point.Two notes for whoever lands this
workflow_dispatchon this branch (ruling 4's literal form) is not available to this seat:POST /actions/workflows/shard-timings-refresh.yml/dispatchesanswers403 Resource not accessible by integration, so the session credential carries no Actions write scope. Thepull_requesttrigger added in section 3 supplied the reverse verification instead, and did so better than a dispatch would have — it ran the lane end to end against live artifacts on a real runner, and it is what surfaced section 4's finding. Its result is on this PR's own checks.The refresh PRs this lane opens carry only
Refs, never a closing keyword. A weekly lane cannot know which cards a given run ought to retire, and a keyword baked into it would have every subsequent Monday try to re-retire cards already retired. Cards #16173 and #16222 therefore stay open when the first refresh PR merges — whoever lands that first refresh should retire them by hand, or add the keyword to that one PR.⛔ This PR is left in draft deliberately. Do not flip it ready, enqueue it, or arm auto-merge from an agent seat.
Generated by Claude Code