| 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 time
|
| 22 | import tempfile
|
| 23 | from typing import Any
|
| 24 |
|
| 25 | SCHEMA_VERSION = 1
|
| 26 | WORKFLOWS = {"wide_config_refactor", "large_asset_manifest"}
|
| 27 | LEVELS = {"zero-shot", "cheat-sheet", "full-docs"}
|
| 28 | ROOT = Path(__file__).resolve().parents[2]
|
| 29 |
|
| 30 |
|
| 31 | class CampaignError(ValueError):
|
| 32 | pass
|
| 33 |
|
| 34 |
|
| 35 | def now() -> str:
|
| 36 | return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
| 37 |
|
| 38 |
|
| 39 | def canonical(value: Any) -> bytes:
|
| 40 | return json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False).encode()
|
| 41 |
|
| 42 |
|
| 43 | def digest(value: Any) -> str:
|
| 44 | return hashlib.sha256(canonical(value)).hexdigest()
|
| 45 |
|
| 46 |
|
| 47 | def file_hash(path: Path) -> str:
|
| 48 | hasher = hashlib.sha256()
|
| 49 | with path.open("rb") as stream:
|
| 50 | for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
| 51 | hasher.update(chunk)
|
| 52 | return hasher.hexdigest()
|
| 53 |
|
| 54 |
|
| 55 | def _unique_object(pairs):
|
| 56 | data = {}
|
| 57 | for key, value in pairs:
|
| 58 | if key in data:
|
| 59 | raise CampaignError("duplicate JSON key: " + key)
|
| 60 | data[key] = value
|
| 61 | return data
|
| 62 |
|
| 63 |
|
| 64 | def read_json(path: Path) -> Any:
|
| 65 | try:
|
| 66 | return json.loads(path.read_text(), object_pairs_hook=_unique_object,
|
| 67 | parse_constant=lambda value: (_ for _ in ()).throw(CampaignError("nonfinite JSON")))
|
| 68 | except (OSError, ValueError) as exc:
|
| 69 | raise CampaignError(f"cannot read {path}: {exc}") from exc
|
| 70 |
|
| 71 |
|
| 72 | def _positive(value, name, integer=False):
|
| 73 | valid = type(value) in ((int,) if integer else (int, float))
|
| 74 | try:
|
| 75 | valid = valid and value > 0 and (integer or math.isfinite(value))
|
| 76 | except OverflowError:
|
| 77 | valid = False
|
| 78 | if not valid:
|
| 79 | raise CampaignError(name + " must be positive" + (" integer" if integer else " finite number"))
|
| 80 |
|
| 81 |
|
| 82 | def _names(value, name, allowed=None):
|
| 83 | if not isinstance(value, list) or not value or any(not isinstance(x, str) or not x for x in value):
|
| 84 | raise CampaignError(name + " must be a nonempty string array")
|
| 85 | if len(set(value)) != len(value):
|
| 86 | raise CampaignError("duplicate " + name)
|
| 87 | if allowed is not None and not set(value) <= allowed:
|
| 88 | raise CampaignError("unsupported " + name + ": " + ", ".join(sorted(set(value) - allowed)))
|
| 89 |
|
| 90 |
|
| 91 | def load_manifest(path: Path) -> dict:
|
| 92 | return validate_manifest(read_json(path), path.resolve().parent)
|
| 93 |
|
| 94 |
|
| 95 | def validate_manifest(raw, base: Path) -> dict:
|
| 96 | required = {"schema_version", "campaign_id", "agents_config", "subjects_config", "agents", "subjects",
|
| 97 | "workflows", "instruction_levels", "repetitions", "seed", "max_trials", "max_wall_seconds",
|
| 98 | "trial_timeout_seconds", "track", "agent_environment"}
|
| 99 | if not isinstance(raw, dict) or not required <= set(raw) or set(raw) - required - {"isolation_attestation", "output_limit_bytes"}:
|
| 100 | raise CampaignError("manifest has missing or unknown fields")
|
| 101 | if type(raw["schema_version"]) is not int or raw["schema_version"] != SCHEMA_VERSION:
|
| 102 | raise CampaignError("unsupported campaign schema_version")
|
| 103 | 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"]):
|
| 104 | raise CampaignError("invalid campaign_id")
|
| 105 | _names(raw["subjects"], "subjects")
|
| 106 | _names(raw["workflows"], "workflows", WORKFLOWS)
|
| 107 | _names(raw["instruction_levels"], "instruction_levels", LEVELS)
|
| 108 | if raw["track"] not in {"agent-default", "core-equivalent"} or raw["agent_environment"] != "minimal":
|
| 109 | raise CampaignError("campaigns require a supported track and minimal agent_environment")
|
| 110 | for key in ("repetitions", "max_trials"):
|
| 111 | _positive(raw[key], key, True)
|
| 112 | for key in ("trial_timeout_seconds", "max_wall_seconds"):
|
| 113 | _positive(raw[key], key)
|
| 114 | if type(raw["seed"]) is not int:
|
| 115 | raise CampaignError("seed must be an integer")
|
| 116 | if "output_limit_bytes" in raw:
|
| 117 | _positive(raw["output_limit_bytes"], "output_limit_bytes", True)
|
| 118 | agents = raw["agents"]
|
| 119 | if not isinstance(agents, list) or not agents:
|
| 120 | raise CampaignError("agents must be a nonempty array")
|
| 121 | for agent in agents:
|
| 122 | if not isinstance(agent, dict) or set(agent) != {"name", "model"}:
|
| 123 | raise CampaignError("each agent requires exactly name and model")
|
| 124 | if agent["model"] is not None and (not isinstance(agent["model"], str) or not agent["model"].strip()):
|
| 125 | raise CampaignError("model must be null or a nonempty string")
|
| 126 | _names([agent["name"] for agent in agents], "agents")
|
| 127 | for name in raw["subjects"] + [agent["name"] for agent in agents]:
|
| 128 | if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_-]{0,79}", name):
|
| 129 | raise CampaignError("agent and subject names must be safe slugs")
|
| 130 | raw = dict(raw)
|
| 131 | for key in ("agents_config", "subjects_config"):
|
| 132 | if not isinstance(raw[key], str) or not raw[key]:
|
| 133 | raise CampaignError(key + " must be a path")
|
| 134 | raw[key] = str((base / Path(raw[key]).expanduser()).resolve())
|
| 135 | return raw
|
| 136 |
|
| 137 |
|
| 138 | def schedule(manifest: dict) -> list[dict]:
|
| 139 | size = (len(manifest["subjects"]) * len(manifest["agents"]) * len(manifest["workflows"])
|
| 140 | * len(manifest["instruction_levels"]) * manifest["repetitions"])
|
| 141 | if size > manifest["max_trials"] or size > 10000:
|
| 142 | raise CampaignError(f"schedule has {size} trials; exceeds manifest or 10000-trial safety limit")
|
| 143 | rows = []
|
| 144 | rng = random.Random(manifest["seed"])
|
| 145 | groups = [(workflow, agent, level) for workflow in manifest["workflows"]
|
| 146 | for agent in manifest["agents"] for level in manifest["instruction_levels"]]
|
| 147 | for repetition in range(manifest["repetitions"]):
|
| 148 | block_order = list(groups)
|
| 149 | rng.shuffle(block_order)
|
| 150 | for workflow, agent, level in block_order:
|
| 151 | identity = {"campaign_id": manifest["campaign_id"], "workflow": workflow,
|
| 152 | "agent": agent["name"], "model": agent["model"], "instruction_level": level}
|
| 153 | order = list(manifest["subjects"])
|
| 154 | random.Random(str(manifest["seed"]) + digest(identity)).shuffle(order)
|
| 155 | shift = repetition % len(order)
|
| 156 | order = order[shift:] + order[:shift]
|
| 157 | block = "block-" + digest({**identity, "repetition": repetition})[:24]
|
| 158 | for subject in order:
|
| 159 | rows.append({**identity, "subject": subject, "repetition": repetition,
|
| 160 | "block_id": block, "trial_id": "trial-" + digest([block, subject])[:24],
|
| 161 | "ordinal": len(rows)})
|
| 162 | if len(rows) > manifest["max_trials"]:
|
| 163 | raise CampaignError(f"schedule has {len(rows)} trials, exceeds max_trials={manifest['max_trials']}")
|
| 164 | return rows
|
| 165 |
|
| 166 |
|
| 167 | def resolve_runtime(manifest: dict):
|
| 168 | import agent_workflow
|
| 169 | from oakbench.subjects import load_subjects, subject_details
|
| 170 | try:
|
| 171 | agents = agent_workflow.load_agents(Path(manifest["agents_config"]), ",".join(x["name"] for x in manifest["agents"]))
|
| 172 | subjects = load_subjects(argparse.Namespace(config=Path(manifest["subjects_config"]),
|
| 173 | subjects=",".join(manifest["subjects"]), git_bin=None, oak_installed_bin=None, oak_local_bin=None))
|
| 174 | except (SystemExit, KeyError, OSError) as exc:
|
| 175 | raise CampaignError("configuration preflight failed: " + str(exc)) from exc
|
| 176 | if {x.name for x in subjects} != set(manifest["subjects"]):
|
| 177 | raise CampaignError("unknown selected subject")
|
| 178 | requested = {x["name"]: x["model"] for x in manifest["agents"]}
|
| 179 | if {agent.name for agent in agents} != set(requested):
|
| 180 | raise CampaignError("selected agent names must identify exact configured entries")
|
| 181 | agents = [replace(agent, model=requested[agent.name]) for agent in agents]
|
| 182 | for agent in agents:
|
| 183 | if agent.adapter not in {"deterministic_mock", "codex_exec_json", "claude_print_stream_json", "cursor_agent_stream_json"}:
|
| 184 | raise CampaignError("unsupported agent adapter: " + agent.adapter)
|
| 185 | if agent.adapter == "deterministic_mock" and agent.model is not None:
|
| 186 | raise CampaignError("mock agents require model=null")
|
| 187 | if agent.adapter != "deterministic_mock" and not agent.model:
|
| 188 | raise CampaignError("real agents require an explicit requested model: " + agent.name)
|
| 189 | for subject in subjects:
|
| 190 | if subject.kind not in {"git", "oak"} or subject.git_mode is not None:
|
| 191 | raise CampaignError("only local Git/Oak subjects are supported")
|
| 192 | for binary in [x.bin for x in agents if x.bin is not None] + [x.bin for x in subjects]:
|
| 193 | if not binary.is_file() or not os.access(binary, os.X_OK):
|
| 194 | raise CampaignError("not an executable binary: " + str(binary))
|
| 195 | from oakbench.runlock import measurement_lock_path
|
| 196 | provenance = {
|
| 197 | "measurement_lock_path": str(measurement_lock_path()),
|
| 198 | "configs": {key: {"path": manifest[key], "sha256": file_hash(Path(manifest[key]))}
|
| 199 | for key in ("agents_config", "subjects_config")},
|
| 200 | "subjects": subject_details(subjects),
|
| 201 | "agents": {agent.name: {"adapter": agent.adapter, "provider": agent.provider, "model": agent.model,
|
| 202 | "version": agent.version, "bin": str(agent.bin.resolve()) if agent.bin else None,
|
| 203 | "binary_sha256": file_hash(agent.bin) if agent.bin else None} for agent in agents},
|
| 204 | "harness": {str(path.relative_to(ROOT)): file_hash(path) for path in sorted((ROOT / "scripts").rglob("*.py"))},
|
| 205 | "configuration_and_prompts": {
|
| 206 | str(path.relative_to(ROOT)): file_hash(path)
|
| 207 | for directory in (ROOT / "config", ROOT / "scenarios")
|
| 208 | for path in sorted(directory.rglob("*")) if path.is_file() and not any(part.startswith(".") for part in path.relative_to(directory).parts)
|
| 209 | },
|
| 210 | }
|
| 211 | return {x.name: x for x in agents}, {x.name: x for x in subjects}, provenance
|
| 212 |
|
| 213 |
|
| 214 | def safe_path(path: Path) -> Path:
|
| 215 | if ".." in path.parts:
|
| 216 | raise CampaignError("destination path traversal is not allowed")
|
| 217 | absolute = path.absolute()
|
| 218 | for item in [absolute, *absolute.parents]:
|
| 219 | if item.is_symlink() and not (str(item) in {"/tmp", "/var"} and str(item.resolve()) == "/private" + str(item)):
|
| 220 | raise CampaignError("destination symlink is not allowed: " + str(item))
|
| 221 | return absolute.resolve()
|
| 222 |
|
| 223 |
|
| 224 | def artifact_hashes(trial_root: Path) -> dict[str, str]:
|
| 225 | candidates = list((trial_root / "artifacts").rglob("*"))
|
| 226 | candidates.extend((trial_root / "work").rglob("AGENTS.md"))
|
| 227 | result = {}
|
| 228 | for path in candidates:
|
| 229 | if path.is_symlink():
|
| 230 | continue
|
| 231 | if path.is_file():
|
| 232 | try:
|
| 233 | safe_path(path)
|
| 234 | result[path.relative_to(trial_root).as_posix()] = file_hash(path)
|
| 235 | except (OSError, CampaignError):
|
| 236 | continue
|
| 237 | return result
|
| 238 |
|
| 239 |
|
| 240 | def _sync_directory(path):
|
| 241 | fd = os.open(path, os.O_RDONLY)
|
| 242 | try:
|
| 243 | os.fsync(fd)
|
| 244 | finally:
|
| 245 | os.close(fd)
|
| 246 |
|
| 247 |
|
| 248 | def atomic_write(path: Path, content: bytes):
|
| 249 | safe_path(path)
|
| 250 | fd, name = tempfile.mkstemp(prefix=path.name + ".tmp-", dir=path.parent)
|
| 251 | temporary = Path(name)
|
| 252 | with os.fdopen(fd, "wb") as stream:
|
| 253 | stream.write(content)
|
| 254 | stream.flush()
|
| 255 | os.fsync(stream.fileno())
|
| 256 | os.replace(temporary, path)
|
| 257 | _sync_directory(path.parent)
|
| 258 |
|
| 259 |
|
| 260 | def create_plan(manifest_path: Path, directory: Path) -> dict:
|
| 261 | manifest = load_manifest(manifest_path)
|
| 262 | trials = schedule(manifest)
|
| 263 | _, _, provenance = resolve_runtime(manifest)
|
| 264 | from oakbench.runner import runner_fields
|
| 265 | plan = {"schema_version": 1, "campaign_id": manifest["campaign_id"], "created_at": now(),
|
| 266 | "manifest": manifest, "provenance": provenance, "runner": runner_fields(), "schedule": trials,
|
| 267 | "budget_policy": "cumulative episode-wall admission; interrupted episodes reserve their timeout; setup/oracle overhead may exceed cap"}
|
| 268 | plan["plan_sha256"] = digest(plan)
|
| 269 | directory = safe_path(directory)
|
| 270 | if directory.exists():
|
| 271 | raise CampaignError("campaign directory already exists; refusing overwrite: " + str(directory))
|
| 272 | try:
|
| 273 | directory.mkdir(parents=True)
|
| 274 | except FileExistsError as exc:
|
| 275 | raise CampaignError("campaign directory collision") from exc
|
| 276 | atomic_write(directory / "plan.json", canonical(plan) + b"\n")
|
| 277 | return plan
|
| 278 |
|
| 279 |
|
| 280 | def load_plan(directory: Path) -> dict:
|
| 281 | safe_path(directory / "plan.json")
|
| 282 | plan = read_json(directory / "plan.json")
|
| 283 | if not isinstance(plan, dict) or type(plan.get("schema_version")) is not int or plan.get("schema_version") != 1:
|
| 284 | raise CampaignError("invalid frozen plan")
|
| 285 | body = {key: value for key, value in plan.items() if key != "plan_sha256"}
|
| 286 | if digest(body) != plan.get("plan_sha256"):
|
| 287 | raise CampaignError("frozen plan fingerprint mismatch")
|
| 288 | try:
|
| 289 | for key in ("campaign_id", "manifest", "schedule", "provenance", "runner"):
|
| 290 | if key not in plan:
|
| 291 | raise CampaignError("invalid frozen plan: missing " + key)
|
| 292 | if not isinstance(plan["provenance"], dict) or not isinstance(plan["runner"], dict):
|
| 293 | raise CampaignError("invalid frozen plan metadata")
|
| 294 | manifest = validate_manifest(plan["manifest"], directory.resolve())
|
| 295 | if manifest != plan["manifest"] or plan["campaign_id"] != manifest["campaign_id"]:
|
| 296 | raise CampaignError("invalid frozen manifest identity or paths")
|
| 297 | if not all(isinstance(plan["runner"].get(key), str) for key in ("runner_id", "runner_class")):
|
| 298 | raise CampaignError("invalid frozen runner identity")
|
| 299 | if schedule(manifest) != plan["schedule"]:
|
| 300 | raise CampaignError("frozen schedule mismatch")
|
| 301 | except (KeyError, TypeError, AttributeError) as exc:
|
| 302 | raise CampaignError("invalid frozen plan structure") from exc
|
| 303 | return plan
|
| 304 |
|
| 305 |
|
| 306 | def validate_trial_row(row: dict, trial: dict, plan: dict):
|
| 307 | import agent_workflow
|
| 308 | if not isinstance(row, dict) or agent_workflow.validate_row(row):
|
| 309 | raise CampaignError("invalid journal terminal row schema")
|
| 310 | expected = {"task_id": trial["trial_id"], "bench_id": plan["campaign_id"],
|
| 311 | "subject": trial["subject"], "scenario": trial["workflow"], "run": trial["repetition"]}
|
| 312 | if any(row.get(key) != value for key, value in expected.items()):
|
| 313 | raise CampaignError("terminal row does not match frozen trial identity")
|
| 314 | expected_agent = {"name": trial["agent"], "model": trial["model"],
|
| 315 | "instruction_level": trial["instruction_level"],
|
| 316 | "environment": plan["manifest"]["agent_environment"]}
|
| 317 | pinned_agent = plan["provenance"].get("agents", {}).get(trial["agent"], {})
|
| 318 | expected_agent.update({key: pinned_agent[key] for key in ("adapter", "provider", "version") if key in pinned_agent})
|
| 319 | if any(row["agent"].get(key) != value for key, value in expected_agent.items()):
|
| 320 | raise CampaignError("terminal row agent does not match frozen trial identity")
|
| 321 |
|
| 322 |
|
| 323 | def load_events(directory: Path, plan: dict, *, tolerate_torn=False) -> list[dict]:
|
| 324 | path = safe_path(directory / "events.jsonl")
|
| 325 | if not path.exists():
|
| 326 | return []
|
| 327 | raw = path.read_bytes()
|
| 328 | tail = b""
|
| 329 | if raw and not raw.endswith(b"\n"):
|
| 330 | if not tolerate_torn:
|
| 331 | raise CampaignError("journal has incomplete final record; resume requires --acknowledge-torn-journal; affected trials are never retried")
|
| 332 | prefix_end = raw.rfind(b"\n") + 1
|
| 333 | raw, tail = raw[:prefix_end], raw[prefix_end:]
|
| 334 | events = []
|
| 335 | known = {trial["trial_id"]: trial for trial in plan["schedule"]}
|
| 336 | started, finished = set(), set()
|
| 337 | for line in raw.splitlines():
|
| 338 | try:
|
| 339 | event = json.loads(line, object_pairs_hook=_unique_object,
|
| 340 | parse_constant=lambda value: (_ for _ in ()).throw(CampaignError("nonfinite journal JSON")))
|
| 341 | except ValueError as exc:
|
| 342 | raise CampaignError("invalid journal record") from exc
|
| 343 | if not isinstance(event, dict):
|
| 344 | raise CampaignError("journal record must be an object")
|
| 345 | if event.get("event") == "journal_recovery":
|
| 346 | if event.get("plan_sha256") != plan["plan_sha256"] or not isinstance(event.get("tail_sha256"), str):
|
| 347 | raise CampaignError("invalid journal recovery marker")
|
| 348 | events.append(event)
|
| 349 | continue
|
| 350 | trial = event.get("trial_id")
|
| 351 | if not isinstance(trial, str):
|
| 352 | raise CampaignError("journal trial_id must be a string")
|
| 353 | if trial not in known or event.get("plan_sha256") != plan["plan_sha256"]:
|
| 354 | raise CampaignError("journal trial or plan mismatch")
|
| 355 | if event.get("event") == "started" and trial not in started:
|
| 356 | _positive(event.get("timeout_seconds"), "journal timeout_seconds")
|
| 357 | started.add(trial)
|
| 358 | elif event.get("event") == "finished" and trial in started and trial not in finished:
|
| 359 | duration = event.get("wall_seconds")
|
| 360 | if type(duration) not in (int, float) or not math.isfinite(duration) or duration < 0:
|
| 361 | raise CampaignError("journal wall_seconds must be finite and nonnegative")
|
| 362 | if event.get("outcome") not in {"pass", "fail", "timeout", "error"}:
|
| 363 | raise CampaignError("invalid journal terminal outcome")
|
| 364 | if event["outcome"] != "error" or event.get("row") is not None:
|
| 365 | row = event.get("row")
|
| 366 | validate_trial_row(row, known[trial], plan)
|
| 367 | if row.get("outcome") != event["outcome"]:
|
| 368 | raise CampaignError("journal outcome contradicts trial row")
|
| 369 | finished.add(trial)
|
| 370 | else:
|
| 371 | raise CampaignError("duplicate or out-of-order journal event")
|
| 372 | events.append(event)
|
| 373 | if tail:
|
| 374 | # Only the most recent intact start can own this torn terminal.
|
| 375 | # Older interrupted trials do not account for a newly torn start.
|
| 376 | active = bool(events and events[-1].get("event") == "started")
|
| 377 | candidate = next((trial for trial in known if trial not in started), None)
|
| 378 | if not active and candidate:
|
| 379 | events.append({"event": "started", "trial_id": candidate, "plan_sha256": plan["plan_sha256"],
|
| 380 | "timeout_seconds": plan["manifest"]["trial_timeout_seconds"], "uncertain_torn_start": True})
|
| 381 | events.append({"event": "journal_recovery", "plan_sha256": plan["plan_sha256"],
|
| 382 | "tail_sha256": hashlib.sha256(tail).hexdigest(), "tail_bytes": len(tail),
|
| 383 | "acknowledged": False})
|
| 384 | return events
|
| 385 |
|
| 386 |
|
| 387 | def acknowledge_torn_journal(directory, plan):
|
| 388 | events = load_events(directory, plan, tolerate_torn=True)
|
| 389 | if not events or events[-1].get("event") != "journal_recovery" or events[-1].get("acknowledged") is not False:
|
| 390 | return events
|
| 391 | raw = (directory / "events.jsonl").read_bytes()
|
| 392 | archive = directory / ("events-before-recovery-" + hashlib.sha256(raw).hexdigest() + ".jsonl")
|
| 393 | if not archive.exists():
|
| 394 | atomic_write(archive, raw)
|
| 395 | elif safe_path(archive).read_bytes() != raw:
|
| 396 | raise CampaignError("recovery archive collision")
|
| 397 | events[-1].update(acknowledged=True, archive=archive.name, archive_sha256=hashlib.sha256(raw).hexdigest(), timestamp_utc=now())
|
| 398 | atomic_write(directory / "events.jsonl", b"".join(canonical(event) + b"\n" for event in events))
|
| 399 | return events
|
| 400 |
|
| 401 |
|
| 402 | def append_event(directory: Path, plan: dict, event: dict):
|
| 403 | record = {**event, "plan_sha256": plan["plan_sha256"], "timestamp_utc": now()}
|
| 404 | with safe_path(directory / "events.jsonl").open("ab") as stream:
|
| 405 | stream.write(canonical(record) + b"\n")
|
| 406 | stream.flush()
|
| 407 | os.fsync(stream.fileno())
|
| 408 | _sync_directory(directory)
|
| 409 | return record
|
| 410 |
|
| 411 |
|
| 412 | def campaign_status(plan: dict, events: list[dict]) -> dict:
|
| 413 | starts = {x["trial_id"]: x for x in events if x["event"] == "started"}
|
| 414 | finishes = {x["trial_id"]: x for x in events if x["event"] == "finished"}
|
| 415 | interrupted = sorted(set(starts) - set(finishes))
|
| 416 | charged = sum(float(x["wall_seconds"]) for x in finishes.values())
|
| 417 | charged += sum(float(starts[key]["timeout_seconds"]) for key in interrupted)
|
| 418 | return {"campaign_id": plan["campaign_id"], "plan_sha256": plan["plan_sha256"],
|
| 419 | "scheduled": len(plan["schedule"]), "finished": len(finishes),
|
| 420 | "journal_recoveries": [x for x in events if x["event"] == "journal_recovery"],
|
| 421 | "interrupted_trial_ids": interrupted,
|
| 422 | "pending_trial_ids": [x["trial_id"] for x in plan["schedule"] if x["trial_id"] not in starts],
|
| 423 | "charged_episode_seconds": charged,
|
| 424 | "remaining_episode_seconds": max(0.0, plan["manifest"]["max_wall_seconds"] - charged)}
|
| 425 |
|
| 426 |
|
| 427 | @contextlib.contextmanager
|
| 428 | def campaign_lock(directory):
|
| 429 | with safe_path(directory / ".run.lock").open("a") as stream:
|
| 430 | try:
|
| 431 | fcntl.flock(stream, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
| 432 | except BlockingIOError as exc:
|
| 433 | raise CampaignError("campaign already has an active runner") from exc
|
| 434 | try:
|
| 435 | yield
|
| 436 | finally:
|
| 437 | fcntl.flock(stream, fcntl.LOCK_UN)
|
| 438 |
|
| 439 |
|
| 440 | def preflight(plan: dict, directory: Path, enable_real=False):
|
| 441 | agents, subjects, provenance = resolve_runtime(plan["manifest"])
|
| 442 | if provenance != plan["provenance"]:
|
| 443 | raise CampaignError("configuration, harness or binary provenance drift; create a new campaign")
|
| 444 | from oakbench.runner import runner_fields
|
| 445 | current = runner_fields()
|
| 446 | if any(current[key] != plan["runner"][key] for key in ("runner_id", "runner_class")):
|
| 447 | raise CampaignError("runner identity drift; create a new campaign")
|
| 448 | real = any(agent.adapter != "deterministic_mock" for agent in agents.values())
|
| 449 | if real:
|
| 450 | attestation = plan["manifest"].get("isolation_attestation")
|
| 451 | if not enable_real:
|
| 452 | raise CampaignError("real agents require --enable-real; no paid invocation has started")
|
| 453 | if not isinstance(attestation, dict) or set(attestation) != {"worker_root", "unrestricted_cli_acknowledged"}:
|
| 454 | raise CampaignError("real agents require isolated-worker attestation; it is not a sandbox")
|
| 455 | if attestation["unrestricted_cli_acknowledged"] is not True or not Path(attestation["worker_root"]).is_absolute():
|
| 456 | raise CampaignError("invalid isolated-worker attestation")
|
| 457 | if not directory.resolve().is_relative_to(Path(attestation["worker_root"]).resolve()):
|
| 458 | raise CampaignError("campaign directory is outside attested worker root")
|
| 459 | return agents, subjects
|
| 460 |
|
| 461 |
|
| 462 | def run_campaign(directory: Path, *, resume=False, enable_real=False, limit=None, acknowledge_torn=False) -> dict:
|
| 463 | import agent_workflow
|
| 464 | import workflow_ab
|
| 465 | from oakbench.runlock import measurement_lock
|
| 466 | directory = safe_path(directory)
|
| 467 | plan = load_plan(directory)
|
| 468 | with campaign_lock(directory):
|
| 469 | agents, subjects = preflight(plan, directory, enable_real)
|
| 470 | events = acknowledge_torn_journal(directory, plan) if resume and acknowledge_torn else load_events(directory, plan)
|
| 471 | if events and not resume:
|
| 472 | raise CampaignError("campaign already started; use resume (interrupted trials are not retried)")
|
| 473 | if limit is not None:
|
| 474 | _positive(limit, "limit", True)
|
| 475 | initial_state = campaign_status(plan, events)
|
| 476 | pending = set(initial_state["pending_trial_ids"])
|
| 477 | remaining = initial_state["remaining_episode_seconds"]
|
| 478 | completed_now = 0
|
| 479 | stopped_reason = None
|
| 480 | rows = [event["row"] for event in events if event.get("row") is not None]
|
| 481 | atomic_write(directory / "rows.jsonl", b"".join(canonical(row) + b"\n" for row in rows))
|
| 482 | for trial in plan["schedule"]:
|
| 483 | if trial["trial_id"] not in pending:
|
| 484 | continue
|
| 485 | timeout = plan["manifest"]["trial_timeout_seconds"]
|
| 486 | if remaining < timeout:
|
| 487 | stopped_reason = "insufficient_budget_for_frozen_trial_timeout"
|
| 488 | break
|
| 489 | if limit is not None and completed_now >= limit:
|
| 490 | stopped_reason = "invocation_trial_limit"
|
| 491 | break
|
| 492 | trial_root = safe_path(directory / "trials" / trial["trial_id"])
|
| 493 | if trial_root.exists():
|
| 494 | raise CampaignError("trial path collision; refusing overwrite: " + str(trial_root))
|
| 495 | with measurement_lock("agent-campaign:" + trial["trial_id"]) as lock:
|
| 496 | if not lock.enabled:
|
| 497 | raise CampaignError("global measurement lock is mandatory for campaigns")
|
| 498 | pinned_lock = plan["provenance"].get("measurement_lock_path")
|
| 499 | if pinned_lock is not None and lock.path != pinned_lock:
|
| 500 | raise CampaignError("acquired measurement lock differs from frozen provenance")
|
| 501 | started = append_event(directory, plan, {"event": "started", "trial_id": trial["trial_id"],
|
| 502 | "timeout_seconds": timeout, "ordinal": trial["ordinal"]})
|
| 503 | events.append(started)
|
| 504 | start = time.monotonic()
|
| 505 | print(f"[trial] {trial['ordinal'] + 1}/{len(plan['schedule'])} {trial['trial_id']} {trial['agent']}/{trial['subject']}", flush=True)
|
| 506 | row = None
|
| 507 | try:
|
| 508 | trial_root.mkdir(parents=True)
|
| 509 | workflow = workflow_ab.WORKFLOWS[trial["workflow"]]
|
| 510 | fixture = workflow_ab.prepare_fixture(trial_root / "fixture", workflow)
|
| 511 | row = agent_workflow.run_one(
|
| 512 | agent=agents[trial["agent"]], subject=subjects[trial["subject"]], workflow=workflow,
|
| 513 | fixture=fixture, run_index=trial["repetition"], run_root=trial_root / "work",
|
| 514 | artifacts_root=trial_root / "artifacts", timeout_seconds=timeout,
|
| 515 | track=plan["manifest"]["track"], agent_environment=plan["manifest"]["agent_environment"],
|
| 516 | instruction_level=trial["instruction_level"], bench_id=plan["campaign_id"],
|
| 517 | timestamp_utc=now(), source={}, host_meta={},
|
| 518 | output_limit_bytes=plan["manifest"].get("output_limit_bytes", 8 * 1024 * 1024),
|
| 519 | )
|
| 520 | row["task_id"] = trial["trial_id"]
|
| 521 | validate_trial_row(row, trial, plan)
|
| 522 | terminal = {"event": "finished", "trial_id": trial["trial_id"], "outcome": row["outcome"], "row": row}
|
| 523 | except (Exception, SystemExit) as exc:
|
| 524 | terminal = {"event": "finished", "trial_id": trial["trial_id"], "outcome": "error",
|
| 525 | "error": type(exc).__name__ + ": " + str(exc)}
|
| 526 | if row is not None:
|
| 527 | try:
|
| 528 | canonical(row)
|
| 529 | terminal["rejected_row"] = row
|
| 530 | except (ValueError, TypeError):
|
| 531 | terminal["rejected_row_repr"] = repr(row)
|
| 532 | terminal["wall_seconds"] = time.monotonic() - start
|
| 533 | remaining = max(0.0, remaining - terminal["wall_seconds"])
|
| 534 | terminal["artifact_sha256"] = artifact_hashes(trial_root)
|
| 535 | terminal["artifact_integrity_source"] = "sha256_of_regular_trial_artifacts_and_instructions;links_and_missing_files_not_attested"
|
| 536 | terminal["artifact_integrity_status"] = "observed_files_only" if terminal["artifact_sha256"] else "unavailable"
|
| 537 | events.append(append_event(directory, plan, terminal))
|
| 538 | if terminal.get("row") is not None:
|
| 539 | with safe_path(directory / "rows.jsonl").open("ab") as stream:
|
| 540 | stream.write(canonical(terminal["row"]) + b"\n")
|
| 541 | stream.flush()
|
| 542 | os.fsync(stream.fileno())
|
| 543 | completed_now += 1
|
| 544 | # The journal is authoritative. A crash after its final fsync may leave
|
| 545 | # derived rows absent/stale even when there are no pending trials.
|
| 546 | rows = [event["row"] for event in events if event.get("row") is not None]
|
| 547 | atomic_write(directory / "rows.jsonl", b"".join(canonical(row) + b"\n" for row in rows))
|
| 548 | return {**campaign_status(plan, events), "stopped_reason": stopped_reason}
|