Summary
The primary Tests workflow currently has two test shards, execution and general. The split was introduced in #2320/#2324 when the single test job took about 5m40s, with the goal of keeping the CI critical path below roughly five minutes.
That balance has drifted:
| Ubuntu run |
execution test step |
general test step |
general job |
| 30133679370, merge-group validation immediately before PR #4345 |
1m57s |
5m17s |
6m41s |
| 30137753678, merge-group validation of PR #4345 |
2m06s |
11m34s |
13m22s |
| 30143142340, recent merge group |
2m05s |
10m56s |
12m25s |
| 30144208946, recent PR |
2m06s |
11m47s |
13m16s |
Treat three total test runners as the default topology:
execution: unchanged
- one positive-select child of the current
general shard
general: the remaining catch-all
PR #4345 is the change associated with the runtime regression. Optimization issue #4356 — Memoize skill capability classification by semantic input is the gating fix. Do not lock the final directory assignment until #4356 is completed and the optimized suite is reprofiled. The current tests/recipe + tests/docs candidate is a starting point, not a fixed requirement.
If post-optimization evidence shows that three total runners cannot keep every test step below five minutes with reasonable headroom, this issue may use four total runners instead: unchanged execution, two positive-select children, and the catch-all general.
The three-runner target produces three Ubuntu test jobs and six Ubuntu/macOS jobs for stable-target pull requests and direct pushes to stable. The permitted four-runner fallback produces four and eight respectively. The current workflow does not apply the macOS expansion to merge-group events.
Current behavior
.github/workflows/tests.yml currently defines:
execution: tests/execution, contracts, core, planner, pipeline, migration, integration, plus root tests/test_*.py
general: all of tests/, minus the execution paths and root test files
Each shard runs task test-check with four xdist workers. On PRs targeting develop, conservative path filtering is applied inside the already-scoped shard. Other events, including merge groups, run the full shard.
The five-minute threshold is policy/history rather than an encoded timeout. It is documented in #2320. The repository does not persist per-test timing data or use an automated shard balancer; the current directory assignment has not been rebalanced since May.
Findings
The imbalance is in test execution, not setup
Recent execution test steps remain close to two minutes, while general is now close to eleven or twelve minutes. Both jobs spend roughly the same time installing dependencies, so setup does not explain the gap.
PR #4345 introduced a discrete runtime regression
Comparing the adjacent merge-group validations immediately before PR #4345 and for the PR itself:
- passing test count increased only from 22,968 to 22,996 (+28, about 0.12%)
- pytest duration increased from 312.83s to 689.64s (+376.81s, about 120.45%)
This establishes a sharp step change coincident with PR #4345. Optimization issue #4356 contains the causal investigation and proposed fix. Added test count alone cannot explain the change, and sharding must not substitute for completing that optimization.
tests/recipe is the natural load and failure boundary
A current local JUnit profile of the intended general paths produced the following aggregate testcase time after excluding root tests/test_*.py records:
| Partition |
Collected records |
Summed testcase time |
Share |
Pre-optimization recipe candidate (tests/recipe + tests/docs) |
6,246 |
563.2s |
56.7% |
| Pre-optimization catch-all remainder |
17,420 |
429.7s |
43.3% |
tests/recipe alone contributed 5,459 records and 561.9 summed seconds; tests/docs contributed 787 records and only 1.3 seconds. Test count is misleading here: the proposed recipe shard has fewer cases but more measured work.
Recipe is also a coherent diagnostic boundary. Its schema, semantic-rule, bundled-YAML, backend-composition, and dispatch-readiness tests commonly fail together for one recipe defect. The diagnostic profile's eight failures were all different assertions of the same remediation-fable validity problem. tests/recipe/conftest.py also provides module-scoped loaded-recipe fixtures and shared construction helpers.
Keep the directory intact. A file-level bin-packing split can get close to 50/50, but it would separate rule tests from bundled-recipe assertions that exercise the same rules, increase duplicated job-level collection/import setup, and require fragile filename exceptions whenever recipe tests are added.
A recipe child must include a conservative-filter sentinel
A recipe-only positive shard can collect zero selected tests on an unrelated develop PR after conservative filtering, causing pytest's no-tests exit.
If post-optimization timing retains recipe as a positive child, tests/docs/test_doc_counts.py is the least expensive existing sentinel. It is always injected by conservative filtering when the full docs directory is not selected. Adding the inexpensive tests/docs directory to that child:
- guarantees that the shard retains a conservative-filter sentinel
- remains functionally reasonable for recipe/documentation validation
- changes the measured balance only slightly (docs totaled about 1.3 summed seconds)
In the three-runner candidate, the catch-all retains arch, infra, and hooks, while execution already retains contracts, so all three shards have conservative always-run coverage. A four-runner topology must distribute additional existing always-run/sentinel coverage across both positive children and prove the resulting shard/filter intersections in tests; it must not add a runner that can exit with no tests.
Recommendation
Sequence the work:
- Complete optimization issue #4356.
- Reprofile the optimized full
general shard using clean, repeated measurements.
- Choose the smallest topology that gives every test step a credible sub-five-minute result with headroom.
Default to three total runners:
shard: [execution, <positive-child>, general]
Permit four total runners without reopening this issue if the post-optimization evidence requires it:
shard: [execution, <positive-child-a>, <positive-child-b>, general]
The current recipe + docs candidate remains the preferred three-runner starting point because it preserves the recipe failure domain and supplies a conservative-filter sentinel. Its membership must be recalculated against post-optimization timing rather than copied mechanically.
Use explicit positive lists for every non-catch-all child and preserve general as the complement. Use an explicit case branch and fail on an unknown shard name; do not allow an unknown value to fall through to the catch-all.
Keep these existing behaviors:
- root test files belong only to
execution
lint-imports runs only on execution
task test-check, xdist settings, marker exclusion, experimental-feature environment, and conservative filter activation are identical across shards
- filter artifact names retain both OS and shard, which is already collision-safe
- new top-level test directories automatically land in
general
No change should be needed in Taskfile.yml, the filter algorithm, or the test-filter manifest.
Expected effect and limit
The pre-optimization data cannot select the final topology. Using the 651-second CI run only as an explanatory anchor, the additive testcase-time model allocates:
- roughly 369 seconds to the proposed
recipe + docs shard
- roughly 282 seconds to the proposed catch-all
These are allocation estimates, not scheduling measurements. Summed testcase time does not model xdist packing, collection, fixture behavior, or CI variance; actual shard wall time must be measured in Actions.
Before optimization, three total runners do not support a promise that both general-child test steps will be below five minutes. Even a mathematically perfect two-way division of the current general work bottoms out at 325.5 seconds before duplicated collection/setup overhead.
That is why the optimization ticket is the gate for this issue. After the regression is corrected, a three-runner topology is expected to be sufficient, but the issue deliberately authorizes a fourth total runner if repeated post-optimization CI measurements show otherwise.
Use a planning target below 270 seconds per test step so normal CI variance does not immediately cross the 300-second ceiling. Accept three runners when the post-optimization partition meets that target or repeated Actions runs demonstrate adequate equivalent headroom. Otherwise use four.
Avoid splitting tests/recipe by filename until profiling proves it is necessary. The most balanced filename-based candidates cross functional and fixture boundaries and are harder to maintain.
Structural guard changes
Update tests/infra/test_ci_shard_config.py. The current guard checks existence and broad assignment but does not prove disjointness or correct variable-cardinality routing.
Add assertions that:
- the chosen matrix contains
execution, general, and either one or two explicitly named positive children
- the final topology decision is three or four total runners, not an accidental duplicate catch-all
- execution directory and root-file ownership is unchanged
- explicit shard directory sets exist, contain tests, and are pairwise disjoint
- every repository-convention
test_*.py file resolves to exactly one shard
- a ratchet rejects pytest's alternate
*_test.py filename form unless shard ownership is explicitly extended to support it
general ignores execution paths, every positive-child path, and root files
- a synthetic new top-level test directory resolves to
general
- for an unrelated conservative change, each shard's actual collected file set has a nonempty intersection with
build_test_scope()
- empty-change and
FullRunReason/fail-open cases remain nonempty and shard-bounded
- unknown shard values fail
lint-imports remains execution-only
- artifact names include both matrix axes
Acceptance criteria
Cost and rollout notes
- The three-runner target increases ordinary test jobs from two to three and stable-target PR/direct-stable-push jobs from four to six.
- The permitted four-runner fallback increases them to four and eight respectively.
- Each additional job duplicates dependency installation and job-level collection/setup, so aggregate runner time is expected to increase and must be measured after rollout even though critical-path latency should fall.
- The current artifact naming already includes
${os}-${shard} and needs no conceptual redesign.
- A timing sidecar would require additional failure-preserving shell and artifact logic. Track that observability work separately; this issue should use existing Actions step/job durations for rollout evidence.
Data-quality note
The local JUnit run was used only for relative allocation. It unintentionally collected root test files as well; those 572 records and 3.8 summed seconds were excluded from the table above. One collection-skip record had an empty classname and was classified from its name. The run also observed eight related remediation-fable recipe failures, so it is not a green-gate result. Final validation must use clean CI runs.
Requirements
REQ-SHRD-001: The execution shard must retain its current directory set and exclusive ownership of root tests/test_*.py files.
REQ-SHRD-002: The test matrix must support three total shards by default and four total shards when post-optimization evidence demonstrates that the three-shard topology lacks sufficient sub-five-minute headroom.
REQ-SHRD-003: Every explicit child shard must be pairwise disjoint, and the general shard must remain the complement that owns every otherwise-unassigned repository-convention test file.
REQ-SHRD-004: Unknown shard identifiers must fail path computation instead of falling through to the general catch-all.
REQ-FILT-001: Every selected shard topology must retain at least one collected test after intersection with conservative filter scope for unrelated changes.
REQ-FILT-002: Empty-change and fail-open filter outcomes must remain nonempty and bounded by each shard's configured collection paths.
REQ-FILT-003: New top-level test directories must default to the general catch-all unless deliberately assigned to an explicit child.
REQ-CI-001: All shards must preserve the existing task test-check, xdist, marker, feature-environment, and filter-activation behavior.
REQ-CI-002: Import linting must continue to run exactly once on the execution shard.
REQ-CI-003: Filter-stat artifact names must remain unique across operating-system and shard matrix axes on conservatively filtered develop pull requests.
REQ-CI-004: Stable-target pull requests and direct stable pushes must expand the selected shard topology across Ubuntu and macOS without changing merge-group operating-system policy.
REQ-PERF-001: Optimization issue #4356 must be completed before final shard membership is selected.
REQ-PERF-002: The three-versus-four-runner decision must be supported by post-optimization timing evidence from at least three representative Actions runs.
REQ-PERF-003: Every selected shard must remain below 300 seconds of test execution in the representative runs, with a 270-second planning target or equivalent demonstrated variance headroom.
REQ-PERF-004: The implementation pull request must report critical-path and aggregate runner-time changes against the historical baseline runs identified in this issue.
REQ-GUARD-001: Structural tests must prove that all supported test filename forms are assigned exactly once across the selected shards.
REQ-GUARD-002: Structural tests must reject unsupported *_test.py files unless shard ownership is explicitly extended for that pytest filename form.
Summary
The primary Tests workflow currently has two test shards,
executionandgeneral. The split was introduced in #2320/#2324 when the single test job took about 5m40s, with the goal of keeping the CI critical path below roughly five minutes.That balance has drifted:
executiontest stepgeneraltest stepgeneraljobTreat three total test runners as the default topology:
execution: unchangedgeneralshardgeneral: the remaining catch-allPR #4345 is the change associated with the runtime regression. Optimization issue #4356 — Memoize skill capability classification by semantic input is the gating fix. Do not lock the final directory assignment until #4356 is completed and the optimized suite is reprofiled. The current
tests/recipe+tests/docscandidate is a starting point, not a fixed requirement.If post-optimization evidence shows that three total runners cannot keep every test step below five minutes with reasonable headroom, this issue may use four total runners instead: unchanged
execution, two positive-select children, and the catch-allgeneral.The three-runner target produces three Ubuntu test jobs and six Ubuntu/macOS jobs for stable-target pull requests and direct pushes to
stable. The permitted four-runner fallback produces four and eight respectively. The current workflow does not apply the macOS expansion to merge-group events.Current behavior
.github/workflows/tests.ymlcurrently defines:execution:tests/execution,contracts,core,planner,pipeline,migration,integration, plus roottests/test_*.pygeneral: all oftests/, minus the execution paths and root test filesEach shard runs
task test-checkwith four xdist workers. On PRs targetingdevelop, conservative path filtering is applied inside the already-scoped shard. Other events, including merge groups, run the full shard.The five-minute threshold is policy/history rather than an encoded timeout. It is documented in #2320. The repository does not persist per-test timing data or use an automated shard balancer; the current directory assignment has not been rebalanced since May.
Findings
The imbalance is in test execution, not setup
Recent
executiontest steps remain close to two minutes, whilegeneralis now close to eleven or twelve minutes. Both jobs spend roughly the same time installing dependencies, so setup does not explain the gap.PR #4345 introduced a discrete runtime regression
Comparing the adjacent merge-group validations immediately before PR #4345 and for the PR itself:
This establishes a sharp step change coincident with PR #4345. Optimization issue #4356 contains the causal investigation and proposed fix. Added test count alone cannot explain the change, and sharding must not substitute for completing that optimization.
tests/recipeis the natural load and failure boundaryA current local JUnit profile of the intended
generalpaths produced the following aggregate testcase time after excluding roottests/test_*.pyrecords:recipecandidate (tests/recipe+tests/docs)tests/recipealone contributed 5,459 records and 561.9 summed seconds;tests/docscontributed 787 records and only 1.3 seconds. Test count is misleading here: the proposed recipe shard has fewer cases but more measured work.Recipe is also a coherent diagnostic boundary. Its schema, semantic-rule, bundled-YAML, backend-composition, and dispatch-readiness tests commonly fail together for one recipe defect. The diagnostic profile's eight failures were all different assertions of the same
remediation-fablevalidity problem.tests/recipe/conftest.pyalso provides module-scoped loaded-recipe fixtures and shared construction helpers.Keep the directory intact. A file-level bin-packing split can get close to 50/50, but it would separate rule tests from bundled-recipe assertions that exercise the same rules, increase duplicated job-level collection/import setup, and require fragile filename exceptions whenever recipe tests are added.
A
recipechild must include a conservative-filter sentinelA recipe-only positive shard can collect zero selected tests on an unrelated
developPR after conservative filtering, causing pytest's no-tests exit.If post-optimization timing retains
recipeas a positive child,tests/docs/test_doc_counts.pyis the least expensive existing sentinel. It is always injected by conservative filtering when the full docs directory is not selected. Adding the inexpensivetests/docsdirectory to that child:In the three-runner candidate, the catch-all retains
arch,infra, andhooks, whileexecutionalready retainscontracts, so all three shards have conservative always-run coverage. A four-runner topology must distribute additional existing always-run/sentinel coverage across both positive children and prove the resulting shard/filter intersections in tests; it must not add a runner that can exit with no tests.Recommendation
Sequence the work:
generalshard using clean, repeated measurements.Default to three total runners:
Permit four total runners without reopening this issue if the post-optimization evidence requires it:
The current
recipe+docscandidate remains the preferred three-runner starting point because it preserves the recipe failure domain and supplies a conservative-filter sentinel. Its membership must be recalculated against post-optimization timing rather than copied mechanically.Use explicit positive lists for every non-catch-all child and preserve
generalas the complement. Use an explicitcasebranch and fail on an unknown shard name; do not allow an unknown value to fall through to the catch-all.Keep these existing behaviors:
executionlint-importsruns only onexecutiontask test-check, xdist settings, marker exclusion, experimental-feature environment, and conservative filter activation are identical across shardsgeneralNo change should be needed in
Taskfile.yml, the filter algorithm, or the test-filter manifest.Expected effect and limit
The pre-optimization data cannot select the final topology. Using the 651-second CI run only as an explanatory anchor, the additive testcase-time model allocates:
recipe+docsshardThese are allocation estimates, not scheduling measurements. Summed testcase time does not model xdist packing, collection, fixture behavior, or CI variance; actual shard wall time must be measured in Actions.
Before optimization, three total runners do not support a promise that both general-child test steps will be below five minutes. Even a mathematically perfect two-way division of the current
generalwork bottoms out at 325.5 seconds before duplicated collection/setup overhead.That is why the optimization ticket is the gate for this issue. After the regression is corrected, a three-runner topology is expected to be sufficient, but the issue deliberately authorizes a fourth total runner if repeated post-optimization CI measurements show otherwise.
Use a planning target below 270 seconds per test step so normal CI variance does not immediately cross the 300-second ceiling. Accept three runners when the post-optimization partition meets that target or repeated Actions runs demonstrate adequate equivalent headroom. Otherwise use four.
Avoid splitting
tests/recipeby filename until profiling proves it is necessary. The most balanced filename-based candidates cross functional and fixture boundaries and are harder to maintain.Structural guard changes
Update
tests/infra/test_ci_shard_config.py. The current guard checks existence and broad assignment but does not prove disjointness or correct variable-cardinality routing.Add assertions that:
execution,general, and either one or two explicitly named positive childrentest_*.pyfile resolves to exactly one shard*_test.pyfilename form unless shard ownership is explicitly extended to support itgeneralignores execution paths, every positive-child path, and root filesgeneralbuild_test_scope()FullRunReason/fail-open cases remain nonempty and shard-boundedlint-importsremains execution-onlyAcceptance criteria
executioncontinues to collect exactly its current directories and root test files.generalremains a catch-all and does not duplicate files owned by any positive child orexecution.developPR.test_*.pyfile is pairwise disjoint and collectively exhaustive across the selected shards, and a ratchet prevents unsupported*_test.pyfiles.developPRs, filter-stat artifacts remain available with unique OS/shard names.Cost and rollout notes
${os}-${shard}and needs no conceptual redesign.Data-quality note
The local JUnit run was used only for relative allocation. It unintentionally collected root test files as well; those 572 records and 3.8 summed seconds were excluded from the table above. One collection-skip record had an empty
classnameand was classified from itsname. The run also observed eight relatedremediation-fablerecipe failures, so it is not a green-gate result. Final validation must use clean CI runs.Requirements
REQ-SHRD-001: The execution shard must retain its current directory set and exclusive ownership of root
tests/test_*.pyfiles.REQ-SHRD-002: The test matrix must support three total shards by default and four total shards when post-optimization evidence demonstrates that the three-shard topology lacks sufficient sub-five-minute headroom.
REQ-SHRD-003: Every explicit child shard must be pairwise disjoint, and the general shard must remain the complement that owns every otherwise-unassigned repository-convention test file.
REQ-SHRD-004: Unknown shard identifiers must fail path computation instead of falling through to the general catch-all.
REQ-FILT-001: Every selected shard topology must retain at least one collected test after intersection with conservative filter scope for unrelated changes.
REQ-FILT-002: Empty-change and fail-open filter outcomes must remain nonempty and bounded by each shard's configured collection paths.
REQ-FILT-003: New top-level test directories must default to the general catch-all unless deliberately assigned to an explicit child.
REQ-CI-001: All shards must preserve the existing
task test-check, xdist, marker, feature-environment, and filter-activation behavior.REQ-CI-002: Import linting must continue to run exactly once on the execution shard.
REQ-CI-003: Filter-stat artifact names must remain unique across operating-system and shard matrix axes on conservatively filtered
developpull requests.REQ-CI-004: Stable-target pull requests and direct stable pushes must expand the selected shard topology across Ubuntu and macOS without changing merge-group operating-system policy.
REQ-PERF-001: Optimization issue #4356 must be completed before final shard membership is selected.
REQ-PERF-002: The three-versus-four-runner decision must be supported by post-optimization timing evidence from at least three representative Actions runs.
REQ-PERF-003: Every selected shard must remain below 300 seconds of test execution in the representative runs, with a 270-second planning target or equivalent demonstrated variance headroom.
REQ-PERF-004: The implementation pull request must report critical-path and aggregate runner-time changes against the historical baseline runs identified in this issue.
REQ-GUARD-001: Structural tests must prove that all supported test filename forms are assigned exactly once across the selected shards.
REQ-GUARD-002: Structural tests must reject unsupported
*_test.pyfiles unless shard ownership is explicitly extended for that pytest filename form.