mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 17:53:39 +00:00
Automatic semantic-check planning now uses effective memory capacity, so a physically large host with a small container ceiling selects the constrained graph and worker policy. Explicit settings and complete selected-file coverage are preserved. Validated with 156 focused tests, native type/lint checks, original-code failure controls and a real 7 GiB cgroup on a 31 GiB host. Separate one-worker test costs and the limits of hosted per-file attribution are recorded in the PR. Related: https://github.com/openclaw/openclaw/issues/160010
412 lines
14 KiB
TypeScript
412 lines
14 KiB
TypeScript
// Applies resource policy for expensive local and CI check commands.
|
|
import { spawnSync } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import { createRequire } from "node:module";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
|
const GIB = 1024 ** 3;
|
|
const DEFAULT_LOCAL_GO_GC = "30";
|
|
const DEFAULT_LOCAL_GO_MAX_PROCS = 2;
|
|
const DEFAULT_LOCAL_GO_MEMORY_LIMIT = "3GiB";
|
|
const DEFAULT_LOCAL_TSGO_BUILD_INFO_FILE = ".artifacts/tsgo-cache/root.tsbuildinfo";
|
|
const DEFAULT_FAST_LOCAL_CHECK_MIN_MEMORY_BYTES = 48 * GIB;
|
|
const DEFAULT_FAST_LOCAL_CHECK_MIN_CPUS = 12;
|
|
const CI_PARALLEL_MIN_CPUS = 8;
|
|
export const CI_PARALLEL_MIN_MEMORY_BYTES = 24 * GIB;
|
|
|
|
const EXCLUSIVE_CI_TEST_CONFIGS = new Set([
|
|
"vitest.config.ts",
|
|
"test/vitest/vitest.config.ts",
|
|
"test/vitest/vitest.full-agentic.config.ts",
|
|
"test/vitest/vitest.gateway.config.ts",
|
|
"test/vitest/vitest.gateway-core.config.ts",
|
|
"test/vitest/vitest.gateway-database-workers.config.ts",
|
|
"test/vitest/vitest.gateway-methods.config.ts",
|
|
"test/vitest/vitest.gateway-methods-isolated.config.ts",
|
|
"test/vitest/vitest.gateway-server.config.ts",
|
|
"test/vitest/vitest.gateway-server-isolated.config.ts",
|
|
]);
|
|
|
|
export function isExclusiveCiTestConfig(config: string): boolean {
|
|
return EXCLUSIVE_CI_TEST_CONFIGS.has(config);
|
|
}
|
|
|
|
type Env = NodeJS.ProcessEnv;
|
|
type Resources = {
|
|
logicalCpuCount: number;
|
|
totalMemoryBytes: number;
|
|
memoryCapacityBytes?: number | null;
|
|
memoryLimitBytes?: number | null;
|
|
platform?: NodeJS.Platform;
|
|
};
|
|
|
|
type LocalCheckMode = "auto" | "full" | "throttled";
|
|
type RepoToolOptions = {
|
|
cwd?: string;
|
|
fileExists?: (candidate: string) => boolean;
|
|
resolveCommonDir?: (cwd: string) => string | null;
|
|
};
|
|
type NodeModulesLinkOptions = Pick<RepoToolOptions, "cwd" | "fileExists"> & {
|
|
symlink?: typeof fs.symlinkSync;
|
|
platform?: NodeJS.Platform;
|
|
};
|
|
|
|
/** Return whether local check safeguards are enabled for an environment. */
|
|
export function isLocalCheckEnabled(env: Env) {
|
|
const raw = env.OPENCLAW_LOCAL_CHECK?.trim().toLowerCase();
|
|
return raw !== "0" && raw !== "false";
|
|
}
|
|
|
|
function isCiLikeEnv(env: Env = process.env) {
|
|
return env.CI === "true" || env.GITHUB_ACTIONS === "true";
|
|
}
|
|
|
|
export function resolveCheckMemoryCapacityBytes(
|
|
resources: Pick<Resources, "totalMemoryBytes" | "memoryCapacityBytes">,
|
|
) {
|
|
// Omitted capacity is a physical-only caller; null means discovery was unresolved.
|
|
// A known ancestor ceiling, or unknown ownership, must not select a roomy workload.
|
|
return Math.min(
|
|
resources.totalMemoryBytes,
|
|
resources.memoryCapacityBytes === undefined
|
|
? resources.totalMemoryBytes
|
|
: (resources.memoryCapacityBytes ?? 0),
|
|
);
|
|
}
|
|
|
|
// Small CI runners share one constraint check for shard concurrency and Go memory policy.
|
|
export function isConstrainedCiCheckHost(hostResources: Resources) {
|
|
return !(
|
|
resolveCheckMemoryCapacityBytes(hostResources) >= CI_PARALLEL_MIN_MEMORY_BYTES &&
|
|
hostResources.logicalCpuCount >= CI_PARALLEL_MIN_CPUS
|
|
);
|
|
}
|
|
|
|
/** Ensure local check runs opt into safeguard environment outside CI. */
|
|
export function resolveLocalCheckEnv(env: Env = process.env) {
|
|
if (isCiLikeEnv(env) || isLocalCheckEnabled(env)) {
|
|
return env;
|
|
}
|
|
|
|
return {
|
|
...env,
|
|
OPENCLAW_LOCAL_CHECK: "1",
|
|
};
|
|
}
|
|
|
|
const withinRoot = (root: string, file: string) => {
|
|
const relative = path.relative(root, file);
|
|
return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
};
|
|
|
|
export function createDeclarationInputBoundary(cwd: string) {
|
|
const declared = path.resolve(cwd);
|
|
const prefixes = [declared];
|
|
if (fs.lstatSync(declared).isSymbolicLink()) {
|
|
prefixes.push(path.resolve(path.dirname(declared), fs.readlinkSync(declared)));
|
|
}
|
|
prefixes.push(fs.realpathSync(declared));
|
|
const root = fs.realpathSync.native(declared);
|
|
// Runtimes differ on whether realpath preserves a case-only symlink target.
|
|
// Translate only declared checkout spellings; never canonicalize outside candidates into scope.
|
|
const resolve = (file: string) => {
|
|
const absolute = path.resolve(declared, file);
|
|
const prefix = prefixes.find((candidate) => withinRoot(candidate, absolute));
|
|
return prefix ? path.resolve(root, path.relative(prefix, absolute)) : absolute;
|
|
};
|
|
return {
|
|
root,
|
|
resolve,
|
|
assert(file: string) {
|
|
const absolute = resolve(file);
|
|
// Generated declaration IDs do not exist yet, but their source directory does.
|
|
let existing = absolute;
|
|
while (!fs.existsSync(existing) && path.dirname(existing) !== existing) {
|
|
existing = path.dirname(existing);
|
|
}
|
|
const real = fs.realpathSync.native(existing);
|
|
if (!withinRoot(root, absolute) || !withinRoot(root, real)) {
|
|
const diagnosis = `Keep declaration dependencies and compiler files physically inside ${root}; shared installs and external symlinks are unsupported. Inspect the reported path and dependency links; this error alone does not establish a missing or undeclared dependency.`;
|
|
throw new Error(`Declaration input escapes checkout: ${absolute} -> ${real}. ${diagnosis}`);
|
|
}
|
|
return absolute;
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Resolve a repo tool from this worktree or the primary checkout's installed toolchain. */
|
|
export function resolveRepoToolBinPath(
|
|
toolName: string,
|
|
{
|
|
cwd = process.cwd(),
|
|
fileExists = fs.existsSync,
|
|
resolveCommonDir = resolveGitCommonDir,
|
|
}: RepoToolOptions = {},
|
|
) {
|
|
if (toolName === "tsgo") {
|
|
// Resolve this checkout's native compiler independently of the ambient tsc bin link.
|
|
const require = createRequire(import.meta.url);
|
|
const inputs = createDeclarationInputBoundary(cwd);
|
|
const fromCheckout = createRequire(path.join(inputs.root, "package.json"));
|
|
const nativeRoot = path.dirname(inputs.assert(fromCheckout.resolve("typescript/package.json")));
|
|
const getExePath: { default: () => string } = require(
|
|
inputs.assert(path.join(nativeRoot, "lib/getExePath.js")),
|
|
);
|
|
const executable = getExePath.default();
|
|
// Windows launches need the extended-length prefix; normalize only for admission.
|
|
inputs.assert(fileURLToPath(pathToFileURL(executable)));
|
|
return executable;
|
|
}
|
|
const localPath = path.resolve(cwd, "node_modules", ".bin", toolName);
|
|
if (fileExists(localPath)) {
|
|
return localPath;
|
|
}
|
|
|
|
const commonDir = resolveCommonDir(cwd);
|
|
if (!commonDir || path.basename(commonDir) !== ".git") {
|
|
return localPath;
|
|
}
|
|
|
|
// Linked worktrees share the primary checkout's .git directory. Its parent
|
|
// owns the installed toolchain that dependency-less worktrees can reuse.
|
|
const primaryPath = path.join(path.dirname(commonDir), "node_modules", ".bin", toolName);
|
|
return fileExists(primaryPath) ? primaryPath : localPath;
|
|
}
|
|
|
|
/** Link explicitly provisioned dependencies for hydration or relocated declarations. */
|
|
export function ensureRepoNodeModulesLink(
|
|
modulesDir: string,
|
|
{
|
|
cwd = process.cwd(),
|
|
fileExists = fs.existsSync,
|
|
symlink = fs.symlinkSync,
|
|
platform = process.platform,
|
|
}: NodeModulesLinkOptions = {},
|
|
) {
|
|
const localNodeModules = path.resolve(cwd, "node_modules");
|
|
if (fileExists(localNodeModules)) {
|
|
return localNodeModules;
|
|
}
|
|
if (!fileExists(modulesDir)) {
|
|
return null;
|
|
}
|
|
try {
|
|
// Keep existing checkout dependencies locally owned; only absent modules
|
|
// reuse the selected installed toolchain, without reconciling dependencies.
|
|
symlink(modulesDir, localNodeModules, platform === "win32" ? "junction" : "dir");
|
|
} catch (error) {
|
|
// Another local runner may have installed the same stable link concurrently.
|
|
if (!fileExists(localNodeModules)) {
|
|
throw error;
|
|
}
|
|
}
|
|
return localNodeModules;
|
|
}
|
|
|
|
function hasFlag(args: string[], name: string) {
|
|
return args.some((arg) => arg === name || arg.startsWith(`${name}=`));
|
|
}
|
|
|
|
function hasOxlintFormatArg(args: string[]) {
|
|
return args.some(
|
|
(arg) =>
|
|
arg === "--format" ||
|
|
arg.startsWith("--format=") ||
|
|
arg === "-f" ||
|
|
arg.startsWith("-f=") ||
|
|
(arg.startsWith("-f") && arg.length > 2),
|
|
);
|
|
}
|
|
|
|
/** Apply the shared memory and scheduler limits for Go-backed check helpers. */
|
|
function applyThrottledGoRuntimeEnv(env: Env, hostResources: Resources) {
|
|
if (!env.GOMAXPROCS) {
|
|
env.GOMAXPROCS = String(
|
|
Math.min(DEFAULT_LOCAL_GO_MAX_PROCS, Math.max(1, hostResources.logicalCpuCount)),
|
|
);
|
|
}
|
|
if (!env.GOGC) {
|
|
env.GOGC = DEFAULT_LOCAL_GO_GC;
|
|
}
|
|
if (!env.GOMEMLIMIT) {
|
|
env.GOMEMLIMIT = DEFAULT_LOCAL_GO_MEMORY_LIMIT;
|
|
}
|
|
}
|
|
|
|
/** Apply local tsgo defaults for declaration skipping, caching, throttling, and profiling. */
|
|
export function applyLocalTsgoPolicy(args: string[], env: Env, hostResources: Resources) {
|
|
const nextEnv = { ...env };
|
|
const nextArgs = [...args];
|
|
const defaultProjectRun = nextArgs.length === 0;
|
|
|
|
if (!hasFlag(nextArgs, "--declaration") && !nextArgs.includes("-d")) {
|
|
insertBeforeSeparator(nextArgs, "--declaration", "false");
|
|
}
|
|
|
|
const localCheckEnabled = isLocalCheckEnabled(nextEnv);
|
|
if (localCheckEnabled && defaultProjectRun) {
|
|
insertBeforeSeparator(nextArgs, "--incremental");
|
|
insertBeforeSeparator(
|
|
nextArgs,
|
|
"--tsBuildInfoFile",
|
|
nextEnv.OPENCLAW_TSGO_BUILD_INFO_FILE ?? DEFAULT_LOCAL_TSGO_BUILD_INFO_FILE,
|
|
);
|
|
}
|
|
|
|
const resolvedHostResources = resolveHostResources(hostResources);
|
|
if (
|
|
shouldThrottleLocalChecks(nextEnv, resolvedHostResources, "auto") ||
|
|
(isCiLikeEnv(nextEnv) && isConstrainedCiCheckHost(resolvedHostResources))
|
|
) {
|
|
insertBeforeSeparator(nextArgs, "--singleThreaded");
|
|
insertBeforeSeparator(nextArgs, "--checkers", "1");
|
|
applyThrottledGoRuntimeEnv(nextEnv, resolvedHostResources);
|
|
}
|
|
if (localCheckEnabled && nextEnv.OPENCLAW_TSGO_PPROF_DIR && !hasFlag(nextArgs, "--pprofDir")) {
|
|
insertBeforeSeparator(nextArgs, "--pprofDir", nextEnv.OPENCLAW_TSGO_PPROF_DIR);
|
|
}
|
|
|
|
return { env: nextEnv, args: nextArgs };
|
|
}
|
|
|
|
/** Apply oxlint defaults for type-aware checking and throttled worker settings. */
|
|
export function applyLocalOxlintPolicy(args: string[], env: Env, hostResources: Resources) {
|
|
const nextEnv = { ...env };
|
|
const nextArgs = [...args];
|
|
|
|
insertBeforeSeparator(nextArgs, "--type-aware");
|
|
insertBeforeSeparator(nextArgs, "--tsconfig", "config/tsconfig/oxlint.json");
|
|
if (
|
|
!hasFlag(nextArgs, "--report-unused-disable-directives") &&
|
|
!hasFlag(nextArgs, "--report-unused-disable-directives-severity")
|
|
) {
|
|
insertBeforeSeparator(nextArgs, "--report-unused-disable-directives-severity", "error");
|
|
}
|
|
if (nextEnv.GITHUB_ACTIONS === "true" && !hasOxlintFormatArg(nextArgs)) {
|
|
insertBeforeSeparator(nextArgs, "--format", "stylish");
|
|
}
|
|
|
|
const options = nextArgs.slice(0, nextArgs.includes("--") ? nextArgs.indexOf("--") : undefined);
|
|
const option = (name: string) => {
|
|
const index = options.findIndex((arg) => arg === name || arg.startsWith(`${name}=`));
|
|
return index < 0 ? undefined : (options[index]!.split("=")[1] ?? options[index + 1]);
|
|
};
|
|
const threads = option("--threads");
|
|
const extensionShard = option("--tsconfig") === "extensions/tsconfig.json";
|
|
const balancedCiShard =
|
|
isCiLikeEnv(nextEnv) &&
|
|
!isLocalCheckEnabled(nextEnv) &&
|
|
isConstrainedCiCheckHost(hostResources) &&
|
|
nextEnv.OPENCLAW_OXLINT_BATCH_CONCURRENCY === "1" &&
|
|
nextEnv.OPENCLAW_OXLINT_BOUNDED_SHARD_ARGS === JSON.stringify(args) &&
|
|
hostResources.platform === "linux" &&
|
|
hostResources.logicalCpuCount >= 4 &&
|
|
hostResources.memoryCapacityBytes != null &&
|
|
resolveCheckMemoryCapacityBytes(hostResources) >= 15 * GIB &&
|
|
(hostResources.memoryLimitBytes ?? 0) >= (extensionShard ? 10 : 14) * GIB &&
|
|
["config/tsconfig/oxlint.core.json", "extensions/tsconfig.json"].includes(
|
|
option("--tsconfig") ?? "",
|
|
) &&
|
|
((!hasFlag(nextArgs, "--threads") && threads === undefined) ||
|
|
threads === "1" ||
|
|
threads === "2");
|
|
if (balancedCiShard) {
|
|
// The batch owner admits only bounded targets and one checker child.
|
|
// A 3-GiB Go target repeatedly collects a larger live graph; keep one child
|
|
// and give its compiler four CPUs instead of duplicating it in parallel.
|
|
if (!hasFlag(nextArgs, "--threads")) {
|
|
insertBeforeSeparator(nextArgs, "--threads=2");
|
|
}
|
|
nextEnv.GOMAXPROCS ||= "4";
|
|
nextEnv.GOGC ||= "100";
|
|
nextEnv.GOMEMLIMIT ||= "8GiB";
|
|
} else if (
|
|
shouldThrottleLocalChecks(nextEnv, hostResources) ||
|
|
(isCiLikeEnv(nextEnv) && isConstrainedCiCheckHost(hostResources))
|
|
) {
|
|
if (!hasFlag(nextArgs, "--threads")) {
|
|
insertBeforeSeparator(nextArgs, "--threads=1");
|
|
}
|
|
// Oxlint's thread flag does not govern the Go tsgolint helper.
|
|
applyThrottledGoRuntimeEnv(nextEnv, hostResources);
|
|
}
|
|
|
|
return { env: nextEnv, args: nextArgs };
|
|
}
|
|
|
|
function shouldThrottleLocalChecks(
|
|
env: Env,
|
|
hostResources: Resources | undefined,
|
|
defaultMode: LocalCheckMode = "throttled",
|
|
) {
|
|
if (!isLocalCheckEnabled(env)) {
|
|
return false;
|
|
}
|
|
|
|
const mode = readLocalCheckMode(env, defaultMode);
|
|
if (mode === "throttled") {
|
|
return true;
|
|
}
|
|
if (mode === "full") {
|
|
return false;
|
|
}
|
|
|
|
const resolvedHostResources = resolveHostResources(hostResources);
|
|
return (
|
|
resolveCheckMemoryCapacityBytes(resolvedHostResources) <
|
|
DEFAULT_FAST_LOCAL_CHECK_MIN_MEMORY_BYTES ||
|
|
resolvedHostResources.logicalCpuCount < DEFAULT_FAST_LOCAL_CHECK_MIN_CPUS
|
|
);
|
|
}
|
|
|
|
function resolveGitCommonDir(cwd: string) {
|
|
const result = spawnSync("git", ["rev-parse", "--git-common-dir"], {
|
|
cwd,
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "ignore"],
|
|
});
|
|
|
|
if (result.status === 0) {
|
|
const raw = result.stdout.trim();
|
|
if (raw.length > 0) {
|
|
return path.resolve(cwd, raw);
|
|
}
|
|
}
|
|
|
|
return path.join(cwd, ".git");
|
|
}
|
|
|
|
function insertBeforeSeparator(args: string[], ...items: [string, ...string[]]) {
|
|
if (hasFlag(args, items[0])) {
|
|
return;
|
|
}
|
|
|
|
const separatorIndex = args.indexOf("--");
|
|
const insertIndex = separatorIndex === -1 ? args.length : separatorIndex;
|
|
args.splice(insertIndex, 0, ...items);
|
|
}
|
|
|
|
function readLocalCheckMode(env: Env, defaultMode: LocalCheckMode) {
|
|
const raw = env.OPENCLAW_LOCAL_CHECK_MODE?.trim().toLowerCase();
|
|
if (raw === "throttled" || raw === "low-memory") {
|
|
return "throttled";
|
|
}
|
|
if (raw === "full" || raw === "fast") {
|
|
return "full";
|
|
}
|
|
return defaultMode;
|
|
}
|
|
|
|
function resolveHostResources(hostResources: Resources | undefined) {
|
|
if (hostResources) {
|
|
return hostResources;
|
|
}
|
|
|
|
return {
|
|
totalMemoryBytes: os.totalmem(),
|
|
logicalCpuCount:
|
|
typeof os.availableParallelism === "function" ? os.availableParallelism() : os.cpus().length,
|
|
};
|
|
}
|