Viewing files at commit 3d84c25e26dbCommit detailsView latest

Browse files
scripts/oakbench/agent_campaign.py 696 lines · 36.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 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
    except CampaignError as exc:
475
        raise FixtureIntegrityError(str(exc)) from exc
476
    if any((path / name).exists() or (path / name).is_symlink() for name in (".git", ".oak")):
477
        raise FixtureIntegrityError(f"fixture content drift at {path}: unexpected repository metadata")
478
    try:
479
        return tree_manifest(path)
480
    except (OSError, AssertionError) as exc:
481
        raise FixtureIntegrityError(f"fixture content drift at {path}: {exc}") from exc
482
483
484
def fixture_permissions(path: Path, *, writable: bool):
485
    # Deterrence only: same-uid processes can chmod these paths again. Hash
486
    # validation, not permission bits, establishes the copied input identity.
487
    for item in [*path.rglob("*"), path]:
488
        mode = item.lstat().st_mode
489
        if stat.S_ISLNK(mode):
490
            raise FixtureIntegrityError("fixture content drift: unexpected symlink")
491
        if not (stat.S_ISREG(mode) or stat.S_ISDIR(mode)):
492
            raise FixtureIntegrityError("fixture content drift: unexpected file type")
493
        # Keep directories owner-writable so normal cleanup/rebuild works.
494
        updated = mode | 0o700 if stat.S_ISDIR(mode) else (mode | 0o200 if writable else mode & ~0o222)
495
        os.chmod(item, stat.S_IMODE(updated))
496
497
498
def verify_fixture_binding(fixture: Path, record_path: Path, record: dict):
499
    try:
500
        current_record = read_json(safe_path(record_path))
501
    except CampaignError as exc:
502
        raise FixtureIntegrityError(str(exc)) from exc
503
    if current_record != record or fixture_tree(fixture) != record.get("manifest"):
504
        raise FixtureIntegrityError(f"fixture integrity drift at {fixture}: cache or {record_path} changed")
505
506
507
def copied_fixture_validator(expected: dict):
508
    def validate(repo: Path):
509
        if fixture_tree(repo) != expected:
510
            raise FixtureIntegrityError("copied fixture identity drift before agent launch")
511
        fixture_permissions(repo, writable=True)
512
    return validate
513
514
515
def cached_fixture(directory: Path, plan: dict, workflow) -> Path:
516
    """Generate once; verify the complete cached input before each invocation.
517
518
    The campaign writer lock protects this cache. Each trial gets a staged,
519
    validated copy before admission. Host containment is an operator responsibility.
520
    """
521
    import workflow_ab
522
    fixture = safe_path(directory / "fixtures" / workflow.name)
523
    record_path = safe_path(directory / "fixture-integrity" / (workflow.name + ".json"))
524
    expected_identity = {"plan_sha256": plan["plan_sha256"], "fixture_version": workflow_ab.FIXTURE_VERSION}
525
    if fixture.exists() or record_path.exists():
526
        if not fixture.is_dir() or not record_path.is_file():
527
            raise CampaignError(f"fixture cache is incomplete for {workflow.name} at {fixture}; preserve evidence and create a new campaign")
528
        record = read_json(record_path)
529
        if not isinstance(record, dict) or any(record.get(key) != value for key, value in expected_identity.items()):
530
            raise CampaignError(f"fixture cache identity drift for {workflow.name} at {record_path}")
531
        if fixture_tree(fixture) != record.get("manifest"):
532
            raise FixtureIntegrityError(f"fixture content drift for {workflow.name} at {fixture}; refusing reuse")
533
        fixture_permissions(fixture, writable=False)
534
        return fixture
535
    generated = workflow_ab.prepare_fixture(directory, workflow)
536
    if generated != fixture:
537
        raise CampaignError("fixture generator returned an unexpected cache path")
538
    record_path.parent.mkdir(parents=True, exist_ok=True)
539
    atomic_write(record_path, canonical({**expected_identity, "manifest": fixture_tree(fixture)}) + b"\n")
540
    fixture_permissions(fixture, writable=False)
541
    return fixture
542
543
544
def verify_journal_fixture_binding(name: str, record: dict, plan: dict, events: list[dict]):
545
    expected = digest(record["manifest"])
546
    workflows = {trial["trial_id"]: trial["workflow"] for trial in plan["schedule"]}
547
    for event in events:
548
        if event.get("event") not in {"started", "finished"} or workflows.get(event.get("trial_id")) != name:
549
            continue
550
        # Recovery reserves an ambiguous start without asserting observed input.
