Viewing files at commit 2735d7f5558eCommit detailsView latest

Browse files
scripts/oakbench/agent_campaign.py 584 lines · 30.4 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 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}