GitNexus/eval/tests/test_evolve.py
Gergő Magyar becac9a5d3
feat(eval): run the skill-evolution loop online (#2571)
* feat(eval): run the skill-evolution loop online

Add a scheduled + dispatch-gated workflow that runs the offline
propose -> benchmark -> gate loop (workflow_bench.evolve) in CI with the
pinned Claude canary runtime and bubblewrap containment, uploads the
benchmark evidence as an artifact, and on a gate-passed promotion opens
a human-reviewed PR via the release App token. The applied overlay is
bounded to the canonical skill tree and its shipped mirrors; any escape
fails the run instead of reaching a PR.

The scheduled lane ships disabled behind GITNEXUS_EVOLUTION_ENABLED and
requires the new GITNEXUS_BENCH_AUTH_TOKEN secret (benchmark sessions
bill real API usage), mirroring the review agent's staged rollout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ

* fix(ci): restructure promotion-PR script so no lint suppression is needed

Replace the inline single-quoted credential helper with a GIT_ASKPASS
file written via a quoted heredoc (the App token still reaches git only
through step env at push time), and assemble the PR body from quoted
heredocs plus double-quoted printf instead of a backtick-laden
single-quoted template. Every run script in the workflow now passes
shellcheck with zero findings and zero disables; the body and askpass
rendering are smoke-tested.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ

* fix(ci): apply gate-passing overlays in the evolution loop

The loop invoked workflow_bench.evolve without --apply, so
apply_promoted_overlay (its only working-tree writer, gated by
`if args.apply:`) never ran. git status stayed clean, promoted=false was
emitted every run, and the App-token/PR-open steps were unreachable dead
code — a gate-passing run went green as "No promotion this run".

validate_promotion_for_apply already runs before the apply gate, so
adding --apply lets a passing candidate reach the tree without weakening
the deterministic gate; the boundary check then confirms it stayed in the
skill trees.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ

* fix(ci): provision ~/GitNexus so the benchmark repo resolves on CI

Every scenario in tasks.scenarios.yaml addresses the target repo as
~/GitNexus; runner_tasks.py resolves it with expanduser().resolve() then
`git -C <repo> rev-parse`, which raises when the path is missing. On a
hosted runner the checkout lands in $GITHUB_WORKSPACE and nothing created
~/GitNexus, so the first real run failed at task-binding.

Symlink ~/GitNexus -> $GITHUB_WORKSPACE before the loop. The checkout uses
fetch-depth: 0 (full history for the parentless clone), and the benchmark
only clones the repo copy-on-write and mounts deps read-only, so the
checkout is never mutated. GITNEXUS_BENCH_ORACLE_ROOT stays unset — it
defaults to the in-repo oracles dir and is staged by the harness.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ

* fix(ci): harden promotion summary output and PR branch recovery

Three fixes to the promotion-detection and PR-open steps:

- GITHUB_OUTPUT summary used a fixed `PROMOTION_EOF` heredoc delimiter; a
  value containing that marker on its own line could close the block early
  and inject output keys. Use a per-run random delimiter, matching the
  pattern already in tree-sitter-upgrade-readiness.yml.
- The summary concatenated every generation's promotion.json (including
  rejected ones), so the PR body could show a losing generation's
  decisions. The loop returns on the first promotion, so emit only the
  highest-numbered gen-N/bench/promotion.json — the decision that fired.
- The promotion branch name omitted the run attempt. GITHUB_RUN_ID is
  stable across re-runs, so a re-run after push-succeeds/PR-create-fails
  could never push. Include ${GITHUB_RUN_ATTEMPT} (the artifact name
  already does).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ

* fix(ci): least-privilege the promotion App token and gate on an Environment

The Mint-App-Token step passed only app-id + private-key, so the minted
token inherited every permission the Release App installation holds
(including Workflows: write) — far more than "push a branch, open a PR".
Switch to `client-id` (as publish.yml does) and request only
permission-contents: write + permission-pull-requests: write.

Bind the job to a protected Environment (gitnexus-evolution) so promotion
runs can be gated server-side. workflow_dispatch runs the workflow and
in-tree evolve.py from the *dispatched ref*, so a code-side ref guard is
removable by the dispatched branch itself; an Environment deployment-branch
rule (main only) is the boundary that holds. The admin steps to create it
and scope the secrets are documented in the activation checklist.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ

* fix(ci): correct upload-artifact pin comment and add shell strict-mode

