| 1 | """Frozen local agent campaigns with durable, non-retrying trial journals.
|
| 2 |
|
| 3 | The manifest attestation is an operator assertion, not an OS sandbox. Episode
|
| 4 | wall budgets control admission and the next agent timeout; setup/oracle overhead
|
| 5 | can exceed that budget. No dollar or provider-token ceiling is claimed.
|
| 6 | """
|
| 7 | from __future__ import annotations
|
| 8 |
|
| 9 | import argparse
|
| 10 | import contextlib
|
| 11 | from dataclasses import replace
|
| 12 | from datetime import datetime, timezone
|
| 13 | import fcntl
|
| 14 | import hashlib
|
| 15 | import json
|
| 16 | import math
|
| 17 | import os
|
| 18 | from pathlib import Path
|
| 19 | import random
|
| 20 | import re
|
| 21 | import sys
|
| 22 | import stat
|
| 23 | import time
|
| 24 | import tempfile
|
| 25 | from typing import Any
|
| 26 |
|
| 27 | SCHEMA_VERSION = 1
|
| 28 | WORKFLOWS = {"wide_config_refactor", "large_asset_manifest"}
|
| 29 | LEVELS = {"zero-shot", "cheat-sheet", "full-docs"}
|
| 30 | ROOT = Path(__file__).resolve().parents[2]
|
| 31 |
|
| 32 |
|
| 33 | class CampaignError(ValueError):
|
| 34 | pass
|
| 35 |
|
| 36 |
|
| 37 | def now() -> str:
|
| 38 | return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
| 39 |
|
| 40 |
|
| 41 | def canonical(value: Any) -> bytes:
|
| 42 | return json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False).encode()
|
| 43 |
|
| 44 |
|
| 45 | def digest(value: Any) -> str:
|
| 46 | return hashlib.sha256(canonical(value)).hexdigest()
|
| 47 |
|
| 48 |
|
| 49 | def file_hash(path: Path) -> str:
|
| 50 | hasher = hashlib.sha256()
|
| 51 | with path.open("rb") as stream:
|
| 52 | for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
| 53 | hasher.update(chunk)
|
| 54 | return hasher.hexdigest()
|
| 55 |
|
| 56 |
|
| 57 | def _unique_object(pairs):
|
| 58 | data = {}
|
| 59 | for key, value in pairs:
|
| 60 | if key in data:
|
| 61 | raise CampaignError("duplicate JSON key: " + key)
|
| 62 | data[key] = value
|
| 63 | return data
|
| 64 |
|
| 65 |
|
| 66 | def read_json(path: Path) -> Any:
|
| 67 | try:
|
| 68 | return json.loads(path.read_text(), object_pairs_hook=_unique_object,
|
| 69 | parse_constant=lambda value: (_ for _ in ()).throw(CampaignError("nonfinite JSON")))
|
| 70 | except (OSError, ValueError) as exc:
|
| 71 | raise CampaignError(f"cannot read {path}: {exc}") from exc
|
| 72 |
|
| 73 |
|
| 74 | def _positive(value, name, integer=False):
|
| 75 | valid = type(value) in ((int,) if integer else (int, float))
|
| 76 | try:
|
| 77 | valid = valid and value > 0 and (integer or math.isfinite(value))
|
| 78 | except OverflowError:
|
| 79 | valid = False
|
| 80 | if not valid:
|
| 81 | raise CampaignError(name + " must be positive" + (" integer" if integer else " finite number"))
|
| 82 |
|
| 83 |
|
| 84 | def _names(value, name, allowed=None):
|
| 85 | if not isinstance(value, list) or not value or any(not isinstance(x, str) or not x for x in value):
|
| 86 | raise CampaignError(name + " must be a nonempty string array")
|
| 87 | if len(set(value)) != len(value):
|
| 88 | raise CampaignError("duplicate " + name)
|
| 89 | if allowed is not None and not set(value) <= allowed:
|
| 90 | raise CampaignError("unsupported " + name + ": " + ", ".join(sorted(set(value) - allowed)))
|
| 91 |
|
| 92 |
|
| 93 | def load_manifest(path: Path) -> dict:
|
| 94 | return validate_manifest(read_json(path), path.resolve().parent)
|
| 95 |
|
| 96 |
|
| 97 | def validate_manifest(raw, base: Path) -> dict:
|
| 98 | required = {"schema_version", "campaign_id", "agents_config", "subjects_config", "agents", "subjects",
|
| 99 | "workflows", "instruction_levels", "repetitions", "seed", "max_trials", "max_wall_seconds",
|
| 100 | "trial_timeout_seconds", "track", "agent_environment"}
|
| 101 | if not isinstance(raw, dict) or not required <= set(raw) or set(raw) - required - {"isolation_attestation", "output_limit_bytes"}:
|
| 102 | raise CampaignError("manifest has missing or unknown fields")
|
| 103 | if type(raw["schema_version"]) is not int or raw["schema_version"] != SCHEMA_VERSION:
|
| 104 | raise CampaignError("unsupported campaign schema_version")
|
| 105 | if not isinstance(raw["campaign_id"], str) or not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_-]{0,79}", raw["campaign_id"]):
|
| 106 | raise CampaignError("invalid campaign_id")
|
| 107 | _names(raw["subjects"], "subjects")
|
| 108 | _names(raw["workflows"], "workflows", WORKFLOWS)
|
| 109 | _names(raw["instruction_levels"], "instruction_levels", LEVELS)
|
| 110 | if raw["track"] not in {"agent-default", "core-equivalent"} or raw["agent_environment"] != "minimal":
|
| 111 | raise CampaignError("campaigns require a supported track and minimal agent_environment")
|
| 112 | for key in ("repetitions", "max_trials"):
|
| 113 | _positive(raw[key], key, True)
|
| 114 | for key in ("trial_timeout_seconds", "max_wall_seconds"):
|
| 115 | _positive(raw[key], key)
|
| 116 | if type(raw["seed"]) is not int:
|
| 117 | raise CampaignError("seed must be an integer")
|
| 118 | if "output_limit_bytes" in raw:
|
| 119 | _positive(raw["output_limit_bytes"], "output_limit_bytes", True)
|
| 120 | agents = raw["agents"]
|
| 121 | if not isinstance(agents, list) or not agents:
|
| 122 | raise CampaignError("agents must be a nonempty array")
|
| 123 | for agent in agents:
|
| 124 | if not isinstance(agent, dict) or set(agent) != {"name", "model"}:
|
| 125 | raise CampaignError("each agent requires exactly name and model")
|
| 126 | if agent["model"] is not None and (not isinstance(agent["model"], str) or not agent["model"].strip()):
|
| 127 | raise CampaignError("model must be null or a nonempty string")
|
| 128 | _names([agent["name"] for agent in agents], "agents")
|
| 129 | for name in raw["subjects"] + [agent["name"] for agent in agents]:
|
| 130 | if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_-]{0,79}", name):
|
| 131 | raise CampaignError("agent and subject names must be safe slugs")
|
| 132 | raw = dict(raw)
|
| 133 | for key in ("agents_config", "subjects_config"):
|
| 134 | if not isinstance(raw[key], str) or not raw[key]:
|
| 135 | raise CampaignError(key + " must be a path")
|
| 136 | raw[key] = str((base / Path(raw[key]).expanduser()).resolve())
|
| 137 | return raw
|
| 138 |
|
| 139 |
|
| 140 | def schedule(manifest: dict) -> list[dict]:
|
| 141 | size = (len(manifest["subjects"]) * len(manifest["agents"]) * len(manifest["workflows"])
|
| 142 | * len(manifest["instruction_levels"]) * manifest["repetitions"])
|
| 143 | if size > manifest["max_trials"] or size > 10000:
|
| 144 | raise CampaignError(f"schedule has {size} trials; exceeds manifest or 10000-trial safety limit")
|
| 145 | rows = []
|
| 146 | rng = random.Random(manifest["seed"])
|
| 147 | groups = [(workflow, agent, level) for workflow in manifest["workflows"]
|
| 148 | for agent in manifest["agents"] for level in manifest["instruction_levels"]]
|
| 149 | for repetition in range(manifest["repetitions"]):
|
| 150 | block_order = list(groups)
|
| 151 | rng.shuffle(block_order)
|
| 152 | for workflow, agent, level in block_order:
|
| 153 | identity = {"campaign_id": manifest["campaign_id"], "workflow": workflow,
|
| 154 | "agent": agent["name"], "model": agent["model"], "instruction_level": level}
|
| 155 | order = list(manifest["subjects"])
|
| 156 | random.Random(str(manifest["seed"]) + digest(identity)).shuffle(order)
|
| 157 | shift = repetition % len(order)
|
| 158 | order = order[shift:] + order[:shift]
|
| 159 | block = "block-" + digest({**identity, "repetition": repetition})[:24]
|
| 160 | for subject in order:
|
| 161 | rows.append({**identity, "subject": subject, "repetition": repetition,
|
| 162 | "block_id": block, "trial_id": "trial-" + digest([block, subject])[:24],
|
| 163 | "ordinal": len(rows)})
|
| 164 | if len(rows) > manifest["max_trials"]:
|
| 165 | raise CampaignError(f"schedule has {len(rows)} trials, exceeds max_trials={manifest['max_trials']}")
|
| 166 | return rows
|
| 167 |
|
| 168 |
|
| 169 | def resolve_runtime(manifest: dict):
|
| 170 | import agent_workflow
|
| 171 | from oakbench.subjects import load_subjects, subject_details
|
| 172 | try:
|
| 173 | agents = agent_workflow.load_agents(Path(manifest["agents_config"]), ",".join(x["name"] for x in manifest["agents"]))
|
| 174 | subjects = load_subjects(argparse.Namespace(config=Path(manifest["subjects_config"]),
|
| 175 | subjects=",".join(manifest["subjects"]), git_bin=None, oak_installed_bin=None, oak_local_bin=None))
|
| 176 | except (SystemExit, KeyError, OSError) as exc:
|
| 177 | raise CampaignError("configuration preflight failed: " + str(exc)) from exc
|
| 178 | if {x.name for x in subjects} != set(manifest["subjects"]):
|
| 179 | raise CampaignError("unknown selected subject")
|
| 180 | requested = {x["name"]: x["model"] for x in manifest["agents"]}
|
| 181 | if {agent.name for agent in agents} != set(requested):
|
| 182 | raise CampaignError("selected agent names must identify exact configured entries")
|
| 183 | agents = [replace(agent, model=requested[agent.name]) for agent in agents]
|
| 184 | for agent in agents:
|
| 185 | if agent.adapter not in {"deterministic_mock", "codex_exec_json", "claude_print_stream_json", "cursor_agent_stream_json"}:
|
| 186 | raise CampaignError("unsupported agent adapter: " + agent.adapter)
|
| 187 | if agent.adapter == "deterministic_mock" and agent.model is not None:
|
| 188 | raise CampaignError("mock agents require model=null")
|
| 189 | if agent.adapter != "deterministic_mock" and not agent.model:
|
| 190 | raise CampaignError("real agents require an explicit requested model: " + agent.name)
|
| 191 | for subject in subjects:
|
| 192 | if subject.kind not in {"git", "oak"} or subject.git_mode is not None:
|
| 193 | raise CampaignError("only local Git/Oak subjects are supported")
|
| 194 | for binary in [x.bin for x in agents if x.bin is not None] + [x.bin for x in subjects]:
|
| 195 | if not binary.is_file() or not os.access(binary, os.X_OK):
|
| 196 | raise CampaignError("not an executable binary: " + str(binary))
|
| 197 | from oakbench.runlock import measurement_lock_path
|
| 198 | provenance = {
|
| 199 | "measurement_lock_path": str(measurement_lock_path()),
|
| 200 | "configs": {key: {"path": manifest[key], "sha256": file_hash(Path(manifest[key]))}
|
| 201 | for key in ("agents_config", "subjects_config")},
|
| 202 | "subjects": subject_details(subjects),
|
| 203 | "agents": {agent.name: {"adapter": agent.adapter, "provider": agent.provider, "model": agent.model,
|
| 204 | "version": agent.version, "bin": str(agent.bin.resolve()) if agent.bin else None,
|
| 205 | "binary_sha256": file_hash(agent.bin) if agent.bin else None} for agent in agents},
|
| 206 | "harness": {str(path.relative_to(ROOT)): file_hash(path) for path in sorted((ROOT / "scripts").rglob("*.py"))},
|
| 207 | "configuration_and_prompts": {
|
| 208 | str(path.relative_to(ROOT)): file_hash(path)
|
| 209 | for directory in (ROOT / "config", ROOT / "scenarios")
|
| 210 | for path in sorted(directory.rglob("*")) if path.is_file() and not any(part.startswith(".") for part in path.relative_to(directory).parts)
|
| 211 | },
|
| 212 | }
|
| 213 | return {x.name: x for x in agents}, {x.name: x for x in subjects}, provenance
|
| 214 |
|
| 215 |
|
| 216 | def safe_path(path: Path) -> Path:
|
| 217 | if ".." in path.parts:
|
| 218 | raise CampaignError("destination path traversal is not allowed")
|
| 219 | absolute = path.absolute()
|
| 220 | for item in [absolute, *absolute.parents]:
|
| 221 | if item.is_symlink() and not (str(item) in {"/tmp", "/var"} and str(item.resolve()) == "/private" + str(item)):
|
| 222 | raise CampaignError("destination symlink is not allowed: " + str(item))
|
| 223 | return absolute.resolve()
|
| 224 |
|
| 225 |
|
| 226 | def artifact_hashes(trial_root: Path) -> dict[str, str]:
|
| 227 | candidates = list((trial_root / "artifacts").rglob("*"))
|
| 228 | candidates.extend((trial_root / "work").rglob("AGENTS.md"))
|
| 229 | result = {}
|
| 230 | for path in candidates:
|
| 231 | if path.is_symlink():
|
| 232 | continue
|
| 233 | if path.is_file():
|
| 234 | try:
|
| 235 | safe_path(path)
|
| 236 | result[path.relative_to(trial_root).as_posix()] = file_hash(path)
|
| 237 | except (OSError, CampaignError):
|
| 238 | continue
|
| 239 | return result
|
| 240 |
|
| 241 |
|
| 242 | def _sync_directory(path):
|
| 243 | fd = os.open(path, os.O_RDONLY)
|
| 244 | try:
|
| 245 | os.fsync(fd)
|
| 246 | finally:
|
| 247 | os.close(fd)
|
| 248 |
|
| 249 |
|
| 250 | def atomic_write(path: Path, content: bytes):
|
| 251 | safe_path(path)
|
| 252 | fd, name = tempfile.mkstemp(prefix=path.name + ".tmp-", dir=path.parent)
|
| 253 | temporary = Path(name)
|
| 254 | with os.fdopen(fd, "wb") as stream:
|
| 255 | stream.write(content)
|
| 256 | stream.flush()
|
| 257 | os.fsync(stream.fileno())
|
| 258 | os.replace(temporary, path)
|
| 259 | _sync_directory(path.parent)
|
| 260 |
|
| 261 |
|
| 262 | def create_plan(manifest_path: Path, directory: Path) -> dict:
|
| 263 | manifest = load_manifest(manifest_path)
|
| 264 | trials = schedule(manifest)
|
| 265 | _, _, provenance = resolve_runtime(manifest)
|
| 266 | from oakbench.runner import runner_fields
|
| 267 | plan = {"schema_version": 1, "campaign_id": manifest["campaign_id"], "created_at": now(),
|
| 268 | "manifest": manifest, "provenance": provenance, "runner": runner_fields(), "schedule": trials,
|
| 269 | "budget_policy": "cumulative episode-wall admission; interrupted episodes reserve their timeout; setup/oracle overhead may exceed cap"}
|
| 270 | plan["plan_sha256"] = digest(plan)
|
| 271 | directory = safe_path(directory)
|
| 272 | if directory.exists():
|
| 273 | raise CampaignError("campaign directory already exists; refusing overwrite: " + str(directory))
|
| 274 | try:
|
| 275 | directory.mkdir(parents=True)
|
| 276 | except FileExistsError as exc:
|
| 277 | raise CampaignError("campaign directory collision") from exc
|
| 278 | atomic_write(directory / "plan.json", canonical(plan) + b"\n")
|
| 279 | return plan
|
| 280 |
|
| 281 |
|
| 282 | def load_plan(directory: Path) -> dict:
|
| 283 | safe_path(directory / "plan.json")
|
| 284 | plan = read_json(directory / "plan.json")
|
| 285 | if not isinstance(plan, dict) or type(plan.get("schema_version")) is not int or plan.get("schema_version") != 1:
|
| 286 | raise CampaignError("invalid frozen plan")
|
| 287 | body = {key: value for key, value in plan.items() if key != "plan_sha256"}
|
| 288 | if digest(body) != plan.get("plan_sha256"):
|
| 289 | raise CampaignError("frozen plan fingerprint mismatch")
|
| 290 | try:
|
| 291 | for key in ("campaign_id", "manifest", "schedule", "provenance", "runner"):
|
| 292 | if key not in plan:
|
| 293 | raise CampaignError("invalid frozen plan: missing " + key)
|
| 294 | if not isinstance(plan["provenance"], dict) or not isinstance(plan["runner"], dict):
|
| 295 | raise CampaignError("invalid frozen plan metadata")
|
| 296 | manifest = validate_manifest(plan["manifest"], directory.resolve())
|
| 297 | if manifest != plan["manifest"] or plan["campaign_id"] != manifest["campaign_id"]:
|
| 298 | raise CampaignError("invalid frozen manifest identity or paths")
|
| 299 | if not all(isinstance(plan["runner"].get(key), str) for key in ("runner_id", "runner_class")):
|
| 300 | raise CampaignError("invalid frozen runner identity")
|
| 301 | if schedule(manifest) != plan["schedule"]:
|
| 302 | raise CampaignError("frozen schedule mismatch")
|
| 303 | except (KeyError, TypeError, AttributeError) as exc:
|
| 304 | raise CampaignError("invalid frozen plan structure") from exc
|
| 305 | return plan
|
| 306 |
|
| 307 |
|
| 308 | def validate_trial_row(row: dict, trial: dict, plan: dict):
|
| 309 | import agent_workflow
|
| 310 | if not isinstance(row, dict) or agent_workflow.validate_row(row):
|
| 311 | raise CampaignError("invalid journal terminal row schema")
|
| 312 | expected = {"task_id": trial["trial_id"], "bench_id": plan["campaign_id"],
|
| 313 | "subject": trial["subject"], "scenario": trial["workflow"], "run": trial["repetition"]}
|
| 314 | if any(row.get(key) != value for key, value in expected.items()):
|
| 315 | raise CampaignError("terminal row does not match frozen trial identity")
|
| 316 | expected_agent = {"name": trial["agent"], "model": trial["model"],
|
| 317 | "instruction_level": trial["instruction_level"],
|
| 318 | "environment": plan["manifest"]["agent_environment"]}
|
| 319 | pinned_agent = plan["provenance"].get("agents", {}).get(trial["agent"], {})
|
| 320 | expected_agent.update({key: pinned_agent[key] for key in ("adapter", "provider", "version") if key in pinned_agent})
|
| 321 | if any(row["agent"].get(key) != value for key, value in expected_agent.items()):
|
| 322 | raise CampaignError("terminal row agent does not match frozen trial identity")
|
| 323 |
|
| 324 |
|
| 325 | def load_events(directory: Path, plan: dict, *, tolerate_torn=False) -> list[dict]:
|
| 326 | path = safe_path(directory / "events.jsonl")
|
| 327 | if not path.exists():
|
| 328 | return []
|
| 329 | raw = path.read_bytes()
|
| 330 | tail = b""
|
| 331 | if raw and not raw.endswith(b"\n"):
|
| 332 | if not tolerate_torn:
|
| 333 | raise CampaignError("journal has incomplete final record; resume requires --acknowledge-torn-journal; affected trials are never retried")
|
| 334 | prefix_end = raw.rfind(b"\n") + 1
|
| 335 | raw, tail = raw[:prefix_end], raw[prefix_end:]
|
| 336 | events = []
|
| 337 | known = {trial["trial_id"]: trial for trial in plan["schedule"]}
|
| 338 | started, finished = set(), set()
|
| 339 | for line in raw.splitlines():
|
| 340 | try:
|
| 341 | event = json.loads(line, object_pairs_hook=_unique_object,
|
| 342 | parse_constant=lambda value: (_ for _ in ()).throw(CampaignError("nonfinite journal JSON")))
|
| 343 | except ValueError as exc:
|
| 344 | raise CampaignError("invalid journal record") from exc
|
| 345 | if not isinstance(event, dict):
|
| 346 | raise CampaignError("journal record must be an object")
|
| 347 | if event.get("event") == "journal_recovery":
|
| 348 | if event.get("plan_sha256") != plan["plan_sha256"] or not isinstance(event.get("tail_sha256"), str):
|
| 349 | raise CampaignError("invalid journal recovery marker")
|
| 350 | events.append(event)
|
| 351 | continue
|
| 352 | trial = event.get("trial_id")
|
| 353 | if not isinstance(trial, str):
|
| 354 | raise CampaignError("journal trial_id must be a string")
|
| 355 | if trial not in known or event.get("plan_sha256") != plan["plan_sha256"]:
|
| 356 | raise CampaignError("journal trial or plan mismatch")
|
| 357 | if event.get("event") == "started" and trial not in started:
|
| 358 | _positive(event.get("timeout_seconds"), "journal timeout_seconds")
|
| 359 | started.add(trial)
|
| 360 | elif event.get("event") == "finished" and trial in started and trial not in finished:
|
| 361 | duration = event.get("wall_seconds")
|
| 362 | if type(duration) not in (int, float) or not math.isfinite(duration) or duration < 0:
|
| 363 | raise CampaignError("journal wall_seconds must be finite and nonnegative")
|
| 364 | if event.get("outcome") not in {"pass", "fail", "timeout", "error"}:
|
| 365 | raise CampaignError("invalid journal terminal outcome")
|
| 366 | if event["outcome"] != "error" or event.get("row") is not None:
|
| 367 | row = event.get("row")
|
| 368 | validate_trial_row(row, known[trial], plan)
|
| 369 | if row.get("outcome") != event["outcome"]:
|
| 370 | raise CampaignError("journal outcome contradicts trial row")
|
| 371 | finished.add(trial)
|
| 372 | else:
|
| 373 | raise CampaignError("duplicate or out-of-order journal event")
|
| 374 | events.append(event)
|
| 375 | if tail:
|
| 376 | # Only the most recent intact start can own this torn terminal.
|
| 377 | # Older interrupted trials do not account for a newly torn start.
|
| 378 | active = bool(events and events[-1].get("event") == "started")
|
| 379 | candidate = next((trial for trial in known if trial not in started), None)
|
| 380 | if not active and candidate:
|
| 381 | events.append({"event": "started", "trial_id": candidate, "plan_sha256": plan["plan_sha256"],
|
| 382 | "timeout_seconds": plan["manifest"]["trial_timeout_seconds"], "uncertain_torn_start": True})
|
| 383 | events.append({"event": "journal_recovery", "plan_sha256": plan["plan_sha256"],
|
| 384 | "tail_sha256": hashlib.sha256(tail).hexdigest(), "tail_bytes": len(tail),
|
| 385 | "acknowledged": False})
|
| 386 | return events
|
| 387 |
|
| 388 |
|
| 389 | def acknowledge_torn_journal(directory, plan):
|
| 390 | events = load_events(directory, plan, tolerate_torn=True)
|
| 391 | if not events or events[-1].get("event") != "journal_recovery" or events[-1].get("acknowledged") is not False:
|
| 392 | return events
|
| 393 | raw = (directory / "events.jsonl").read_bytes()
|
| 394 | archive = directory / ("events-before-recovery-" + hashlib.sha256(raw).hexdigest() + ".jsonl")
|
| 395 | if not archive.exists():
|
| 396 | atomic_write(archive, raw)
|
| 397 | elif safe_path(archive).read_bytes() != raw:
|
| 398 | raise CampaignError("recovery archive collision")
|
| 399 | events[-1].update(acknowledged=True, archive=archive.name, archive_sha256=hashlib.sha256(raw).hexdigest(), timestamp_utc=now())
|
| 400 | atomic_write(directory / "events.jsonl", b"".join(canonical(event) + b"\n" for event in events))
|
| 401 | return events
|
| 402 |
|
| 403 |
|
| 404 | def append_event(directory: Path, plan: dict, event: dict):
|
| 405 | record = {**event, "plan_sha256": plan["plan_sha256"], "timestamp_utc": now()}
|
| 406 | with safe_path(directory / "events.jsonl").open("ab") as stream:
|
| 407 | stream.write(canonical(record) + b"\n")
|
| 408 | stream.flush()
|
| 409 | os.fsync(stream.fileno())
|
| 410 | _sync_directory(directory)
|
| 411 | return record
|
| 412 |
|
| 413 |
|
| 414 | def campaign_status(plan: dict, events: list[dict]) -> dict:
|
| 415 | starts = {x["trial_id"]: x for x in events if x["event"] == "started"}
|
| 416 | finishes = {x["trial_id"]: x for x in events if x["event"] == "finished"}
|
| 417 | interrupted = sorted(set(starts) - set(finishes))
|
| 418 | charged = sum(float(x["wall_seconds"]) for x in finishes.values())
|
| 419 | charged += sum(float(starts[key]["timeout_seconds"]) for key in interrupted)
|
| 420 | return {"campaign_id": plan["campaign_id"], "plan_sha256": plan["plan_sha256"],
|
| 421 | "scheduled": len(plan["schedule"]), "finished": len(finishes),
|
| 422 | "journal_recoveries": [x for x in events if x["event"] == "journal_recovery"],
|
| 423 | "interrupted_trial_ids": interrupted,
|
| 424 | "pending_trial_ids": [x["trial_id"] for x in plan["schedule"] if x["trial_id"] not in starts],
|
| 425 | "charged_episode_seconds": charged,
|
| 426 | "remaining_episode_seconds": max(0.0, plan["manifest"]["max_wall_seconds"] - charged)}
|
| 427 |
|
| 428 |
|
| 429 | @contextlib.contextmanager
|
| 430 | def campaign_lock(directory):
|
| 431 | with safe_path(directory / ".run.lock").open("a") as stream:
|
| 432 | try:
|
| 433 | fcntl.flock(stream, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
| 434 | except BlockingIOError as exc:
|
| 435 | raise CampaignError("campaign already has an active runner") from exc
|
| 436 | try:
|
| 437 | yield
|
| 438 | finally:
|
| 439 | fcntl.flock(stream, fcntl.LOCK_UN)
|
| 440 |
|
| 441 |
|
| 442 | def preflight(plan: dict, directory: Path, enable_real=False):
|
| 443 | agents, subjects, provenance = resolve_runtime(plan["manifest"])
|
| 444 | if provenance != plan["provenance"]:
|
| 445 | changed = sorted(key for key in set(provenance) | set(plan["provenance"])
|
| 446 | if provenance.get(key) != plan["provenance"].get(key))
|
| 447 | raise CampaignError("provenance drift in " + ", ".join(changed) + "; create a new campaign")
|
| 448 | from oakbench.runner import runner_fields
|
| 449 | current = runner_fields()
|
| 450 | if any(current[key] != plan["runner"][key] for key in ("runner_id", "runner_class")):
|
| 451 | raise CampaignError("runner identity drift; create a new campaign")
|
| 452 | real = any(agent.adapter != "deterministic_mock" for agent in agents.values())
|
| 453 | if real:
|
| 454 | attestation = plan["manifest"].get("isolation_attestation")
|
| 455 | if not enable_real:
|
| 456 | raise CampaignError("real agents require --enable-real; no paid invocation has started")
|
| 457 | if not isinstance(attestation, dict) or set(attestation) != {"worker_root", "unrestricted_cli_acknowledged"}:
|
| 458 | raise CampaignError("real agents require isolated-worker attestation; it is not a sandbox")
|
| 459 | if attestation["unrestricted_cli_acknowledged"] is not True or not Path(attestation["worker_root"]).is_absolute():
|
| 460 | raise CampaignError("invalid isolated-worker attestation")
|
| 461 | if not directory.resolve().is_relative_to(Path(attestation["worker_root"]).resolve()):
|
| 462 | raise CampaignError("campaign directory is outside attested worker root")
|
| 463 | return agents, subjects
|
| 464 |
|
| 465 |
|
| 466 | class FixtureIntegrityError(CampaignError):
|
| 467 | """A campaign input changed; stop admission, do not score a task failure."""
|
| 468 |
|
| 469 |
|
| 470 | def fixture_tree(path: Path) -> dict:
|
| 471 | from oakbench.conformance import tree_manifest
|
| 472 | try:
|
| 473 | safe_path(path)
|
| 474 | except CampaignError as exc:
|
| 475 | raise FixtureIntegrityError(str(exc)) from exc
|
| 476 | if any((path / name).exists() or (path / name).is_symlink() for name in (".git", ".oak")):
|
| 477 | raise FixtureIntegrityError(f"fixture content drift at {path}: unexpected repository metadata")
|
| 478 | try:
|
| 479 | return tree_manifest(path)
|
| 480 | except (OSError, AssertionError) as exc:
|
| 481 | raise FixtureIntegrityError(f"fixture content drift at {path}: {exc}") from exc
|
| 482 |
|
| 483 |
|
| 484 | def fixture_permissions(path: Path, *, writable: bool):
|
| 485 | # Deterrence only: same-uid processes can chmod these paths again. Hash
|
| 486 | # validation, not permission bits, establishes the copied input identity.
|
| 487 | for item in [*path.rglob("*"), path]:
|
| 488 | mode = item.lstat().st_mode
|
| 489 | if stat.S_ISLNK(mode):
|
| 490 | raise FixtureIntegrityError("fixture content drift: unexpected symlink")
|
| 491 | if not (stat.S_ISREG(mode) or stat.S_ISDIR(mode)):
|
| 492 | raise FixtureIntegrityError("fixture content drift: unexpected file type")
|
| 493 | # Keep directories owner-writable so normal cleanup/rebuild works.
|
| 494 | updated = mode | 0o700 if stat.S_ISDIR(mode) else (mode | 0o200 if writable else mode & ~0o222)
|
| 495 | os.chmod(item, stat.S_IMODE(updated))
|
| 496 |
|
| 497 |
|
| 498 | def verify_fixture_binding(fixture: Path, record_path: Path, record: dict):
|
| 499 | try:
|
| 500 | current_record = read_json(safe_path(record_path))
|
| 501 | except CampaignError as exc:
|
| 502 | raise FixtureIntegrityError(str(exc)) from exc
|
| 503 | if current_record != record or fixture_tree(fixture) != record.get("manifest"):
|
| 504 | raise FixtureIntegrityError(f"fixture integrity drift at {fixture}: cache or {record_path} changed")
|
| 505 |
|
| 506 |
|
| 507 | def copied_fixture_validator(expected: dict):
|
| 508 | def validate(repo: Path):
|
| 509 | if fixture_tree(repo) != expected:
|
| 510 | raise FixtureIntegrityError("copied fixture identity drift before agent launch")
|
| 511 | fixture_permissions(repo, writable=True)
|
| 512 | return validate
|
| 513 |
|
| 514 |
|
| 515 | def cached_fixture(directory: Path, plan: dict, workflow) -> Path:
|
| 516 | """Generate once; verify the complete cached input before each invocation.
|
| 517 |
|
| 518 | The campaign writer lock protects this cache. Each trial gets a staged,
|
| 519 | validated copy before admission. Host containment is an operator responsibility.
|
| 520 | """
|
| 521 | import workflow_ab
|
| 522 | fixture = safe_path(directory / "fixtures" / workflow.name)
|
| 523 | record_path = safe_path(directory / "fixture-integrity" / (workflow.name + ".json"))
|
| 524 | expected_identity = {"plan_sha256": plan["plan_sha256"], "fixture_version": workflow_ab.FIXTURE_VERSION}
|
| 525 | if fixture.exists() or record_path.exists():
|
| 526 | if not fixture.is_dir() or not record_path.is_file():
|
| 527 | raise CampaignError(f"fixture cache is incomplete for {workflow.name} at {fixture}; preserve evidence and create a new campaign")
|
| 528 | record = read_json(record_path)
|
| 529 | if not isinstance(record, dict) or any(record.get(key) != value for key, value in expected_identity.items()):
|
| 530 | raise CampaignError(f"fixture cache identity drift for {workflow.name} at {record_path}")
|
| 531 | if fixture_tree(fixture) != record.get("manifest"):
|
| 532 | raise FixtureIntegrityError(f"fixture content drift for {workflow.name} at {fixture}; refusing reuse")
|
| 533 | fixture_permissions(fixture, writable=False)
|
| 534 | return fixture
|
| 535 | generated = workflow_ab.prepare_fixture(directory, workflow)
|
| 536 | if generated != fixture:
|
| 537 | raise CampaignError("fixture generator returned an unexpected cache path")
|
| 538 | record_path.parent.mkdir(parents=True, exist_ok=True)
|
| 539 | atomic_write(record_path, canonical({**expected_identity, "manifest": fixture_tree(fixture)}) + b"\n")
|
| 540 | fixture_permissions(fixture, writable=False)
|
| 541 | return fixture
|
| 542 |
|
| 543 |
|
| 544 | def verify_journal_fixture_binding(name: str, record: dict, plan: dict, events: list[dict]):
|
| 545 | expected = digest(record["manifest"])
|
| 546 | workflows = {trial["trial_id"]: trial["workflow"] for trial in plan["schedule"]}
|
| 547 | for event in events:
|
| 548 | if event.get("event") not in {"started", "finished"} or workflows.get(event.get("trial_id")) != name:
|
| 549 | continue
|
| 550 | # Recovery reserves an ambiguous start without asserting observed input.
|
| 551 | if event.get("event") == "started" and event.get("uncertain_torn_start") is True:
|
| 552 | continue
|
| 553 | if event.get("fixture_manifest_sha256") != expected:
|
| 554 | raise CampaignError("journal fixture binding missing or mismatched for " + name)
|
| 555 |
|
| 556 |
|
| 557 | def require_measurement_lock(lock, plan: dict):
|
| 558 | if not lock.enabled:
|
| 559 | raise CampaignError("global measurement lock is mandatory for campaigns")
|
| 560 | pinned_lock = plan["provenance"].get("measurement_lock_path")
|
| 561 | if pinned_lock is not None and lock.path != pinned_lock:
|
| 562 | raise CampaignError("acquired measurement lock differs from frozen provenance")
|
| 563 |
|
| 564 |
|
| 565 | def run_campaign(directory: Path, *, resume=False, enable_real=False, limit=None, acknowledge_torn=False) -> dict:
|
| 566 | import agent_workflow
|
| 567 | import workflow_ab
|
| 568 | from oakbench.runlock import measurement_lock
|
| 569 | directory = safe_path(directory)
|
| 570 | plan = load_plan(directory)
|
| 571 | with campaign_lock(directory):
|
| 572 | agents, subjects = preflight(plan, directory, enable_real)
|
| 573 | events = acknowledge_torn_journal(directory, plan) if resume and acknowledge_torn else load_events(directory, plan)
|
| 574 | if events and not resume:
|
| 575 | raise CampaignError("campaign already started; use resume (interrupted trials are not retried)")
|
| 576 | if limit is not None:
|
| 577 | _positive(limit, "limit", True)
|
| 578 | initial_state = campaign_status(plan, events)
|
| 579 | pending = set(initial_state["pending_trial_ids"])
|
| 580 | remaining = initial_state["remaining_episode_seconds"]
|
| 581 | fixtures = {}
|
| 582 | # Campaign-wide setup failures are preflight errors, never trial rows.
|
| 583 | trial_workflows = {trial["trial_id"]: trial["workflow"] for trial in plan["schedule"]}
|
| 584 | needed = {trial_workflows[event["trial_id"]] for event in events
|
| 585 | if event.get("event") in {"started", "finished"} and not event.get("uncertain_torn_start")}
|
| 586 | if remaining >= plan["manifest"]["trial_timeout_seconds"]:
|
| 587 | needed.update(trial["workflow"] for trial in plan["schedule"] if trial["trial_id"] in pending)
|
| 588 | if not pending:
|
| 589 | needed.clear()
|
| 590 | if needed:
|
| 591 | with measurement_lock("agent-campaign-fixture-preflight") as lock:
|
| 592 | require_measurement_lock(lock, plan)
|
| 593 | for name in sorted(needed):
|
| 594 | fixture = cached_fixture(directory, plan, workflow_ab.WORKFLOWS[name])
|
| 595 | record_path = safe_path(directory / "fixture-integrity" / (name + ".json"))
|
| 596 | record = read_json(record_path)
|
| 597 | verify_fixture_binding(fixture, record_path, record)
|
| 598 | verify_journal_fixture_binding(name, record, plan, events)
|
| 599 | fixtures[name] = (fixture, record_path, record)
|
| 600 | completed_now = 0
|
| 601 | stopped_reason = None
|
| 602 | rows = [event["row"] for event in events if event.get("row") is not None]
|
| 603 | atomic_write(directory / "rows.jsonl", b"".join(canonical(row) + b"\n" for row in rows))
|
| 604 | for trial in plan["schedule"]:
|
| 605 | if trial["trial_id"] not in pending:
|
| 606 | continue
|
| 607 | timeout = plan["manifest"]["trial_timeout_seconds"]
|
| 608 | if remaining < timeout:
|
| 609 | stopped_reason = "insufficient_budget_for_frozen_trial_timeout"
|
| 610 | break
|
| 611 | if limit is not None and completed_now >= limit:
|
| 612 | stopped_reason = "invocation_trial_limit"
|
| 613 | break
|
| 614 | trial_root = safe_path(directory / "trials" / trial["trial_id"])
|
| 615 | if trial_root.exists():
|
| 616 | raise CampaignError("trial path collision; refusing overwrite: " + str(trial_root))
|
| 617 | with measurement_lock("agent-campaign:" + trial["trial_id"]) as lock, tempfile.TemporaryDirectory(prefix=".trial-input-", dir=directory) as staging:
|
| 618 | require_measurement_lock(lock, plan)
|
| 619 | fixture, fixture_record_path, fixture_record = fixtures[trial["workflow"]]
|
| 620 | # Rehash before every copy; a prior same-uid process may still
|
| 621 | # exist even after its agent CLI returned.
|
| 622 | verify_fixture_binding(fixture, fixture_record_path, fixture_record)
|
| 623 | workflow = workflow_ab.WORKFLOWS[trial["workflow"]]
|
| 624 | staged_repo = Path(staging) / "work" / workflow.name / trial["agent"] / trial["subject"] / f"run-{trial['repetition']}"
|
| 625 | try:
|
| 626 | agent_workflow.copy_fixture(fixture, staged_repo)
|
| 627 | copied_fixture_validator(fixture_record["manifest"])(staged_repo)
|
| 628 | except OSError as exc:
|
| 629 | raise CampaignError(f"fixture copy failed for {workflow.name} from {fixture}: {exc}") from exc
|
| 630 | verify_fixture_binding(fixture, fixture_record_path, fixture_record)
|
| 631 | trial_root.parent.mkdir(parents=True, exist_ok=True)
|
| 632 | started = append_event(directory, plan, {"event": "started", "trial_id": trial["trial_id"],
|
| 633 | "timeout_seconds": timeout, "ordinal": trial["ordinal"],
|
| 634 | "fixture_manifest_sha256": digest(fixture_record["manifest"])})
|
| 635 | events.append(started)
|
| 636 | start = time.monotonic()
|
| 637 | print(f"[trial] {trial['ordinal'] + 1}/{len(plan['schedule'])} {trial['trial_id']} {trial['agent']}/{trial['subject']}", file=sys.stderr, flush=True)
|
| 638 | row = None
|
| 639 | try:
|
| 640 | Path(staging).rename(trial_root)
|
| 641 | row = agent_workflow.run_one(
|
| 642 | agent=agents[trial["agent"]], subject=subjects[trial["subject"]], workflow=workflow,
|
| 643 | fixture=fixture, run_index=trial["repetition"], run_root=trial_root / "work",
|
| 644 | artifacts_root=trial_root / "artifacts", timeout_seconds=timeout,
|
| 645 | track=plan["manifest"]["track"], agent_environment=plan["manifest"]["agent_environment"],
|
| 646 | instruction_level=trial["instruction_level"], bench_id=plan["campaign_id"],
|
| 647 | timestamp_utc=now(), source={}, host_meta={},
|
| 648 | output_limit_bytes=plan["manifest"].get("output_limit_bytes", 8 * 1024 * 1024),
|
| 649 | fixture_copy_validator=copied_fixture_validator(fixture_record["manifest"]),
|
| 650 | fixture_prepared=True,
|
| 651 | )
|
| 652 | row["task_id"] = trial["trial_id"]
|
| 653 | validate_trial_row(row, trial, plan)
|
| 654 | terminal = {"event": "finished", "trial_id": trial["trial_id"], "outcome": row["outcome"], "row": row}
|
| 655 | except (Exception, SystemExit) as exc:
|
| 656 | terminal = {"event": "finished", "trial_id": trial["trial_id"], "outcome": "error",
|
| 657 | "error": type(exc).__name__ + ": " + str(exc)}
|
| 658 | if isinstance(exc, FixtureIntegrityError):
|
| 659 | terminal.update(measurement_invalidated=True, invalidation_reason=str(exc))
|
| 660 | if row is not None:
|
| 661 | try:
|
| 662 | canonical(row)
|
| 663 | terminal["rejected_row"] = row
|
| 664 | except (ValueError, TypeError):
|
| 665 | terminal["rejected_row_repr"] = repr(row)
|
| 666 | try:
|
| 667 | verify_fixture_binding(fixture, fixture_record_path, fixture_record)
|
| 668 | except FixtureIntegrityError as exc:
|
| 669 | original_row = terminal.pop("row", None)
|
| 670 | if original_row is not None:
|
| 671 | terminal["rejected_row"] = original_row
|
| 672 | terminal.update(outcome="error", measurement_invalidated=True,
|
| 673 | invalidation_reason=str(exc), error=str(exc))
|
| 674 | if terminal.get("measurement_invalidated"):
|
| 675 | stopped_reason = "fixture_integrity_drift"
|
| 676 | print(f"[invalidated] {trial['trial_id']}: {terminal['invalidation_reason']}", file=sys.stderr, flush=True)
|
| 677 | terminal["fixture_manifest_sha256"] = digest(fixture_record["manifest"])
|
| 678 | terminal["wall_seconds"] = time.monotonic() - start
|
| 679 | remaining = max(0.0, remaining - terminal["wall_seconds"])
|
| 680 | terminal["artifact_sha256"] = artifact_hashes(trial_root)
|
| 681 | terminal["artifact_integrity_source"] = "sha256_of_regular_trial_artifacts_and_instructions;links_and_missing_files_not_attested"
|
| 682 | terminal["artifact_integrity_status"] = "observed_files_only" if terminal["artifact_sha256"] else "unavailable"
|
| 683 | events.append(append_event(directory, plan, terminal))
|
| 684 | if terminal.get("row") is not None:
|
| 685 | with safe_path(directory / "rows.jsonl").open("ab") as stream:
|
| 686 | stream.write(canonical(terminal["row"]) + b"\n")
|
| 687 | stream.flush()
|
| 688 | os.fsync(stream.fileno())
|
| 689 | completed_now += 1
|
| 690 | if stopped_reason == "fixture_integrity_drift":
|
| 691 | break
|
| 692 | # The journal is authoritative. A crash after its final fsync may leave
|
| 693 | # derived rows absent/stale even when there are no pending trials.
|
| 694 | rows = [event["row"] for event in events if event.get("row") is not None]
|
| 695 | atomic_write(directory / "rows.jsonl", b"".join(canonical(row) + b"\n" for row in rows))
|
| 696 | return {**campaign_status(plan, events), "stopped_reason": stopped_reason}
|