Files
wehub-resource-sync 426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

350 lines
11 KiB
JavaScript
Executable File

#!/usr/bin/env node
/**
* Single-machine parallel certification orchestrator (#14549): regenerates the
* lane topology from the live test plan, brings the compose cpu profile (and,
* at --tier full, the gpu profile) up, collects per-lane exit codes and wall
* times from docker, and writes a timings.json whose `timings` record is
* BundleMeta.timings-compatible (packages/evidence: Record<phase, ms>) so a
* certification bundle can absorb it verbatim.
*
* Degradation is deterministic and loud, never silent: no docker → the serial
* native fallback is printed and the process exits with EXIT_NO_DOCKER;
* default-params drift between compose.yml and regeneration → EXIT_DRIFT with
* the regenerate command; lane failure → EXIT_LANE_FAILED after timings are
* still written; wall-clock timeout → EXIT_TIMEOUT. macOS certifiers keep the
* native path (bun run test + scripts/gpu-vision/serve.mjs) — compose is the
* Linux/vast lane.
*
* Usage:
* node docker/certification/certify-parallel.mjs [--tier cpu|full]
* [--cores N] [--unit-shards N] [--e2e-shards N] [--gpu-parallel N]
* [--timeout-min N] [--out timings.json] [--keep-up] [--dry-run]
*/
import { spawnSync } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { MODEL_SETS, parseArgs } from "../../scripts/gpu-vision/lib.mjs";
import {
buildComposeModel,
paramsAreDefault,
renderCompose,
resolveParams,
} from "./compose-lanes-lib.mjs";
import { composePath, readPlan } from "./generate-compose-lanes.mjs";
export const EXIT_LANE_FAILED = 1;
export const EXIT_NO_DOCKER = 4;
export const EXIT_DRIFT = 5;
export const EXIT_TIMEOUT = 6;
const here = path.dirname(fileURLToPath(import.meta.url));
const POLL_MS = 5000;
function log(message) {
process.stdout.write(`[certify-parallel] ${message}\n`);
}
function dockerAvailable() {
// Timed probe: a wedged docker daemon/desktop reads as unavailable (and the
// serial fallback prints) rather than hanging the orchestrator at step zero.
const probe = spawnSync("docker", ["compose", "version"], {
encoding: "utf8",
timeout: 15_000,
});
return !probe.error && probe.status === 0;
}
function compose(fileArg, args, { capture = false } = {}) {
const result = spawnSync(
"docker",
["compose", "-f", fileArg, "--project-directory", here, ...args],
{
encoding: "utf8",
stdio: capture ? ["ignore", "pipe", "pipe"] : "inherit",
maxBuffer: 64 * 1024 * 1024,
},
);
if (result.error) throw result.error;
return result;
}
/** `docker compose ps -a --format json` emits one JSON object per line. */
function composePs(fileArg, profiles) {
const result = compose(
fileArg,
[
...profiles.flatMap((p) => ["--profile", p]),
"ps",
"-a",
"--format",
"json",
],
{
capture: true,
},
);
if (result.status !== 0) {
throw new Error(
`[certify-parallel] docker compose ps failed:\n${result.stderr}`,
);
}
return result.stdout
.split("\n")
.filter((line) => line.trim().length > 0)
.map((line) => JSON.parse(line));
}
function inspectContainer(id) {
const result = spawnSync("docker", ["inspect", id], {
encoding: "utf8",
maxBuffer: 16 * 1024 * 1024,
});
if (result.error) throw result.error;
if (result.status !== 0) {
throw new Error(
`[certify-parallel] docker inspect ${id} failed:\n${result.stderr}`,
);
}
return JSON.parse(result.stdout)[0];
}
async function queueDrained(queueDir) {
for (const sub of ["pending", "processing"]) {
const entries = await fs.readdir(path.join(queueDir, sub)).catch((err) => {
if (err.code === "ENOENT") return [];
throw err;
});
if (entries.filter((name) => name.endsWith(".json")).length > 0)
return false;
}
return true;
}
/**
* Assemble the timings report. `timings` is deliberately a flat
* Record<string, number> of milliseconds so packages/evidence can merge it
* verbatim into BundleMeta.timings; everything richer (exit codes, ISO
* stamps, skips) lives beside it.
*/
export function buildTimingsReport({
tier,
params,
startedAt,
finishedAt,
lanes,
timedOut,
}) {
const timings = { wall: finishedAt.getTime() - startedAt.getTime() };
for (const [lane, record] of Object.entries(lanes)) {
timings[`lane:${lane}`] = record.durationMs;
}
const skipped = [];
if (tier === "cpu") {
skipped.push({
lane: "gpu",
reason: "tier=cpu — gpu profile not started; queue jobs remain pending",
});
}
return {
schema: 1,
tier,
params,
startedAt: startedAt.toISOString(),
finishedAt: finishedAt.toISOString(),
wallClockMs: timings.wall,
timings,
lanes,
skipped,
timedOut,
};
}
function printSerialFallback() {
process.stdout.write(
[
"[certify-parallel] docker compose is not available on this host.",
"Falling back is manual and serial — run the native path instead:",
" bun run test # all cpu lanes, serial",
" node scripts/gpu-vision/setup.mjs --with-vlm # pinned model blobs",
" node scripts/gpu-vision/serve.mjs # resident OCR llama-server",
" node scripts/gpu-vision/serve.mjs --vlm # resident Qwen3-VL llama-server",
"This is the supported macOS certifier path; compose is the Linux/vast lane.",
`Exiting with code ${EXIT_NO_DOCKER} (EXIT_NO_DOCKER) so callers can tell "degraded" from "failed".`,
"",
].join("\n"),
);
}
async function main() {
const { flags } = parseArgs(process.argv.slice(2), {
booleans: ["keep-up", "dry-run"],
});
const params = resolveParams(flags);
const tier = flags.tier === undefined ? "cpu" : flags.tier;
if (tier !== "cpu" && tier !== "full") {
throw new Error(`[certify-parallel] --tier must be cpu|full, got: ${tier}`);
}
const timeoutMin =
flags["timeout-min"] === undefined ? 90 : Number(flags["timeout-min"]);
if (!Number.isFinite(timeoutMin) || timeoutMin <= 0) {
throw new Error(
`[certify-parallel] --timeout-min must be positive, got: ${flags["timeout-min"]}`,
);
}
const outPath = path.resolve(
flags.out === undefined ? path.join(here, "timings.json") : flags.out,
);
if (!dockerAvailable()) {
printSerialFallback();
process.exit(EXIT_NO_DOCKER);
}
log("deriving lanes from run-all-tests.mjs --plan=json --all");
const model = buildComposeModel(readPlan(), params, MODEL_SETS);
const text = renderCompose(model);
// The committed compose.yml is the reviewed artifact; with default params it
// must match regeneration or the board is being certified against a stale
// topology. Non-default params legitimately diverge and use a temp file.
if (paramsAreDefault(params)) {
const committed = await fs.readFile(composePath, "utf8").catch(() => null);
if (committed !== text) {
process.stderr.write(
"[certify-parallel] compose.yml drift: committed file does not match the current plan.\n" +
" Regenerate and commit: node docker/certification/generate-compose-lanes.mjs\n",
);
process.exit(EXIT_DRIFT);
}
}
const composeFile = path.join(
await fs.mkdtemp(path.join(os.tmpdir(), "eliza-cert-")),
"compose.yml",
);
await fs.writeFile(composeFile, text, "utf8");
const profiles = tier === "full" ? ["cpu", "gpu"] : ["cpu"];
const laneServices = model.services.filter(
(service) => service.lane !== undefined,
);
log(
`tier=${tier} lanes=${laneServices.length} (${laneServices.map((s) => s.lane).join(", ")})`,
);
if (flags["dry-run"] === true) {
log(`dry run — compose file at ${composeFile}, not starting containers`);
return;
}
const queueDir = path.join(
here,
process.env.ELIZA_CERT_QUEUE_HOST ?? "queue",
);
await fs.mkdir(queueDir, { recursive: true });
const startedAt = new Date();
const up = compose(composeFile, [
...profiles.flatMap((p) => ["--profile", p]),
"up",
"-d",
]);
if (up.status !== 0) {
throw new Error(`[certify-parallel] docker compose up exited ${up.status}`);
}
// Poll until every cpu lane container exits (and, at full tier, the GPU
// queue drains — pipelining means the queue usually empties moments after
// the last capture lane finishes).
const deadline = Date.now() + timeoutMin * 60_000;
const lanes = {};
let timedOut = false;
for (;;) {
const ps = composePs(composeFile, profiles);
const byService = new Map(ps.map((entry) => [entry.Service, entry]));
let allExited = true;
for (const service of laneServices) {
const entry = byService.get(service.name);
if (entry?.State !== "exited") {
allExited = false;
continue;
}
if (lanes[service.lane]) continue;
const inspected = inspectContainer(entry.ID);
const started = new Date(inspected.State.StartedAt);
const finished = new Date(inspected.State.FinishedAt);
lanes[service.lane] = {
service: service.name,
exitCode: inspected.State.ExitCode,
startedAt: started.toISOString(),
finishedAt: finished.toISOString(),
durationMs: Math.max(0, finished.getTime() - started.getTime()),
};
log(
`lane ${service.lane}: exit ${inspected.State.ExitCode} in ${lanes[service.lane].durationMs}ms`,
);
}
const drained = tier === "full" ? await queueDrained(queueDir) : true;
if (allExited && drained) break;
if (Date.now() > deadline) {
timedOut = true;
break;
}
await new Promise((resolve) => setTimeout(resolve, POLL_MS));
}
const finishedAt = new Date();
if (flags["keep-up"] !== true) {
compose(composeFile, [
...profiles.flatMap((p) => ["--profile", p]),
"down",
"--remove-orphans",
]);
}
const report = buildTimingsReport({
tier,
params,
startedAt,
finishedAt,
lanes,
timedOut,
});
await fs.writeFile(outPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
log(`timings written to ${outPath}`);
if (timedOut) {
const missing = laneServices
.filter((s) => !lanes[s.lane])
.map((s) => s.lane);
process.stderr.write(
`[certify-parallel] TIMEOUT after ${timeoutMin}min — lanes still running: ${missing.join(", ") || "(none; queue not drained)"}\n`,
);
process.exit(EXIT_TIMEOUT);
}
const failed = Object.entries(lanes).filter(
([, record]) => record.exitCode !== 0,
);
if (failed.length > 0) {
process.stderr.write(
`[certify-parallel] ${failed.length} lane(s) failed: ${failed
.map(([lane, record]) => `${lane} (exit ${record.exitCode})`)
.join(", ")}\n`,
);
process.exit(EXIT_LANE_FAILED);
}
log(`all ${laneServices.length} lanes passed in ${report.wallClockMs}ms`);
}
if (
process.argv[1] &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
) {
main().catch((err) => {
process.stderr.write(`${err.stack || err.message}\n`);
process.exit(1);
});
}