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