Repository navigation
ci(test-core): force the sliced test leg to execute, not replay - #19271
Merged
os-zhuang merged 1 commit intoSep 20, 2026
Merged
Conversation
The per-shard slice leg runs `turbo run test --filter=$PKG --only ... -- --shard=k/n`. `--only` drops the dependency tasks out of the run graph -- and out of the task hash with them, so the leg's hash carries only the package's own files plus the hand-declared `$TURBO_ROOT$` inputs. A slice that is in the shard's affected set can therefore match a main-seeded cache entry across the very change that put it there, and be replayed instead of run, while the completeness guard and the shard attestation both grade the replay green. Add `--force` to that one invocation. `--only` stays: dropping it instead re-executes the whole dependency closure the preceding step just built. Measured on this tree, turbo 2.10.10, after an upstream source change plus that closure rebuild: `--only` replays (81ms, FULL TURBO); `--only --force` runs (1.15s, one task); dropping `--only` runs but re-executes the closure (1m54.963s on a 6-task closure, and 60 of 60 tasks plan as cache misses at cli's real scale, 5m11.262s to re-execute). The comment at the call site carries the readings and says why the two flags are a pair. Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk Co-authored-by: Claude <noreply@anthropic.com>
This was referenced Sep 20, 2026
os-try-charles
marked this pull request as ready for review
September 20, 2026 09:08
os-zhuang
approved these changes
Sep 20, 2026
os-zhuang
deleted the
claude/issue-18671-shard-leg-executes-affected-tasks
branch
September 20, 2026 09:45
This was referenced Sep 30, 2026
akarma-synetal
pushed a commit
to akarma-synetal/framework
that referenced
this pull request
Oct 7, 2026
… hash sees its upstream closure (objectstack-ai#19278) (objectstack-ai#20824) Fixes objectstack-ai#19278 Clause-②: no Seat review ACCEPT on the landing head `e00136407`: comment `5909822071`. No contract-review record is owed, because the diff touches no review face and `Clause-②: no` (`contract-review.md`). ## What it changes Test Core's file-level slice leg (`.github/workflows/ci.yml`) ran `pnpm turbo run test --filter=$PKG --only --force … -- --shard=$SLICE`. - `--only` drops the dependency tasks, so the slice's task hash sees only the package's own files and the hand-declared `$TURBO_ROOT$` inputs. An upstream change does not move it. - `--force` (PR objectstack-ai#19271) keeps such a slice from being replayed from cache, but the hash stays blind. That is objectstack-ai#19278. This branch carries the slice in a task-declared env instead: - `turbo.json` declares `OS_TEST_SHARD` on `test` and on `@objectstack/cli#test`. A package-specific task replaces the generic one, so both need it. - `packages/cli/vitest.config.ts` reads it into vitest's `shard`, typed through vitest's CLI-options type. A literal `test.shard` fails typecheck with TS2769. - The slice leg runs `env OS_TEST_SHARD=$SLICE pnpm turbo run test --filter=$PKG …`, with no passthrough, no `--only` and no `--force`. - `scripts/partition-test-shards.mjs --self-test` gains a battery. It fails any package the partitioner can slice whose turbo task or vitest config does not carry the variable. - `scripts/measure-test-shard-timings.mjs` reads an env-carried slice back from the run summary's `environmentVariables.configured` digest, and keeps the `cliArguments` path that nightly-tiers still uses. Without this, a timings refresh would record one slice as the package's whole cost (objectstack-ai#16173's defect). - Comments the change made false are rewritten: the slice leg's block and the closure-build step in `ci.yml`, and `report-test-timings.mjs`. ## Measured (round 1, the dev's report `5907562928` on objectstack-ai#19278, turbo 2.10.10, vitest 4.1.11) | | old leg | new leg | |:--|:--|:--| | `cli#test` hash after an upstream edit in `spec` / `types` | unchanged | moves each time | | an edit inside `cli` itself | moves | moves | | control edit outside cli's closure | — | unchanged | | slice after an undeclared upstream edit, no `--force` | `cache hit, replaying logs` (not run) | `cache miss, executing` (runs) | | planned tasks, HIT / MISS | 1 task (the `--only` leg) | 62 tasks: 59 HIT / 3 MISS | ## Evidence on the final head `e00136407d` (handover steps 1 to 4) Final head: `e00136407d04e3b2871b30a9c6463a2f9f108f6c`, a merge of `origin/main` `4b45afaed5`. The six paths are byte-identical to `9df0b71851`, except one comment block in `scripts/measure-test-shard-timings.mjs` (`099e5ff641`, comment only). ### 1. The reader's new self-test cases, ablated - Every mutation went through `scripts/ablation-replace.mjs`: the anchor hit 1 then 0, the blob moved, the file was restored to the HEAD blob, and `git diff HEAD` was empty afterwards. A shell trap also guarded each run. - The reader blob was `453a072feedb` on the final head. The same table was measured first on `cb42313d89e6` (`9df0b71851`) and gave the same result. - Each mutation got two readings: - the real `--self-test`, its exit code and its first red; - an instrumented copy of the harness that records every red case instead of stopping at the first one. - Control: the instrumented harness with no mutation records 0 reds. | mutation | real `--self-test` | red cases in `env-carried slices (objectstack-ai#19278)` | |:--|:--|:--| | A0: the digest path is dropped (`sliceOfLeg` never reads `OS_TEST_SHARD`) | exit 1, "read as null, not 2/3" | 1, 2, 4, 5, 6, 9, 10 | | A1: the env slice is computed but not returned | exit 1, same | 1, 2, 10 | | A2: the default map is unbound from `FILE_SHARDED_PACKAGES` | exit 1, the refusal on `@objectstack/cli` 2/2 | 2 | | A3: the `cliArguments` carrier is dropped | exit 1, older battery case 29 first | 3, plus older-battery cases 29, 31, 34 to 37 and 40 to 43 | | A4: the refusal is removed (an unmatched digest returns null) | exit 1, "not refused naming its candidates" | 4, 5, 6 | | A5: a package the partitioner does not slice ignores its digest | exit 1 | 5 | | A6: the empty-value guard is removed | exit 1 | 7 | | A7: the entry is picked without its `OS_TEST_SHARD=` prefix | exit 1 | 8 | | A8: the refusal of two disagreeing carriers is removed | exit 1 | 9 | - Every case goes red under at least one mutation. - The unmatched-digest case (4) goes red under A0 and A4. - Cases 6 and 10 have no mutation of their own. They are end-to-end pins over the code that cases 4 and 1 pin: case 6 checks that the refusal never reaches a dataset, and case 10 that three env slices sum to one package. - After the mutations the self-test reads OK and `git diff HEAD` is empty. - One comment was fixed as a result (`099e5ff641`). The battery comment said every case fails when the digest path is dropped. A0 shows that cases 3, 7 and 8 are controls that pass either way, and the comment now says so. ### 2. A real new-leg summary through the pipeline - This PR's CI summaries could not be read from this container. The artifact download redirects to blob storage, and the container's egress proxy refuses that with 403. The summaries were therefore produced locally with the leg's own shape, after the closure build: `OS_TEST_TIERS=queue env OS_TEST_SHARD=k/2 pnpm turbo run test --filter=@objectstack/cli --summarize --log-order=stream`. - Added for this box: `--concurrency=2`, `VITEST_MAX_WORKERS=2` and a private `--cache-dir`. None of them enters a task hash. - Commit: `95b97ef2e`. Its six paths and every `packages/cli` file are identical to the final head's. - Slice 1/2: `cache miss, executing 90ddc44dcc24b458`; 60 tasks, 59 cached; 152 files; 2069 tests passed and 1 skipped. In the summary, `cliArguments` is empty, and `environmentVariables.configured` holds `OS_TEST_SHARD=d939926f…` (the sha256 of `1/2`) and `OS_TEST_TIERS=00b109cf…`. - Slice 2/2: `cache miss, executing 52f80464736e668b`; 151 files; 1865 tests. - CI shows the same thing. In `Test Core (6/6)` on `9df0b71851` (job 109819361192, whose log was read through the MCP job-log tool): `@objectstack/cli:test: cache miss, executing 874a69822b22ac22`, 60 tasks, 59 cached, 151 files, 1865 tests. That is the same slice with the same counts. | reader | slice 1/2 | slice 2/2 | cli in the dataset | |:--|:--|:--|:--| | this branch | 644.411 s, read as slice 1/2 | 572.636 s, read as slice 2/2 | **1217.05 s** (the two slices summed) | | `origin/main` `810d42b69c` | 644.411 s, no slice | 572.636 s, no slice | 608.52 s (the median of two "whole" samples, which is objectstack-ai#16173's defect) | - `report-test-timings.mjs`, with `--capture` run twice and then `--merge`: - cli reads 1217.05 s, "all 2 slices", complete, with 303 of 303 file lines. - With only slice 1/2's capture present, this branch labels the row "slice 1/2 — PART, not a total". The `origin/main` script prints 644.41 s as cli's total, with no note. - `partition-test-shards.mjs --check-drift`: - It predicts **229.1 s** for cli, one slice's share of the 458.15 s weight, against 644.4 s measured. That is DRIFT at 2.81x, exit 1. The ratio is this shared container, about 2.7 times slower than CI. The number that matters is the prediction. - The `origin/main` script predicts 458.1 s, the whole package, and reports OK at 1.41x. It compares one slice against the whole package's weight. - An unmatched digest on a real summary. One hex digit of the `OS_TEST_SHARD` digest in slice 1/2's summary was flipped: - `measure-test-shard-timings.mjs` exits 1, with the refusal naming the candidates `1/2, 2/2`, and writes no dataset. - `--check-drift` exits 1 with the same refusal. - `report-test-timings.mjs` exits 0, as designed, because it may not fail the job it reports on. It lists the refusal under "Problems while reading this run" and shows cli as "slice 2/2 — PART, not a total". It never shows cli as a whole sample. ### 3. The merge of `origin/main` - Two merge commits, no rebase: `95b97ef2e` merged main at `810d42b69c`, and `e00136407` merged main at `4b45afaed5`. - The second merge was needed because `dispatch-gates` flagged the first derivation as stale. Main had changed `scripts/pm/fleet-write/dispatch.mjs`, one of the files the derivation reads. - Neither merge conflicted. Main touched none of the six paths. After each merge the six paths were byte-identical to the branch's own. - Main has since moved to `22e584c9d`. Neither of the two new commits touches the six paths or anything the derivation reads, and the derivation says so. ### 4. Gates and the card's acceptance, on `e00136407` **Gates.** - `dispatch-gates --commands --repo objectstack-ai/objectstack` derived 89 commands, and all 89 were run. - `check:dual-build-cjs-loads` first exited 3 with PREREQUISITE NOT MET: nine packages outside cli's closure had no `dist`. Those nine were built and the gate re-run: exit 0. The four other `dist` readers (`dts-closure`, `lean-entry-closure`, `published-files`, `sourcemap-no-sources-content`) were re-run on the fuller tree: exit 0. - `check:pm-dispatch-gates` ran detached: exit 0, 1976 cases, 1058.8 s. - Verdict: `✓ dispatch-gates --ran: 89 derived famil(ies) accounted for — 89 run, 0 NOT-MEASURED (a DERIVED zero — all 89 recorded an exit code and none of them is 3)`, with 0 UNRUN. - Nine families that the derivation names as taking a value from the workflow cannot run locally, because their argv or env has no value outside CI. They are the `check-shard-attestation` emit and verify steps, `check-test-completeness` on the two CI logs, `check-issue-citations --census` and `check-required-contexts --verify-required-set`. They are not in the 89, and CI runs them. - Beyond the derived list: - `pnpm --filter @objectstack/cli typecheck`: exit 0; `check:test-typecheck` OK, holding the same 3 files, 28 errors and 6 pinned signatures. - `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2`: 237 files and 3370 tests passed. - The whole cli suite at the queue tier (both slices above, 303 files) passed on `95b97ef2e`. - `check-test-completeness.mjs --self-test` passed. That script imports the partitioner. - eslint over the four changed lintable files: 4 files, 0 errors, 0 warnings. eslint's own config places all four in its population. The count comes from `--format json`. The config enables no type-aware linting, so this diff cannot change eslint's verdict on any untouched file. **Acceptance 2: the hash moves.** These are `--dry=json` hashes of `cli#test`. Each edit was a one-line marker, planted and then restored. | edit | new leg (`OS_TEST_SHARD=1/2`) | old leg (`--only --force -- --shard=1/2`) | |:--|:--|:--| | clean | `5fe85e3cd60398b1` | `d26a68b9c44aac59` | | `packages/spec/src/ui/view.zod.ts` | `f57477faa9b85239`, moves | unchanged | | `packages/types/src/env.ts` | `b64907aa07f030ad`, moves | unchanged | | `packages/cli/src/utils/one-shot-exit.ts` | `123e600f94cc13c8`, moves | `297d412288a9c65b`, moves | | `packages/client/src/index.ts` (no `$TURBO_ROOT$` input names it) | `32e74945bc85d7e1`, moves | unchanged | | connector-slack (control, outside cli's closure) | unchanged | unchanged | - The new leg plans 62 tasks: 59 HIT and 3 MISS. All 61 of its build hashes equal those of `turbo run build --filter=@objectstack/cli`, so the closure replays. - The second merge moved the new leg's clean hash from `6a3fe12522c74bb9` (on `099e5ff641`) to `5fe85e3cd60398b1`. The old leg stayed at `d26a68b9c44aac59`. **Acceptances 1 and 3: an affected slice executes without `--force`.** The runs used cli slice 1/16 for speed; turbo's replay decision is a hash lookup and does not depend on k/n. `OS_TEST_TIERS=queue` was set throughout. - New leg, unchanged tree: `cache miss, executing 8cc2f107d21814cf`, then `cache hit, replaying logs 8cc2f107d21814cf` in 86 ms. That replay is legitimate, because nothing changed. - Old leg (`--only`, no `--force`), unchanged final tree: `cache hit, replaying logs dc666be1735ca19a`. That cache entry was written on `099e5ff641`, before the second merge brought in upstream changes; the closure rebuild after that merge executed 58 of 59 builds. **This is the defect, reproduced by a real merge.** - Next, an edit was planted in `packages/client/src/index.ts`, followed by CI's closure rebuild (`client#build` and `cli#build` executed; 57 of 59 were cached): - Old leg, no `--force`: `cache hit, replaying logs dc666be1735ca19a` in 118 ms. The slice was not run. - New leg, no `--force`: `cache miss, executing 61f0a4c75daa610c`, 19 files passed. The slice ran. - The same sequence ran on `099e5ff641` with the real partition slice 1/2: - unchanged tree: the slice replays `90ddc44dcc24b458`; - after the client edit: `cache miss, executing 2df7b9311ca50a57`; 152 files; 2069 tests passed and 1 skipped; 10m22.8s. - After each run the edited file matched its HEAD blob and `git diff HEAD` was empty. ### Changeset disposition: `skip-changeset`, applied by the seat - `Check Changeset` on `e00136407` fails at "Require a changeset (or the skip-changeset label)". The PR adds no changeset and has no label. It lacks a disposition; the gate did not misread anything. - `check-empty-changeset.mjs --base origin/main`: exit 0. No declaring changeset was added, and no existing changeset was modified or deleted. - `check-changeset-no-major.mjs --base origin/main`: exit 0, no major bump. - Presence: 0 `.changeset/*.md` files were added against merge base `4b45afaed5`. - No published file changes: - `packages/cli` ships `files: [dist, README.md, CHANGELOG.md]`. Its `tsconfig.build.json` compiles only `src`, so `vitest.config.ts` is test configuration and is never shipped. - In the built cli `dist`, README and CHANGELOG, `OS_TEST_SHARD` appears in 0 files. The positive control, `exitOneShotCommand`, appears in 6 files. There is no `vitest.config*` in `dist`. - The other five paths are CI configuration, the root `turbo.json`, and scripts of the root package, which is private. ## Acceptance notes - The config-level shard is typed through vitest's CLI-options type. If a vitest upgrade stops merging the test block with the CLI options, both slices will run the whole suite: extra cost, not lost coverage. The partitioner battery checks only spelling, so it cannot catch this. - Landing moves 52 test-task hashes once. Any `turbo.json` edit already makes all 80 packages affected on the landing PR. - The Dogfood gate's shard leg has the same `--only` passthrough shape and no `--force`. It is filed separately as objectstack-ai#20820. - The unmatched-digest refusal reaches `report-test-timings.mjs` as a stated problem, not as an exit code. That line names the summary file twice, because `readSummaries` prefixes the entry and `samplesFromSummary` also labels the error with it. Every refusal that function relays already has this shape. It is left alone. - The local timings in section 2 are shared-container seconds, about 2.7 times CI's. Read the prediction, not the drift ratio. - `Shard Timings Refresh` went red on `e00136407` (run 36701198568): "candidates existed and none was eligible". - That lane reads main's scheduled CI runs through the GitHub run-listing API, not this diff. Its inputs here (`select-shard-timings-run.mjs`, the workflow, and the reader) are byte-identical to the three earlier heads, where it was green. - Reproduced locally at 10:22Z: the listing served a stale index (70 scheduled runs, the newest from 2026-09-27T14:00Z, every artifact expired), so every candidate was rejected. - The same query at 10:23Z returned the 10:01Z and 09:01Z runs, both ELIGIBLE, and the selector exited 0 in three straight attempts. - The details are in the report on objectstack-ai#19278. Rounds 1 and 2 were written by session `session_014EJ1ED8X4MMrT18BhVx4tx`. The evidence above, the second merge and the comment fix are by session `session_01TdiauJaVCHuj45EzZGUxHh`, the `domain:devx` seat 2 dev, on 2026-09-30. --------- Co-authored-by: Claude <noreply@anthropic.com>
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 #18671
Second flight on this card: the first one pinned the contradiction and named the mechanism (verdict (A), comment 5736428716). This one measures the three named repairs and implements the one the measurement picks.
The defect, in one line
CI runs two legs of the same task in one job. The whole-package leg is
pnpm turbo run test test:repo $FILTERS ...; the file-level slice leg ispnpm turbo run test "--filter=$PKG" --only ... -- "--shard=$SLICE".--onlydrops the dependency tasks out of the run graph and out of the task hash with them, so the slice leg's hash carries only the package's own files plus the hand-declared$TURBO_ROOT$inputs. A slice that IS in the shard's affected set can therefore match a main-seeded cache entry across the very change that put it in that set. Run 34746808828 did exactly that:Cached: 1 cached, 1 total,73ms >>> FULL TURBO, replaying a log a main push run had produced ~15 minutes earlier on a commit that did not contain the PR under test — while all six shards reported success,check-test-completenessgraded the replay OK and the shard attestation said "ran to completion".What changed
One line in
.github/workflows/ci.yml, plus the comment above it that carries the readings:turbo.jsonis unchanged. Nothing else is touched.The measurement that picked it
Three repairs were named by the first flight, verbatim: (1)
TURBO_FORCEon that leg; (2) make the shard a task-declared env so--onlyis not needed at all; (3) drop--onlyand pay back the closure-rebuild cost that #16395 removed.Does the affected task actually execute? Measured on this tree, turbo 2.10.10, on
@objectstack/service-cache— a package whosetesttask has the same shape (dependsOn: ["^build"]) and which depends on@objectstack/spec. Sequence: build the closure passthrough-free (as the job's own preceding step does), run the slice leg once to seed the cache, then append one line topackages/spec/src/ui/view.zod.ts, rebuild the closure, and run each variant. The mutation was proven on disk (marker count 0 to 1; blob9051f93e...to4edcf771...) and restored under a trap, verified by an emptygit diff HEAD.--only+ passthrough (before)Cached: 1 cached, 1 total/81ms >>> FULL TURBO/cache hit, replaying logs--only --force(this PR)Cached: 0 cached, 1 total/1.15s/cache bypass, force executing--only, passthrough kept (repair 3)Cached: 0 cached, 6 total/1m54.963s--only, no passthrough (repair 2's shape)Cached: 5 cached, 6 total/1.512sAll four rows ran the same suite; only the first one did not.
What it costs, re-measured today at
@objectstack/cli's real scale,--dry=jsonagainst a cache the passthrough-free closure build had just filled (59 build tasks in the plan, of which 57 execute —@objectstack/consoleand@objectstack/refd-timer-testkitdeclare nobuildscript, so they appear in the plan and never run):--only+ passthrough (before, and with--force)--only, passthrough kept (repair 3)--only, no passthrough (repair 2)So repair 3 re-executes 57 real build tasks that the job's preceding step had just made replayable. Executing that closure here measured 5m11.262s at
--concurrency=2on a shared container, against 60ms>>> FULL TURBOfor the same command when its hashes are left alone (that 60ms is the control: the closure is replayable, and only the passthrough stops it being replayed). These are shared-box seconds, not idle-box seconds; the ratio is the durable half. CI runs--concurrency=4on a 4-vCPU runner and the job stands against a 30-minute wall.The slice leg's own execution cost — which repairs 1, 2 and 3 all pay, because it is the cost of actually testing — is
@objectstack/cliat 458.15s whole-package (scripts/test-shard-timings.json,measuredAt: 2026-08-24, median across six green merge_group run summaries), sliced 1/2 across shards 5/6 and 6/6, which run in parallel. Onpull_requestandmerge_groupthe shard's package set IS the affected set (scripts/ci/select-shard-packages.sh), so a slice is scheduled only when it is affected and a replay there is wrong by construction — this is not new CI time, it is CI time the job was skipping.Repair 2 is out of this PR's file surface, and that is why it is not implemented. It needs the shard to reach vitest without a run-level passthrough. vitest 4.1.11 reads no
VITEST_SHARD(the env vars it reads are enumerable in its dist;shardis not among them), so the value has to be plumbed through a vitest config file —test: { shard: process.env.OS_TEST_SHARD }inpackages/cli/vitest.config.ts, plus anOS_TEST_SHARDentry inturbo.json'stesttaskenv. The dispatch's surface isci.ymlandturbo.jsononly, so this stops here and is reported rather than widened. Two things worth knowing if it is picked up: it is the cheapest correct shape (the closure replays, 57 HIT above) and it makes the hash honest rather than only the execution; but the sliced set is data-driven (scripts/partition-test-shards.mjsweights by measured duration), so a newly-sliced package without the config line would silently run its whole suite on both shards, and no gate would say so.What this does not fix
--forcemakes the leg execute; it does not make the hash honest. The slice leg's hash stays blind to its dependency closure, so nothing in this repo fails when a slice leg's hash stops moving for an upstream source change. The first flight proposed exactly that pin and it is deliberately out of scope here: it would redmainon the day it lands unless it lands with a repair, and its natural home islint.yml, which is held behind an open PR in another lane.⛔ Merging
.github/workflows/**is outside the PM seat's arming channel:auto_mergeanswers HTTP 422 there, every time. This PR merges by a human. That 422 is a channel fact, not a tool fault — it is not to be retried, and this PR is not to be undrafted or armed by an agent seat.Local verification
Gate families derived on this tree with
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack .github/workflows/ci.yml turbo.json— 46 commands. Results are in the report on the card.No changeset: nothing published moves.
.github/workflows/ci.ymlappears in no package'sfiles[].Generated by Claude Code