551
        if event.get("event") == "started" and event.get("uncertain_torn_start") is True:
552
            continue
553
        if event.get("fixture_manifest_sha256") != expected:
554
            raise CampaignError("journal fixture binding missing or mismatched for " + name)
555
556
557
def require_measurement_lock(lock, plan: dict):
558
    if not lock.enabled:
559
        raise CampaignError("global measurement lock is mandatory for campaigns")
560
    pinned_lock = plan["provenance"].get("measurement_lock_path")
561
    if pinned_lock is not None and lock.path != pinned_lock:
562
        raise CampaignError("acquired measurement lock differs from frozen provenance")
563
564
565
def run_campaign(directory: Path, *, resume=False, enable_real=False, limit=None, acknowledge_torn=False) -> dict:
566
    import agent_workflow
567
    import workflow_ab
568
    from oakbench.runlock import measurement_lock
569
    directory = safe_path(directory)
570
    plan = load_plan(directory)
571
    with campaign_lock(directory):
572
        agents, subjects = preflight(plan, directory, enable_real)
573
        events = acknowledge_torn_journal(directory, plan) if resume and acknowledge_torn else load_events(directory, plan)
574
        if events and not resume:
575
            raise CampaignError("campaign already started; use resume (interrupted trials are not retried)")
576
        if limit is not None:
577
            _positive(limit, "limit", True)
578
        initial_state = campaign_status(plan, events)
579
        pending = set(initial_state["pending_trial_ids"])
580
        remaining = initial_state["remaining_episode_seconds"]
581
        fixtures = {}
582
        # Campaign-wide setup failures are preflight errors, never trial rows.
583
        trial_workflows = {trial["trial_id"]: trial["workflow"] for trial in plan["schedule"]}
584
        needed = {trial_workflows[event["trial_id"]] for event in events
585
                  if event.get("event") in {"started", "finished"} and not event.get("uncertain_torn_start")}
586
        if remaining >= plan["manifest"]["trial_timeout_seconds"]:
587
            needed.update(trial["workflow"] for trial in plan["schedule"] if trial["trial_id"] in pending)
588
        if not pending:
589
            needed.clear()
590
        if needed:
591
            with measurement_lock("agent-campaign-fixture-preflight") as lock:
592
                require_measurement_lock(lock, plan)
593
                for name in sorted(needed):
594
                    fixture = cached_fixture(directory, plan, workflow_ab.WORKFLOWS[name])
595
                    record_path = safe_path(directory / "fixture-integrity" / (name + ".json"))
596
                    record = read_json(record_path)
597
                    verify_fixture_binding(fixture, record_path, record)
598
                    verify_journal_fixture_binding(name, record, plan, events)
599
                    fixtures[name] = (fixture, record_path, record)
600
        completed_now = 0
601
        stopped_reason = None
602
        rows = [event["row"] for event in events if event.get("row") is not None]
603
        atomic_write(directory / "rows.jsonl", b"".join(canonical(row) + b"\n" for row in rows))
604
        for trial in plan["schedule"]:
605
            if trial["trial_id"] not in pending:
606
                continue
607
            timeout = plan["manifest"]["trial_timeout_seconds"]
608
            if remaining < timeout:
609
                stopped_reason = "insufficient_budget_for_frozen_trial_timeout"
610
                break
611
            if limit is not None and completed_now >= limit:
612
                stopped_reason = "invocation_trial_limit"
613
                break
614
            trial_root = safe_path(directory / "trials" / trial["trial_id"])
615
            if trial_root.exists():
616
                raise CampaignError("trial path collision; refusing overwrite: " + str(trial_root))
617
            with measurement_lock("agent-campaign:" + trial["trial_id"]) as lock, tempfile.TemporaryDirectory(prefix=".trial-input-", dir=directory) as staging:
618
                require_measurement_lock(lock, plan)
619
                fixture, fixture_record_path, fixture_record = fixtures[trial["workflow"]]
620
                # Rehash before every copy; a prior same-uid process may still
621
                # exist even after its agent CLI returned.
622
                verify_fixture_binding(fixture, fixture_record_path, fixture_record)
623
                workflow = workflow_ab.WORKFLOWS[trial["workflow"]]
624
                staged_repo = Path(staging) / "work" / workflow.name / trial["agent"] / trial["subject"] / f"run-{trial['repetition']}"
625
                try:
626
                    agent_workflow.copy_fixture(fixture, staged_repo)
627
                    copied_fixture_validator(fixture_record["manifest"])(staged_repo)
628
                except OSError as exc:
