From 8814e75cd65bb0b2dbf4401ca2b7d93ffafce44f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 13 Jul 2026 09:56:30 -0700 Subject: [PATCH] perf(ci): start long node shards first (#106522) --- docs/ci.md | 1 + scripts/lib/ci-node-test-plan.mjs | 17 ++++++++++++++++- test/scripts/ci-node-test-plan.test.ts | 4 ++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/docs/ci.md b/docs/ci.md index 486513ef7f62..ad8a01ad4d1f 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -103,6 +103,7 @@ The slowest Node test families are split or balanced so each job stays small wit - Agentic gateway/server (control-plane) configs split across chat, auth, model, HTTP/plugin, runtime, and startup lanes instead of waiting on built artifacts. - Normal CI packs only isolated infra include-pattern shards into deterministic bundles of at most 64 test files, reducing the Node matrix without merging non-isolated command/cron, stateful agents-core, or gateway/server suites. Heavy fixed suites stay on 8 vCPU while the bundled and lower-weight lanes use 4 vCPU. - Pull requests on the canonical repository use a compact admission plan: the same per-config groups run in isolated subprocesses, currently 14 Node test jobs instead of the 74-job full matrix. Whole-config batches retain their 120-minute timeout, and the serial tooling config is striped across three PR-only groups; `main` pushes, manual dispatches, and release gates retain the full matrix. +- The full Node matrix admits the consistently slow serial tooling and auto-reply command shards first. This keeps the 28-job cap while preventing short alphabetical groups from pushing critical-path work into a later wave. - Broad browser, QA, media, and miscellaneous plugin tests use their dedicated Vitest configs instead of the shared plugin catch-all. Include-pattern shards record timing entries using the CI shard name, so `.artifacts/vitest-shard-timings.json` can distinguish a whole config from a filtered shard. - `check-additional-*` stripes the supplemental boundary guard list (`scripts/run-additional-boundary-checks.mjs`) into one prompt-heavy shard (`check-additional-boundaries-a`, which includes the Codex prompt snapshot drift check) and one combined shard for the remaining stripes (`check-additional-boundaries-bcd`), each running independent guards concurrently and printing per-check timings. Package-boundary compile/canary work stays together, and runtime topology architecture runs separately from the gateway watch coverage embedded in `build-artifacts`. - Gateway watch, channel tests, and the core support-boundary shard run concurrently inside `build-artifacts` after `dist/` and `dist-runtime/` are already built. diff --git a/scripts/lib/ci-node-test-plan.mjs b/scripts/lib/ci-node-test-plan.mjs index 7a952fb072c7..129f4fc6e5c3 100644 --- a/scripts/lib/ci-node-test-plan.mjs +++ b/scripts/lib/ci-node-test-plan.mjs @@ -35,6 +35,12 @@ const TOOLING_CONFIG = "test/vitest/vitest.tooling.config.ts"; const TOOLING_DOCKER_TEST_FILE = "test/scripts/docker-build-helper.test.ts"; const TOOLING_ISOLATED_CONFIG = "test/vitest/vitest.tooling-isolated.config.ts"; const TOOLING_ISOLATED_TEST_FILE = "test/scripts/openclaw-e2e-instance.test.ts"; +// The full matrix is capped at 28 jobs. Admit the consistently slow serial +// shards first so short alphabetical groups cannot leave them on the tail. +const FULL_NODE_TEST_ADMISSION_PRIORITY = new Map([ + ["core-tooling", 0], + ["auto-reply-reply-commands", 1], +]); // Commands and cron run non-isolated, so keep their split shards as separate // processes. Combining their include lists can retain test state across groups. const BUNDLEABLE_NODE_TEST_CONFIGS = new Set(["test/vitest/vitest.infra.config.ts"]); @@ -1013,6 +1019,15 @@ function bundleNameForConfigs(configs) { .replace(/[^a-z0-9-]+/giu, "-"); } +function compareFullNodeTestAdmissionOrder(a, b) { + const fallbackPriority = FULL_NODE_TEST_ADMISSION_PRIORITY.size; + return ( + (FULL_NODE_TEST_ADMISSION_PRIORITY.get(a.shardName) ?? fallbackPriority) - + (FULL_NODE_TEST_ADMISSION_PRIORITY.get(b.shardName) ?? fallbackPriority) || + a.checkName.localeCompare(b.checkName) + ); +} + function createStripedBatches(values, batchCount) { const batches = Array.from({ length: batchCount }, () => []); for (const [index, value] of values.entries()) { @@ -1144,7 +1159,7 @@ export function createNodeTestShardBundles(options = {}) { } } - return [...unbundled, ...bundled].toSorted((a, b) => a.checkName.localeCompare(b.checkName)); + return [...unbundled, ...bundled].toSorted(compareFullNodeTestAdmissionOrder); } function createCompactNodeTestShardBundles(options = {}) { diff --git a/test/scripts/ci-node-test-plan.test.ts b/test/scripts/ci-node-test-plan.test.ts index 18be486f2ff3..d19312dc30a9 100644 --- a/test/scripts/ci-node-test-plan.test.ts +++ b/test/scripts/ci-node-test-plan.test.ts @@ -145,6 +145,10 @@ describe("scripts/lib/ci-node-test-plan.mjs", () => { ).toBe(true); expect(bundled.every((shard) => shard.runner?.startsWith("blacksmith-"))).toBe(true); expect(bundled).toEqual(createNodeTestShardBundles({ includeReleaseOnlyPluginShards: false })); + expect(bundled.slice(0, 2).map((shard) => shard.shardName)).toEqual([ + "core-tooling", + "auto-reply-reply-commands", + ]); expect(bundled.find((shard) => shard.shardName === "core-unit-fast")?.runner).toBe( DEFAULT_NODE_TEST_RUNNER, );