Viewing files at commit fcd8a785ee1aCommit detailsView latest

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