Runners Log in

oak/benchmarks

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