Viewing files at commit b00752e49ee2Commit detailsView latest

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