mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 17:53:39 +00:00
Keep the Gateway isolated/database-worker cohort at its existing two-worker budget so cold startup has room within unchanged test deadlines. Preserve former eight-worker complete generations as conservative timing floors without relabeling them as current measurements. Validation: 325 owning tests, selected changed checks, and fresh managed review. Generated job counts and coverage are unchanged. Exact changed CI remains required.
5427 lines
206 KiB
TypeScript
5427 lines
206 KiB
TypeScript
// Builds CI node/Vitest shard plans from the full suite configuration.
|
|
import { statSync } from "node:fs";
|
|
import { matchesGlob, relative, resolve } from "node:path";
|
|
import {
|
|
agentVitestProjectOwners,
|
|
embeddedAgentVitestProjectOwners,
|
|
} from "../../test/vitest/vitest.agents-paths.mjs";
|
|
import {
|
|
databaseWorkerCoreTestFiles,
|
|
isDatabaseWorkerCoreTestFile,
|
|
} from "../../test/vitest/vitest.database-worker-core-paths.mjs";
|
|
import {
|
|
gatewayDatabaseWorkerTestFiles,
|
|
gatewayPluginTestFiles,
|
|
gatewayServerExcludedTestFiles,
|
|
gatewayServerIsolatedTestFiles,
|
|
gatewayServerSerialTestFiles,
|
|
isGatewayServerBackedHttpTestFile,
|
|
isGatewayServerTestFile,
|
|
} from "../../test/vitest/vitest.gateway-server-paths.mjs";
|
|
import { startupCorpusTestFiles } from "../../test/vitest/vitest.startup-corpus-paths.mjs";
|
|
import { fullSuiteVitestShards } from "../../test/vitest/vitest.test-shards.mjs";
|
|
import { toolingIsolatedTestFiles } from "../../test/vitest/vitest.tooling-isolated-paths.mjs";
|
|
import { uiIsolatedTestFiles } from "../../test/vitest/vitest.ui-isolated-paths.mjs";
|
|
import {
|
|
controlUiE2eTestGlobs,
|
|
isPluginControlUiPath,
|
|
isUiBrowserTestFile,
|
|
isUiTestTarget,
|
|
uiTimingTestFiles,
|
|
uiE2ePrebuiltParallelTestFiles,
|
|
uiE2eRealGatewayTestFiles,
|
|
} from "../../test/vitest/vitest.ui-paths.mjs";
|
|
import {
|
|
getUnitFastIsolatedTestFiles,
|
|
getUnitFastTestFiles,
|
|
getUnitFastTestFilesForIncludePatterns,
|
|
getUnitFastTimerTestFiles,
|
|
} from "../../test/vitest/vitest.unit-fast-paths.mjs";
|
|
import {
|
|
boundaryTestFiles,
|
|
filterUnitConfigTestFiles,
|
|
} from "../../test/vitest/vitest.unit-paths.mjs";
|
|
import {
|
|
buildVitestRunPlans,
|
|
isTestFileTarget,
|
|
isToolingTestOwnerPath,
|
|
resolveAffectedTestsFromImportGraph,
|
|
resolveChangedTestTargetPlan,
|
|
} from "../test-projects.test-support.mts";
|
|
import {
|
|
COMMANDS_PARALLEL_TIMING_SUFFIX,
|
|
COMMANDS_RUNTIME_GROUP,
|
|
estimateSerialCommandSeconds,
|
|
estimateLegacyCommandStripeSeconds,
|
|
commandFileSecondsFloor,
|
|
createAgenticCommandSplitShards,
|
|
isParallelCommandsGroup,
|
|
estimateCommandWorkerSeconds,
|
|
} from "./ci-command-test-plan.mts";
|
|
import { rebalanceMeasuredSerialJobs } from "./ci-measured-compact-packing.mts";
|
|
import {
|
|
COMPACT_EMBEDDED_BASE_GROUP_NAME,
|
|
canSplitWholeConfigGroup,
|
|
listScopedOwnerTestFiles,
|
|
listNodeTestConfigFiles,
|
|
listWholeConfigFiles,
|
|
listWholeConfigSplitFiles,
|
|
} from "./ci-node-test-inventory.mts";
|
|
import { resolvePolicyTestTargets } from "./ci-policy-test-watch.mts";
|
|
import {
|
|
isCiProofTestFile,
|
|
isPrExemptRuntimeTestFile,
|
|
isReleaseOnlyRuntimeTestFile,
|
|
} from "./ci-proof-test-inventory.mts";
|
|
import { rebalanceRuntimeTestJobs } from "./ci-runtime-test-placement.mts";
|
|
import {
|
|
NATIVE_SOLO_TIMING_PROFILE,
|
|
createNativeSoloTimingKey,
|
|
isRuntimePlacementIncludePatterns,
|
|
} from "./ci-test-timings-schema.mts";
|
|
import {
|
|
readCompactGroupTimings,
|
|
readCompleteSplitGenerationSeconds,
|
|
readRuntimePlacementTimings,
|
|
readToolingFileTimings,
|
|
resolveRuntimePlacementSeconds,
|
|
} from "./ci-test-timings.mts";
|
|
import { isStripeEligibleTestFile, listTrackedTestFiles } from "./list-test-files.mts";
|
|
import { isExclusiveCiTestConfig } from "./local-check-runtime.mts";
|
|
import { readPositiveEnvInt } from "./numeric-options.mjs";
|
|
import {
|
|
listVitestRuntimeConsumerFiles,
|
|
mergeVitestPretestBuildModes,
|
|
resolveVitestPretestBuildMode,
|
|
type VitestPretestBuildMode as NodeTestPretestBuildMode,
|
|
} from "./vitest-build-prerequisites.mts";
|
|
import {
|
|
COMPACT_GITHUB_GROUP_SECONDS_SCALE,
|
|
COMPACT_HYBRID_GROUP_SECONDS_SCALE,
|
|
VITEST_PRETEST_BUILD_SECONDS,
|
|
createCompactSplitTimingGeneration,
|
|
estimateVitestTestFileSeconds as stripeFileWeight,
|
|
estimateVitestToolingFileSeconds as toolingFileWeight,
|
|
parseCompactSplitTimingKey,
|
|
} from "./vitest-shard-metadata.mts";
|
|
|
|
export { isToolingTestOwnerPath } from "../test-projects.test-support.mts";
|
|
|
|
export type NodeTestShardGroup = {
|
|
shard_name: string;
|
|
timing_key?: string;
|
|
configs: string[];
|
|
includePatterns?: string[];
|
|
pretestBuildMode?: NodeTestPretestBuildMode;
|
|
requiresDist: boolean;
|
|
runner: string;
|
|
env?: Record<string, string>;
|
|
fallbackMaxWorkers?: number;
|
|
minTotalMemoryBytes?: number;
|
|
};
|
|
|
|
function compactGroupTimingKey(group: NodeTestShardGroup): string {
|
|
return group.timing_key ?? group.shard_name;
|
|
}
|
|
|
|
export type NodeTestShard = {
|
|
checkName: string;
|
|
shardName: string;
|
|
timing_key?: string;
|
|
configs: string[];
|
|
runner: string;
|
|
requiresDist: boolean;
|
|
pretestBuildMode?: NodeTestPretestBuildMode;
|
|
includePatterns?: string[];
|
|
env?: Record<string, string>;
|
|
groups?: NodeTestShardGroup[];
|
|
timeoutMinutes?: number;
|
|
planConcurrency?: number;
|
|
predictedSeconds?: number;
|
|
predictedTestSeconds?: number;
|
|
};
|
|
|
|
type NodeTestPlanOptions = {
|
|
changedPaths?: readonly string[];
|
|
includeReleaseOnlyPluginShards?: boolean;
|
|
includeProofTests?: boolean;
|
|
includeReleaseOnlyToolingShards?: boolean;
|
|
includeReleaseOnlyRuntimeTests?: boolean;
|
|
includePrExemptRuntimeTests?: boolean;
|
|
compact?: boolean;
|
|
compactMode?: CompactNodeTestPlanMode;
|
|
compactGroupCount?: number;
|
|
compactWholeGroupCount?: number;
|
|
compactNodeJobCap?: number;
|
|
runnerBackend?: string;
|
|
};
|
|
|
|
export type RuntimeTestSelection = Pick<
|
|
NodeTestPlanOptions,
|
|
"changedPaths" | "includeReleaseOnlyRuntimeTests" | "includePrExemptRuntimeTests"
|
|
>;
|
|
|
|
const prExemptChangedTargets = new WeakMap<readonly string[], Map<string, ReadonlySet<string>>>();
|
|
|
|
function resolvePrExemptChangedTargets(changedPaths: readonly string[], cwd: string) {
|
|
let byCwd = prExemptChangedTargets.get(changedPaths);
|
|
const cached = byCwd?.get(cwd);
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
const paths = [...changedPaths];
|
|
// Explicit source owners cover spawned processes and filename readers;
|
|
// transitive imports cover subjects reached through shared integration fixtures.
|
|
const targets = new Set(
|
|
[
|
|
...resolvePolicyTestTargets(paths),
|
|
...resolveChangedTestTargetPlan(paths, {
|
|
broad: false,
|
|
combineSiblingWithImportGraph: true,
|
|
cwd,
|
|
forceFullImportGraph: true,
|
|
includeExtensionImpact: false,
|
|
resolveAliases: true,
|
|
runtimeOnly: true,
|
|
}).targets,
|
|
...resolveAffectedTestsFromImportGraph(paths, cwd, {
|
|
tooling: true,
|
|
forceFull: true,
|
|
resolveAliases: true,
|
|
runtimeOnly: true,
|
|
}),
|
|
].filter(isPrExemptRuntimeTestFile),
|
|
);
|
|
if (!byCwd) {
|
|
byCwd = new Map();
|
|
prExemptChangedTargets.set(changedPaths, byCwd);
|
|
}
|
|
byCwd.set(cwd, targets);
|
|
return targets;
|
|
}
|
|
|
|
export function isRuntimeTestFileIncluded(
|
|
file: string,
|
|
options: RuntimeTestSelection,
|
|
cwd = process.cwd(),
|
|
): boolean {
|
|
const releaseOnly =
|
|
options.includeReleaseOnlyRuntimeTests === false && isReleaseOnlyRuntimeTestFile(file);
|
|
const prExempt = options.includePrExemptRuntimeTests === false && isPrExemptRuntimeTestFile(file);
|
|
if (!releaseOnly && !prExempt) {
|
|
return true;
|
|
}
|
|
if (statSync(resolve(cwd, file), { throwIfNoEntry: false })?.isFile() !== true) {
|
|
return false;
|
|
}
|
|
const changedPaths = options.changedPaths ?? [];
|
|
if (changedPaths.includes(file)) {
|
|
return true;
|
|
}
|
|
return (
|
|
!releaseOnly &&
|
|
changedPaths.length > 0 &&
|
|
resolvePrExemptChangedTargets(changedPaths, cwd).has(file)
|
|
);
|
|
}
|
|
|
|
export function resolveStartupCorpusTestFiles(options: RuntimeTestSelection = {}): string[] {
|
|
return startupCorpusTestFiles.filter((file) => isRuntimeTestFileIncluded(file, options));
|
|
}
|
|
|
|
export function hasCompleteStartupCorpusCoverage(
|
|
shards: readonly {
|
|
requiresDist: boolean;
|
|
targets?: readonly string[];
|
|
groups?: readonly NodeTestShardGroup[];
|
|
}[],
|
|
expectedFiles: readonly string[] = startupCorpusTestFiles,
|
|
): boolean {
|
|
// Only explicit, unfiltered file owners prove the corpus is complete. A
|
|
// config name or native shard can still execute just part of the matrix.
|
|
const groups = shards.flatMap((shard) =>
|
|
!shard.requiresDist && !shard.targets?.length ? (shard.groups ?? []) : [],
|
|
);
|
|
return (
|
|
expectedFiles.length > 0 &&
|
|
expectedFiles.every((file) =>
|
|
groups.some(
|
|
(group) =>
|
|
group.configs.length === 1 &&
|
|
group.configs[0] === "test/vitest/vitest.runtime-config.config.ts" &&
|
|
Object.keys(group.env ?? {}).every((key) => key === "OPENCLAW_VITEST_MAX_WORKERS") &&
|
|
group.includePatterns?.includes(file),
|
|
),
|
|
)
|
|
);
|
|
}
|
|
|
|
type CompactNodeTestPlanMode = "pull-request" | "push";
|
|
|
|
function includesReleaseOnlyTooling(options: NodeTestPlanOptions): boolean {
|
|
return (
|
|
options.includeReleaseOnlyToolingShards !== false ||
|
|
(options.changedPaths ?? []).some(isToolingTestOwnerPath)
|
|
);
|
|
}
|
|
|
|
export type CompactNodeTestShard = Omit<NodeTestShard, "configs" | "groups"> & {
|
|
groups: NodeTestShardGroup[];
|
|
};
|
|
|
|
type NodeTestSplitShard = Omit<NodeTestShard, "checkName" | "runner" | "pretestBuildMode"> & {
|
|
includeExternalConfigs?: boolean;
|
|
runner?: string;
|
|
};
|
|
|
|
const EXCLUDED_FULL_SUITE_SHARDS = new Set([
|
|
"test/vitest/vitest.full-core-contracts.config.ts",
|
|
"test/vitest/vitest.full-core-bundled.config.ts",
|
|
"test/vitest/vitest.full-extensions.config.ts",
|
|
]);
|
|
|
|
export const SOURCE_CHANNEL_TEST_POLICY = {
|
|
config: "test/vitest/vitest.channels.config.ts",
|
|
env: {
|
|
NODE_OPTIONS: "--max-old-space-size=8192",
|
|
OPENCLAW_VITEST_MAX_WORKERS: "1",
|
|
},
|
|
} as const;
|
|
|
|
const EXCLUDED_PROJECT_CONFIGS = new Set([
|
|
SOURCE_CHANNEL_TEST_POLICY.config,
|
|
// checks-ui owns the Chromium project; Node stripes retain Node-driven Playwright tests.
|
|
"test/vitest/vitest.ui-browser.config.ts",
|
|
]);
|
|
const DEFAULT_NODE_TEST_RUNNER = "blacksmith-8vcpu-ubuntu-2404";
|
|
const BUNDLED_NODE_TEST_RUNNER = "blacksmith-4vcpu-ubuntu-2404";
|
|
const EXTRA_LARGE_NODE_TEST_RUNNER = "blacksmith-32vcpu-ubuntu-2404";
|
|
// Startup-core transforms the broad gateway graph before its assertions run.
|
|
// Keep enough CPU here to avoid spending minutes in Vitest imports on 4 vCPU.
|
|
const GATEWAY_STARTUP_CORE_RUNNER = DEFAULT_NODE_TEST_RUNNER;
|
|
// Fail the known warm-cache startup stall at its existing scoped deadline.
|
|
const GATEWAY_STARTUP_HEALTH_RUNTIME_ENV = {
|
|
OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS: "60000",
|
|
};
|
|
// The first embedded-agent file owns 157 serial tests and can stay quiet for
|
|
// more than five minutes on a cold GitHub-hosted fork runner. Keep the outer
|
|
// watchdog above the scoped 600-second hook budget so it cannot preempt Vitest.
|
|
const AGENTS_EMBEDDED_AGENT_ENV = {
|
|
OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS: "660000",
|
|
};
|
|
const COMPACT_EMBEDDED_GROUP_NAMES = [
|
|
COMPACT_EMBEDDED_BASE_GROUP_NAME,
|
|
"agentic-agents-embedded-incomplete-turn",
|
|
"agentic-agents-embedded-overflow-compaction",
|
|
"agentic-agents-embedded-run",
|
|
];
|
|
const MAX_BUNDLED_NODE_TEST_PATTERNS = 64;
|
|
// Compact bundles trade a little serial work for fewer ephemeral runner registrations.
|
|
// Keep runner classes and subprocess isolation intact while bounding each combined job.
|
|
// Settle the original 360s placement before compacting only its parallel rows.
|
|
// Serial jobs retain 200s/276s caps; expanded serial jobs retain 210s.
|
|
const COMPACT_LARGE_NODE_TEST_JOB_SECONDS = 200;
|
|
const COMPACT_SMALL_NODE_TEST_JOB_SECONDS = 276;
|
|
const COMPACT_PARALLEL_NODE_TEST_JOB_SECONDS = 360;
|
|
const COMPACT_SERIAL_NODE_TEST_JOB_SECONDS = 300;
|
|
const COMPACT_FINAL_PARALLEL_NODE_TEST_JOB_SECONDS = 500;
|
|
const COMPACT_EXPANDED_NODE_TEST_JOB_SECONDS = 210;
|
|
// Runtime preparation is charged once beside the same serial test budget.
|
|
// This is admission, never a test deadline.
|
|
const COMPACT_HYBRID_RUNTIME_JOB_SECONDS =
|
|
COMPACT_SERIAL_NODE_TEST_JOB_SECONDS + VITEST_PRETEST_BUILD_SECONDS.runtime;
|
|
// Split groups above this hosted prediction before packing. Hybrid reuses the
|
|
// hosted-derived splits so retries cannot reunite an oversized hosted group.
|
|
const COMPACT_GITHUB_MAX_PREDICTED_SECONDS = 150;
|
|
// Hosted run 35477045216 timed out after an hour on a 203-file serial stripe;
|
|
// its 196-file sibling took 2867s. Bound admission independently of stale costs.
|
|
const COMPACT_HOSTED_STORAGE_STATE_MAX_FILES = 64;
|
|
// Hourly hosted run 35983526919 spent ~25 minutes in each of these owners.
|
|
// Bound the main-tier work independently of stale whole-owner timing estimates.
|
|
const COMPACT_HOSTED_MAIN_MAX_FILES = new Map([
|
|
["agentic-control-plane-agent-chat", 30],
|
|
["agentic-gateway-methods", 96],
|
|
]);
|
|
const HOSTED_MAIN_UPDATE_TEST = "src/cli/update-cli.test.ts";
|
|
// Trusted forks can use the GitHub profile on Blacksmith. Every compact
|
|
// profile must fit the same runner-registration allowance.
|
|
const COMPACT_NODE_TEST_JOB_CAP = 90;
|
|
const COMPACT_GITHUB_NODE_TEST_JOB_CAP = 96;
|
|
const COMPACT_NODE_TEST_JOB_GROUPS = 10;
|
|
const COMPACT_TOOLING_NODE_TEST_GROUPS = 16;
|
|
const COMPACT_WHOLE_NODE_TEST_TIMEOUT_MINUTES = 120;
|
|
// Keep capacity with the workload when packing changes; hosted stripes follow
|
|
// their parent's profile policy without changing test or worker boundaries.
|
|
const COMPACT_NODE_TEST_OWNER_RUNNERS = new Map([
|
|
[
|
|
"blacksmith",
|
|
new Map([
|
|
["agentic-agents-core-isolated", DEFAULT_NODE_TEST_RUNNER],
|
|
["agentic-agents-support", EXTRA_LARGE_NODE_TEST_RUNNER],
|
|
["agentic-cli", DEFAULT_NODE_TEST_RUNNER],
|
|
["agentic-commands-doctor", DEFAULT_NODE_TEST_RUNNER],
|
|
["agentic-commands-doctor-sessions-cron", DEFAULT_NODE_TEST_RUNNER],
|
|
["agentic-commands-doctor-sessions-cron-memory", DEFAULT_NODE_TEST_RUNNER],
|
|
["agentic-commands-doctor-sessions-cron-sqlite", DEFAULT_NODE_TEST_RUNNER],
|
|
["agentic-commands-doctor-sessions-cron-sqlite-recovery", DEFAULT_NODE_TEST_RUNNER],
|
|
["agentic-commands-doctor-platform", DEFAULT_NODE_TEST_RUNNER],
|
|
["agentic-commands-status-tools", DEFAULT_NODE_TEST_RUNNER],
|
|
["agentic-control-plane-auth-node", DEFAULT_NODE_TEST_RUNNER],
|
|
["agentic-control-plane-http-plugin-ws", DEFAULT_NODE_TEST_RUNNER],
|
|
["agentic-control-plane-runtime-ui-tools", DEFAULT_NODE_TEST_RUNNER],
|
|
["auto-reply-reply-agent-runner", DEFAULT_NODE_TEST_RUNNER],
|
|
["core-runtime-infra-heartbeat-runner", DEFAULT_NODE_TEST_RUNNER],
|
|
["core-runtime-infra-storage-state", DEFAULT_NODE_TEST_RUNNER],
|
|
["core-runtime-infra-system-runtime", DEFAULT_NODE_TEST_RUNNER],
|
|
]),
|
|
],
|
|
[
|
|
"hybrid",
|
|
new Map([
|
|
["agentic-commands-doctor-config-state", DEFAULT_NODE_TEST_RUNNER],
|
|
[COMMANDS_RUNTIME_GROUP, DEFAULT_NODE_TEST_RUNNER],
|
|
["agentic-commands-doctor-platform", DEFAULT_NODE_TEST_RUNNER],
|
|
["agentic-control-plane-runtime-shared-token", DEFAULT_NODE_TEST_RUNNER],
|
|
["core-runtime-cron-parallel-service", DEFAULT_NODE_TEST_RUNNER],
|
|
]),
|
|
],
|
|
[
|
|
"github",
|
|
new Map([
|
|
["agentic-agents-tools", DEFAULT_NODE_TEST_RUNNER],
|
|
["core-runtime-cron-parallel-service", DEFAULT_NODE_TEST_RUNNER],
|
|
["core-runtime-infra-storage-state", DEFAULT_NODE_TEST_RUNNER],
|
|
]),
|
|
],
|
|
]);
|
|
const AUTO_REPLY_COMMANDS_STRIPES = 3;
|
|
const AGENTS_CORE_RUNNER_CLI_STRIPES = 3;
|
|
const AGENTIC_GATEWAY_CORE_STRIPES = 3;
|
|
const CORE_RUNTIME_MEDIA_UI_STRIPES = 3;
|
|
const CORE_UNIT_SRC_SECURITY_STRIPES = 3;
|
|
const UNIT_FAST_NODE_TEST_STRIPES = 2;
|
|
// Preserve three balanced base stripes: the largest individual harness files
|
|
// remain indivisible even after files within each stripe run in parallel.
|
|
const EMBEDDED_BASE_NODE_TEST_STRIPES = 3;
|
|
// Cold-start fallback when committed CI measurements are missing. Refresh
|
|
// config/ci-test-timings.json with pnpm ci:timings:refit, not these literals.
|
|
const COMPACT_GROUP_SECONDS_HINTS = new Map<string, number>([
|
|
["agentic-agents-core-auth", 30],
|
|
["agentic-agents-core-isolated", 18],
|
|
["agentic-agents-core-models", 41],
|
|
["agentic-agents-core-runner-cli-1", 6],
|
|
["agentic-agents-core-runner-cli-2", 13],
|
|
["agentic-agents-core-runner-cli-3", 7],
|
|
["agentic-agents-core-runner-commands", 28],
|
|
["agentic-agents-core-runner-embedded", 17],
|
|
["agentic-agents-core-runner-sessions", 14],
|
|
["agentic-agents-core-runtime", 106],
|
|
["agentic-agents-core-subagents", 20],
|
|
["agentic-agents-core-tools", 39],
|
|
["agentic-agents-embedded-base-1", 86],
|
|
["agentic-agents-embedded-base-2", 86],
|
|
["agentic-agents-embedded-base-3", 86],
|
|
["agentic-agents-embedded-incomplete-turn", 19],
|
|
["agentic-agents-embedded-overflow-compaction", 20],
|
|
["agentic-agents-embedded-run", 46],
|
|
// Main runs 33537556582/33537739443/33543106647 totaled 478.25s/450.21s/418.13s
|
|
// across the complete support inventory. Keep its upper bound as the fallback
|
|
// when membership changes and exact generation timings no longer match.
|
|
["agentic-agents-support", 479],
|
|
["agentic-agents-tools", 69],
|
|
// The measured 131s pair split per config; apportioned by the hosted
|
|
// per-config walls (139s/67s) until direct Blacksmith samples exist.
|
|
["agentic-cli", 88],
|
|
["agentic-cli-process", 43],
|
|
["agentic-command-support", 49],
|
|
["agentic-commands-agent-channel", 76],
|
|
["agentic-commands-doctor", 23],
|
|
["agentic-commands-doctor-auth", 19],
|
|
["agentic-commands-doctor-config-state", 67],
|
|
["agentic-commands-doctor-device", 2],
|
|
["agentic-commands-doctor-gateway", 3],
|
|
["agentic-commands-doctor-platform", 5],
|
|
["agentic-commands-doctor-plugins-tools", 13],
|
|
// Job 99770912022 measured 116.6s/112.3s of test bodies and 53.8s of
|
|
// group overhead. Charge that full overhead to each new process until
|
|
// the canonical refit has two main-run samples for the new owners.
|
|
["agentic-commands-doctor-sessions-cron", 31],
|
|
["agentic-commands-doctor-sessions-cron-memory", 167],
|
|
["agentic-commands-doctor-sessions-cron-sqlite", 171],
|
|
// Job 106098306092 measured 11.27s including setup for both recovery files.
|
|
["agentic-commands-doctor-sessions-cron-sqlite-recovery", 15],
|
|
["agentic-commands-doctor-shared", 37],
|
|
["agentic-commands-doctor-whatsapp", 1],
|
|
["agentic-commands-doctor-workspace", 1],
|
|
["agentic-commands-models", 32],
|
|
["agentic-commands-onboard-config", 49],
|
|
["agentic-commands-status-tools", 35],
|
|
["agentic-control-plane-agent-chat", 167],
|
|
["agentic-control-plane-auth-node", 166],
|
|
["agentic-control-plane-http-models", 41],
|
|
["agentic-control-plane-http-plugin-ws", 52],
|
|
["agentic-control-plane-runtime", 19],
|
|
["agentic-control-plane-runtime-config", 20],
|
|
["agentic-control-plane-runtime-cron", 22],
|
|
["agentic-control-plane-runtime-server", 23],
|
|
["agentic-control-plane-runtime-shared-token", 9],
|
|
["agentic-control-plane-runtime-state", 33],
|
|
["agentic-control-plane-runtime-ui-tools", 9],
|
|
["agentic-control-plane-startup-config", 5],
|
|
["agentic-control-plane-startup-core", 31],
|
|
["agentic-control-plane-startup-health-runtime", 11],
|
|
["agentic-control-plane-startup-restart-close", 10],
|
|
// Run 33364935118 measured 21s of tests; the build belongs to bin admission.
|
|
["agentic-gateway-core-runtime", 21],
|
|
// Two-CPU Linux proof: 40s including preparation, 19s for the 13,000-file case.
|
|
["agentic-gateway-core-inventory", 40],
|
|
["agentic-gateway-core-1", 99],
|
|
["agentic-gateway-core-2", 99],
|
|
["agentic-gateway-core-3", 99],
|
|
// Five successful main runs through 35607421993 measured a 1,043s median
|
|
// across both configs and complete child spans; direct refits remain authoritative.
|
|
["agentic-gateway-server-isolated", 1043],
|
|
["agentic-gateway-methods", 157],
|
|
["agentic-plugin-sdk", 45],
|
|
["auto-reply-core-top-level", 27],
|
|
["auto-reply-reply-agent-runner", 60],
|
|
["auto-reply-reply-commands-1", 28],
|
|
["auto-reply-reply-commands-2", 9],
|
|
["auto-reply-reply-commands-3", 24],
|
|
["auto-reply-reply-dispatch", 15],
|
|
["auto-reply-reply-dispatch-core", 35],
|
|
["auto-reply-reply-dispatch-delivery", 32],
|
|
["auto-reply-reply-dispatch-lifecycle", 8],
|
|
["auto-reply-reply-session", 34],
|
|
["auto-reply-reply-state-routing", 63],
|
|
// Apportioned from the split infra-process trio (see below).
|
|
["core-runtime-config", 113],
|
|
["core-runtime-cron-parallel-core", 13],
|
|
["core-runtime-cron-parallel-isolated-agent", 53],
|
|
["core-runtime-cron-parallel-service", 29],
|
|
["core-runtime-hooks", 19],
|
|
["core-runtime-infra-approval-exec", 28],
|
|
["core-runtime-infra-channel-plugin", 19],
|
|
["core-runtime-infra-cli-ui", 2],
|
|
["core-runtime-infra-core-utils", 3],
|
|
["core-runtime-infra-device", 8],
|
|
["core-runtime-infra-diagnostics-state", 24],
|
|
["core-runtime-infra-env-auth", 6],
|
|
["core-runtime-infra-events-runtime", 8],
|
|
["core-runtime-infra-file-safety", 2],
|
|
["core-runtime-infra-files-commands", 5],
|
|
["core-runtime-infra-gateway-lock-argv", 3],
|
|
["core-runtime-infra-gateway-processes", 1],
|
|
["core-runtime-infra-gateway-watch", 1],
|
|
["core-runtime-infra-heartbeat-core", 7],
|
|
["core-runtime-infra-heartbeat-runner", 59],
|
|
["core-runtime-infra-misc", 14],
|
|
["core-runtime-infra-misc-dedupe-disk", 1],
|
|
["core-runtime-infra-misc-os", 1],
|
|
["core-runtime-infra-misc-values", 2],
|
|
["core-runtime-infra-net-install", 11],
|
|
["core-runtime-infra-network-node", 3],
|
|
["core-runtime-infra-network-platform", 5],
|
|
["core-runtime-infra-outbound-actions", 37],
|
|
["core-runtime-infra-outbound-core", 59],
|
|
// The measured 126s trio split; apportioned by the hosted per-config walls
|
|
// (17s/157s) until direct Blacksmith samples exist.
|
|
["core-runtime-infra-process", 13],
|
|
["core-runtime-infra-provider-push", 13],
|
|
["core-runtime-infra-repo-tooling", 4],
|
|
["core-runtime-infra-storage-state", 104],
|
|
["core-runtime-infra-system-runtime", 36],
|
|
["core-runtime-media-ui-1", 93],
|
|
["core-runtime-media-ui-2", 93],
|
|
["core-runtime-media-ui-3", 93],
|
|
["core-runtime-media-ui-support", 100],
|
|
["core-runtime-secrets", 61],
|
|
["core-runtime-shared", 67],
|
|
// This dist-only group is outside the sampled nondist logs and retains its
|
|
// prior measured hint. The exclusive-bin cap keeps its lane lightly packed.
|
|
["core-runtime-tui-pty", 116],
|
|
// This PR-only owner is excluded from sampled push plans. Retained exact runs
|
|
// measured 108.79s/130.83s, so use the conservative wall until its owner can
|
|
// supply canonical samples through another path.
|
|
["core-tooling-isolated", 131],
|
|
["core-unit-fast-1", 66],
|
|
["core-unit-fast-2", 64],
|
|
// The measured 116s pair split per config; apportioned by the hosted
|
|
// per-config walls (158s/32s) until direct Blacksmith samples exist.
|
|
["core-unit-fast-fake-timers", 20],
|
|
["core-unit-fast-isolated", 96],
|
|
["core-unit-src-security-1", 101],
|
|
["core-unit-src-security-2", 101],
|
|
["core-unit-src-security-3", 101],
|
|
["core-unit-src-security-support", 12],
|
|
["core-unit-support", 20],
|
|
]);
|
|
|
|
// Rounded mean of the same 8-vCPU groups across successful canonical-main
|
|
// compact runs 31684307744, 31683213137, 31682494259, 31682258389,
|
|
// 31681118857, 31680010311, 31678309660, 31678086868, and 31677305067.
|
|
// Means expose recurrent slow tails hidden by medians; resource ownership
|
|
// remains with resolveCiNodeTestRunner.
|
|
const COMPACT_LARGE_GROUP_STRIPE_SECONDS_HINTS = new Map<string, number>([
|
|
["agentic-agents-core-auth", 33],
|
|
["agentic-agents-core-models", 41],
|
|
["agentic-agents-core-runner-cli-1", 7],
|
|
["agentic-agents-core-runner-cli-2", 14],
|
|
["agentic-agents-core-runner-cli-3", 7],
|
|
["agentic-agents-core-runner-commands", 28],
|
|
["agentic-agents-core-runner-embedded", 20],
|
|
["agentic-agents-core-runner-sessions", 16],
|
|
["agentic-agents-core-runtime", 119],
|
|
["agentic-agents-core-subagents", 21],
|
|
["agentic-agents-core-tools", 47],
|
|
["agentic-agents-embedded-base-1", 86],
|
|
["agentic-agents-embedded-base-2", 86],
|
|
["agentic-agents-embedded-base-3", 86],
|
|
["agentic-agents-embedded-incomplete-turn", 20],
|
|
["agentic-agents-embedded-overflow-compaction", 21],
|
|
["agentic-agents-embedded-run", 47],
|
|
["agentic-agents-support", 479],
|
|
["agentic-control-plane-startup-core", 33],
|
|
// Run 31691151297 measured 296.68s for gateway-core and 303.93s for unit-src.
|
|
// Run 31694057974 measured the two isolated UI envelopes at 159.50s and
|
|
// 120.55s. Rebalance those walls over the three-way LPT weights: 457/455/455,
|
|
// 633/634/633, and 393/393/393 respectively.
|
|
["agentic-gateway-core-1", 99],
|
|
["agentic-gateway-core-2", 99],
|
|
["agentic-gateway-core-3", 99],
|
|
["agentic-gateway-methods", 153],
|
|
["auto-reply-reply-commands-1", 34],
|
|
["auto-reply-reply-commands-2", 11],
|
|
["auto-reply-reply-commands-3", 28],
|
|
["auto-reply-reply-dispatch", 18],
|
|
["auto-reply-reply-dispatch-core", 42],
|
|
["auto-reply-reply-dispatch-delivery", 38],
|
|
["auto-reply-reply-dispatch-lifecycle", 10],
|
|
["core-runtime-media-ui-1", 93],
|
|
["core-runtime-media-ui-2", 93],
|
|
["core-runtime-media-ui-3", 93],
|
|
["core-runtime-media-ui-support", 100],
|
|
["core-unit-fast-1", 68],
|
|
["core-unit-fast-2", 67],
|
|
["core-unit-fast-fake-timers", 21],
|
|
["core-unit-fast-isolated", 96],
|
|
["core-unit-src-security-1", 101],
|
|
["core-unit-src-security-2", 101],
|
|
["core-unit-src-security-3", 101],
|
|
["core-unit-src-security-support", 12],
|
|
]);
|
|
|
|
// Rounded medians from standard 4-core GitHub-hosted runs 31737316152,
|
|
// 31742781948, 31749838728, 31754493208, 31776290645, 31784022043, and
|
|
// 31784883914. Exclude failed samples and reject media-ui-3's 444s compact
|
|
// retry sample because its log records a 300s no-output timeout; its three
|
|
// healthy samples are 52-63s. Unmeasured groups use the scale above.
|
|
const COMPACT_GITHUB_GROUP_SECONDS_HINTS = new Map<string, number>([
|
|
["agentic-agents-core-auth", 50],
|
|
["agentic-agents-core-isolated", 23],
|
|
["agentic-agents-core-models", 198],
|
|
["agentic-agents-core-runner-cli-1", 16],
|
|
["agentic-agents-core-runner-cli-2", 25],
|
|
["agentic-agents-core-runner-cli-3", 23],
|
|
["agentic-agents-core-runner-commands", 55],
|
|
["agentic-agents-core-runner-embedded", 30],
|
|
["agentic-agents-core-runner-sessions", 23],
|
|
["agentic-agents-core-runtime", 185],
|
|
["agentic-agents-core-subagents", 29],
|
|
["agentic-agents-core-tools", 83],
|
|
["agentic-agents-embedded-base-1", 138],
|
|
["agentic-agents-embedded-base-2", 138],
|
|
["agentic-agents-embedded-base-3", 138],
|
|
["agentic-agents-embedded-incomplete-turn", 3],
|
|
["agentic-agents-embedded-overflow-compaction", 31],
|
|
["agentic-agents-embedded-run", 62],
|
|
["agentic-agents-support", 253],
|
|
["agentic-agents-tools", 124],
|
|
// Measured per config inside run 31814517685's combined 206s wall.
|
|
["agentic-cli", 139],
|
|
["agentic-cli-process", 67],
|
|
["agentic-command-support", 67],
|
|
["agentic-commands-agent-channel", 121],
|
|
["agentic-commands-doctor", 33],
|
|
["agentic-commands-doctor-auth", 32],
|
|
["agentic-commands-doctor-config-state", 124],
|
|
["agentic-commands-doctor-device", 5],
|
|
["agentic-commands-doctor-gateway", 8],
|
|
["agentic-commands-doctor-platform", 7],
|
|
["agentic-commands-doctor-plugins-tools", 21],
|
|
// Conservative native fallbacks above, scaled by 1.6 until hosted samples exist.
|
|
["agentic-commands-doctor-sessions-cron", 87],
|
|
["agentic-commands-doctor-sessions-cron-memory", 268],
|
|
["agentic-commands-doctor-sessions-cron-sqlite", 274],
|
|
["agentic-commands-doctor-shared", 61],
|
|
["agentic-commands-doctor-whatsapp", 2],
|
|
["agentic-commands-doctor-workspace", 3],
|
|
["agentic-commands-models", 64],
|
|
["agentic-commands-onboard-config", 76],
|
|
["agentic-commands-status-tools", 57],
|
|
["agentic-control-plane-agent-chat", 232],
|
|
["agentic-control-plane-auth-node", 254],
|
|
["agentic-control-plane-http-models", 59],
|
|
["agentic-control-plane-http-plugin-ws", 86],
|
|
["agentic-control-plane-runtime", 31],
|
|
["agentic-control-plane-runtime-config", 31],
|
|
["agentic-control-plane-runtime-cron", 52],
|
|
["agentic-control-plane-runtime-server", 54],
|
|
["agentic-control-plane-runtime-shared-token", 28],
|
|
["agentic-control-plane-runtime-state", 55],
|
|
["agentic-control-plane-runtime-ui-tools", 31],
|
|
["agentic-control-plane-startup-config", 15],
|
|
["agentic-control-plane-startup-core", 51],
|
|
["agentic-control-plane-startup-health-runtime", 31],
|
|
["agentic-control-plane-startup-restart-close", 28],
|
|
["agentic-gateway-core-1", 176],
|
|
["agentic-gateway-core-2", 149],
|
|
["agentic-gateway-core-3", 141],
|
|
["agentic-gateway-core-inventory", 40],
|
|
["agentic-gateway-methods", 169],
|
|
// Full Release Validation job 106995310855 (4-core ubuntu-24.04, two workers)
|
|
// ran the whole cohort in 2914s wall; 1.6x the Blacksmith median predicted 1669s.
|
|
["agentic-gateway-server-isolated", 2914],
|
|
["agentic-plugin-sdk", 70],
|
|
["auto-reply-core-top-level", 43],
|
|
["auto-reply-reply-agent-runner", 169],
|
|
["auto-reply-reply-commands-1", 53],
|
|
["auto-reply-reply-commands-2", 26],
|
|
["auto-reply-reply-commands-3", 48],
|
|
["auto-reply-reply-dispatch", 55],
|
|
["auto-reply-reply-dispatch-core", 75],
|
|
["auto-reply-reply-dispatch-delivery", 70],
|
|
["auto-reply-reply-dispatch-lifecycle", 20],
|
|
["auto-reply-reply-session", 79],
|
|
["auto-reply-reply-state-routing", 34],
|
|
// Measured per config inside run 31814517685's combined 175s infra wall.
|
|
["core-runtime-config", 157],
|
|
["core-runtime-cron-parallel-core", 22],
|
|
["core-runtime-cron-parallel-isolated-agent", 77],
|
|
["core-runtime-cron-parallel-service", 66],
|
|
["core-runtime-hooks", 31],
|
|
["core-runtime-infra-approval-exec", 45],
|
|
["core-runtime-infra-channel-plugin", 30],
|
|
["core-runtime-infra-cli-ui", 3],
|
|
["core-runtime-infra-core-utils", 7],
|
|
["core-runtime-infra-device", 13],
|
|
["core-runtime-infra-diagnostics-state", 34],
|
|
["core-runtime-infra-env-auth", 10],
|
|
["core-runtime-infra-events-runtime", 11],
|
|
["core-runtime-infra-file-safety", 4],
|
|
["core-runtime-infra-files-commands", 7],
|
|
["core-runtime-infra-gateway-lock-argv", 3],
|
|
["core-runtime-infra-gateway-processes", 1],
|
|
["core-runtime-infra-gateway-watch", 1],
|
|
["core-runtime-infra-heartbeat-core", 10],
|
|
["core-runtime-infra-heartbeat-runner", 106],
|
|
["core-runtime-infra-misc", 33],
|
|
["core-runtime-infra-misc-dedupe-disk", 1],
|
|
["core-runtime-infra-misc-os", 1],
|
|
["core-runtime-infra-misc-values", 2],
|
|
["core-runtime-infra-net-install", 17],
|
|
["core-runtime-infra-network-node", 5],
|
|
["core-runtime-infra-network-platform", 8],
|
|
["core-runtime-infra-outbound-actions", 53],
|
|
["core-runtime-infra-outbound-core", 112],
|
|
// Measured per config inside run 31814517685's combined 175s wall.
|
|
["core-runtime-infra-process", 17],
|
|
["core-runtime-infra-provider-push", 29],
|
|
["core-runtime-infra-repo-tooling", 6],
|
|
["core-runtime-infra-storage-state", 235],
|
|
["core-runtime-infra-system-runtime", 69],
|
|
["core-runtime-media-ui-1", 97],
|
|
["core-runtime-media-ui-2", 78],
|
|
["core-runtime-media-ui-3", 71],
|
|
["core-runtime-media-ui-support", 101],
|
|
["core-runtime-secrets", 73],
|
|
["core-runtime-shared", 92],
|
|
["core-tooling-isolated", 41],
|
|
["core-unit-fast-1", 85],
|
|
["core-unit-fast-2", 84],
|
|
// Measured per config inside run 31814517685's combined 190s wall.
|
|
["core-unit-fast-fake-timers", 32],
|
|
["core-unit-fast-isolated", 158],
|
|
["core-unit-src-security-1", 132],
|
|
["core-unit-src-security-2", 131],
|
|
["core-unit-src-security-3", 132],
|
|
["core-unit-src-security-support", 20],
|
|
["core-unit-support", 32],
|
|
]);
|
|
|
|
// Hybrid-specific Blacksmith observations, plus the gateway-core-3 139.5s spike
|
|
// in 31938297538 that must stay singleton.
|
|
// Sum a shard's per-config Duration lines before taking a median; pooling them
|
|
// reads as a large over-prediction that is not there. Normalize each run by its
|
|
// own VM speed (median of every shard's duration over that shard's cross-run
|
|
// median) before comparing, or a slow draw looks like a hint miss.
|
|
// Values below are VM-normalized medians over runs 32316204633, 32317242374,
|
|
// 32318250756, and 32320063231 (2026-08-20). Across 100 groups the GitHub hints
|
|
// run 0.64x on Blacksmith, so only the ones that overshoot are pinned here:
|
|
// leaving these low packs partners onto the tallest bins, which set the wall.
|
|
const COMPACT_HYBRID_GROUP_SECONDS_HINTS = new Map<string, number>([
|
|
// Preserve the observed support budget through inventory growth without
|
|
// scaling the current Blacksmith evidence by an older whole-suite ratio.
|
|
["agentic-agents-support", 479],
|
|
["agentic-agents-core-models", 81],
|
|
["agentic-cli-process", 110],
|
|
["agentic-commands-doctor", 83],
|
|
["agentic-gateway-core-3", 140],
|
|
["core-runtime-cron-parallel-service", 54],
|
|
["core-runtime-infra-process", 35],
|
|
]);
|
|
|
|
// Exact 6a0571 storage-child replay on a four-CPU Testbox: native module
|
|
// setup + collection + suite elapsed time, not case sums or eight-CPU CI walls.
|
|
// Keep these config-scoped work hints through file regrouping. The two-slot
|
|
// work estimate was 369.995s; its 389.731s wrapper included 12.745s preparation.
|
|
const STORAGE_MODULE_WORK_SECONDS = new Map<string, number>([
|
|
["src/infra/sqlite-readonly-location.copy.test.ts", 0.852],
|
|
["src/infra/sqlite-snapshot-staging-cancelled-owner.test.ts", 0.368],
|
|
["src/infra/sqlite-worker-client.test.ts", 0.379],
|
|
["src/infra/sqlite-worker-store.generation.test.ts", 2.427],
|
|
["src/infra/state-migrations.caller-mode.refusal.test.ts", 20.635],
|
|
["src/infra/state-migrations.media-persistence.canonical-archives.test.ts", 9.166],
|
|
["src/infra/state-migrations.retained-deleted-agent.test.ts", 36.507],
|
|
["src/channels/message/ingress-drain.async-work.test.ts", 2.656],
|
|
["src/tui/tui-last-session.test.ts", 5.299],
|
|
["src/worker/worker.chat-abort.test.ts", 15.867],
|
|
["src/agents/bash-tools.exec-background-followup.test.ts", 2.969],
|
|
["src/channels/message-access/operator-authority.test.ts", 31.775],
|
|
["src/agents/subagents/registry/subagent-registry.persistence.test.ts", 23.639],
|
|
["src/auto-reply/reply/session.acp-reset-routing.test.ts", 8.938],
|
|
["src/agents/tools/skill-workshop-tool.support-paths.test.ts", 1.686],
|
|
["src/claws/package-update.test.ts", 4.164],
|
|
["src/cli/update-cli.git-service.test.ts", 24.578],
|
|
["src/flows/doctor-health.fleet-preflight.test.ts", 8.648],
|
|
["src/agents/embedded-agent-runner/run/attempt-prompt-submit.retention.test.ts", 6.452],
|
|
["src/agents/embedded-agent-runner/compact.delegate.test.ts", 16.151],
|
|
["src/cli/plugins-cli.update.test.ts", 9.511],
|
|
["src/state/openclaw-agent-participants-migration.test.ts", 9.279],
|
|
["src/agents/tools/transcripts-tool.selection.test.ts", 14.535],
|
|
["src/agents/main-session-recovery/main-session-restart-recovery-admission.test.ts", 16.187],
|
|
["src/agents/main-session-recovery/main-session-restart-recovery.test.ts", 279.963],
|
|
["test/runtime-agent.codex-initialization.integration.test.ts", 45.303],
|
|
["src/tasks/task-executor.test.ts", 7.528],
|
|
["src/agents/agent-harness-native-custody.test.ts", 27.986],
|
|
["src/system-agent/setup-inference-activate.test.ts", 69.039],
|
|
["src/plugins/install-record-commit.retention.test.ts", 7.563],
|
|
["src/infra/device-pairing-node.lifecycle.test.ts", 4.165],
|
|
["src/agents/tools-effective-inventory.cold-provider.test.ts", 9.37],
|
|
["src/meeting-bot/participation.capacity-race.test.ts", 0.909],
|
|
["test/imessage-reply-alias.integration.test.ts", 6.901],
|
|
["test/plugins/memory-dreaming-cron.test.ts", 2.991],
|
|
["src/config/sessions/session-accessor.sqlite-history-query-plan.test.ts", 5.374],
|
|
]);
|
|
const STORAGE_WRAPPER_SECONDS = 20;
|
|
|
|
const DEFAULT_WHOLE_GROUP_SECONDS = 25;
|
|
const DEFAULT_SECONDS_PER_TEST_FILE = 0.5;
|
|
const COMPACT_PUSH_EXCLUDED_SHARDS = new Set([
|
|
"core-runtime-tui-pty",
|
|
...Array.from(
|
|
{ length: COMPACT_TOOLING_NODE_TEST_GROUPS },
|
|
(_, index) => `core-tooling-${index + 1}`,
|
|
),
|
|
"core-tooling-isolated",
|
|
]);
|
|
const COMPACT_BLACKSMITH_SPLIT_OWNERS = new Set([
|
|
"agentic-cli",
|
|
"agentic-agents-support",
|
|
"agentic-control-plane-agent-chat",
|
|
"agentic-gateway-core-1",
|
|
"agentic-gateway-core-2",
|
|
"agentic-gateway-core-3",
|
|
"core-runtime-infra-storage-state",
|
|
]);
|
|
// Spawn/signal-timing suites (process-group waits, PTY smoke) flake when a
|
|
// concurrent sibling Vitest run competes for the 4 vCPU runner. Pack them
|
|
// into bins the shard runner executes at concurrency 1.
|
|
const EXCLUSIVE_COMPACT_GROUP_RE =
|
|
/^core-tooling(?:-\d+(?:-hosted-\d+)?|-isolated)$|^core-runtime-tui-pty$|^agentic-gateway-core-(?:runtime|inventory)$|^agentic-cli(?:-hosted-\d+|-process(?:-hosted-\d+)?)?$/u;
|
|
// Exclusive bins run serially, so their packed estimate is their wall clock.
|
|
// An indivisible file above this budget must not acquire additional work.
|
|
const COMPACT_EXCLUSIVE_JOB_SECONDS = 150;
|
|
const COMPACT_HYBRID_SERIAL_CLI_JOB_SECONDS = 250;
|
|
const COMPACT_SERIAL_CLI_GROUP_NAME_RE = /^agentic-cli(?:(?:-process)?-hosted-\d+)?$/u;
|
|
|
|
export function isExclusiveCompactShardName(shardName: string): boolean {
|
|
return EXCLUSIVE_COMPACT_GROUP_RE.test(shardName);
|
|
}
|
|
|
|
function isExclusiveCompactGroup(group: NodeTestShardGroup): boolean {
|
|
return (
|
|
isExclusiveCompactShardName(group.shard_name) ||
|
|
// Blacksmith's whole support owner used its host alone. File partitions
|
|
// retain that allocation instead of inheriting overlapping worker caps.
|
|
(group.runner === EXTRA_LARGE_NODE_TEST_RUNNER &&
|
|
/^agentic-agents-support-hosted-\d+$/u.test(group.shard_name))
|
|
);
|
|
}
|
|
|
|
function isParallelCompactGroup(group: NodeTestShardGroup): boolean {
|
|
return !isExclusiveCompactGroup(group) && !group.requiresDist && !group.pretestBuildMode;
|
|
}
|
|
|
|
// Spawn/signal/PTY-timing suites also flake under high in-process worker
|
|
// counts; pin them to the proven 2-worker budget while the job-level default
|
|
// scales with the runner class. infra-process spawns child processes per test
|
|
// and hit worker-startup timeouts under contention before serialization.
|
|
const PINNED_WORKER_COMPACT_GROUP_RE =
|
|
/^core-tooling(?:-\d+(?:-hosted-\d+)?|-isolated)$|^core-runtime-tui-pty$|^core-runtime-infra-process$|^core-runtime-config$|^core-runtime-media-ui-(?:\d+|support)$|^agentic-cli(?:-process)?$|^agentic-gateway-(?:core-\d+|methods)$/u;
|
|
const PINNED_COMPACT_GROUP_ENV = { OPENCLAW_VITEST_MAX_WORKERS: "2" };
|
|
const MEASURED_GATEWAY_ISOLATED_GROUP_RE = /^agentic-gateway-server-isolated(?:-hosted-\d+)?$/u;
|
|
const MEASURED_GATEWAY_METHODS_GROUP_RE = /^agentic-gateway-methods(?:-hosted-\d+)?$/u;
|
|
const MEASURED_CLI_GROUP_RE = /^agentic-cli(?:-hosted-\d+)?$/u;
|
|
const FILE_PARALLEL_AGENT_GROUP_RE = /^agentic-agents-(?:embedded-base-\d+|embedded-run|tools)$/u;
|
|
const FILE_PARALLEL_AGENT_MIN_WORKERS = 2;
|
|
|
|
// Existing samples describe serial file execution. Keep new parallel wall
|
|
// samples under a separate identity so refitting never discounts them twice.
|
|
function fileParallelAgentFallbackSeconds(
|
|
group: NodeTestShardGroup,
|
|
seconds: number | undefined,
|
|
): number | undefined {
|
|
return seconds !== undefined && FILE_PARALLEL_AGENT_GROUP_RE.test(group.shard_name)
|
|
? Math.max(
|
|
seconds / FILE_PARALLEL_AGENT_MIN_WORKERS,
|
|
...(group.includePatterns ?? []).map(stripeFileWeight),
|
|
)
|
|
: seconds;
|
|
}
|
|
|
|
function isAutoReplyReplyGroup(group: NodeTestShardGroup): boolean {
|
|
return (
|
|
group.configs.length === 1 &&
|
|
group.configs[0] === "test/vitest/vitest.auto-reply-reply.config.ts"
|
|
);
|
|
}
|
|
|
|
function compactEffectiveFileWorkers(group: NodeTestShardGroup, fileCount: number): number {
|
|
return isAutoReplyReplyGroup(group) ? Math.max(1, Math.min(2, fileCount)) : 1;
|
|
}
|
|
|
|
function readSerialAutoReplySplitSeconds(
|
|
group: NodeTestShardGroup,
|
|
profile: "blacksmith" | "github",
|
|
): number | undefined {
|
|
if (!isAutoReplyReplyGroup(group) || !group.includePatterns?.length) {
|
|
return undefined;
|
|
}
|
|
const { OPENCLAW_VITEST_MAX_WORKERS: _workers, ...env } = group.env ?? {};
|
|
const legacy = createCompactSplitTimingGeneration({
|
|
configs: group.configs,
|
|
env,
|
|
parentShardName: group.shard_name,
|
|
stripes: [group.includePatterns],
|
|
});
|
|
return readCompleteSplitGenerationSeconds(readCompactGroupTimings(profile), legacy.selectorKey);
|
|
}
|
|
|
|
function parallelCompactFallbackSeconds(
|
|
group: NodeTestShardGroup,
|
|
seconds: number,
|
|
profile: "blacksmith" | "github" = "blacksmith",
|
|
): number {
|
|
// A complete legacy generation can be newer than its parent; partial or changed inventories cannot.
|
|
return (
|
|
Math.max(seconds, readSerialAutoReplySplitSeconds(group, profile) ?? 0) /
|
|
compactEffectiveFileWorkers(group, group.includePatterns?.length ?? 1)
|
|
);
|
|
}
|
|
|
|
function isParallelAgentsCoreGroup(group: NodeTestShardGroup): boolean {
|
|
return group.configs.length === 1 && group.configs[0] === agentVitestProjectOwners.core.config;
|
|
}
|
|
|
|
function agentsCoreWorkFiles(group: NodeTestShardGroup): string[] {
|
|
const files = group.includePatterns ?? [];
|
|
const unitFastFiles = new Set(getUnitFastTestFilesForIncludePatterns(files));
|
|
return files.filter((file) => isStripeEligibleTestFile(file, unitFastFiles));
|
|
}
|
|
|
|
function effectiveAgentsCoreWorkers(group: NodeTestShardGroup): number {
|
|
if (!isParallelAgentsCoreGroup(group) || !group.includePatterns?.length) {
|
|
return 1;
|
|
}
|
|
const weights = agentsCoreWorkFiles(group).map(stripeFileWeight);
|
|
if (weights.length === 0) {
|
|
return 1;
|
|
}
|
|
const total = weights.reduce((sum, weight) => sum + weight, 0);
|
|
// Two workers share the file work; an indivisible file keeps its serial cost.
|
|
return Math.min(2, weights.length, total / Math.max(...weights));
|
|
}
|
|
|
|
function readSerialAgentsCoreSeconds(
|
|
group: NodeTestShardGroup,
|
|
profile: "blacksmith" | "github",
|
|
): number | undefined {
|
|
if (!isParallelAgentsCoreGroup(group)) {
|
|
return undefined;
|
|
}
|
|
const { OPENCLAW_VITEST_MAX_WORKERS: _workers, ...env } = group.env ?? {};
|
|
const complete = group.includePatterns?.length
|
|
? readCompleteSplitGenerationSeconds(
|
|
readCompactGroupTimings(profile),
|
|
createCompactSplitTimingGeneration({
|
|
configs: group.configs,
|
|
env,
|
|
parentShardName: group.shard_name,
|
|
stripes: [group.includePatterns],
|
|
}).selectorKey,
|
|
)
|
|
: undefined;
|
|
const parent = readCompactGroupTimings(profile)[group.shard_name];
|
|
return parent === undefined && complete === undefined
|
|
? undefined
|
|
: Math.max(parent ?? 0, complete ?? 0);
|
|
}
|
|
|
|
function usesMeasuredCompactWorkers(group: NodeTestShardGroup, runnerBackend: string | undefined) {
|
|
return (
|
|
(runnerBackend === undefined || runnerBackend === "blacksmith" || runnerBackend === "hybrid") &&
|
|
(MEASURED_CLI_GROUP_RE.test(group.shard_name) ||
|
|
isParallelCommandsGroup(group) ||
|
|
/^agentic-gateway-core-2(?:-hosted-\d+)?$/u.test(group.shard_name) ||
|
|
MEASURED_GATEWAY_ISOLATED_GROUP_RE.test(group.shard_name) ||
|
|
MEASURED_GATEWAY_METHODS_GROUP_RE.test(group.shard_name) ||
|
|
FILE_PARALLEL_AGENT_GROUP_RE.test(group.shard_name.replace(/-hosted-\d+$/u, "")))
|
|
);
|
|
}
|
|
|
|
function isParallelGatewayServerGroup(group: NodeTestShardGroup): boolean {
|
|
return (
|
|
group.configs.length === 1 && group.configs[0] === "test/vitest/vitest.gateway-server.config.ts"
|
|
);
|
|
}
|
|
|
|
function gatewayServerEffectiveWorkers(group: NodeTestShardGroup): number {
|
|
return Math.max(
|
|
1,
|
|
Math.min(
|
|
2,
|
|
group.includePatterns?.filter((file) => !gatewayServerSerialTestFiles.includes(file))
|
|
.length ?? 2,
|
|
),
|
|
);
|
|
}
|
|
|
|
function gatewayServerSerialSeconds(
|
|
files: readonly string[] | undefined,
|
|
runnerBackend: string | undefined,
|
|
): number {
|
|
const scale =
|
|
runnerBackend === "github"
|
|
? COMPACT_GITHUB_GROUP_SECONDS_SCALE
|
|
: runnerBackend === "hybrid"
|
|
? COMPACT_HYBRID_GROUP_SECONDS_SCALE
|
|
: 1;
|
|
return (files ?? []).reduce(
|
|
(seconds, file) =>
|
|
seconds + (gatewayServerSerialTestFiles.includes(file) ? stripeFileWeight(file) * scale : 0),
|
|
0,
|
|
);
|
|
}
|
|
|
|
function estimateLegacyGatewayServerSeconds(
|
|
group: NodeTestShardGroup,
|
|
profile: "blacksmith" | "github",
|
|
hint: number | undefined,
|
|
): number | undefined {
|
|
const { OPENCLAW_VITEST_MAX_WORKERS: _workers, ...env } = group.env ?? {};
|
|
const generation = createCompactSplitTimingGeneration({
|
|
configs: group.configs,
|
|
env,
|
|
parentShardName: group.shard_name,
|
|
stripes: [group.includePatterns ?? []],
|
|
});
|
|
const measured = readCompactGroupTimings(profile)[group.shard_name];
|
|
const complete = readCompleteSplitGenerationSeconds(
|
|
readCompactGroupTimings(profile),
|
|
generation.selectorKey,
|
|
);
|
|
const seconds = measured ?? hint;
|
|
const serialSeconds = gatewayServerSerialSeconds(group.includePatterns, profile);
|
|
return seconds === undefined && complete === undefined
|
|
? undefined
|
|
: serialSeconds +
|
|
Math.max(0, Math.max(seconds ?? 0, complete ?? 0) - serialSeconds) /
|
|
gatewayServerEffectiveWorkers(group);
|
|
}
|
|
|
|
function applyCompactGroupWorkerPins(
|
|
group: NodeTestShardGroup,
|
|
runnerBackend: string | undefined,
|
|
): NodeTestShardGroup {
|
|
if (isAutoReplyReplyGroup(group)) {
|
|
// Keep serial observations separate so refitted parallel walls are never divided again.
|
|
return {
|
|
...group,
|
|
env: { ...group.env, ...PINNED_COMPACT_GROUP_ENV },
|
|
timing_key: `${compactGroupTimingKey(group)}-parallel-2`,
|
|
};
|
|
}
|
|
if (isParallelGatewayServerGroup(group)) {
|
|
// New measurements must not be divided again after serial files become parallel.
|
|
return {
|
|
...group,
|
|
timing_key: `${compactGroupTimingKey(group)}-parallel${gatewayServerSerialSeconds(group.includePatterns, runnerBackend) > 0 ? "-native-serial" : ""}`,
|
|
env: { ...group.env, ...PINNED_COMPACT_GROUP_ENV },
|
|
};
|
|
}
|
|
if (isParallelAgentsCoreGroup(group)) {
|
|
// Parallel samples must not overwrite serial history and be divided again.
|
|
return {
|
|
...group,
|
|
env: { ...group.env, ...PINNED_COMPACT_GROUP_ENV },
|
|
timing_key: `${compactGroupTimingKey(group)}-parallel`,
|
|
};
|
|
}
|
|
if (isParallelCommandsGroup(group)) {
|
|
return {
|
|
...group,
|
|
fallbackMaxWorkers: 2,
|
|
// Preserve the coverage tier while separating parallel and serial samples.
|
|
timing_key: `${compactGroupTimingKey(group)}${COMMANDS_PARALLEL_TIMING_SUFFIX}`,
|
|
};
|
|
}
|
|
const timedGroup =
|
|
FILE_PARALLEL_AGENT_GROUP_RE.test(group.shard_name) && group.timing_key === undefined
|
|
? { ...group, timing_key: `${group.shard_name}#file-parallel` }
|
|
: group;
|
|
if (
|
|
MEASURED_GATEWAY_METHODS_GROUP_RE.test(timedGroup.shard_name) &&
|
|
usesMeasuredCompactWorkers(timedGroup, runnerBackend)
|
|
) {
|
|
return {
|
|
...timedGroup,
|
|
timing_key: `${compactGroupTimingKey(timedGroup)}#workers-4`,
|
|
env: { ...timedGroup.env, OPENCLAW_VITEST_MAX_WORKERS: "4" },
|
|
minTotalMemoryBytes: 28 * 1024 ** 3,
|
|
fallbackMaxWorkers: 2,
|
|
};
|
|
}
|
|
if (usesMeasuredCompactWorkers(timedGroup, runnerBackend)) {
|
|
return {
|
|
...timedGroup,
|
|
...(MEASURED_GATEWAY_ISOLATED_GROUP_RE.test(timedGroup.shard_name)
|
|
? {
|
|
// Leave cold Gateway startup room within the existing test deadlines.
|
|
env: { ...timedGroup.env, OPENCLAW_VITEST_MAX_WORKERS: "2" },
|
|
minTotalMemoryBytes: 28 * 1024 ** 3,
|
|
}
|
|
: {}),
|
|
fallbackMaxWorkers: 2,
|
|
};
|
|
}
|
|
if (!PINNED_WORKER_COMPACT_GROUP_RE.test(timedGroup.shard_name)) {
|
|
return timedGroup;
|
|
}
|
|
return { ...timedGroup, env: { ...timedGroup.env, ...PINNED_COMPACT_GROUP_ENV } };
|
|
}
|
|
|
|
function readCompactGroupSeconds(
|
|
group: NodeTestShardGroup,
|
|
profile: "blacksmith" | "github",
|
|
): number | undefined {
|
|
const timings = readCompactGroupTimings(profile);
|
|
const key = compactGroupTimingKey(group);
|
|
const fullOwnerKey = key.startsWith("changed-") ? key.slice("changed-".length) : undefined;
|
|
// A reduced parallel sample retires the full-owner fallback for both timing shapes.
|
|
const hasReducedParallelSample =
|
|
fullOwnerKey !== undefined &&
|
|
isParallelGatewayServerGroup(group) &&
|
|
timings[key.endsWith("-stripes") ? key.slice(0, -"-stripes".length) : `${key}-stripes`] !==
|
|
undefined;
|
|
// Keep the full owner's admission estimate until the reduced tier has samples.
|
|
const measured =
|
|
timings[key] ??
|
|
(fullOwnerKey !== undefined && !hasReducedParallelSample ? timings[fullOwnerKey] : undefined);
|
|
if (measured !== undefined) {
|
|
return measured;
|
|
}
|
|
if (key.endsWith("#workers-4") && MEASURED_GATEWAY_METHODS_GROUP_RE.test(group.shard_name)) {
|
|
return (
|
|
timings[key.slice(0, -"#workers-4".length)] ??
|
|
(fullOwnerKey === undefined
|
|
? undefined
|
|
: timings[fullOwnerKey.slice(0, -"#workers-4".length)])
|
|
);
|
|
}
|
|
if (FILE_PARALLEL_AGENT_GROUP_RE.test(group.shard_name)) {
|
|
return fileParallelAgentFallbackSeconds(group, timings[group.shard_name]);
|
|
}
|
|
const cronOwner = /^core-runtime-cron-parallel-(core|isolated-agent|service)$/u.exec(
|
|
compactGroupTimingKey(group),
|
|
)?.[1];
|
|
const serialSeconds =
|
|
cronOwner === undefined ? undefined : timings[`core-runtime-cron-${cronOwner}`];
|
|
// Compact overlap and inherited Gateway caps allow two workers; hosted serial
|
|
// rows allow at least three. New names let parallel samples replace this
|
|
// conservative serial-cost fallback without dividing those samples again.
|
|
return serialSeconds === undefined ? undefined : serialSeconds / 2;
|
|
}
|
|
|
|
function isParallelToolingGroup(group: NodeTestShardGroup): boolean {
|
|
return /^core-tooling-\d+(?:-hosted-\d+)?$/u.test(group.shard_name);
|
|
}
|
|
|
|
function compactToolingTestSecondsLimit(
|
|
groups: readonly NodeTestShardGroup[],
|
|
runnerBackend: string | undefined,
|
|
): number {
|
|
// Keep the measured planner-proof tail bounded without fragmenting unrelated tooling.
|
|
const hasPlannerProof = groups.some((group) =>
|
|
group.includePatterns?.some((file) =>
|
|
/^test\/scripts\/ci-changed-node-test-plan(?:\.[^/]+)?\.test\.ts$/u.test(file),
|
|
),
|
|
);
|
|
return runnerBackend !== "github" && hasPlannerProof
|
|
? COMPACT_EXCLUSIVE_JOB_SECONDS
|
|
: COMPACT_SERIAL_NODE_TEST_JOB_SECONDS;
|
|
}
|
|
|
|
function estimateParallelToolingSeconds(
|
|
group: Pick<NodeTestShardGroup, "env">,
|
|
files: readonly string[],
|
|
runnerBackend: string | undefined,
|
|
fileTimings = readToolingFileTimings(runnerBackend === "github" ? "github" : "blacksmith"),
|
|
): number {
|
|
const workers = Math.min(
|
|
files.length,
|
|
Number(
|
|
group.env?.OPENCLAW_VITEST_MAX_WORKERS ??
|
|
PINNED_COMPACT_GROUP_ENV.OPENCLAW_VITEST_MAX_WORKERS,
|
|
),
|
|
);
|
|
const fallbackTimings =
|
|
runnerBackend === "github" ? readToolingFileTimings("blacksmith") : fileTimings;
|
|
const weights = files.map(
|
|
(file) =>
|
|
(fileTimings[file] ?? toolingFileWeight(file, fallbackTimings)) *
|
|
(runnerBackend === "github" && fileTimings[file] === undefined
|
|
? COMPACT_GITHUB_GROUP_SECONDS_SCALE
|
|
: 1),
|
|
);
|
|
// File observations retain their elapsed cost under parallel execution. Old
|
|
// numbered parent/child spans describe serial files and cannot price this lane.
|
|
return Math.max(
|
|
0,
|
|
...weights,
|
|
weights.reduce((sum, seconds) => sum + seconds, 0) / Math.max(1, workers),
|
|
);
|
|
}
|
|
|
|
function estimateDefaultCompactGroupSeconds(group: NodeTestShardGroup): number {
|
|
if (isParallelToolingGroup(group) && group.includePatterns) {
|
|
return estimateParallelToolingSeconds(group, group.includePatterns, "blacksmith");
|
|
}
|
|
const measured = readCompactGroupSeconds(group, "blacksmith");
|
|
if (measured !== undefined) {
|
|
return measured;
|
|
}
|
|
const hint = isParallelGatewayServerGroup(group)
|
|
? estimateLegacyGatewayServerSeconds(
|
|
group,
|
|
"blacksmith",
|
|
COMPACT_GROUP_SECONDS_HINTS.get(group.shard_name),
|
|
)
|
|
: ((isAutoReplyReplyGroup(group)
|
|
? readCompactGroupTimings("blacksmith")[group.shard_name]
|
|
: undefined) ??
|
|
readSerialAgentsCoreSeconds(group, "blacksmith") ??
|
|
(isParallelAgentsCoreGroup(group)
|
|
? COMPACT_LARGE_GROUP_STRIPE_SECONDS_HINTS.get(group.shard_name)
|
|
: undefined) ??
|
|
fileParallelAgentFallbackSeconds(group, COMPACT_GROUP_SECONDS_HINTS.get(group.shard_name)));
|
|
if (hint !== undefined) {
|
|
return parallelCompactFallbackSeconds(group, hint) / effectiveAgentsCoreWorkers(group);
|
|
}
|
|
if (Array.isArray(group.includePatterns)) {
|
|
return (
|
|
parallelCompactFallbackSeconds(
|
|
group,
|
|
Math.max(3, Math.round(group.includePatterns.length * DEFAULT_SECONDS_PER_TEST_FILE)),
|
|
) / effectiveAgentsCoreWorkers(group)
|
|
);
|
|
}
|
|
return DEFAULT_WHOLE_GROUP_SECONDS;
|
|
}
|
|
|
|
function usesExpandedRunnerProfile(runnerBackend: string | undefined): boolean {
|
|
return runnerBackend === "github" || runnerBackend === "hybrid";
|
|
}
|
|
|
|
// Hand-fitted tables stand in only until a group has direct Blacksmith samples,
|
|
// so a committed measurement owns the weight and the table covers the rest.
|
|
// Reading the tables first made every pinned group immune to the nightly refit:
|
|
// pins stale-low kept packing partners onto the tallest bins, and pins
|
|
// stale-high kept splitting groups that had since become cheap.
|
|
function readUnmeasuredCompactHint(
|
|
group: NodeTestShardGroup,
|
|
hints: ReadonlyMap<string, number>,
|
|
): number | undefined {
|
|
const timings = readCompactGroupTimings("blacksmith");
|
|
if (
|
|
readCompactGroupSeconds(group, "blacksmith") !== undefined ||
|
|
readSerialAgentsCoreSeconds(group, "blacksmith") !== undefined ||
|
|
(isAutoReplyReplyGroup(group) && timings[group.shard_name] !== undefined) ||
|
|
readSerialAutoReplySplitSeconds(group, "blacksmith") !== undefined
|
|
) {
|
|
return undefined;
|
|
}
|
|
const hint = fileParallelAgentFallbackSeconds(group, hints.get(group.shard_name));
|
|
return hint === undefined ? undefined : parallelCompactFallbackSeconds(group, hint);
|
|
}
|
|
|
|
function estimateHybridCompactGroupSeconds(group: NodeTestShardGroup, seconds: number): number {
|
|
// Hybrid first attempts use the same Blacksmith allocation as these samples.
|
|
// A historical hosted-to-Blacksmith factor must not discount a direct wall.
|
|
if (readCompactGroupSeconds(group, "blacksmith") !== undefined) {
|
|
return seconds;
|
|
}
|
|
// The 4,723s Blacksmith push hint sum measured 3,742.046s/3,756.674s
|
|
// (79.230%/79.540%) in runs 31945998653/31949756966. A 0.87 scale keeps
|
|
// 9.379% headroom above the higher ratio. With direct outlier hints, it sits
|
|
// one point above the 0.86 packing cliff.
|
|
const hint = readUnmeasuredCompactHint(group, COMPACT_HYBRID_GROUP_SECONDS_HINTS);
|
|
return hint === undefined
|
|
? Math.round(seconds * COMPACT_HYBRID_GROUP_SECONDS_SCALE)
|
|
: hint / effectiveAgentsCoreWorkers(group);
|
|
}
|
|
|
|
function estimateCompactGroupSeconds(
|
|
group: NodeTestShardGroup,
|
|
runnerBackend: string | undefined,
|
|
): number {
|
|
if (isParallelToolingGroup(group) && group.includePatterns) {
|
|
return estimateParallelToolingSeconds(group, group.includePatterns, runnerBackend);
|
|
}
|
|
if (
|
|
isParallelCommandsGroup(group) &&
|
|
group.timing_key?.endsWith(COMMANDS_PARALLEL_TIMING_SUFFIX)
|
|
) {
|
|
const profile = runnerBackend === "github" ? "github" : "blacksmith";
|
|
const measured = readCompactGroupTimings(profile)[group.timing_key];
|
|
if (measured !== undefined) {
|
|
return runnerBackend === "hybrid"
|
|
? estimateHybridCompactGroupSeconds(group, measured)
|
|
: measured;
|
|
}
|
|
return estimateSerialCommandSeconds(group, runnerBackend, (source) =>
|
|
estimateCompactGroupSeconds({ ...group, ...source, timing_key: undefined }, runnerBackend),
|
|
);
|
|
}
|
|
const defaultSeconds = estimateDefaultCompactGroupSeconds(group);
|
|
// Hybrid attempt 1 runs on Blacksmith. It keeps the expanded topology for
|
|
// hosted retries, but its packing weights must describe the runner that
|
|
// normally executes the plan.
|
|
if (runnerBackend === "hybrid") {
|
|
return Math.max(
|
|
isParallelGatewayServerGroup(group)
|
|
? gatewayServerSerialSeconds(group.includePatterns, runnerBackend)
|
|
: 0,
|
|
estimateHybridCompactGroupSeconds(group, defaultSeconds),
|
|
);
|
|
}
|
|
if (runnerBackend !== "github") {
|
|
return Math.max(
|
|
defaultSeconds,
|
|
isParallelGatewayServerGroup(group)
|
|
? gatewayServerSerialSeconds(group.includePatterns, runnerBackend)
|
|
: 0,
|
|
);
|
|
}
|
|
const serialFloor = isParallelGatewayServerGroup(group)
|
|
? gatewayServerSerialSeconds(group.includePatterns, runnerBackend)
|
|
: 0;
|
|
const measured = readCompactGroupSeconds(group, "github");
|
|
if (measured !== undefined) {
|
|
return Math.max(serialFloor, measured);
|
|
}
|
|
const hint = isParallelGatewayServerGroup(group)
|
|
? estimateLegacyGatewayServerSeconds(
|
|
group,
|
|
"github",
|
|
COMPACT_GITHUB_GROUP_SECONDS_HINTS.get(group.shard_name),
|
|
)
|
|
: ((isAutoReplyReplyGroup(group)
|
|
? readCompactGroupTimings("github")[group.shard_name]
|
|
: undefined) ??
|
|
readSerialAgentsCoreSeconds(group, "github") ??
|
|
fileParallelAgentFallbackSeconds(
|
|
group,
|
|
COMPACT_GITHUB_GROUP_SECONDS_HINTS.get(group.shard_name),
|
|
));
|
|
return Math.max(
|
|
serialFloor,
|
|
hint === undefined
|
|
? Math.round(defaultSeconds * COMPACT_GITHUB_GROUP_SECONDS_SCALE)
|
|
: parallelCompactFallbackSeconds(group, hint, "github") / effectiveAgentsCoreWorkers(group),
|
|
parallelCompactFallbackSeconds(group, 0, "github"),
|
|
);
|
|
}
|
|
|
|
function estimateCompactStripeSeconds(
|
|
group: NodeTestShardGroup,
|
|
runnerBackend: string | undefined,
|
|
): number {
|
|
if (isParallelToolingGroup(group) && group.includePatterns) {
|
|
return estimateParallelToolingSeconds(group, group.includePatterns, runnerBackend);
|
|
}
|
|
if (
|
|
(isParallelAgentsCoreGroup(group) && group.timing_key === `${group.shard_name}-parallel`) ||
|
|
group.timing_key?.endsWith(COMMANDS_PARALLEL_TIMING_SUFFIX)
|
|
) {
|
|
return estimateCompactGroupSeconds(group, runnerBackend);
|
|
}
|
|
const split = group.timing_key && parseCompactSplitTimingKey(group.timing_key);
|
|
if (split && group.timing_key) {
|
|
// The parent-derived floor owns a new split until its exact child has samples.
|
|
// File-count fallbacks would price the same work again after partitioning.
|
|
const timings = readCompactGroupTimings(runnerBackend === "github" ? "github" : "blacksmith");
|
|
const exact = timings[group.timing_key];
|
|
if (exact !== undefined) {
|
|
return exact;
|
|
}
|
|
// Repacking siblings changes the generation, not an unchanged child's
|
|
// config, worker environment, or file membership within this selector.
|
|
const membership = group.timing_key.slice(group.timing_key.lastIndexOf("#include-"));
|
|
return Math.max(
|
|
0,
|
|
...Object.entries(timings)
|
|
.filter(
|
|
([key]) =>
|
|
key.endsWith(membership) &&
|
|
parseCompactSplitTimingKey(key)?.selectorKey === split.selectorKey,
|
|
)
|
|
.map(([, seconds]) => seconds),
|
|
);
|
|
}
|
|
if (
|
|
runnerBackend === "github" ||
|
|
isParallelGatewayServerGroup(group) ||
|
|
isAutoReplyReplyGroup(group)
|
|
) {
|
|
return estimateCompactGroupSeconds(group, runnerBackend);
|
|
}
|
|
const blacksmithSeconds =
|
|
readUnmeasuredCompactHint(group, COMPACT_LARGE_GROUP_STRIPE_SECONDS_HINTS) ??
|
|
estimateDefaultCompactGroupSeconds(group);
|
|
return runnerBackend === "hybrid"
|
|
? estimateHybridCompactGroupSeconds(group, blacksmithSeconds)
|
|
: blacksmithSeconds;
|
|
}
|
|
|
|
// Identify split siblings, including nested children of deliberately separated
|
|
// fixed stripes.
|
|
function compactStripeFamily(group: NodeTestShardGroup): string | undefined {
|
|
if (
|
|
/^agentic-commands-doctor-sessions-cron(?:-(?:memory|sqlite(?:-recovery)?))?(?:-hosted-\d+)*$/u.test(
|
|
group.shard_name,
|
|
)
|
|
) {
|
|
return "agentic-commands-doctor-sessions-cron";
|
|
}
|
|
return (
|
|
/^(agentic-agents-embedded-base|agentic-gateway-core|core-runtime-media-ui|core-unit-src-security)-\d+(?:-hosted-\d+)*$/u.exec(
|
|
group.shard_name,
|
|
)?.[1] ??
|
|
(group.timing_key ? parseCompactSplitTimingKey(group.timing_key)?.selectorKey : undefined)
|
|
);
|
|
}
|
|
|
|
function expandCompactGroup(
|
|
group: NodeTestShardGroup,
|
|
runnerBackend: string | undefined,
|
|
): NodeTestShardGroup[] {
|
|
if (group.shard_name !== "agentic-agents-embedded") {
|
|
return [group];
|
|
}
|
|
if (group.configs.length !== COMPACT_EMBEDDED_GROUP_NAMES.length) {
|
|
throw new Error("embedded compact group names must cover every config");
|
|
}
|
|
|
|
const expandedGroups: NodeTestShardGroup[] = [];
|
|
const selectedFiles = group.includePatterns ? new Set(group.includePatterns) : undefined;
|
|
for (const [index, config] of group.configs.entries()) {
|
|
const shardName = COMPACT_EMBEDDED_GROUP_NAMES[index];
|
|
if (!shardName) {
|
|
throw new Error("embedded compact group name is missing");
|
|
}
|
|
const configFiles = listNodeTestConfigFiles(config);
|
|
if (!configFiles) {
|
|
throw new Error(`embedded compact config lacks its file owner: ${config}`);
|
|
}
|
|
const selectedConfigFiles = selectedFiles
|
|
? configFiles.filter((file) => selectedFiles.has(file))
|
|
: undefined;
|
|
if (selectedConfigFiles?.length === 0) {
|
|
continue;
|
|
}
|
|
if (shardName !== COMPACT_EMBEDDED_BASE_GROUP_NAME) {
|
|
expandedGroups.push({
|
|
...group,
|
|
configs: [config],
|
|
...(selectedConfigFiles
|
|
? { includePatterns: selectedConfigFiles, timing_key: `changed-${shardName}` }
|
|
: {}),
|
|
shard_name: shardName,
|
|
});
|
|
continue;
|
|
}
|
|
const stripes = createStripedBatches(
|
|
configFiles,
|
|
EMBEDDED_BASE_NODE_TEST_STRIPES,
|
|
stripeFileWeight,
|
|
);
|
|
for (const [stripeIndex, stripe] of stripes.entries()) {
|
|
const includePatterns = selectedFiles
|
|
? stripe.filter((file) => selectedFiles.has(file))
|
|
: stripe;
|
|
// An empty include list makes the shard runner drop the include file and
|
|
// run the whole config, so every stripe would replay the entire suite.
|
|
if (includePatterns.length === 0) {
|
|
if (selectedFiles) {
|
|
continue;
|
|
}
|
|
throw new Error("embedded base stripe cannot be empty");
|
|
}
|
|
expandedGroups.push({
|
|
...group,
|
|
configs: [config],
|
|
includePatterns,
|
|
...(selectedFiles ? { timing_key: `changed-${shardName}-${stripeIndex + 1}` } : {}),
|
|
shard_name: `${shardName}-${stripeIndex + 1}`,
|
|
});
|
|
}
|
|
}
|
|
return expandedGroups.map((expandedGroup) =>
|
|
applyCompactGroupWorkerPins(expandedGroup, runnerBackend),
|
|
);
|
|
}
|
|
const TOOLING_CONFIG = "test/vitest/vitest.tooling.config.ts";
|
|
const TOOLING_DOCKER_TEST_FILE = "test/scripts/docker-build-helper.test.ts";
|
|
const TOOLING_UNIFIED_DECLARATIONS_TEST_FILE = "test/scripts/write-unified-entry-dts.test.ts";
|
|
const TOOLING_LARGE_CAPACITY_TEST_FILES = new Set([
|
|
// Generation-retention cases require eight CPUs / 24 GiB; the two-CPU
|
|
// screen also peaked at 6.05 GiB before those gated cases could run.
|
|
"test/scripts/vitest-worker-artifacts.ci.test.ts",
|
|
"test/scripts/write-unified-entry-dts.test.ts",
|
|
"test/scripts/write-plugin-sdk-entry-dts.test.ts",
|
|
]);
|
|
const TOOLING_ISOLATED_CONFIG = "test/vitest/vitest.tooling-isolated.config.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([
|
|
// Admit the broad unit-fast graphs before short alphabetical groups.
|
|
["core-unit-fast-1", 0],
|
|
["core-unit-fast-2", 0],
|
|
...Array.from(
|
|
{ length: COMPACT_TOOLING_NODE_TEST_GROUPS },
|
|
(_, index) => [`core-tooling-${index + 1}`, 1] as const,
|
|
),
|
|
]);
|
|
// 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"]);
|
|
const KEEP_LARGE_NODE_TEST_RUNNER = new Set([
|
|
"agentic-agents-core-auth",
|
|
"agentic-agents-core-models",
|
|
"agentic-agents-core-runtime",
|
|
"agentic-agents-core-subagents",
|
|
"agentic-agents-embedded",
|
|
"agentic-agents-support",
|
|
"agentic-agents-core-runner-cli-1",
|
|
"agentic-agents-core-runner-cli-2",
|
|
"agentic-agents-core-runner-cli-3",
|
|
"agentic-agents-core-runner-commands",
|
|
"agentic-agents-core-runner-embedded",
|
|
"agentic-agents-core-runner-sessions",
|
|
"agentic-agents-core-tools",
|
|
"agentic-control-plane-startup-core",
|
|
"agentic-gateway-core-1",
|
|
"agentic-gateway-core-2",
|
|
"agentic-gateway-core-3",
|
|
"agentic-gateway-methods",
|
|
"agentic-gateway-server-isolated",
|
|
"auto-reply-reply-dispatch",
|
|
"auto-reply-reply-dispatch-core",
|
|
"auto-reply-reply-dispatch-delivery",
|
|
"auto-reply-reply-dispatch-lifecycle",
|
|
// The commands stripes and security suite are import-bound (30-45s of
|
|
// module-graph import per file); the 8 vCPU class with a higher Vitest
|
|
// worker budget cuts their wall clock roughly linearly.
|
|
"auto-reply-reply-commands-1",
|
|
"auto-reply-reply-commands-2",
|
|
"auto-reply-reply-commands-3",
|
|
"core-runtime-media-ui-1",
|
|
"core-runtime-media-ui-2",
|
|
"core-runtime-media-ui-3",
|
|
"core-runtime-media-ui-support",
|
|
"core-unit-fast-1",
|
|
"core-unit-fast-2",
|
|
"core-unit-fast-isolated",
|
|
"core-unit-src-security-1",
|
|
"core-unit-src-security-2",
|
|
"core-unit-src-security-3",
|
|
"core-unit-src-security-support",
|
|
]);
|
|
const RELEASE_ONLY_PLUGIN_SHARDS = new Set(["agentic-plugins"]);
|
|
const RELEASE_ONLY_TOOLING_SHARDS = new Set(["core-tooling"]);
|
|
const RELEASE_ONLY_UI_TEST_FILES = new Set([
|
|
"ui/src/e2e/activity-run-inspector.real-gateway.e2e.test.ts",
|
|
"ui/src/e2e/board-fixture.e2e.test.ts",
|
|
"ui/src/e2e/chat-attachment-menu.e2e.test.ts",
|
|
"ui/src/e2e/chat-mobile-bubble-margin.e2e.test.ts",
|
|
"ui/src/e2e/github-link-hovercard.e2e.test.ts",
|
|
"ui/src/e2e/settings-layout.e2e.test.ts",
|
|
"ui/src/e2e/theme-muted-contrast.e2e.test.ts",
|
|
"ui/src/e2e/native-embed-settings.e2e.test.ts",
|
|
"ui/src/e2e/chat-session-entry.e2e.test.ts",
|
|
"ui/src/components/app-sidebar.stress.browser.test.ts",
|
|
"ui/src/e2e/cron-duration-save.real-gateway.e2e.test.ts",
|
|
"ui/src/e2e/desktop-resize.real-gateway.e2e.test.ts",
|
|
"extensions/qa-lab/src/control-ui-automation-management.real-gateway.e2e.test.ts",
|
|
"ui/src/e2e/quota-reset-status.real-gateway.e2e.test.ts",
|
|
"ui/src/e2e/session-pr-reader-lifetime.real-gateway.e2e.test.ts",
|
|
"ui/src/e2e/chat-collaborator-scroll.real-gateway.e2e.test.ts",
|
|
"ui/src/e2e/mcp-app-conformance.e2e.test.ts",
|
|
"ui/src/e2e/usage-sessions-owner-attribution.e2e.test.ts",
|
|
"extensions/qa-lab/src/control-ui-openclaw-delegation.real-gateway.e2e.test.ts",
|
|
"extensions/qa-lab/src/control-ui-media-transcript.real-gateway.e2e.test.ts",
|
|
"extensions/qa-lab/src/session-host-command-state.real-gateway.e2e.test.ts",
|
|
]);
|
|
|
|
export function createUiTestShardGroups(
|
|
options: RuntimeTestSelection & { includeReleaseOnlyTests?: boolean } = {},
|
|
) {
|
|
const includeReleaseOnlyTests = options.includeReleaseOnlyTests ?? true;
|
|
const changedPaths = new Set(options.changedPaths ?? []);
|
|
const files =
|
|
includeReleaseOnlyTests && options.includePrExemptRuntimeTests !== false
|
|
? undefined
|
|
: listTrackedTestFiles(".").filter(
|
|
(file) =>
|
|
(includeReleaseOnlyTests ||
|
|
!RELEASE_ONLY_UI_TEST_FILES.has(file) ||
|
|
changedPaths.has(file)) &&
|
|
isRuntimeTestFileIncluded(file, options),
|
|
);
|
|
const group = (config: string, ownsFile: (file: string) => boolean) => [
|
|
{
|
|
configs: [config],
|
|
shard_name: config,
|
|
...(files ? { includePatterns: files.filter(ownsFile) } : {}),
|
|
},
|
|
];
|
|
return {
|
|
ui: group("ui/vitest.config.ts", isUiTestTarget),
|
|
e2e: group(
|
|
"test/vitest/vitest.ui-e2e.config.ts",
|
|
(file) =>
|
|
controlUiE2eTestGlobs.some((pattern) => matchesGlob(file, pattern)) ||
|
|
uiE2eRealGatewayTestFiles.includes(file),
|
|
),
|
|
};
|
|
}
|
|
|
|
export function createUiRealGatewayTestShards(
|
|
e2eGroups: ReturnType<typeof createUiTestShardGroups>["e2e"],
|
|
) {
|
|
const selected = new Set(
|
|
e2eGroups.flatMap((group) => group.includePatterns ?? uiE2eRealGatewayTestFiles),
|
|
);
|
|
const parallelFiles = new Set(uiE2ePrebuiltParallelTestFiles);
|
|
// Balance the serial phase with standalone fixtures that need no preview build.
|
|
const standaloneCompanions = new Set([
|
|
"ui/src/e2e/chat-loading-performance.real-gateway.e2e.test.ts",
|
|
"ui/src/e2e/chat-project-media.real-gateway.e2e.test.ts",
|
|
"ui/src/e2e/chat-widget-sandbox.real-gateway.e2e.test.ts",
|
|
"ui/src/e2e/command-palette-catalog.real-gateway.e2e.test.ts",
|
|
"ui/src/e2e/model-api-keys.real-gateway.e2e.test.ts",
|
|
"ui/src/e2e/model-catalog-partial-refresh.real-gateway.e2e.test.ts",
|
|
]);
|
|
const desktop = "ui/src/e2e/desktop-resize.real-gateway.e2e.test.ts";
|
|
const files = uiE2eRealGatewayTestFiles.filter((file) => selected.has(file) && file !== desktop);
|
|
// Desktop transport proof owns its file separately, alongside the serial phase.
|
|
return ([1, 2] as const).map((shard) => ({
|
|
shard,
|
|
shard_count: 2 as const,
|
|
run_desktop: shard === 1 && selected.has(desktop),
|
|
groups: [
|
|
{
|
|
configs: ["test/vitest/vitest.ui-e2e-prebuilt.config.ts"],
|
|
shard_name: `ui-e2e-real-gateway-${shard === 1 ? "desktop" : "parallel"}`,
|
|
includePatterns: files.filter(
|
|
(file) => (parallelFiles.has(file) && !standaloneCompanions.has(file)) === (shard === 2),
|
|
),
|
|
},
|
|
],
|
|
}));
|
|
}
|
|
|
|
export const RELEASE_ONLY_TOOLING_CONFIGS = new Set(
|
|
fullSuiteVitestShards
|
|
.filter((shard) => RELEASE_ONLY_TOOLING_SHARDS.has(shard.name))
|
|
.flatMap((shard) => shard.projects),
|
|
);
|
|
|
|
export function isReleaseOnlyToolingTestFile(file: string): boolean {
|
|
return (
|
|
!file.endsWith(".e2e.test.ts") &&
|
|
!file.endsWith(".live.test.ts") &&
|
|
(file.startsWith("test/scripts/") || file.startsWith("src/scripts/"))
|
|
);
|
|
}
|
|
|
|
function listToolingGroupFiles(configs: readonly string[]): string[] | undefined {
|
|
const files = [
|
|
...(configs.includes(TOOLING_CONFIG) ? listCompactToolingTestFiles() : []),
|
|
...(configs.includes(TOOLING_ISOLATED_CONFIG) ? toolingIsolatedTestFiles : []),
|
|
...(configs.includes("test/vitest/vitest.tooling-docker.config.ts")
|
|
? [TOOLING_DOCKER_TEST_FILE]
|
|
: []),
|
|
...(configs.includes("test/vitest/vitest.unit-fast-isolated.config.ts")
|
|
? getUnitFastIsolatedTestFiles()
|
|
: []),
|
|
...(configs.includes("test/vitest/vitest.unit-fast-fake-timers.config.ts")
|
|
? getUnitFastTimerTestFiles()
|
|
: []),
|
|
];
|
|
return files.length > 0 ? files : undefined;
|
|
}
|
|
|
|
function resolveTestFilesBuildMode(files: readonly string[]): NodeTestPretestBuildMode | undefined {
|
|
// Planner inventories contain resolved paths. Preserve their exact membership
|
|
// instead of reparsing every file as a glob against the runtime consumers.
|
|
const selectedFiles = new Set(files);
|
|
return resolveVitestPretestBuildMode([{ matchesFile: (file) => selectedFiles.has(file) }]);
|
|
}
|
|
|
|
function createAutoReplyReplySplitShards(): NodeTestSplitShard[] {
|
|
const files = listTrackedTestFiles("src/auto-reply/reply");
|
|
const groups = {
|
|
"auto-reply-reply-agent-runner": [] as string[],
|
|
"auto-reply-reply-commands": [] as string[],
|
|
"auto-reply-reply-dispatch": [] as string[],
|
|
"auto-reply-reply-dispatch-core": [] as string[],
|
|
"auto-reply-reply-dispatch-delivery": [] as string[],
|
|
"auto-reply-reply-dispatch-lifecycle": [] as string[],
|
|
"auto-reply-reply-session": [] as string[],
|
|
"auto-reply-reply-state-routing": [] as string[],
|
|
};
|
|
const dispatchEntrypoints = new Map<string, keyof typeof groups>([
|
|
["dispatch-from-config.test.ts", "auto-reply-reply-dispatch-core"],
|
|
["dispatch-from-config.delivery.test.ts", "auto-reply-reply-dispatch-delivery"],
|
|
["dispatch-from-config.lifecycle.test.ts", "auto-reply-reply-dispatch-lifecycle"],
|
|
]);
|
|
|
|
for (const file of files) {
|
|
const name = relative("src/auto-reply/reply", file).replaceAll("\\", "/");
|
|
const dispatchEntrypointGroup = dispatchEntrypoints.get(name);
|
|
if (dispatchEntrypointGroup) {
|
|
groups[dispatchEntrypointGroup].push(file);
|
|
continue;
|
|
}
|
|
if (
|
|
name.startsWith("agent-runner") ||
|
|
name.startsWith("acp-") ||
|
|
name === "abort.test.ts" ||
|
|
name === "bash-command.stop.test.ts" ||
|
|
name.startsWith("block-")
|
|
) {
|
|
groups["auto-reply-reply-agent-runner"].push(file);
|
|
} else if (name.startsWith("commands")) {
|
|
groups["auto-reply-reply-commands"].push(file);
|
|
} else if (
|
|
name.startsWith("directive-") ||
|
|
name.startsWith("dispatch") ||
|
|
name.startsWith("followup-") ||
|
|
name.startsWith("get-reply")
|
|
) {
|
|
groups["auto-reply-reply-dispatch"].push(file);
|
|
} else if (name.startsWith("session")) {
|
|
groups["auto-reply-reply-session"].push(file);
|
|
} else {
|
|
groups["auto-reply-reply-state-routing"].push(file);
|
|
}
|
|
}
|
|
|
|
return Object.entries(groups)
|
|
.flatMap(([groupName, includePatterns]) => {
|
|
// Retain separate command stripes so packing can spread their import cost across jobs.
|
|
if (groupName === "auto-reply-reply-commands") {
|
|
return createStripedBatches(
|
|
includePatterns,
|
|
AUTO_REPLY_COMMANDS_STRIPES,
|
|
stripeFileWeight,
|
|
).map((batch, index) => ({
|
|
configs: ["test/vitest/vitest.auto-reply-reply.config.ts"],
|
|
includePatterns: batch,
|
|
requiresDist: false,
|
|
shardName: `${groupName}-${index + 1}`,
|
|
}));
|
|
}
|
|
return [
|
|
{
|
|
configs: ["test/vitest/vitest.auto-reply-reply.config.ts"],
|
|
includePatterns,
|
|
requiresDist: false,
|
|
shardName: groupName,
|
|
},
|
|
];
|
|
})
|
|
.filter((shard) => shard.includePatterns.length > 0);
|
|
}
|
|
|
|
function resolveAgentCoreShardName(file: string): string {
|
|
const name = relative("src/agents", file).replaceAll("\\", "/");
|
|
if (
|
|
name.includes("auth") ||
|
|
name.includes("credential") ||
|
|
name.includes("api-key") ||
|
|
name.includes("token")
|
|
) {
|
|
return "agentic-agents-core-auth";
|
|
}
|
|
if (
|
|
name.startsWith("model") ||
|
|
name.includes("provider") ||
|
|
name.includes("openai") ||
|
|
name.includes("anthropic") ||
|
|
name.includes("gemini") ||
|
|
name.includes("moonshot") ||
|
|
name.includes("minimax") ||
|
|
name.includes("xai") ||
|
|
name.includes("zai") ||
|
|
name.includes("chutes") ||
|
|
name.includes("catalog")
|
|
) {
|
|
return "agentic-agents-core-models";
|
|
}
|
|
if (
|
|
name.startsWith("agent-tools") ||
|
|
name.startsWith("openclaw-tools") ||
|
|
name.startsWith("bash-tools") ||
|
|
name.startsWith("tool") ||
|
|
name.startsWith("apply-patch") ||
|
|
name.startsWith("exec") ||
|
|
name.startsWith("sandbox")
|
|
) {
|
|
return "agentic-agents-core-tools";
|
|
}
|
|
if (
|
|
name.startsWith("subagent") ||
|
|
name.startsWith("spawn") ||
|
|
name.startsWith("embedded-agent-subscribe")
|
|
) {
|
|
return "agentic-agents-core-subagents";
|
|
}
|
|
// The former single "core-runner" bucket serialized ~3 minutes of tests in
|
|
// one group; keep these three slices separate so packing can balance them.
|
|
if (name.startsWith("embedded-agent-runner")) {
|
|
return "agentic-agents-core-runner-embedded";
|
|
}
|
|
if (
|
|
name.startsWith("agent-command") ||
|
|
name.startsWith("command") ||
|
|
name.includes("compaction")
|
|
) {
|
|
return "agentic-agents-core-runner-commands";
|
|
}
|
|
if (name.startsWith("cli-runner")) {
|
|
return "agentic-agents-core-runner-cli";
|
|
}
|
|
if (name.includes("session")) {
|
|
return "agentic-agents-core-runner-sessions";
|
|
}
|
|
return "agentic-agents-core-runtime";
|
|
}
|
|
|
|
function createAgentCoreSplitShards(): NodeTestSplitShard[] {
|
|
const excludedTests = new Set(agentVitestProjectOwners.core.exclude);
|
|
const groups = new Map<string, string[]>();
|
|
for (const file of listTrackedTestFiles("src/agents")) {
|
|
const name = relative("src/agents", file).replaceAll("\\", "/");
|
|
if (name.includes("/") || excludedTests.has(file)) {
|
|
continue;
|
|
}
|
|
const shardName = resolveAgentCoreShardName(file);
|
|
groups.set(shardName, [...(groups.get(shardName) ?? []), file]);
|
|
}
|
|
|
|
const sharedShards = [
|
|
"agentic-agents-core-auth",
|
|
"agentic-agents-core-models",
|
|
"agentic-agents-core-tools",
|
|
"agentic-agents-core-subagents",
|
|
"agentic-agents-core-runner-cli",
|
|
"agentic-agents-core-runner-commands",
|
|
"agentic-agents-core-runner-embedded",
|
|
"agentic-agents-core-runner-sessions",
|
|
"agentic-agents-core-runtime",
|
|
]
|
|
.flatMap((shardName) => {
|
|
const includePatterns = groups.get(shardName) ?? [];
|
|
// Retain the import-heavy CLI stripes; their files also share the bounded
|
|
// worker pool, while timing estimates account for the effective file workers.
|
|
if (shardName === "agentic-agents-core-runner-cli") {
|
|
return createStripedBatches(
|
|
includePatterns,
|
|
AGENTS_CORE_RUNNER_CLI_STRIPES,
|
|
stripeFileWeight,
|
|
).map((batch, index) => ({
|
|
configs: [agentVitestProjectOwners.core.config],
|
|
includePatterns: batch,
|
|
requiresDist: false,
|
|
shardName: `${shardName}-${index + 1}`,
|
|
}));
|
|
}
|
|
return [
|
|
{
|
|
configs: [agentVitestProjectOwners.core.config],
|
|
includePatterns,
|
|
requiresDist: false,
|
|
shardName,
|
|
},
|
|
];
|
|
})
|
|
.filter((shard) => shard.includePatterns.length > 0);
|
|
|
|
return [
|
|
...sharedShards,
|
|
{
|
|
configs: [agentVitestProjectOwners.spawnProductionBoundary.config],
|
|
includePatterns: agentVitestProjectOwners.spawnProductionBoundary.include,
|
|
requiresDist: false,
|
|
shardName: "agentic-agents-core-spawn-production-boundary",
|
|
},
|
|
{
|
|
configs: [agentVitestProjectOwners.coreIsolated.config],
|
|
includePatterns: agentVitestProjectOwners.coreIsolated.include,
|
|
requiresDist: false,
|
|
shardName: "agentic-agents-core-isolated",
|
|
},
|
|
];
|
|
}
|
|
|
|
function resolveGatewayStartupShardName(file: string): string {
|
|
const name = relative("src/gateway", file).replaceAll("\\", "/");
|
|
if (name.startsWith("server-startup-config") || name.startsWith("server-startup-early")) {
|
|
return "agentic-control-plane-startup-config";
|
|
}
|
|
if (
|
|
name.startsWith("server-runtime") ||
|
|
name.startsWith("server.health") ||
|
|
name.startsWith("server.lazy")
|
|
) {
|
|
return "agentic-control-plane-startup-health-runtime";
|
|
}
|
|
if (name.startsWith("server-restart") || name === "server-close.test.ts") {
|
|
return "agentic-control-plane-startup-restart-close";
|
|
}
|
|
return "agentic-control-plane-startup-core";
|
|
}
|
|
|
|
function resolveGatewayServerShardName(file: string): string {
|
|
const name = relative("src/gateway", file).replaceAll("\\", "/");
|
|
if (
|
|
isGatewayServerBackedHttpTestFile(file) ||
|
|
name.startsWith("server.models") ||
|
|
name.startsWith("server.talk")
|
|
) {
|
|
return "agentic-control-plane-http-models";
|
|
}
|
|
if (
|
|
name.startsWith("server.agent") ||
|
|
name.startsWith("server.chat") ||
|
|
name.startsWith("server.sessions")
|
|
) {
|
|
return "agentic-control-plane-agent-chat";
|
|
}
|
|
if (
|
|
name.includes("auth") ||
|
|
name.includes("device") ||
|
|
name.includes("node") ||
|
|
name.includes("roles") ||
|
|
name.includes("silent") ||
|
|
name.includes("preauth") ||
|
|
name.includes("control-plane-rate-limit")
|
|
) {
|
|
return "agentic-control-plane-auth-node";
|
|
}
|
|
if (
|
|
name.startsWith("server-startup") ||
|
|
name.startsWith("server-restart") ||
|
|
name.startsWith("server-runtime") ||
|
|
name.startsWith("server.lazy") ||
|
|
name.startsWith("server.health") ||
|
|
name === "server-close.test.ts"
|
|
) {
|
|
return resolveGatewayStartupShardName(file);
|
|
}
|
|
if (name.includes("cron")) {
|
|
return "agentic-control-plane-runtime-cron";
|
|
}
|
|
if (
|
|
name.includes("plugin") ||
|
|
name.includes("hooks") ||
|
|
name.includes("http") ||
|
|
name.includes("ws-connection")
|
|
) {
|
|
return "agentic-control-plane-http-plugin-ws";
|
|
}
|
|
if (name.startsWith("server-")) {
|
|
return "agentic-control-plane-runtime-server";
|
|
}
|
|
if (name.startsWith("server.config-patch")) {
|
|
return "agentic-control-plane-runtime-config";
|
|
}
|
|
if (name.startsWith("server.shared-token")) {
|
|
return "agentic-control-plane-runtime-shared-token";
|
|
}
|
|
if (
|
|
name.startsWith("server.control-ui-root") ||
|
|
name.startsWith("server.ios-client-id") ||
|
|
name.startsWith("server.tools-catalog")
|
|
) {
|
|
return "agentic-control-plane-runtime-ui-tools";
|
|
}
|
|
if (name.startsWith("server.")) {
|
|
return "agentic-control-plane-runtime-state";
|
|
}
|
|
return "agentic-control-plane-runtime";
|
|
}
|
|
|
|
function createGatewayServerSplitShards(): NodeTestSplitShard[] {
|
|
const groups = new Map<string, string[]>();
|
|
for (const file of listTrackedTestFiles("src/gateway").filter(isGatewayServerTestFile)) {
|
|
const shardName = resolveGatewayServerShardName(file);
|
|
groups.set(shardName, [...(groups.get(shardName) ?? []), file]);
|
|
}
|
|
return [
|
|
"agentic-control-plane-agent-chat",
|
|
"agentic-control-plane-auth-node",
|
|
"agentic-control-plane-http-models",
|
|
"agentic-control-plane-http-plugin-ws",
|
|
"agentic-control-plane-runtime",
|
|
"agentic-control-plane-runtime-config",
|
|
"agentic-control-plane-runtime-cron",
|
|
"agentic-control-plane-runtime-server",
|
|
"agentic-control-plane-runtime-shared-token",
|
|
"agentic-control-plane-runtime-state",
|
|
"agentic-control-plane-runtime-ui-tools",
|
|
"agentic-control-plane-startup-config",
|
|
"agentic-control-plane-startup-core",
|
|
"agentic-control-plane-startup-health-runtime",
|
|
"agentic-control-plane-startup-restart-close",
|
|
]
|
|
.map((shardName) => ({
|
|
configs: ["test/vitest/vitest.gateway-server.config.ts"],
|
|
env:
|
|
shardName === "agentic-control-plane-startup-health-runtime"
|
|
? GATEWAY_STARTUP_HEALTH_RUNTIME_ENV
|
|
: undefined,
|
|
includePatterns: groups.get(shardName) ?? [],
|
|
requiresDist: false,
|
|
runner:
|
|
shardName === "agentic-control-plane-startup-core"
|
|
? GATEWAY_STARTUP_CORE_RUNNER
|
|
: BUNDLED_NODE_TEST_RUNNER,
|
|
shardName,
|
|
}))
|
|
.filter((shard) => shard.includePatterns.length > 0);
|
|
}
|
|
|
|
function resolveCronShardName(file: string): string {
|
|
const name = relative("src/cron", file).replaceAll("\\", "/");
|
|
if (name.startsWith("isolated-agent")) {
|
|
return "core-runtime-cron-parallel-isolated-agent";
|
|
}
|
|
if (name.startsWith("service")) {
|
|
return "core-runtime-cron-parallel-service";
|
|
}
|
|
return "core-runtime-cron-parallel-core";
|
|
}
|
|
|
|
function createCronSplitShards(): NodeTestSplitShard[] {
|
|
const groups = new Map<string, string[]>();
|
|
for (const file of listTrackedTestFiles("src/cron")) {
|
|
const shardName = resolveCronShardName(file);
|
|
groups.set(shardName, [...(groups.get(shardName) ?? []), file]);
|
|
}
|
|
|
|
return [
|
|
"core-runtime-cron-parallel-core",
|
|
"core-runtime-cron-parallel-isolated-agent",
|
|
"core-runtime-cron-parallel-service",
|
|
]
|
|
.map((shardName) => ({
|
|
configs: ["test/vitest/vitest.cron.config.ts"],
|
|
includePatterns: groups.get(shardName) ?? [],
|
|
requiresDist: false,
|
|
shardName,
|
|
}))
|
|
.filter((shard) => shard.includePatterns.length > 0);
|
|
}
|
|
|
|
// Keep precedence: specific owners such as heartbeat-runner precede their broad prefixes.
|
|
const INFRA_SHARD_PREFIXES = [
|
|
["approval-exec", ["approval", "exec"]],
|
|
["heartbeat-runner", ["heartbeat-runner"]],
|
|
["heartbeat-core", ["heartbeat"]],
|
|
["outbound-actions", ["outbound/message-action"]],
|
|
["outbound-core", ["outbound/"]],
|
|
["net-install", ["net/", "install", "npm", "brew", "binaries"]],
|
|
["device", ["device"]],
|
|
["gateway-lock-argv", ["gateway-lock", "gateway-process-argv"]],
|
|
["gateway-processes", ["gateway-processes"]],
|
|
["gateway-watch", ["gateway-watch"]],
|
|
["network-node", ["node", "bonjour", "network"]],
|
|
["diagnostics-state", ["archive", "backup", "diagnostic"]],
|
|
[
|
|
"files-commands",
|
|
[
|
|
"command-analysis/",
|
|
"command-explainer/",
|
|
"file-",
|
|
"fs-",
|
|
"json",
|
|
"path",
|
|
"shell",
|
|
"tmp-openclaw-dir",
|
|
],
|
|
],
|
|
["provider-push", ["provider-usage", "push-"]],
|
|
["storage-state", ["kysely", "session", "sqlite", "stale-lock", "state-migrations"]],
|
|
["channel-plugin", ["channel", "plugin", "pairing", "voicewake"]],
|
|
[
|
|
"system-runtime",
|
|
["package", "ports", "process", "restart", "runtime", "run-node", "system", "update"],
|
|
],
|
|
[
|
|
"env-auth",
|
|
[
|
|
"dotenv",
|
|
"env",
|
|
"gemini-auth",
|
|
"google-api",
|
|
"home-dir",
|
|
"host-env",
|
|
"openclaw-exec-env",
|
|
"secret",
|
|
"secure-random",
|
|
],
|
|
],
|
|
[
|
|
"repo-tooling",
|
|
[
|
|
"build-stamp",
|
|
"changelog",
|
|
"clawhub",
|
|
"detect-package-manager",
|
|
"git-",
|
|
"openclaw-root",
|
|
"tsdown",
|
|
"vitest",
|
|
],
|
|
],
|
|
[
|
|
"network-platform",
|
|
["scp", "ssh", "tailnet", "tailscale", "tcp", "tls/", "transport", "widearea", "windows", "ws"],
|
|
],
|
|
[
|
|
"core-utils",
|
|
[
|
|
"abort",
|
|
"backoff",
|
|
"errors",
|
|
"fatal-error",
|
|
"fetch",
|
|
"fixed-window",
|
|
"format-time/",
|
|
"http-body",
|
|
"plain-object",
|
|
"prototype-keys",
|
|
"retry",
|
|
"warning-filter",
|
|
],
|
|
],
|
|
["cli-ui", ["browser", "cli-", "clipboard", "control-ui", "embedded", "is-main"]],
|
|
[
|
|
"events-runtime",
|
|
["agent-events", "event-session", "infra-", "non-fatal", "supervisor", "unhandled"],
|
|
],
|
|
[
|
|
"file-safety",
|
|
[
|
|
"boundary",
|
|
"hardlink",
|
|
"replace-file",
|
|
"resolve-system-bin",
|
|
"safe-package-install",
|
|
"stable-node-path",
|
|
"watch-node",
|
|
],
|
|
],
|
|
["misc-dedupe-disk", ["dedupe", "disk-space"]],
|
|
["misc-values", ["inline-option-token", "map-size", "machine-name"]],
|
|
["misc-os", ["os-summary"]],
|
|
] as const;
|
|
|
|
function resolveInfraShardName(file: string): string {
|
|
const name = relative("src/infra", file).replaceAll("\\", "/");
|
|
const owner = INFRA_SHARD_PREFIXES.find(([, prefixes]) =>
|
|
prefixes.some((prefix) => name.startsWith(prefix)),
|
|
);
|
|
return `core-runtime-infra-${owner?.[0] ?? "misc"}`;
|
|
}
|
|
|
|
function createInfraSplitShards(): NodeTestSplitShard[] {
|
|
const groups = new Map<string, string[]>();
|
|
for (const file of listTrackedTestFiles("src/infra")) {
|
|
if (isDatabaseWorkerCoreTestFile(file)) {
|
|
continue;
|
|
}
|
|
const shardName = resolveInfraShardName(file);
|
|
groups.set(shardName, [...(groups.get(shardName) ?? []), file]);
|
|
}
|
|
groups.set("core-runtime-infra-storage-state", [
|
|
...(groups.get("core-runtime-infra-storage-state") ?? []),
|
|
...databaseWorkerCoreTestFiles,
|
|
]);
|
|
|
|
return [
|
|
"core-runtime-infra-approval-exec",
|
|
"core-runtime-infra-channel-plugin",
|
|
"core-runtime-infra-cli-ui",
|
|
"core-runtime-infra-device",
|
|
"core-runtime-infra-diagnostics-state",
|
|
"core-runtime-infra-core-utils",
|
|
"core-runtime-infra-env-auth",
|
|
"core-runtime-infra-events-runtime",
|
|
"core-runtime-infra-file-safety",
|
|
"core-runtime-infra-files-commands",
|
|
"core-runtime-infra-gateway-lock-argv",
|
|
"core-runtime-infra-gateway-processes",
|
|
"core-runtime-infra-gateway-watch",
|
|
"core-runtime-infra-heartbeat-core",
|
|
"core-runtime-infra-heartbeat-runner",
|
|
"core-runtime-infra-misc",
|
|
"core-runtime-infra-misc-dedupe-disk",
|
|
"core-runtime-infra-misc-os",
|
|
"core-runtime-infra-misc-values",
|
|
"core-runtime-infra-net-install",
|
|
"core-runtime-infra-network-node",
|
|
"core-runtime-infra-network-platform",
|
|
"core-runtime-infra-outbound-actions",
|
|
"core-runtime-infra-outbound-core",
|
|
"core-runtime-infra-provider-push",
|
|
"core-runtime-infra-repo-tooling",
|
|
"core-runtime-infra-storage-state",
|
|
"core-runtime-infra-system-runtime",
|
|
]
|
|
.map((shardName) => ({
|
|
configs: ["test/vitest/vitest.infra.config.ts"],
|
|
includePatterns: groups.get(shardName) ?? [],
|
|
requiresDist: false,
|
|
runner: "blacksmith-4vcpu-ubuntu-2404",
|
|
shardName,
|
|
}))
|
|
.filter((shard) => shard.includePatterns.length > 0);
|
|
}
|
|
|
|
// The broad unit-fast graph is import-bound (~180s of module evaluation on an
|
|
// 8 vCPU runner as one job); striping the file list halves the wall clock.
|
|
// Isolated and fake-timer projects stay whole: they are small and own
|
|
// worker-isolation semantics that include lists must not slice.
|
|
function createUnitFastSplitShards(): NodeTestSplitShard[] {
|
|
const timerTestFiles = new Set(getUnitFastTimerTestFiles());
|
|
const isolatedTestFiles = new Set(getUnitFastIsolatedTestFiles());
|
|
const stripeFiles = getUnitFastTestFiles().filter(
|
|
(file) => !timerTestFiles.has(file) && !isolatedTestFiles.has(file),
|
|
);
|
|
return [
|
|
...createStripedBatches(stripeFiles, UNIT_FAST_NODE_TEST_STRIPES, stripeFileWeight).map(
|
|
(includePatterns, index) => ({
|
|
shardName: `core-unit-fast-${index + 1}`,
|
|
configs: ["test/vitest/vitest.unit-fast.config.ts"],
|
|
includePatterns,
|
|
requiresDist: false,
|
|
}),
|
|
),
|
|
// Split per config: the combined pair owned a ~190s hosted wall that no
|
|
// bin packing could shorten, while the halves fit normal lanes.
|
|
{
|
|
shardName: "core-unit-fast-isolated",
|
|
configs: ["test/vitest/vitest.unit-fast-isolated.config.ts"],
|
|
requiresDist: false,
|
|
},
|
|
{
|
|
shardName: "core-unit-fast-fake-timers",
|
|
configs: ["test/vitest/vitest.unit-fast-fake-timers.config.ts"],
|
|
requiresDist: false,
|
|
},
|
|
];
|
|
}
|
|
|
|
// Price the sixteen parent stripes with the same file workers as their compact
|
|
// children, so a long file can share spare worker capacity with shorter files.
|
|
// Push compacts omit tooling; retained compact groups stay exclusive.
|
|
function createToolingSplitShards(): NodeTestSplitShard[] {
|
|
const files = listCompactToolingTestFiles();
|
|
// Resolve file ownership once; batch scoring reuses those prepared facts.
|
|
const buildModes = new Map(files.map((file) => [file, resolveTestFilesBuildMode([file])]));
|
|
const toolingBatchWeight = (batch: string[]) => {
|
|
const mode = mergeVitestPretestBuildModes(batch.map((file) => buildModes.get(file)));
|
|
return (
|
|
estimateParallelToolingSeconds({ env: PINNED_COMPACT_GROUP_ENV }, batch, "blacksmith") +
|
|
(mode ? VITEST_PRETEST_BUILD_SECONDS[mode] : 0)
|
|
);
|
|
};
|
|
return [
|
|
...createStripedBatches(
|
|
files,
|
|
COMPACT_TOOLING_NODE_TEST_GROUPS,
|
|
(file) => toolingBatchWeight([file]),
|
|
toolingBatchWeight,
|
|
).map((includePatterns, index) => ({
|
|
shardName: `core-tooling-${index + 1}`,
|
|
configs: [TOOLING_CONFIG],
|
|
includePatterns,
|
|
requiresDist: false,
|
|
})),
|
|
{
|
|
shardName: "core-tooling-isolated",
|
|
configs: ["test/vitest/vitest.tooling-docker.config.ts", TOOLING_ISOLATED_CONFIG],
|
|
requiresDist: false,
|
|
},
|
|
];
|
|
}
|
|
|
|
function createStripedSplitShards(params: {
|
|
configs: string[];
|
|
files: string[];
|
|
includeExternalConfigs?: boolean;
|
|
shardName: string;
|
|
stripeCount: number;
|
|
}): NodeTestSplitShard[] {
|
|
return createStripedBatches(params.files, params.stripeCount, stripeFileWeight).map(
|
|
(includePatterns, index) => ({
|
|
configs: params.configs,
|
|
includeExternalConfigs: params.includeExternalConfigs,
|
|
includePatterns,
|
|
requiresDist: false,
|
|
shardName: `${params.shardName}-${index + 1}`,
|
|
}),
|
|
);
|
|
}
|
|
|
|
function createCoreUnitSrcSecuritySplitShards(): NodeTestSplitShard[] {
|
|
const unitFastFiles = new Set(getUnitFastTestFiles());
|
|
const files = filterUnitConfigTestFiles(
|
|
listTrackedTestFiles("src").filter(
|
|
(file) =>
|
|
isStripeEligibleTestFile(file, unitFastFiles) &&
|
|
!file.startsWith("src/acp/") &&
|
|
!file.startsWith("src/security/"),
|
|
),
|
|
);
|
|
return [
|
|
...createStripedSplitShards({
|
|
configs: ["test/vitest/vitest.unit-src.config.ts"],
|
|
files,
|
|
shardName: "core-unit-src-security",
|
|
stripeCount: CORE_UNIT_SRC_SECURITY_STRIPES,
|
|
}),
|
|
{
|
|
configs: ["test/vitest/vitest.unit-security.config.ts"],
|
|
includeExternalConfigs: true,
|
|
requiresDist: false,
|
|
shardName: "core-unit-src-security-support",
|
|
},
|
|
];
|
|
}
|
|
|
|
function createCoreRuntimeMediaUiSplitShards(): NodeTestSplitShard[] {
|
|
const unitFastFiles = new Set(getUnitFastTestFiles());
|
|
const separateUiFiles = new Set([...uiIsolatedTestFiles, ...uiTimingTestFiles]);
|
|
const files = [
|
|
...listTrackedTestFiles("ui/src"),
|
|
...listTrackedTestFiles("extensions").filter(isPluginControlUiPath),
|
|
].filter(
|
|
(file) =>
|
|
isStripeEligibleTestFile(file, unitFastFiles) &&
|
|
!separateUiFiles.has(file) &&
|
|
!isUiBrowserTestFile(file),
|
|
);
|
|
return [
|
|
...createStripedSplitShards({
|
|
configs: ["test/vitest/vitest.ui.config.ts"],
|
|
files,
|
|
shardName: "core-runtime-media-ui",
|
|
stripeCount: CORE_RUNTIME_MEDIA_UI_STRIPES,
|
|
}),
|
|
{
|
|
configs: [
|
|
"test/vitest/vitest.media.config.ts",
|
|
"test/vitest/vitest.media-understanding.config.ts",
|
|
"test/vitest/vitest.tui.config.ts",
|
|
"test/vitest/vitest.ui-isolated.config.ts",
|
|
"test/vitest/vitest.ui-timing.config.ts",
|
|
"test/vitest/vitest.wizard.config.ts",
|
|
],
|
|
requiresDist: false,
|
|
shardName: "core-runtime-media-ui-support",
|
|
},
|
|
];
|
|
}
|
|
|
|
function partitionRuntimeTestFiles(configs: string[], files: string[]) {
|
|
const runtimeFiles = new Set(listVitestRuntimeConsumerFiles(configs));
|
|
return {
|
|
runtimeFiles: files.filter((file) => runtimeFiles.has(file)),
|
|
otherFiles: files.filter((file) => !runtimeFiles.has(file)),
|
|
};
|
|
}
|
|
|
|
const GATEWAY_CORE_NODE_TEST_CONFIGS = [
|
|
"test/vitest/vitest.gateway-core.config.ts",
|
|
"test/vitest/vitest.gateway-client.config.ts",
|
|
];
|
|
|
|
function createAgenticGatewayCoreSplitShards(): NodeTestSplitShard[] {
|
|
const unitFastFiles = new Set(getUnitFastTestFiles());
|
|
const excludedGatewayFiles = new Set([
|
|
...databaseWorkerCoreTestFiles,
|
|
...gatewayDatabaseWorkerTestFiles,
|
|
...gatewayServerExcludedTestFiles,
|
|
...gatewayServerIsolatedTestFiles,
|
|
]);
|
|
const gatewayFiles = listTrackedTestFiles("src/gateway").filter(
|
|
(file) =>
|
|
isStripeEligibleTestFile(file, unitFastFiles) &&
|
|
!file.startsWith("src/gateway/server-methods/") &&
|
|
!isGatewayServerTestFile(file) &&
|
|
!excludedGatewayFiles.has(file),
|
|
);
|
|
const packageFiles = ["packages/gateway-client/src", "packages/gateway-protocol/src"]
|
|
.flatMap((rootDir) => listTrackedTestFiles(rootDir))
|
|
.filter((file) => isStripeEligibleTestFile(file, unitFastFiles));
|
|
const configs = GATEWAY_CORE_NODE_TEST_CONFIGS;
|
|
// The pretest runtime build is charged per job, so a stripe holding one of
|
|
// these files pays it for the whole stripe. Striping spreads them, which made
|
|
// every gateway-core job pay a 275s build to run ~120s of tests. Keep them in
|
|
// one shard so exactly one job builds.
|
|
const { runtimeFiles, otherFiles } = partitionRuntimeTestFiles(configs, [
|
|
...gatewayFiles,
|
|
...packageFiles,
|
|
]);
|
|
const inventoryFile = "src/gateway/worker-environments/workspace-large-inventory.test.ts";
|
|
return [
|
|
...createStripedSplitShards({
|
|
configs,
|
|
files: otherFiles.filter((file) => file !== inventoryFile),
|
|
shardName: "agentic-gateway-core",
|
|
stripeCount: AGENTIC_GATEWAY_CORE_STRIPES,
|
|
}),
|
|
// Keep the full journal/apply/recovery proof off the shared Vitest worker pool.
|
|
{
|
|
configs: ["test/vitest/vitest.gateway-core.config.ts"],
|
|
includePatterns: [inventoryFile],
|
|
requiresDist: false,
|
|
shardName: "agentic-gateway-core-inventory",
|
|
},
|
|
...(runtimeFiles.length > 0
|
|
? [
|
|
{
|
|
configs,
|
|
includePatterns: runtimeFiles,
|
|
requiresDist: false,
|
|
shardName: "agentic-gateway-core-runtime",
|
|
},
|
|
]
|
|
: []),
|
|
];
|
|
}
|
|
|
|
const TUI_PTY_NODE_TEST_SHARD: NodeTestSplitShard = {
|
|
shardName: "core-runtime-tui-pty",
|
|
configs: ["test/vitest/vitest.tui-pty.config.ts"],
|
|
env: {
|
|
OPENCLAW_TUI_PTY_INCLUDE_LOCAL: "1",
|
|
OPENCLAW_TUI_PTY_USE_BUILT_CLI: "1",
|
|
},
|
|
requiresDist: true,
|
|
runner: "blacksmith-4vcpu-ubuntu-2404",
|
|
};
|
|
const AGENTS_EMBEDDED_NODE_TEST_SHARD: NodeTestSplitShard = {
|
|
shardName: "agentic-agents-embedded",
|
|
configs: embeddedAgentVitestProjectOwners.map((owner) => owner.config),
|
|
env: AGENTS_EMBEDDED_AGENT_ENV,
|
|
requiresDist: false,
|
|
};
|
|
|
|
const SPLIT_NODE_SHARDS = new Map<string, NodeTestSplitShard[] | (() => NodeTestSplitShard[])>([
|
|
["core-unit-fast", createUnitFastSplitShards],
|
|
["core-tooling", createToolingSplitShards],
|
|
["core-unit-src", createCoreUnitSrcSecuritySplitShards],
|
|
["core-unit-security", []],
|
|
[
|
|
"core-unit-support",
|
|
[
|
|
{
|
|
shardName: "core-unit-support",
|
|
configs: ["test/vitest/vitest.unit-support.config.ts"],
|
|
requiresDist: false,
|
|
},
|
|
],
|
|
],
|
|
[
|
|
"core-runtime",
|
|
() => [
|
|
{
|
|
shardName: "core-runtime-hooks",
|
|
configs: ["test/vitest/vitest.hooks.config.ts"],
|
|
requiresDist: false,
|
|
runner: "blacksmith-4vcpu-ubuntu-2404",
|
|
},
|
|
...createInfraSplitShards(),
|
|
{
|
|
shardName: "core-runtime-secrets",
|
|
configs: ["test/vitest/vitest.secrets.config.ts"],
|
|
requiresDist: false,
|
|
runner: "blacksmith-4vcpu-ubuntu-2404",
|
|
},
|
|
// runtime-config owns ~90% of the former three-config wall; keeping it
|
|
// separate lets the hosted splitter stripe it while logging/process
|
|
// stay a cheap pair.
|
|
{
|
|
shardName: "core-runtime-infra-process",
|
|
configs: ["test/vitest/vitest.logging.config.ts", "test/vitest/vitest.process.config.ts"],
|
|
includePatterns: ["src/logging", "src/process"].flatMap((root) =>
|
|
listScopedOwnerTestFiles({
|
|
root,
|
|
include: [`${root}/**/*.test.ts`],
|
|
exclude: [...databaseWorkerCoreTestFiles],
|
|
}),
|
|
),
|
|
requiresDist: false,
|
|
runner: "blacksmith-4vcpu-ubuntu-2404",
|
|
},
|
|
{
|
|
shardName: "core-runtime-config",
|
|
configs: ["test/vitest/vitest.runtime-config.config.ts"],
|
|
requiresDist: false,
|
|
runner: "blacksmith-4vcpu-ubuntu-2404",
|
|
},
|
|
TUI_PTY_NODE_TEST_SHARD,
|
|
...createCoreRuntimeMediaUiSplitShards(),
|
|
{
|
|
shardName: "core-runtime-shared",
|
|
configs: [
|
|
"test/vitest/vitest.acp.config.ts",
|
|
"test/vitest/vitest.shared-core.config.ts",
|
|
"test/vitest/vitest.utils.config.ts",
|
|
],
|
|
requiresDist: false,
|
|
},
|
|
...createCronSplitShards(),
|
|
],
|
|
],
|
|
[
|
|
"auto-reply",
|
|
() => [
|
|
{
|
|
shardName: "auto-reply-core-top-level",
|
|
configs: [
|
|
"test/vitest/vitest.auto-reply-core.config.ts",
|
|
"test/vitest/vitest.auto-reply-top-level.config.ts",
|
|
],
|
|
requiresDist: false,
|
|
},
|
|
...createAutoReplyReplySplitShards(),
|
|
],
|
|
],
|
|
[
|
|
"agentic",
|
|
() => [
|
|
...createGatewayServerSplitShards(),
|
|
{
|
|
shardName: "agentic-gateway-server-isolated",
|
|
configs: [
|
|
"test/vitest/vitest.gateway-server-isolated.config.ts",
|
|
"test/vitest/vitest.gateway-database-workers.config.ts",
|
|
],
|
|
requiresDist: false,
|
|
},
|
|
// Split per config: the combined pair owned a ~206s hosted wall that no
|
|
// bin packing could shorten, while the halves fit normal lanes.
|
|
{
|
|
shardName: "agentic-cli",
|
|
configs: ["test/vitest/vitest.cli.config.ts"],
|
|
requiresDist: false,
|
|
},
|
|
{
|
|
shardName: "agentic-cli-process",
|
|
configs: ["test/vitest/vitest.cli-process.config.ts"],
|
|
requiresDist: false,
|
|
},
|
|
{
|
|
shardName: "agentic-command-support",
|
|
configs: [
|
|
"test/vitest/vitest.commands-light.config.ts",
|
|
"test/vitest/vitest.daemon.config.ts",
|
|
],
|
|
requiresDist: false,
|
|
},
|
|
...createAgenticCommandSplitShards(resolveTestFilesBuildMode),
|
|
...createAgentCoreSplitShards(),
|
|
AGENTS_EMBEDDED_NODE_TEST_SHARD,
|
|
{
|
|
shardName: "agentic-agents-support",
|
|
configs: [agentVitestProjectOwners.support.config],
|
|
requiresDist: false,
|
|
},
|
|
{
|
|
shardName: "agentic-agents-tools",
|
|
configs: [agentVitestProjectOwners.tools.config],
|
|
requiresDist: false,
|
|
},
|
|
...createAgenticGatewayCoreSplitShards(),
|
|
{
|
|
shardName: "agentic-gateway-methods",
|
|
configs: [
|
|
"test/vitest/vitest.gateway-methods.config.ts",
|
|
"test/vitest/vitest.gateway-methods-isolated.config.ts",
|
|
],
|
|
requiresDist: false,
|
|
},
|
|
{
|
|
shardName: "agentic-plugin-sdk",
|
|
configs: [
|
|
"test/vitest/vitest.plugin-sdk-light.config.ts",
|
|
"test/vitest/vitest.plugin-sdk.config.ts",
|
|
],
|
|
requiresDist: false,
|
|
},
|
|
{
|
|
shardName: "agentic-plugins",
|
|
configs: ["test/vitest/vitest.plugins.config.ts"],
|
|
requiresDist: false,
|
|
},
|
|
],
|
|
],
|
|
]);
|
|
const DIST_DEPENDENT_NODE_SHARD_NAMES = new Set(["core-support-boundary"]);
|
|
// Definitions are immutable for a planner process; defer file discovery until an
|
|
// owner is requested while retaining the original canonical metadata snapshot.
|
|
const canonicalNodeTestOwners = fullSuiteVitestShards.map((shard) => ({
|
|
...shard,
|
|
projects: [...shard.projects],
|
|
}));
|
|
let canonicalMetadataConfigs: ReadonlySet<string> | undefined;
|
|
type CanonicalTargetInventory = {
|
|
configsByFile: Map<string, Set<string>>;
|
|
configs: Set<string>;
|
|
completeConfigs: Set<string>;
|
|
};
|
|
const canonicalTargetInventories = new Map<
|
|
(typeof canonicalNodeTestOwners)[number] | undefined,
|
|
CanonicalTargetInventory
|
|
>();
|
|
|
|
function resolveCanonicalTargetInventory(requestedConfig?: string) {
|
|
const complete = canonicalTargetInventories.get(undefined);
|
|
if (complete) {
|
|
return complete;
|
|
}
|
|
const matchingOwners = requestedConfig
|
|
? canonicalNodeTestOwners.filter((owner) => owner.projects.includes(requestedConfig))
|
|
: [];
|
|
const owner = matchingOwners.length === 1 ? matchingOwners[0] : undefined;
|
|
const cached = canonicalTargetInventories.get(owner);
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
const configsByFile = new Map<string, Set<string>>();
|
|
const configs = new Set<string>();
|
|
const incompleteConfigs = new Set<string>();
|
|
for (const shard of createNodeTestShardsForOwners(owner ? [owner] : canonicalNodeTestOwners, {
|
|
includeReleaseOnlyPluginShards: true,
|
|
includeReleaseOnlyToolingShards: true,
|
|
includeProofTests: false,
|
|
})) {
|
|
const envelope = shard.includePatterns ?? listWholeConfigFiles(shard.shardName);
|
|
const included = envelope ? new Set(envelope) : undefined;
|
|
for (const config of shard.configs) {
|
|
configs.add(config);
|
|
const files =
|
|
listNodeTestConfigFiles(config) ?? (shard.configs.length === 1 ? envelope : undefined);
|
|
if (!files) {
|
|
incompleteConfigs.add(config);
|
|
continue;
|
|
}
|
|
for (const file of files) {
|
|
if (included && !included.has(file)) {
|
|
continue;
|
|
}
|
|
const owners = configsByFile.get(file) ?? new Set<string>();
|
|
owners.add(config);
|
|
configsByFile.set(file, owners);
|
|
}
|
|
}
|
|
}
|
|
const inventory = {
|
|
configsByFile,
|
|
configs,
|
|
completeConfigs: new Set([...configs].filter((config) => !incompleteConfigs.has(config))),
|
|
};
|
|
canonicalTargetInventories.set(owner, inventory);
|
|
return inventory;
|
|
}
|
|
|
|
export function isCanonicalNodeTestConfig(config: string): boolean {
|
|
return resolveCanonicalTargetInventory(config).configs.has(config);
|
|
}
|
|
|
|
/** Null excludes a file from a complete inventory; undefined leaves ownership unresolved. */
|
|
export function resolveCanonicalNodeTestConfig(
|
|
target: string,
|
|
config: string,
|
|
): string | null | undefined {
|
|
let inventory = resolveCanonicalTargetInventory(config);
|
|
let owners = inventory.configsByFile.get(target);
|
|
if (
|
|
owners?.has(config) ||
|
|
(inventory.configs.has(config) && !inventory.completeConfigs.has(config))
|
|
) {
|
|
return config;
|
|
}
|
|
// A direct owner should not discover unrelated suites. Aggregate or moved
|
|
// targets still need the complete inventory to prove a unique replacement.
|
|
inventory = resolveCanonicalTargetInventory();
|
|
owners = inventory.configsByFile.get(target);
|
|
if (
|
|
owners?.has(config) ||
|
|
(inventory.configs.has(config) && !inventory.completeConfigs.has(config))
|
|
) {
|
|
return config;
|
|
}
|
|
if (owners?.size === 1) {
|
|
return owners.values().next().value;
|
|
}
|
|
if (
|
|
EXCLUDED_PROJECT_CONFIGS.has(config) ||
|
|
canonicalNodeTestOwners.some(
|
|
(owner) => EXCLUDED_FULL_SUITE_SHARDS.has(owner.config) && owner.projects.includes(config),
|
|
)
|
|
) {
|
|
return null;
|
|
}
|
|
if (!owners && inventory.configs.has(config)) {
|
|
return inventory.completeConfigs.has(config) ? null : config;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function resolveSplitNodeShards(name: string): NodeTestSplitShard[] | undefined {
|
|
const entry = SPLIT_NODE_SHARDS.get(name);
|
|
if (typeof entry !== "function") {
|
|
return entry;
|
|
}
|
|
const shards = entry();
|
|
SPLIT_NODE_SHARDS.set(name, shards);
|
|
return shards;
|
|
}
|
|
|
|
export function nodeTestConfigRequiresCanonicalMetadata(config: string): boolean {
|
|
// Aggregate configs can be exclusive without owning canonical shard metadata.
|
|
// Check immutable project descriptors before discovering unrelated test files.
|
|
if (!canonicalNodeTestOwners.some((owner) => owner.projects.includes(config))) {
|
|
return false;
|
|
}
|
|
const toolingOwner = canonicalNodeTestOwners.some(
|
|
(owner) => owner.name === "core-tooling" && owner.projects.includes(config),
|
|
);
|
|
if (config === TOOLING_CONFIG) {
|
|
return toolingOwner;
|
|
}
|
|
// Gateway environment overrides belong to exclusive configs. The remaining
|
|
// environment owners share these descriptors with shard construction.
|
|
if (
|
|
!toolingOwner &&
|
|
!isExclusiveCiTestConfig(config) &&
|
|
!(
|
|
GATEWAY_CORE_NODE_TEST_CONFIGS.includes(config) &&
|
|
GATEWAY_CORE_NODE_TEST_CONFIGS.some(isExclusiveCiTestConfig)
|
|
) &&
|
|
![TUI_PTY_NODE_TEST_SHARD, AGENTS_EMBEDDED_NODE_TEST_SHARD].some((shard) =>
|
|
shard.configs.includes(config),
|
|
)
|
|
) {
|
|
return false;
|
|
}
|
|
canonicalMetadataConfigs ??= new Set(
|
|
createNodeTestShardsForOwners(canonicalNodeTestOwners, {
|
|
includeReleaseOnlyPluginShards: false,
|
|
})
|
|
.filter(
|
|
(shard) =>
|
|
shard.env ||
|
|
shard.shardName.startsWith("core-tooling") ||
|
|
shard.configs.some(isExclusiveCiTestConfig),
|
|
)
|
|
.flatMap((shard) => shard.configs),
|
|
);
|
|
return canonicalMetadataConfigs.has(config);
|
|
}
|
|
|
|
function formatNodeTestShardCheckName(shardName: string): string {
|
|
const normalizedShardName = shardName.startsWith("core-unit-")
|
|
? `core-${shardName.slice("core-unit-".length)}`
|
|
: shardName;
|
|
return `checks-node-${normalizedShardName}`;
|
|
}
|
|
|
|
/** Create node test shard descriptors for CI with explicit release-tier selection. */
|
|
export function createNodeTestShards(options: NodeTestPlanOptions = {}): NodeTestShard[] {
|
|
return createNodeTestShardsForOwners(fullSuiteVitestShards, options);
|
|
}
|
|
|
|
function createNodeTestShardsForOwners(
|
|
owners: readonly (typeof fullSuiteVitestShards)[number][],
|
|
options: NodeTestPlanOptions,
|
|
toolingOnly = false,
|
|
preparedChangedTestPlans?: ReturnType<typeof buildVitestRunPlans>,
|
|
): NodeTestShard[] {
|
|
const includeReleaseOnlyPluginShards = options.includeReleaseOnlyPluginShards ?? true;
|
|
const includeProofTests =
|
|
options.includeProofTests ??
|
|
(options.compactMode ?? (options.compact ? "pull-request" : undefined)) !== "pull-request";
|
|
const includeTooling = includesReleaseOnlyTooling(options);
|
|
const keepPrExemptRuntimeFile = (file: string) =>
|
|
options.includePrExemptRuntimeTests !== undefined &&
|
|
isPrExemptRuntimeTestFile(file) &&
|
|
isRuntimeTestFileIncluded(file, options);
|
|
const changedTestPlans = includeReleaseOnlyPluginShards
|
|
? []
|
|
: (preparedChangedTestPlans ??
|
|
(options.changedPaths ?? [])
|
|
.filter(
|
|
(file) =>
|
|
isTestFileTarget(file) &&
|
|
!file.endsWith(".live.test.ts") &&
|
|
statSync(file, { throwIfNoEntry: false })?.isFile(),
|
|
)
|
|
.flatMap((file) => buildVitestRunPlans([file])));
|
|
|
|
return owners.flatMap((shard) => {
|
|
if (
|
|
EXCLUDED_FULL_SUITE_SHARDS.has(shard.config) ||
|
|
(toolingOnly &&
|
|
shard.name !== "core-tooling" &&
|
|
shard.name !== "core-runtime" &&
|
|
!DIST_DEPENDENT_NODE_SHARD_NAMES.has(shard.name))
|
|
) {
|
|
return [];
|
|
}
|
|
|
|
const configs = shard.projects.filter((config) => !EXCLUDED_PROJECT_CONFIGS.has(config));
|
|
if (configs.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const splitShards =
|
|
toolingOnly && shard.name === "core-runtime"
|
|
? [TUI_PTY_NODE_TEST_SHARD]
|
|
: resolveSplitNodeShards(shard.name);
|
|
if (splitShards) {
|
|
return splitShards.flatMap((splitShard) => {
|
|
const splitConfigs = splitShard.includeExternalConfigs
|
|
? splitShard.configs
|
|
: splitShard.configs.filter((config) => configs.includes(config));
|
|
if (splitConfigs.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
let includePatterns =
|
|
splitShard.includePatterns ??
|
|
(!includeTooling ? listToolingGroupFiles(splitConfigs) : undefined);
|
|
let timingKey: string | undefined;
|
|
if (!includeProofTests) {
|
|
const files = includePatterns ?? listWholeConfigSplitFiles(splitShard.shardName);
|
|
if (files?.some(isCiProofTestFile)) {
|
|
includePatterns = files.filter((file) => !isCiProofTestFile(file));
|
|
if (includePatterns.length === 0) {
|
|
return [];
|
|
}
|
|
}
|
|
}
|
|
if (
|
|
options.includeReleaseOnlyRuntimeTests === false ||
|
|
options.includePrExemptRuntimeTests === false
|
|
) {
|
|
const files =
|
|
includePatterns ??
|
|
listWholeConfigFiles(splitShard.shardName) ??
|
|
listToolingGroupFiles(splitConfigs);
|
|
if (
|
|
files?.some(
|
|
(file) => isReleaseOnlyRuntimeTestFile(file) || isPrExemptRuntimeTestFile(file),
|
|
)
|
|
) {
|
|
const selectedFiles = files.filter((file) => isRuntimeTestFileIncluded(file, options));
|
|
if (selectedFiles.length === 0) {
|
|
return [];
|
|
}
|
|
if (selectedFiles.length < files.length) {
|
|
includePatterns = selectedFiles;
|
|
// Refit folds complete split generations into their timing parent.
|
|
// Reduced automatic coverage must never reprice the full release owner.
|
|
timingKey = `changed-${splitShard.shardName}`;
|
|
}
|
|
}
|
|
}
|
|
if (includePatterns && !includeTooling) {
|
|
const selectedFiles = includePatterns.filter(
|
|
(file) =>
|
|
(!RELEASE_ONLY_TOOLING_SHARDS.has(shard.name) &&
|
|
!isReleaseOnlyToolingTestFile(file)) ||
|
|
keepPrExemptRuntimeFile(file),
|
|
);
|
|
if (selectedFiles.length < includePatterns.length) {
|
|
timingKey = `changed-${splitShard.shardName}`;
|
|
}
|
|
includePatterns = selectedFiles;
|
|
if (includePatterns.length === 0) {
|
|
return [];
|
|
}
|
|
}
|
|
if (
|
|
RELEASE_ONLY_PLUGIN_SHARDS.has(splitShard.shardName) &&
|
|
!includeReleaseOnlyPluginShards
|
|
) {
|
|
// Keep direct edits and the selected integration tier without enabling
|
|
// the unrelated plugin sweep or changing canonical Vitest ownership.
|
|
const files = includePatterns ?? listWholeConfigFiles(splitShard.shardName) ?? [];
|
|
includePatterns = [
|
|
...new Set([
|
|
...files.filter(keepPrExemptRuntimeFile),
|
|
...changedTestPlans
|
|
.filter((plan) => splitConfigs.includes(plan.config))
|
|
.flatMap((plan) => plan.includePatterns ?? []),
|
|
]),
|
|
]
|
|
.filter((file) => includeProofTests || !isCiProofTestFile(file))
|
|
.toSorted();
|
|
if (includePatterns.length === 0) {
|
|
return [];
|
|
}
|
|
if (options.includePrExemptRuntimeTests !== undefined) {
|
|
timingKey = `changed-${splitShard.shardName}`;
|
|
}
|
|
}
|
|
|
|
const pretestBuildMode = includePatterns
|
|
? resolveTestFilesBuildMode(includePatterns)
|
|
: resolveVitestPretestBuildMode([{ configs: splitConfigs }]);
|
|
|
|
return [
|
|
{
|
|
checkName: formatNodeTestShardCheckName(splitShard.shardName),
|
|
shardName: splitShard.shardName,
|
|
...(timingKey ? { timing_key: timingKey } : {}),
|
|
configs: splitConfigs,
|
|
...(splitShard.env ? { env: splitShard.env } : {}),
|
|
...(includePatterns ? { includePatterns } : {}),
|
|
...(pretestBuildMode ? { pretestBuildMode } : {}),
|
|
runner: splitShard.runner ?? DEFAULT_NODE_TEST_RUNNER,
|
|
requiresDist: splitShard.requiresDist,
|
|
},
|
|
];
|
|
});
|
|
}
|
|
|
|
const pretestBuildMode = resolveVitestPretestBuildMode([{ configs }]);
|
|
return [
|
|
{
|
|
checkName: formatNodeTestShardCheckName(shard.name),
|
|
shardName: shard.name,
|
|
configs,
|
|
...(pretestBuildMode ? { pretestBuildMode } : {}),
|
|
runner: DEFAULT_NODE_TEST_RUNNER,
|
|
requiresDist: DIST_DEPENDENT_NODE_SHARD_NAMES.has(shard.name),
|
|
},
|
|
];
|
|
});
|
|
}
|
|
|
|
/** Select planner envelopes that produce the protected Vitest transform-cache seed. */
|
|
export function createVitestCacheWarmGroups(profile: "full" | "hybrid-hosted" = "full"): Array<{
|
|
configs: string[];
|
|
env?: Record<string, string>;
|
|
includePatterns?: string[];
|
|
shard_name: string;
|
|
}> {
|
|
// Preserve the package root and aliases used by checks-ui in either backend.
|
|
const uiGroup = {
|
|
configs: ["ui/vitest.config.ts"],
|
|
env: { OPENCLAW_VITEST_MAX_WORKERS: "1" },
|
|
includePatterns: [
|
|
"ui/src/components/app-sidebar.catalog.test.ts",
|
|
"ui/src/components/app-sidebar.interactions.test.ts",
|
|
"ui/src/components/app-sidebar.people.test.ts",
|
|
"ui/src/components/app-sidebar.sessions.test.ts",
|
|
"ui/src/pages/chat/chat-view.test.ts",
|
|
"ui/src/pages/chat/chat-pane-lifecycle.test.ts",
|
|
"ui/src/pages/usage/metrics.node.test.ts",
|
|
],
|
|
shard_name: "cache-warm:ui-package",
|
|
};
|
|
if (profile === "hybrid-hosted") {
|
|
// Seed the hosted CI-routing and contract closures without collecting all
|
|
// tooling tests or building the runtime. Ordinary CI still runs every test.
|
|
return [
|
|
{
|
|
configs: ["test/vitest/vitest.tooling.config.ts"],
|
|
includePatterns: [
|
|
"test/scripts/ci-workflow-guards.test.ts",
|
|
"test/scripts/ci-workflow-planning.test.ts",
|
|
"test/scripts/ci-workflow-evidence.test.ts",
|
|
"test/scripts/ci-run-node-test-shard.test.ts",
|
|
],
|
|
shard_name: "cache-warm:hosted-tooling",
|
|
},
|
|
...(
|
|
[
|
|
["plugin", "src/plugins/contracts/registry.contract.test.ts"],
|
|
["channel-surface", "src/channels/plugins/contracts/channel-catalog.contract.test.ts"],
|
|
[
|
|
"channel-config",
|
|
"src/channels/plugins/contracts/gateway-auth-artifact.contract.test.ts",
|
|
],
|
|
[
|
|
"channel-registry",
|
|
"src/channels/plugins/contracts/plugins-core.loader.contract.test.ts",
|
|
],
|
|
[
|
|
"channel-session",
|
|
"src/channels/plugins/contracts/session-key-artifact.contract.test.ts",
|
|
],
|
|
] as const
|
|
).map(([name, file]) => ({
|
|
configs: [`test/vitest/vitest.contracts-${name}.config.ts`],
|
|
includePatterns: [file],
|
|
shard_name: `cache-warm:hosted-contracts-${name}`,
|
|
})),
|
|
uiGroup,
|
|
];
|
|
}
|
|
const additionalShardNames = new Set([
|
|
"agentic-agents-embedded",
|
|
"agentic-gateway-methods",
|
|
"auto-reply-reply-commands-3",
|
|
]);
|
|
const allShards = createNodeTestShards();
|
|
const coreShards = allShards.filter((candidate) =>
|
|
candidate.shardName.startsWith("core-unit-fast"),
|
|
);
|
|
if (coreShards.length === 0) {
|
|
throw new Error("core-unit-fast cache seed shards are missing");
|
|
}
|
|
const additionalShards = allShards.filter((candidate) =>
|
|
additionalShardNames.has(candidate.shardName),
|
|
);
|
|
const foundAdditionalShardNames = new Set(additionalShards.map((shard) => shard.shardName));
|
|
const missingShardNames = [...additionalShardNames].filter(
|
|
(name) => !foundAdditionalShardNames.has(name),
|
|
);
|
|
if (missingShardNames.length > 0) {
|
|
throw new Error(`cache seed shards are missing: ${missingShardNames.join(", ")}`);
|
|
}
|
|
return [
|
|
...[...coreShards, ...additionalShards].flatMap((shard) =>
|
|
shard.configs.map((config) => ({
|
|
configs: [config],
|
|
...(shard.env ? { env: shard.env } : {}),
|
|
...(shard.includePatterns ? { includePatterns: shard.includePatterns } : {}),
|
|
shard_name: `cache-warm:${shard.shardName}:${config}`,
|
|
})),
|
|
),
|
|
uiGroup,
|
|
];
|
|
}
|
|
|
|
function resolveCiNodeTestRunner(shard: NodeTestShard, compactProfile?: string): string {
|
|
const ownerRunner = COMPACT_NODE_TEST_OWNER_RUNNERS.get(compactProfile ?? "")?.get(
|
|
shard.shardName,
|
|
);
|
|
if (ownerRunner) {
|
|
return ownerRunner;
|
|
}
|
|
if (
|
|
(compactProfile === "blacksmith" &&
|
|
shard.includePatterns?.includes("src/cli/update-dry-run-state.process.test.ts")) ||
|
|
(compactProfile === "hybrid" &&
|
|
(shard.includePatterns?.includes("src/cli/gateway-backed-exit.process.test.ts") ||
|
|
shard.includePatterns?.includes("src/cli/gateway-backed-exit-health.process.test.ts")))
|
|
) {
|
|
return DEFAULT_NODE_TEST_RUNNER;
|
|
}
|
|
if (shard.runner !== DEFAULT_NODE_TEST_RUNNER) {
|
|
return shard.runner;
|
|
}
|
|
// The full-build compiler fixture must pass the real 4352MB heap guard even
|
|
// after earlier tooling tests have retained their module graphs.
|
|
return KEEP_LARGE_NODE_TEST_RUNNER.has(shard.shardName) ||
|
|
shard.includePatterns?.includes(TOOLING_UNIFIED_DECLARATIONS_TEST_FILE)
|
|
? DEFAULT_NODE_TEST_RUNNER
|
|
: BUNDLED_NODE_TEST_RUNNER;
|
|
}
|
|
|
|
function resolveCiNodeTestRunnerClass(runner: string) {
|
|
const name =
|
|
runner === EXTRA_LARGE_NODE_TEST_RUNNER
|
|
? "large32"
|
|
: runner.includes("-8vcpu-")
|
|
? "large"
|
|
: "small";
|
|
// Each runner bucket starts numbering at one; distinct classes need distinct
|
|
// names while larger requests retain the conservative large-job budget.
|
|
return {
|
|
name,
|
|
secondsCap:
|
|
name === "small" ? COMPACT_SMALL_NODE_TEST_JOB_SECONDS : COMPACT_LARGE_NODE_TEST_JOB_SECONDS,
|
|
};
|
|
}
|
|
|
|
function bundleNameForConfigs(configs: string[]): string {
|
|
const config = configs[0] ?? "node";
|
|
return config
|
|
.replace(/^test\/vitest\/vitest\./u, "")
|
|
.replace(/\.config\.ts$/u, "")
|
|
.replace(/[^a-z0-9-]+/giu, "-");
|
|
}
|
|
|
|
function compareFullNodeTestAdmissionOrder(a: NodeTestShard, b: NodeTestShard): number {
|
|
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)
|
|
);
|
|
}
|
|
|
|
// Deterministic cost-aware batching (greedy LPT): heaviest values first, each
|
|
// into the currently lightest batch. Round-robin by discovery order can pack
|
|
// one whale next to another and leave sibling batches much lighter.
|
|
function createStripedBatches<T>(
|
|
values: T[],
|
|
batchCount: number,
|
|
weightForValue: (value: T) => number,
|
|
weightForBatch?: (values: T[]) => number,
|
|
): T[][] {
|
|
if (batchCount < 1) {
|
|
throw new Error("striped batch count must be positive");
|
|
}
|
|
const entries = values.map((value, index) => ({
|
|
index,
|
|
value,
|
|
weight: weightForValue(value),
|
|
}));
|
|
entries.sort((a, b) => b.weight - a.weight || a.index - b.index);
|
|
const batches: Array<{
|
|
totalWeight: number;
|
|
entries: Array<{ index: number; value: T; weight: number }>;
|
|
}> = Array.from({ length: batchCount }, () => ({ totalWeight: 0, entries: [] }));
|
|
const firstBatch = batches[0];
|
|
if (!firstBatch) {
|
|
throw new Error("striped batch allocation failed");
|
|
}
|
|
for (const entry of entries) {
|
|
const nextWeight = (batch: (typeof batches)[number]) =>
|
|
weightForBatch
|
|
? weightForBatch([...batch.entries.map(({ value }) => value), entry.value])
|
|
: batch.totalWeight + entry.weight;
|
|
let target = firstBatch;
|
|
for (const batch of batches) {
|
|
if (nextWeight(batch) < nextWeight(target)) {
|
|
target = batch;
|
|
}
|
|
}
|
|
target.totalWeight = nextWeight(target);
|
|
target.entries.push(entry);
|
|
}
|
|
// Keep discovery order inside each batch so include lists stay stable.
|
|
return batches.map((batch) =>
|
|
batch.entries.toSorted((a, b) => a.index - b.index).map((entry) => entry.value),
|
|
);
|
|
}
|
|
|
|
function listCompactToolingTestFiles(): string[] {
|
|
const unitFastFiles = getUnitFastTestFilesForIncludePatterns([
|
|
"test/**/*.test.ts",
|
|
"src/scripts/**/*.test.ts",
|
|
]);
|
|
const excludedFiles = new Set([
|
|
...boundaryTestFiles,
|
|
...gatewayPluginTestFiles,
|
|
...gatewayDatabaseWorkerTestFiles,
|
|
...unitFastFiles,
|
|
TOOLING_DOCKER_TEST_FILE,
|
|
...toolingIsolatedTestFiles,
|
|
...databaseWorkerCoreTestFiles,
|
|
]);
|
|
return [...listTrackedTestFiles("test"), ...listTrackedTestFiles("src/scripts")].filter(
|
|
(file) =>
|
|
!file.startsWith("test/fixtures/") &&
|
|
!file.endsWith(".e2e.test.ts") &&
|
|
!file.endsWith(".live.test.ts") &&
|
|
!excludedFiles.has(file),
|
|
);
|
|
}
|
|
|
|
function applyNativeSoloTimings(
|
|
jobs: CompactNodeTestShard[],
|
|
runnerBackend: string | undefined,
|
|
): CompactNodeTestShard[] {
|
|
if (runnerBackend !== undefined && !["blacksmith", "hybrid", "runson"].includes(runnerBackend)) {
|
|
return jobs;
|
|
}
|
|
const timings = readCompactGroupTimings("blacksmith");
|
|
return jobs.map((job) => {
|
|
if (
|
|
job.runner !== NATIVE_SOLO_TIMING_PROFILE.runner ||
|
|
job.planConcurrency !== 1 ||
|
|
job.groups.length !== 1 ||
|
|
job.requiresDist ||
|
|
job.pretestBuildMode !== undefined ||
|
|
Object.entries(job.env ?? {}).some(
|
|
([key, value]) =>
|
|
key !== "OPENCLAW_VITEST_MAX_WORKERS" ||
|
|
value !== String(NATIVE_SOLO_TIMING_PROFILE.maxWorkers),
|
|
)
|
|
) {
|
|
return job;
|
|
}
|
|
const key = createNativeSoloTimingKey(job.groups[0]!);
|
|
const seconds = key === undefined ? undefined : timings[key];
|
|
// Solo measurements price only the final allocation. Feeding them into
|
|
// packing would let an unqualified shared row spend the solo discount.
|
|
return seconds === undefined
|
|
? job
|
|
: { ...job, predictedSeconds: seconds, predictedTestSeconds: seconds };
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Collapse split include-pattern shards into bounded jobs for normal CI.
|
|
* The base plan remains unchanged for release and coverage consumers.
|
|
*/
|
|
export function createNodeTestShardBundles(
|
|
options: NodeTestPlanOptions & { compactMode: CompactNodeTestPlanMode },
|
|
): CompactNodeTestShard[];
|
|
/** @deprecated Use compactMode so push and pull-request coverage stay explicit. */
|
|
export function createNodeTestShardBundles(
|
|
options: NodeTestPlanOptions & { compact: true },
|
|
): CompactNodeTestShard[];
|
|
export function createNodeTestShardBundles(options?: NodeTestPlanOptions): NodeTestShard[];
|
|
export function createNodeTestShardBundles(
|
|
options: NodeTestPlanOptions = {},
|
|
): NodeTestShard[] | CompactNodeTestShard[] {
|
|
const compactMode =
|
|
options.compactMode ?? (options.compact === true ? "pull-request" : undefined);
|
|
if (compactMode !== undefined) {
|
|
return applyNativeSoloTimings(
|
|
createCompactNodeTestShardBundles(
|
|
// Keep complete owners for cost admission; compact projection below gives
|
|
// a reduced tooling selection its own timing identity.
|
|
createNodeTestShards({ ...options, includeReleaseOnlyToolingShards: true }),
|
|
{ ...options, compactMode },
|
|
compactMode,
|
|
),
|
|
options.runnerBackend,
|
|
);
|
|
}
|
|
|
|
const shards = createNodeTestShards(options);
|
|
const unbundled: NodeTestShard[] = [];
|
|
const groups = new Map<
|
|
string,
|
|
{
|
|
configs: string[];
|
|
pretestBuildMode?: NodeTestPretestBuildMode;
|
|
requiresDist: boolean;
|
|
runner: string;
|
|
shards: NodeTestShard[];
|
|
}
|
|
>();
|
|
|
|
for (const shard of shards) {
|
|
const runner = resolveCiNodeTestRunner(shard);
|
|
if (shard.shardName === "agentic-gateway-server-isolated") {
|
|
// Full release validation retains two workers. Its whole native cohort
|
|
// exhausted the job deadline, so reuse the existing file envelope here.
|
|
const files = shard.includePatterns ?? [
|
|
...gatewayServerIsolatedTestFiles,
|
|
...gatewayDatabaseWorkerTestFiles,
|
|
];
|
|
const stripes = createStripedBatches(
|
|
files,
|
|
Math.ceil(files.length / MAX_BUNDLED_NODE_TEST_PATTERNS),
|
|
stripeFileWeight,
|
|
).flatMap((stripe) =>
|
|
Array.from(
|
|
{ length: Math.ceil(stripe.length / MAX_BUNDLED_NODE_TEST_PATTERNS) },
|
|
(_, index) =>
|
|
stripe.slice(
|
|
index * MAX_BUNDLED_NODE_TEST_PATTERNS,
|
|
(index + 1) * MAX_BUNDLED_NODE_TEST_PATTERNS,
|
|
),
|
|
),
|
|
);
|
|
const timingKeys = shard.timing_key
|
|
? createCompactSplitTimingGeneration({
|
|
configs: shard.configs,
|
|
env: shard.env,
|
|
parentShardName: shard.timing_key,
|
|
stripes,
|
|
}).timingKeys
|
|
: undefined;
|
|
for (const [index, includePatterns] of stripes.entries()) {
|
|
const shardName = `${shard.shardName}-${index + 1}`;
|
|
unbundled.push({
|
|
...shard,
|
|
checkName: formatNodeTestShardCheckName(shardName),
|
|
shardName,
|
|
...(timingKeys ? { timing_key: timingKeys[index]! } : {}),
|
|
includePatterns,
|
|
runner,
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
const [config] = shard.configs;
|
|
if (
|
|
shard.requiresDist ||
|
|
shard.configs.length !== 1 ||
|
|
config === undefined ||
|
|
!BUNDLEABLE_NODE_TEST_CONFIGS.has(config) ||
|
|
!Array.isArray(shard.includePatterns) ||
|
|
shard.includePatterns.length === 0
|
|
) {
|
|
unbundled.push({ ...shard, runner });
|
|
continue;
|
|
}
|
|
|
|
const key = JSON.stringify([shard.configs, shard.pretestBuildMode, shard.requiresDist, runner]);
|
|
const group = groups.get(key) ?? {
|
|
configs: shard.configs,
|
|
...(shard.pretestBuildMode ? { pretestBuildMode: shard.pretestBuildMode } : {}),
|
|
requiresDist: shard.requiresDist,
|
|
runner,
|
|
shards: [],
|
|
};
|
|
group.shards.push(shard);
|
|
groups.set(key, group);
|
|
}
|
|
|
|
const bundled: NodeTestShard[] = [];
|
|
for (const group of groups.values()) {
|
|
const bins: Array<{ includePatterns: string[] }> = [];
|
|
const sortedShards = group.shards.toSorted(
|
|
(a, b) =>
|
|
(b.includePatterns?.length ?? 0) - (a.includePatterns?.length ?? 0) ||
|
|
a.shardName.localeCompare(b.shardName),
|
|
);
|
|
for (const shard of sortedShards) {
|
|
const patterns = shard.includePatterns ?? [];
|
|
for (let offset = 0; offset < patterns.length; offset += MAX_BUNDLED_NODE_TEST_PATTERNS) {
|
|
const chunk = patterns.slice(offset, offset + MAX_BUNDLED_NODE_TEST_PATTERNS);
|
|
const bin = bins.find(
|
|
(candidate) =>
|
|
candidate.includePatterns.length + chunk.length <= MAX_BUNDLED_NODE_TEST_PATTERNS,
|
|
);
|
|
if (bin) {
|
|
bin.includePatterns.push(...chunk);
|
|
} else {
|
|
bins.push({ includePatterns: [...chunk] });
|
|
}
|
|
}
|
|
}
|
|
|
|
const { name: runnerClass } = resolveCiNodeTestRunnerClass(group.runner);
|
|
const buildModeSuffix = group.pretestBuildMode ? `-${group.pretestBuildMode}` : "";
|
|
const bundleName = `${bundleNameForConfigs(group.configs)}-${runnerClass}${buildModeSuffix}`;
|
|
for (const [index, bin] of bins.entries()) {
|
|
const shardName = `bundle-${bundleName}-${index + 1}`;
|
|
bundled.push({
|
|
checkName: formatNodeTestShardCheckName(shardName),
|
|
shardName,
|
|
...(group.shards.some((shard) => shard.timing_key)
|
|
? {
|
|
timing_key: createCompactSplitTimingGeneration({
|
|
configs: group.configs,
|
|
parentShardName: `changed-${shardName}`,
|
|
stripes: [bin.includePatterns],
|
|
}).timingKeys[0]!,
|
|
}
|
|
: {}),
|
|
configs: group.configs,
|
|
includePatterns: bin.includePatterns.toSorted((a, b) => a.localeCompare(b)),
|
|
...(group.pretestBuildMode ? { pretestBuildMode: group.pretestBuildMode } : {}),
|
|
runner: group.runner,
|
|
requiresDist: group.requiresDist,
|
|
});
|
|
}
|
|
}
|
|
|
|
const full = [...unbundled, ...bundled];
|
|
return (
|
|
options.runnerBackend === "github" ? full.flatMap(splitHostedReleaseShard) : full
|
|
).toSorted(compareFullNodeTestAdmissionOrder);
|
|
}
|
|
|
|
// Full release jobs include setup and can execute both runtimes. Keep their
|
|
// measured walls separate from compact test-group spans and reserve eight minutes
|
|
// of the 20-minute objective for changes in setup and cold-run overhead.
|
|
function splitHostedReleaseShard(shard: NodeTestShard): NodeTestShard[] {
|
|
const budget = 720;
|
|
const parentShardName = `release-full-${shard.shardName}`;
|
|
const timings = readCompactGroupTimings("github");
|
|
const files = canSplitWholeConfigGroup(shard.shardName)
|
|
? (shard.includePatterns ?? listWholeConfigSplitFiles(shard.shardName))
|
|
: undefined;
|
|
if (!isRuntimePlacementIncludePatterns(files)) {
|
|
const seconds = timings[parentShardName];
|
|
if (seconds !== undefined && seconds > budget) {
|
|
throw new Error(
|
|
`Release shard ${shard.shardName} cannot fit the hosted budget; split its test owner before release`,
|
|
);
|
|
}
|
|
return [
|
|
{
|
|
...shard,
|
|
timing_key: parentShardName,
|
|
...(seconds === undefined ? {} : { predictedSeconds: seconds }),
|
|
},
|
|
];
|
|
}
|
|
const generation = (stripes: string[][]) =>
|
|
createCompactSplitTimingGeneration({
|
|
configs: shard.configs,
|
|
env: shard.env,
|
|
parentShardName,
|
|
stripes,
|
|
});
|
|
const original = generation([files]);
|
|
const singletonCosts = new Map<string, number>();
|
|
for (const [key, cost] of Object.entries(timings)) {
|
|
const singleton = key.match(/#include-1-[a-f0-9]{12}$/u)?.[0];
|
|
if (singleton && key.startsWith(`${original.selectorKey}#generation-`)) {
|
|
singletonCosts.set(singleton, Math.max(singletonCosts.get(singleton) ?? 0, cost));
|
|
}
|
|
}
|
|
if ([...singletonCosts.values()].some((cost) => cost > budget)) {
|
|
throw new Error(
|
|
`Release shard ${shard.shardName} contains an indivisible test above the hosted budget; split that test before release`,
|
|
);
|
|
}
|
|
const seconds = Math.max(
|
|
timings[parentShardName] ?? 0,
|
|
timings[original.timingKeys[0]!] ?? 0,
|
|
readCompleteSplitGenerationSeconds(timings, original.selectorKey) ?? 0,
|
|
);
|
|
if (seconds <= budget) {
|
|
return [
|
|
{
|
|
...shard,
|
|
timing_key: original.timingKeys[0]!,
|
|
...(seconds === 0 ? {} : { predictedSeconds: seconds }),
|
|
},
|
|
];
|
|
}
|
|
const weight = (entries: readonly string[]) =>
|
|
entries.reduce((sum, file) => sum + stripeFileWeight(file), 0);
|
|
const totalWeight = weight(files);
|
|
let count = Math.min(
|
|
files.length,
|
|
Math.max(
|
|
2,
|
|
Math.ceil(seconds / budget),
|
|
Math.ceil(files.length / MAX_BUNDLED_NODE_TEST_PATTERNS),
|
|
),
|
|
);
|
|
for (;;) {
|
|
const stripes = createStripedBatches(files, count, stripeFileWeight);
|
|
const keys = generation(stripes).timingKeys;
|
|
const predicted = stripes.map((stripe, index) =>
|
|
Math.ceil(timings[keys[index]!] ?? (seconds * weight(stripe)) / totalWeight),
|
|
);
|
|
if (
|
|
predicted.every((cost) => cost <= budget) &&
|
|
stripes.every((stripe) => stripe.length <= MAX_BUNDLED_NODE_TEST_PATTERNS)
|
|
) {
|
|
const result: NodeTestShard[] = [];
|
|
for (const [index, includePatterns] of stripes.entries()) {
|
|
const shardName = `${shard.shardName}-hosted-${index + 1}`;
|
|
result.push({
|
|
...shard,
|
|
shardName,
|
|
checkName: formatNodeTestShardCheckName(shardName),
|
|
timing_key: keys[index]!,
|
|
includePatterns,
|
|
predictedSeconds: predicted[index]!,
|
|
});
|
|
}
|
|
return result;
|
|
}
|
|
if (count === files.length) {
|
|
throw new Error(
|
|
`Release shard ${shard.shardName} contains an indivisible test above the hosted budget; split that test before release`,
|
|
);
|
|
}
|
|
count += 1;
|
|
}
|
|
}
|
|
|
|
type HostedToolingTailDonation = {
|
|
parentShardName: string;
|
|
file: string;
|
|
freedSeconds: number;
|
|
};
|
|
|
|
function selectHostedToolingTailDonation(
|
|
stripes: readonly string[][],
|
|
secondsForFiles: (files: readonly string[]) => number,
|
|
selectedFile?: string,
|
|
selectedToolingFiles?: ReadonlySet<string>,
|
|
): { file: string; donorIndex: number; freedSeconds: number } | undefined {
|
|
const tail = stripes.at(-1);
|
|
if (!tail) {
|
|
return undefined;
|
|
}
|
|
let best: { file: string; donorIndex: number; freedSeconds: number } | undefined;
|
|
for (const [donorIndex, donor] of stripes.slice(0, -1).entries()) {
|
|
const donorSeconds = secondsForFiles(donor);
|
|
if (donor.length < 2 || donorSeconds <= COMPACT_EXCLUSIVE_JOB_SECONDS / 2) {
|
|
continue;
|
|
}
|
|
for (const file of donor) {
|
|
const freedSeconds = donorSeconds - secondsForFiles(donor.filter((entry) => entry !== file));
|
|
if (
|
|
(selectedFile !== undefined && file !== selectedFile) ||
|
|
(selectedToolingFiles !== undefined && !selectedToolingFiles.has(file)) ||
|
|
freedSeconds <= 0 ||
|
|
secondsForFiles([...tail, file]) > COMPACT_EXCLUSIVE_JOB_SECONDS / 2
|
|
) {
|
|
continue;
|
|
}
|
|
if (
|
|
!best ||
|
|
freedSeconds > best.freedSeconds ||
|
|
(freedSeconds === best.freedSeconds && file.localeCompare(best.file) < 0)
|
|
) {
|
|
best = { file, donorIndex, freedSeconds };
|
|
}
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
function hasStorageModuleWork(group: NodeTestShardGroup): boolean {
|
|
return group.configs.length === 1 && group.configs[0] === "test/vitest/vitest.infra.config.ts";
|
|
}
|
|
|
|
function storageModuleWorkFloor(
|
|
group: NodeTestShardGroup,
|
|
runnerBackend: string | undefined,
|
|
): number {
|
|
if (runnerBackend === "github" || !hasStorageModuleWork(group)) {
|
|
return 0;
|
|
}
|
|
const measured = (group.includePatterns ?? []).flatMap((file) => {
|
|
const seconds = STORAGE_MODULE_WORK_SECONDS.get(file);
|
|
return seconds === undefined ? [] : [seconds];
|
|
});
|
|
if (measured.length === 0) {
|
|
return 0;
|
|
}
|
|
// Packed children keep two workers. Additional solo capacity cannot divide
|
|
// the longest file; compiler and wrapper work are charged once per child.
|
|
return (
|
|
STORAGE_WRAPPER_SECONDS +
|
|
Math.max(...measured, measured.reduce((sum, seconds) => sum + seconds, 0) / 2)
|
|
);
|
|
}
|
|
|
|
function splitOversizedCompactGroup(
|
|
group: NodeTestShardGroup,
|
|
runnerBackend: string | undefined,
|
|
observedSeconds = 0,
|
|
runtimePartition?: ReturnType<typeof partitionRuntimeTestFiles>,
|
|
splitHostedToolingTails = false,
|
|
hostedToolingTailBudgets?: ReadonlyMap<string, number>,
|
|
hostedToolingTailDonation?: HostedToolingTailDonation,
|
|
onHostedToolingTailDonation?: (donation: HostedToolingTailDonation) => void,
|
|
selectedToolingFiles?: ReadonlySet<string>,
|
|
hostedMain = false,
|
|
): Array<{ group: NodeTestShardGroup; seconds: number }> {
|
|
// Hybrid groups must fit both the first-attempt runner and hosted retries;
|
|
// a faster retry estimate must not leave a slow first attempt unsplit.
|
|
const isCliProcess = group.shard_name === "agentic-cli-process";
|
|
const isTooling = isParallelToolingGroup(group);
|
|
const hostedFileLimit =
|
|
(hostedMain ? COMPACT_HOSTED_MAIN_MAX_FILES.get(group.shard_name) : undefined) ??
|
|
(runnerBackend === "github" && group.shard_name === "core-runtime-infra-storage-state"
|
|
? COMPACT_HOSTED_STORAGE_STATE_MAX_FILES
|
|
: undefined);
|
|
const exceedsHostedFileLimit =
|
|
hostedFileLimit !== undefined && (group.includePatterns?.length ?? 0) > hostedFileLimit;
|
|
const splitHostedUpdate =
|
|
hostedMain &&
|
|
(group.includePatterns?.length ?? 0) > 1 &&
|
|
group.includePatterns!.includes(HOSTED_MAIN_UPDATE_TEST);
|
|
const measuredProfileSeconds = Math.max(
|
|
estimateCompactGroupSeconds(group, runnerBackend),
|
|
observedSeconds,
|
|
storageModuleWorkFloor(group, runnerBackend),
|
|
);
|
|
const measuredHostedSeconds = Math.max(
|
|
estimateCompactGroupSeconds(group, "github"),
|
|
observedSeconds > 0
|
|
? runnerBackend === "github"
|
|
? observedSeconds
|
|
: estimateCompactStripeSeconds(group, "github") ||
|
|
observedSeconds * COMPACT_GITHUB_GROUP_SECONDS_SCALE
|
|
: 0,
|
|
);
|
|
if (!canSplitWholeConfigGroup(group.shard_name)) {
|
|
return [{ group, seconds: measuredProfileSeconds }];
|
|
}
|
|
// These consumers share one prepared runtime; admission retains the retry budget.
|
|
if (group.shard_name === COMMANDS_RUNTIME_GROUP && isParallelCommandsGroup(group)) {
|
|
return [{ group, seconds: measuredProfileSeconds }];
|
|
}
|
|
const parallelCommands = isParallelCommandsGroup(group);
|
|
const parallelGateway = isParallelGatewayServerGroup(group);
|
|
const parentWorkers = isAutoReplyReplyGroup(group)
|
|
? compactEffectiveFileWorkers(group, group.includePatterns?.length ?? 1)
|
|
: effectiveAgentsCoreWorkers(group);
|
|
// Whole parallel walls and sums of split invocations have different worker costs.
|
|
const splitTimingParent = `${compactGroupTimingKey(group)}${parallelGateway ? "-stripes" : ""}`;
|
|
const splitTimingPrefixes = [
|
|
splitTimingParent,
|
|
...(parallelCommands ? [group.shard_name] : []),
|
|
].map((key) => `${key}#selector-`);
|
|
const splitParentSeconds = {
|
|
blacksmith: parallelGateway
|
|
? (readCompactGroupSeconds({ ...group, timing_key: splitTimingParent }, "blacksmith") ?? 0)
|
|
: 0,
|
|
github: parallelGateway
|
|
? (readCompactGroupSeconds({ ...group, timing_key: splitTimingParent }, "github") ?? 0)
|
|
: 0,
|
|
};
|
|
const hasSplitTimingHistory =
|
|
!isTooling &&
|
|
(["blacksmith", "github"] as const).some(
|
|
(profile) =>
|
|
splitParentSeconds[profile] > 0 ||
|
|
Object.keys(readCompactGroupTimings(profile)).some((key) =>
|
|
splitTimingPrefixes.some((prefix) => key.startsWith(prefix)),
|
|
),
|
|
);
|
|
if (
|
|
!isCliProcess &&
|
|
!exceedsHostedFileLimit &&
|
|
!splitHostedUpdate &&
|
|
!runtimePartition &&
|
|
!hasSplitTimingHistory &&
|
|
Math.max(measuredProfileSeconds, measuredHostedSeconds) <= COMPACT_GITHUB_MAX_PREDICTED_SECONDS
|
|
) {
|
|
return [{ group, seconds: measuredProfileSeconds }];
|
|
}
|
|
const includePatterns = group.includePatterns ?? listWholeConfigSplitFiles(group.shard_name);
|
|
const buildModes = new Map(
|
|
includePatterns?.map((file) => [file, resolveTestFilesBuildMode([file])]) ?? [],
|
|
);
|
|
// Hybrid also reserves hosted retry costs; use the same spare-worker packing.
|
|
const packTooling = isTooling && (runnerBackend === "github" || runnerBackend === "hybrid");
|
|
const agentsCoreFiles = isParallelAgentsCoreGroup(group)
|
|
? new Set(agentsCoreWorkFiles(group))
|
|
: undefined;
|
|
const weightForFile = isTooling
|
|
? toolingFileWeight
|
|
: (file: string) =>
|
|
!agentsCoreFiles || agentsCoreFiles.has(file)
|
|
? Math.max(
|
|
stripeFileWeight(file),
|
|
hasStorageModuleWork(group) ? (STORAGE_MODULE_WORK_SECONDS.get(file) ?? 0) : 0,
|
|
)
|
|
: 0;
|
|
const totalWeight =
|
|
includePatterns?.reduce((seconds, file) => seconds + weightForFile(file), 0) ?? 0;
|
|
// A measured whole-config parent can lag newly cataloged files. Its old
|
|
// aggregate must not hide the complete process owner's file costs.
|
|
const profileSeconds = Math.max(measuredProfileSeconds, isCliProcess ? totalWeight : 0);
|
|
const splitBuildMode =
|
|
isCliProcess && !runtimePartition
|
|
? mergeVitestPretestBuildModes([...buildModes.values()])
|
|
: undefined;
|
|
const splitBuildSeconds = splitBuildMode ? VITEST_PRETEST_BUILD_SECONDS[splitBuildMode] : 0;
|
|
const hostedProfileSeconds = Math.max(measuredHostedSeconds, isCliProcess ? totalWeight : 0);
|
|
const splitSeconds = Math.max(
|
|
profileSeconds + splitBuildSeconds,
|
|
hostedProfileSeconds + Math.round(splitBuildSeconds * COMPACT_GITHUB_GROUP_SECONDS_SCALE),
|
|
);
|
|
if (!includePatterns || includePatterns.length < 2 || totalWeight === 0) {
|
|
return [{ group, seconds: profileSeconds }];
|
|
}
|
|
|
|
// The prerequisite is charged once per emitted job. Include it in placement
|
|
// so a balanced test stripe still leaves room for its runtime build.
|
|
let tailDonation: HostedToolingTailDonation | undefined;
|
|
const createStripes = (seconds: number) => {
|
|
tailDonation = undefined;
|
|
const files = runtimePartition?.otherFiles ?? includePatterns;
|
|
const batchWeight = (patterns: readonly string[]) => {
|
|
const mode = mergeVitestPretestBuildModes(patterns.map((file) => buildModes.get(file)));
|
|
const weight = patterns.reduce((sum, file) => sum + weightForFile(file), 0);
|
|
return (
|
|
(isTooling
|
|
? estimateParallelToolingSeconds(group, patterns, packTooling ? "github" : runnerBackend)
|
|
: weight) +
|
|
Math.round(
|
|
(mode ? VITEST_PRETEST_BUILD_SECONDS[mode] : 0) *
|
|
(packTooling ? COMPACT_GITHUB_GROUP_SECONDS_SCALE : 1),
|
|
)
|
|
);
|
|
};
|
|
const weightForValue =
|
|
isCliProcess || isTooling ? (file: string) => batchWeight([file]) : weightForFile;
|
|
let stripes: string[][];
|
|
if (packTooling) {
|
|
// Retain the normal file partition; unrelated families share its tails.
|
|
const discoveryOrder = (a: string, b: string) => files.indexOf(a) - files.indexOf(b);
|
|
const packFiles = (patterns: string[], secondsCap: number) =>
|
|
packNodeTestGroups(
|
|
patterns.toSorted(
|
|
(a, b) => weightForValue(b) - weightForValue(a) || discoveryOrder(a, b),
|
|
),
|
|
(bin, file) => {
|
|
const combinedSeconds = batchWeight([...bin, file]);
|
|
// Fill an indivisible file's spare worker without increasing its
|
|
// cost, but keep files above the whole-job budget alone.
|
|
return (
|
|
combinedSeconds <= secondsCap ||
|
|
(bin.length === 1 &&
|
|
combinedSeconds <= COMPACT_SERIAL_NODE_TEST_JOB_SECONDS &&
|
|
combinedSeconds <= batchWeight(bin))
|
|
);
|
|
},
|
|
).map((batch) => batch.toSorted(discoveryOrder));
|
|
// Full children plus small tails can strand a whole row even when the
|
|
// files fit. On overflow, expose smaller file envelopes for placement.
|
|
const refineTooling =
|
|
splitHostedToolingTails && !hostedToolingTailBudgets && !hostedToolingTailDonation;
|
|
stripes = refineTooling
|
|
? packFiles(files, COMPACT_EXCLUSIVE_JOB_SECONDS / 2)
|
|
: packFiles(files, COMPACT_EXCLUSIVE_JOB_SECONDS);
|
|
const tail = stripes.at(-1);
|
|
if (
|
|
splitHostedToolingTails &&
|
|
!refineTooling &&
|
|
tail &&
|
|
tail.length > 1 &&
|
|
batchWeight(tail) <= COMPACT_EXCLUSIVE_JOB_SECONDS
|
|
) {
|
|
// Keep runtime generations whole; ordinary tails use available capacity
|
|
// only after half-budget packing failed. Indivisible files keep their cost.
|
|
const tailBudget =
|
|
runtimePartition === undefined &&
|
|
group.pretestBuildMode === undefined &&
|
|
!group.requiresDist
|
|
? hostedToolingTailBudgets?.get(group.shard_name)
|
|
: undefined;
|
|
stripes.splice(-1, 1, ...packFiles(tail, tailBudget ?? COMPACT_EXCLUSIVE_JOB_SECONDS / 2));
|
|
}
|
|
const selectedDonation =
|
|
hostedToolingTailDonation?.parentShardName === group.shard_name
|
|
? hostedToolingTailDonation
|
|
: undefined;
|
|
if (
|
|
(onHostedToolingTailDonation || selectedDonation) &&
|
|
runtimePartition === undefined &&
|
|
group.pretestBuildMode === undefined &&
|
|
!group.requiresDist
|
|
) {
|
|
const donation = selectHostedToolingTailDonation(
|
|
stripes,
|
|
batchWeight,
|
|
selectedDonation?.file,
|
|
selectedToolingFiles,
|
|
);
|
|
if (donation) {
|
|
if (selectedDonation) {
|
|
stripes[donation.donorIndex] = stripes[donation.donorIndex]!.filter(
|
|
(file) => file !== donation.file,
|
|
);
|
|
stripes[stripes.length - 1] = [...stripes.at(-1)!, donation.file].toSorted(
|
|
discoveryOrder,
|
|
);
|
|
} else {
|
|
tailDonation = {
|
|
parentShardName: group.shard_name,
|
|
file: donation.file,
|
|
freedSeconds: donation.freedSeconds,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// The fixed build stays with its runtime child; only remaining test
|
|
// work benefits from more stripes. Empty include lists run the whole config.
|
|
const remainingSeconds = isTooling
|
|
? estimateParallelToolingSeconds(group, files, "github")
|
|
: runtimePartition
|
|
? (seconds * files.reduce((sum, file) => sum + weightForFile(file), 0)) / totalWeight
|
|
: seconds;
|
|
stripes = createStripedBatches(
|
|
files,
|
|
Math.min(
|
|
agentsCoreFiles
|
|
? Math.max(1, files.filter((file) => agentsCoreFiles.has(file)).length)
|
|
: files.length,
|
|
Math.max(1, Math.ceil(remainingSeconds / COMPACT_GITHUB_MAX_PREDICTED_SECONDS)),
|
|
),
|
|
weightForValue,
|
|
isCliProcess || isTooling ? batchWeight : undefined,
|
|
);
|
|
}
|
|
const partitioned = runtimePartition ? [runtimePartition.runtimeFiles, ...stripes] : stripes;
|
|
// Preserve prerequisite ownership and the existing weighted stripes. The
|
|
// family guard prevents cost packing from joining these serial chunks again.
|
|
const bounded =
|
|
hostedFileLimit === undefined
|
|
? partitioned
|
|
: partitioned.flatMap((stripeFiles) =>
|
|
Array.from({ length: Math.ceil(stripeFiles.length / hostedFileLimit) }, (_, index) =>
|
|
stripeFiles.slice(index * hostedFileLimit, (index + 1) * hostedFileLimit),
|
|
),
|
|
);
|
|
// The update file alone contributed 827 seconds of serial case time.
|
|
// Preserve the other storage files' existing order, workers and ownership.
|
|
return splitHostedUpdate
|
|
? bounded.flatMap((stripeFiles) =>
|
|
stripeFiles.includes(HOSTED_MAIN_UPDATE_TEST) && stripeFiles.length > 1
|
|
? [
|
|
[HOSTED_MAIN_UPDATE_TEST],
|
|
stripeFiles.filter((file) => file !== HOSTED_MAIN_UPDATE_TEST),
|
|
]
|
|
: [stripeFiles],
|
|
)
|
|
: bounded;
|
|
};
|
|
const timingEnv = parallelCommands ? { ...group.env, ...PINNED_COMPACT_GROUP_ENV } : group.env;
|
|
let stripes = createStripes(splitSeconds);
|
|
let timingGeneration = createCompactSplitTimingGeneration({
|
|
configs: group.configs,
|
|
env: timingEnv,
|
|
parentShardName: splitTimingParent,
|
|
stripes,
|
|
});
|
|
// The measured worker cutover changes timing identity, not admission budgets.
|
|
// Retain the previous two-worker observations as floors until timing refits retire them.
|
|
const previousWorkerEnv: Record<string, string> | undefined =
|
|
!parallelCommands && usesMeasuredCompactWorkers(group, runnerBackend)
|
|
? { ...group.env, ...PINNED_COMPACT_GROUP_ENV }
|
|
: undefined;
|
|
// The isolated cohort inherited its old two-worker cap from the job, not its descriptor.
|
|
if (previousWorkerEnv && MEASURED_GATEWAY_ISOLATED_GROUP_RE.test(group.shard_name)) {
|
|
delete previousWorkerEnv.OPENCLAW_VITEST_MAX_WORKERS;
|
|
}
|
|
const previousWorkerGeneration = previousWorkerEnv
|
|
? createCompactSplitTimingGeneration({
|
|
configs: group.configs,
|
|
env: previousWorkerEnv,
|
|
parentShardName: splitTimingParent,
|
|
stripes,
|
|
})
|
|
: undefined;
|
|
// Former eight-worker Gateway measurements remain floors, not two-worker samples.
|
|
const previousGatewayWorkerGeneration =
|
|
previousWorkerEnv && MEASURED_GATEWAY_ISOLATED_GROUP_RE.test(group.shard_name)
|
|
? createCompactSplitTimingGeneration({
|
|
configs: group.configs,
|
|
env: { ...group.env, OPENCLAW_VITEST_MAX_WORKERS: "8" },
|
|
parentShardName: splitTimingParent,
|
|
stripes,
|
|
})
|
|
: undefined;
|
|
const selectors = (
|
|
isTooling
|
|
? []
|
|
: [
|
|
timingGeneration.selectorKey,
|
|
previousWorkerGeneration?.selectorKey,
|
|
previousGatewayWorkerGeneration?.selectorKey,
|
|
]
|
|
).filter((selector): selector is string => selector !== undefined);
|
|
const completeBlacksmithSeconds = Math.max(
|
|
splitParentSeconds.blacksmith,
|
|
...selectors.map(
|
|
(selector) =>
|
|
readCompleteSplitGenerationSeconds(readCompactGroupTimings("blacksmith"), selector) ?? 0,
|
|
),
|
|
);
|
|
const completeHostedSeconds = Math.max(
|
|
splitParentSeconds.github,
|
|
...selectors.map(
|
|
(selector) =>
|
|
readCompleteSplitGenerationSeconds(readCompactGroupTimings("github"), selector) ?? 0,
|
|
),
|
|
);
|
|
const completeMeasuredSeconds =
|
|
runnerBackend === "github"
|
|
? completeHostedSeconds
|
|
: runnerBackend === "hybrid"
|
|
? Math.max(completeBlacksmithSeconds, completeHostedSeconds)
|
|
: completeBlacksmithSeconds;
|
|
// A measured two-worker envelope must keep that ceiling after its children move to solo rows.
|
|
const measuredGroup =
|
|
parallelCommands && completeMeasuredSeconds > 0 ? { ...group, env: timingEnv } : group;
|
|
if (
|
|
!runtimePartition &&
|
|
!exceedsHostedFileLimit &&
|
|
!splitHostedUpdate &&
|
|
(!parallelGateway || completeMeasuredSeconds === 0) &&
|
|
Math.max(splitSeconds, completeMeasuredSeconds) <= COMPACT_GITHUB_MAX_PREDICTED_SECONDS
|
|
) {
|
|
return [
|
|
{
|
|
group: measuredGroup,
|
|
seconds: parallelCommands
|
|
? Math.max(
|
|
profileSeconds,
|
|
runnerBackend === "github" ? completeHostedSeconds : completeBlacksmithSeconds,
|
|
)
|
|
: profileSeconds,
|
|
},
|
|
];
|
|
}
|
|
if (completeMeasuredSeconds > splitSeconds) {
|
|
stripes = createStripes(completeMeasuredSeconds);
|
|
timingGeneration = createCompactSplitTimingGeneration({
|
|
configs: group.configs,
|
|
env: timingEnv,
|
|
parentShardName: splitTimingParent,
|
|
stripes,
|
|
});
|
|
}
|
|
const completeProfileSeconds =
|
|
runnerBackend === "github" ? completeHostedSeconds : completeBlacksmithSeconds;
|
|
const distributedProfileSeconds = Math.max(profileSeconds, completeProfileSeconds);
|
|
if (tailDonation) {
|
|
onHostedToolingTailDonation?.(tailDonation);
|
|
}
|
|
const legacyCommandTimingKeys = parallelCommands
|
|
? createCompactSplitTimingGeneration({
|
|
configs: group.configs,
|
|
parentShardName: group.shard_name,
|
|
stripes,
|
|
}).timingKeys
|
|
: [];
|
|
const previousWorkerTimingKeys = previousWorkerGeneration
|
|
? createCompactSplitTimingGeneration({
|
|
configs: group.configs,
|
|
env: previousWorkerEnv,
|
|
parentShardName: splitTimingParent,
|
|
stripes,
|
|
}).timingKeys
|
|
: [];
|
|
const gatewaySingletonWorkers = parallelGateway ? gatewayServerEffectiveWorkers(group) : 1;
|
|
const serialSeconds = parallelGateway
|
|
? gatewayServerSerialSeconds(includePatterns, runnerBackend)
|
|
: 0;
|
|
const parallelWeight = includePatterns.reduce(
|
|
(sum, file) => sum + (gatewayServerSerialTestFiles.includes(file) ? 0 : weightForFile(file)),
|
|
0,
|
|
);
|
|
const mixedPhaseSeconds = (patterns: string[]) => {
|
|
const childSerialSeconds = gatewayServerSerialSeconds(patterns, runnerBackend);
|
|
const childParallelWeight = patterns.reduce(
|
|
(sum, file) => sum + (gatewayServerSerialTestFiles.includes(file) ? 0 : weightForFile(file)),
|
|
0,
|
|
);
|
|
if (parallelWeight === 0) {
|
|
return (
|
|
(Math.max(profileSeconds, distributedProfileSeconds) * childSerialSeconds) / serialSeconds
|
|
);
|
|
}
|
|
const fraction = childParallelWeight / parallelWeight;
|
|
const childWorkers = gatewayServerEffectiveWorkers({ ...group, includePatterns: patterns });
|
|
return (
|
|
childSerialSeconds +
|
|
Math.max(
|
|
(Math.max(0, profileSeconds - serialSeconds) * gatewaySingletonWorkers) / childWorkers,
|
|
Math.max(0, distributedProfileSeconds - serialSeconds),
|
|
) *
|
|
fraction
|
|
);
|
|
};
|
|
const serialChildSeconds = new Map<string, number>();
|
|
const childTimings = readCompactGroupTimings(
|
|
runnerBackend === "github" ? "github" : "blacksmith",
|
|
);
|
|
if (isParallelAgentsCoreGroup(group)) {
|
|
const { OPENCLAW_VITEST_MAX_WORKERS: _workers, ...env } = group.env ?? {};
|
|
const legacy = createCompactSplitTimingGeneration({
|
|
configs: group.configs,
|
|
env,
|
|
parentShardName: group.shard_name,
|
|
stripes,
|
|
});
|
|
for (const [key, seconds] of Object.entries(childTimings)) {
|
|
if (parseCompactSplitTimingKey(key)?.selectorKey !== legacy.selectorKey) {
|
|
continue;
|
|
}
|
|
// Unchanged child membership retains its sample when sibling stripes merge.
|
|
const membership = key.slice(key.lastIndexOf("#include-"));
|
|
serialChildSeconds.set(
|
|
membership,
|
|
Math.max(serialChildSeconds.get(membership) ?? 0, seconds),
|
|
);
|
|
}
|
|
}
|
|
return stripes.flatMap((patterns, index) => {
|
|
const timingKey = timingGeneration.timingKeys[index]!;
|
|
const child: NodeTestShardGroup = {
|
|
...measuredGroup,
|
|
includePatterns: patterns,
|
|
pretestBuildMode: mergeVitestPretestBuildModes(patterns.map((file) => buildModes.get(file))),
|
|
shard_name: `${group.shard_name}-hosted-${index + 1}`,
|
|
timing_key: timingKey,
|
|
};
|
|
if (isTooling) {
|
|
return [
|
|
{ group: child, seconds: estimateParallelToolingSeconds(child, patterns, runnerBackend) },
|
|
];
|
|
}
|
|
const measuredChild = estimateCompactStripeSeconds(child, runnerBackend);
|
|
const fileWorkFloor = storageModuleWorkFloor(child, runnerBackend);
|
|
const childFloor = Math.max(measuredChild, fileWorkFloor);
|
|
if (childFloor > 0) {
|
|
if (
|
|
childFloor > COMPACT_SERIAL_NODE_TEST_JOB_SECONDS &&
|
|
!child.requiresDist &&
|
|
!child.pretestBuildMode &&
|
|
patterns.length > 1 &&
|
|
isRuntimePlacementIncludePatterns(patterns) &&
|
|
(child.env?.OPENCLAW_VITEST_MAX_WORKERS === "2" ||
|
|
(child.env?.OPENCLAW_VITEST_MAX_WORKERS === undefined &&
|
|
patterns.every((file) => STORAGE_MODULE_WORK_SECONDS.has(file)) &&
|
|
fileWorkFloor >= childFloor))
|
|
) {
|
|
// Two-worker observations or the two-slot storage floor can fund this cap.
|
|
// An unpinned measured wall can describe eight workers; preserve its policy.
|
|
return splitOversizedCompactGroup(
|
|
{ ...child, env: { ...child.env, ...PINNED_COMPACT_GROUP_ENV } },
|
|
runnerBackend,
|
|
childFloor,
|
|
);
|
|
}
|
|
if (measuredChild > 0) {
|
|
return [{ group: child, seconds: childFloor }];
|
|
}
|
|
}
|
|
const childWorkers = isAutoReplyReplyGroup(child)
|
|
? compactEffectiveFileWorkers(child, patterns.length)
|
|
: effectiveAgentsCoreWorkers(child);
|
|
const weight = patterns.reduce((seconds, file) => seconds + weightForFile(file), 0);
|
|
const projectedSeconds = parallelGateway
|
|
? serialSeconds > 0
|
|
? mixedPhaseSeconds(patterns)
|
|
: (Math.max(
|
|
distributedProfileSeconds,
|
|
patterns.length === 1 ? profileSeconds * gatewaySingletonWorkers : 0,
|
|
) *
|
|
weight) /
|
|
totalWeight
|
|
: Math.max(
|
|
// Convert parent wall back to work before projecting a child's worker budget.
|
|
// Complete-generation totals already sum child walls and must not be multiplied.
|
|
((profileSeconds * weight) / totalWeight) * (parentWorkers / childWorkers),
|
|
(completeProfileSeconds * weight) / totalWeight,
|
|
((serialChildSeconds.get(timingKey.slice(timingKey.lastIndexOf("#include-"))) ?? 0) *
|
|
(runnerBackend === "hybrid" ? COMPACT_HYBRID_GROUP_SECONDS_SCALE : 1)) /
|
|
childWorkers,
|
|
);
|
|
return [
|
|
{
|
|
group: child,
|
|
// Round once at the job boundary; per-child rounding can duplicate a build
|
|
// when many small consumers together still fit one preparation budget.
|
|
seconds: Math.max(
|
|
parallelCommands ? commandFileSecondsFloor(patterns, runnerBackend) : 0,
|
|
legacyCommandTimingKeys[index]
|
|
? estimateLegacyCommandStripeSeconds(
|
|
patterns,
|
|
legacyCommandTimingKeys[index],
|
|
runnerBackend,
|
|
)
|
|
: 0,
|
|
projectedSeconds,
|
|
fileWorkFloor,
|
|
previousWorkerTimingKeys[index]
|
|
? estimateCompactStripeSeconds(
|
|
{ ...group, timing_key: previousWorkerTimingKeys[index] },
|
|
runnerBackend,
|
|
)
|
|
: 0,
|
|
),
|
|
},
|
|
];
|
|
});
|
|
}
|
|
|
|
// Owners supply admission order and compatibility; placement retains the original
|
|
// descriptors and never emits an empty job.
|
|
export function packNodeTestGroups<Group>(
|
|
orderedGroups: readonly Group[],
|
|
canShareJob: (bin: readonly [Group, ...Group[]], group: Group) => boolean,
|
|
allowGroupExchange = false,
|
|
): Array<[Group, ...Group[]]> {
|
|
const bins: Array<[Group, ...Group[]]> = [];
|
|
const admits = ([first, ...rest]: [Group, ...Group[]]) => {
|
|
const admitted: [Group, ...Group[]] = [first];
|
|
for (const entry of rest) {
|
|
if (!canShareJob(admitted, entry)) {
|
|
return false;
|
|
}
|
|
admitted.push(entry);
|
|
}
|
|
return true;
|
|
};
|
|
// A single exchange can free both time and group slots without adding a job.
|
|
// Validate complete replacement bins before changing either existing bin.
|
|
const exchange = (group: Group) => {
|
|
for (const [leftIndex, left] of bins.entries()) {
|
|
for (const right of bins.slice(leftIndex + 1)) {
|
|
for (const [leftSlot, leftGroup] of left.entries()) {
|
|
for (const [rightSlot, rightGroup] of right.entries()) {
|
|
const nextLeft: [Group, ...Group[]] = [...left];
|
|
const nextRight: [Group, ...Group[]] = [...right];
|
|
nextLeft[leftSlot] = rightGroup;
|
|
nextRight[rightSlot] = leftGroup;
|
|
const target = canShareJob(nextLeft, group)
|
|
? nextLeft
|
|
: canShareJob(nextRight, group)
|
|
? nextRight
|
|
: undefined;
|
|
if (target && admits(nextLeft) && admits(nextRight)) {
|
|
target.push(group);
|
|
left.splice(0, left.length, ...nextLeft);
|
|
right.splice(0, right.length, ...nextRight);
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
for (const group of orderedGroups) {
|
|
const bin = bins.find((candidate) => canShareJob(candidate, group));
|
|
if (bin) {
|
|
bin.push(group);
|
|
} else if (!allowGroupExchange || !exchange(group)) {
|
|
bins.push([group]);
|
|
}
|
|
}
|
|
return bins;
|
|
}
|
|
|
|
/** Select exact files without losing their canonical process and artifact owners. */
|
|
export function createSelectedNodeTestShardBundles(
|
|
targets: readonly string[],
|
|
options: Pick<NodeTestPlanOptions, "runnerBackend"> &
|
|
RuntimeTestSelection & {
|
|
onFallback?: (reason: string) => void;
|
|
preparedTestPlans?: ReadonlyMap<string, ReturnType<typeof buildVitestRunPlans>>;
|
|
} = {},
|
|
): CompactNodeTestShard[] | null {
|
|
if (options.runnerBackend === "runson") {
|
|
const selected = createSelectedNodeTestShardBundles(targets, {
|
|
...options,
|
|
runnerBackend: "hybrid",
|
|
});
|
|
return selected && routeRunsOnJobs(selected, COMPACT_NODE_TEST_JOB_CAP);
|
|
}
|
|
const selected = new Set(
|
|
targets.filter((file) => !isCiProofTestFile(file) && isRuntimeTestFileIncluded(file, options)),
|
|
);
|
|
const configs = new Map<string, string>();
|
|
const changedTestPlans: ReturnType<typeof buildVitestRunPlans> = [];
|
|
for (const target of selected) {
|
|
const plans = options.preparedTestPlans?.get(target) ?? buildVitestRunPlans([target]);
|
|
const exactFilter =
|
|
plans.length === 1 &&
|
|
plans[0]!.forwardedArgs.length === 1 &&
|
|
plans[0]!.forwardedArgs[0] === target;
|
|
if (
|
|
!isTestFileTarget(target) ||
|
|
!statSync(target, { throwIfNoEntry: false })?.isFile() ||
|
|
plans.length !== 1 ||
|
|
plans[0]!.watchMode ||
|
|
(plans[0]!.forwardedArgs.length > 0 && !exactFilter) ||
|
|
(plans[0]!.includePatterns
|
|
? plans[0]!.includePatterns.length !== 1 || plans[0]!.includePatterns[0] !== target
|
|
: !exactFilter)
|
|
) {
|
|
options.onFallback?.(
|
|
`unsupported canonical target: ${target} (${plans.map((plan) => plan.config).join(", ")})`,
|
|
);
|
|
return null;
|
|
}
|
|
const config = resolveCanonicalNodeTestConfig(target, plans[0]!.config);
|
|
if (!config) {
|
|
options.onFallback?.(`missing canonical config: ${target} (${plans[0]!.config})`);
|
|
return null;
|
|
}
|
|
configs.set(target, config);
|
|
if (!target.endsWith(".live.test.ts")) {
|
|
changedTestPlans.push(...plans);
|
|
}
|
|
}
|
|
if (selected.size === 0) {
|
|
return targets.length > 0 ? [] : null;
|
|
}
|
|
const tooling = new Set([...selected].filter((target) => configs.get(target) === TOOLING_CONFIG));
|
|
const shards = createNodeTestShardsForOwners(
|
|
fullSuiteVitestShards,
|
|
{
|
|
...options,
|
|
// Exact selection opts into its existing owner, not the unrelated plugin sweep.
|
|
changedPaths: [...selected],
|
|
includeReleaseOnlyPluginShards: false,
|
|
includeProofTests: false,
|
|
},
|
|
tooling.size === selected.size,
|
|
changedTestPlans,
|
|
);
|
|
const owners = new Set<NodeTestShard>();
|
|
for (const target of tooling) {
|
|
const matches = shards.filter(
|
|
(shard) =>
|
|
shard.configs.length === 1 &&
|
|
shard.configs[0] === TOOLING_CONFIG &&
|
|
shard.includePatterns?.includes(target),
|
|
);
|
|
if (matches.length !== 1) {
|
|
options.onFallback?.(`canonical tooling owner count ${matches.length}: ${target}`);
|
|
return null;
|
|
}
|
|
owners.add(matches[0]!);
|
|
}
|
|
// The complete plan owns process boundaries and resource policies. Its prices
|
|
// are planning facts; a selected subset must not reserve every original row.
|
|
const groupSeconds = new Map<NodeTestShardGroup, number>();
|
|
const full =
|
|
selected.size > tooling.size
|
|
? createCompactNodeTestShardBundles(shards, { ...options, groupSeconds }, "pull-request")
|
|
: [];
|
|
const canonicalGroups = full.flatMap((shard) => shard.groups);
|
|
const isWholeToolingPair = (group: NodeTestShardGroup) =>
|
|
group.shard_name === "core-tooling-isolated" &&
|
|
group.configs.length === 2 &&
|
|
group.configs[0] === "test/vitest/vitest.tooling-docker.config.ts" &&
|
|
group.configs[1] === TOOLING_ISOLATED_CONFIG;
|
|
const selectedGroups = new Map<NodeTestShardGroup, string[]>();
|
|
for (const target of selected) {
|
|
if (tooling.has(target)) {
|
|
continue;
|
|
}
|
|
const matches = canonicalGroups.filter(
|
|
(group) =>
|
|
group.configs.includes(configs.get(target)!) &&
|
|
group.env?.OPENCLAW_NODE_TEST_VITEST_ARGS_JSON === undefined &&
|
|
(!group.includePatterns || group.includePatterns.includes(target)),
|
|
);
|
|
if (matches.length !== 1) {
|
|
options.onFallback?.(
|
|
`canonical owner count ${matches.length}: ${target} (${configs.get(target)})`,
|
|
);
|
|
return null;
|
|
}
|
|
const owner = matches[0]!;
|
|
selectedGroups.set(owner, [...(selectedGroups.get(owner) ?? []), target]);
|
|
}
|
|
const canonicalFamilies = new Map<NodeTestShardGroup, string | undefined>();
|
|
const selectedTimings = readCompactGroupTimings(
|
|
options.runnerBackend === "github" ? "github" : "blacksmith",
|
|
);
|
|
const projected = full.flatMap((shard) => {
|
|
let retainedSeconds = 0;
|
|
const groups = shard.groups.flatMap((group) => {
|
|
const seconds = groupSeconds.get(group)!;
|
|
const files = selectedGroups.get(group);
|
|
if (!files?.length) {
|
|
return [];
|
|
}
|
|
// Artifact descriptors and opaque paired configs retain their complete owner.
|
|
if (group.requiresDist || isWholeToolingPair(group)) {
|
|
retainedSeconds += seconds;
|
|
canonicalFamilies.set(group, compactStripeFamily(group));
|
|
return [group];
|
|
}
|
|
const includePatterns =
|
|
group.includePatterns?.filter((file) => files.includes(file)) ?? files;
|
|
const selectedConfigs = group.configs.filter((config) =>
|
|
files.some((file) => configs.get(file) === config),
|
|
);
|
|
const inventory =
|
|
group.includePatterns ??
|
|
listWholeConfigFiles(group.shard_name) ??
|
|
(group.configs.length === 1 ? listNodeTestConfigFiles(group.configs[0]!) : undefined);
|
|
// Preserve opaque prices. Known inventories use the existing relative file
|
|
// weights, retaining an indivisible-file floor and separate build admission.
|
|
const weight = (paths: readonly string[]) =>
|
|
paths.reduce((total, file) => total + stripeFileWeight(file), 0);
|
|
const fallbackSeconds = inventory?.length
|
|
? Math.min(
|
|
seconds,
|
|
Math.max(
|
|
(seconds * weight(includePatterns)) / weight(inventory),
|
|
DEFAULT_WHOLE_GROUP_SECONDS *
|
|
(options.runnerBackend === "github" ? COMPACT_GITHUB_GROUP_SECONDS_SCALE : 1),
|
|
...includePatterns.map(stripeFileWeight),
|
|
),
|
|
)
|
|
: seconds;
|
|
const canonicalTimingKey = compactGroupTimingKey(group);
|
|
const timingParent =
|
|
parseCompactSplitTimingKey(canonicalTimingKey)?.parentShardName ?? canonicalTimingKey;
|
|
const { timingKeys } = createCompactSplitTimingGeneration({
|
|
configs: selectedConfigs,
|
|
env: group.env,
|
|
// Retain worker/parallel timing policy without borrowing the full owner's inventory.
|
|
parentShardName: timingParent.startsWith("changed-")
|
|
? timingParent
|
|
: `changed-${timingParent}`,
|
|
stripes: [includePatterns],
|
|
});
|
|
const selectedSeconds = Math.max(fallbackSeconds, selectedTimings[timingKeys[0]!] ?? 0);
|
|
retainedSeconds += selectedSeconds;
|
|
const projectedGroup = {
|
|
...group,
|
|
configs: selectedConfigs,
|
|
includePatterns,
|
|
timing_key: timingKeys[0]!,
|
|
};
|
|
groupSeconds.set(projectedGroup, selectedSeconds);
|
|
canonicalFamilies.set(projectedGroup, compactStripeFamily(group));
|
|
return [projectedGroup];
|
|
});
|
|
return groups.length
|
|
? [
|
|
{
|
|
...shard,
|
|
checkName: `checks-node-changed-${shard.shardName}`,
|
|
shardName: `changed-${shard.shardName}`,
|
|
groups,
|
|
predictedSeconds: Math.ceil(
|
|
retainedSeconds +
|
|
compactPreparationSeconds(shard.pretestBuildMode, options.runnerBackend),
|
|
),
|
|
},
|
|
]
|
|
: [];
|
|
});
|
|
return applyNativeSoloTimings(
|
|
[
|
|
...(tooling.size
|
|
? createCompactNodeTestShardBundles(
|
|
shards.filter((shard) => owners.has(shard)),
|
|
options,
|
|
"pull-request",
|
|
tooling,
|
|
)
|
|
: []),
|
|
...packSelectedNodeTestJobs(
|
|
projected,
|
|
options.runnerBackend,
|
|
canonicalFamilies,
|
|
groupSeconds,
|
|
),
|
|
],
|
|
options.runnerBackend,
|
|
);
|
|
}
|
|
|
|
function compactPreparationSeconds(
|
|
mode: NodeTestPretestBuildMode | undefined,
|
|
runnerBackend: string | undefined,
|
|
) {
|
|
return mode
|
|
? Math.round(
|
|
VITEST_PRETEST_BUILD_SECONDS[mode] *
|
|
(runnerBackend === "github" ? COMPACT_GITHUB_GROUP_SECONDS_SCALE : 1),
|
|
)
|
|
: 0;
|
|
}
|
|
|
|
function estimateParallelTestSeconds(
|
|
groups: readonly NodeTestShardGroup[],
|
|
estimateGroupSeconds: (group: NodeTestShardGroup) => number,
|
|
) {
|
|
// The executor takes the next group as soon as either of its two slots is free.
|
|
const slots = [0, 0];
|
|
for (const group of groups) {
|
|
const slot = slots[0]! <= slots[1]! ? 0 : 1;
|
|
slots[slot]! += estimateGroupSeconds(group);
|
|
}
|
|
return Math.ceil(Math.max(...slots));
|
|
}
|
|
|
|
function packSelectedNodeTestJobs(
|
|
jobs: CompactNodeTestShard[],
|
|
runnerBackend: string | undefined,
|
|
canonicalFamilies: ReadonlyMap<NodeTestShardGroup, string | undefined>,
|
|
groupSeconds: ReadonlyMap<NodeTestShardGroup, number>,
|
|
) {
|
|
const policy = (job: CompactNodeTestShard) =>
|
|
JSON.stringify([
|
|
job.runner,
|
|
job.requiresDist,
|
|
job.pretestBuildMode,
|
|
job.planConcurrency,
|
|
job.groups.some(isExclusiveCompactGroup),
|
|
Object.entries(job.env ?? {}).toSorted(([a], [b]) => a.localeCompare(b)),
|
|
job.timeoutMinutes,
|
|
]);
|
|
const seconds = (bin: readonly CompactNodeTestShard[]) =>
|
|
bin.reduce(
|
|
(total, job) =>
|
|
total +
|
|
job.predictedSeconds! -
|
|
compactPreparationSeconds(job.pretestBuildMode, runnerBackend),
|
|
0,
|
|
) + compactPreparationSeconds(bin[0]!.pretestBuildMode, runnerBackend);
|
|
const testSeconds = (bin: readonly CompactNodeTestShard[]) =>
|
|
bin[0]!.planConcurrency === 2
|
|
? estimateParallelTestSeconds(
|
|
bin.flatMap((job) => job.groups),
|
|
(group) => groupSeconds.get(group)!,
|
|
)
|
|
: Math.max(
|
|
0,
|
|
Math.ceil(seconds(bin)) -
|
|
compactPreparationSeconds(bin[0]!.pretestBuildMode, runnerBackend),
|
|
);
|
|
const cap = (bin: readonly CompactNodeTestShard[]) => {
|
|
const first = bin[0]!;
|
|
const groups = bin.flatMap((job) => job.groups);
|
|
const hostedBuild = runnerBackend === "github" && first.pretestBuildMode;
|
|
if (groups.some(isExclusiveCompactGroup) && !hostedBuild) {
|
|
return runnerBackend === "hybrid" &&
|
|
!first.pretestBuildMode &&
|
|
bin.every((job) => job.predictedSeconds! <= COMPACT_EXCLUSIVE_JOB_SECONDS) &&
|
|
groups.every((group) => COMPACT_SERIAL_CLI_GROUP_NAME_RE.test(group.shard_name))
|
|
? COMPACT_HYBRID_SERIAL_CLI_JOB_SECONDS
|
|
: COMPACT_EXCLUSIVE_JOB_SECONDS;
|
|
}
|
|
if (first.pretestBuildMode === "runtime" && runnerBackend === "hybrid") {
|
|
return COMPACT_HYBRID_RUNTIME_JOB_SECONDS;
|
|
}
|
|
if (first.planConcurrency === 2) {
|
|
return COMPACT_FINAL_PARALLEL_NODE_TEST_JOB_SECONDS;
|
|
}
|
|
return usesExpandedRunnerProfile(runnerBackend)
|
|
? COMPACT_EXPANDED_NODE_TEST_JOB_SECONDS
|
|
: resolveCiNodeTestRunnerClass(first.runner).secondsCap;
|
|
};
|
|
// Whole projected jobs are indivisible: regrouping cannot reduce a child's
|
|
// prior concurrency envelope and accidentally unlock more workers.
|
|
return packNodeTestGroups(
|
|
jobs.toSorted(
|
|
(a, b) => b.predictedSeconds! - a.predictedSeconds! || a.checkName.localeCompare(b.checkName),
|
|
),
|
|
(bin, job) => {
|
|
if (job.requiresDist || policy(bin[0]) !== policy(job)) {
|
|
return false;
|
|
}
|
|
const combined = [...bin, job];
|
|
const groups = combined.flatMap((entry) => entry.groups);
|
|
const families = groups
|
|
.map((group) => canonicalFamilies.get(group))
|
|
.filter((family) => family !== undefined);
|
|
return (
|
|
groups.length <= COMPACT_NODE_TEST_JOB_GROUPS &&
|
|
(job.planConcurrency === 1 || new Set(families).size === families.length) &&
|
|
seconds(combined) <= cap(combined) &&
|
|
(job.planConcurrency !== 2 || testSeconds(combined) <= COMPACT_SERIAL_NODE_TEST_JOB_SECONDS)
|
|
);
|
|
},
|
|
)
|
|
.map((bin) =>
|
|
Object.assign(bin[0], {
|
|
groups: bin.flatMap((job) => job.groups),
|
|
predictedSeconds: Math.ceil(seconds(bin)),
|
|
predictedTestSeconds: testSeconds(bin),
|
|
}),
|
|
)
|
|
.toSorted((a, b) => a.checkName.localeCompare(b.checkName));
|
|
}
|
|
|
|
function routeRunsOnJobs(
|
|
jobs: CompactNodeTestShard[],
|
|
compactNodeJobCap: number,
|
|
): CompactNodeTestShard[] {
|
|
const cronGroups: NodeTestShardGroup[] = [];
|
|
const cronTimeouts: number[] = [];
|
|
const routed = jobs.flatMap((job) => {
|
|
if (
|
|
job.requiresDist ||
|
|
job.pretestBuildMode ||
|
|
(job.env?.OPENCLAW_VITEST_MAX_WORKERS !== undefined &&
|
|
job.env.OPENCLAW_VITEST_MAX_WORKERS !== "2") ||
|
|
Object.keys(job.env ?? {}).some((key) => key !== "OPENCLAW_VITEST_MAX_WORKERS")
|
|
) {
|
|
return [job];
|
|
}
|
|
const retained = job.groups.filter((group) => {
|
|
if (
|
|
!/^core-runtime-cron-parallel-(?:core|isolated-agent|service)(?:-hosted-\d+)?$/u.test(
|
|
group.shard_name,
|
|
) ||
|
|
group.requiresDist ||
|
|
group.pretestBuildMode ||
|
|
group.configs.length !== 1 ||
|
|
group.configs[0] !== "test/vitest/vitest.cron.config.ts" ||
|
|
!group.includePatterns?.length ||
|
|
group.includePatterns.some((file) => !file.startsWith("src/cron/")) ||
|
|
(group.env?.OPENCLAW_VITEST_MAX_WORKERS !== undefined &&
|
|
group.env.OPENCLAW_VITEST_MAX_WORKERS !== "2") ||
|
|
(group.env?.OPENCLAW_VITEST_MAX_WORKERS === undefined &&
|
|
job.env?.OPENCLAW_VITEST_MAX_WORKERS === undefined &&
|
|
job.planConcurrency !== 2)
|
|
) {
|
|
return true;
|
|
}
|
|
cronGroups.push(group);
|
|
// The matrix's existing default is 60 minutes; extraction cannot extend
|
|
// any contributing job's execution deadline.
|
|
cronTimeouts.push(job.timeoutMinutes ?? 60);
|
|
return false;
|
|
});
|
|
return retained.length === job.groups.length
|
|
? [job]
|
|
: retained.length
|
|
? [{ ...job, groups: retained }]
|
|
: [];
|
|
});
|
|
if (cronGroups.length > 0) {
|
|
routed.push({
|
|
checkName: "checks-node-runson-cron",
|
|
shardName: "runson-cron",
|
|
runner: "runson-c8i-8xlarge",
|
|
groups: cronGroups,
|
|
requiresDist: false,
|
|
planConcurrency: 1,
|
|
timeoutMinutes: Math.min(...cronTimeouts),
|
|
// Extraction must not turn a packed child's two-worker allowance into
|
|
// the larger host default. Keep its selectors and group policy intact.
|
|
env: { ...PINNED_COMPACT_GROUP_ENV },
|
|
});
|
|
}
|
|
const jobCap = Math.min(
|
|
COMPACT_NODE_TEST_JOB_CAP,
|
|
compactNodeJobCap + routed.filter((job) => job.requiresDist).length,
|
|
);
|
|
if (routed.length > jobCap) {
|
|
throw new Error(
|
|
`compact runson node test plan exceeds ${jobCap} jobs (${routed.length} planned)`,
|
|
);
|
|
}
|
|
return routed.toSorted((a, b) => a.checkName.localeCompare(b.checkName));
|
|
}
|
|
|
|
function createCompactNodeTestShardBundles(
|
|
sourceShards: readonly NodeTestShard[],
|
|
options: NodeTestPlanOptions & { groupSeconds?: Map<NodeTestShardGroup, number> },
|
|
compactMode: CompactNodeTestPlanMode,
|
|
selectedToolingFiles?: ReadonlySet<string>,
|
|
splitHostedToolingTails = false,
|
|
hostedToolingTailBudgets?: ReadonlyMap<string, number>,
|
|
hostedToolingTailDonation?: HostedToolingTailDonation,
|
|
prioritizeSerialGateway = false,
|
|
): CompactNodeTestShard[] {
|
|
if (options.runnerBackend === "runson") {
|
|
// Hybrid owns placement and measured serial packing; RunsOn only extracts cron.
|
|
return routeRunsOnJobs(
|
|
createCompactNodeTestShardBundles(
|
|
sourceShards,
|
|
{ ...options, runnerBackend: "hybrid" },
|
|
compactMode,
|
|
selectedToolingFiles,
|
|
splitHostedToolingTails,
|
|
hostedToolingTailBudgets,
|
|
hostedToolingTailDonation,
|
|
prioritizeSerialGateway,
|
|
),
|
|
options.compactNodeJobCap ?? COMPACT_NODE_TEST_JOB_CAP,
|
|
);
|
|
}
|
|
const profileJobCap =
|
|
options.runnerBackend === "github"
|
|
? COMPACT_GITHUB_NODE_TEST_JOB_CAP
|
|
: COMPACT_NODE_TEST_JOB_CAP;
|
|
const compactNodeJobCap = options.compactNodeJobCap ?? profileJobCap;
|
|
if (!Number.isSafeInteger(compactNodeJobCap) || compactNodeJobCap < 1) {
|
|
throw new Error("compact Node job cap must be a positive integer");
|
|
}
|
|
const effectiveJobCap = (bins: readonly (readonly NodeTestShardGroup[])[]) =>
|
|
Math.min(profileJobCap, compactNodeJobCap + bins.filter((bin) => bin[0]?.requiresDist).length);
|
|
const includeTooling = compactMode !== "push" && includesReleaseOnlyTooling(options);
|
|
const keepPrExemptRuntimeFile = (file: string) =>
|
|
options.includePrExemptRuntimeTests !== undefined &&
|
|
isPrExemptRuntimeTestFile(file) &&
|
|
isRuntimeTestFileIncluded(file, options);
|
|
const isBlacksmithProfile = (options.runnerBackend ?? "blacksmith") === "blacksmith";
|
|
const packsHostedTooling = compactMode === "pull-request" && options.runnerBackend === "github";
|
|
let bestTailDonation: HostedToolingTailDonation | undefined;
|
|
const collectTailDonation =
|
|
packsHostedTooling && splitHostedToolingTails && !hostedToolingTailDonation
|
|
? (donation: HostedToolingTailDonation) => {
|
|
if (
|
|
!bestTailDonation ||
|
|
donation.freedSeconds > bestTailDonation.freedSeconds ||
|
|
(donation.freedSeconds === bestTailDonation.freedSeconds &&
|
|
`${donation.parentShardName}/${donation.file}`.localeCompare(
|
|
`${bestTailDonation.parentShardName}/${bestTailDonation.file}`,
|
|
) < 0)
|
|
) {
|
|
bestTailDonation = donation;
|
|
}
|
|
}
|
|
: undefined;
|
|
const shards = sourceShards.filter(
|
|
(shard) =>
|
|
((compactMode !== "push" || !COMPACT_PUSH_EXCLUDED_SHARDS.has(shard.shardName)) &&
|
|
(includeTooling ||
|
|
!shard.configs.every((config) => RELEASE_ONLY_TOOLING_CONFIGS.has(config)))) ||
|
|
(shard.includePatterns ?? listToolingGroupFiles(shard.configs))?.some(
|
|
keepPrExemptRuntimeFile,
|
|
),
|
|
);
|
|
const groupsByRunner = new Map<string, [NodeTestShardGroup, ...NodeTestShardGroup[]]>();
|
|
const synthesizedSplitSeconds = new Map<string, number>();
|
|
const runnerRank = (group: Pick<NodeTestShardGroup, "runner">) =>
|
|
[BUNDLED_NODE_TEST_RUNNER, DEFAULT_NODE_TEST_RUNNER, EXTRA_LARGE_NODE_TEST_RUNNER].indexOf(
|
|
group.runner,
|
|
);
|
|
|
|
for (const shard of shards) {
|
|
const runner = resolveCiNodeTestRunner(shard);
|
|
const group = applyCompactGroupWorkerPins(
|
|
{
|
|
configs: shard.configs,
|
|
...(shard.env ? { env: shard.env } : {}),
|
|
...(shard.includePatterns ? { includePatterns: shard.includePatterns } : {}),
|
|
...(shard.pretestBuildMode ? { pretestBuildMode: shard.pretestBuildMode } : {}),
|
|
requiresDist: shard.requiresDist,
|
|
runner,
|
|
shard_name: shard.shardName,
|
|
...(shard.timing_key ? { timing_key: shard.timing_key } : {}),
|
|
},
|
|
options.runnerBackend,
|
|
);
|
|
const partitionFiles = group.pretestBuildMode
|
|
? (group.includePatterns ?? listWholeConfigSplitFiles(group.shard_name))
|
|
: undefined;
|
|
const partition = partitionFiles
|
|
? partitionRuntimeTestFiles(group.configs, partitionFiles)
|
|
: undefined;
|
|
const runtimePartition =
|
|
partition?.runtimeFiles.length && partition.otherFiles.length ? partition : undefined;
|
|
// Resolve whole-config ownership before splitting so ordinary files do not
|
|
// inherit a runtime build. Keep consumers together and split the remaining work.
|
|
let plannedGroups =
|
|
usesExpandedRunnerProfile(options.runnerBackend) ||
|
|
COMPACT_BLACKSMITH_SPLIT_OWNERS.has(group.shard_name) ||
|
|
(isParallelToolingGroup(group) &&
|
|
compactToolingTestSecondsLimit([group], options.runnerBackend) ===
|
|
COMPACT_EXCLUSIVE_JOB_SECONDS) ||
|
|
isParallelGatewayServerGroup(group) ||
|
|
isParallelCommandsGroup(group) ||
|
|
runtimePartition !== undefined ||
|
|
(group.pretestBuildMode !== undefined && group.includePatterns === undefined)
|
|
? splitOversizedCompactGroup(
|
|
group,
|
|
options.runnerBackend,
|
|
0,
|
|
runtimePartition,
|
|
splitHostedToolingTails,
|
|
hostedToolingTailBudgets,
|
|
hostedToolingTailDonation,
|
|
collectTailDonation,
|
|
selectedToolingFiles,
|
|
compactMode === "push" && options.runnerBackend === "github",
|
|
)
|
|
: [{ group, seconds: estimateCompactGroupSeconds(group, options.runnerBackend) }];
|
|
const reducedToolingTier =
|
|
!includeTooling &&
|
|
(shard.configs.every((config) => RELEASE_ONLY_TOOLING_CONFIGS.has(config)) ||
|
|
plannedGroups.some((planned) =>
|
|
(planned.group.includePatterns ?? listToolingGroupFiles(planned.group.configs))?.some(
|
|
isReleaseOnlyToolingTestFile,
|
|
),
|
|
));
|
|
const selectedTooling =
|
|
(selectedToolingFiles && group.configs.includes(TOOLING_CONFIG)) || reducedToolingTier;
|
|
if (selectedTooling) {
|
|
// Keep the parent's admission cost and partition policy, but never report
|
|
// a precise subset as a sample of the complete canonical stripe.
|
|
plannedGroups = plannedGroups.flatMap((planned) => {
|
|
const includePatterns = (
|
|
planned.group.includePatterns ?? listToolingGroupFiles(planned.group.configs)
|
|
)?.filter(
|
|
(file) =>
|
|
(!selectedToolingFiles || selectedToolingFiles.has(file)) &&
|
|
(includeTooling ||
|
|
(!planned.group.configs.every((config) => RELEASE_ONLY_TOOLING_CONFIGS.has(config)) &&
|
|
!isReleaseOnlyToolingTestFile(file)) ||
|
|
keepPrExemptRuntimeFile(file)),
|
|
);
|
|
return includePatterns?.length
|
|
? [{ ...planned, group: { ...planned.group, includePatterns } }]
|
|
: [];
|
|
});
|
|
const generation = createCompactSplitTimingGeneration({
|
|
configs: group.configs,
|
|
env: group.env,
|
|
parentShardName: reducedToolingTier ? `changed-${group.shard_name}` : group.shard_name,
|
|
stripes: plannedGroups.map((planned) => planned.group.includePatterns!),
|
|
});
|
|
plannedGroups.forEach((planned, index) => {
|
|
planned.group.timing_key = generation.timingKeys[index]!;
|
|
});
|
|
}
|
|
for (const planned of plannedGroups) {
|
|
planned.group.runner = resolveCiNodeTestRunner(
|
|
{
|
|
...shard,
|
|
includePatterns: planned.group.includePatterns,
|
|
},
|
|
options.runnerBackend ?? "blacksmith",
|
|
);
|
|
if (
|
|
storageModuleWorkFloor(planned.group, options.runnerBackend) > COMPACT_EXCLUSIVE_JOB_SECONDS
|
|
) {
|
|
// Retain the measured storage allocation when splitting leaves an outlier alone.
|
|
planned.group.runner = EXTRA_LARGE_NODE_TEST_RUNNER;
|
|
}
|
|
// Ordinary packed self-hosted jobs already receive the 32-class. Share
|
|
// those slots across logical classes while keeping runtime owners separate.
|
|
const sharesOrdinaryCapacity =
|
|
(options.runnerBackend === "github" ||
|
|
((isBlacksmithProfile || options.runnerBackend === "hybrid") &&
|
|
!planned.group.pretestBuildMode)) &&
|
|
!planned.group.requiresDist &&
|
|
!isExclusiveCompactGroup(planned.group) &&
|
|
runnerRank(planned.group) >= 0;
|
|
const dedicatedHostedMainGroup =
|
|
compactMode === "push" &&
|
|
options.runnerBackend === "github" &&
|
|
(/^agentic-control-plane-agent-chat(?:-hosted-\d+)?$/u.test(planned.group.shard_name) ||
|
|
(/^agentic-gateway-methods(?:-hosted-\d+)?$/u.test(planned.group.shard_name) &&
|
|
(planned.group.includePatterns?.length ?? 0) > 32) ||
|
|
planned.group.includePatterns?.includes(HOSTED_MAIN_UPDATE_TEST));
|
|
const key = JSON.stringify([
|
|
sharesOrdinaryCapacity ? "ordinary" : planned.group.runner,
|
|
shard.requiresDist,
|
|
// Keep measured heavy children alone despite their stale packing estimates.
|
|
// Small Gateway tails and its runtime prerequisite keep ordinary packing.
|
|
dedicatedHostedMainGroup ? planned.group.shard_name : undefined,
|
|
]);
|
|
const groups = groupsByRunner.get(key);
|
|
if (groups) {
|
|
groups.push(planned.group);
|
|
} else {
|
|
groupsByRunner.set(key, [planned.group]);
|
|
}
|
|
// Exact child measurements replace parent projections. Changed membership
|
|
// keeps its parent floor until the new workload has an observation.
|
|
if (
|
|
selectedTooling ||
|
|
isParallelCommandsGroup(group) ||
|
|
planned.group.shard_name !== group.shard_name
|
|
) {
|
|
synthesizedSplitSeconds.set(compactGroupTimingKey(planned.group), planned.seconds);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Packing revisits immutable groups; prepare their cost and family once.
|
|
// Keep facts within this plan's timing inputs and partitions.
|
|
const stripeFacts = new Map<
|
|
NodeTestShardGroup,
|
|
{ seconds: number; family: string | undefined }
|
|
>();
|
|
const prepareStripe = (group: NodeTestShardGroup) => {
|
|
let facts = stripeFacts.get(group);
|
|
if (!facts) {
|
|
const seconds = Math.max(
|
|
synthesizedSplitSeconds.get(compactGroupTimingKey(group)) ?? 0,
|
|
estimateCompactStripeSeconds(group, options.runnerBackend),
|
|
storageModuleWorkFloor(group, options.runnerBackend),
|
|
);
|
|
facts = {
|
|
// Worker selection after packing may lower a price, never introduce a
|
|
// previously uncharged file floor or measured worker-specific wall.
|
|
seconds: isParallelCommandsGroup(group)
|
|
? Math.max(
|
|
seconds,
|
|
estimateCommandWorkerSeconds(
|
|
group,
|
|
seconds,
|
|
Math.min(
|
|
options.runnerBackend === "github" ? 2 : 8,
|
|
readPositiveEnvInt("OPENCLAW_VITEST_MAX_WORKERS", group.env ?? {}, 8),
|
|
),
|
|
options.runnerBackend,
|
|
).seconds,
|
|
)
|
|
: seconds,
|
|
family: compactStripeFamily(group),
|
|
};
|
|
stripeFacts.set(group, facts);
|
|
}
|
|
return facts;
|
|
};
|
|
const estimateStripeSeconds = (group: NodeTestShardGroup) => prepareStripe(group).seconds;
|
|
const estimateBinSeconds = (groups: NodeTestShardGroup[]) => {
|
|
const mode = mergeVitestPretestBuildModes(groups.map((group) => group.pretestBuildMode));
|
|
return (
|
|
groups.reduce((seconds, group) => seconds + estimateStripeSeconds(group), 0) +
|
|
compactPreparationSeconds(mode, options.runnerBackend)
|
|
);
|
|
};
|
|
const isHostedToolingGroup = (group: NodeTestShardGroup) =>
|
|
!group.requiresDist &&
|
|
group.pretestBuildMode === undefined &&
|
|
/^core-tooling-\d+-hosted-\d+$/u.test(group.shard_name) &&
|
|
group.configs.includes(TOOLING_CONFIG) &&
|
|
runnerRank(group) >= 0;
|
|
const hasDistinctStripeFamilies = (groups: NodeTestShardGroup[]) => {
|
|
const families = groups
|
|
.map((group) => prepareStripe(group).family)
|
|
.filter((family): family is string => family !== undefined);
|
|
return new Set(families).size === families.length;
|
|
};
|
|
const admitsCompactBin = (
|
|
groups: NodeTestShardGroup[],
|
|
secondsCap: number,
|
|
seconds = estimateBinSeconds,
|
|
{ sharedFamily = false, parallel = false } = {},
|
|
) =>
|
|
groups.length > 0 &&
|
|
(sharedFamily || hasDistinctStripeFamilies(groups)) &&
|
|
(parallel || groups.length <= COMPACT_NODE_TEST_JOB_GROUPS) &&
|
|
seconds(groups) <= secondsCap &&
|
|
(!parallel ||
|
|
estimateParallelTestSeconds(groups, estimateStripeSeconds) <=
|
|
COMPACT_SERIAL_NODE_TEST_JOB_SECONDS);
|
|
const usesBlacksmithCapacity = (runner: string) =>
|
|
isBlacksmithProfile ||
|
|
(options.runnerBackend === "hybrid" &&
|
|
[DEFAULT_NODE_TEST_RUNNER, BUNDLED_NODE_TEST_RUNNER, EXTRA_LARGE_NODE_TEST_RUNNER].includes(
|
|
runner,
|
|
));
|
|
const hostedToolingGroups: NodeTestShardGroup[] = [];
|
|
const maximumInitialBinSeconds = Math.max(
|
|
COMPACT_PARALLEL_NODE_TEST_JOB_SECONDS,
|
|
COMPACT_SERIAL_NODE_TEST_JOB_SECONDS,
|
|
COMPACT_HYBRID_SERIAL_CLI_JOB_SECONDS,
|
|
COMPACT_EXCLUSIVE_JOB_SECONDS,
|
|
COMPACT_EXPANDED_NODE_TEST_JOB_SECONDS,
|
|
COMPACT_LARGE_NODE_TEST_JOB_SECONDS,
|
|
COMPACT_SMALL_NODE_TEST_JOB_SECONDS,
|
|
);
|
|
let packedBins = [...groupsByRunner.values()].flatMap((groups) => {
|
|
const usesBlacksmithRunner = usesBlacksmithCapacity(groups[0].runner);
|
|
// Admit the final groups with their shared prerequisite. Rebalancing after
|
|
// this check can break build sharing and exceed a bin's admitted cap.
|
|
// Retry over-cap hybrid plans with Gateway first; leave successful cost-first
|
|
// placement and its runtime recipients intact.
|
|
const sortedGroups = groups
|
|
.flatMap((group) => expandCompactGroup(group, options.runnerBackend))
|
|
.toSorted(
|
|
(a, b) =>
|
|
(prioritizeSerialGateway
|
|
? Number(b.configs.some(isExclusiveCiTestConfig)) -
|
|
Number(a.configs.some(isExclusiveCiTestConfig))
|
|
: 0) ||
|
|
estimateBinSeconds([b]) - estimateBinSeconds([a]) ||
|
|
runnerRank(b) - runnerRank(a) ||
|
|
a.shard_name.localeCompare(b.shard_name),
|
|
);
|
|
// Hosted inventory must not influence non-hosted anchor membership.
|
|
const anchorGroups = packsHostedTooling
|
|
? sortedGroups.filter((group) => !isHostedToolingGroup(group))
|
|
: sortedGroups;
|
|
if (packsHostedTooling) {
|
|
hostedToolingGroups.push(...sortedGroups.filter(isHostedToolingGroup));
|
|
}
|
|
const canShareCompactJob = (
|
|
candidate: readonly [NodeTestShardGroup, ...NodeTestShardGroup[]],
|
|
group: NodeTestShardGroup,
|
|
) => {
|
|
const exclusive = isExclusiveCompactGroup(group);
|
|
if (isExclusiveCompactGroup(candidate[0]) !== exclusive) {
|
|
return false;
|
|
}
|
|
// Keep ordinary work off serial runtime hosts. Hybrid exclusive/dist bins
|
|
// retain their existing prerequisite sharing and admission policy.
|
|
if (
|
|
(isBlacksmithProfile || (usesBlacksmithRunner && !exclusive && !group.requiresDist)) &&
|
|
Boolean(candidate[0].pretestBuildMode) !== Boolean(group.pretestBuildMode)
|
|
) {
|
|
return false;
|
|
}
|
|
const combined = [...candidate, group];
|
|
const combinedSeconds = estimateBinSeconds(combined);
|
|
// Exchange candidates usually overflow before their finer policy matters.
|
|
// Every multi-group admission below has one of these bounded budgets.
|
|
if (combinedSeconds > maximumInitialBinSeconds) {
|
|
return false;
|
|
}
|
|
// Spend the larger budget only on a complete no-build CLI bin. Each child
|
|
// keeps its 150s admission limit, worker budget and separate process.
|
|
const sharesSerialCliBudget =
|
|
options.runnerBackend === "hybrid" &&
|
|
combined.every(
|
|
(entry) =>
|
|
!entry.requiresDist &&
|
|
!entry.pretestBuildMode &&
|
|
COMPACT_SERIAL_CLI_GROUP_NAME_RE.test(entry.shard_name) &&
|
|
estimateBinSeconds([entry]) <= COMPACT_EXCLUSIVE_JOB_SECONDS,
|
|
);
|
|
// Hosted preparation exceeds the exclusive test budget by itself. Share
|
|
// that build within the normal serial budget while keeping no-build work separate.
|
|
const sharesHostedBuild =
|
|
options.runnerBackend === "github" &&
|
|
combined.every((entry) => entry.pretestBuildMode !== undefined && !entry.requiresDist);
|
|
const serialSecondsCap = sharesSerialCliBudget
|
|
? COMPACT_HYBRID_SERIAL_CLI_JOB_SECONDS
|
|
: combined.every(isParallelToolingGroup)
|
|
? compactToolingTestSecondsLimit(combined, options.runnerBackend)
|
|
: exclusive && !sharesHostedBuild
|
|
? COMPACT_EXCLUSIVE_JOB_SECONDS
|
|
: usesExpandedRunnerProfile(options.runnerBackend)
|
|
? COMPACT_EXPANDED_NODE_TEST_JOB_SECONDS
|
|
: resolveCiNodeTestRunnerClass(group.runner).secondsCap;
|
|
const parallel =
|
|
usesBlacksmithRunner &&
|
|
combined.every(isParallelCompactGroup) &&
|
|
combined.every((entry) => estimateBinSeconds([entry]) <= serialSecondsCap);
|
|
const secondsCap = parallel
|
|
? combined.some((entry) => entry.configs.some(isExclusiveCiTestConfig))
|
|
? COMPACT_SERIAL_NODE_TEST_JOB_SECONDS
|
|
: COMPACT_PARALLEL_NODE_TEST_JOB_SECONDS
|
|
: serialSecondsCap;
|
|
return admitsCompactBin(combined, secondsCap, () => combinedSeconds, {
|
|
sharedFamily: sharesSerialCliBudget,
|
|
parallel,
|
|
});
|
|
};
|
|
const bins = packNodeTestGroups(anchorGroups, canShareCompactJob, true);
|
|
if (options.runnerBackend === "github") {
|
|
for (const bin of bins) {
|
|
bin.sort((a, b) => runnerRank(b) - runnerRank(a));
|
|
}
|
|
}
|
|
bins.sort(
|
|
(a, b) => Number(isExclusiveCompactGroup(a[0])) - Number(isExclusiveCompactGroup(b[0])),
|
|
);
|
|
return bins;
|
|
});
|
|
const canShareHostedGroup = (candidate: NodeTestShardGroup[], group: NodeTestShardGroup) => {
|
|
const owner = candidate[0];
|
|
const combined = [...candidate, group];
|
|
return (
|
|
owner !== undefined &&
|
|
((owner.runner === group.runner && owner.requiresDist === group.requiresDist) ||
|
|
(combined.every(isHostedToolingGroup) && runnerRank(owner) > runnerRank(group))) &&
|
|
isExclusiveCompactGroup(owner) === isExclusiveCompactGroup(group) &&
|
|
candidate.length < COMPACT_NODE_TEST_JOB_GROUPS &&
|
|
hasDistinctStripeFamilies(combined)
|
|
);
|
|
};
|
|
const hostedBinBudget = (groups: NodeTestShardGroup[]) =>
|
|
groups.every(isParallelToolingGroup)
|
|
? COMPACT_SERIAL_NODE_TEST_JOB_SECONDS
|
|
: COMPACT_EXCLUSIVE_JOB_SECONDS;
|
|
if (packsHostedTooling) {
|
|
const anchors = packedBins;
|
|
const hostedGroups = hostedToolingGroups.toSorted(
|
|
(a, b) =>
|
|
runnerRank(b) - runnerRank(a) ||
|
|
estimateBinSeconds([b]) - estimateBinSeconds([a]) ||
|
|
a.shard_name.localeCompare(b.shard_name),
|
|
);
|
|
const strongestGroupCount =
|
|
hostedGroups.findLastIndex((group) => group.runner === hostedGroups[0]!.runner) + 1;
|
|
type HostedUnit = [NodeTestShardGroup, ...NodeTestShardGroup[]];
|
|
const units = [
|
|
...hostedGroups.slice(0, strongestGroupCount).map((group) => [group]),
|
|
...anchors,
|
|
...hostedGroups.slice(strongestGroupCount).map((group) => [group]),
|
|
] as HostedUnit[];
|
|
const canShareHostedUnit = (
|
|
candidate: readonly [HostedUnit, ...HostedUnit[]],
|
|
unit: HostedUnit,
|
|
) => {
|
|
if (!isHostedToolingGroup(unit[0])) {
|
|
return false;
|
|
}
|
|
const groups = candidate.flat();
|
|
if (!canShareHostedGroup(groups, unit[0])) {
|
|
return false;
|
|
}
|
|
const combined = [...groups, ...unit];
|
|
return estimateBinSeconds(combined) <= hostedBinBudget(combined);
|
|
};
|
|
packedBins = packNodeTestGroups(units, canShareHostedUnit, true).map(
|
|
(bin) => bin.flat() as HostedUnit,
|
|
);
|
|
// Preserve successful plans; compare one alternate only after tail splitting still overflows.
|
|
if (splitHostedToolingTails && packedBins.length > effectiveJobCap(packedBins)) {
|
|
const alternateUnits = [
|
|
...anchors,
|
|
...hostedGroups
|
|
.toSorted(
|
|
(a, b) =>
|
|
estimateBinSeconds([b]) - estimateBinSeconds([a]) ||
|
|
runnerRank(b) - runnerRank(a) ||
|
|
a.shard_name.localeCompare(b.shard_name),
|
|
)
|
|
.map((group) => [group]),
|
|
] as HostedUnit[];
|
|
const alternateBins = packNodeTestGroups(alternateUnits, canShareHostedUnit, true).map(
|
|
(bin) => bin.flat() as HostedUnit,
|
|
);
|
|
if (alternateBins.length < packedBins.length) {
|
|
packedBins = alternateBins;
|
|
}
|
|
}
|
|
}
|
|
|
|
const compactJobs: CompactNodeTestShard[] = [];
|
|
const nextJobIndexByClass = new Map<string, number>();
|
|
for (const bin of packedBins) {
|
|
const [firstGroup] = bin;
|
|
const { name: runnerClass } = resolveCiNodeTestRunnerClass(firstGroup.runner);
|
|
const distSuffix = firstGroup.requiresDist ? "-dist" : "";
|
|
const jobClass = `${runnerClass}${distSuffix}`;
|
|
const jobIndex = (nextJobIndexByClass.get(jobClass) ?? 0) + 1;
|
|
nextJobIndexByClass.set(jobClass, jobIndex);
|
|
const checkName = `checks-node-compact-${jobClass}-${jobIndex}`;
|
|
const runner = firstGroup.runner;
|
|
const pretestBuildMode = mergeVitestPretestBuildModes(
|
|
bin.map((group) => group.pretestBuildMode),
|
|
);
|
|
// The runner admits overlap only after measuring capacity; exclusive and
|
|
// runtime-building jobs stay serial regardless of the requested class.
|
|
const planConcurrency =
|
|
usesBlacksmithCapacity(firstGroup.runner) &&
|
|
bin.length > 1 &&
|
|
bin.every(isParallelCompactGroup)
|
|
? 2
|
|
: 1;
|
|
// Tooling and the full CLI need host capacity while keeping serial isolation.
|
|
// Promote only the emitted runner so packing, names and timing keys stay stable.
|
|
const capacityRunner =
|
|
runner === EXTRA_LARGE_NODE_TEST_RUNNER ||
|
|
planConcurrency === 2 ||
|
|
(isBlacksmithProfile && bin.some((group) => group.configs.includes(TOOLING_CONFIG))) ||
|
|
(options.runnerBackend === "hybrid" &&
|
|
usesBlacksmithCapacity(runner) &&
|
|
bin.some((group) =>
|
|
group.includePatterns?.some((file) => TOOLING_LARGE_CAPACITY_TEST_FILES.has(file)),
|
|
)) ||
|
|
(usesBlacksmithCapacity(runner) &&
|
|
bin.some((group) => MEASURED_CLI_GROUP_RE.test(group.shard_name)))
|
|
? EXTRA_LARGE_NODE_TEST_RUNNER
|
|
: runner;
|
|
compactJobs.push({
|
|
checkName,
|
|
groups: bin,
|
|
...(pretestBuildMode ? { pretestBuildMode } : {}),
|
|
requiresDist: firstGroup.requiresDist,
|
|
runner: capacityRunner,
|
|
shardName: `compact-${jobClass}-${jobIndex}`,
|
|
// Whole-config groups run entire suites; keep their generous timeout.
|
|
...(bin.some((group) => !group.includePatterns)
|
|
? { timeoutMinutes: COMPACT_WHOLE_NODE_TEST_TIMEOUT_MINUTES }
|
|
: {}),
|
|
planConcurrency,
|
|
predictedSeconds: Math.ceil(estimateBinSeconds(bin)),
|
|
});
|
|
}
|
|
|
|
const compactJobCap = effectiveJobCap(packedBins);
|
|
if (packsHostedTooling && compactJobs.length > compactJobCap) {
|
|
if (!splitHostedToolingTails) {
|
|
// Repartition once at the file owner so timing identities and build costs
|
|
// describe the smaller tails before the same admission checks pack them.
|
|
return createCompactNodeTestShardBundles(
|
|
sourceShards,
|
|
options,
|
|
compactMode,
|
|
selectedToolingFiles,
|
|
true,
|
|
);
|
|
}
|
|
if (hostedToolingTailBudgets === undefined && hostedToolingTailDonation === undefined) {
|
|
// Repartition only stranded tails to fit capacity left by compatible owners.
|
|
// File ownership and timing identities are rebuilt before normal admission.
|
|
const tailBudgets = new Map<string, number>();
|
|
for (const group of packedBins.slice(compactJobCap).flat()) {
|
|
if (!isHostedToolingGroup(group) || (group.includePatterns?.length ?? 0) < 2) {
|
|
continue;
|
|
}
|
|
const available = Math.max(
|
|
0,
|
|
...packedBins
|
|
.filter((candidate) => canShareHostedGroup(candidate, group))
|
|
.map(
|
|
(candidate) => hostedBinBudget([...candidate, group]) - estimateBinSeconds(candidate),
|
|
),
|
|
);
|
|
if (available > 0) {
|
|
tailBudgets.set(group.shard_name.replace(/-hosted-\d+$/u, ""), available);
|
|
}
|
|
}
|
|
if (tailBudgets.size > 0) {
|
|
return createCompactNodeTestShardBundles(
|
|
sourceShards,
|
|
options,
|
|
compactMode,
|
|
selectedToolingFiles,
|
|
true,
|
|
tailBudgets,
|
|
);
|
|
}
|
|
}
|
|
if (bestTailDonation) {
|
|
// One largest safe donation opens donor capacity without changing the family selector.
|
|
return createCompactNodeTestShardBundles(
|
|
sourceShards,
|
|
options,
|
|
compactMode,
|
|
selectedToolingFiles,
|
|
true,
|
|
hostedToolingTailBudgets,
|
|
bestTailDonation,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Settle Gateway admission before runtime placement reads the recipient's policy.
|
|
for (const job of compactJobs) {
|
|
if (
|
|
job.planConcurrency !== 2 ||
|
|
!job.groups.some((group) => group.configs.some(isExclusiveCiTestConfig))
|
|
) {
|
|
continue;
|
|
}
|
|
// Keep packed jobs and their summed time budgets; Gateway boots own the host
|
|
// serially. Preserve the previous two-worker ceiling during runtime placement.
|
|
job.planConcurrency = 1;
|
|
job.env = { ...job.env, ...PINNED_COMPACT_GROUP_ENV };
|
|
}
|
|
|
|
// Only the public complete-plan entry normalizes this option. Precise plans
|
|
// retain their original template capacity before projecting selected files.
|
|
if (options.runnerBackend === "hybrid" && options.compactMode !== undefined) {
|
|
const timings = readRuntimePlacementTimings("blacksmith");
|
|
const placementJobs = compactJobs.filter(
|
|
(job) =>
|
|
job.pretestBuildMode !== "private-qa" &&
|
|
!job.requiresDist &&
|
|
runnerRank(job) >= 0 &&
|
|
job.groups.every(
|
|
(group) => group.pretestBuildMode !== "private-qa" && !isExclusiveCompactGroup(group),
|
|
),
|
|
);
|
|
const measured = (group: NodeTestShardGroup) => resolveRuntimePlacementSeconds(group, timings);
|
|
if (placementJobs.some((job) => job.groups.some((group) => measured(group) !== undefined))) {
|
|
// Observe complete existing envelopes only after splitting/packing. These
|
|
// floors cannot feed a runtime cost back into ordinary stripe generation.
|
|
const cost = (groups: NodeTestShardGroup[]) =>
|
|
VITEST_PRETEST_BUILD_SECONDS.runtime +
|
|
groups.reduce(
|
|
(total, group) =>
|
|
total +
|
|
Math.max(
|
|
estimateStripeSeconds(group),
|
|
isParallelCommandsGroup(group) || measured(group) === undefined
|
|
? estimateCompactGroupSeconds(group, "hybrid")
|
|
: Math.round(measured(group)! * COMPACT_HYBRID_GROUP_SECONDS_SCALE),
|
|
),
|
|
0,
|
|
);
|
|
const admits = (groups: NodeTestShardGroup[]) =>
|
|
admitsCompactBin(groups, COMPACT_HYBRID_RUNTIME_JOB_SECONDS, cost);
|
|
const prepareRecipient = (job: CompactNodeTestShard) => {
|
|
if (job.planConcurrency !== 2) {
|
|
return job.groups;
|
|
}
|
|
if (
|
|
job.groups.some(
|
|
(group) =>
|
|
group.env?.OPENCLAW_VITEST_MAX_WORKERS === undefined &&
|
|
!isParallelCommandsGroup(group) &&
|
|
!isRuntimePlacementIncludePatterns(group.includePatterns),
|
|
)
|
|
) {
|
|
return undefined;
|
|
}
|
|
// Preserve the executed allowance and its prepared timing identity.
|
|
// Re-keying an equivalent cap would break a complete parent generation.
|
|
return job.groups.map((group) =>
|
|
group.env?.OPENCLAW_VITEST_MAX_WORKERS !== undefined || isParallelCommandsGroup(group)
|
|
? group
|
|
: Object.assign({}, group, { env: { ...group.env, ...PINNED_COMPACT_GROUP_ENV } }),
|
|
);
|
|
};
|
|
rebalanceRuntimeTestJobs(placementJobs, { cost, admits, runnerRank, prepareRecipient });
|
|
}
|
|
}
|
|
|
|
for (const job of compactJobs) {
|
|
// Memory-gated plans need the measured 8-CPU/30.95-GiB allocation, even alone.
|
|
// Normalize the previous two-worker allowance onto their unmeasured siblings below.
|
|
if (
|
|
usesBlacksmithCapacity(job.runner) &&
|
|
job.runner !== EXTRA_LARGE_NODE_TEST_RUNNER &&
|
|
job.groups.some((group) => group.minTotalMemoryBytes !== undefined)
|
|
) {
|
|
job.runner = EXTRA_LARGE_NODE_TEST_RUNNER;
|
|
job.env = { ...job.env, ...PINNED_COMPACT_GROUP_ENV };
|
|
}
|
|
if (
|
|
job.env?.OPENCLAW_VITEST_MAX_WORKERS !== "2" ||
|
|
!job.groups.some((group) => usesMeasuredCompactWorkers(group, options.runnerBackend))
|
|
) {
|
|
continue;
|
|
}
|
|
// Finish placement before moving the job cap onto every unproven sibling,
|
|
// including donated runtime groups. Measured groups retain their own worker limits.
|
|
const { OPENCLAW_VITEST_MAX_WORKERS: _workers, ...env } = job.env;
|
|
job.env = Object.keys(env).length > 0 ? env : undefined;
|
|
job.groups = job.groups.map((group) =>
|
|
usesMeasuredCompactWorkers(group, options.runnerBackend)
|
|
? group
|
|
: Object.assign({}, group, {
|
|
env: {
|
|
...group.env,
|
|
OPENCLAW_VITEST_MAX_WORKERS: String(
|
|
Math.min(2, Number(group.env?.OPENCLAW_VITEST_MAX_WORKERS ?? 2)),
|
|
),
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
// Larger initial bins change which groups reach serial Gateway/runtime rows.
|
|
// Freeze those settled rows; only already-overlapping jobs can share more work.
|
|
const parallelJobs: CompactNodeTestShard[] = [];
|
|
for (const job of compactJobs) {
|
|
if (
|
|
job.planConcurrency !== 2 ||
|
|
job.runner !== EXTRA_LARGE_NODE_TEST_RUNNER ||
|
|
!usesBlacksmithCapacity(job.runner) ||
|
|
job.pretestBuildMode ||
|
|
job.requiresDist ||
|
|
!job.groups.every(isParallelCompactGroup) ||
|
|
job.groups.some((group) => group.configs.some(isExclusiveCiTestConfig))
|
|
) {
|
|
continue;
|
|
}
|
|
parallelJobs.push(job);
|
|
}
|
|
const retiredJobs = new Set<CompactNodeTestShard>();
|
|
if (parallelJobs.length > 1) {
|
|
const groups = parallelJobs
|
|
.flatMap((job) => job.groups)
|
|
.toSorted(
|
|
(a, b) =>
|
|
estimateStripeSeconds(b) - estimateStripeSeconds(a) ||
|
|
a.shard_name.localeCompare(b.shard_name),
|
|
);
|
|
const bins = packNodeTestGroups(
|
|
groups,
|
|
(candidate, group) =>
|
|
admitsCompactBin(
|
|
[...candidate, group],
|
|
COMPACT_FINAL_PARALLEL_NODE_TEST_JOB_SECONDS,
|
|
estimateBinSeconds,
|
|
{ parallel: true },
|
|
),
|
|
options.runnerBackend === "hybrid",
|
|
);
|
|
if (bins.length < parallelJobs.length) {
|
|
parallelJobs.forEach((job, index) => {
|
|
const bin = bins[index];
|
|
if (!bin) {
|
|
retiredJobs.add(job);
|
|
return;
|
|
}
|
|
job.groups = bin;
|
|
job.predictedSeconds = Math.ceil(estimateBinSeconds(bin));
|
|
job.planConcurrency = bin.length > 1 ? 2 : 1;
|
|
job.timeoutMinutes = bin.some((group) => !group.includePatterns)
|
|
? COMPACT_WHOLE_NODE_TEST_TIMEOUT_MINUTES
|
|
: undefined;
|
|
if (bin.length === 1) {
|
|
// Losing a sibling must not increase this child's previous worker allowance.
|
|
job.env = { ...job.env, ...PINNED_COMPACT_GROUP_ENV };
|
|
}
|
|
});
|
|
}
|
|
}
|
|
const finalJobs = compactJobs.filter((job) => !retiredJobs.has(job));
|
|
for (const job of finalJobs) {
|
|
// The 4/8 classes both deliver two CPUs. Routing must not alter placement anchors.
|
|
if (usesBlacksmithCapacity(job.runner) && job.runner === BUNDLED_NODE_TEST_RUNNER) {
|
|
job.runner = DEFAULT_NODE_TEST_RUNNER;
|
|
}
|
|
}
|
|
|
|
// Split/packing admission retains the two-worker retry budget. Once placement
|
|
// settles, price commands at the allocation that the executor can actually use.
|
|
// The observed 32-class supplies 8 CPUs / 30.95 GiB; smaller classes and
|
|
// overlapping plans take H2's two-worker fallback. Live pressure can reduce it.
|
|
for (const job of finalJobs) {
|
|
const workers =
|
|
job.runner === EXTRA_LARGE_NODE_TEST_RUNNER &&
|
|
job.planConcurrency === 1 &&
|
|
job.env?.OPENCLAW_VITEST_MAX_WORKERS === undefined &&
|
|
options.runnerBackend !== "github"
|
|
? 8
|
|
: 2;
|
|
let savedSeconds = 0;
|
|
job.groups = job.groups.map((group) => {
|
|
if (!isParallelCommandsGroup(group)) {
|
|
return group;
|
|
}
|
|
const previousSeconds = estimateStripeSeconds(group);
|
|
const adjusted = estimateCommandWorkerSeconds(
|
|
group,
|
|
previousSeconds,
|
|
Math.min(
|
|
workers,
|
|
readPositiveEnvInt("OPENCLAW_VITEST_MAX_WORKERS", group.env ?? {}, workers),
|
|
),
|
|
options.runnerBackend,
|
|
);
|
|
savedSeconds += previousSeconds - adjusted.seconds;
|
|
const prepared = { ...group, timing_key: adjusted.timingKey };
|
|
stripeFacts.set(prepared, { ...prepareStripe(group), seconds: adjusted.seconds });
|
|
return prepared;
|
|
});
|
|
job.predictedSeconds = Math.ceil(job.predictedSeconds! - savedSeconds);
|
|
}
|
|
|
|
const hostedHourly = options.runnerBackend === "github" && compactMode === "push";
|
|
const toolingFileTimings =
|
|
options.runnerBackend === "hybrid" || hostedHourly
|
|
? readToolingFileTimings("blacksmith")
|
|
: undefined;
|
|
const measuredJobs =
|
|
(options.runnerBackend === "hybrid" || hostedHourly) && options.compactMode !== undefined
|
|
? (hostedHourly
|
|
? [DEFAULT_NODE_TEST_RUNNER, BUNDLED_NODE_TEST_RUNNER]
|
|
: [DEFAULT_NODE_TEST_RUNNER]
|
|
).reduce(
|
|
(jobs, runner) =>
|
|
rebalanceMeasuredSerialJobs(jobs, {
|
|
runner,
|
|
profile: hostedHourly ? "hosted-hourly" : "native",
|
|
estimateGroup: (group) => ({
|
|
seconds: estimateParallelToolingSeconds(
|
|
group,
|
|
group.includePatterns ?? [],
|
|
hostedHourly ? "github" : "blacksmith",
|
|
toolingFileTimings,
|
|
),
|
|
complete: Boolean(
|
|
group.includePatterns?.every((file) => toolingFileTimings?.[file] !== undefined),
|
|
),
|
|
}),
|
|
...(hostedHourly
|
|
? {}
|
|
: {
|
|
testSecondsLimit: (groups: NodeTestShardGroup[]) =>
|
|
compactToolingTestSecondsLimit(groups, options.runnerBackend),
|
|
}),
|
|
canShare: (groups) =>
|
|
groups.length <= COMPACT_NODE_TEST_JOB_GROUPS && hasDistinctStripeFamilies(groups),
|
|
}),
|
|
finalJobs,
|
|
)
|
|
: finalJobs;
|
|
if (measuredJobs.length > compactJobCap) {
|
|
if (options.runnerBackend === "hybrid" && !prioritizeSerialGateway) {
|
|
// Rebuild from source so the alternate order passes every admission and
|
|
// runtime-placement check before any rows are published.
|
|
return createCompactNodeTestShardBundles(
|
|
sourceShards,
|
|
options,
|
|
compactMode,
|
|
selectedToolingFiles,
|
|
splitHostedToolingTails,
|
|
hostedToolingTailBudgets,
|
|
hostedToolingTailDonation,
|
|
true,
|
|
);
|
|
}
|
|
throw new Error(
|
|
`compact ${options.runnerBackend ?? "blacksmith"} node test plan exceeds ${compactJobCap} jobs (${measuredJobs.length} planned)`,
|
|
);
|
|
}
|
|
for (const job of measuredJobs) {
|
|
if (job.planConcurrency !== 2) {
|
|
job.predictedTestSeconds = Math.max(
|
|
0,
|
|
(job.predictedSeconds ?? 0) -
|
|
compactPreparationSeconds(job.pretestBuildMode, options.runnerBackend),
|
|
);
|
|
continue;
|
|
}
|
|
job.predictedTestSeconds = estimateParallelTestSeconds(job.groups, estimateStripeSeconds);
|
|
}
|
|
if (options.groupSeconds) {
|
|
for (const job of measuredJobs) {
|
|
const seconds = job.groups.map(estimateStripeSeconds);
|
|
const total = seconds.reduce((sum, value) => sum + value, 0);
|
|
const testSeconds = Math.max(
|
|
0,
|
|
job.predictedSeconds! -
|
|
compactPreparationSeconds(job.pretestBuildMode, options.runnerBackend),
|
|
);
|
|
job.groups.forEach((group, index) =>
|
|
options.groupSeconds!.set(
|
|
group,
|
|
total > 0 ? (testSeconds * seconds[index]!) / total : testSeconds / job.groups.length,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
return measuredJobs.toSorted((a, b) => a.checkName.localeCompare(b.checkName));
|
|
}
|