agent-orchestrator/experiments/benchmark-plan.md

44 KiB

Benchmark Harness Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Implement experiments/benchmark.mjs — a repeatable benchmark harness that measures AO's GitHub API consumption and produces a scorecard for before/after comparison of rate-limiting fixes.

Architecture: Single Node.js ESM script with three CLI modes (setup, measure, report). Shells out to ao CLI and gh CLI via child_process. No external dependencies — Node.js stdlib only. Reads agent-orchestrator.yaml for project config (YAML parsed with a minimal inline parser). All output artifacts go to experiments/out/.

Tech Stack: Node.js 20+, ESM (import), node:child_process, node:fs, node:path, node:timers/promises


File Structure

File Responsibility
experiments/benchmark.mjs The entire harness — CLI parsing, all three modes, scorecard computation, console output

No other files created or modified. This is a standalone experiment script.


Task 1: CLI Skeleton + Arg Parsing + Dispatch

Files:

  • Create: experiments/benchmark.mjs

Sets up the entry point: shebang, imports, CLI arg parser, mode dispatch, and usage/error helpers.

  • Step 1: Create file with shebang, imports, and constants
#!/usr/bin/env node

import { execFile, spawn as spawnChild } from "node:child_process";
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
import { resolve, dirname, join, basename } from "node:path";
import { promisify } from "node:util";
import { setTimeout as sleep } from "node:timers/promises";
import { homedir } from "node:os";

const execFileAsync = promisify(execFile);

const BENCHMARK_VERSION = "0.1.0";
const SCHEMA_VERSION = 1;
const OUT_DIR = resolve(dirname(new URL(import.meta.url).pathname), "out");
const AO_CLI = resolve(dirname(new URL(import.meta.url).pathname), "..", "packages", "cli", "dist", "index.js");
  • Step 2: Add CLI arg parser
/** Parse CLI args into { mode, flags }. No libraries — just process.argv. */
function parseArgs() {
  const args = process.argv.slice(2);
  const mode = args[0];
  const flags = {};
  for (let i = 1; i < args.length; i++) {
    const arg = args[i];
    if (arg.startsWith("--")) {
      const key = arg.slice(2);
      const next = args[i + 1];
      if (next && !next.startsWith("--")) {
        flags[key] = next;
        i++;
      } else {
        flags[key] = true;
      }
    }
  }
  return { mode, flags };
}
  • Step 3: Add usage function and mode dispatch
function usage() {
  console.error(`Usage:
  node experiments/benchmark.mjs setup   --project <name> --sessions <n> --issues <1,2,3,...>
  node experiments/benchmark.mjs measure --project <name> --sessions <n> --duration <15m> [--warmup <2m>]
  node experiments/benchmark.mjs report  --trace <path> [--warmup-end <ISO timestamp>]`);
  process.exit(1);
}

function die(msg) {
  console.error(`\x1b[31mError: ${msg}\x1b[0m`);
  process.exit(1);
}

const { mode, flags } = parseArgs();
if (!mode || !["setup", "measure", "report"].includes(mode)) usage();
  • Step 4: Add mode dispatch at bottom of file
// Mode dispatch (at end of file, after all function definitions)
if (mode === "setup") {
  await runSetup(flags);
} else if (mode === "measure") {
  await runMeasure(flags);
} else if (mode === "report") {
  await runReport(flags);
}
  • Step 5: Verify the skeleton runs

Run: node experiments/benchmark.mjs
Expected: prints usage and exits with code 1

Run: node experiments/benchmark.mjs setup
Expected: errors about missing flags (once Task 3 is implemented — for now, add stub functions)

Add temporary stubs so the file parses:

async function runSetup(flags) { die("setup not implemented"); }
async function runMeasure(flags) { die("measure not implemented"); }
async function runReport(flags) { die("report not implemented"); }

Task 2: Shared Utilities

Files:

  • Modify: experiments/benchmark.mjs

Add all shared helpers used across modes. Place these between the arg parser and the mode implementations.

  • Step 1: Add parseDuration

Parses strings like "2m", "15m", "1h" into milliseconds.

/** Parse duration string (e.g. "2m", "15m", "1h") to milliseconds. */
function parseDuration(str) {
  const match = str.match(/^(\d+)(s|m|h)$/);
  if (!match) die(`Invalid duration: "${str}". Use format like 2m, 15m, 1h.`);
  const value = parseInt(match[1], 10);
  const unit = match[2];
  if (unit === "s") return value * 1000;
  if (unit === "m") return value * 60_000;
  if (unit === "h") return value * 3_600_000;
}
  • Step 2: Add exec helpers
/** Run a command and return stdout. Throws on non-zero exit. */
async function run(cmd, args, opts = {}) {
  const { stdout } = await execFileAsync(cmd, args, {
    maxBuffer: 10 * 1024 * 1024,
    timeout: opts.timeout ?? 30_000,
    cwd: opts.cwd,
    env: opts.env ?? process.env,
  });
  return stdout.trim();
}

/** Run `gh` CLI without the trace env var (benchmark-control call). */
async function ghControl(args, opts = {}) {
  const env = { ...process.env };
  delete env.AO_GH_TRACE_FILE;
  return run("gh", args, { ...opts, env });
}

/** Get current git SHA (short). */
function gitSha() {
  try {
    const { execSync } = await import("node:child_process");
    // Can't use async here easily, use execFileSync instead
    return require("node:child_process").execFileSync("git", ["rev-parse", "--short", "HEAD"], {
      encoding: "utf-8",
    }).trim();
  } catch {
    return "unknown";
  }
}

Wait — this is ESM, we can't use require. Let me fix:

import { execFileSync } from "node:child_process";

/** Get current git SHA (short). */
function gitSha() {
  try {
    return execFileSync("git", ["rev-parse", "--short", "HEAD"], {
      encoding: "utf-8",
      timeout: 5000,
    }).trim();
  } catch {
    return "unknown";
  }
}

