GitNexus/eval/tests/test_workflow_bench.py
Gergő Magyar 5549403082
fix(eval): self-hosted skill-evolution runner + sandbox Python 3 trust fix (#2600)
* fix(eval): move skill-evolution to a self-hosted runner and fix the sandbox's Python 3 trust gap

GitHub-hosted runners hard-cap job execution at 6 hours, which is too
short once a benchmark session actually invokes Skill/MCP tools for
real (the --bare fix in #2584 means sessions no longer no-op). Move the
job onto a self-hosted runner (5-day cap instead) and document the
activation step in the workflow's own checklist.

Validating the self-hosted run surfaced a real bug: gitnexus-plan
sessions inside the bwrap sandbox failed with "planning must create or
modify exactly one plan artifact; observed 0". Root cause:
evidence-provenance.mjs's atomic plan-writer only trusts a Python 3
binary owned by root or by the current process. Inside this
--unshare-user sandbox only the calling uid is mapped (root isn't), so
the real, root-owned /usr/bin/python3 surfaces as the kernel's overflow
uid and gets correctly refused as untrusted. Fix: provision a small,
self-owned wrapper script (same pattern already used for
shell-prefix) that execs the real interpreter, so the sandbox has a
Python 3 candidate the existing trust check can actually accept --
without touching that security-sensitive validation logic at all.

Also add visibility so this class of failure isn't quiet next time:
report.md now shows why each row failed (error_kinds), not just
resolved 0/1, and the benchmark now exits non-zero when an incumbent
arm -- the currently-shipped skill -- resolves zero across every task,
since that reads as a broken harness rather than a normal candidate
miss.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(eval): close the broken_incumbent_arms zero-valid-runs gap; document runner exposure tradeoff

Addresses the two MEDIUM findings from the gitnexus-review-agent on this PR
(https://github.com/abhigyanpatwari/GitNexus/pull/2600#issuecomment-5033363096).

broken_incumbent_arms required valid_runs > 0 before flagging an incumbent,
so an incumbent that fails every run with an excluded-but-non-systemic
error_kind (e.g. evidence-unverified, which the outage-streak breaker
explicitly resets on rather than accumulates) never accumulated a single
valid run and sailed through silently -- the exact quiet no-promotion
outcome this guard exists to catch, and arguably worse than the
some-runs-resolved-zero case since here nothing completed at all.
aggregate() never marks an excluded/unverifiable row resolved=True, so
dropping the valid_runs requirement and checking resolved == 0 alone
correctly covers both cases. Added a test for exactly this all-excluded
scenario, which none of the existing three did.

Updated the workflow's own activation checklist to reflect what's actually
true now (the gitnexus-evolution environment's branch policy and the
self-hosted runner are both live, codified in infra/gitnexus-evolution/ in
a companion PR) and documented the exposure-window tradeoff the review
flagged: the runner is stopped between runs but not destroyed/recreated per
run, so it isn't fully ephemeral. Stopping already bounds the exposure
window to the job's own runtime on one day out of seven; full per-job
ephemeral provisioning is a deliberate non-goal for a job that runs at
most weekly, revisit if that changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(eval): remove public infra/ pointers from the activation checklist

PR #2603 (the Terraform codification this checklist pointed to) got closed
-- publishing the exact IAM roles, security group rules, and self-hosted
runner topology for a real, live AWS account isn't safe to do in a public
repo, even with no literal secrets or resource IDs in the diff. The
underlying AWS/GitHub setup is unaffected and still documented privately;
this just removes the now-dangling references to a directory that won't
exist in this repo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* ci(actionlint): register the gitnexus-evolution self-hosted runner label

actionlint rejected `runs-on: [self-hosted, linux, x64, gitnexus-evolution]`
in gitnexus-skill-evolution.yml because it can't discover custom runner
labels. Register it in .github/actionlint.yaml so the Workflow Lint check
passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 14:40:20 +01:00

427 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Unit tests for workflow benchmark aggregation, reporting, task, and CI contracts."""
import json
import re
import subprocess
from pathlib import Path
import pytest
import yaml
from workflow_bench.runner import (
aggregate,
broken_incumbent_arms,
build_parser,
infra_error_record,
normalized_model_identifier,
parse_shortstat,
render_report,
savings,
select_tasks,
systemic_outage_streak,
)
def record(**overrides):
base = {
"input_tokens": 1000,
"cache_creation_input_tokens": 200,
"cache_read_input_tokens": 5000,
"output_tokens": 400,
"cost_usd": 0.5,
"duration_s": 60.0,
"num_turns": 10,
"diff_files": 2,
"diff_insertions": 30,
"diff_deletions": 5,
"class": "demo",
"resolved": True,
}
base.update(overrides)
return base
def test_aggregate_takes_medians_and_counts_resolved():
records = [
record(input_tokens=1000, resolved=True),
record(input_tokens=3000, resolved=False),
record(input_tokens=2000, resolved=True),
]
agg = aggregate(records)
assert agg == {
"input_tokens": 2000,
"cache_creation_input_tokens": 200,
"cache_read_input_tokens": 5000,
"output_tokens": 400,
"cost_usd": 0.5,
"duration_s": 60.0,
"num_turns": 10,
"diff_files": 2,
"diff_insertions": 30,
"diff_deletions": 5,
"class": "demo",
"resolved": 2,
"runs": 3,
"valid_runs": 3,
"excluded_runs": 0,
"transcripts_missing": 0,
"error_kinds": {},
}
def test_savings_is_positive_when_workflow_is_cheaper():
baseline = aggregate([record(input_tokens=2000, output_tokens=800, cost_usd=1.0)])
workflow = aggregate([record(input_tokens=1000, output_tokens=400, cost_usd=0.4)])
s = savings(baseline, workflow)
assert s["input_tokens"] == 50.0
assert s["output_tokens"] == 50.0
assert s["cost_usd"] == 60.0
def task_row(task_id: str, **overrides):
task = {
"id": task_id,
"class": "demo",
"repo": "/repo",
"prompt": "do it",
"verify": "true",
"oracle": {
"command": "true",
"files": [
{
"source": "trivial-version-alias.oracle.test.ts",
"target": "oracle.test.ts",
}
],
},
}
task.update(overrides)
return task
def test_expensive_tasks_are_opt_in_and_reported_as_skipped():
tasks = [task_row("default"), task_row("large", expensive=True)]
selected, skipped = select_tasks(tasks, include_expensive=False)
assert [task["id"] for task in selected] == ["default"]
assert skipped == ["large"]
selected, skipped = select_tasks(tasks, include_expensive=True)
assert [task["id"] for task in selected] == ["default", "large"]
assert skipped == []
@pytest.mark.parametrize("value", ["true", 1, None, [], {}])
def test_expensive_metadata_must_be_boolean(value):
with pytest.raises(ValueError, match="expensive.*boolean"):
select_tasks([task_row("bad", expensive=value)], include_expensive=False)
def test_task_selection_rejects_duplicate_ids_and_empty_selection():
with pytest.raises(ValueError, match="duplicate task id"):
select_tasks([task_row("same"), task_row("same")], include_expensive=True)
with pytest.raises(ValueError, match="no tasks selected"):
select_tasks([task_row("large", expensive=True)], include_expensive=False)
def test_runner_requires_a_named_model_and_supports_expensive_opt_in():
with pytest.raises(SystemExit):
build_parser().parse_args(["--tasks", "tasks.yaml"])
args = build_parser().parse_args(
[
"--tasks",
"tasks.yaml",
"--model",
"claude-sonnet-4-20250514",
"--include-expensive",
]
)
assert args.include_expensive is True
with pytest.raises(ValueError, match="nonblank"):
normalized_model_identifier(" ")
@pytest.mark.parametrize(
"alias",
["Auto", "AUTO", "latest", "provider/latest", "provider:Latest", "provider@LATEST"],
)
def test_runner_rejects_mutable_model_aliases(alias):
with pytest.raises(ValueError, match="mutable auto/latest"):
normalized_model_identifier(alias)
assert normalized_model_identifier("free-coder") == "free-coder"
assert normalized_model_identifier("claude-sonnet-4-20250514") == "claude-sonnet-4-20250514"
def test_eval_ci_uses_locked_uv_and_blocking_native_containment_jobs():
repo_root = Path(__file__).resolve().parents[2]
workflow = (repo_root / ".github" / "workflows" / "ci-tests.yml").read_text()
workflow_document = yaml.safe_load(workflow)
containment = workflow_document["jobs"]["eval-containment-linux"]
containment_steps = {step.get("name"): step for step in containment["steps"] if "name" in step}
containment_node_setup = next(
step for step in containment["steps"] if str(step.get("uses", "")).startswith("actions/setup-node@")
)
claude_lock = json.loads((repo_root / ".github" / "claude-canary-runtime" / "package-lock.json").read_text())
setup_uv = "astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990"
assert workflow.count(setup_uv) >= 3
assert workflow.count("version: '0.11.23'") >= 3
assert workflow.count("uv run --locked --extra dev python -m pytest") >= 3
assert "eval-containment-linux:" in workflow
assert "GITNEXUS_REQUIRE_BWRAP_CANARY: '1'" in workflow
assert "GITNEXUS_REQUIRE_CLAUDE_CANARY: '1'" in workflow
assert containment["env"] == {
"GITNEXUS_REQUIRE_BWRAP_CANARY": "1",
"GITNEXUS_REQUIRE_CLAUDE_CANARY": "1",
}
assert containment["timeout-minutes"] == 20
assert containment_node_setup["with"] == {
"node-version": "22.18.0",
"cache": "npm",
"cache-dependency-path": "gitnexus/package-lock.json\ngitnexus-shared/package-lock.json\n",
}
assert (
"CLAUDE_CANARY_BIN: ${{ runner.temp }}/claude-canary/node_modules/@anthropic-ai/claude-code-linux-x64/claude"
in workflow
)
assert ".github/claude-canary-runtime/package-lock.json" in workflow
assert "npm ci" in workflow
assert "--package-lock=false" not in workflow
assert claude_lock["packages"]["node_modules/@anthropic-ai/claude-code"]["version"] == "2.1.214"
assert claude_lock["packages"]["node_modules/@anthropic-ai/claude-code"]["integrity"].startswith("sha512-")
assert "if(p.version!=='2.1.214') process.exit(1)" in workflow
assert "'2.1.214 (Claude Code)'" in workflow
assert containment_steps["Build pinned shared runtime"]["working-directory"] == "gitnexus-shared"
assert containment_steps["Build pinned shared runtime"]["run"].splitlines() == [
"npm ci",
"npm run build",
]
assert containment_steps["Install and build pinned GitNexus runtime"]["working-directory"] == "gitnexus"
assert containment_steps["Install and build pinned GitNexus runtime"]["run"].splitlines() == [
"npm ci",
"npm run build",
]
selected_containment_tests = containment_steps["Prove process-tree and sandbox containment"]["run"].split()
assert selected_containment_tests == [
"uv",
"run",
"--locked",
"--extra",
"dev",
"python",
"-m",
"pytest",
"tests/test_process_control.py",
"tests/test_proposer_sandbox.py",
"tests/test_workflow_bench_sessions.py",
"tests/test_ce_plugin_runtime.py",
"-q",
]
bwrap_canary_marker = re.compile(
r'@pytest\.mark\.skipif\(\s*os\.environ\.get\("GITNEXUS_REQUIRE_BWRAP_CANARY"\)',
re.MULTILINE,
)
bwrap_canary_files = sorted(
path.name
for path in (repo_root / "eval" / "tests").glob("test_*.py")
if bwrap_canary_marker.search(path.read_text())
)
assert bwrap_canary_files == ["test_proposer_sandbox.py", "test_workflow_bench_sessions.py"]
assert all(f"tests/{name}" in selected_containment_tests for name in bwrap_canary_files)
assert "eval-containment-windows:" in workflow
def test_shipped_scenarios_opt_out_the_cross_module_cell_and_rebuild_graph_assets():
task_file = Path(__file__).resolve().parents[1] / "workflow_bench" / "tasks.scenarios.yaml"
tasks = yaml.safe_load(task_file.read_text())["tasks"]
selected, skipped = select_tasks(tasks, include_expensive=False)
assert [task["id"] for task in selected] == [
"trivial-version-alias",
"inv-bug-pdg-note",
"inv-feature-list-repos-filter",
]
assert skipped == ["cross-module-parse-retry"]
assert all(not task.get("sandbox_copy") for task in tasks)
assert all(task["sandbox_dependencies"] for task in tasks)
assert all(task["oracle"]["command"] and task["oracle"]["files"] for task in tasks)
assert all("./node_modules/.bin/vitest run" in task["oracle"]["command"] for task in tasks)
assert all("npx vitest" not in task["oracle"]["command"] for task in tasks)
assert all(
'--config "$GITNEXUS_BENCH_ORACLE_ROOT/vitest.config.mts"' in task["oracle"]["command"] for task in tasks
)
assert all({item["target"] for item in task["oracle"]["files"]} >= {"vitest.config.mts"} for task in tasks)
def test_savings_handles_zero_baseline_without_dividing():
baseline = aggregate([record(cost_usd=0.0)])
workflow = aggregate([record(cost_usd=0.0)])
assert savings(baseline, workflow)["cost_usd"] == 0.0
def test_parse_shortstat_full_and_empty():
full = parse_shortstat(" 3 files changed, 120 insertions(+), 7 deletions(-)")
assert full == {"diff_files": 3, "diff_insertions": 120, "diff_deletions": 7}
assert parse_shortstat("") == {
"diff_files": 0,
"diff_insertions": 0,
"diff_deletions": 0,
}
singular = parse_shortstat(" 1 file changed, 1 insertion(+)")
assert singular == {"diff_files": 1, "diff_insertions": 1, "diff_deletions": 0}
def test_render_report_emits_arm_rows_and_per_arm_savings_rows():
results = {
"demo-task": {
"workflow": aggregate([record(input_tokens=1000)]),
"workflow_direct": aggregate([record(input_tokens=1500)]),
"baseline": aggregate([record(input_tokens=2000)]),
}
}
report = render_report(results)
assert "| demo-task | demo | workflow | 1/1 | 1000 |" in report
assert "| demo-task | demo | baseline | 1/1 | 2000 |" in report
assert "| demo-task | demo | **workflow savings %** | — | 50.0 |" in report
assert "| demo-task | demo | **workflow_direct savings %** | — | 25.0 |" in report
assert "2/+30/5" in report
assert "results.jsonl" in report
assert "subagent spend" in report # token columns are main-loop-only
def test_aggregate_excludes_session_error_rows_from_medians():
records = [
record(cost_usd=1.0),
record(cost_usd=3.0, transcript_missing=True),
record(cost_usd=100.0, resolved=False, error_kind="session-error"),
]
agg = aggregate(records)
assert agg["cost_usd"] == 2.0
assert agg["runs"] == 3
assert agg["valid_runs"] == 2
assert agg["excluded_runs"] == 1
assert agg["transcripts_missing"] == 1
assert agg["resolved"] == 2
def test_aggregate_excludes_unverified_transcript_evidence():
agg = aggregate(
[
record(cost_usd=1.0),
record(
cost_usd=100.0,
resolved=False,
error_kind="evidence-unverified",
transcript_missing=True,
),
]
)
assert agg["cost_usd"] == 1.0
assert agg["valid_runs"] == 1
assert agg["excluded_runs"] == 1
def test_render_report_surfaces_excluded_and_unverified_runs():
results = {
"t": {
"workflow": aggregate(
[
record(transcript_missing=True),
record(resolved=False, error_kind="session-error"),
]
)
}
}
report = render_report(results)
assert "| t | demo | workflow | 1/1 (1 excluded) |" in report
assert "session/infra errors" in report
assert "no locatable session transcript" in report
def test_render_report_surfaces_why_each_row_failed():
results = {
"t": {
"workflow": aggregate(
[record(resolved=False, error_kind="plan-evidence-invalid")],
),
}
}
report = render_report(results)
assert "plan-evidence-invalid×1" in report
def test_broken_incumbent_arms_flags_an_incumbent_that_resolved_nothing():
results = {
"t1": {"workflow": aggregate([record(resolved=False, error_kind="plan-evidence-invalid")])},
"t2": {"workflow": aggregate([record(resolved=False, error_kind="plan-evidence-invalid")])},
}
assert broken_incumbent_arms(results, {"workflow"}) == ["workflow"]
def test_broken_incumbent_arms_ignores_a_merely_underperforming_candidate():
# The incumbent works fine; only the candidate arm fails. That's a normal,
# expected "bad candidate" outcome and must not read as a broken harness.
results = {
"t1": {
"workflow": aggregate([record(resolved=True)]),
"candidate_workflow": aggregate([record(resolved=False, error_kind="verify-failed")]),
},
}
assert broken_incumbent_arms(results, {"workflow"}) == []
def test_broken_incumbent_arms_flags_an_incumbent_with_zero_valid_runs():
# Every run excluded via an excluded-but-non-systemic error_kind
# ("evidence-unverified"): valid_runs == 0 for every task, which the old
# `valid_runs > 0` guard let sail through silently, and which the outage
# streak breaker also doesn't catch (it resets rather than accumulates
# on this exact error_kind -- see test_systemic_outage_streak_resets_on_non_outage).
results = {
"t1": {"workflow": aggregate([record(resolved=False, error_kind="evidence-unverified")])},
"t2": {"workflow": aggregate([record(resolved=False, error_kind="evidence-unverified")])},
}
assert results["t1"]["workflow"]["valid_runs"] == 0
assert broken_incumbent_arms(results, {"workflow"}) == ["workflow"]
def test_broken_incumbent_arms_ignores_partial_incumbent_failure():
# Resolved in at least one task — struggling, not broken.
results = {
"t1": {"workflow": aggregate([record(resolved=False, error_kind="verify-failed")])},
"t2": {"workflow": aggregate([record(resolved=True)])},
}
assert broken_incumbent_arms(results, {"workflow"}) == []
def test_infra_error_record_captures_the_failure_and_is_excluded():
exc = subprocess.TimeoutExpired(cmd="claude -p", timeout=5)
rec = infra_error_record(exc)
assert rec["ok"] is False
assert rec["resolved"] is False
assert rec["error_kind"] == "infra-error"
assert "TimeoutExpired" in rec["error_detail"]
assert rec["output_tokens"] == 0
agg = aggregate([record(cost_usd=2.0), rec])
assert agg["cost_usd"] == 2.0
assert agg["valid_runs"] == 1
assert agg["excluded_runs"] == 1
def test_systemic_outage_streak_counts_consecutive_systemic_failures():
# session/infra/cleanup failures accumulate; a cleanup-failure that masked a
# session-error still counts toward the streak.
streak = 0
for kind in ("session-error", "infra-error", "cleanup-failure"):
streak = systemic_outage_streak(kind, streak)
assert streak == 3
assert systemic_outage_streak("cleanup-failure", 4) == 5
def test_systemic_outage_streak_resets_on_non_outage():
# A real task failure (resolved=False → error_kind None) or an unverifiable
# evidence run is not an outage and resets the streak.
assert systemic_outage_streak(None, 4) == 0
assert systemic_outage_streak("evidence-unverified", 4) == 0
def test_outage_streak_flag_defaults_and_disables():
base = ["--tasks", "tasks.yaml", "--model", "claude-sonnet-4-20250514"]
assert build_parser().parse_args(base).outage_streak == 5
assert build_parser().parse_args([*base, "--outage-streak", "0"]).outage_streak == 0