openclaw/scripts/bench-agent-concurrency.ts
Peter Steinberger 6652f7eac8
refactor: remove Tasks and TaskFlow runtime (#159179)
Remove Tasks and TaskFlow runtime, APIs, CLI, SDK surfaces and panels after the Cron, session, native execution and media completion ownership cutovers. Preserve stored rows and import provable legacy native assignments through Doctor; ambiguous ownership stays untouched with a warning.

Follows #158221, #158217, #158225, #158222, #158702 and #158776. Related: #156532. Task-specific public APIs retire immediately; retained responsibilities use their existing owners.

Maintainer-authorized administrative landing after full CI run 36312986498 attempt 2 passed on 274595e2, with subsequent actual conflicts reviewed and focused checks passing. Current PR CI preflight hits the 64 KiB changed-path metadata limit before tests (run 36335042695); its duplicate security-review status mirrors that planning failure. Review and scoped proof are recorded in the PR. Published 9.4 native import is proven; remaining native completion and 9.4 rollback witnesses are explicitly unproven.
2026-09-27 10:40:29 -07:00

377 lines
12 KiB
TypeScript

import { pathToFileURL } from "node:url";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import {
emitBenchmarkReport,
parseBenchmarkInteger,
parseBenchmarkIntegerList,
parseBenchmarkOptions,
parseBenchmarkWorkerResult,
runBenchmarkEntrypoint,
runBenchmarkJobs,
runBenchmarkWorker,
summarizeBenchmarkTimings,
type BenchmarkTimingSummary,
type BenchmarkWorkerProcessResult,
} from "./lib/benchmark-harness.mts";
const DEFAULT_FANOUT = [1, 8, 32, 64];
const DEFAULT_SWEEP_ROWS = [32, 128, 512];
const WORKER_TIMEOUT_MS = 300_000;
export const WORKER_RESULT_SENTINEL = "[bench-agent-concurrency-result] ";
export type WorkerScenario =
| "spawnPipelineInMemory"
| "spawnPipelineDurable"
| "admission"
| "recoverySweep"
| "duplicateSuppression";
export type WorkerResult = {
scenario: WorkerScenario;
size: number;
timingsMs: number[];
memory: {
rssStartBytes: number;
rssEndBytes: number;
processMaxRssBytes: number;
};
invariant: Record<string, number | boolean>;
};
type Options = {
runs: number;
warmup: number;
fanout: number[];
sweepRows: number[];
output?: string;
json: boolean;
help: boolean;
};
const SCENARIO_SPECS: ReadonlyArray<{
scenario: WorkerScenario;
sizes: "fanout" | "sweepRows";
}> = [
{ scenario: "spawnPipelineInMemory", sizes: "fanout" },
{ scenario: "spawnPipelineDurable", sizes: "fanout" },
{ scenario: "admission", sizes: "fanout" },
{ scenario: "recoverySweep", sizes: "sweepRows" },
{ scenario: "duplicateSuppression", sizes: "sweepRows" },
];
const SPAWN_INVARIANT_FIELDS = [
"ok",
"registeredRuns",
"reservationsReleased",
"blockedWaits",
"settledRuns",
"outstandingWaits",
"durableSubagentRows",
"durableStateFile",
"postTeardownRegistryRows",
"postTeardownDurableSubagentRows",
"postTeardownActiveRootWork",
] as const;
const REQUIRED_INVARIANT_FIELDS: Record<WorkerScenario, readonly string[]> = {
spawnPipelineInMemory: SPAWN_INVARIANT_FIELDS,
spawnPipelineDurable: SPAWN_INVARIANT_FIELDS,
admission: ["ok", "admissionCap", "overflowRejected", "released"],
recoverySweep: [
"ok",
"seededRows",
"removedRows",
"retainedCurrent",
"sessionEffects",
"recoveryProjections",
"lostContextCompletions",
],
duplicateSuppression: [
"ok",
"inputRowsPerOrdering",
"newestFirstSelectedRows",
"oldestFirstSelectedRows",
"newestFirstSelectedNewest",
"oldestFirstSelectedNewest",
],
};
type BenchmarkRuntime = {
runWorker?: typeof runWorker;
writeProgress?: (line: string) => void;
now?: () => number;
};
function usage(): string {
return `OpenClaw agent concurrency benchmark
Usage:
node --import tsx scripts/bench-agent-concurrency.ts [options]
Options:
--runs <n> Measured samples per scenario (default: 5)
--warmup <n> Warmup samples per scenario (default: 1)
--fanout <list> Comma-separated spawn/admission sizes (default: 1,8,32,64)
--sweep-rows <list> Comma-separated child counts, with 3 generations each (default: 32,128,512)
--output <path> Write the JSON report to a file
--json Print only the JSON report
--help Show this text
`;
}
function parseOptions(argv: string[]): Options {
return parseBenchmarkOptions<Options>(
argv,
{
runs: 5,
warmup: 1,
fanout: DEFAULT_FANOUT,
sweepRows: DEFAULT_SWEEP_ROWS,
json: false,
help: false,
},
{
"--runs": (options, value) => {
options.runs = parseBenchmarkInteger(value, "--runs", 1, 100);
},
"--warmup": (options, value) => {
options.warmup = parseBenchmarkInteger(value, "--warmup", 0, 20);
},
"--fanout": (options, value) => {
options.fanout = parseBenchmarkIntegerList(value, "--fanout", 256);
},
"--sweep-rows": (options, value) => {
options.sweepRows = parseBenchmarkIntegerList(value, "--sweep-rows", 4096);
},
"--output": (options, value) => {
options.output = value;
},
},
);
}
function expectedWorkerKeys(options: Options): string[] {
return SCENARIO_SPECS.flatMap(({ scenario, sizes }) =>
options[sizes].map((size) => `${scenario}:${size}`),
);
}
function aggregateWorkerResults(
options: Options,
workers: WorkerResult[],
parentMemory = {
rssStartBytes: process.memoryUsage().rss,
rssEndBytes: process.memoryUsage().rss,
},
) {
const expected = expectedWorkerKeys(options);
const byKey = new Map(workers.map((worker) => [`${worker.scenario}:${worker.size}`, worker]));
if (byKey.size !== workers.length) {
throw new Error("worker results contain duplicate scenario/size pairs");
}
const missing = expected.filter((key) => !byKey.has(key));
const unexpected = [...byKey.keys()].filter((key) => !expected.includes(key));
if (missing.length > 0 || unexpected.length > 0) {
throw new Error(
`worker result mismatch: missing=${missing.join(",") || "none"} unexpected=${unexpected.join(",") || "none"}`,
);
}
const scenarios = Object.fromEntries(
SCENARIO_SPECS.map(({ scenario, sizes }) => [
scenario,
options[sizes].map((size) => {
const worker = byKey.get(`${scenario}:${size}`);
if (!worker) {
throw new Error(`missing worker result for ${scenario}:${size}`);
}
return {
size,
timingsMs: summarizeBenchmarkTimings(worker.timingsMs),
memory: worker.memory,
invariant: worker.invariant,
};
}),
]),
) as Record<
WorkerScenario,
Array<{
size: number;
timingsMs: BenchmarkTimingSummary;
memory: WorkerResult["memory"];
invariant: WorkerResult["invariant"];
}>
>;
const checks = {
spawnPipelineInMemory: scenarios.spawnPipelineInMemory.every(
(entry) => entry.invariant.ok === true,
),
spawnPipelineDurable: scenarios.spawnPipelineDurable.every(
(entry) => entry.invariant.ok === true,
),
admissionCapOverflowRelease: scenarios.admission.every((entry) => entry.invariant.ok === true),
sweepRecoveryRowsWithoutSessionEffects: scenarios.recoverySweep.every(
(entry) => entry.invariant.ok === true,
),
dedupeNewestPerChild: scenarios.duplicateSuppression.every(
(entry) => entry.invariant.ok === true,
),
};
const failures = Object.entries(checks)
.filter(([, ok]) => !ok)
.map(([name]) => name);
return {
schemaVersion: 2,
generatedAt: new Date().toISOString(),
runtime: { node: process.version, platform: process.platform, arch: process.arch },
options: {
runs: options.runs,
warmup: options.warmup,
fanout: options.fanout,
sweepRows: options.sweepRows,
},
memory: {
...parentMemory,
workerProcessMaxRssBytes: Math.max(
...workers.map((worker) => worker.memory.processMaxRssBytes),
),
},
scenarios,
invariants: { ok: failures.length === 0, failures, ...checks },
};
}
function assertFiniteNonNegative(value: unknown, field: string): asserts value is number {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
throw new Error(`worker result field ${field} must be a finite nonnegative number`);
}
}
function validateWorkerResult(
value: unknown,
expected: { scenario: WorkerScenario; size: number; runs: number },
): WorkerResult {
if (!isRecord(value)) {
throw new Error("worker result must be an object");
}
if (value.scenario !== expected.scenario || value.size !== expected.size) {
throw new Error(`worker ${expected.scenario}:${expected.size} returned mismatched identity`);
}
if (!Array.isArray(value.timingsMs) || value.timingsMs.length !== expected.runs) {
throw new Error(
`worker ${expected.scenario}:${expected.size} returned ${Array.isArray(value.timingsMs) ? value.timingsMs.length : "invalid"} samples; expected ${expected.runs}`,
);
}
value.timingsMs.forEach((timing, index) =>
assertFiniteNonNegative(timing, `timingsMs[${index}]`),
);
if (!isRecord(value.memory)) {
throw new Error("worker result memory must be an object");
}
assertFiniteNonNegative(value.memory.rssStartBytes, "memory.rssStartBytes");
assertFiniteNonNegative(value.memory.rssEndBytes, "memory.rssEndBytes");
assertFiniteNonNegative(value.memory.processMaxRssBytes, "memory.processMaxRssBytes");
if (!isRecord(value.invariant)) {
throw new Error("worker result invariant must be an object");
}
for (const field of REQUIRED_INVARIANT_FIELDS[expected.scenario]) {
const invariantValue = value.invariant[field];
if (typeof invariantValue !== "number" && typeof invariantValue !== "boolean") {
throw new Error(`worker result invariant.${field} is missing or invalid`);
}
}
if (value.invariant.ok !== true) {
throw new Error(`worker ${expected.scenario}:${expected.size} reported a failed invariant`);
}
return value as WorkerResult;
}
function parseWorkerProcessResult(
result: BenchmarkWorkerProcessResult,
expected: { scenario: WorkerScenario; size: number; runs: number },
): WorkerResult {
return parseBenchmarkWorkerResult({
result,
label: `${expected.scenario}:${expected.size}`,
sentinel: WORKER_RESULT_SENTINEL,
timeoutMs: WORKER_TIMEOUT_MS,
validate: (value) => validateWorkerResult(value, expected),
});
}
function runWorker(options: Options, scenario: WorkerScenario, size: number): WorkerResult {
return runBenchmarkWorker({
args: [
"--experimental-test-module-mocks",
"--import",
"tsx",
"scripts/bench-agent-concurrency-worker.ts",
"--scenario",
scenario,
"--size",
String(size),
"--runs",
String(options.runs),
"--warmup",
String(options.warmup),
],
label: `${scenario}:${size}`,
sentinel: WORKER_RESULT_SENTINEL,
timeoutMs: WORKER_TIMEOUT_MS,
validate: (value) => validateWorkerResult(value, { scenario, size, runs: options.runs }),
});
}
function benchmark(options: Options, runtime: BenchmarkRuntime = {}) {
const rssStartBytes = process.memoryUsage().rss;
const jobs = SCENARIO_SPECS.flatMap(({ scenario, sizes }) =>
options[sizes].map((size) => ({ scenario, size })),
);
const run = runtime.runWorker ?? runWorker;
const workers = runBenchmarkJobs(jobs, {
prefix: "bench-agent-concurrency",
describe: ({ scenario, size }) => `scenario=${scenario} size=${size}`,
run: ({ scenario, size }) => run(options, scenario, size),
now: runtime.now,
writeProgress: runtime.writeProgress,
});
return aggregateWorkerResults(options, workers, {
rssStartBytes,
rssEndBytes: process.memoryUsage().rss,
});
}
async function main(argv = process.argv.slice(2)): Promise<void> {
const options = parseOptions(argv);
if (options.help) {
process.stdout.write(usage());
return;
}
const report = benchmark(options);
emitBenchmarkReport(report, options, (result) => [
...Object.entries(result.scenarios).flatMap(([name, scenarios]) =>
scenarios.map((scenario) => {
const tail =
scenario.timingsMs.p95 === undefined
? ""
: ` p95=${scenario.timingsMs.p95.toFixed(3)}ms p99=${scenario.timingsMs.p99?.toFixed(3)}ms`;
return `${name} size=${scenario.size} p50=${scenario.timingsMs.p50.toFixed(3)}ms max=${scenario.timingsMs.max.toFixed(3)}ms${tail}`;
}),
),
`max worker RSS ${(result.memory.workerProcessMaxRssBytes / 1024 / 1024).toFixed(1)} MiB`,
]);
}
export const testing = {
aggregateWorkerResults,
benchmark,
parseOptions,
parseWorkerProcessResult,
summarizeTimings: summarizeBenchmarkTimings,
};
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
await runBenchmarkEntrypoint("bench-agent-concurrency", main);
}