openclaw/scripts/ci-run-node-test-shard.mts
Peter Steinberger 9137cfcc5f
perf(ci): run qualified unit tests with native Bun (#159988)
* perf(ci): run qualified unit tests with native Bun

* test(ci): keep a V8-only cache warm fixture

* fix(ci): include shared timeout in PR wrapper inventory

* perf(test): skip Bun bytecode cache during test teardown

* test: handle worker fixture retirement notifications

Handle rejected observer returns without delaying retirement, matching the
worker pool contract, and avoid shadowing the lane fixture input helper.

* perf(ci): qualify and tune Bun test workers

Complete SQLite admission before Bun Vitest threads start and retain each
worker generation's canonical cleanup decision in lifecycle fixtures.
Keep proxy-agent fixture values out of Bun's native fetch cache and compare
large socket payloads with full byte equality.

Renew the 292-file native qualification from paired 2,518-case proof.
Apply the existing UI JIT and allocator settings to ordinary unit-fast
Bun/Vitest selections and their cache warmer, preserving Node and native
Bun settings and dual-runtime release coverage.

Proof: 208 routing/warming cases; 61 lifecycle cases on each runtime;
66 ordered proxy/fetch cases plus the canonical 11-case proxy owner on each
runtime; independent P0-P2 review. The changed-file gate was interrupted
by the local disk reserve guard after core typing and 14 core-test type
shards passed. Tuned full-cohort performance remains to be measured;
the completed untuned comparison was 4.60% slower than Node.

* perf(test): expand native Bun coverage and reduce artifact IO

* perf(test): yield between artifact verification batches

* test: qualify native Bun cases after runtime integration

* test: refresh native Bun qualification after main integration
2026-10-01 17:28:11 -07:00

1003 lines
37 KiB
TypeScript

// Runs one CI node test shard job: either explicit changed-test targets or a
// list of packed group plans. Extracted from .github/workflows/ci.yml so the
// execution policy is unit-testable and plans can run concurrently.
import { spawn, type ChildProcess } from "node:child_process";
import { randomUUID } from "node:crypto";
import {
constants,
cpSync,
existsSync,
lstatSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
statSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { rm } from "node:fs/promises";
import os, { tmpdir } from "node:os";
import { join } from "node:path";
import type { Readable } from "node:stream";
import { StringDecoder } from "node:string_decoder";
import { fileURLToPath } from "node:url";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { decodeNodeTestGroups } from "./lib/ci-node-test-groups-codec.mts";
import type { CiTestRuntimeSelection } from "./lib/ci-test-runtime.mts";
import { isDirectRunUrl } from "./lib/direct-run.mjs";
import { isConstrainedCiCheckHost, isExclusiveCiTestConfig } from "./lib/local-check-runtime.mts";
import { parsePositiveInt, readPositiveEnvInt } from "./lib/numeric-options.mjs";
import { isCiLikeEnv } from "./lib/vitest-local-scheduling.mts";
import {
MAX_CI_VITEST_PLAN_CONCURRENCY,
resolveCiVitestPlanConcurrency,
runVitestPlans,
} from "./lib/vitest-plan-scheduling.mts";
import type { VitestWorkerRun } from "./lib/vitest-worker-run.mts";
// CI admits at most two plans only when the actual host has room. Each plan
// normally keeps inner parallelism 1; qualified singleton envelopes have a separate admission.
const FS_MODULE_CACHE_ROOT_ENV_KEY = "OPENCLAW_VITEST_FS_MODULE_CACHE_ROOT";
const FS_MODULE_CACHE_PATH_ENV_KEY = "OPENCLAW_VITEST_FS_MODULE_CACHE_PATH";
const FS_MODULE_CACHE_WRITER_ENV_KEY = "OPENCLAW_VITEST_FS_MODULE_CACHE_WRITER";
const NODE_COMPILE_CACHE_PATH_ENV_KEY = "NODE_COMPILE_CACHE";
const NODE_COMPILE_CACHE_WRITER_ENV_KEY = "OPENCLAW_NODE_COMPILE_CACHE_WRITER";
const VITEST_EXTRA_ARGS_ENV_KEY = "OPENCLAW_NODE_TEST_VITEST_ARGS_JSON";
const FS_MODULE_CACHE_MAX_BYTES = 2 * 1024 * 1024 * 1024;
const NODE_COMPILE_CACHE_MAX_BYTES = 1024 * 1024 * 1024;
const FS_MODULE_CACHE_PRUNE_TARGET_RATIO = 0.75;
const FS_MODULE_CACHE_METADATA_FILE = "_metadata.json";
const FS_MODULE_CACHE_GENERATION_FILE = ".openclaw-transform-generation";
function reportCiResourceSnapshot(phase: "start" | "end") {
const pressure = (resource: "cpu" | "memory" | "io") => {
if (process.platform !== "linux") {
return null;
}
try {
return readFileSync(`/proc/pressure/${resource}`, "utf8").trim();
} catch {
return null;
}
};
console.log(
`[shard:resource-snapshot] ${JSON.stringify({
phase,
uptimeSeconds: os.uptime(),
cpuModel: os.cpus()[0]?.model ?? null,
loadAverage: os.loadavg(),
freeMemoryBytes: os.freemem(),
availableMemoryBytes: process.availableMemory?.() ?? null,
constrainedMemoryBytes: process.constrainedMemory?.() ?? null,
pressure: { cpu: pressure("cpu"), memory: pressure("memory"), io: pressure("io") },
})}`,
);
}
export type ShardTargetPlan = { kind: "target"; name: string; target: string };
type ShardGroupConfig = {
configs: string[];
requiresDist?: boolean;
pretestBuildMode?: "runtime" | "private-qa";
fallbackMaxWorkers?: number;
minTotalMemoryBytes?: number;
env?: Record<string, unknown> | null;
includePatterns?: string[] | null;
shard_name?: string;
timing_key?: string;
};
export type ShardGroupPlan = {
kind: "group";
name: string;
plan: ShardGroupConfig;
timingKey?: string;
};
export type ShardPlan = ShardTargetPlan | ShardGroupPlan;
type RunShardOptions = {
concurrency?: number;
continueOnFailure?: boolean;
env?: NodeJS.ProcessEnv;
fsModuleCacheMaxBytes?: number;
nodeCompileCacheMaxBytes?: number;
runChild?: typeof runChild;
scratchDir?: string;
};
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((entry) => typeof entry === "string");
}
function isShardGroupConfig(value: unknown): value is ShardGroupConfig {
return isRecord(value) && isStringArray(value.configs);
}
function parseJsonEnv(
env: Record<string, unknown>,
name: string,
fallback: unknown = null,
): unknown {
try {
const value = env[name];
return typeof value === "string" ? (JSON.parse(value) ?? fallback) : fallback;
} catch {
return fallback;
}
}
export function resolveShardPlans(env: NodeJS.ProcessEnv = process.env): ShardPlan[] {
const targets = parseJsonEnv(env, "OPENCLAW_NODE_TEST_TARGETS_JSON");
if (isStringArray(targets) && targets.length > 0) {
// One target per child process preserves the isolation boundaries encoded
// by full-suite include-pattern shards while keeping one runner job.
return targets.map((target) => ({ kind: "target", name: target, target }));
}
// The CI manifest packs matrix groups so preflight's job outputs stay under
// GitHub's 1 MiB UTF-16 cap. Plain JSON remains for the Vitest cache warmer,
// which writes its small group list straight into GITHUB_ENV.
const packedGroups = env.OPENCLAW_NODE_TEST_GROUPS_GZIP_BASE64?.trim();
const groups = packedGroups
? decodeNodeTestGroups(packedGroups)
: parseJsonEnv(env, "OPENCLAW_NODE_TEST_GROUPS_JSON");
const groupPlans = Array.isArray(groups) ? groups.filter(isShardGroupConfig) : [];
const configs = parseJsonEnv(env, "OPENCLAW_NODE_TEST_CONFIGS_JSON", []);
const groupEnv = parseJsonEnv(env, "OPENCLAW_NODE_TEST_ENV_JSON");
const includePatterns = parseJsonEnv(env, "OPENCLAW_NODE_TEST_INCLUDE_PATTERNS_JSON");
const plans: ShardGroupConfig[] =
groupPlans.length > 0
? groupPlans
: [
{
configs: isStringArray(configs) ? configs : [],
env: isRecord(groupEnv) ? groupEnv : null,
includePatterns: isStringArray(includePatterns) ? includePatterns : null,
shard_name: env.OPENCLAW_VITEST_SHARD_NAME,
},
];
return plans.map((plan) => {
const name = plan.shard_name ?? plan.configs?.[0] ?? "group";
return { kind: "group", name, plan, timingKey: plan.timing_key ?? name };
});
}
function mergePlanEnv(baseEnv: NodeJS.ProcessEnv, overrides: unknown): NodeJS.ProcessEnv {
const childEnv = { ...baseEnv };
if (isRecord(overrides)) {
for (const [key, value] of Object.entries(overrides)) {
if (typeof value === "string") {
const inherited = baseEnv[key]?.trim();
// Pins may lower the admitted job budget, never raise it. Compiler
// preparation and test children must inherit the same intersection.
childEnv[key] =
key === "OPENCLAW_VITEST_MAX_WORKERS" && inherited
? String(
Math.min(
parsePositiveInt(inherited, key),
parsePositiveInt(value.trim() || inherited, key),
),
)
: value;
}
}
}
return childEnv;
}
function prepareChildEnv(entry: ShardPlan, baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
return mergePlanEnv(
{
...baseEnv,
OPENCLAW_TEST_PROJECTS_PARALLEL: "1",
...(entry.kind === "group" && entry.plan.shard_name
? { OPENCLAW_VITEST_SHARD_NAME: entry.plan.shard_name }
: {}),
},
entry.kind === "group" ? entry.plan.env : undefined,
);
}
export function buildChildEnv(
entry: ShardPlan,
baseEnv: NodeJS.ProcessEnv,
scratchDir: string,
index: number,
options: { serial?: boolean; cacheSlot?: number; runtime?: "node" | "bun" } = {},
) {
const configuredCacheRoot = baseEnv[FS_MODULE_CACHE_ROOT_ENV_KEY]?.trim();
const persistentCacheRoot = configuredCacheRoot || baseEnv[FS_MODULE_CACHE_PATH_ENV_KEY]?.trim();
const cachePrefix = options.runtime === "bun" ? "vitest-cache-bun" : "vitest-cache";
const cacheDirectory = persistentCacheRoot
? `${cachePrefix}-${options.cacheSlot ?? index}`
: options.serial
? `${cachePrefix}-shared`
: `${cachePrefix}-${index}`;
// Persistent worker slots let serial plans reuse transforms without concurrent
// writers. Scratch caches stay per-plan; group overrides still apply last.
const cacheEnv: NodeJS.ProcessEnv = {
...baseEnv,
[FS_MODULE_CACHE_ROOT_ENV_KEY]: join(persistentCacheRoot || scratchDir, cacheDirectory),
};
// Legacy shard callers supplied the archive root through PATH. With ROOT,
// PATH instead belongs to a caller that explicitly selected a final leaf.
if (!configuredCacheRoot) {
delete cacheEnv[FS_MODULE_CACHE_PATH_ENV_KEY];
}
const childEnv = prepareChildEnv(entry, cacheEnv);
if (options.runtime) {
childEnv.OPENCLAW_VITEST_RUNTIME = options.runtime;
}
if (entry.kind === "group") {
const plan = entry.plan;
if (Array.isArray(plan.includePatterns) && plan.includePatterns.length > 0) {
const includeFile = join(scratchDir, `node-test-include-${index}.json`);
writeFileSync(includeFile, JSON.stringify(plan.includePatterns), "utf8");
childEnv.OPENCLAW_VITEST_INCLUDE_FILE = includeFile;
} else {
delete childEnv.OPENCLAW_VITEST_INCLUDE_FILE;
}
}
return childEnv;
}
export function pruneFsModuleCache(root: string, maxBytes = FS_MODULE_CACHE_MAX_BYTES) {
if (!root || !existsSync(root) || !Number.isFinite(maxBytes) || maxBytes < 0) {
return { beforeBytes: 0, afterBytes: 0, removedFiles: 0 };
}
const files: Array<{ filePath: string; mtimeMs: number; size: number }> = [];
let totalBytes = 0;
const visit = (directory: string) => {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const filePath = join(directory, entry.name);
if (entry.isDirectory()) {
visit(filePath);
continue;
}
if (!entry.isFile()) {
continue;
}
const fileStat = statSync(filePath);
totalBytes += fileStat.size;
if (
entry.name !== FS_MODULE_CACHE_METADATA_FILE &&
entry.name !== FS_MODULE_CACHE_GENERATION_FILE
) {
files.push({ filePath, mtimeMs: fileStat.mtimeMs, size: fileStat.size });
}
}
};
visit(root);
const beforeBytes = totalBytes;
if (totalBytes <= maxBytes) {
return { beforeBytes, afterBytes: totalBytes, removedFiles: 0 };
}
const targetBytes = Math.floor(maxBytes * FS_MODULE_CACHE_PRUNE_TARGET_RATIO);
files.sort((left, right) => left.mtimeMs - right.mtimeMs);
let removedFiles = 0;
for (const file of files) {
if (totalBytes <= targetBytes) {
break;
}
unlinkSync(file.filePath);
totalBytes -= file.size;
removedFiles += 1;
}
return { beforeBytes, afterBytes: totalBytes, removedFiles };
}
export function clonePersistentCacheSlots(root: string | undefined, concurrency: number) {
if (!root || concurrency <= 1) {
return 0;
}
let clonedSlots = 0;
for (const prefix of ["vitest-cache", "vitest-cache-bun"]) {
const seed = join(root, `${prefix}-0`);
if (!existsSync(seed)) {
continue;
}
for (let cacheSlot = 1; cacheSlot < concurrency; cacheSlot += 1) {
const destination = join(root, `${prefix}-${cacheSlot}`);
rmSync(destination, { force: true, recursive: true });
// Clone before workers start. Reflinks make the common Linux path cheap;
// unsupported filesystems transparently fall back to a regular copy.
cpSync(seed, destination, {
mode: constants.COPYFILE_FICLONE,
recursive: true,
});
clonedSlots += 1;
}
}
return clonedSlots;
}
const MAX_PENDING_LINE_CHARS = 1_000_000;
function relayChildStream(stream: Readable, label: string) {
const decoder = new StringDecoder("utf8");
let pending = "";
const writeLine = (line: string) => {
// Actions only parses workflow commands at the start of a line.
const workflowCommand = /^::(?:error|warning|notice|group|endgroup)(?: .*?)?::/u.test(line);
const output = workflowCommand ? line : `[shard:${label}] ${line}`;
if (!process.stdout.write(`${output}\n`)) {
stream.pause();
process.stdout.once("drain", () => stream.resume());
}
};
stream.on("data", (chunk: Buffer | string) => {
pending += decoder.write(chunk);
const lines = pending.split("\n");
pending = lines.pop() ?? "";
for (const line of lines) {
writeLine(line);
}
if (pending.length > MAX_PENDING_LINE_CHARS) {
writeLine(pending);
pending = "";
}
});
return () => {
pending += decoder.end();
if (pending !== "") {
writeLine(pending);
pending = "";
}
};
}
const TEST_PROJECTS_ENTRYPOINTS = ["scripts/test-projects.mts", "scripts/test-projects.mjs"];
export function resolveTestProjectsEntrypoint(
fileExists: (path: string) => boolean = existsSync,
): string {
const entrypoint = TEST_PROJECTS_ENTRYPOINTS.find((candidate) => fileExists(candidate));
if (!entrypoint) {
throw new Error("CI target does not provide scripts/test-projects.mts or .mjs");
}
return entrypoint;
}
export function resolveShardChildCommand(
args: string[],
nodeExecPath = process.execPath,
testProjectsEntrypoint = resolveTestProjectsEntrypoint(),
workerRun?: VitestWorkerRun,
) {
if (args[0] === "--native-bun") {
return {
command: nodeExecPath,
args: [
"--import",
"tsx",
fileURLToPath(new URL("./run-vitest.mts", import.meta.url)),
...args,
],
};
}
const loaderArgs = testProjectsEntrypoint.endsWith(".mts") ? ["--import", "tsx"] : [];
return {
command: nodeExecPath,
args: [
...loaderArgs,
...(workerRun
? [
fileURLToPath(new URL("./lib/vitest-worker-bootstrap.mts", import.meta.url)),
workerRun.descriptor.directory,
]
: []),
testProjectsEntrypoint,
...args,
],
};
}
async function loadWorkerOwner() {
const ownedRunner = join(process.cwd(), "scripts/ci-run-node-test-shard.mts");
// ci.yml's frozen-release adapter must execute the old target,
// never load a modern compiler from its workflow-owned .ci-workflow checkout.
if (
fileURLToPath(import.meta.url) ===
join(process.cwd(), ".ci-workflow/scripts/ci-run-node-test-shard.mts") &&
!existsSync(ownedRunner)
) {
return undefined;
}
if (fileURLToPath(import.meta.url) !== ownedRunner) {
throw new Error("Compiled CI worker ownership requires the target's own shard runner");
}
const groupOwner = await import("./vitest-process-group.mts");
// Windows' portable entry keeps per-group owners: a close-only receipt cannot
// authorize deleting a generation shared with another group's descendants.
if (!groupOwner.shouldUseDetachedVitestProcessGroup()) {
return undefined;
}
const { resolveSharedVitestCompilerEnv } = await import("./lib/vitest-process-env.mts");
const [worker, processOwner] = await Promise.all([
import("./lib/vitest-worker-run.mts"),
import("./lib/vitest-process.mts"),
]);
return {
createWorkerRun: worker.createVitestWorkerRun,
resolveCompilerEnv: resolveSharedVitestCompilerEnv,
spawn: processOwner.spawnOwnedVitestProcess,
exitBySignal: processOwner.exitVitestBySignal,
installCleanup: groupOwner.installVitestProcessGroupCleanup,
};
}
function createWorkerContext(
owner: NonNullable<Awaited<ReturnType<typeof loadWorkerOwner>>>,
env: NodeJS.ProcessEnv,
plans: ShardPlan[],
) {
const compilerEnv = owner.resolveCompilerEnv(
plans.length > 0 ? plans.map((plan) => prepareChildEnv(plan, env)) : [env],
);
return {
workerRun: owner.createWorkerRun(compilerEnv),
normalCompletion: true,
spawn: owner.spawn,
exitBySignal: owner.exitBySignal,
installCleanup: owner.installCleanup,
};
}
async function runChild(
args: string[],
childEnv: NodeJS.ProcessEnv,
label: string,
timingKey: string,
context?: Awaited<ReturnType<typeof createWorkerContext>>,
) {
const spawnEnv =
args[0] === "--native-bun" && context
? { ...childEnv, OPENCLAW_NATIVE_BUN_PARENT_IPC: "1" }
: childEnv;
// Use Node directly. `pnpm exec node` may reconcile the workspace before
// tests, which destroys the sticky dependency fast path.
const childCommand = resolveShardChildCommand(
args,
process.execPath,
undefined,
context?.workerRun,
);
let child: ChildProcess;
let completion: Promise<number>;
let teardown: (() => void) | undefined;
if (context) {
const owned = context.spawn({
command: childCommand.command,
args: childCommand.args,
options: {
env: spawnEnv,
stdio: ["ignore", "pipe", "pipe", "ipc"],
},
homeMode: "tooling",
});
child = owned.child;
teardown = context.installCleanup({
child,
forceSignal: "SIGKILL",
forceSignalDelayMs: 100,
}).teardown;
// The containing TMP owner preserves nested claims if a group parent dies.
completion = context.workerRun.borrow(
child,
owned.completion.then((result) => {
if (!result.groupJoined) {
throw new Error("CI group descendant completion is unverified");
}
context.normalCompletion &&= typeof result.code === "number" && result.signal === null;
return result.code ?? 1;
}),
);
} else {
child = spawn(childCommand.command, childCommand.args, {
env: spawnEnv,
stdio: ["ignore", "pipe", "pipe"],
});
completion = new Promise<number>((resolve) => {
child.once("close", (code) => resolve(code ?? 1));
child.once("error", (error) => {
process.stdout.write(`[shard:${label}] failed to spawn: ${error}\n`);
resolve(1);
});
});
}
// Stream with a per-line label instead of buffering: children can run
// whole suites for hours and verbose output must not accumulate on the
// wrapper heap. Backpressure pauses the child stream while stdout drains,
// and an oversized newline-free tail is force-flushed so the pending
// partial line stays bounded too.
const flushers = [child.stdout!, child.stderr!].map((stream) => relayChildStream(stream, label));
process.stdout.write(`[shard:${timingKey}] begin\n`);
let code: number;
try {
code = await completion;
} finally {
for (const flush of flushers) {
flush();
}
teardown?.();
}
process.stdout.write(`[shard:${timingKey}] end (exit ${code})\n`);
return code;
}
function readUiNativeShardReceipt(
file: string,
requestId: string,
expectedFiles: ReadonlySet<string>,
): Set<string> | undefined {
try {
const stat = lstatSync(file);
if (!stat.isFile() || stat.size > 1024 * 1024) {
return undefined;
}
const receipt: unknown = JSON.parse(readFileSync(file, "utf8"));
if (
!isRecord(receipt) ||
receipt.version !== 1 ||
receipt.requestId !== requestId ||
receipt.config !== join(process.cwd(), "ui/vitest.config.ts").replaceAll("\\", "/") ||
receipt.root !== join(process.cwd(), "ui").replaceAll("\\", "/") ||
!isStringArray(receipt.files) ||
receipt.files.length === 0 ||
receipt.files.some((candidateFile) => !expectedFiles.has(candidateFile))
) {
return undefined;
}
return new Set(receipt.files);
} catch {
return undefined;
}
}
export async function runShardPlans(plans: ShardPlan[], options: RunShardOptions = {}) {
const inheritedEnv = options.env ?? process.env;
const jobEnv = mergePlanEnv({}, parseJsonEnv(inheritedEnv, "OPENCLAW_NODE_TEST_ENV_JSON"));
const baseEnv = mergePlanEnv(inheritedEnv, jobEnv);
if (
baseEnv.OPENCLAW_E2E_USE_PREBUILT_DIST === "1" &&
fileURLToPath(import.meta.url) === join(process.cwd(), "scripts/ci-run-node-test-shard.mts")
) {
const { preparePrebuiltAiPackage } = await import("./lib/vitest-build-prerequisites.mts");
for (const entry of plans) {
const selection = entry.kind === "target" ? { includePatterns: [entry.target] } : entry.plan;
const code = await preparePrebuiltAiPackage([selection], prepareChildEnv(entry, baseEnv));
if (code !== 0) {
return code;
}
}
}
// Historical targets use a workflow-owned adapter. Their Node
// contract must not import current target discovery or runtime policy code.
const runtimePolicy = baseEnv.OPENCLAW_CI_TEST_RUNTIME_POLICY?.trim() || "node";
const runtimeOwner =
runtimePolicy === "node" ? undefined : await import("./lib/ci-test-runtime.mts");
const policy = runtimeOwner?.resolveCiTestRuntimePolicy(baseEnv) ?? "node";
// Respect serial timing-sensitive bins and never clone cache slots that
// cannot receive a plan.
const requestedConcurrency =
options.concurrency === undefined
? readPositiveEnvInt(
"OPENCLAW_NODE_TEST_PLAN_CONCURRENCY",
baseEnv,
MAX_CI_VITEST_PLAN_CONCURRENCY,
)
: parsePositiveInt(options.concurrency, "Shard plan concurrency");
const ci = isCiLikeEnv(baseEnv);
const hostResources = ci
? { logicalCpuCount: os.availableParallelism(), totalMemoryBytes: os.totalmem() }
: null;
const exclusive = (entry: ShardPlan) =>
entry.kind === "group" && entry.plan.configs.some(isExclusiveCiTestConfig);
let concurrency = hostResources
? resolveCiVitestPlanConcurrency(plans.length, hostResources, requestedConcurrency)
: Math.min(plans.length, requestedConcurrency);
const measuredHost =
hostResources !== null &&
!isConstrainedCiCheckHost(hostResources) &&
baseEnv.RUNNER_ENVIRONMENT === "self-hosted" &&
baseEnv.FROZEN_TARGET !== "true"
? hostResources
: null;
const workerOwner = await loadWorkerOwner();
// A portable close receipt cannot release a shared lane, and a caller's
// final cache leaf cannot be split into scheduler-owned writer slots.
const callerCacheLeaf =
Boolean(
baseEnv[FS_MODULE_CACHE_ROOT_ENV_KEY]?.trim() &&
baseEnv[FS_MODULE_CACHE_PATH_ENV_KEY]?.trim(),
) ||
plans.some(
(entry) =>
entry.kind === "group" &&
typeof entry.plan.env?.[FS_MODULE_CACHE_PATH_ENV_KEY] === "string" &&
entry.plan.env[FS_MODULE_CACHE_PATH_ENV_KEY].trim(),
);
if ((!workerOwner && plans.some(exclusive)) || callerCacheLeaf) {
concurrency = Math.min(plans.length, 1);
}
// Final plan admission owns both compiler and child worker budgets.
let admittedPlans = plans.map((entry): ShardPlan => {
if (entry.kind !== "group" || entry.plan.fallbackMaxWorkers === undefined) {
return entry;
}
const fallback = parsePositiveInt(entry.plan.fallbackMaxWorkers, "Fallback worker limit");
const minTotalMemoryBytes =
entry.plan.minTotalMemoryBytes === undefined
? 0
: parsePositiveInt(entry.plan.minTotalMemoryBytes, "Worker memory floor");
if (
measuredHost &&
(concurrency === 1 || exclusive(entry)) &&
measuredHost.totalMemoryBytes >= minTotalMemoryBytes
) {
return entry;
}
return {
...entry,
plan: {
...entry.plan,
env: mergePlanEnv(mergePlanEnv({}, entry.plan.env), {
OPENCLAW_VITEST_MAX_WORKERS: String(fallback),
}),
},
};
});
const preparedRuntimeSelections = new Map<ShardPlan, CiTestRuntimeSelection[]>();
const overlapRequests = admittedPlans.filter(
(entry): entry is ShardGroupPlan =>
entry.kind === "group" &&
entry.name.startsWith("changed-extensions-config") &&
entry.plan.env?.OPENCLAW_TEST_PROJECTS_PARALLEL === "2",
);
if (overlapRequests.length > 0) {
const constrained = process.constrainedMemory?.() ?? 0;
const memory = hostResources
? Math.min(
hostResources.totalMemoryBytes,
Number.isFinite(constrained) && constrained > 0
? constrained
: hostResources.totalMemoryBytes,
)
: 0;
const hostAdmitted =
ci &&
process.platform === "linux" &&
baseEnv.RUNNER_ENVIRONMENT === "self-hosted" &&
baseEnv.FROZEN_TARGET === "false" &&
hostResources !== null &&
Number.isFinite(hostResources.logicalCpuCount) &&
hostResources.logicalCpuCount >= 2 &&
Number.isFinite(memory) &&
memory >= 7.5 * 1024 ** 3 &&
concurrency === 1 &&
Boolean(workerOwner) &&
!callerCacheLeaf;
const canOverlap = hostAdmitted
? (await import("./lib/extension-test-plan.mts")).canOverlapTelegramSingletonProcesses
: undefined;
admittedPlans = admittedPlans.map((entry) => {
if (entry.kind !== "group" || !overlapRequests.includes(entry)) {
return entry;
}
const env = prepareChildEnv(entry, baseEnv);
const shapeAdmitted =
canOverlap !== undefined &&
entry.plan.configs.length === 1 &&
!entry.plan.requiresDist &&
entry.plan.pretestBuildMode === undefined &&
env.OPENCLAW_VITEST_MAX_WORKERS === "2" &&
[inheritedEnv[VITEST_EXTRA_ARGS_ENV_KEY], env[VITEST_EXTRA_ARGS_ENV_KEY]].every(
(args) => !args?.trim() || args.trim() === "[]",
) &&
canOverlap(entry.plan.configs[0]!, entry.plan.includePatterns);
const selections: CiTestRuntimeSelection[] = shapeAdmitted
? (runtimeOwner?.resolveCiTestRuntimeSelections(
{ ...entry.plan, env, vitestArgs: [] },
policy,
) ?? [{ runtime: "node" }])
: [];
const [selected] = selections;
const admitted =
selections.length === 1 &&
selected?.runtime === "node" &&
selected.configs === undefined &&
selected.includePatterns === undefined &&
selected.includeAfterShard === undefined &&
selected.env === undefined;
const prepared: ShardGroupPlan = {
...entry,
plan: {
...entry.plan,
env: { ...entry.plan.env, OPENCLAW_TEST_PROJECTS_PARALLEL: admitted ? "2" : "1" },
},
};
if (admitted) {
preparedRuntimeSelections.set(prepared, selections);
}
return prepared;
});
}
const scratchDir = options.scratchDir ?? mkdtempSync(join(tmpdir(), "openclaw-node-shard-"));
const persistentCacheRoot =
baseEnv[FS_MODULE_CACHE_ROOT_ENV_KEY]?.trim() || baseEnv[FS_MODULE_CACHE_PATH_ENV_KEY]?.trim();
const nodeCompileCacheRoot = baseEnv[NODE_COMPILE_CACHE_PATH_ENV_KEY]?.trim();
let context: ReturnType<typeof createWorkerContext> | undefined;
let unverifiedChild = false;
let scratchCleanupPending = options.scratchDir === undefined;
let interrupted: NodeJS.Signals | undefined;
let exitCode = 0;
const completion = {
version: 1,
planned: admittedPlans.length,
completed: 0,
invocations: 0,
failedInvocations: 0,
};
const onSignal = (signal: NodeJS.Signals) => {
interrupted ??= signal;
};
try {
context = workerOwner ? createWorkerContext(workerOwner, baseEnv, admittedPlans) : undefined;
if (hostResources) {
console.log(
`[shard:resources] logicalCpuCount=${hostResources.logicalCpuCount} totalMemoryBytes=${hostResources.totalMemoryBytes} requested plans=${requestedConcurrency} admitted plans=${concurrency}`,
);
reportCiResourceSnapshot("start");
}
const clonedCacheSlots = clonePersistentCacheSlots(persistentCacheRoot, concurrency);
if (clonedCacheSlots > 0) {
process.stdout.write(
`[shard:cache] cloned restored Vitest seed into ${clonedCacheSlots} isolated lane(s)\n`,
);
}
if (context) {
process.on("SIGINT", onSignal);
process.on("SIGTERM", onSignal);
}
const runner: typeof runChild =
options.runChild ??
((args, childEnv, label, timingKey) => runChild(args, childEnv, label, timingKey, context));
await runVitestPlans(admittedPlans, {
concurrency,
isExclusive: exclusive,
shouldStop: () => Boolean(interrupted) || (exitCode !== 0 && !options.continueOnFailure),
run: async (entry, index, cacheSlot) => {
const targetArgs = entry.kind === "target" ? [entry.target] : entry.plan.configs;
if (!Array.isArray(targetArgs) || targetArgs.length === 0) {
console.error(`Missing node test shard configs for ${entry.name}`);
exitCode = exitCode || 1;
return;
}
// A standalone plan already projects the job environment. Resolve its
// scoped override once, then append it to the inherited global flags.
const vitestExtraArgs = [
inheritedEnv,
mergePlanEnv(jobEnv, entry.kind === "group" ? entry.plan.env : undefined),
].flatMap((env) => {
const value = parseJsonEnv(env, VITEST_EXTRA_ARGS_ENV_KEY, []);
return isStringArray(value) ? value : [];
});
const selections = preparedRuntimeSelections.get(entry) ??
runtimeOwner?.resolveCiTestRuntimeSelections(
{
...(entry.kind === "target" ? { targets: [entry.target] } : entry.plan),
env: prepareChildEnv(entry, baseEnv),
vitestArgs: vitestExtraArgs,
},
policy,
) ?? [{ runtime: "node" as const }];
const [nodeSelection, bunSelection] = selections;
// Only the proven UI partition has native sharding after discovery.
// Run it once on Bun and reuse that owner's facts, never its algorithm.
const uiReceipt =
context &&
policy === "bun-compatible" &&
entry.kind === "group" &&
entry.plan.configs.length === 1 &&
entry.plan.configs[0] === "ui/vitest.config.ts" &&
selections.length === 2 &&
nodeSelection?.runtime === "node" &&
nodeSelection.includeAfterShard &&
nodeSelection.includePatterns?.length &&
bunSelection?.runtime === "bun" &&
bunSelection.includeAfterShard &&
bunSelection.includePatterns?.length
? {
directory: mkdtempSync(join(scratchDir, "ui-native-shard-")),
requestId: randomUUID(),
expectedFiles: new Set([
...nodeSelection.includePatterns,
...bunSelection.includePatterns,
]),
selections: [bunSelection, nodeSelection],
}
: undefined;
let nativeShardFiles: Set<string> | undefined;
for (const selection of uiReceipt?.selections ?? selections) {
if (interrupted) {
return;
}
const runtime = selection.runtime;
const nativeBun = selection.engine === "bun-test";
const nativeFiles = nativeShardFiles;
if (
runtime === "node" &&
nativeFiles &&
selection.includePatterns &&
!selection.includePatterns.some((file) => nativeFiles.has(file))
) {
process.stdout.write(
`[shard:node-subset:${entry.name}] skipped (native shard has no Node-only files)\n`,
);
continue;
}
const selectedEntry =
entry.kind === "group" && (selection.configs || selection.includePatterns)
? {
...entry,
plan: {
...entry.plan,
configs: selection.configs ?? entry.plan.configs,
includePatterns: selection.includePatterns ?? entry.plan.includePatterns,
},
}
: entry;
const selectedArgs =
selectedEntry.kind === "target" ? [selectedEntry.target] : selectedEntry.plan.configs;
const args = nativeBun
? ["--native-bun", ...selection.files.map((file) => `./${file}`)]
: vitestExtraArgs.length > 0
? [...selectedArgs, "--", ...vitestExtraArgs]
: selectedArgs;
const childEnv = nativeBun
? prepareChildEnv(entry, { ...baseEnv, OPENCLAW_VITEST_RUNTIME: "bun" })
: buildChildEnv(selectedEntry, baseEnv, scratchDir, index, {
serial: concurrency === 1,
cacheSlot,
runtime,
});
if (nativeBun) {
delete childEnv[FS_MODULE_CACHE_ROOT_ENV_KEY];
delete childEnv[FS_MODULE_CACHE_PATH_ENV_KEY];
delete childEnv.OPENCLAW_VITEST_INCLUDE_FILE;
}
if (selection.includeAfterShard) {
childEnv.OPENCLAW_VITEST_POST_SHARD_INCLUDE_FILE =
childEnv.OPENCLAW_VITEST_INCLUDE_FILE;
const includePatterns = entry.kind === "group" ? entry.plan.includePatterns : null;
if (includePatterns?.length) {
// Tier selection precedes native sharding; runtime membership follows it.
const includeFile = join(scratchDir, `node-test-pre-shard-include-${index}.json`);
writeFileSync(includeFile, JSON.stringify(includePatterns), "utf8");
childEnv.OPENCLAW_VITEST_INCLUDE_FILE = includeFile;
} else {
delete childEnv.OPENCLAW_VITEST_INCLUDE_FILE;
}
}
Object.assign(childEnv, selection.env);
delete childEnv.OPENCLAW_VITEST_NATIVE_SHARD_RECEIPT;
delete childEnv.OPENCLAW_VITEST_NATIVE_SHARD_REQUEST_ID;
if (uiReceipt && runtime === "bun") {
childEnv.OPENCLAW_VITEST_NATIVE_SHARD_RECEIPT = join(uiReceipt.directory, "files.json");
childEnv.OPENCLAW_VITEST_NATIVE_SHARD_REQUEST_ID = uiReceipt.requestId;
}
const timingKey = entry.kind === "group" ? (entry.timingKey ?? entry.name) : entry.name;
const timingPrefix = nativeBun
? "bun-native:"
: runtime === "bun"
? "bun:"
: selection.configs || selection.includePatterns
? "node-subset:"
: "";
unverifiedChild ||= !context;
completion.invocations++;
const code = await runner(
args,
childEnv,
`${timingPrefix}${entry.name}`,
`${timingPrefix}${timingKey}`,
);
if (uiReceipt && runtime === "bun") {
// runner() has joined the child and its descendants before these
// facts can suppress a process or their scratch directory retires.
if (code === 0 && !interrupted) {
nativeShardFiles = readUiNativeShardReceipt(
join(uiReceipt.directory, "files.json"),
uiReceipt.requestId,
uiReceipt.expectedFiles,
);
}
rmSync(uiReceipt.directory, { recursive: true, force: true });
}
// A dual-runtime envelope always completes both ordinary test runs;
// its first failure still stops admission of later envelopes.
if (code !== 0) {
completion.failedInvocations++;
exitCode = exitCode || code;
}
}
completion.completed++;
},
});
if (persistentCacheRoot && baseEnv[FS_MODULE_CACHE_WRITER_ENV_KEY] === "1") {
try {
const pruned = pruneFsModuleCache(
persistentCacheRoot,
options.fsModuleCacheMaxBytes ?? FS_MODULE_CACHE_MAX_BYTES,
);
process.stdout.write(
`[shard:cache] vitest ${pruned.beforeBytes} -> ${pruned.afterBytes} bytes; removed ${pruned.removedFiles} files\n`,
);
} catch (error) {
console.warn(`[shard:cache] failed to prune Vitest cache: ${String(error)}`);
}
}
if (nodeCompileCacheRoot && baseEnv[NODE_COMPILE_CACHE_WRITER_ENV_KEY] === "1") {
try {
const pruned = pruneFsModuleCache(
nodeCompileCacheRoot,
options.nodeCompileCacheMaxBytes ?? NODE_COMPILE_CACHE_MAX_BYTES,
);
process.stdout.write(
`[shard:cache] node-compile ${pruned.beforeBytes} -> ${pruned.afterBytes} bytes; removed ${pruned.removedFiles} files\n`,
);
} catch (error) {
console.warn(`[shard:cache] failed to prune Node compile cache: ${String(error)}`);
}
}
} finally {
try {
await context?.workerRun.dispose();
if (scratchCleanupPending && !unverifiedChild) {
// Disposal proves compiler, borrower, and nested-resource settlement.
// Portable close-only launches cannot authorize deleting shared scratch.
try {
await rm(scratchDir, { recursive: true, force: true, maxRetries: 3 });
scratchCleanupPending = false;
} catch {
// Report retained scratch below without replacing the shard's result.
}
}
} finally {
if (scratchCleanupPending) {
console.warn(
`[shard:cache] retained ${scratchDir}: descendant or scratch cleanup is unverified`,
);
}
if (hostResources) {
reportCiResourceSnapshot("end");
}
process.off("SIGINT", onSignal);
process.off("SIGTERM", onSignal);
if (interrupted && context) {
await context.exitBySignal(interrupted);
}
}
}
// Publish after disposal; custom or portable runners cannot certify child joins.
if (
completion.completed === completion.planned &&
context?.normalCompletion &&
!interrupted &&
!unverifiedChild &&
!options.runChild &&
!scratchCleanupPending
) {
process.stdout.write(`[shard:completion] ${JSON.stringify(completion)}\n`);
}
return exitCode;
}
if (isDirectRunUrl(process.argv[1], import.meta.url)) {
const plans = resolveShardPlans();
process.exitCode = await runShardPlans(plans, {
continueOnFailure: process.env.OPENCLAW_NODE_TEST_PLAN_CONTINUE_ON_FAILURE === "1",
});
}