/** Get current git branch. */
function gitBranch() {
  try {
    return execFileSync("git", ["branch", "--show-current"], {
      encoding: "utf-8",
      timeout: 5000,
    }).trim();
  } catch {
    return "unknown";
  }
}

Update imports at top of file to include execFileSync:

import { execFile, execFileSync, spawn as spawnChild } from "node:child_process";
  • Step 3: Add YAML config loader

The benchmark needs to read agent-orchestrator.yaml to find repo and path for a project. We use a minimal approach — parse just enough YAML to extract what we need. The config uses simple key-value YAML without advanced features.

/**
 * Minimal YAML parser — handles the flat project config we need.
 * Reads agent-orchestrator.yaml via AO's config search logic:
 * AO_CONFIG_PATH env, then search up from cwd, then ~/agent-orchestrator.yaml.
 */
function findConfig() {
  if (process.env.AO_CONFIG_PATH && existsSync(process.env.AO_CONFIG_PATH)) {
    return process.env.AO_CONFIG_PATH;
  }
  const names = ["agent-orchestrator.yaml", "agent-orchestrator.yml"];
  let dir = process.cwd();
  while (true) {
    for (const name of names) {
      const p = join(dir, name);
      if (existsSync(p)) return p;
    }
    const parent = dirname(dir);
    if (parent === dir) break;
    dir = parent;
  }
  const home = homedir();
  for (const name of names) {
    const p = join(home, name);
    if (existsSync(p)) return p;
  }
  return null;
}

/**
 * Extract project config from agent-orchestrator.yaml.
 * Returns { repo, path, defaultBranch, sessionPrefix }.
 * Uses a line-based parser — sufficient for the flat structure we need.
 */
