From 9afd09a64a026136f9de9917a6cf820f65315339 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Wed, 22 Jul 2026 15:25:09 -0700 Subject: [PATCH 1/8] ci(e2e): record runner comparison telemetry Signed-off-by: Apurv Kumaria --- .github/workflows/e2e.yaml | 48 +++ tools/e2e/private-file.mts | 20 ++ tools/e2e/runner-comparison-core.mts | 429 +++++++++++++++++++++++++++ tools/e2e/runner-comparison.mts | 108 +++++++ 4 files changed, 605 insertions(+) create mode 100644 tools/e2e/runner-comparison-core.mts create mode 100644 tools/e2e/runner-comparison.mts diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 44f29301f0..f8de659380 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -512,6 +512,12 @@ jobs: - name: Prepare E2E workspace uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@50281ee84c4a6fc759da95ea28fc0b7d9c378a28 + - name: Initialize runner comparison telemetry + if: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && inputs.checkout_sha == '' && (matrix.agent == 'hermes' || matrix.agent == 'deepagents') }} + continue-on-error: true + shell: bash + run: npx tsx tools/e2e/runner-comparison.mts initialize + - name: Install and verify cloudflared prerequisite # Update posture: maintainers review upstream cloudflared releases and # update the version and reviewed SHA256 together in both explicit MCP @@ -582,6 +588,12 @@ jobs: --silent=false --reporter=default --reporter=test/e2e/risk-signal-reporter.ts fi + - name: Finalize runner comparison telemetry + if: ${{ always() && github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && inputs.checkout_sha == '' && (matrix.agent == 'hermes' || matrix.agent == 'deepagents') }} + continue-on-error: true + shell: bash + run: npx tsx tools/e2e/runner-comparison.mts finalize + - id: mcp_artifact_secret_scan name: Scan MCP artifacts for fixture credentials if: always() @@ -2067,6 +2079,12 @@ jobs: - name: Prepare E2E workspace uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@50281ee84c4a6fc759da95ea28fc0b7d9c378a28 + - name: Initialize runner comparison telemetry + if: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && inputs.checkout_sha == '' }} + continue-on-error: true + shell: bash + run: npx tsx tools/e2e/runner-comparison.mts initialize + - name: Install OpenShell # Direct Vitest execution uses bin/nemoclaw.js instead of install.sh, # so install OpenShell explicitly before onboard and SSH-agent probes. @@ -2100,6 +2118,12 @@ jobs: "$OPENSHELL_BIN" --version npx tsx tools/e2e/live-vitest-invocation.mts run --test-path test/e2e/live/common-egress-agent.test.ts + - name: Finalize runner comparison telemetry + if: ${{ always() && github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && inputs.checkout_sha == '' }} + continue-on-error: true + shell: bash + run: npx tsx tools/e2e/runner-comparison.mts finalize + - name: Upload common-egress agent artifacts if: always() uses: NVIDIA/NemoClaw/.github/actions/upload-e2e-artifacts@7768e15eb90d3ee2d33432f481dfe8747e4f6d57 @@ -2295,6 +2319,12 @@ jobs: - name: Prepare E2E workspace uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@50281ee84c4a6fc759da95ea28fc0b7d9c378a28 + - name: Initialize runner comparison telemetry + if: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && inputs.checkout_sha == '' }} + continue-on-error: true + shell: bash + run: npx tsx tools/e2e/runner-comparison.mts initialize + - name: Install OpenShell # Full NemoClaw installer coverage remains in hermes-e2e. Rebuild uses # the checked-out CLI and installs only its external runtime dependency. @@ -2354,6 +2384,12 @@ jobs: fi exit "$test_status" + - name: Finalize runner comparison telemetry + if: ${{ always() && github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && inputs.checkout_sha == '' }} + continue-on-error: true + shell: bash + run: npx tsx tools/e2e/runner-comparison.mts finalize + - name: Upload Hermes rebuild artifacts if: always() uses: NVIDIA/NemoClaw/.github/actions/upload-e2e-artifacts@7768e15eb90d3ee2d33432f481dfe8747e4f6d57 @@ -2397,6 +2433,12 @@ jobs: - name: Prepare E2E workspace uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@50281ee84c4a6fc759da95ea28fc0b7d9c378a28 + - name: Initialize runner comparison telemetry + if: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && inputs.checkout_sha == '' }} + continue-on-error: true + shell: bash + run: npx tsx tools/e2e/runner-comparison.mts initialize + - name: Install OpenShell # Full NemoClaw installer coverage remains in hermes-e2e. Rebuild uses # the checked-out CLI and installs only its external runtime dependency. @@ -2456,6 +2498,12 @@ jobs: fi exit "$test_status" + - name: Finalize runner comparison telemetry + if: ${{ always() && github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && inputs.checkout_sha == '' }} + continue-on-error: true + shell: bash + run: npx tsx tools/e2e/runner-comparison.mts finalize + - name: Upload Hermes stale-base rebuild artifacts if: always() uses: NVIDIA/NemoClaw/.github/actions/upload-e2e-artifacts@7768e15eb90d3ee2d33432f481dfe8747e4f6d57 diff --git a/tools/e2e/private-file.mts b/tools/e2e/private-file.mts index 1ff9a0371c..18e6230318 100644 --- a/tools/e2e/private-file.mts +++ b/tools/e2e/private-file.mts @@ -19,6 +19,26 @@ function openPrivateFileForWrite(file: string): number { } } +/** Create a private regular file without replacing any existing path. */ +export function createPrivateRegularFile(file: string, contents: string): void { + const descriptor = fs.openSync( + file, + fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | NO_FOLLOW | NON_BLOCK, + 0o600, + ); + try { + const stat = fs.fstatSync(descriptor); + if (!stat.isFile() || stat.nlink !== 1) { + throw new Error(`${file} must be a private regular file`); + } + fs.fchmodSync(descriptor, 0o600); + fs.writeFileSync(descriptor, contents, "utf8"); + fs.fsyncSync(descriptor); + } finally { + fs.closeSync(descriptor); + } +} + export function readPrivateRegularFile( file: string, options: { allowMissing?: boolean; maxBytes: number }, diff --git a/tools/e2e/runner-comparison-core.mts b/tools/e2e/runner-comparison-core.mts new file mode 100644 index 0000000000..6b6517a3d1 --- /dev/null +++ b/tools/e2e/runner-comparison-core.mts @@ -0,0 +1,429 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; + +export const RUNNER_COMPARISON_LEDGER_FILE = "runner-comparison.jsonl"; +export const RUNNER_COMPARISON_SUMMARY_FILE = "runner-comparison-summary.json"; +export const RUNNER_COMPARISON_LEDGER_MAX_BYTES = 8192; +export const RUNNER_COMPARISON_SUMMARY_MAX_BYTES = 8192; + +const SAMPLE_LINE_MAX_BYTES = 4096; +const LABEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u; +const CANONICAL_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u; + +export interface RunnerComparisonIdentity { + target: string; + shard: string | null; +} + +export interface RunnerComparisonSample extends RunnerComparisonIdentity { + v: 1; + at: string; + cpu: { + logicalCpuCount: number; + idleTicks: number; + totalTicks: number; + } | null; + memory: { + totalKb: number | null; + availableKb: number | null; + rootCgroupPeakBytes: number | null; + }; + workspace: { + totalBytes: number | null; + freeBytes: number | null; + }; +} + +export interface RunnerComparisonSummary extends RunnerComparisonIdentity { + v: 1; + startedAt: string; + finishedAt: string; + durationMs: number; + sampleCount: number; + cpu: { + logicalCpuCount: number | null; + averageBusyPercent: number | null; + averageBusyLogicalCpus: number | null; + }; + memory: { + totalKb: number | null; + startAvailableKb: number | null; + endAvailableKb: number | null; + maximumEndpointUsedKb: number | null; + rootCgroupPeakBytes: number | null; + }; + workspace: { + totalBytes: number | null; + startFreeBytes: number | null; + endFreeBytes: number | null; + netGrowthBytes: number | null; + minimumEndpointFreeBytes: number | null; + }; +} + +type UnknownRecord = Record; + +function record(value: unknown, field: string): UnknownRecord { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`${field} must be an object`); + } + return value as UnknownRecord; +} + +function exactKeys(value: UnknownRecord, expected: readonly string[], field: string): void { + const actual = Object.keys(value).sort(); + const wanted = [...expected].sort(); + if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) { + throw new Error(`${field} has an unsupported shape`); + } +} + +function nonNegativeInteger(value: unknown, field: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) { + throw new Error(`${field} must be a non-negative safe integer`); + } + return value; +} + +function nullableNonNegativeInteger(value: unknown, field: string): number | null { + return value === null ? null : nonNegativeInteger(value, field); +} + +function identityLabel(value: unknown, field: string): string { + if (typeof value !== "string" || !LABEL_PATTERN.test(value)) { + throw new Error(`${field} must be a bounded alphanumeric label`); + } + return value; +} + +function canonicalTimestamp(value: unknown): string { + if (typeof value !== "string" || !CANONICAL_TIMESTAMP_PATTERN.test(value)) { + throw new Error("sample.at must be a canonical UTC timestamp"); + } + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime()) || parsed.toISOString() !== value) { + throw new Error("sample.at must be a canonical UTC timestamp"); + } + return value; +} + +function parseCpu(value: unknown): RunnerComparisonSample["cpu"] { + if (value === null) return null; + const cpu = record(value, "sample.cpu"); + exactKeys(cpu, ["logicalCpuCount", "idleTicks", "totalTicks"], "sample.cpu"); + const parsed = { + logicalCpuCount: nonNegativeInteger(cpu.logicalCpuCount, "sample.cpu.logicalCpuCount"), + idleTicks: nonNegativeInteger(cpu.idleTicks, "sample.cpu.idleTicks"), + totalTicks: nonNegativeInteger(cpu.totalTicks, "sample.cpu.totalTicks"), + }; + if (parsed.logicalCpuCount < 1) throw new Error("sample.cpu.logicalCpuCount must be positive"); + if (parsed.idleTicks > parsed.totalTicks) { + throw new Error("sample.cpu.idleTicks cannot exceed totalTicks"); + } + return parsed; +} + +function parseMemory(value: unknown): RunnerComparisonSample["memory"] { + const memory = record(value, "sample.memory"); + exactKeys(memory, ["totalKb", "availableKb", "rootCgroupPeakBytes"], "sample.memory"); + const parsed = { + totalKb: nullableNonNegativeInteger(memory.totalKb, "sample.memory.totalKb"), + availableKb: nullableNonNegativeInteger(memory.availableKb, "sample.memory.availableKb"), + rootCgroupPeakBytes: nullableNonNegativeInteger( + memory.rootCgroupPeakBytes, + "sample.memory.rootCgroupPeakBytes", + ), + }; + if ( + parsed.totalKb !== null && + parsed.availableKb !== null && + parsed.availableKb > parsed.totalKb + ) { + throw new Error("sample.memory.availableKb cannot exceed totalKb"); + } + return parsed; +} + +function parseWorkspace(value: unknown): RunnerComparisonSample["workspace"] { + const workspace = record(value, "sample.workspace"); + exactKeys(workspace, ["totalBytes", "freeBytes"], "sample.workspace"); + const parsed = { + totalBytes: nullableNonNegativeInteger(workspace.totalBytes, "sample.workspace.totalBytes"), + freeBytes: nullableNonNegativeInteger(workspace.freeBytes, "sample.workspace.freeBytes"), + }; + if ( + parsed.totalBytes !== null && + parsed.freeBytes !== null && + parsed.freeBytes > parsed.totalBytes + ) { + throw new Error("sample.workspace.freeBytes cannot exceed totalBytes"); + } + return parsed; +} + +export function parseRunnerComparisonSample(line: string): RunnerComparisonSample { + if (Buffer.byteLength(line) > SAMPLE_LINE_MAX_BYTES) { + throw new Error("runner comparison sample exceeds its size bound"); + } + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + throw new Error("runner comparison sample must be valid JSON"); + } + const sample = record(parsed, "sample"); + exactKeys(sample, ["v", "at", "target", "shard", "cpu", "memory", "workspace"], "sample"); + if (sample.v !== 1) throw new Error("sample.v must be 1"); + const target = identityLabel(sample.target, "sample.target"); + const shard = sample.shard === null ? null : identityLabel(sample.shard, "sample.shard"); + const canonical: RunnerComparisonSample = { + v: 1, + at: canonicalTimestamp(sample.at), + target, + shard, + cpu: parseCpu(sample.cpu), + memory: parseMemory(sample.memory), + workspace: parseWorkspace(sample.workspace), + }; + if (JSON.stringify(canonical) !== line) { + throw new Error("runner comparison sample must use the canonical JSON encoding"); + } + return canonical; +} + +export function renderRunnerComparisonSample(sample: RunnerComparisonSample): string { + return JSON.stringify(parseRunnerComparisonSample(JSON.stringify(sample))); +} + +function sameIdentity(left: RunnerComparisonIdentity, right: RunnerComparisonIdentity): boolean { + return left.target === right.target && left.shard === right.shard; +} + +export function parseRunnerComparisonLedger(contents: string): RunnerComparisonSample[] { + if (Buffer.byteLength(contents) > RUNNER_COMPARISON_LEDGER_MAX_BYTES) { + throw new Error("runner comparison ledger exceeds its size bound"); + } + const lines = contents.split(/\r?\n/u).filter((line) => line.length > 0); + if (lines.length < 1 || lines.length > 2) { + throw new Error("runner comparison ledger must contain one or two samples"); + } + if (contents !== `${lines.join("\n")}\n`) { + throw new Error("runner comparison ledger must use the canonical JSONL encoding"); + } + const samples = lines.map(parseRunnerComparisonSample); + for (let index = 1; index < samples.length; index += 1) { + const previous = samples[index - 1]!; + const current = samples[index]!; + if (!sameIdentity(previous, current)) { + throw new Error("runner comparison sample identity changed during the job"); + } + if (Date.parse(current.at) <= Date.parse(previous.at)) { + throw new Error("runner comparison timestamps must increase"); + } + if ( + previous.cpu !== null && + current.cpu !== null && + (previous.cpu.logicalCpuCount !== current.cpu.logicalCpuCount || + current.cpu.totalTicks < previous.cpu.totalTicks || + current.cpu.idleTicks < previous.cpu.idleTicks) + ) { + throw new Error("runner comparison CPU counters must be monotonic and use one capacity"); + } + } + return samples; +} + +function rounded(value: number, digits: number): number { + const scale = 10 ** digits; + return Math.round(value * scale) / scale; +} + +function commonValue(left: number | null, right: number | null): number | null { + return left !== null && right !== null && left === right ? left : null; +} + +function maximum(values: Array): number | null { + const present = values.filter((value): value is number => value !== null); + return present.length === 0 ? null : Math.max(...present); +} + +function minimum(values: Array): number | null { + const present = values.filter((value): value is number => value !== null); + return present.length === 0 ? null : Math.min(...present); +} + +export function summarizeRunnerComparison( + samples: readonly RunnerComparisonSample[], +): RunnerComparisonSummary { + if (samples.length < 2) throw new Error("at least two runner comparison samples are required"); + const start = samples[0]!; + const finish = samples[samples.length - 1]!; + if (!sameIdentity(start, finish)) { + throw new Error("runner comparison sample identity changed during the job"); + } + const durationMs = Date.parse(finish.at) - Date.parse(start.at); + if (!Number.isSafeInteger(durationMs) || durationMs <= 0) { + throw new Error("runner comparison duration must be positive"); + } + + let logicalCpuCount: number | null = null; + let averageBusyPercent: number | null = null; + let averageBusyLogicalCpus: number | null = null; + if (start.cpu !== null && finish.cpu !== null) { + const totalDelta = finish.cpu.totalTicks - start.cpu.totalTicks; + const idleDelta = finish.cpu.idleTicks - start.cpu.idleTicks; + if ( + start.cpu.logicalCpuCount === finish.cpu.logicalCpuCount && + totalDelta > 0 && + idleDelta >= 0 && + idleDelta <= totalDelta + ) { + logicalCpuCount = start.cpu.logicalCpuCount; + const busyFraction = (totalDelta - idleDelta) / totalDelta; + averageBusyPercent = rounded(busyFraction * 100, 2); + averageBusyLogicalCpus = rounded(busyFraction * logicalCpuCount, 3); + } + } + + const memoryTotalKb = commonValue(start.memory.totalKb, finish.memory.totalKb); + const endpointMemoryUsed = [start.memory.availableKb, finish.memory.availableKb].map( + (available) => + memoryTotalKb !== null && available !== null ? memoryTotalKb - available : null, + ); + const workspaceTotalBytes = commonValue(start.workspace.totalBytes, finish.workspace.totalBytes); + const netGrowthBytes = + start.workspace.freeBytes !== null && finish.workspace.freeBytes !== null + ? start.workspace.freeBytes - finish.workspace.freeBytes + : null; + + return { + v: 1, + target: start.target, + shard: start.shard, + startedAt: start.at, + finishedAt: finish.at, + durationMs, + sampleCount: samples.length, + cpu: { logicalCpuCount, averageBusyPercent, averageBusyLogicalCpus }, + memory: { + totalKb: memoryTotalKb, + startAvailableKb: start.memory.availableKb, + endAvailableKb: finish.memory.availableKb, + maximumEndpointUsedKb: maximum(endpointMemoryUsed), + rootCgroupPeakBytes: maximum([ + start.memory.rootCgroupPeakBytes, + finish.memory.rootCgroupPeakBytes, + ]), + }, + workspace: { + totalBytes: workspaceTotalBytes, + startFreeBytes: start.workspace.freeBytes, + endFreeBytes: finish.workspace.freeBytes, + netGrowthBytes, + minimumEndpointFreeBytes: minimum([start.workspace.freeBytes, finish.workspace.freeBytes]), + }, + }; +} + +export function renderRunnerComparisonSummary(summary: RunnerComparisonSummary): string { + const serialized = JSON.stringify(summary, null, 2); + if (Buffer.byteLength(serialized) > RUNNER_COMPARISON_SUMMARY_MAX_BYTES) { + throw new Error("runner comparison summary exceeds its size bound"); + } + return `${serialized}\n`; +} + +export function parseCpuStat(text: string): RunnerComparisonSample["cpu"] { + const lines = text.split("\n"); + const aggregate = lines.find((line) => /^cpu\s+/u.test(line)); + const logicalCpuCount = lines.filter((line) => /^cpu\d+\s+/u.test(line)).length; + if (!aggregate || logicalCpuCount < 1) return null; + const values = aggregate.trim().split(/\s+/u).slice(1, 9); + if (values.length !== 8 || values.some((value) => !/^\d+$/u.test(value))) return null; + const counters = values.map(Number); + if (counters.some((value) => !Number.isSafeInteger(value))) return null; + const idleTicks = counters[3]! + counters[4]!; + const totalTicks = counters.reduce((sum, value) => sum + value, 0); + if (!Number.isSafeInteger(idleTicks) || !Number.isSafeInteger(totalTicks)) return null; + return { logicalCpuCount, idleTicks, totalTicks }; +} + +export function parseComparisonMeminfo(text: string): { + totalKb: number | null; + availableKb: number | null; +} { + const read = (name: string): number | null => { + const match = new RegExp(`^${name}:\\s+(\\d+)\\s+kB\\s*$`, "mu").exec(text); + if (!match) return null; + const value = Number(match[1]); + return Number.isSafeInteger(value) ? value : null; + }; + return { totalKb: read("MemTotal"), availableKb: read("MemAvailable") }; +} + +function parseScalar(text: string | null): number | null { + if (text === null || !/^\d+\s*$/u.test(text)) return null; + const value = Number(text.trim()); + return Number.isSafeInteger(value) ? value : null; +} + +function checkedProduct(left: number | bigint, right: number | bigint): number | null { + const value = Number(left) * Number(right); + return Number.isSafeInteger(value) && value >= 0 ? value : null; +} + +export interface RunnerComparisonSources { + now: () => Date; + readText: (file: string) => string | null; + statfs: (directory: string) => { + bavail: number | bigint; + blocks: number | bigint; + bsize: number | bigint; + }; +} + +const defaultSources: RunnerComparisonSources = { + now: () => new Date(), + readText: (file) => { + try { + return fs.readFileSync(file, "utf8"); + } catch { + return null; + } + }, + statfs: (directory) => fs.statfsSync(directory), +}; + +export function collectRunnerComparisonSample( + identity: RunnerComparisonIdentity, + workspace = process.cwd(), + sources: RunnerComparisonSources = defaultSources, +): RunnerComparisonSample { + const target = identityLabel(identity.target, "sample.target"); + const shard = identity.shard === null ? null : identityLabel(identity.shard, "sample.shard"); + const meminfo = parseComparisonMeminfo(sources.readText("/proc/meminfo") ?? ""); + let totalBytes: number | null = null; + let freeBytes: number | null = null; + try { + const stat = sources.statfs(workspace); + totalBytes = checkedProduct(stat.blocks, stat.bsize); + freeBytes = checkedProduct(stat.bavail, stat.bsize); + } catch { + // Missing workspace telemetry is represented by null fields. + } + return { + v: 1, + at: canonicalTimestamp(sources.now().toISOString()), + target, + shard, + cpu: parseCpuStat(sources.readText("/proc/stat") ?? ""), + memory: { + totalKb: meminfo.totalKb, + availableKb: meminfo.availableKb, + rootCgroupPeakBytes: parseScalar(sources.readText("/sys/fs/cgroup/memory.peak")), + }, + workspace: { totalBytes, freeBytes }, + }; +} diff --git a/tools/e2e/runner-comparison.mts b/tools/e2e/runner-comparison.mts new file mode 100644 index 0000000000..3961847a83 --- /dev/null +++ b/tools/e2e/runner-comparison.mts @@ -0,0 +1,108 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { + appendPrivateRegularFile, + createPrivateRegularFile, + readPrivateRegularFile, +} from "./private-file.mts"; +import { + collectRunnerComparisonSample, + parseRunnerComparisonLedger, + RUNNER_COMPARISON_LEDGER_FILE, + RUNNER_COMPARISON_LEDGER_MAX_BYTES, + RUNNER_COMPARISON_SUMMARY_FILE, + renderRunnerComparisonSample, + renderRunnerComparisonSummary, + summarizeRunnerComparison, +} from "./runner-comparison-core.mts"; + +const WORKSPACE_ROOT = path.resolve(process.cwd()); + +function requiredEnvironment(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`${name} is required`); + return value; +} + +function artifactDirectory(): string { + const resolved = path.resolve(WORKSPACE_ROOT, requiredEnvironment("E2E_ARTIFACT_DIR")); + if (resolved !== WORKSPACE_ROOT && !resolved.startsWith(`${WORKSPACE_ROOT}${path.sep}`)) { + throw new Error("E2E_ARTIFACT_DIR must stay inside the checked-out workspace"); + } + return resolved; +} + +function comparisonIdentity(): { target: string; shard: string | null } { + return { + target: requiredEnvironment("E2E_TARGET_ID"), + shard: process.env.NEMOCLAW_E2E_SHARD || null, + }; +} + +function comparisonPaths(): { ledger: string; summary: string; directory: string } { + const directory = artifactDirectory(); + return { + directory, + ledger: path.join(directory, RUNNER_COMPARISON_LEDGER_FILE), + summary: path.join(directory, RUNNER_COMPARISON_SUMMARY_FILE), + }; +} + +export function initializeRunnerComparison(): void { + const paths = comparisonPaths(); + fs.mkdirSync(paths.directory, { recursive: true, mode: 0o700 }); + const sample = collectRunnerComparisonSample(comparisonIdentity(), WORKSPACE_ROOT); + createPrivateRegularFile(paths.ledger, `${renderRunnerComparisonSample(sample)}\n`); + console.log(`Initialized runner comparison telemetry for ${sample.target}`); +} + +export function finalizeRunnerComparison(): void { + const paths = comparisonPaths(); + const contents = readPrivateRegularFile(paths.ledger, { + maxBytes: RUNNER_COMPARISON_LEDGER_MAX_BYTES, + }); + if (contents === null) throw new Error("runner comparison ledger could not be read"); + const initialSamples = parseRunnerComparisonLedger(contents); + if (initialSamples.length !== 1) { + throw new Error("runner comparison finalization requires exactly one initial sample"); + } + const finalSample = collectRunnerComparisonSample(comparisonIdentity(), WORKSPACE_ROOT); + const finalLine = `${renderRunnerComparisonSample(finalSample)}\n`; + const samples = parseRunnerComparisonLedger(`${contents}${finalLine}`); + const summary = summarizeRunnerComparison(samples); + appendPrivateRegularFile(paths.ledger, finalLine, { + maxBytes: RUNNER_COMPARISON_LEDGER_MAX_BYTES, + }); + createPrivateRegularFile(paths.summary, renderRunnerComparisonSummary(summary)); + console.log( + `Finalized runner comparison telemetry for ${summary.target} (${summary.durationMs} ms)`, + ); +} + +function main(): number { + const [mode] = process.argv.slice(2); + if (mode === "initialize") { + initializeRunnerComparison(); + return 0; + } + if (mode === "finalize") { + finalizeRunnerComparison(); + return 0; + } + console.error("usage: runner-comparison.mts "); + return 2; +} + +const invoked = process.argv[1]; +if (invoked && import.meta.url === pathToFileURL(invoked).href) { + try { + process.exitCode = main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} From 1c924c6d331b34c6d1034bda27e86f55e506cf25 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Wed, 22 Jul 2026 15:25:45 -0700 Subject: [PATCH 2/8] test(e2e): lock runner comparison telemetry Signed-off-by: Apurv Kumaria --- ...unner-comparison-workflow-boundary.test.ts | 181 ++++++++++ test/e2e/support/runner-comparison.test.ts | 331 ++++++++++++++++++ .../runner-comparison-workflow-boundary.mts | 159 +++++++++ tools/e2e/workflow-boundary.mts | 3 +- 4 files changed, 673 insertions(+), 1 deletion(-) create mode 100644 test/e2e/support/runner-comparison-workflow-boundary.test.ts create mode 100644 test/e2e/support/runner-comparison.test.ts create mode 100644 tools/e2e/runner-comparison-workflow-boundary.mts diff --git a/test/e2e/support/runner-comparison-workflow-boundary.test.ts b/test/e2e/support/runner-comparison-workflow-boundary.test.ts new file mode 100644 index 0000000000..dd62e4abb6 --- /dev/null +++ b/test/e2e/support/runner-comparison-workflow-boundary.test.ts @@ -0,0 +1,181 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { + RUNNER_COMPARISON_COMMAND, + RUNNER_COMPARISON_FINALIZE_STEP, + RUNNER_COMPARISON_INITIALIZE_STEP, + validateRunnerComparisonWorkflow, + validateRunnerComparisonWorkflowBoundary, +} from "../../../tools/e2e/runner-comparison-workflow-boundary.mts"; +import { readWorkflow } from "../../helpers/e2e-workflow-contract"; + +type WorkflowStep = Record & { + "continue-on-error"?: boolean; + if?: string; + name?: string; + run?: string; + shell?: string; + uses?: string; +}; +type Workflow = { + jobs: Record; +}; + +const JOBS = [ + "common-egress-agent", + "rebuild-hermes", + "rebuild-hermes-stale-base", + "mcp-bridge", +] as const; + +function loadWorkflow(): Workflow { + return structuredClone(readWorkflow()) as Workflow; +} + +function step(workflow: Workflow, jobId: string, name: string): WorkflowStep { + const found = workflow.jobs[jobId]!.steps.find((candidate) => candidate.name === name); + if (!found) throw new Error(`${jobId} is missing ${name}`); + return found; +} + +function telemetrySteps(workflow: Workflow, jobId: string): WorkflowStep[] { + return workflow.jobs[jobId]!.steps.filter((candidate) => + candidate.run?.includes("tools/e2e/runner-comparison.mts"), + ); +} + +describe("runner comparison E2E workflow boundary (#7145)", () => { + it("accepts the exact five-execution comparison wiring", () => { + const workflow = loadWorkflow(); + + expect(validateRunnerComparisonWorkflowBoundary(workflow)).toEqual([]); + expect(JOBS.flatMap((jobId) => telemetrySteps(workflow, jobId))).toHaveLength(8); + expect( + 3 + + workflow.jobs["mcp-bridge"]!.strategy!.matrix!.agent!.filter((agent) => + ["hermes", "deepagents"].includes(String(agent)), + ).length, + ).toBe(5); + }); + + it("requires both measured MCP matrix entries exactly once", () => { + const workflow = loadWorkflow(); + workflow.jobs["mcp-bridge"]!.strategy!.matrix!.agent = ["openclaw", "hermes", "hermes"]; + + expect(validateRunnerComparisonWorkflow(workflow)).toEqual( + expect.arrayContaining([ + "mcp-bridge matrix must contain hermes exactly once for runner comparison telemetry", + "mcp-bridge matrix must contain deepagents exactly once for runner comparison telemetry", + ]), + ); + }); + + it("rejects runner comparison consumers outside the four comparison jobs", () => { + const workflow = loadWorkflow(); + workflow.jobs["shields-config"]!.steps.push( + structuredClone(telemetrySteps(workflow, "common-egress-agent")[0]!), + ); + + expect(validateRunnerComparisonWorkflow(workflow)).toContain( + "shields-config must not collect runner comparison telemetry", + ); + }); + + it.each(JOBS)("requires exactly one initialize and one finalize step in %s", (jobId) => { + const missing = loadWorkflow(); + missing.jobs[jobId]!.steps = missing.jobs[jobId]!.steps.filter( + (candidate) => candidate.name !== RUNNER_COMPARISON_FINALIZE_STEP, + ); + expect(validateRunnerComparisonWorkflow(missing)).toContain( + `${jobId} must invoke runner comparison telemetry exactly twice`, + ); + + const duplicated = loadWorkflow(); + duplicated.jobs[jobId]!.steps.push( + structuredClone(step(duplicated, jobId, RUNNER_COMPARISON_INITIALIZE_STEP)), + ); + expect(validateRunnerComparisonWorkflow(duplicated)).toContain( + `${jobId} must invoke runner comparison telemetry exactly twice`, + ); + }); + + it.each(JOBS)("keeps %s telemetry around the entire post-prepare job", (jobId) => { + const lateInitialize = loadWorkflow(); + const lateSteps = lateInitialize.jobs[jobId]!.steps; + const initializeIndex = lateSteps.indexOf( + step(lateInitialize, jobId, RUNNER_COMPARISON_INITIALIZE_STEP), + ); + [lateSteps[initializeIndex], lateSteps[initializeIndex + 1]] = [ + lateSteps[initializeIndex + 1]!, + lateSteps[initializeIndex]!, + ]; + expect(validateRunnerComparisonWorkflow(lateInitialize)).toContain( + `${jobId} must initialize runner comparison telemetry immediately after prepare-e2e`, + ); + + const afterPublication = loadWorkflow(); + const publicationSteps = afterPublication.jobs[jobId]!.steps; + const finalizeIndex = publicationSteps.indexOf( + step(afterPublication, jobId, RUNNER_COMPARISON_FINALIZE_STEP), + ); + [publicationSteps[finalizeIndex], publicationSteps[finalizeIndex + 1]] = [ + publicationSteps[finalizeIndex + 1]!, + publicationSteps[finalizeIndex]!, + ]; + expect(validateRunnerComparisonWorkflow(afterPublication)).toContain( + `${jobId} must finalize runner comparison telemetry immediately before artifact scanning or upload`, + ); + }); + + it("rejects weakened trusted-main and always-run guards", () => { + const workflow = loadWorkflow(); + step(workflow, "common-egress-agent", RUNNER_COMPARISON_INITIALIZE_STEP).if = + "${{ github.repository == 'NVIDIA/NemoClaw' }}"; + step(workflow, "rebuild-hermes", RUNNER_COMPARISON_FINALIZE_STEP).if = + "${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && inputs.checkout_sha == '' }}"; + + expect(validateRunnerComparisonWorkflow(workflow)).toEqual( + expect.arrayContaining([ + "common-egress-agent must use the exact trusted initialize telemetry step", + "rebuild-hermes must use the exact always-run trusted finalize telemetry step", + ]), + ); + }); + + it("keeps OpenClaw out of both MCP comparison samples", () => { + const workflow = loadWorkflow(); + for (const name of [RUNNER_COMPARISON_INITIALIZE_STEP, RUNNER_COMPARISON_FINALIZE_STEP]) { + const comparison = step(workflow, "mcp-bridge", name); + comparison.if = comparison.if!.replace( + "(matrix.agent == 'hermes' || matrix.agent == 'deepagents')", + "(matrix.agent == 'openclaw' || matrix.agent == 'hermes' || matrix.agent == 'deepagents')", + ); + } + + expect(validateRunnerComparisonWorkflow(workflow)).toEqual( + expect.arrayContaining([ + "mcp-bridge must use the exact trusted initialize telemetry step", + "mcp-bridge must use the exact always-run trusted finalize telemetry step", + ]), + ); + }); + + it("rejects invocation shape, mode, and best-effort drift", () => { + const workflow = loadWorkflow(); + const initialize = step( + workflow, + "rebuild-hermes-stale-base", + RUNNER_COMPARISON_INITIALIZE_STEP, + ); + initialize["continue-on-error"] = false; + initialize.run = `${RUNNER_COMPARISON_COMMAND} finalize`; + initialize.env = { UNREVIEWED: "1" }; + + expect(validateRunnerComparisonWorkflow(workflow)).toContain( + "rebuild-hermes-stale-base must use the exact trusted initialize telemetry step", + ); + }); +}); diff --git a/test/e2e/support/runner-comparison.test.ts b/test/e2e/support/runner-comparison.test.ts new file mode 100644 index 0000000000..a83df74c97 --- /dev/null +++ b/test/e2e/support/runner-comparison.test.ts @@ -0,0 +1,331 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { createPrivateRegularFile } from "../../../tools/e2e/private-file.mts"; +import { + collectRunnerComparisonSample, + parseComparisonMeminfo, + parseCpuStat, + parseRunnerComparisonLedger, + parseRunnerComparisonSample, + RUNNER_COMPARISON_LEDGER_FILE, + RUNNER_COMPARISON_SUMMARY_FILE, + type RunnerComparisonSample, + summarizeRunnerComparison, +} from "../../../tools/e2e/runner-comparison-core.mts"; + +const REPO_ROOT = process.cwd(); +const TSX_IMPORT = path.join(REPO_ROOT, "node_modules", "tsx", "dist", "loader.mjs"); +const CLI = path.join(REPO_ROOT, "tools", "e2e", "runner-comparison.mts"); + +function sample(overrides: Partial = {}): RunnerComparisonSample { + return { + v: 1, + at: "2026-07-22T10:00:00.000Z", + target: "rebuild-hermes", + shard: "hosted", + cpu: { logicalCpuCount: 4, idleTicks: 40, totalTicks: 100 }, + memory: { totalKb: 1_000, availableKb: 800, rootCgroupPeakBytes: 1_000 }, + workspace: { totalBytes: 10_000, freeBytes: 8_000 }, + ...overrides, + }; +} + +function ledger(...samples: RunnerComparisonSample[]): string { + return `${samples.map((entry) => JSON.stringify(entry)).join("\n")}\n`; +} + +function runCli( + cwd: string, + mode: string, + environment: Record = {}, +): ReturnType { + return spawnSync(process.execPath, ["--import", TSX_IMPORT, CLI, mode], { + cwd, + encoding: "utf8", + timeout: 10_000, + env: { + ...process.env, + E2E_ARTIFACT_DIR: "artifacts", + E2E_TARGET_ID: "rebuild-hermes", + NEMOCLAW_E2E_SHARD: "hosted", + ...environment, + }, + }); +} + +function expectSuccess(result: ReturnType): void { + expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); +} + +describe("runner comparison collection", () => { + it("parses Linux CPU and memory sources without subprocesses (#7145)", () => { + expect( + parseCpuStat( + "cpu 100 20 30 400 50 6 7 8 9 10\ncpu0 1 2 3 4 5 6 7 8\ncpu1 1 2 3 4 5 6 7 8\n", + ), + ).toEqual({ logicalCpuCount: 2, idleTicks: 450, totalTicks: 621 }); + expect(parseCpuStat("cpu malformed\n")).toBeNull(); + expect(parseComparisonMeminfo("MemTotal: 16384 kB\nMemAvailable: 4096 kB\n")).toEqual({ + totalKb: 16_384, + availableKb: 4_096, + }); + expect(parseComparisonMeminfo("MemFree: 12 kB\n")).toEqual({ + totalKb: null, + availableKb: null, + }); + }); + + it("collects a bounded sample from injected proc, cgroup, clock, and filesystem sources (#7145)", () => { + const sources = { + now: () => new Date("2026-07-22T10:00:00.000Z"), + readText: (file: string) => + new Map([ + ["/proc/stat", "cpu 100 20 30 400 50 6 7 8\ncpu0 1 2 3 4 5 6 7 8\n"], + ["/proc/meminfo", "MemTotal: 8192 kB\nMemAvailable: 3072 kB\n"], + ["/sys/fs/cgroup/memory.peak", "123456\n"], + ]).get(file) ?? null, + statfs: (_directory: string) => ({ blocks: 100, bavail: 25, bsize: 4096 }), + }; + + expect( + collectRunnerComparisonSample( + { target: "rebuild-hermes", shard: "hosted" }, + "/workspace", + sources, + ), + ).toEqual({ + v: 1, + at: "2026-07-22T10:00:00.000Z", + target: "rebuild-hermes", + shard: "hosted", + cpu: { logicalCpuCount: 1, idleTicks: 450, totalTicks: 621 }, + memory: { totalKb: 8192, availableKb: 3072, rootCgroupPeakBytes: 123456 }, + workspace: { totalBytes: 409600, freeBytes: 102400 }, + }); + }); +}); + +describe("runner comparison schema", () => { + it("rejects unknown fields so secret-bearing strings cannot enter the artifact (#7145)", () => { + expect(() => + parseRunnerComparisonSample(JSON.stringify({ ...sample(), token: "ghp_do-not-record" })), + ).toThrow("unsupported shape"); + const valid = sample(); + expect(() => + parseRunnerComparisonSample( + JSON.stringify({ ...valid, memory: { ...valid.memory, command: "docker login secret" } }), + ), + ).toThrow("unsupported shape"); + }); + + it("rejects duplicate JSON keys that could hide non-canonical artifact text (#7145)", () => { + const canonical = JSON.stringify(sample()); + const duplicate = canonical.replace( + '"target":"rebuild-hermes"', + '"target":"sensitive-value","target":"rebuild-hermes"', + ); + + expect(() => parseRunnerComparisonSample(duplicate)).toThrow("canonical JSON encoding"); + }); + + it.each([ + ["non-canonical timestamp", { at: "2026-07-22T10:00:00Z" }], + ["impossible timestamp", { at: "2026-02-30T10:00:00.000Z" }], + ["negative CPU counter", { cpu: { logicalCpuCount: 4, idleTicks: -1, totalTicks: 100 } }], + ["fractional memory", { memory: { totalKb: 1.5, availableKb: 1, rootCgroupPeakBytes: 1 } }], + ["excess free disk", { workspace: { totalBytes: 10, freeBytes: 11 } }], + ])("rejects a %s (#7145)", (_label, override) => { + expect(() => parseRunnerComparisonSample(JSON.stringify(sample(override)))).toThrow(); + }); + + it("rejects ledgers with more than two samples (#7145)", () => { + const start = sample(); + const middle = sample({ at: "2026-07-22T10:01:00.000Z" }); + const finish = sample({ at: "2026-07-22T10:02:00.000Z" }); + expect(() => parseRunnerComparisonLedger(ledger(start, middle, finish))).toThrow( + "one or two samples", + ); + }); + + it("rejects non-canonical JSONL separators and empty records (#7145)", () => { + expect(() => parseRunnerComparisonLedger(`${ledger(sample())}\n`)).toThrow( + "canonical JSONL encoding", + ); + expect(() => parseRunnerComparisonLedger(ledger(sample()).replace("\n", "\r\n"))).toThrow( + "canonical JSONL encoding", + ); + }); + + it.each([ + ["target identity", { target: "hermes-e2e", at: "2026-07-22T10:01:00.000Z" }], + ["shard identity", { shard: "anthropic", at: "2026-07-22T10:01:00.000Z" }], + ["timestamp order", { at: "2026-07-22T10:00:00.000Z" }], + [ + "CPU capacity", + { + at: "2026-07-22T10:01:00.000Z", + cpu: { logicalCpuCount: 8, idleTicks: 50, totalTicks: 120 }, + }, + ], + [ + "CPU counters", + { + at: "2026-07-22T10:01:00.000Z", + cpu: { logicalCpuCount: 4, idleTicks: 39, totalTicks: 99 }, + }, + ], + ])("rejects ledger %s drift (#7145)", (_label, override) => { + expect(() => parseRunnerComparisonLedger(ledger(sample(), sample(override)))).toThrow(); + }); +}); + +describe("runner comparison summary", () => { + it("reduces post-prepare CPU, memory, and workspace deltas (#7145)", () => { + const summary = summarizeRunnerComparison([ + sample(), + sample({ + at: "2026-07-22T10:02:00.000Z", + cpu: { logicalCpuCount: 4, idleTicks: 100, totalTicks: 300 }, + memory: { totalKb: 1_000, availableKb: 600, rootCgroupPeakBytes: 1_500 }, + workspace: { totalBytes: 10_000, freeBytes: 6_500 }, + }), + ]); + + expect(summary).toMatchObject({ + durationMs: 120_000, + sampleCount: 2, + cpu: { logicalCpuCount: 4, averageBusyPercent: 70, averageBusyLogicalCpus: 2.8 }, + memory: { + totalKb: 1_000, + startAvailableKb: 800, + endAvailableKb: 600, + maximumEndpointUsedKb: 400, + rootCgroupPeakBytes: 1_500, + }, + workspace: { + totalBytes: 10_000, + startFreeBytes: 8_000, + endFreeBytes: 6_500, + netGrowthBytes: 1_500, + minimumEndpointFreeBytes: 6_500, + }, + }); + }); + + it("uses explicit nulls when comparison inputs are unavailable or inconsistent (#7145)", () => { + const missing = { + cpu: null, + memory: { totalKb: null, availableKb: null, rootCgroupPeakBytes: null }, + workspace: { totalBytes: null, freeBytes: null }, + } as const; + const summary = summarizeRunnerComparison([ + sample(missing), + sample({ ...missing, at: "2026-07-22T10:01:00.000Z" }), + ]); + + expect(summary.cpu).toEqual({ + logicalCpuCount: null, + averageBusyPercent: null, + averageBusyLogicalCpus: null, + }); + expect(summary.memory).toMatchObject({ totalKb: null, maximumEndpointUsedKb: null }); + expect(summary.workspace).toMatchObject({ totalBytes: null, netGrowthBytes: null }); + }); +}); + +describe("runner comparison private artifacts", () => { + it("creates a new private regular file and refuses every existing path (#7145)", () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-private-create-")); + try { + const file = path.join(directory, "sample.jsonl"); + createPrivateRegularFile(file, "first\n"); + expect(fs.readFileSync(file, "utf8")).toBe("first\n"); + expect(fs.statSync(file).mode & 0o777).toBe(0o600); + expect(() => createPrivateRegularFile(file, "replacement\n")).toThrow(); + + const target = path.join(directory, "target"); + fs.writeFileSync(target, "target\n"); + const symbolic = path.join(directory, "symbolic"); + fs.symlinkSync(target, symbolic); + expect(() => createPrivateRegularFile(symbolic, "replacement\n")).toThrow(); + const hard = path.join(directory, "hard"); + fs.linkSync(target, hard); + expect(() => createPrivateRegularFile(hard, "replacement\n")).toThrow(); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); + + it("initializes and finalizes exactly two mode-0600 samples plus a summary (#7145)", () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-runner-comparison-")); + try { + expectSuccess(runCli(directory, "initialize")); + expectSuccess(runCli(directory, "finalize")); + const artifacts = path.join(directory, "artifacts"); + const ledgerPath = path.join(artifacts, RUNNER_COMPARISON_LEDGER_FILE); + const summaryPath = path.join(artifacts, RUNNER_COMPARISON_SUMMARY_FILE); + const samples = parseRunnerComparisonLedger(fs.readFileSync(ledgerPath, "utf8")); + const summary = JSON.parse(fs.readFileSync(summaryPath, "utf8")); + + expect(samples).toHaveLength(2); + expect(summary).toMatchObject({ + v: 1, + target: "rebuild-hermes", + shard: "hosted", + sampleCount: 2, + }); + expect(fs.statSync(ledgerPath).mode & 0o777).toBe(0o600); + expect(fs.statSync(summaryPath).mode & 0o777).toBe(0o600); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); + + it("rejects duplicate initialization (#7145)", () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-runner-duplicate-")); + try { + expectSuccess(runCli(directory, "initialize")); + expect(runCli(directory, "initialize").status).not.toBe(0); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); + + it.each(["symlink", "hardlink"])("rejects a %s ledger replacement (#7145)", (kind) => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-runner-link-")); + try { + expectSuccess(runCli(directory, "initialize")); + const ledgerPath = path.join(directory, "artifacts", RUNNER_COMPARISON_LEDGER_FILE); + const replacement = path.join(directory, "replacement"); + if (kind === "symlink") { + fs.writeFileSync(replacement, fs.readFileSync(ledgerPath)); + fs.unlinkSync(ledgerPath); + fs.symlinkSync(replacement, ledgerPath); + } else { + fs.linkSync(ledgerPath, replacement); + } + expect(runCli(directory, "finalize").status).not.toBe(0); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); + + it("returns a nonzero status for an unsupported CLI mode (#7145)", () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-runner-mode-")); + try { + const result = runCli(directory, "sample-continuously"); + expect(result.status).toBe(2); + expect(result.stderr).toContain(""); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/tools/e2e/runner-comparison-workflow-boundary.mts b/tools/e2e/runner-comparison-workflow-boundary.mts new file mode 100644 index 0000000000..695cf6b747 --- /dev/null +++ b/tools/e2e/runner-comparison-workflow-boundary.mts @@ -0,0 +1,159 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { isDeepStrictEqual } from "node:util"; +import { PREPARE_E2E_STEP } from "./prepare-e2e-workflow-boundary.mts"; +import { UPLOAD_E2E_ARTIFACTS_ACTION } from "./upload-e2e-artifacts-workflow-boundary.mts"; + +export const RUNNER_COMPARISON_INITIALIZE_STEP = "Initialize runner comparison telemetry"; +export const RUNNER_COMPARISON_FINALIZE_STEP = "Finalize runner comparison telemetry"; +export const RUNNER_COMPARISON_COMMAND = "npx tsx tools/e2e/runner-comparison.mts"; + +const TRUSTED_MAIN_GUARD = + "github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && inputs.checkout_sha == ''"; +const MCP_AGENT_GUARD = "(matrix.agent == 'hermes' || matrix.agent == 'deepagents')"; +const ORDINARY_INITIALIZE_GUARD = `\${{ ${TRUSTED_MAIN_GUARD} }}`; +const ORDINARY_FINALIZE_GUARD = `\${{ always() && ${TRUSTED_MAIN_GUARD} }}`; +const MCP_INITIALIZE_GUARD = `\${{ ${TRUSTED_MAIN_GUARD} && ${MCP_AGENT_GUARD} }}`; +const MCP_FINALIZE_GUARD = `\${{ always() && ${TRUSTED_MAIN_GUARD} && ${MCP_AGENT_GUARD} }}`; + +const COMPARISON_JOBS: ReadonlyMap = new Map([ + [ + "common-egress-agent", + { initializeIf: ORDINARY_INITIALIZE_GUARD, finalizeIf: ORDINARY_FINALIZE_GUARD }, + ], + [ + "rebuild-hermes", + { initializeIf: ORDINARY_INITIALIZE_GUARD, finalizeIf: ORDINARY_FINALIZE_GUARD }, + ], + [ + "rebuild-hermes-stale-base", + { initializeIf: ORDINARY_INITIALIZE_GUARD, finalizeIf: ORDINARY_FINALIZE_GUARD }, + ], + ["mcp-bridge", { initializeIf: MCP_INITIALIZE_GUARD, finalizeIf: MCP_FINALIZE_GUARD }], +]); + +type WorkflowRecord = Record; +type WorkflowStep = WorkflowRecord & { + "continue-on-error"?: boolean; + if?: string; + name?: string; + run?: string; + shell?: string; + uses?: string; +}; + +function record(value: unknown): WorkflowRecord { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as WorkflowRecord) + : {}; +} + +function steps(value: unknown): WorkflowStep[] { + return Array.isArray(value) ? (value as WorkflowStep[]) : []; +} + +function exactStep( + step: WorkflowStep, + name: string, + condition: string, + mode: "initialize" | "finalize", +): boolean { + return isDeepStrictEqual(step, { + name, + if: condition, + "continue-on-error": true, + shell: "bash", + run: `${RUNNER_COMPARISON_COMMAND} ${mode}`, + }); +} + +function isRunnerComparisonConsumer(step: WorkflowStep): boolean { + return typeof step.run === "string" && step.run.includes("tools/e2e/runner-comparison.mts"); +} + +function publicationIndex(jobSteps: readonly WorkflowStep[]): number { + return jobSteps.findIndex( + (step) => + step.uses === UPLOAD_E2E_ARTIFACTS_ACTION || + step.name === "Scan MCP artifacts for fixture credentials", + ); +} + +/** + * Keep the #7145 comparison to five trusted-main executions: three standalone + * jobs plus the Hermes and Deep Agents MCP matrix entries. Telemetry is + * best-effort, but it must span the complete job and finish before evidence is + * scanned or uploaded. + */ +export function validateRunnerComparisonWorkflow(workflowValue: unknown): string[] { + const jobs = record(record(workflowValue).jobs); + const errors: string[] = []; + + const mcpMatrix = record(record(record(jobs["mcp-bridge"]).strategy).matrix); + const mcpAgents = Array.isArray(mcpMatrix.agent) ? mcpMatrix.agent : []; + for (const expectedAgent of ["hermes", "deepagents"]) { + if (mcpAgents.filter((agent) => agent === expectedAgent).length !== 1) { + errors.push( + `mcp-bridge matrix must contain ${expectedAgent} exactly once for runner comparison telemetry`, + ); + } + } + + for (const [jobId, value] of Object.entries(jobs)) { + const jobSteps = steps(record(value).steps); + const consumers = jobSteps.filter(isRunnerComparisonConsumer); + if (!COMPARISON_JOBS.has(jobId) && consumers.length > 0) { + errors.push(`${jobId} must not collect runner comparison telemetry`); + } + } + + for (const [jobId, contract] of COMPARISON_JOBS) { + const jobSteps = steps(record(jobs[jobId]).steps); + const consumers = jobSteps.filter(isRunnerComparisonConsumer); + if (consumers.length !== 2) { + errors.push(`${jobId} must invoke runner comparison telemetry exactly twice`); + continue; + } + + const initialize = consumers.find((step) => step.name === RUNNER_COMPARISON_INITIALIZE_STEP); + const finalize = consumers.find((step) => step.name === RUNNER_COMPARISON_FINALIZE_STEP); + if ( + !initialize || + !exactStep(initialize, RUNNER_COMPARISON_INITIALIZE_STEP, contract.initializeIf, "initialize") + ) { + errors.push(`${jobId} must use the exact trusted initialize telemetry step`); + } + if ( + !finalize || + !exactStep(finalize, RUNNER_COMPARISON_FINALIZE_STEP, contract.finalizeIf, "finalize") + ) { + errors.push(`${jobId} must use the exact always-run trusted finalize telemetry step`); + } + if (!initialize || !finalize) continue; + + const prepare = jobSteps.findIndex((step) => step.name === PREPARE_E2E_STEP); + const initializeIndex = jobSteps.indexOf(initialize); + const finalizeIndex = jobSteps.indexOf(finalize); + const publish = publicationIndex(jobSteps); + if (prepare < 0 || initializeIndex !== prepare + 1) { + errors.push( + `${jobId} must initialize runner comparison telemetry immediately after prepare-e2e`, + ); + } + if (publish < 0 || finalizeIndex !== publish - 1) { + errors.push( + `${jobId} must finalize runner comparison telemetry immediately before artifact scanning or upload`, + ); + } + if (initializeIndex >= finalizeIndex) { + errors.push(`${jobId} must initialize runner comparison telemetry before finalizing it`); + } + } + + return errors; +} + +export function validateRunnerComparisonWorkflowBoundary(workflowValue: unknown): string[] { + return validateRunnerComparisonWorkflow(workflowValue); +} diff --git a/tools/e2e/workflow-boundary.mts b/tools/e2e/workflow-boundary.mts index f3473ad57c..1aafd4d5c4 100644 --- a/tools/e2e/workflow-boundary.mts +++ b/tools/e2e/workflow-boundary.mts @@ -35,6 +35,7 @@ import { validateE2eOperationsWorkflow, } from "./operations-workflow-boundary.mts"; import { validatePrepareE2eWorkflowBoundary } from "./prepare-e2e-workflow-boundary.mts"; +import { validateRunnerComparisonWorkflowBoundary } from "./runner-comparison-workflow-boundary.mts"; import { validateRunnerPressureWorkflow } from "./runner-pressure-workflow-boundary.mts"; import { validateSandboxOperationsWorkflow } from "./sandbox-operations-workflow-boundary.mts"; import { validateSecurityPostureWorkflow } from "./security-posture-workflow-boundary.mts"; @@ -1683,7 +1684,6 @@ function validateRebuildHermesJob( if (asRecord(checkout?.with)["persist-credentials"] !== false) { errors.push(`${jobName} checkout step must set persist-credentials=false`); } - } function validateSandboxRebuildJob(errors: string[], jobs: WorkflowRecord): void { @@ -3895,6 +3895,7 @@ export function validateE2eWorkflow(workflowValue: unknown): string[] { errors.push(...validateE2eOperationsWorkflow(workflow as unknown as OperationsWorkflow)); errors.push(...validateSecurityPostureWorkflow(workflow)); errors.push(...validateRunnerPressureWorkflow(workflow)); + errors.push(...validateRunnerComparisonWorkflowBoundary(workflow)); const triggers = asRecord(workflow.on ?? workflow[true as unknown as string]); const workflowDispatch = requireWorkflowDispatch(errors, triggers); From 9a3752a8d49027a0a2144dd110930592e21a77e3 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Wed, 22 Jul 2026 15:26:32 -0700 Subject: [PATCH 3/8] docs(e2e): explain runner comparison evidence Signed-off-by: Apurv Kumaria --- test/e2e/README.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/test/e2e/README.md b/test/e2e/README.md index 35884af2f4..af22513540 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -69,6 +69,37 @@ graph as the live targets: `post_to_slack=true`, which uses the preview Slack route. Branch-dispatched runs never receive Slack webhook secrets. +### Runner comparison telemetry + +Trusted `main` runs without an alternate checkout SHA record runner-comparison +telemetry for the five #7145 candidate executions: + +- `common-egress-agent` +- `rebuild-hermes` +- `rebuild-hermes-stale-base` +- `mcp-bridge` with the `hermes` shard +- `mcp-bridge` with the `deepagents` shard + +Each execution writes two best-effort numeric samples to +`runner-comparison.jsonl`, one after workspace preparation and one from an +`always()` finalizer before artifact checking and upload. The finalizer writes +`runner-comparison-summary.json` only when both samples are valid, monotonic, +and have the same target and shard identity. The summary contains the sampled +post-prepare time window, average CPU utilization and logical CPU capacity, +memory availability and endpoint usage, the available root-cgroup memory peak, +and workspace free-space and growth values. Unsupported measurements are `null`. +Endpoint memory and workspace samples do not establish the true maximum or +minimum between those two observations. The root-cgroup peak is a lifetime +counter that includes Docker siblings but can also include host activity before +the measured window. Compare it only across runs with the same runner setup. + +Treat a missing summary as unavailable evidence, not as low utilization. A +hard runner loss can prevent finalization or artifact upload. When you compare +standard and larger runners, use runs with the same commit SHA, workflow +inputs, target, and shard. Pair the artifact with the GitHub Actions runner +label, queue time, result, and usage or cost metadata. This telemetry does not +maintain rolling history or write to the GitHub Actions step summary. + Raw cloud-onboard traces stay under the runner temporary directory. Before artifact upload, `scripts/e2e/sanitize-trace-timing.py` reduces them to the allowlisted `cloud-onboard-trace-timing-summary.json` timing schema and deletes From 246581da484e4d9f9b52502b73616f113025a0c0 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Wed, 22 Jul 2026 15:30:38 -0700 Subject: [PATCH 4/8] test(e2e): keep telemetry contracts linear Signed-off-by: Apurv Kumaria --- ...unner-comparison-workflow-boundary.test.ts | 4 +-- test/e2e/support/runner-comparison.test.ts | 25 +++++++++++++------ 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/test/e2e/support/runner-comparison-workflow-boundary.test.ts b/test/e2e/support/runner-comparison-workflow-boundary.test.ts index dd62e4abb6..32f68412d7 100644 --- a/test/e2e/support/runner-comparison-workflow-boundary.test.ts +++ b/test/e2e/support/runner-comparison-workflow-boundary.test.ts @@ -37,8 +37,8 @@ function loadWorkflow(): Workflow { function step(workflow: Workflow, jobId: string, name: string): WorkflowStep { const found = workflow.jobs[jobId]!.steps.find((candidate) => candidate.name === name); - if (!found) throw new Error(`${jobId} is missing ${name}`); - return found; + expect(found, `${jobId} is missing ${name}`).toBeDefined(); + return found!; } function telemetrySteps(workflow: Workflow, jobId: string): WorkflowStep[] { diff --git a/test/e2e/support/runner-comparison.test.ts b/test/e2e/support/runner-comparison.test.ts index a83df74c97..780eb78af3 100644 --- a/test/e2e/support/runner-comparison.test.ts +++ b/test/e2e/support/runner-comparison.test.ts @@ -299,19 +299,28 @@ describe("runner comparison private artifacts", () => { } }); - it.each(["symlink", "hardlink"])("rejects a %s ledger replacement (#7145)", (kind) => { + it.each([ + { + kind: "symlink", + replace: (ledgerPath: string, replacement: string) => { + fs.writeFileSync(replacement, fs.readFileSync(ledgerPath)); + fs.unlinkSync(ledgerPath); + fs.symlinkSync(replacement, ledgerPath); + }, + }, + { + kind: "hardlink", + replace: (ledgerPath: string, replacement: string) => { + fs.linkSync(ledgerPath, replacement); + }, + }, + ])("rejects a $kind ledger replacement (#7145)", ({ replace }) => { const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-runner-link-")); try { expectSuccess(runCli(directory, "initialize")); const ledgerPath = path.join(directory, "artifacts", RUNNER_COMPARISON_LEDGER_FILE); const replacement = path.join(directory, "replacement"); - if (kind === "symlink") { - fs.writeFileSync(replacement, fs.readFileSync(ledgerPath)); - fs.unlinkSync(ledgerPath); - fs.symlinkSync(replacement, ledgerPath); - } else { - fs.linkSync(ledgerPath, replacement); - } + replace(ledgerPath, replacement); expect(runCli(directory, "finalize").status).not.toBe(0); } finally { fs.rmSync(directory, { recursive: true, force: true }); From d836597c0c7ebc56f7b4273713c1b95f0497e3c6 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Wed, 22 Jul 2026 15:39:35 -0700 Subject: [PATCH 5/8] test(e2e): replace all JSONL separators Signed-off-by: Apurv Kumaria --- test/e2e/support/runner-comparison.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/e2e/support/runner-comparison.test.ts b/test/e2e/support/runner-comparison.test.ts index 780eb78af3..7e291052a9 100644 --- a/test/e2e/support/runner-comparison.test.ts +++ b/test/e2e/support/runner-comparison.test.ts @@ -159,7 +159,7 @@ describe("runner comparison schema", () => { expect(() => parseRunnerComparisonLedger(`${ledger(sample())}\n`)).toThrow( "canonical JSONL encoding", ); - expect(() => parseRunnerComparisonLedger(ledger(sample()).replace("\n", "\r\n"))).toThrow( + expect(() => parseRunnerComparisonLedger(ledger(sample()).replaceAll("\n", "\r\n"))).toThrow( "canonical JSONL encoding", ); }); From 48cf40c18527d2f2727a12b029ca5e73c241a38d Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Wed, 22 Jul 2026 22:29:15 -0700 Subject: [PATCH 6/8] ci(e2e): cover all larger runner telemetry lanes Signed-off-by: Apurv Kumaria --- .github/workflows/e2e.yaml | 82 ++++++++++++++++++ test/e2e/README.md | 14 +++- ...unner-comparison-workflow-boundary.test.ts | 72 ++++++++++++---- .../runner-comparison-workflow-boundary.mts | 83 +++++++++++++++---- 4 files changed, 221 insertions(+), 30 deletions(-) diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index f8de659380..9c3d11a2bd 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -1135,6 +1135,11 @@ jobs: - *dockerhub-auth - name: Prepare E2E workspace uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@50281ee84c4a6fc759da95ea28fc0b7d9c378a28 + - name: Initialize runner comparison telemetry + if: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && inputs.checkout_sha == '' }} + continue-on-error: true + shell: bash + run: npx tsx tools/e2e/runner-comparison.mts initialize - name: Install OpenShell CLI run: bash scripts/install-openshell.sh - name: Run Hermes inference switch live Vitest test @@ -1146,6 +1151,11 @@ jobs: export PATH="$HOME/.local/bin:$HOME/.npm-global/bin:$PATH" export OPENSHELL_BIN="$(command -v openshell || true)" npx tsx tools/e2e/live-vitest-invocation.mts run --test-path test/e2e/live/hermes-inference-switch.test.ts + - name: Finalize runner comparison telemetry + if: ${{ always() && github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && inputs.checkout_sha == '' }} + continue-on-error: true + shell: bash + run: npx tsx tools/e2e/runner-comparison.mts finalize - name: Upload Hermes inference switch artifacts if: always() uses: NVIDIA/NemoClaw/.github/actions/upload-e2e-artifacts@7768e15eb90d3ee2d33432f481dfe8747e4f6d57 @@ -1614,6 +1624,12 @@ jobs: - name: Prepare E2E workspace uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@50281ee84c4a6fc759da95ea28fc0b7d9c378a28 + - name: Initialize runner comparison telemetry + if: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && inputs.checkout_sha == '' }} + continue-on-error: true + shell: bash + run: npx tsx tools/e2e/runner-comparison.mts initialize + - name: Run Hermes live Vitest test env: NVIDIA_INFERENCE_API_KEY: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && github.event_name == 'workflow_dispatch' && inputs.checkout_sha == '' && (inputs.inference_mode || 'mock') != 'mock' && secrets.NVIDIA_INFERENCE_API_KEY || '' }} @@ -1621,6 +1637,12 @@ jobs: set -euo pipefail npx tsx tools/e2e/live-vitest-invocation.mts run --test-path test/e2e/live/hermes-e2e.test.ts + - name: Finalize runner comparison telemetry + if: ${{ always() && github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && inputs.checkout_sha == '' }} + continue-on-error: true + shell: bash + run: npx tsx tools/e2e/runner-comparison.mts finalize + - name: Upload Hermes live Vitest artifacts if: always() uses: NVIDIA/NemoClaw/.github/actions/upload-e2e-artifacts@7768e15eb90d3ee2d33432f481dfe8747e4f6d57 @@ -1922,6 +1944,12 @@ jobs: - name: Prepare E2E workspace uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@50281ee84c4a6fc759da95ea28fc0b7d9c378a28 + - name: Initialize runner comparison telemetry + if: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && inputs.checkout_sha == '' }} + continue-on-error: true + shell: bash + run: npx tsx tools/e2e/runner-comparison.mts initialize + - name: Run Hermes dashboard live Vitest test env: NVIDIA_INFERENCE_API_KEY: ${{ secrets.NVIDIA_INFERENCE_API_KEY }} @@ -1929,6 +1957,12 @@ jobs: set -euo pipefail npx tsx tools/e2e/live-vitest-invocation.mts run --test-path test/e2e/live/hermes-e2e.test.ts + - name: Finalize runner comparison telemetry + if: ${{ always() && github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && inputs.checkout_sha == '' }} + continue-on-error: true + shell: bash + run: npx tsx tools/e2e/runner-comparison.mts finalize + - name: Upload Hermes dashboard live Vitest artifacts if: always() uses: NVIDIA/NemoClaw/.github/actions/upload-e2e-artifacts@7768e15eb90d3ee2d33432f481dfe8747e4f6d57 @@ -1965,6 +1999,12 @@ jobs: - name: Prepare E2E workspace uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@50281ee84c4a6fc759da95ea28fc0b7d9c378a28 + - name: Initialize runner comparison telemetry + if: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && inputs.checkout_sha == '' }} + continue-on-error: true + shell: bash + run: npx tsx tools/e2e/runner-comparison.mts initialize + - name: Run Hermes Discord live test # Preserves the # ubuntu-latest Docker/OpenShell/Hermes Discord schema, provider, @@ -1979,6 +2019,12 @@ jobs: set -euo pipefail npx tsx tools/e2e/live-vitest-invocation.mts run --test-path test/e2e/live/hermes-discord.test.ts + - name: Finalize runner comparison telemetry + if: ${{ always() && github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && inputs.checkout_sha == '' }} + continue-on-error: true + shell: bash + run: npx tsx tools/e2e/runner-comparison.mts finalize + - name: Upload Hermes Discord artifacts if: always() uses: NVIDIA/NemoClaw/.github/actions/upload-e2e-artifacts@7768e15eb90d3ee2d33432f481dfe8747e4f6d57 @@ -2210,6 +2256,12 @@ jobs: - name: Prepare E2E workspace uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@50281ee84c4a6fc759da95ea28fc0b7d9c378a28 + - name: Initialize runner comparison telemetry + if: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && inputs.checkout_sha == '' }} + continue-on-error: true + shell: bash + run: npx tsx tools/e2e/runner-comparison.mts initialize + - name: Run Hermes shields-config live regression # This hermetic regression uses a local OpenAI-compatible endpoint and # proves two complete down/up cycles on a fresh non-root Hermes sandbox. @@ -2217,6 +2269,12 @@ jobs: set -euo pipefail npx tsx tools/e2e/live-vitest-invocation.mts run --test-path test/e2e/live/hermes-shields-config.test.ts + - name: Finalize runner comparison telemetry + if: ${{ always() && github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && inputs.checkout_sha == '' }} + continue-on-error: true + shell: bash + run: npx tsx tools/e2e/runner-comparison.mts finalize + - name: Upload Hermes shields-config artifacts if: always() uses: NVIDIA/NemoClaw/.github/actions/upload-e2e-artifacts@7768e15eb90d3ee2d33432f481dfe8747e4f6d57 @@ -3146,6 +3204,12 @@ jobs: - name: Prepare E2E workspace uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@50281ee84c4a6fc759da95ea28fc0b7d9c378a28 + - name: Initialize runner comparison telemetry + if: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && inputs.checkout_sha == '' && matrix.agent == 'hermes' }} + continue-on-error: true + shell: bash + run: npx tsx tools/e2e/runner-comparison.mts initialize + - name: Install OpenShell CLI run: env -u DOCKER_CONFIG -u DOCKERHUB_USERNAME -u DOCKERHUB_TOKEN -u NVIDIA_API_KEY -u NVIDIA_INFERENCE_API_KEY -u GITHUB_TOKEN bash scripts/install-openshell.sh @@ -3157,6 +3221,12 @@ jobs: export PATH="$HOME/.local/bin:$HOME/.npm-global/bin:$PATH" npx tsx tools/e2e/live-vitest-invocation.mts run --test-path "${{ matrix.test_file }}" + - name: Finalize runner comparison telemetry + if: ${{ always() && github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && inputs.checkout_sha == '' && matrix.agent == 'hermes' }} + continue-on-error: true + shell: bash + run: npx tsx tools/e2e/runner-comparison.mts finalize + - name: Upload security posture artifacts if: always() uses: NVIDIA/NemoClaw/.github/actions/upload-e2e-artifacts@7768e15eb90d3ee2d33432f481dfe8747e4f6d57 @@ -4611,6 +4681,12 @@ jobs: - name: Prepare E2E workspace uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@50281ee84c4a6fc759da95ea28fc0b7d9c378a28 + - name: Initialize runner comparison telemetry + if: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && inputs.checkout_sha == '' && matrix.agent == 'hermes' }} + continue-on-error: true + shell: bash + run: npx tsx tools/e2e/runner-comparison.mts initialize + - name: Install OpenShell env: NEMOCLAW_NON_INTERACTIVE: "1" @@ -4645,6 +4721,12 @@ jobs: "$OPENSHELL_BIN" --version npx tsx tools/e2e/live-vitest-invocation.mts run --test-path test/e2e/live/channels-stop-start.test.ts + - name: Finalize runner comparison telemetry + if: ${{ always() && github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && inputs.checkout_sha == '' && matrix.agent == 'hermes' }} + continue-on-error: true + shell: bash + run: npx tsx tools/e2e/runner-comparison.mts finalize + - name: Upload channels stop/start artifacts if: always() uses: NVIDIA/NemoClaw/.github/actions/upload-e2e-artifacts@7768e15eb90d3ee2d33432f481dfe8747e4f6d57 diff --git a/test/e2e/README.md b/test/e2e/README.md index af22513540..ea1a2fd5db 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -72,13 +72,25 @@ graph as the live targets: ### Runner comparison telemetry Trusted `main` runs without an alternate checkout SHA record runner-comparison -telemetry for the five #7145 candidate executions: +telemetry for the #7145 contract: 12 routed workflow lane identities / 13 +concrete job executions. - `common-egress-agent` - `rebuild-hermes` - `rebuild-hermes-stale-base` - `mcp-bridge` with the `hermes` shard - `mcp-bridge` with the `deepagents` shard +- `channels-stop-start` with the `hermes` shard +- `hermes-dashboard` +- `hermes-discord` +- `hermes-e2e` +- `hermes-inference-switch` with the `hosted` and `anthropic` modes +- `hermes-shields-config` +- `security-posture` with the `hermes` shard + +The extra execution comes from `hermes-inference-switch`, which runs both +listed modes. The OpenClaw matrix entries for `mcp-bridge`, +`channels-stop-start`, and `security-posture` are not instrumented. Each execution writes two best-effort numeric samples to `runner-comparison.jsonl`, one after workspace preparation and one from an diff --git a/test/e2e/support/runner-comparison-workflow-boundary.test.ts b/test/e2e/support/runner-comparison-workflow-boundary.test.ts index 32f68412d7..b4b1fa31b7 100644 --- a/test/e2e/support/runner-comparison-workflow-boundary.test.ts +++ b/test/e2e/support/runner-comparison-workflow-boundary.test.ts @@ -20,15 +20,25 @@ type WorkflowStep = Record & { shell?: string; uses?: string; }; +type WorkflowMatrix = Record & { + include?: Array>; +}; type Workflow = { - jobs: Record; + jobs: Record; }; const JOBS = [ + "channels-stop-start", "common-egress-agent", + "hermes-dashboard", + "hermes-discord", + "hermes-e2e", + "hermes-inference-switch", + "hermes-shields-config", + "mcp-bridge", "rebuild-hermes", "rebuild-hermes-stale-base", - "mcp-bridge", + "security-posture", ] as const; function loadWorkflow(): Workflow { @@ -47,33 +57,54 @@ function telemetrySteps(workflow: Workflow, jobId: string): WorkflowStep[] { ); } +function matrixValues(workflow: Workflow, jobId: string, key: string): unknown[] { + const matrix = workflow.jobs[jobId]!.strategy!.matrix!; + const direct = matrix[key]; + if (Array.isArray(direct)) return direct; + return matrix.include?.map((entry) => entry[key]) ?? []; +} + describe("runner comparison E2E workflow boundary (#7145)", () => { - it("accepts the exact five-execution comparison wiring", () => { + it("accepts 12 routed workflow lane identities / 13 concrete job executions", () => { const workflow = loadWorkflow(); expect(validateRunnerComparisonWorkflowBoundary(workflow)).toEqual([]); - expect(JOBS.flatMap((jobId) => telemetrySteps(workflow, jobId))).toHaveLength(8); - expect( - 3 + - workflow.jobs["mcp-bridge"]!.strategy!.matrix!.agent!.filter((agent) => - ["hermes", "deepagents"].includes(String(agent)), - ).length, - ).toBe(5); + expect(JOBS.flatMap((jobId) => telemetrySteps(workflow, jobId))).toHaveLength(JOBS.length * 2); + + const mcpLanes = matrixValues(workflow, "mcp-bridge", "agent").filter((agent) => + ["hermes", "deepagents"].includes(String(agent)), + ).length; + const routedLanes = JOBS.length - 1 + mcpLanes; + const concreteExecutions = + routedLanes - 1 + matrixValues(workflow, "hermes-inference-switch", "mode").length; + expect(routedLanes).toBe(12); + expect(concreteExecutions).toBe(13); }); - it("requires both measured MCP matrix entries exactly once", () => { + it("locks the matrix topology that produces thirteen concrete executions", () => { const workflow = loadWorkflow(); workflow.jobs["mcp-bridge"]!.strategy!.matrix!.agent = ["openclaw", "hermes", "hermes"]; + workflow.jobs["channels-stop-start"]!.strategy!.matrix!.agent = ["openclaw", "openclaw"]; + workflow.jobs["security-posture"]!.strategy!.matrix!.include = [ + { agent: "openclaw" }, + { agent: "openclaw" }, + ]; + workflow.jobs["hermes-inference-switch"]!.strategy!.matrix!.include = [ + { mode: "hosted" }, + { mode: "hosted" }, + ]; expect(validateRunnerComparisonWorkflow(workflow)).toEqual( expect.arrayContaining([ - "mcp-bridge matrix must contain hermes exactly once for runner comparison telemetry", - "mcp-bridge matrix must contain deepagents exactly once for runner comparison telemetry", + "channels-stop-start matrix must contain exactly openclaw, hermes for runner comparison telemetry", + "mcp-bridge matrix must contain exactly openclaw, hermes, deepagents for runner comparison telemetry", + "security-posture matrix must contain exactly openclaw, hermes for runner comparison telemetry", + "hermes-inference-switch matrix must contain exactly hosted, anthropic for runner comparison telemetry", ]), ); }); - it("rejects runner comparison consumers outside the four comparison jobs", () => { + it("rejects runner comparison consumers outside the eleven comparison jobs", () => { const workflow = loadWorkflow(); workflow.jobs["shields-config"]!.steps.push( structuredClone(telemetrySteps(workflow, "common-egress-agent")[0]!), @@ -145,7 +176,7 @@ describe("runner comparison E2E workflow boundary (#7145)", () => { ); }); - it("keeps OpenClaw out of both MCP comparison samples", () => { + it("keeps non-routed matrix counterparts out of comparison telemetry", () => { const workflow = loadWorkflow(); for (const name of [RUNNER_COMPARISON_INITIALIZE_STEP, RUNNER_COMPARISON_FINALIZE_STEP]) { const comparison = step(workflow, "mcp-bridge", name); @@ -153,12 +184,23 @@ describe("runner comparison E2E workflow boundary (#7145)", () => { "(matrix.agent == 'hermes' || matrix.agent == 'deepagents')", "(matrix.agent == 'openclaw' || matrix.agent == 'hermes' || matrix.agent == 'deepagents')", ); + for (const jobId of ["channels-stop-start", "security-posture"]) { + const hermesComparison = step(workflow, jobId, name); + hermesComparison.if = hermesComparison.if!.replace( + "matrix.agent == 'hermes'", + "(matrix.agent == 'openclaw' || matrix.agent == 'hermes')", + ); + } } expect(validateRunnerComparisonWorkflow(workflow)).toEqual( expect.arrayContaining([ "mcp-bridge must use the exact trusted initialize telemetry step", "mcp-bridge must use the exact always-run trusted finalize telemetry step", + "channels-stop-start must use the exact trusted initialize telemetry step", + "channels-stop-start must use the exact always-run trusted finalize telemetry step", + "security-posture must use the exact trusted initialize telemetry step", + "security-posture must use the exact always-run trusted finalize telemetry step", ]), ); }); diff --git a/tools/e2e/runner-comparison-workflow-boundary.mts b/tools/e2e/runner-comparison-workflow-boundary.mts index 695cf6b747..556988ac27 100644 --- a/tools/e2e/runner-comparison-workflow-boundary.mts +++ b/tools/e2e/runner-comparison-workflow-boundary.mts @@ -11,17 +11,42 @@ export const RUNNER_COMPARISON_COMMAND = "npx tsx tools/e2e/runner-comparison.mt const TRUSTED_MAIN_GUARD = "github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && inputs.checkout_sha == ''"; +const HERMES_AGENT_GUARD = "matrix.agent == 'hermes'"; const MCP_AGENT_GUARD = "(matrix.agent == 'hermes' || matrix.agent == 'deepagents')"; const ORDINARY_INITIALIZE_GUARD = `\${{ ${TRUSTED_MAIN_GUARD} }}`; const ORDINARY_FINALIZE_GUARD = `\${{ always() && ${TRUSTED_MAIN_GUARD} }}`; +const HERMES_INITIALIZE_GUARD = `\${{ ${TRUSTED_MAIN_GUARD} && ${HERMES_AGENT_GUARD} }}`; +const HERMES_FINALIZE_GUARD = `\${{ always() && ${TRUSTED_MAIN_GUARD} && ${HERMES_AGENT_GUARD} }}`; const MCP_INITIALIZE_GUARD = `\${{ ${TRUSTED_MAIN_GUARD} && ${MCP_AGENT_GUARD} }}`; const MCP_FINALIZE_GUARD = `\${{ always() && ${TRUSTED_MAIN_GUARD} && ${MCP_AGENT_GUARD} }}`; const COMPARISON_JOBS: ReadonlyMap = new Map([ + [ + "channels-stop-start", + { initializeIf: HERMES_INITIALIZE_GUARD, finalizeIf: HERMES_FINALIZE_GUARD }, + ], [ "common-egress-agent", { initializeIf: ORDINARY_INITIALIZE_GUARD, finalizeIf: ORDINARY_FINALIZE_GUARD }, ], + [ + "hermes-dashboard", + { initializeIf: ORDINARY_INITIALIZE_GUARD, finalizeIf: ORDINARY_FINALIZE_GUARD }, + ], + [ + "hermes-discord", + { initializeIf: ORDINARY_INITIALIZE_GUARD, finalizeIf: ORDINARY_FINALIZE_GUARD }, + ], + ["hermes-e2e", { initializeIf: ORDINARY_INITIALIZE_GUARD, finalizeIf: ORDINARY_FINALIZE_GUARD }], + [ + "hermes-inference-switch", + { initializeIf: ORDINARY_INITIALIZE_GUARD, finalizeIf: ORDINARY_FINALIZE_GUARD }, + ], + [ + "hermes-shields-config", + { initializeIf: ORDINARY_INITIALIZE_GUARD, finalizeIf: ORDINARY_FINALIZE_GUARD }, + ], + ["mcp-bridge", { initializeIf: MCP_INITIALIZE_GUARD, finalizeIf: MCP_FINALIZE_GUARD }], [ "rebuild-hermes", { initializeIf: ORDINARY_INITIALIZE_GUARD, finalizeIf: ORDINARY_FINALIZE_GUARD }, @@ -30,7 +55,10 @@ const COMPARISON_JOBS: ReadonlyMap; @@ -53,6 +81,31 @@ function steps(value: unknown): WorkflowStep[] { return Array.isArray(value) ? (value as WorkflowStep[]) : []; } +function matrixValues(jobs: WorkflowRecord, jobId: string, key: string): unknown[] { + const matrix = record(record(record(jobs[jobId]).strategy).matrix); + if (Array.isArray(matrix[key])) return matrix[key]; + if (!Array.isArray(matrix.include)) return []; + return matrix.include.map((entry) => record(entry)[key]); +} + +function requireExactMatrixValues( + errors: string[], + jobs: WorkflowRecord, + jobId: string, + key: string, + expected: readonly string[], +): void { + const actual = matrixValues(jobs, jobId, key); + if ( + actual.length !== expected.length || + expected.some((value) => actual.filter((candidate) => candidate === value).length !== 1) + ) { + errors.push( + `${jobId} matrix must contain exactly ${expected.join(", ")} for runner comparison telemetry`, + ); + } +} + function exactStep( step: WorkflowStep, name: string, @@ -81,24 +134,26 @@ function publicationIndex(jobSteps: readonly WorkflowStep[]): number { } /** - * Keep the #7145 comparison to five trusted-main executions: three standalone - * jobs plus the Hermes and Deep Agents MCP matrix entries. Telemetry is - * best-effort, but it must span the complete job and finish before evidence is - * scanned or uploaded. + * Keep the #7145 comparison to 12 routed workflow lane identities / 13 + * concrete trusted-main job executions. Telemetry is best-effort, but it must + * span the complete post-prepare job and finish before evidence is scanned or + * uploaded. */ export function validateRunnerComparisonWorkflow(workflowValue: unknown): string[] { const jobs = record(record(workflowValue).jobs); const errors: string[] = []; - const mcpMatrix = record(record(record(jobs["mcp-bridge"]).strategy).matrix); - const mcpAgents = Array.isArray(mcpMatrix.agent) ? mcpMatrix.agent : []; - for (const expectedAgent of ["hermes", "deepagents"]) { - if (mcpAgents.filter((agent) => agent === expectedAgent).length !== 1) { - errors.push( - `mcp-bridge matrix must contain ${expectedAgent} exactly once for runner comparison telemetry`, - ); - } - } + requireExactMatrixValues(errors, jobs, "channels-stop-start", "agent", ["openclaw", "hermes"]); + requireExactMatrixValues(errors, jobs, "mcp-bridge", "agent", [ + "openclaw", + "hermes", + "deepagents", + ]); + requireExactMatrixValues(errors, jobs, "security-posture", "agent", ["openclaw", "hermes"]); + requireExactMatrixValues(errors, jobs, "hermes-inference-switch", "mode", [ + "hosted", + "anthropic", + ]); for (const [jobId, value] of Object.entries(jobs)) { const jobSteps = steps(record(value).steps); From a1fa33250e0c109ecb667383dc05acaf6113673b Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Wed, 22 Jul 2026 22:38:35 -0700 Subject: [PATCH 7/8] test(e2e): satisfy workflow boundary guardrail Signed-off-by: Prekshi Vyas --- test/e2e/support/runner-comparison-workflow-boundary.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/e2e/support/runner-comparison-workflow-boundary.test.ts b/test/e2e/support/runner-comparison-workflow-boundary.test.ts index b4b1fa31b7..9662977915 100644 --- a/test/e2e/support/runner-comparison-workflow-boundary.test.ts +++ b/test/e2e/support/runner-comparison-workflow-boundary.test.ts @@ -60,8 +60,7 @@ function telemetrySteps(workflow: Workflow, jobId: string): WorkflowStep[] { function matrixValues(workflow: Workflow, jobId: string, key: string): unknown[] { const matrix = workflow.jobs[jobId]!.strategy!.matrix!; const direct = matrix[key]; - if (Array.isArray(direct)) return direct; - return matrix.include?.map((entry) => entry[key]) ?? []; + return Array.isArray(direct) ? direct : (matrix.include?.map((entry) => entry[key]) ?? []); } describe("runner comparison E2E workflow boundary (#7145)", () => { From c09137dfce7f9da0f9a337e0b01682d43b16ac04 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Thu, 23 Jul 2026 00:09:20 -0700 Subject: [PATCH 8/8] test(e2e): assert concrete telemetry matrices Signed-off-by: Apurv Kumaria --- .../runner-comparison-workflow-boundary.test.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/test/e2e/support/runner-comparison-workflow-boundary.test.ts b/test/e2e/support/runner-comparison-workflow-boundary.test.ts index 9662977915..be95385bb5 100644 --- a/test/e2e/support/runner-comparison-workflow-boundary.test.ts +++ b/test/e2e/support/runner-comparison-workflow-boundary.test.ts @@ -57,12 +57,6 @@ function telemetrySteps(workflow: Workflow, jobId: string): WorkflowStep[] { ); } -function matrixValues(workflow: Workflow, jobId: string, key: string): unknown[] { - const matrix = workflow.jobs[jobId]!.strategy!.matrix!; - const direct = matrix[key]; - return Array.isArray(direct) ? direct : (matrix.include?.map((entry) => entry[key]) ?? []); -} - describe("runner comparison E2E workflow boundary (#7145)", () => { it("accepts 12 routed workflow lane identities / 13 concrete job executions", () => { const workflow = loadWorkflow(); @@ -70,12 +64,17 @@ describe("runner comparison E2E workflow boundary (#7145)", () => { expect(validateRunnerComparisonWorkflowBoundary(workflow)).toEqual([]); expect(JOBS.flatMap((jobId) => telemetrySteps(workflow, jobId))).toHaveLength(JOBS.length * 2); - const mcpLanes = matrixValues(workflow, "mcp-bridge", "agent").filter((agent) => + const mcpAgents = workflow.jobs["mcp-bridge"]!.strategy!.matrix!.agent as unknown[]; + expect(mcpAgents).toEqual(["openclaw", "hermes", "deepagents"]); + const mcpLanes = mcpAgents.filter((agent) => ["hermes", "deepagents"].includes(String(agent)), ).length; + const inferenceSwitchModes = workflow.jobs[ + "hermes-inference-switch" + ]!.strategy!.matrix!.include?.map((entry) => entry.mode); + expect(inferenceSwitchModes).toEqual(["hosted", "anthropic"]); const routedLanes = JOBS.length - 1 + mcpLanes; - const concreteExecutions = - routedLanes - 1 + matrixValues(workflow, "hermes-inference-switch", "mode").length; + const concreteExecutions = routedLanes - 1 + inferenceSwitchModes!.length; expect(routedLanes).toBe(12); expect(concreteExecutions).toBe(13); });