629
                    raise CampaignError(f"fixture copy failed for {workflow.name} from {fixture}: {exc}") from exc
630
                verify_fixture_binding(fixture, fixture_record_path, fixture_record)
631
                trial_root.parent.mkdir(parents=True, exist_ok=True)
632
                started = append_event(directory, plan, {"event": "started", "trial_id": trial["trial_id"],
633
                    "timeout_seconds": timeout, "ordinal": trial["ordinal"],
634
                    "fixture_manifest_sha256": digest(fixture_record["manifest"])})
635
                events.append(started)
636
                start = time.monotonic()
637
                print(f"[trial] {trial['ordinal'] + 1}/{len(plan['schedule'])} {trial['trial_id']} {trial['agent']}/{trial['subject']}", file=sys.stderr, flush=True)
638
                row = None
639
                try:
640
                    Path(staging).rename(trial_root)
641
                    row = agent_workflow.run_one(
642
                        agent=agents[trial["agent"]], subject=subjects[trial["subject"]], workflow=workflow,
643
                        fixture=fixture, run_index=trial["repetition"], run_root=trial_root / "work",
644
                        artifacts_root=trial_root / "artifacts", timeout_seconds=timeout,
645
                        track=plan["manifest"]["track"], agent_environment=plan["manifest"]["agent_environment"],
646
                        instruction_level=trial["instruction_level"], bench_id=plan["campaign_id"],
647
                        timestamp_utc=now(), source={}, host_meta={},
648
                        output_limit_bytes=plan["manifest"].get("output_limit_bytes", 8 * 1024 * 1024),
649
                        fixture_copy_validator=copied_fixture_validator(fixture_record["manifest"]),
650
                        fixture_prepared=True,
651
                    )
652
                    row["task_id"] = trial["trial_id"]
653
                    validate_trial_row(row, trial, plan)
654
                    terminal = {"event": "finished", "trial_id": trial["trial_id"], "outcome": row["outcome"], "row": row}
655
                except (Exception, SystemExit) as exc:
656
                    terminal = {"event": "finished", "trial_id": trial["trial_id"], "outcome": "error",
657
                                "error": type(exc).__name__ + ": " + str(exc)}
658
                    if isinstance(exc, FixtureIntegrityError):
659
                        terminal.update(measurement_invalidated=True, invalidation_reason=str(exc))
660
                    if row is not None:
661
                        try:
662
                            canonical(row)
663
                            terminal["rejected_row"] = row
664
                        except (ValueError, TypeError):
665
                            terminal["rejected_row_repr"] = repr(row)
666
                try:
667
                    verify_fixture_binding(fixture, fixture_record_path, fixture_record)
668
                except FixtureIntegrityError as exc:
669
                    original_row = terminal.pop("row", None)
670
                    if original_row is not None:
671
                        terminal["rejected_row"] = original_row
672
                    terminal.update(outcome="error", measurement_invalidated=True,
673
                                    invalidation_reason=str(exc), error=str(exc))
674
                if terminal.get("measurement_invalidated"):
675
                    stopped_reason = "fixture_integrity_drift"
676
                    print(f"[invalidated] {trial['trial_id']}: {terminal['invalidation_reason']}", file=sys.stderr, flush=True)
677
                terminal["fixture_manifest_sha256"] = digest(fixture_record["manifest"])
678
                terminal["wall_seconds"] = time.monotonic() - start
679
                remaining = max(0.0, remaining - terminal["wall_seconds"])
680
                terminal["artifact_sha256"] = artifact_hashes(trial_root)
681
                terminal["artifact_integrity_source"] = "sha256_of_regular_trial_artifacts_and_instructions;links_and_missing_files_not_attested"
682
                terminal["artifact_integrity_status"] = "observed_files_only" if terminal["artifact_sha256"] else "unavailable"
683
                events.append(append_event(directory, plan, terminal))
684
                if terminal.get("row") is not None:
685
                    with safe_path(directory / "rows.jsonl").open("ab") as stream:
686
                        stream.write(canonical(terminal["row"]) + b"\n")
687
                        stream.flush()
688
                        os.fsync(stream.fileno())
689
                completed_now += 1
690
                if stopped_reason == "fixture_integrity_drift":
691
                    break
692
        # The journal is authoritative. A crash after its final fsync may leave
693
        # derived rows absent/stale even when there are no pending trials.
694
        rows = [event["row"] for event in events if event.get("row") is not None]
695
        atomic_write(directory / "rows.jsonl", b"".join(canonical(row) + b"\n" for row in rows))
696
        return {**campaign_status(plan, events), "stopped_reason": stopped_reason}