function loadProjectConfig(projectName) {
  const configPath = findConfig();
  if (!configPath) die("Cannot find agent-orchestrator.yaml");

  const raw = readFileSync(configPath, "utf-8");
  const lines = raw.split("\n");

  // Find the project block: "  projectName:" under "projects:"
  let inProjects = false;
  let inTarget = false;
  let indent = 0;
  const config = {};

  for (const line of lines) {
    const trimmed = line.trimStart();
    const currentIndent = line.length - trimmed.length;

    if (trimmed === "projects:" || trimmed === "projects: ") {
      inProjects = true;
      continue;
    }

    if (inProjects && !inTarget) {
      if (trimmed === `${projectName}:` || trimmed.startsWith(`${projectName}: `)) {
        inTarget = true;
        indent = currentIndent;
        continue;
      }
    }

    if (inTarget) {
      // If we've de-dented back to or past the project key level, we're done
      if (currentIndent <= indent && trimmed.length > 0) break;

      const kvMatch = trimmed.match(/^(\w[\w-]*)\s*:\s*(.+)$/);
      if (kvMatch) {
        config[kvMatch[1]] = kvMatch[2].replace(/^["']|["']$/g, "");
      }
    }
  }

  if (!config.repo) die(`Project "${projectName}" not found in config or missing repo field`);
  if (!config.path) die(`Project "${projectName}" missing path field in config`);

  return {
    repo: config.repo,
    path: config.path.replace(/^~/, homedir()),
    defaultBranch: config.defaultBranch || "main",
    sessionPrefix: config.sessionPrefix || projectName.replace(/[^a-zA-Z0-9]/g, "").slice(0, 4).toLowerCase(),
  };
}
  • Step 4: Add percentile and formatting helpers
/** Compute percentile from sorted array. */
function percentile(sortedArr, p) {
  if (sortedArr.length === 0) return 0;
  const idx = Math.min(sortedArr.length - 1, Math.floor(sortedArr.length * p));
  return sortedArr[idx];
}

/** Format number with commas. */
function fmt(n) {
  return n.toLocaleString("en-US");
}

/** Ensure output directory exists. */
function ensureOutDir() {
  mkdirSync(OUT_DIR, { recursive: true });
}
  • Step 5: Verify utilities work

Run: node -e "import('./experiments/benchmark.mjs')"
Expected: prints usage (since no mode given) — confirms the file parses without syntax errors.


Task 3: Scorecard Computation Engine

Files:

  • Modify: experiments/benchmark.mjs

The core math that turns trace rows + optional rate-limit snapshots into a scorecard object. Used by both measure and report modes.

  • Step 1: Add trace reader
/** Read and parse a JSONL trace file. Returns array of entry objects. */
function readTrace(tracePath) {
  if (!existsSync(tracePath)) die(`Trace file not found: ${tracePath}`);
  const raw = readFileSync(tracePath, "utf-8");
  return raw.split("\n").filter(Boolean).map((line) => JSON.parse(line));
}
  • Step 2: Add per-window burn rate computation

This is the authoritative method — groups trace rows by rateLimitReset value for each resource, computes delta within each window, sums across windows, normalizes to per-hour.

/**
 * Compute per-hour burn rate from trace rows using per-window method.
 * Groups rows by rateLimitReset, computes remaining delta per window,
 * sums across windows, normalizes to 1 hour.
 *
 * Returns { pointsPerHr, estimated: false, straddled, windows } or null if no data.
 */
function computeBurnRate(rows, resource, windowDurationMs) {
  const withRL = rows.filter(
    (r) =>
      typeof r.rateLimitRemaining === "number" &&
      r.rateLimitResource === resource,
  );
  if (withRL.length < 2) return null;

  // Group by rateLimitReset
  const windowMap = new Map();
  for (const r of withRL) {
    const key = r.rateLimitReset ?? "unknown";
    if (!windowMap.has(key)) windowMap.set(key, []);
    windowMap.get(key).push(r);
  }

  let totalDelta = 0;
  const windows = [];
  for (const [resetEpoch, windowRows] of windowMap.entries()) {
    if (windowRows.length < 2) continue;
    const first = windowRows[0];
    const last = windowRows[windowRows.length - 1];
    const delta = first.rateLimitRemaining - last.rateLimitRemaining;
    totalDelta += Math.max(0, delta); // ignore negative deltas (refills)
    windows.push({
      resetAt: typeof resetEpoch === "number"
        ? new Date(resetEpoch * 1000).toISOString()
        : "unknown",
      delta,
      rows: windowRows.length,
    });
  }

  const durationHr = windowDurationMs / 3_600_000;
  return {
    pointsPerHr: durationHr > 0 ? Math.round(totalDelta / durationHr) : 0,
    estimated: false,
    straddled: windowMap.size > 1,
    windows,
  };
}
  • Step 3: Add computeScorecard function
/**
 * Compute scorecard from trace rows and optional rate-limit snapshots.
 *
 * @param {object[]} allRows - All trace rows (including warmup)
 * @param {string} windowStart - ISO timestamp of measurement window start
 * @param {string} windowEnd - ISO timestamp of measurement window end
 * @param {object|null} snapshots - { before, after } rate-limit snapshots, or null
 * @param {number} benchmarkControlCalls - Count of GH calls made by the harness
 * @returns {object} Scorecard object matching the spec schema
 */
function computeScorecard(allRows, windowStart, windowEnd, snapshots, benchmarkControlCalls) {
  const startMs = new Date(windowStart).getTime();
  const endMs = new Date(windowEnd).getTime();
  const windowDurationMs = endMs - startMs;
  const windowDurationMin = windowDurationMs / 60_000;

  // Filter to measurement window only, exclude benchmark-control rows
  const rows = allRows.filter((r) => {
    const ts = new Date(r.timestamp).getTime();
    if (ts < startMs || ts > endMs) return false;
    if (r.component === "benchmark-control") return false;
    return true;
  });

  const totalCalls = rows.length;
  const callsPerMin = windowDurationMin > 0 ? +(totalCalls / windowDurationMin).toFixed(1) : 0;

  // GraphQL burn rate (per-window method)
  const graphqlBurn = computeBurnRate(rows, "graphql", windowDurationMs);
  // REST core burn rate (per-window method)
  const coreBurn = computeBurnRate(rows, "core", windowDurationMs);

  // Bracket delta from snapshots (fallback source)
  let bracketDelta = { core: null, graphql: null };
  if (snapshots) {
    bracketDelta = {
      core: (snapshots.before.core?.remaining ?? 0) - (snapshots.after.core?.remaining ?? 0),
      graphql: (snapshots.before.graphql?.remaining ?? 0) - (snapshots.after.graphql?.remaining ?? 0),
    };
  }

  // GraphQL points/hr — prefer per-row, fallback to bracket delta
  let graphqlPointsPerHr = 0;
  let graphqlPointsPerHrEstimated = false;
  if (graphqlBurn) {
    graphqlPointsPerHr = graphqlBurn.pointsPerHr;
  } else if (bracketDelta.graphql !== null && bracketDelta.graphql > 0) {
    const durationHr = windowDurationMs / 3_600_000;
    graphqlPointsPerHr = durationHr > 0 ? Math.round(bracketDelta.graphql / durationHr) : 0;
    graphqlPointsPerHrEstimated = true;
  }

  // REST core requests/hr — same logic
  let restCoreRequestsPerHr = 0;
  let restCoreRequestsPerHrEstimated = false;
  if (coreBurn) {
    restCoreRequestsPerHr = coreBurn.pointsPerHr;
  } else if (bracketDelta.core !== null && bracketDelta.core > 0) {
    const durationHr = windowDurationMs / 3_600_000;
    restCoreRequestsPerHr = durationHr > 0 ? Math.round(bracketDelta.core / durationHr) : 0;
    restCoreRequestsPerHrEstimated = true;
  }

  // Operation counts
  const graphqlBatchCount = rows.filter((r) => r.operation === "gh.api.graphql-batch").length;

  const guardPrListRows = rows.filter((r) => r.operation === "gh.api.guard-pr-list");
  const guardPrList304Count = guardPrListRows.filter((r) => r.httpStatus === 304).length;
  const guardPrListSuccessCount = guardPrListRows.filter((r) => r.ok && r.httpStatus !== 304).length;
  const guardPrListErrorCount = guardPrListRows.filter((r) => !r.ok && r.httpStatus !== 304).length;
  const guardPrListTotal = guardPrList304Count + guardPrListSuccessCount + guardPrListErrorCount;
  const guardPrList304Rate = guardPrListTotal > 0 ? +(guardPrList304Count / guardPrListTotal).toFixed(3) : 0;

  // Opaque calls (no HTTP status)
  const opaqueCallCount = rows.filter((r) => r.httpStatus === undefined || r.httpStatus === null).length;
  const opaqueCallPct = totalCalls > 0 ? +(opaqueCallCount / totalCalls).toFixed(2) : 0;

  // Duration percentiles
  const durations = rows.map((r) => Number(r.durationMs) || 0).sort((a, b) => a - b);

  // Resource windows
  const resourceWindows = {
    graphql: graphqlBurn
      ? { resetAt: graphqlBurn.windows[0]?.resetAt ?? null, straddled: graphqlBurn.straddled }
      : { resetAt: null, straddled: false },
    core: coreBurn
      ? { resetAt: coreBurn.windows[0]?.resetAt ?? null, straddled: coreBurn.straddled }
      : { resetAt: null, straddled: false },
  };

  return {
    totalCalls,
    callsPerMin,
    graphqlPointsPerHr,
    graphqlPointsPerHrEstimated,
    restCoreRequestsPerHr,
    restCoreRequestsPerHrEstimated,
    graphqlBatchCount,
    guardPrList304Count,
    guardPrListErrorCount,
    guardPrList304Rate,
    opaqueCallCount,
    opaqueCallPct,
    bracketDelta,
    p50DurationMs: percentile(durations, 0.5),
    p95DurationMs: percentile(durations, 0.95),
    p99DurationMs: percentile(durations, 0.99),
    benchmarkControlCalls: benchmarkControlCalls ?? null,
    resourceWindows,
  };
}
  • Step 4: Verify scorecard computation with existing trace

Run: node -e " import { readFileSync } from 'node:fs'; // Quick inline test — will be replaced by report mode " (or just verify the file still parses: node experiments/benchmark.mjs)

Expected: exits with usage message (no mode given), confirming no syntax errors.

  • Step 5: Commit
git add experiments/benchmark.mjs
git commit -m "feat(experiments): add benchmark harness skeleton with scorecard engine

Implements CLI parsing, shared utilities, and scorecard computation
for the rate-limit benchmark harness (experiments/benchmark.mjs).
Three modes stubbed: setup, measure, report."

Task 4: Console Output (printScorecard)

Files:

  • Modify: experiments/benchmark.mjs

Implements the formatted console output with budget progress bars matching the spec.

  • Step 1: Add progress bar renderer
/** Render a budget progress bar. Green <50%, yellow 50-80%, red >80%. */
function budgetBar(used, budget, width = 20) {
  const pct = budget > 0 ? used / budget : 0;
  const filled = Math.round(pct * width);
  const empty = width - filled;
  const bar = "\u2588".repeat(filled) + "\u2591".repeat(empty);

  const isTTY = process.stdout.isTTY;
  const pctStr = `${(pct * 100).toFixed(0)}%`;

  if (!isTTY) return `${bar}  ${pctStr}`;

  let color;
  if (pct < 0.5) color = "\x1b[32m";       // green
  else if (pct < 0.8) color = "\x1b[33m";   // yellow
  else color = "\x1b[31m";                   // red

  return `${color}${bar}\x1b[0m  ${pctStr}`;
}
  • Step 2: Add printScorecard function
/**
 * Print formatted scorecard to stdout.
 * Matches the console output format from the spec.
 */
function printScorecard(envelope) {
  const sc = envelope.scorecard;
  const w = envelope.window;
  const warmupStr = envelope.warmup?.requested ?? "none";
  const durationStr = w.durationActual ?? w.durationRequested;
  const sessions = envelope.sessionCount;

  const line = "=".repeat(59);
  console.log(`\n${line}`);
  console.log(`  GH Rate-Limit Benchmark`);
  console.log(`  ${envelope.scenarioId} | ${envelope.gitSha} | ${envelope.branch}`);
  console.log(`  ${envelope.measuredAt} | warmup ${warmupStr} | measured ${durationStr} | ${sessions} sessions`);
  console.log(line);

  console.log();
  console.log(`  Total GH calls:          ${fmt(sc.totalCalls).padStart(6)}`);
  console.log(`  Calls/min:               ${String(sc.callsPerMin).padStart(6)}`);

  console.log();
  const gqlStr = fmt(sc.graphqlPointsPerHr).padStart(6);
  const gqlEst = sc.graphqlPointsPerHrEstimated ? " (estimated)" : "";
  console.log(`  GraphQL points/hr:     ${gqlStr}  / 5,000  ${budgetBar(sc.graphqlPointsPerHr, 5000)}${gqlEst}`);

  const coreStr = fmt(sc.restCoreRequestsPerHr).padStart(6);
  const coreEst = sc.restCoreRequestsPerHrEstimated ? " (estimated)" : "";
  console.log(`  REST core requests/hr: ${coreStr}  / 5,000  ${budgetBar(sc.restCoreRequestsPerHr, 5000)}${coreEst}`);

  console.log();
  console.log(`  graphql-batch count:     ${fmt(sc.graphqlBatchCount).padStart(6)}`);
  console.log(`  guard-pr-list 304s:      ${fmt(sc.guardPrList304Count).padStart(6)}  (${(sc.guardPrList304Rate * 100).toFixed(1)}%)`);
  console.log(`  guard-pr-list errors:    ${fmt(sc.guardPrListErrorCount).padStart(6)}`);

  console.log();
  console.log(`  Opaque calls:            ${fmt(sc.opaqueCallCount).padStart(6)}  (${(sc.opaqueCallPct * 100).toFixed(1)}%)`);
  if (sc.bracketDelta.core !== null) {
    console.log(`  Bracket delta (core):    ${fmt(sc.bracketDelta.core).padStart(6)}`);
  }
  if (sc.bracketDelta.graphql !== null) {
    console.log(`  Bracket delta (graphql): ${fmt(sc.bracketDelta.graphql).padStart(6)}`);
  }

  console.log();
  console.log(`  p50 / p95 / p99:     ${fmt(sc.p50DurationMs)} / ${fmt(sc.p95DurationMs)} / ${fmt(sc.p99DurationMs)} ms`);

  console.log();
  if (envelope.trace) {
    console.log(`  Trace:     ${envelope.trace}`);
  }
  if (envelope.scorecardPath) {
    console.log(`  Scorecard: ${envelope.scorecardPath}`);
  }
  console.log(line);
  console.log();
}
  • Step 3: Commit
git add experiments/benchmark.mjs
git commit -m "feat(experiments): add scorecard console output with budget bars"

Task 5: Report Mode

Files:

  • Modify: experiments/benchmark.mjs

Implement report first since it's the simplest mode and lets us verify scorecard computation against existing trace data.

  • Step 1: Implement runReport

Replace the stub with the full implementation:

async function runReport(flags) {
  const tracePath = flags.trace;
  if (!tracePath) die("--trace is required for report mode");

  const warmupEnd = flags["warmup-end"] ?? null;
  const resolvedTrace = resolve(tracePath);

  console.error(`Reading trace: ${resolvedTrace}`);
  const allRows = readTrace(resolvedTrace);
  console.error(`Loaded ${allRows.length} trace entries`);

  if (allRows.length === 0) die("Trace file is empty");

  // Determine window boundaries
  const timestamps = allRows.map((r) => new Date(r.timestamp).getTime()).sort((a, b) => a - b);
  const windowStart = warmupEnd ?? new Date(timestamps[0]).toISOString();
  const windowEnd = new Date(timestamps[timestamps.length - 1]).toISOString();

  const scorecard = computeScorecard(allRows, windowStart, windowEnd, null, null);

  // Null out fields unavailable in report mode
  scorecard.bracketDelta = { core: null, graphql: null };
  scorecard.benchmarkControlCalls = null;

  const windowDurationMs = timestamps[timestamps.length - 1] - (warmupEnd ? new Date(warmupEnd).getTime() : timestamps[0]);
  const durationMin = windowDurationMs / 60_000;

  const envelope = {
    schemaVersion: SCHEMA_VERSION,
    benchmarkVersion: BENCHMARK_VERSION,
    scenarioId: "report",
    gitSha: gitSha(),
    branch: gitBranch(),
    measuredAt: new Date().toISOString(),
    sessionCount: "?",
    warmup: warmupEnd ? { requested: "?", actualEnd: warmupEnd } : null,
    window: {
      start: windowStart,
      end: windowEnd,
      durationRequested: "?",
      durationActual: `${durationMin.toFixed(0)}m ${Math.round((durationMin % 1) * 60)}s`,
    },
    resourceWindows: scorecard.resourceWindows,
    rateLimitSnapshots: null,
    scorecard,
    trace: resolvedTrace,
    supplemental: null,
  };

  // Write scorecard
  ensureOutDir();
  const ts = Math.floor(Date.now() / 1000);
  const scorecardPath = join(OUT_DIR, `scorecard-report-${ts}.json`);
  writeFileSync(scorecardPath, JSON.stringify(envelope, null, 2) + "\n");
  envelope.scorecardPath = scorecardPath;

  printScorecard(envelope);
  console.error(`Scorecard written to: ${scorecardPath}`);
}
  • Step 2: Test report mode with existing trace

Run: node experiments/benchmark.mjs report --trace experiments/out/gh-trace-verify-1776345900.jsonl

Expected: prints a scorecard with the numbers we already know from baseline.md:

  • ~234 total calls
  • ~10.5 calls/min
  • 27 guard-pr-list 304s
  • graphql burn rate (may be estimated from bracket delta since this is report mode)

Verify the output looks reasonable and matches the known data.

  • Step 3: Commit
git add experiments/benchmark.mjs
git commit -m "feat(experiments): implement report mode for benchmark harness

Validates scorecard computation against existing trace data."

Task 6: Setup Mode

Files:

  • Modify: experiments/benchmark.mjs

Implements the full setup flow: start AO, spawn sessions, wait for PRs, write setup artifact.

  • Step 1: Add AO process management helpers
/**
 * Start AO as a child process. Returns the ChildProcess.
 * Does NOT set AO_GH_TRACE_FILE — setup calls must not pollute traces.
 */
function startAO(project, opts = {}) {
  const args = ["start", project, "--no-dashboard"];
  if (opts.noOrchestrator) args.push("--no-orchestrator");
  const env = { ...process.env };
  if (opts.traceFile) {
    env.AO_GH_TRACE_FILE = opts.traceFile;
  } else {
    delete env.AO_GH_TRACE_FILE;
  }

  console.error(`Starting AO: node ${AO_CLI} ${args.join(" ")}`);
  const child = spawnChild("node", [AO_CLI, ...args], {
    stdio: ["ignore", "pipe", "pipe"],
    env,
    detached: false,
  });

  // Log AO output to stderr for debugging
  child.stdout?.on("data", (chunk) => {
    for (const line of chunk.toString().split("\n").filter(Boolean)) {
      console.error(`  [ao] ${line}`);
    }
  });
  child.stderr?.on("data", (chunk) => {
    for (const line of chunk.toString().split("\n").filter(Boolean)) {
      console.error(`  [ao:err] ${line}`);
    }
  });

  return child;
}

/**
 * Stop AO process. SIGTERM first, SIGKILL after timeout.
 */
async function stopAO(child, timeoutMs = 10_000) {
  if (!child || child.exitCode !== null) return;
  console.error("Stopping AO...");
  child.kill("SIGTERM");
  const deadline = Date.now() + timeoutMs;
  while (child.exitCode === null && Date.now() < deadline) {
    await sleep(500);
  }
  if (child.exitCode === null) {
    console.error("AO did not exit in time, sending SIGKILL");
    child.kill("SIGKILL");
    await sleep(1000);
  }
  console.error(`AO exited with code ${child.exitCode}`);
}
  • Step 2: Add scenarioId builder and artifact path helpers
/** Build scenarioId from components. v1 hardcodes scenario=quiet-steady, topology=single-repo. */
function buildScenarioId(sessionCount) {
  return `quiet-steady.single-repo.${sessionCount}`;
}

/** Path to setup artifact. */
function setupArtifactPath(project, scenarioId) {
  return join(OUT_DIR, `setup-${project}-${scenarioId}.json`);
}
  • Step 3: Add session discovery helpers
/**
 * List worktrees for a project and extract session mappings.
 * Returns Map<sessionId, worktreePath>.
 */
async function discoverWorktrees(projectPath) {
  const output = await run("git", ["-C", projectPath, "worktree", "list", "--porcelain"]);
  const worktrees = new Map();
  let currentPath = null;
  for (const line of output.split("\n")) {
    if (line.startsWith("worktree ")) {
      currentPath = line.slice(9);
    } else if (line === "" && currentPath) {
      // Extract session ID from worktree path (last component)
      const name = basename(currentPath);
      worktrees.set(name, currentPath);
      currentPath = null;
    }
  }
  // Handle last entry if no trailing newline
  if (currentPath) {
    worktrees.set(basename(currentPath), currentPath);
  }
  return worktrees;
}

/**
 * Get branch for a worktree path.
 */
async function getWorktreeBranch(worktreePath) {
  try {
    return await run("git", ["-C", worktreePath, "branch", "--show-current"]);
  } catch {
    return null;
  }
}
  • Step 4: Implement runSetup

Replace the stub:

async function runSetup(flags) {
  const project = flags.project;
  const sessionCount = parseInt(flags.sessions, 10);
  const issuesRaw = flags.issues;

  if (!project) die("--project is required");
  if (!sessionCount || sessionCount < 1) die("--sessions must be a positive integer");
  if (!issuesRaw) die("--issues is required (comma-separated issue numbers)");

  const issues = issuesRaw.split(",").map((s) => s.trim()).filter(Boolean);
  if (issues.length !== sessionCount) {
    die(`Issue count (${issues.length}) must equal session count (${sessionCount})`);
  }

  const scenarioId = buildScenarioId(sessionCount);
  const artifactPath = setupArtifactPath(project, scenarioId);
  const projectConfig = loadProjectConfig(project);

  console.error(`\n=== Benchmark Setup ===`);
  console.error(`Project:   ${project}`);
  console.error(`Repo:      ${projectConfig.repo}`);
  console.error(`Sessions:  ${sessionCount}`);
  console.error(`Issues:    ${issues.join(", ")}`);
  console.error(`Scenario:  ${scenarioId}`);
  console.error();

  // Check for existing setup artifact
  if (existsSync(artifactPath)) {
    console.error(`Found existing setup artifact: ${artifactPath}`);
    try {
      const existing = JSON.parse(readFileSync(artifactPath, "utf-8"));
      // Validate it's still usable
      let allValid = true;
      for (const s of existing.sessions || []) {
        if (!existsSync(s.worktreePath)) {
          console.error(`  Stale: worktree missing for ${s.sessionId} at ${s.worktreePath}`);
          allValid = false;
          break;
        }
      }
      if (allValid && existing.ready) {
        console.error(`Setup already exists and is valid. Reusing.`);
        console.error(`To force re-setup, delete: ${artifactPath}`);
        return;
      }
      console.error(`Existing setup is stale, running fresh.`);
    } catch {
      console.error(`Existing artifact is corrupt, running fresh.`);
    }
  }

  ensureOutDir();

  // Start AO (no trace, no dashboard)
  const aoProcess = startAO(project);
  await sleep(5000); // Give AO time to initialize

  try {
    // Run batch-spawn
    console.error(`Spawning ${sessionCount} sessions...`);
    const spawnOutput = await run("node", [AO_CLI, "batch-spawn", ...issues], {
      timeout: 120_000,
    });
    console.error(spawnOutput);

    // Wait for sessions to stabilize
    await sleep(5000);

    // Discover sessions: worktrees and branches
    console.error(`Discovering session worktrees and branches...`);
    const worktrees = await discoverWorktrees(projectConfig.path);
    console.error(`Found ${worktrees.size} worktrees`);

    // Match sessions to issues. Session IDs typically follow the pattern: {prefix}-{n}
    // We discover all sessions, then poll for PRs on their branches.
    const sessions = [];
    for (const [sessionId, worktreePath] of worktrees) {
      // Skip the main worktree
      if (worktreePath === projectConfig.path) continue;

      const branch = await getWorktreeBranch(worktreePath);
      if (!branch) {
        console.error(`  Warning: no branch for ${sessionId} at ${worktreePath}`);
        continue;
      }

      sessions.push({
        sessionId,
        issue: null, // Will be filled if we can match
        branch,
        worktreePath,
        prNumber: null,
        prUrl: null,
      });
    }

    console.error(`Discovered ${sessions.length} session worktrees`);

    // Poll for PR readiness
    console.error(`Waiting for PRs (timeout: 10 minutes)...`);
    const prDeadline = Date.now() + 10 * 60_000;
    let controlCalls = 0;
    while (Date.now() < prDeadline) {
      let allReady = true;
      for (const s of sessions) {
        if (s.prNumber) continue; // Already found
        try {
          const prJson = await ghControl([
            "pr", "list",
            "--head", s.branch,
            "--repo", projectConfig.repo,
            "--json", "number,url",
            "--limit", "1",
          ]);
          controlCalls++;
          const prs = JSON.parse(prJson);
          if (prs.length > 0) {
            s.prNumber = prs[0].number;
            s.prUrl = prs[0].url;
            console.error(`  PR found: ${s.sessionId} → #${s.prNumber}`);
          } else {
            allReady = false;
          }
        } catch {
          allReady = false;
        }
      }
      if (allReady) break;
      console.error(`  ${sessions.filter((s) => s.prNumber).length}/${sessions.length} PRs ready, waiting 30s...`);
      await sleep(30_000);
    }

    const readySessions = sessions.filter((s) => s.prNumber);
    const ready = readySessions.length === sessions.length;
    console.error(`\n${readySessions.length}/${sessions.length} sessions have PRs. Ready: ${ready}`);

    // Kill agent tmux sessions (agents have done their job)
    console.error(`Killing agent tmux sessions...`);
    for (const s of sessions) {
      try {
        await run("tmux", ["kill-session", "-t", s.sessionId], { timeout: 5000 }).catch(() => {});
      } catch {
        // Best effort — session may already be dead
      }
    }

    // Write setup artifact
    const artifact = {
      schemaVersion: SCHEMA_VERSION,
      benchmarkVersion: BENCHMARK_VERSION,
      scenarioId,
      createdAt: new Date().toISOString(),
      gitSha: gitSha(),
      branch: gitBranch(),
      project,
      repo: projectConfig.repo,
      scenario: "quiet-steady",
      topology: "single-repo",
      sessionCount,
      ready,
      sessions: sessions.map((s) => ({
        sessionId: s.sessionId,
        issue: s.issue,
        branch: s.branch,
        worktreePath: s.worktreePath,
        prNumber: s.prNumber,
        prUrl: s.prUrl,
      })),
    };

    writeFileSync(artifactPath, JSON.stringify(artifact, null, 2) + "\n");
    console.error(`\nSetup artifact written: ${artifactPath}`);
    console.error(`Benchmark control calls (not traced): ${controlCalls}`);

  } finally {
    await stopAO(aoProcess);
  }
}
  • Step 5: Commit
git add experiments/benchmark.mjs
git commit -m "feat(experiments): implement setup mode for benchmark harness

Spawns sessions, waits for PR readiness, writes setup artifact."

Task 7: Measure Mode

Files:

  • Modify: experiments/benchmark.mjs

Implements the repeatable measurement flow: validate setup, start AO with trace, warmup, snapshot, measure, snapshot, stop, compute scorecard.

  • Step 1: Add rate-limit snapshot helper
/**
 * Capture a rate-limit snapshot via `gh api rate_limit`.
 * Called without AO_GH_TRACE_FILE to avoid polluting the trace.
 */
async function captureRateLimitSnapshot() {
  const raw = await ghControl(["api", "rate_limit"]);
  const data = JSON.parse(raw);
  return {
    capturedAt: new Date().toISOString(),
    core: data.resources?.core ?? null,
    graphql: data.resources?.graphql ?? null,
  };
}
  • Step 2: Implement runMeasure

Replace the stub:

async function runMeasure(flags) {
  const project = flags.project;
  const sessionCount = parseInt(flags.sessions, 10);
  const warmupStr = flags.warmup ?? "2m";
  const durationStr = flags.duration;

  if (!project) die("--project is required");
  if (!sessionCount || sessionCount < 1) die("--sessions must be a positive integer");
  if (!durationStr) die("--duration is required (e.g. 15m)");

  const warmupMs = parseDuration(warmupStr);
  const durationMs = parseDuration(durationStr);
  const scenarioId = buildScenarioId(sessionCount);
  const artifactPath = setupArtifactPath(project, scenarioId);
  const projectConfig = loadProjectConfig(project);

  // 1. Load and validate setup artifact
  if (!existsSync(artifactPath)) {
    die(`Setup artifact not found: ${artifactPath}\nRun setup first: node experiments/benchmark.mjs setup --project ${project} --sessions ${sessionCount} --issues ...`);
  }
  const setup = JSON.parse(readFileSync(artifactPath, "utf-8"));

  console.error(`\n=== Benchmark Measure ===`);
  console.error(`Project:   ${project}`);
  console.error(`Scenario:  ${scenarioId}`);
  console.error(`Warmup:    ${warmupStr}`);
  console.error(`Duration:  ${durationStr}`);
  console.error(`Sessions:  ${setup.sessions.length}`);
  console.error();

  // 2. Validate setup — worktrees exist, PRs still open
  let controlCalls = 0;
  console.error(`Validating setup...`);
  const invalid = [];
  for (const s of setup.sessions) {
    if (!existsSync(s.worktreePath)) {
      invalid.push(`${s.sessionId}: worktree missing at ${s.worktreePath}`);
      continue;
    }
    if (s.prNumber) {
      try {
        const prJson = await ghControl([
          "pr", "view", String(s.prNumber),
          "--repo", projectConfig.repo,
          "--json", "state",
        ]);
        controlCalls++;
        const pr = JSON.parse(prJson);
        if (pr.state !== "OPEN") {
          invalid.push(`${s.sessionId}: PR #${s.prNumber} is ${pr.state}, not OPEN`);
        }
      } catch (err) {
        invalid.push(`${s.sessionId}: cannot verify PR #${s.prNumber}: ${err.message}`);
        controlCalls++;
      }
    }
  }
  if (invalid.length > 0) {
    console.error(`\nSetup validation failed:`);
    for (const msg of invalid) console.error(`  - ${msg}`);
    die(`${invalid.length} sessions failed validation. Re-run setup or fix manually.`);
  }
  console.error(`All ${setup.sessions.length} sessions validated OK`);

  // 3. Set up trace file
  const ts = Math.floor(Date.now() / 1000);
  ensureOutDir();
  const traceFile = join(OUT_DIR, `gh-trace-bench-${ts}.jsonl`);

  // 4. Start AO with trace
  const aoProcess = startAO(project, { traceFile });

  try {
    // 5. Warmup phase
    console.error(`\nWarmup phase: ${warmupStr}...`);
    const warmupStart = Date.now();
    const warmupInterval = setInterval(() => {
      const elapsed = Math.round((Date.now() - warmupStart) / 1000);
      const total = Math.round(warmupMs / 1000);
      process.stderr.write(`\r  Warmup: ${elapsed}s / ${total}s`);
    }, 5000);
    await sleep(warmupMs);
    clearInterval(warmupInterval);
    process.stderr.write("\n");

    // 6. Record warmup end
    const warmupEnd = new Date().toISOString();
    console.error(`Warmup ended: ${warmupEnd}`);

    // 7. Before rate-limit snapshot
    console.error(`Capturing rate-limit before-snapshot...`);
    const snapshotBefore = await captureRateLimitSnapshot();
    controlCalls++;
    console.error(`  core: ${snapshotBefore.core?.remaining}/${snapshotBefore.core?.limit}`);
    console.error(`  graphql: ${snapshotBefore.graphql?.remaining}/${snapshotBefore.graphql?.limit}`);

    // 8. Measurement window
    const measureStart = new Date().toISOString();
    console.error(`\nMeasurement window: ${durationStr}...`);
    const measureStartMs = Date.now();
    const measureInterval = setInterval(() => {
      const remainMs = durationMs - (Date.now() - measureStartMs);
      const remainMin = Math.ceil(remainMs / 60_000);
      process.stderr.write(`\r  Measuring: ${remainMin}m remaining  `);
    }, 60_000);
    await sleep(durationMs);
    clearInterval(measureInterval);
    process.stderr.write("\n");

    const measureEnd = new Date().toISOString();

    // 9. After rate-limit snapshot
    console.error(`Capturing rate-limit after-snapshot...`);
    const snapshotAfter = await captureRateLimitSnapshot();
    controlCalls++;
    console.error(`  core: ${snapshotAfter.core?.remaining}/${snapshotAfter.core?.limit}`);
    console.error(`  graphql: ${snapshotAfter.graphql?.remaining}/${snapshotAfter.graphql?.limit}`);

    // 10. Stop AO
    await stopAO(aoProcess);

    // 11. Compute scorecard
    console.error(`\nComputing scorecard from trace...`);
    const allRows = readTrace(traceFile);
    console.error(`Trace entries: ${allRows.length}`);

    const snapshots = { before: snapshotBefore, after: snapshotAfter };
    const scorecard = computeScorecard(allRows, measureStart, measureEnd, snapshots, controlCalls);

    // 12. Build envelope
    const windowDurationMs = new Date(measureEnd).getTime() - new Date(measureStart).getTime();
    const durationMin = windowDurationMs / 60_000;

    const envelope = {
      schemaVersion: SCHEMA_VERSION,
      benchmarkVersion: BENCHMARK_VERSION,
      scenarioId,
      gitSha: gitSha(),
      branch: gitBranch(),
      measuredAt: measureEnd,
      sessionCount: setup.sessions.length,
      warmup: { requested: warmupStr, actualEnd: warmupEnd },
      window: {
        start: measureStart,
        end: measureEnd,
        durationRequested: durationStr,
        durationActual: `${Math.floor(durationMin)}m ${Math.round((durationMin % 1) * 60)}s`,
      },
      resourceWindows: scorecard.resourceWindows,
      rateLimitSnapshots: snapshots,
      scorecard,
      trace: traceFile,
      supplemental: null,
    };

    // 13. Write scorecard
    const scorecardPath = join(OUT_DIR, `scorecard-${scenarioId}-${ts}.json`);
    writeFileSync(scorecardPath, JSON.stringify(envelope, null, 2) + "\n");
    envelope.scorecardPath = scorecardPath;

    // 14. Print scorecard
    printScorecard(envelope);
    console.error(`Trace written to: ${traceFile}`);
    console.error(`Scorecard written to: ${scorecardPath}`);

  } catch (err) {
    // Ensure AO is stopped even on error
    await stopAO(aoProcess).catch(() => {});
    throw err;
  }
}
  • Step 3: Commit
git add experiments/benchmark.mjs
git commit -m "feat(experiments): implement measure mode for benchmark harness

Validates setup, runs AO with trace, captures rate-limit snapshots,
computes and prints scorecard."

Task 8: Integration Verification

Files:

  • No modifications — verification only

Verify the complete harness works end-to-end with the existing trace data.

  • Step 1: Verify report mode with the existing verification trace

Run:

node experiments/benchmark.mjs report --trace experiments/out/gh-trace-verify-1776345900.jsonl

Expected output should show:

  • ~234 total calls

  • ~10.5 calls/min

  • 27 guard-pr-list 304s

  • bracket delta fields null (report mode)

  • Duration matching the trace span (~22 min)

  • Progress bars for GraphQL and REST core

  • Step 2: Verify report mode with warmup-end filter

Run (using a timestamp ~5 minutes into the trace as warmup cutoff):

node experiments/benchmark.mjs report \
  --trace experiments/out/gh-trace-verify-1776345900.jsonl \
  --warmup-end 2026-04-16T13:33:00Z

Expected: fewer total calls (rows before the warmup-end timestamp are excluded from the scorecard). The burn rates should still be reasonable.

  • Step 3: Verify CLI error handling

Run these and verify each gives a clear error message:

node experiments/benchmark.mjs                                    # → usage
node experiments/benchmark.mjs measure                            # → missing --project
node experiments/benchmark.mjs measure --project x                # → missing --sessions
node experiments/benchmark.mjs measure --project x --sessions 5   # → missing --duration
node experiments/benchmark.mjs report                             # → missing --trace
node experiments/benchmark.mjs report --trace nonexistent.jsonl   # → file not found
  • Step 4: Verify setup mode arg validation
node experiments/benchmark.mjs setup                               # → missing --project
node experiments/benchmark.mjs setup --project x                   # → missing --sessions
node experiments/benchmark.mjs setup --project x --sessions 5      # → missing --issues
node experiments/benchmark.mjs setup --project x --sessions 5 --issues 1,2,3  # → count mismatch
  • Step 5: Final commit
git add experiments/benchmark.mjs
git commit -m "feat(experiments): complete benchmark harness v1

Three modes: setup (spawn sessions), measure (capture trace + scorecard),
report (recompute scorecard from existing trace).

Spec: docs/superpowers/specs/2026-04-16-benchmark-harness-design.md"

Verification Checklist

After all tasks are complete, verify:

  1. node experiments/benchmark.mjs prints usage
  2. node experiments/benchmark.mjs report --trace experiments/out/gh-trace-verify-1776345900.jsonl produces a valid scorecard matching known baseline numbers
  3. All three modes have proper arg validation with clear error messages
  4. The scorecard JSON is valid and matches the schema from the spec
  5. Budget progress bars render correctly in terminal (green/yellow/red colors)
  6. The harness does not import any external packages — stdlib only