- The upload-artifact SHA 043fb46d… is v7.0.1 (labeled so in the sibling
  workflows that pin it); the comment mislabeled it # v6.0.0. Correct the
  comment; the pin is unchanged.
- Add `set -euo pipefail` to the two build steps that lacked it, matching
  every other run block in the file (GitHub's default shell already sets
  -eo pipefail; this adds -u and consistency).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ

* docs(ci): complete the skill-evolution activation checklist

- Add RELEASE_APP_ID / RELEASE_APP_PRIVATE_KEY to the required-secrets
  checklist (the Mint step hard-fails without them on a promotion) and the
  App-install-scope verification.
- Document the protected Environment admin step and why it is the real
  boundary for the workflow_dispatch ref-secret exposure.
- Note that workflow_dispatch runs the billing loop regardless of
  GITNEXUS_EVOLUTION_ENABLED.
- Justify the weekly cron against the README's ~90-day guidance and note the
  355-minute timeout ceiling.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ

* fix(eval): redact API tokens from diagnostic fields before artifact upload

results.jsonl (runner.py) and proposer-session.json (evolve.py) serialize
session records whose error_detail can carry a stderr_tail that echoed the
API key. Transcripts are redacted before persistence, but these two sinks
were not, and both land in the 14-day evolution artifact.

Run each record's serialized JSON through the existing redact_text with the
run's auth token before writing. Scoped to these diagnostic sinks only: the
promoted overlay and proposal.md are left untouched (the overlay is the
applied artifact and must stay byte-identical for apply and the
shipped-skills-sync guard).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ

* test(ci): add a contract test for the skill-evolution workflow

No test exercised this workflow's path, which is why both P1 blockers
(missing --apply, unresolvable ~/GitNexus task repo) reached production.
Parse the workflow YAML and assert the structural contract: --apply is
passed, the task repo is provisioned, the promotion branch carries the run
attempt, the App token is permission-scoped and the job is Environment-
gated, the output summary uses a random delimiter and a single generation,
the artifact pin is labelled correctly, and every multi-line shell step
sets strict mode. Follows the review-agent-workflow.test.ts precedent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ

* feat(ci): run the proposer on its own (stronger) model

One `model` input drove both the benchmark arms and the proposer/diagnosis
session. Split them: `model` stays the benchmark arms (match the model your
skill users run, so a promotion is valid for them and the tasks aren't
ceiling-saturated), and a new `proposer_model` input runs the proposer —
the harder meta-reasoning task that writes the candidate skill, and only one
session per generation, so a stronger model is cheap here. evolve.py already
supports --proposer-model; the workflow just didn't expose it.

Defaults: arms = claude-sonnet-5, proposer = claude-opus-4-8 (both
overridable via workflow_dispatch). The weekly cadence bounds the added
spend. Contract test asserts the split stays wired.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 05:37:07 +01:00

827 lines
28 KiB
Python

"""Unit tests for the pure evidence/apply/driver helpers of workflow_bench.evolve."""
import hashlib
import json
import os
import subprocess
import sys
import time
from datetime import UTC, datetime, timedelta
import pytest
from workflow_bench import evolve
from workflow_bench.runner_sessions import PARENT_EVENT_STREAM_SOURCE
from workflow_bench.evolve import (
build_parser,
build_proposer_prompt,
generation_timeout_seconds,
load_jsonl,
proposer_evidence_entries,
read_learnings,
resolve_incumbent_arms,
runner_argv,
select_evidence,
summarize_gate,
validate_promotion_for_apply,
)
from workflow_bench.process_control import run_managed
from workflow_bench.proposer_sandbox import pid_namespace_command, preflight_bubblewrap
def row(**overrides):
base = {
"task": "demo-task",
"class": "trivial",
"arm": "workflow",
"run": 0,
"resolved": True,
"error_kind": None,
"cost_usd": 1.0,
"num_turns": 10,
"output_tokens": 400,
"session_ids": ["sess-1"],
"verify_output": "ok",
}
base.update(overrides)
return base
def test_select_evidence_puts_unresolved_before_expensive_resolved():
rows = [
row(task="cheap", resolved=True, cost_usd=0.5),
row(task="fail", resolved=False, error_kind="verify-failed"),
row(task="pricey", resolved=True, cost_usd=9.0),
]
picked = select_evidence(rows)
assert [r["task"] for r in picked] == ["fail", "pricey", "cheap"]
def test_select_evidence_excludes_infra_error_rows_and_caps():
rows = [
row(task="harness-died", resolved=False, error_kind="infra-error"),
row(task="session-died", resolved=False, error_kind="session-error"),
row(
task="missing-transcript",
resolved=False,
error_kind="evidence-unverified",
),
]
rows += [row(task=f"t{i}", cost_usd=float(i)) for i in range(20)]
picked = select_evidence(rows, max_rows=5)
assert len(picked) == 5
assert all(r["error_kind"] != "infra-error" for r in picked)
assert [r["task"] for r in picked] == ["t19", "t18", "t17", "t16", "t15"]
def test_select_evidence_tolerates_an_explicit_null_cost():
# A foreign --seed-results row (e.g. hand-edited or from another tool)
# can carry an explicit JSON null rather than omitting the key; .get's
# default only covers the missing-key case, so this must not raise.
rows = [row(task="no-cost", resolved=True, cost_usd=None), row(task="priced", cost_usd=5.0)]
picked = select_evidence(rows)
assert [r["task"] for r in picked] == ["priced", "no-cost"]
def test_load_jsonl_skips_blank_and_malformed_lines(tmp_path):
path = tmp_path / "learnings.jsonl"
path.write_text('{"skill": "gitnexus-plan"}\n\nnot json\n[1, 2]\n{"skill": "gitnexus-work"}\n')
assert load_jsonl(path) == [{"skill": "gitnexus-plan"}, {"skill": "gitnexus-work"}]
def test_load_jsonl_missing_file_is_empty(tmp_path):
assert load_jsonl(tmp_path / "absent.jsonl") == []
def test_read_learnings_keeps_the_most_recent_entries(tmp_path):
path = tmp_path / "learnings.jsonl"
rows = [{"skill": "gitnexus-work", "n": i} for i in range(10)] + [
{"skill": "gitnexus-review", "n": 10},
{"skill": "gitnexus-lfg", "n": 11},
]
path.write_text("\n".join(json.dumps(row) for row in rows) + "\n")
assert read_learnings(path, cap=3) == [
{"skill": "gitnexus-work", "n": 7},
{"skill": "gitnexus-work", "n": 8},
{"skill": "gitnexus-work", "n": 9},
]
def test_summarize_gate_one_line_per_decision():
promotion = {
"decisions": [
{
"candidate_arm": "candidate_workflow",
"decision": "keep_incumbent",
"reasons": ["a", "b", "c", "d"],
}
]
}
lines = summarize_gate(promotion)
assert lines == ["candidate_workflow: keep_incumbent — a; b; c"]
def test_build_proposer_prompt_carries_evidence_constraints_and_paths(tmp_path):
prompt = build_proposer_prompt(
results_dir=tmp_path / "bench",
evidence=[row(task="fail", resolved=False, error_kind="verify-failed")],
learnings=[{"skill": "gitnexus-work", "friction": "budget blown on reruns"}],
gate_summary=["candidate_workflow: keep_incumbent — quality regressed"],
overlay_dir=tmp_path / "overlay",
proposal_path=tmp_path / "proposal.md",
incumbent_arms=["workflow"],
)
assert str(tmp_path / "overlay") in prompt
assert str(tmp_path / "proposal.md") in prompt
assert "gitnexus-plan, gitnexus-work" in prompt
assert "node .gitnexus/run.cjs analyze" in prompt
assert "1 row(s) in /evidence/learnings.json" in prompt
assert "1 selected row(s) in /evidence/selected-rows.json" in prompt
assert "1 decision(s) in /evidence/gate-summary.json" in prompt
assert "budget blown on reruns" not in prompt
assert "verify-failed" not in prompt
assert "~/.claude/projects" not in prompt
def test_build_proposer_prompt_first_generation_has_no_results_dir(tmp_path):
prompt = build_proposer_prompt(
results_dir=None,
evidence=[],
learnings=[],
gate_summary=[],
overlay_dir=tmp_path / "overlay",
proposal_path=tmp_path / "proposal.md",
incumbent_arms=["workflow_direct"],
)
assert "none (first generation)" in prompt
assert "none yet — use the incumbent skills and staged learning queue" in prompt
def test_proposer_reads_only_digest_bound_transcripts_below_results(tmp_path, monkeypatch):
results = tmp_path / "results"
transcripts = results / "transcripts"
transcripts.mkdir(parents=True, mode=0o700)
transcripts.chmod(0o700)
payload = b'{"message":{"content":[{"type":"text","text":"bound transcript"}]}}\n'
artifact = transcripts / "task-workflow-run0-session.jsonl"
artifact.write_bytes(payload)
artifact.chmod(0o600)
metadata = {
"path": "transcripts/task-workflow-run0-session.jsonl",
"sha256": hashlib.sha256(payload).hexdigest(),
"bytes": len(payload),
"source": PARENT_EVENT_STREAM_SOURCE,
}
foreign_home = tmp_path / "foreign-home"
foreign = foreign_home / ".claude" / "projects" / "other" / "private.jsonl"
foreign.parent.mkdir(parents=True)
foreign.write_text("foreign host transcript")
monkeypatch.setenv("HOME", str(foreign_home))
entries = proposer_evidence_entries(
results_dir=results,
evidence=[row(session_ids=["**/*"], transcript_artifacts=[metadata])],
learnings=[],
gate_summary=[],
)
assert entries["transcript-0-0.jsonl"] == payload.decode()
assert "foreign host transcript" not in json.dumps(entries)
bad_digest = {**metadata, "sha256": "0" * 64}
with pytest.raises(evolve.SandboxError, match="digest does not match"):
proposer_evidence_entries(
results_dir=results,
evidence=[row(transcript_artifacts=[bad_digest])],
learnings=[],
gate_summary=[],
)
@pytest.mark.skipif(os.name == "nt", reason="transcript symlink containment is POSIX-only")
def test_proposer_rejects_symlink_and_foreign_transcript_artifacts(tmp_path):
results = tmp_path / "results"
transcripts = results / "transcripts"
transcripts.mkdir(parents=True, mode=0o700)
transcripts.chmod(0o700)
outside = tmp_path / "outside.jsonl"
outside.write_text("outside")
linked = transcripts / "linked.jsonl"
linked.symlink_to(outside)
link_metadata = {
"path": "transcripts/linked.jsonl",
"sha256": hashlib.sha256(outside.read_bytes()).hexdigest(),
"bytes": outside.stat().st_size,
"source": PARENT_EVENT_STREAM_SOURCE,
}
with pytest.raises(evolve.SandboxError, match="regular non-symlink"):
proposer_evidence_entries(
results_dir=results,
evidence=[row(transcript_artifacts=[link_metadata])],
learnings=[],
gate_summary=[],
)
with pytest.raises(evolve.SandboxError, match="unsafe results artifact path"):
proposer_evidence_entries(
results_dir=results,
evidence=[
row(
transcript_artifacts=[
{
"path": "../outside.jsonl",
"sha256": "0" * 64,
"bytes": 0,
"source": PARENT_EVENT_STREAM_SOURCE,
}
]
)
],
learnings=[],
gate_summary=[],
)
def test_proposer_rejects_duplicate_transcript_metadata_before_materializing():
metadata = {
"path": "transcripts/repeated.jsonl",
"sha256": "0" * 64,
"bytes": 0,
"source": PARENT_EVENT_STREAM_SOURCE,
}
with pytest.raises(evolve.SandboxError, match="duplicate transcript artifact path"):
proposer_evidence_entries(
results_dir=None,
evidence=[
row(
transcript_artifacts=[
metadata,
{**metadata, "path": "transcripts//repeated.jsonl"},
]
)
],
learnings=[],
gate_summary=[],
)
def test_proposer_bounds_transcript_metadata_per_row_and_globally_before_materializing():
def metadata(index):
return {
"path": f"transcripts/session-{index}.jsonl",
"sha256": "0" * 64,
"bytes": 0,
"source": PARENT_EVENT_STREAM_SOURCE,
}
with pytest.raises(evolve.SandboxError, match="per-row session limit"):
proposer_evidence_entries(
results_dir=None,
evidence=[row(transcript_artifacts=[metadata(index) for index in range(3)])],
learnings=[],
gate_summary=[],
)
rows = [
row(
run=index,
transcript_artifacts=[metadata(2 * index), metadata(2 * index + 1)],
)
for index in range(evolve.MAX_EVIDENCE_ROWS + 1)
]
with pytest.raises(evolve.SandboxError, match="global evidence limit"):
proposer_evidence_entries(
results_dir=None,
evidence=rows,
learnings=[],
gate_summary=[],
)
def test_parser_defaults_match_the_gate_minimums():
args = build_parser().parse_args(["--tasks", "t.yaml", "--model", "pinned"])
assert args.runs == 3
assert args.generations == 1
assert args.arms is None
assert args.apply is False
assert args.learnings.name == "learnings.jsonl"
@pytest.mark.parametrize(
"arguments",
[
["--model", "Auto"],
["--model", "provider/latest"],
["--model", "pinned-model", "--proposer-model", "vendor@LATEST"],
],
)
def test_evolve_rejects_mutable_model_aliases(monkeypatch, tmp_path, capsys, arguments):
monkeypatch.setattr(
sys,
"argv",
["workflow_bench.evolve", "--tasks", str(tmp_path / "missing.yaml"), *arguments],
)
with pytest.raises(SystemExit):
evolve.main()
assert "mutable auto/latest" in capsys.readouterr().err
def test_evolve_proposer_failure_returns_nonzero(monkeypatch, tmp_path):
tasks = tmp_path / "tasks.yaml"
tasks.write_text(
"""tasks:
- id: demo
class: test
repo: .
prompt: implement
verify: "true"
oracle:
command: "true"
files:
- source: hidden.test.ts
target: hidden.test.ts
"""
)
monkeypatch.setattr(
sys,
"argv",
[
"workflow_bench.evolve",
"--tasks",
str(tasks),
"--model",
"pinned-model",
"--out-root",
str(tmp_path / "out"),
],
)
monkeypatch.setattr(evolve.runner, "selected_task_bindings", lambda _tasks: [{"id": "demo"}])
monkeypatch.setattr(evolve, "preflight_bubblewrap", lambda: tmp_path / "bwrap")
monkeypatch.setattr(evolve, "require_claude_sandbox_helpers", lambda: None)
monkeypatch.setattr(
evolve,
"run_proposer",
lambda *args, **kwargs: {"ok": False, "error_detail": "proposer failed"},
)
assert evolve.main() == 1
def test_proposer_session_record_is_redacted_before_upload(monkeypatch, tmp_path):
tasks = tmp_path / "tasks.yaml"
tasks.write_text(
"""tasks:
- id: demo
class: test
repo: .
prompt: implement
verify: "true"
oracle:
command: "true"
files:
- source: hidden.test.ts
target: hidden.test.ts
"""
)
literal_token = "secret-LITERAL-XYZ"
pattern_token = "sk-ant-FAKEEXAMPLE0000"
monkeypatch.setattr(
sys,
"argv",
[
"workflow_bench.evolve",
"--tasks",
str(tasks),
"--model",
"pinned-model",
"--out-root",
str(tmp_path / "out"),
"--auth-token",
literal_token,
],
)
monkeypatch.setattr(evolve.runner, "selected_task_bindings", lambda _tasks: [{"id": "demo"}])
monkeypatch.setattr(evolve, "preflight_bubblewrap", lambda: tmp_path / "bwrap")
monkeypatch.setattr(evolve, "require_claude_sandbox_helpers", lambda: None)
# A session error whose stderr echoed both the literal API key and an
# sk-ant-shaped token into the record that gets written to the artifact.
monkeypatch.setattr(
evolve,
"run_proposer",
lambda *args, **kwargs: {
"ok": False,
"error_detail": {"stderr_tail": f"boom {literal_token} {pattern_token}"},
},
)
assert evolve.main() == 1
written = (tmp_path / "out" / "gen-0" / "proposer-session.json").read_text()
assert literal_token not in written
assert pattern_token not in written
assert "[REDACTED]" in written
def test_runner_argv_pairs_each_incumbent_with_its_candidate(tmp_path):
args = build_parser().parse_args(
[
"--tasks",
"t.yaml",
"--model",
"pinned",
"--arms",
"workflow",
"--include-expensive",
]
)
overlay = tmp_path / "overlay"
skill = overlay / ".claude" / "skills" / "gitnexus-plan" / "SKILL.md"
skill.parent.mkdir(parents=True)
skill.write_text("candidate")
task_bindings = [{"id": "task", "resolved_sha": "a" * 40}]
target_bases = {".claude/skills/gitnexus-plan/SKILL.md": "b" * 64}
argv = runner_argv(
args,
tmp_path / "bench",
overlay,
task_bindings=task_bindings,
target_base_digests=target_bases,
proposer_model="pinned",
)
arms = argv[argv.index("--arms") + 1 : argv.index("--promotion-metric")]
assert arms == ["workflow", "candidate_workflow"]
assert str(overlay) in argv
assert str(tmp_path / "bench") in argv
assert "pinned" in argv
assert argv[argv.index("--proposer-model") + 1] == "pinned"
assert "--include-expensive" in argv
assert json.loads(argv[argv.index("--task-bindings-json") + 1]) == task_bindings
assert json.loads(argv[argv.index("--promotion-target-bases-json") + 1]) == target_bases
def test_runner_argv_omits_proposer_for_manual_overlay(tmp_path):
args = build_parser().parse_args(["--tasks", "t.yaml", "--model", "pinned"])
overlay = tmp_path / "overlay"
skill = overlay / ".claude" / "skills" / "gitnexus-plan" / "SKILL.md"
skill.parent.mkdir(parents=True)
skill.write_text("candidate")
argv = runner_argv(
args,
tmp_path / "bench",
overlay,
task_bindings=[{"id": "task"}],
target_base_digests={},
proposer_model=None,
)
assert "--proposer-model" not in argv
def test_runner_argv_keeps_task_commit_pinned_when_ref_moves(tmp_path):
repo = tmp_path / "task-repo"
repo.mkdir()
def git(*arguments):
return subprocess.run(
["git", "-C", str(repo), *arguments],
check=True,
capture_output=True,
text=True,
).stdout.strip()
git("init", "-b", "main")
git("config", "user.name", "Workflow Bench Test")
git("config", "user.email", "workflow-bench@example.invalid")
tracked = repo / "tracked.txt"
tracked.write_text("one")
git("add", "tracked.txt")
git("commit", "-m", "first")
first_sha = git("rev-parse", "HEAD")
task = {
"id": "moving-ref",
"class": "test",
"repo": str(repo),
"ref": "main",
"prompt": "test prompt",
"verify": "true",
"oracle": {
"command": "true",
"files": [
{
"source": "trivial-version-alias.oracle.test.ts",
"target": "oracle.test.ts",
}
],
},
}
bindings = evolve.runner.selected_task_bindings([task])
tracked.write_text("two")
git("commit", "-am", "second")
assert git("rev-parse", "main") != first_sha
args = build_parser().parse_args(["--tasks", "t.yaml", "--model", "pinned"])
overlay = tmp_path / "overlay"
skill = overlay / ".claude" / "skills" / "gitnexus-plan" / "SKILL.md"
skill.parent.mkdir(parents=True)
skill.write_text("candidate")
argv = runner_argv(
args,
tmp_path / "bench",
overlay,
task_bindings=bindings,
target_base_digests={},
)
forwarded = json.loads(argv[argv.index("--task-bindings-json") + 1])
assert forwarded[0]["resolved_sha"] == first_sha
assert evolve.runner.resolve_task_bindings([task], forwarded)[0]["resolved_sha"] == first_sha
def test_generation_timeout_budgets_three_task_workflow_pair():
timeout = generation_timeout_seconds(
task_count=3,
runs=3,
session_timeout=3600,
incumbent_arms=["workflow"],
)
per_task_preparation = (
evolve.TASK_BINDING_GIT_PHASES * evolve.GIT_COMMAND_TIMEOUT_SECONDS
+ 2 * evolve.TASK_SNAPSHOT_TIMEOUT_SECONDS
+ evolve.WORKTREE_PREPARATION_TIMEOUT_SECONDS
+ evolve.GRAPH_SOURCE_PREPARATION_TIMEOUT_SECONDS
+ evolve.GRAPH_BUILD_TIMEOUT_SECONDS
+ 2 * evolve.GRAPH_QUERY_TIMEOUT_SECONDS
+ evolve.CLEANUP_TIMEOUT_SECONDS
)
paired_arm_cells = 2
session_slots = 4
workspace_snapshot_slots = 4
per_task_run = session_slots * (3600 + evolve.SESSION_FINALIZATION_TIMEOUT_SECONDS) + paired_arm_cells * (
evolve.WORKTREE_PREPARATION_TIMEOUT_SECONDS
+ evolve.ARM_ASSET_MATERIALIZATION_PHASES * evolve.TASK_SNAPSHOT_TIMEOUT_SECONDS
+ evolve.SETUP_TIMEOUT_SECONDS
+ 2 * 3600
+ evolve.ARM_EVIDENCE_GIT_PHASES * evolve.GIT_COMMAND_TIMEOUT_SECONDS
+ evolve.CLEANUP_TIMEOUT_SECONDS
)
per_task_run += workspace_snapshot_slots * evolve.TASK_SNAPSHOT_TIMEOUT_SECONDS
per_task_run += evolve.CANDIDATE_OVERLAY_GIT_PHASES * evolve.GIT_COMMAND_TIMEOUT_SECONDS
assert timeout == (
evolve.PROMOTION_BASE_TIMEOUT_SECONDS
+ 3 * (per_task_preparation + 3 * per_task_run)
+ evolve.DRIVER_OVERHEAD_SECONDS
)
# The old deadline omitted clone sanitization entirely. Every graph seed
# and every paired arm cell must now receive the full bounded envelope.
assert timeout >= 3 * (1 + 3 * paired_arm_cells) * evolve.WORKTREE_PREPARATION_TIMEOUT_SECONDS
@pytest.mark.skipif(sys.platform != "linux", reason="Bubblewrap PID namespaces require Linux")
def test_outer_runner_pid_namespace_kills_setsid_descendant(tmp_path):
try:
bwrap = preflight_bubblewrap()
except evolve.SandboxError as exc:
pytest.skip(str(exc))
raise AssertionError("pytest.skip() returned unexpectedly")
sentinel = tmp_path / "escaped"
child = (
"import os,subprocess,sys,time; "
f"subprocess.Popen([sys.executable,'-c',\"import time,pathlib;time.sleep(1);pathlib.Path({str(sentinel)!r}).touch()\"],preexec_fn=os.setsid); "
"time.sleep(10)"
)
result = run_managed(
pid_namespace_command([sys.executable, "-c", child], bwrap_bin=bwrap),
timeout=0.15,
terminate_grace=0.1,
require_pid_namespace=True,
)
time.sleep(1.1)
assert not result.ok
assert result.state in {"timeout", "forced-kill"}
assert not sentinel.exists()
def test_resolve_incumbent_arms_rejects_incomplete_and_extra_explicit_sets(tmp_path):
plan = tmp_path / "plan"
plan_skill = plan / ".claude" / "skills" / "gitnexus-plan" / "SKILL.md"
plan_skill.parent.mkdir(parents=True)
plan_skill.write_text("plan")
assert resolve_incumbent_arms(plan, None) == ["workflow"]
with pytest.raises(ValueError, match="exactly"):
resolve_incumbent_arms(plan, ["workflow", "workflow_direct"])
work = tmp_path / "work"
work_skill = work / ".claude" / "skills" / "gitnexus-work" / "SKILL.md"
work_skill.parent.mkdir(parents=True)
work_skill.write_text("work")
assert resolve_incumbent_arms(work, None) == ["workflow", "workflow_direct"]
with pytest.raises(ValueError, match="exactly"):
resolve_incumbent_arms(work, ["workflow"])
def bound_task_fixture():
return {
"id": "task",
"prompt_digest": "prompt",
"oracle_digest": "a" * 64,
"oracle_command_digest": "b" * 64,
"oracle_manifest_digest": "c" * 64,
"sandbox_dependency_content_digest": "e" * 64,
"sandbox_dependency_manifest_digest": "f" * 64,
"oracle_files": [{"target": "oracle.test.ts", "sha256": "d" * 64, "size": 10}],
}
def promotion_fixture(*, decisions=None, expires_delta=timedelta(days=1)):
now = datetime.now(UTC)
return {
"schema_version": 3,
"generated_at": now.isoformat(),
"evidence_expires_at": (now + expires_delta).isoformat(),
"benchmark_model": "bench-model",
"proposer_model": "proposer-model",
"candidate_origin": "model-proposer",
"candidate_overlay_digest": "digest",
"target_base_digests": {"path": "base"},
"required_candidate_arms": ["candidate_workflow"],
"selected_tasks": [bound_task_fixture()],
"policy": {
"metric": "cost_usd",
"min_runs": 3,
"min_improvement_pct": 5.0,
"max_task_regression_pct": 20.0,
},
"decisions": (
decisions
if decisions is not None
else [
{
"incumbent_arm": "workflow",
"candidate_arm": "candidate_workflow",
"decision": "promote",
"metric": "cost_usd",
}
]
),
}
def validate_fixture(promotion):
return validate_promotion_for_apply(
promotion,
overlay_digest="digest",
benchmark_model="bench-model",
proposer_model="proposer-model",
selected_tasks=[bound_task_fixture()],
target_base_digests={"path": "base"},
required_candidate_arms=["candidate_workflow"],
policy={
"metric": "cost_usd",
"min_runs": 3,
"min_improvement_pct": 5.0,
"max_task_regression_pct": 20.0,
},
)
def test_promotion_apply_requires_one_promote_for_every_bound_arm():
assert [d["candidate_arm"] for d in validate_fixture(promotion_fixture())] == ["candidate_workflow"]
for decisions in (
[],
[
{
"incumbent_arm": "workflow",
"candidate_arm": "candidate_workflow",
"decision": "keep_incumbent",
"metric": "cost_usd",
}
],
[
{
"incumbent_arm": "workflow",
"candidate_arm": "candidate_workflow",
"decision": "promote",
"metric": "cost_usd",
},
{
"incumbent_arm": "workflow",
"candidate_arm": "candidate_workflow",
"decision": "promote",
"metric": "cost_usd",
},
],
[
{
"incumbent_arm": "workflow_direct",
"candidate_arm": "candidate_workflow_direct",
"decision": "promote",
"metric": "cost_usd",
}
],
):
with pytest.raises(ValueError):
validate_fixture(promotion_fixture(decisions=decisions))
def test_manual_initial_overlay_has_no_fictitious_proposer_model():
promotion = promotion_fixture()
promotion["proposer_model"] = None
promotion["candidate_origin"] = "manual-initial-overlay"
decisions = validate_promotion_for_apply(
promotion,
overlay_digest="digest",
benchmark_model="bench-model",
proposer_model=None,
selected_tasks=[bound_task_fixture()],
target_base_digests={"path": "base"},
required_candidate_arms=["candidate_workflow"],
policy=promotion["policy"],
)
assert decisions[0]["decision"] == "promote"
def test_promotion_apply_rejects_pre_oracle_schema_and_missing_oracle_bindings():
legacy = promotion_fixture()
legacy["schema_version"] = 2
with pytest.raises(ValueError, match="unsupported schema"):
validate_fixture(legacy)
weak_task = {"id": "task", "prompt_digest": "prompt"}
weak = promotion_fixture()
weak["selected_tasks"] = [weak_task]
with pytest.raises(ValueError, match="hidden-oracle or dependency digests"):
validate_promotion_for_apply(
weak,
overlay_digest="digest",
benchmark_model="bench-model",
proposer_model="proposer-model",
selected_tasks=[weak_task],
target_base_digests={"path": "base"},
required_candidate_arms=["candidate_workflow"],
policy={
"metric": "cost_usd",
"min_runs": 3,
"min_improvement_pct": 5.0,
"max_task_regression_pct": 20.0,
},
)
@pytest.mark.parametrize(
("field", "value"),
[
("benchmark_model", "other"),
("proposer_model", "other"),
("candidate_overlay_digest", "other"),
("target_base_digests", {"path": "other"}),
("required_candidate_arms", ["candidate_workflow_direct"]),
("selected_tasks", [{"id": "other", "prompt_digest": "prompt"}]),
(
"policy",
{
"metric": "cost_usd",
"min_runs": 4,
"min_improvement_pct": 5.0,
"max_task_regression_pct": 20.0,
},
),
],
)
def test_promotion_apply_rejects_mismatched_evidence_bindings(field, value):
promotion = promotion_fixture()
promotion[field] = value
with pytest.raises(ValueError, match="binding"):
validate_fixture(promotion)
def test_promotion_apply_rejects_expired_evidence():
with pytest.raises(ValueError, match="expired"):
validate_fixture(promotion_fixture(expires_delta=timedelta(seconds=-1)))
def test_promotion_apply_rejects_extended_or_future_dated_evidence():
with pytest.raises(ValueError, match="expired"):
validate_fixture(promotion_fixture(expires_delta=timedelta(days=91)))
promotion = promotion_fixture()
future = datetime.now(UTC) + timedelta(days=1)
promotion["generated_at"] = future.isoformat()
promotion["evidence_expires_at"] = (future + timedelta(days=1)).isoformat()
with pytest.raises(ValueError, match="future"):
validate_fixture(promotion)
def test_promotion_apply_rejects_decision_metric_mismatch():
promotion = promotion_fixture()
promotion["decisions"][0]["metric"] = "output_tokens"
with pytest.raises(ValueError, match="metric mismatch"):
validate_fixture(promotion)